From fa6d902875e1e1653d59bbb2b47d3c6d5f352cec Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:55:48 +0800 Subject: [PATCH 01/22] Add org-management-proto dependency to package.json and package-lock.json --- package-lock.json | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/package-lock.json b/package-lock.json index e256ed69f..39d2d3f3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@vality/fistful-proto": "^2.0.1-360c737.0", "@vality/machinegun-proto": "^1.0.1-273f2f3.0", "@vality/magista-proto": "^2.0.2-bd58cea.0", + "@vality/org-management-proto": "^0.1.1-36e49ef.0", "@vality/repairer-proto": "^2.0.2-07b2a51.0", "@vality/scrooge-proto": "^0.1.1-eec9e06.0", "@vality/thrift-ts": "^2.5.1-2b658f2.0", @@ -5753,6 +5754,12 @@ "resolved": "projects/ng-thrift", "link": true }, + "node_modules/@vality/org-management-proto": { + "version": "0.1.1-36e49ef.0", + "resolved": "https://registry.npmjs.org/@vality/org-management-proto/-/org-management-proto-0.1.1-36e49ef.0.tgz", + "integrity": "sha512-JLxILr3+zWgshcpsxbZdNyVrZqYOEsjhOHutO2oOvf1TjyCdlFnmrJF1gXMHGpZnGLxrPP4D0uzG5UT2ZI+UZA==", + "license": "Apache-2.0" + }, "node_modules/@vality/repairer-proto": { "version": "2.0.2-07b2a51.0", "resolved": "https://registry.npmjs.org/@vality/repairer-proto/-/repairer-proto-2.0.2-07b2a51.0.tgz", diff --git a/package.json b/package.json index ffd8409fc..6d07ac342 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@vality/fistful-proto": "^2.0.1-360c737.0", "@vality/machinegun-proto": "^1.0.1-273f2f3.0", "@vality/magista-proto": "^2.0.2-bd58cea.0", + "@vality/org-management-proto": "^0.1.1-36e49ef.0", "@vality/repairer-proto": "^2.0.2-07b2a51.0", "@vality/scrooge-proto": "^0.1.1-eec9e06.0", "@vality/thrift-ts": "^2.5.1-2b658f2.0", From 86fb7cc613efd39bb8d9901255154bd56f32c9a1 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:58:09 +0800 Subject: [PATCH 02/22] Add Organization Management service to the API --- src/api/services.ts | 12 ++++++++++++ src/services/app-auth-guard/services.ts | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/api/services.ts b/src/api/services.ts index 14bf21c42..080e0a6c3 100644 --- a/src/api/services.ts +++ b/src/api/services.ts @@ -7,6 +7,7 @@ import { metadata$ as fistfulMetadata$ } from '@vality/fistful-proto'; import { metadata$ as machinegunMetadata$ } from '@vality/machinegun-proto'; import { metadata$ as magistaMetadata$ } from '@vality/magista-proto'; import { ThriftAstMetadata, ThriftFormExtension, ThriftViewExtension } from '@vality/ng-thrift'; +import { metadata$ as orgManagementMetadata$ } from '@vality/org-management-proto'; import { metadata$ as repairerMetadata$ } from '@vality/repairer-proto'; import { metadata$ as scroogeMetadata$ } from '@vality/scrooge-proto'; @@ -186,6 +187,17 @@ export const services = [ service: 'Management', public: 'DestinationManagement', }, + + // Organization Management + { + name: Service.OrganizationManagement, + loader: () => + import('@vality/org-management-proto/admin_management').then((m) => m.AdminManagement), + metadata$: orgManagementMetadata$, + namespace: 'admin_management', + service: 'AdminManagement', + public: 'OrganizationManagement', + }, ] as const; export const { services: injectableServices, provideThriftServices } = diff --git a/src/services/app-auth-guard/services.ts b/src/services/app-auth-guard/services.ts index 035fbec20..40725cedb 100644 --- a/src/services/app-auth-guard/services.ts +++ b/src/services/app-auth-guard/services.ts @@ -22,4 +22,6 @@ export enum Service { SourceManagement = 'SourceManagement', WalletsWebhookManager = 'WalletsWebhookManager', DestinationManagement = 'DestinationManagement', + + OrganizationManagement = 'OrgManager', } From 13b99db96396de3373d65fb5d4079a728f25d919 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:24:51 +0800 Subject: [PATCH 03/22] feat: implement organization management module and add Thrift service integration --- AGENTS.md | 19 +++ .../table/table-resource.component.ts | 3 +- src/api/services.ts | 1 + src/app/app.component.ts | 16 +++ src/app/app.routes.ts | 4 + .../invitations/invitations.component.html | 10 ++ .../invitations/invitations.component.ts | 115 ++++++++++++++++++ .../components/members/members.component.html | 10 ++ .../components/members/members.component.ts | 87 +++++++++++++ .../organization-field.component.html | 9 ++ .../organization-field.component.ts | 61 ++++++++++ .../organizations-list.component.html | 3 + .../organizations-list.component.ts | 93 ++++++++++++++ src/app/organizations/index.ts | 3 + .../organizations.component.html | 1 + .../organizations/organizations.component.ts | 10 ++ src/app/organizations/organizations.routes.ts | 32 +++++ src/app/organizations/routing-config.ts | 5 + 18 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 src/app/organizations/components/invitations/invitations.component.html create mode 100644 src/app/organizations/components/invitations/invitations.component.ts create mode 100644 src/app/organizations/components/members/members.component.html create mode 100644 src/app/organizations/components/members/members.component.ts create mode 100644 src/app/organizations/components/organization-field/organization-field.component.html create mode 100644 src/app/organizations/components/organization-field/organization-field.component.ts create mode 100644 src/app/organizations/components/organizations-list/organizations-list.component.html create mode 100644 src/app/organizations/components/organizations-list/organizations-list.component.ts create mode 100644 src/app/organizations/index.ts create mode 100644 src/app/organizations/organizations.component.html create mode 100644 src/app/organizations/organizations.component.ts create mode 100644 src/app/organizations/organizations.routes.ts create mode 100644 src/app/organizations/routing-config.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..013655b2f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +## 1. Thrift API + +- Services are registered in `src/api/services.ts` and exported via `injectableServices`. +- Inject `Thrift...Service` directly via `inject(Thrift...Service)`. Do not create intermediate wrapper services. + +## 2. UI & Shared Libraries + +- Use `@vality/matez` (`projects/matez/`) for shared UI components (`TableModule`, `PageLayoutModule`, `SelectFieldModule`, `DialogModule`, etc.). + +## 3. Signal Forms (`@angular/forms/signals`) + +- **Custom Controls**: Implement `FormValueControl` with `value = model()` and `control = form(this.value)`. Do not use `FormControlSuperclass` or `ControlValueAccessor`. + +## 4. Observable Resource (`@vality/matez`) + +- Use `observableResource` for async data fetching instead of manual Observable pipelines with `reload$` subjects, `inProgress$`, and `shareReplay`. +- Access data and loading state via signals: `resource.value()`, `resource.isLoading()`, and reload using `resource.reload()`. +- For tables, use ``. +- For select fields and other controls, bind signals directly: `[options]="resource.value()"` and `[progress]="resource.isLoading()"`. diff --git a/projects/matez/src/lib/components/table/table-resource.component.ts b/projects/matez/src/lib/components/table/table-resource.component.ts index 976493f17..41f1b3f8e 100644 --- a/projects/matez/src/lib/components/table/table-resource.component.ts +++ b/projects/matez/src/lib/components/table/table-resource.component.ts @@ -59,6 +59,7 @@ export class TableResourceComponent { update(options: UpdateOptions) { const res = this.resource(); - if ('setOptions' in res) res.setOptions(options); + if (res && 'setOptions' in res) res.setOptions(options); + else if (res && 'reload' in res) res.reload(); } } diff --git a/src/api/services.ts b/src/api/services.ts index 080e0a6c3..c7f0c0a2c 100644 --- a/src/api/services.ts +++ b/src/api/services.ts @@ -222,4 +222,5 @@ export const { Accounter: ThriftAccountManagementService, InvoiceTemplating: ThriftInvoiceTemplatingService, DestinationManagement: ThriftDestinationManagementService, + OrgManager: ThriftOrganizationManagementService, } = injectableServices; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5f74b3b7b..33508b895 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -41,6 +41,7 @@ import { LOGGING } from '~/utils'; import { APP_ROUTES } from './app-routes'; import { ROUTING_CONFIG as DEPOSITS_ROUTING_CONFIG } from './deposits/routing-config'; import { ROUTING_CONFIG as MACHINES_ROUTING_CONFIG } from './machines/routing-config'; +import { ROUTING_CONFIG as ORGANIZATIONS_ROUTING_CONFIG } from './organizations/routing-config'; import { ROUTING_CONFIG as PAYMENTS_ROUTING_CONFIG } from './payments/routing-config'; import { SHOPS_ROUTING_CONFIG } from './shops'; import { ROUTING_CONFIG as SOURCES_ROUTING_CONFIG } from './sources/routing-config'; @@ -150,6 +151,21 @@ const createNavLinks = (): Link[] => [ url: '/wallets', isHidden: isHidden(WALLETS_ROUTING_CONFIG.services), }, + { + label: 'Organizations', + url: '/organizations', + isHidden: isHidden(ORGANIZATIONS_ROUTING_CONFIG.services), + children: [ + { + label: 'Invitations', + url: '/organizations/invitations', + }, + { + label: 'Members', + url: '/organizations/members', + }, + ], + }, ], }, { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 20620f720..07b110520 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -65,6 +65,10 @@ export const routes: Routes = [ path: 'studio', loadComponent: () => import('./studio').then((m) => m.StudioComponent), }, + { + path: 'organizations', + loadChildren: () => import('./organizations').then((m) => m.ORGANIZATIONS_ROUTES), + }, { path: '404', loadChildren: () => import('./not-found').then((m) => m.NotFoundModule), diff --git a/src/app/organizations/components/invitations/invitations.component.html b/src/app/organizations/components/invitations/invitations.component.html new file mode 100644 index 000000000..585ccf6cd --- /dev/null +++ b/src/app/organizations/components/invitations/invitations.component.html @@ -0,0 +1,10 @@ + +
+ +
+ +
diff --git a/src/app/organizations/components/invitations/invitations.component.ts b/src/app/organizations/components/invitations/invitations.component.ts new file mode 100644 index 000000000..4b986b359 --- /dev/null +++ b/src/app/organizations/components/invitations/invitations.component.ts @@ -0,0 +1,115 @@ +import { of } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { FormField, form, required } from '@angular/forms/signals'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { + Column, + NotifyLogService, + TableResourceComponent, + observableResource, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; +import { PageLayoutModule } from '~/components/page-layout'; + +import { OrganizationFieldComponent } from '../organization-field/organization-field.component'; + +@Component({ + selector: 'cc-invitations', + templateUrl: './invitations.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PageLayoutModule, TableResourceComponent, FormField, OrganizationFieldComponent], +}) +export class InvitationsComponent implements OnInit { + private thriftOrgManagementService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + orgId = signal(''); + control = form(this.orgId, (schemaPath) => { + required(schemaPath); + }); + + private orgId$ = toObservable(this.orgId).pipe( + tap((orgId) => { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { orgId: orgId || null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }), + ); + + invitations = observableResource({ + params: this.orgId$, + loader: (orgId) => + !orgId + ? of([]) + : this.thriftOrgManagementService.ListInvitations(orgId, {}).pipe( + catchError((err) => { + this.log.error(err); + return of([]); + }), + ), + }); + + columns: Column[] = [ + { + field: 'id', + cell: (inv) => ({ value: inv.id }), + }, + { + field: 'email', + cell: (inv) => ({ value: inv.email }), + }, + { + field: 'status', + cell: (inv) => { + const statusMap: Record< + number, + { label: string; color: 'success' | 'warn' | 'neutral' } + > = { + 1: { label: 'Pending', color: 'neutral' }, + 2: { label: 'Accepted', color: 'success' }, + 3: { label: 'Expired', color: 'neutral' }, + 4: { label: 'Revoked', color: 'warn' }, + }; + const mapped = statusMap[inv.status]; + return { + value: mapped?.label ?? String(inv.status), + color: mapped?.color, + }; + }, + }, + { + field: 'roles', + cell: (inv) => ({ + value: (inv.roles || []).map((r) => r.role_id).join(', ') || '—', + }), + }, + { + field: 'created_at', + header: 'Created at', + cell: (inv) => ({ value: inv.created_at }), + }, + { + field: 'expires_at', + header: 'Expires at', + cell: (inv) => ({ value: inv.expires_at }), + }, + ]; + + ngOnInit(): void { + const initialOrgId = this.route.snapshot.queryParams['orgId']; + if (initialOrgId) { + this.orgId.set(initialOrgId); + } + } +} diff --git a/src/app/organizations/components/members/members.component.html b/src/app/organizations/components/members/members.component.html new file mode 100644 index 000000000..0d3bc658e --- /dev/null +++ b/src/app/organizations/components/members/members.component.html @@ -0,0 +1,10 @@ + +
+ +
+ +
diff --git a/src/app/organizations/components/members/members.component.ts b/src/app/organizations/components/members/members.component.ts new file mode 100644 index 000000000..9976b9913 --- /dev/null +++ b/src/app/organizations/components/members/members.component.ts @@ -0,0 +1,87 @@ +import { of } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { FormField, form, required } from '@angular/forms/signals'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { + Column, + NotifyLogService, + TableResourceComponent, + observableResource, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; +import { PageLayoutModule } from '~/components/page-layout'; + +import { OrganizationFieldComponent } from '../organization-field/organization-field.component'; + +@Component({ + selector: 'cc-members', + templateUrl: './members.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PageLayoutModule, TableResourceComponent, FormField, OrganizationFieldComponent], +}) +export class MembersComponent implements OnInit { + private thriftOrgManagementService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + private route = inject(ActivatedRoute); + private router = inject(Router); + + orgId = signal(''); + control = form(this.orgId, (schemaPath) => { + required(schemaPath); + }); + + private orgId$ = toObservable(this.orgId).pipe( + tap((orgId) => { + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { orgId: orgId || null }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }), + ); + + members = observableResource({ + params: this.orgId$, + loader: (orgId) => + !orgId + ? of([]) + : this.thriftOrgManagementService.ListMembers(orgId).pipe( + catchError((err) => { + this.log.error(err); + return of([]); + }), + ), + }); + + columns: Column[] = [ + { + field: 'id', + header: 'User ID', + cell: (m) => ({ value: m.id }), + }, + { + field: 'email', + cell: (m) => ({ value: m.email || '—' }), + }, + { + field: 'roles', + cell: (m) => ({ + value: (m.roles || []).map((r) => r.role_id).join(', ') || '—', + }), + }, + ]; + + ngOnInit(): void { + const initialOrgId = this.route.snapshot.queryParams['orgId']; + if (initialOrgId) { + this.orgId.set(initialOrgId); + } + } +} diff --git a/src/app/organizations/components/organization-field/organization-field.component.html b/src/app/organizations/components/organization-field/organization-field.component.html new file mode 100644 index 000000000..b8ceed94c --- /dev/null +++ b/src/app/organizations/components/organization-field/organization-field.component.html @@ -0,0 +1,9 @@ + diff --git a/src/app/organizations/components/organization-field/organization-field.component.ts b/src/app/organizations/components/organization-field/organization-field.component.ts new file mode 100644 index 000000000..ad28aa3e3 --- /dev/null +++ b/src/app/organizations/components/organization-field/organization-field.component.ts @@ -0,0 +1,61 @@ +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + inject, + input, + model, +} from '@angular/core'; +import { FormField, FormValueControl, form, required } from '@angular/forms/signals'; + +import { + NotifyLogService, + Option, + SelectFieldComponent, + SelectFieldModule, + observableResource, +} from '@vality/matez'; + +import { ThriftOrganizationManagementService } from '~/api/services'; + +@Component({ + selector: 'cc-organization-field', + templateUrl: './organization-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SelectFieldModule, FormField], +}) +export class OrganizationFieldComponent implements FormValueControl { + private thriftOrgManagementService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + + label = input('Organization'); + required = input(false, { transform: booleanAttribute }); + size = input(); + appearance = input(); + hint = input(); + + value = model(''); + disabled = model(false); + control = form(this.value, (schemaPath) => { + required(schemaPath, { when: () => this.required() }); + }); + + options = observableResource({ + loader: () => + this.thriftOrgManagementService.ListOrganizations({ limit: 100 }).pipe( + catchError((err) => { + this.log.error(err); + return of({ organizations: [] }); + }), + ), + map: (res): Option[] => + (res.organizations || []).map((org) => ({ + value: org.id, + label: org.name || `#${org.id}`, + description: org.id, + })), + }); +} diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.html b/src/app/organizations/components/organizations-list/organizations-list.component.html new file mode 100644 index 000000000..e2f7ce740 --- /dev/null +++ b/src/app/organizations/components/organizations-list/organizations-list.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.ts b/src/app/organizations/components/organizations-list/organizations-list.component.ts new file mode 100644 index 000000000..65d101ee7 --- /dev/null +++ b/src/app/organizations/components/organizations-list/organizations-list.component.ts @@ -0,0 +1,93 @@ +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +import { + Column, + NotifyLogService, + TableResourceComponent, + createMenuColumn, + observableResource, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; +import { PageLayoutModule } from '~/components/page-layout'; +import { createPartyColumn } from '~/utils'; + +@Component({ + selector: 'cc-organizations-list', + templateUrl: './organizations-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PageLayoutModule, TableResourceComponent], +}) +export class OrganizationsListComponent { + private organizationsService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + private router = inject(Router); + + organizations = observableResource({ + loader: () => + this.organizationsService.ListOrganizations({ limit: 100 }).pipe( + catchError((err) => { + this.log.error(err); + return of({ organizations: [] }); + }), + ), + map: (res) => res.organizations || [], + }); + + columns: Column[] = [ + { + field: 'id', + cell: (org) => ({ value: org.id }), + }, + { + field: 'name', + cell: (org) => ({ value: org.name }), + }, + createPartyColumn((org) => ({ id: org.party_id })), + { + field: 'owner_id', + header: 'Owner', + cell: (org) => ({ value: org.owner_id }), + }, + { + field: 'status', + cell: (org) => ({ + value: + org.status === 1 + ? 'Active' + : org.status === 2 + ? 'Deactivated' + : String(org.status), + color: org.status === 1 ? 'success' : 'warn', + }), + }, + { + field: 'created_at', + header: 'Created at', + cell: (org) => ({ value: org.created_at }), + }, + createMenuColumn((org) => ({ + items: [ + { + label: 'Members', + click: () => + this.router.navigate(['/organizations/members'], { + queryParams: { orgId: org.id }, + }), + }, + { + label: 'Invitations', + click: () => + this.router.navigate(['/organizations/invitations'], { + queryParams: { orgId: org.id }, + }), + }, + ], + })), + ]; +} diff --git a/src/app/organizations/index.ts b/src/app/organizations/index.ts new file mode 100644 index 000000000..7a0c3670f --- /dev/null +++ b/src/app/organizations/index.ts @@ -0,0 +1,3 @@ +export * from './organizations.component'; +export * from './routing-config'; +export * from './organizations.routes'; diff --git a/src/app/organizations/organizations.component.html b/src/app/organizations/organizations.component.html new file mode 100644 index 000000000..0680b43f9 --- /dev/null +++ b/src/app/organizations/organizations.component.html @@ -0,0 +1 @@ + diff --git a/src/app/organizations/organizations.component.ts b/src/app/organizations/organizations.component.ts new file mode 100644 index 000000000..a049b45e4 --- /dev/null +++ b/src/app/organizations/organizations.component.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'cc-organizations', + imports: [RouterOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './organizations.component.html', +}) +export class OrganizationsComponent {} diff --git a/src/app/organizations/organizations.routes.ts b/src/app/organizations/organizations.routes.ts new file mode 100644 index 000000000..fba8d43da --- /dev/null +++ b/src/app/organizations/organizations.routes.ts @@ -0,0 +1,32 @@ +import { Routes } from '@angular/router'; + +import { canActivateAuthRole } from '~/services'; + +import { InvitationsComponent } from './components/invitations/invitations.component'; +import { MembersComponent } from './components/members/members.component'; +import { OrganizationsListComponent } from './components/organizations-list/organizations-list.component'; +import { OrganizationsComponent } from './organizations.component'; +import { ROUTING_CONFIG } from './routing-config'; + +export const ORGANIZATIONS_ROUTES: Routes = [ + { + path: '', + component: OrganizationsComponent, + canActivate: [canActivateAuthRole], + data: ROUTING_CONFIG, + children: [ + { + path: '', + component: OrganizationsListComponent, + }, + { + path: 'invitations', + component: InvitationsComponent, + }, + { + path: 'members', + component: MembersComponent, + }, + ], + }, +]; diff --git a/src/app/organizations/routing-config.ts b/src/app/organizations/routing-config.ts new file mode 100644 index 000000000..943ad2d4f --- /dev/null +++ b/src/app/organizations/routing-config.ts @@ -0,0 +1,5 @@ +import { RoutingConfig, Service } from '~/services'; + +export const ROUTING_CONFIG: RoutingConfig = { + services: [Service.OrganizationManagement], +}; From ba09bdd81ec1aaee1fc4ba280c589f8ff7896b73 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:27:43 +0800 Subject: [PATCH 04/22] chore: update org-management-proto version and bump postcss dependency --- package-lock.json | 50 ++++++++++++++++++++++++++++++++++++++--------- package.json | 5 ++--- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39d2d3f3a..f27476ace 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "@vality/fistful-proto": "^2.0.1-360c737.0", "@vality/machinegun-proto": "^1.0.1-273f2f3.0", "@vality/magista-proto": "^2.0.2-bd58cea.0", - "@vality/org-management-proto": "^0.1.1-36e49ef.0", + "@vality/org-management-proto": "^0.1.1-23aa5d8.0", "@vality/repairer-proto": "^2.0.2-07b2a51.0", "@vality/scrooge-proto": "^0.1.1-eec9e06.0", "@vality/thrift-ts": "^2.5.1-2b658f2.0", @@ -37,7 +37,6 @@ "lodash-es": "^4.18.1", "monaco-editor": "^0.55.1", "ngx-monaco-editor-v2": "^22.0.4", - "postcss": "^8.5.15", "rxjs": "~7.8.2", "short-uuid": "^6.0.3", "tailwindcss": "^4.2.2", @@ -67,7 +66,7 @@ "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "^29.1.1", "ng-packagr": "^22.0.0", - "postcss": "^8.5.15", + "postcss": "^8.5.28", "prettier": "^3.8.4", "prettier-plugin-organize-attributes": "^1.0.0", "typescript": "~6.0.3", @@ -5011,6 +5010,34 @@ "tailwindcss": "4.3.1" } }, + "node_modules/@tailwindcss/postcss/node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "dev": true, @@ -5755,9 +5782,9 @@ "link": true }, "node_modules/@vality/org-management-proto": { - "version": "0.1.1-36e49ef.0", - "resolved": "https://registry.npmjs.org/@vality/org-management-proto/-/org-management-proto-0.1.1-36e49ef.0.tgz", - "integrity": "sha512-JLxILr3+zWgshcpsxbZdNyVrZqYOEsjhOHutO2oOvf1TjyCdlFnmrJF1gXMHGpZnGLxrPP4D0uzG5UT2ZI+UZA==", + "version": "0.1.1-23aa5d8.0", + "resolved": "https://registry.npmjs.org/@vality/org-management-proto/-/org-management-proto-0.1.1-23aa5d8.0.tgz", + "integrity": "sha512-lfV7zRpubYBcK481QdBpVzBVu6DuAyPneCqWNBG/vXqVFuPMF9Oq0w7UBUd9Cj7djgqn7HheG3ZRyt85y0RJEw==", "license": "Apache-2.0" }, "node_modules/@vality/repairer-proto": { @@ -10356,7 +10383,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -11183,7 +11212,10 @@ } }, "node_modules/postcss": { - "version": "8.5.15", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, "funding": [ { "type": "opencollective", @@ -11200,7 +11232,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/package.json b/package.json index 6d07ac342..27fef7a18 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@vality/fistful-proto": "^2.0.1-360c737.0", "@vality/machinegun-proto": "^1.0.1-273f2f3.0", "@vality/magista-proto": "^2.0.2-bd58cea.0", - "@vality/org-management-proto": "^0.1.1-36e49ef.0", + "@vality/org-management-proto": "^0.1.1-23aa5d8.0", "@vality/repairer-proto": "^2.0.2-07b2a51.0", "@vality/scrooge-proto": "^0.1.1-eec9e06.0", "@vality/thrift-ts": "^2.5.1-2b658f2.0", @@ -52,7 +52,6 @@ "lodash-es": "^4.18.1", "monaco-editor": "^0.55.1", "ngx-monaco-editor-v2": "^22.0.4", - "postcss": "^8.5.15", "rxjs": "~7.8.2", "short-uuid": "^6.0.3", "tailwindcss": "^4.2.2", @@ -82,7 +81,7 @@ "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "^29.1.1", "ng-packagr": "^22.0.0", - "postcss": "^8.5.15", + "postcss": "^8.5.28", "prettier": "^3.8.4", "prettier-plugin-organize-attributes": "^1.0.0", "typescript": "~6.0.3", From b4cc60eff464fc2aa613302acb9ac3c1d132d609 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:38:26 +0800 Subject: [PATCH 05/22] refactor: migrate invitations and members lists to pagedObservableResource for pagination support --- .../invitations/invitations.component.ts | 39 ++++++++++++------- .../components/members/members.component.ts | 31 +++++++++------ 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/app/organizations/components/invitations/invitations.component.ts b/src/app/organizations/components/invitations/invitations.component.ts index 4b986b359..bc96344a3 100644 --- a/src/app/organizations/components/invitations/invitations.component.ts +++ b/src/app/organizations/components/invitations/invitations.component.ts @@ -1,5 +1,5 @@ import { of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError, map, tap } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; @@ -10,9 +10,9 @@ import { Column, NotifyLogService, TableResourceComponent, - observableResource, + pagedObservableResource, } from '@vality/matez'; -import { domain } from '@vality/org-management-proto/admin_management'; +import { ListInvitationsRequest, domain } from '@vality/org-management-proto/admin_management'; import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; @@ -47,17 +47,30 @@ export class InvitationsComponent implements OnInit { }), ); - invitations = observableResource({ - params: this.orgId$, - loader: (orgId) => + invitations = pagedObservableResource< + domain.Invitation, + { orgId: string } & Omit + >({ + params: this.orgId$.pipe(map((orgId) => ({ orgId }))), + loader: ({ orgId, ...params }, options) => !orgId - ? of([]) - : this.thriftOrgManagementService.ListInvitations(orgId, {}).pipe( - catchError((err) => { - this.log.error(err); - return of([]); - }), - ), + ? of({ result: [] }) + : this.thriftOrgManagementService + .ListInvitations(orgId, { + limit: options.size, + continuation_token: options.continuationToken, + ...params, + }) + .pipe( + map((res) => ({ + result: res.invitations, + continuationToken: res.continuation_token, + })), + catchError((err) => { + this.log.error(err); + return of({ result: [] }); + }), + ), }); columns: Column[] = [ diff --git a/src/app/organizations/components/members/members.component.ts b/src/app/organizations/components/members/members.component.ts index 9976b9913..108be5517 100644 --- a/src/app/organizations/components/members/members.component.ts +++ b/src/app/organizations/components/members/members.component.ts @@ -1,5 +1,5 @@ import { of } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { catchError, map, tap } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; @@ -10,7 +10,7 @@ import { Column, NotifyLogService, TableResourceComponent, - observableResource, + pagedObservableResource, } from '@vality/matez'; import { domain } from '@vality/org-management-proto/admin_management'; @@ -47,17 +47,26 @@ export class MembersComponent implements OnInit { }), ); - members = observableResource({ + members = pagedObservableResource({ params: this.orgId$, - loader: (orgId) => + loader: (orgId, options) => !orgId - ? of([]) - : this.thriftOrgManagementService.ListMembers(orgId).pipe( - catchError((err) => { - this.log.error(err); - return of([]); - }), - ), + ? of({ result: [] }) + : this.thriftOrgManagementService + .ListMembers(orgId, { + limit: options.size, + continuation_token: options.continuationToken, + }) + .pipe( + map((res) => ({ + result: res.members, + continuationToken: res.continuation_token, + })), + catchError((err) => { + this.log.error(err); + return of({ result: [] }); + }), + ), }); columns: Column[] = [ From e294cad8573a257421be4d9df9c16a9dd0d2b56e Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:07:56 +0800 Subject: [PATCH 06/22] feat: implement pagination and filtering for organizations list and update documentation --- AGENTS.md | 3 +- .../organizations-list.component.html | 17 +++ .../organizations-list.component.ts | 104 +++++++++++++++--- 3 files changed, 106 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 013655b2f..ff069f006 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ ## 4. Observable Resource (`@vality/matez`) -- Use `observableResource` for async data fetching instead of manual Observable pipelines with `reload$` subjects, `inProgress$`, and `shareReplay`. +- Use `observableResource` or `pagedObservableResource` for async data fetching instead of manual Observable pipelines with `reload$` subjects, `inProgress$`, and `shareReplay`. +- Use `pagedObservableResource` for paginated / list data (with `continuationToken` and `size`), mapping result to `{ result, continuationToken }`. - Access data and loading state via signals: `resource.value()`, `resource.isLoading()`, and reload using `resource.reload()`. - For tables, use ``. - For select fields and other controls, bind signals directly: `[options]="resource.value()"` and `[progress]="resource.isLoading()"`. diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.html b/src/app/organizations/components/organizations-list/organizations-list.component.html index e2f7ce740..22fb0994d 100644 --- a/src/app/organizations/components/organizations-list/organizations-list.component.html +++ b/src/app/organizations/components/organizations-list/organizations-list.component.html @@ -1,3 +1,20 @@ + + + + + + + + + diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.ts b/src/app/organizations/components/organizations-list/organizations-list.component.ts index 65d101ee7..96aa3c85a 100644 --- a/src/app/organizations/components/organizations-list/organizations-list.component.ts +++ b/src/app/organizations/components/organizations-list/organizations-list.component.ts @@ -1,15 +1,25 @@ +import isEqual from 'lodash-es/isEqual'; import { of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { FormField, form } from '@angular/forms/signals'; import { Router } from '@angular/router'; import { Column, + FiltersModule, + InputFieldModule, NotifyLogService, + Option, + QueryParamsService, + SelectFieldModule, TableResourceComponent, + clean, createMenuColumn, - observableResource, + debounceTimeWithFirst, + pagedObservableResource, } from '@vality/matez'; import { domain } from '@vality/org-management-proto/admin_management'; @@ -17,26 +27,76 @@ import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; import { createPartyColumn } from '~/utils'; +export interface OrganizationsFilters { + status: domain.OrganizationStatus | null; + owner_id: domain.UserID; +} + +const DEFAULT_FILTERS: OrganizationsFilters = { + status: null, + owner_id: '', +}; + @Component({ selector: 'cc-organizations-list', templateUrl: './organizations-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PageLayoutModule, TableResourceComponent], + imports: [ + PageLayoutModule, + TableResourceComponent, + FiltersModule, + InputFieldModule, + SelectFieldModule, + FormField, + ], }) export class OrganizationsListComponent { private organizationsService = inject(ThriftOrganizationManagementService); private log = inject(NotifyLogService); private router = inject(Router); + private qp = inject>>(QueryParamsService); - organizations = observableResource({ - loader: () => - this.organizationsService.ListOrganizations({ limit: 100 }).pipe( - catchError((err) => { - this.log.error(err); - return of({ organizations: [] }); - }), - ), - map: (res) => res.organizations || [], + statusOptions: Option[] = [ + { label: 'Active', value: domain.OrganizationStatus.active }, + { label: 'Deactivated', value: domain.OrganizationStatus.deactivated }, + ]; + + filters = signal({ + status: this.qp.params.status ?? null, + owner_id: this.qp.params.owner_id || '', + }); + filtersControl = form(this.filters); + + active = computed( + () => Number(Boolean(this.filters().status)) + Number(Boolean(this.filters().owner_id)), + ); + + private filters$ = toObservable(this.filters).pipe( + debounceTimeWithFirst(300), + distinctUntilChanged(isEqual), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + organizations = pagedObservableResource({ + params: this.filters$, + loader: (filters, options) => + this.organizationsService + .ListOrganizations({ + limit: options.size, + continuation_token: options.continuationToken, + status: filters?.status ?? undefined, + owner_id: filters?.owner_id || undefined, + }) + .pipe( + map((res) => ({ + result: res.organizations, + continuationToken: res.continuation_token, + })), + catchError((err) => { + this.log.error(err); + return of({ result: [] }); + }), + ), }); columns: Column[] = [ @@ -58,18 +118,18 @@ export class OrganizationsListComponent { field: 'status', cell: (org) => ({ value: - org.status === 1 + org.status === domain.OrganizationStatus.active ? 'Active' - : org.status === 2 + : org.status === domain.OrganizationStatus.deactivated ? 'Deactivated' : String(org.status), - color: org.status === 1 ? 'success' : 'warn', + color: org.status === domain.OrganizationStatus.active ? 'success' : 'warn', }), }, { field: 'created_at', header: 'Created at', - cell: (org) => ({ value: org.created_at }), + cell: (org) => ({ value: org.created_at, type: 'datetime' }), }, createMenuColumn((org) => ({ items: [ @@ -90,4 +150,14 @@ export class OrganizationsListComponent { ], })), ]; + + constructor() { + this.filters$.pipe(takeUntilDestroyed()).subscribe((filters) => { + void this.qp.set(clean(filters)); + }); + } + + resetFilters(): void { + this.filters.set(DEFAULT_FILTERS); + } } From e3d4ff174ca0d49ddbbffd8cdbfa6a662db99592 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:49:14 +0800 Subject: [PATCH 07/22] refactor: migrate members and invitations components from organizations to parties module --- .../lib/components/value/value.component.html | 2 +- .../paged-observable-resource.ts | 2 +- src/app/app.component.ts | 18 +++---- .../invitations/invitations.component.html | 10 ---- .../components/members/members.component.html | 10 ---- .../organizations-list.component.ts | 15 +++--- src/app/organizations/index.ts | 2 +- .../organizations.component.html | 1 - .../organizations/organizations.component.ts | 10 ---- src/app/organizations/organizations.routes.ts | 19 +------ src/app/parties/parties.component.ts | 12 +++++ src/app/parties/party/invitations/index.ts | 2 + .../invitations/invitations.component.html | 3 ++ .../invitations/invitations.component.ts | 53 +++++-------------- .../party/invitations/routing-config.ts | 5 ++ src/app/parties/party/members/index.ts | 2 + .../party/members/members.component.html | 3 ++ .../party}/members/members.component.ts | 47 ++++------------ .../parties/party/members/routing-config.ts | 5 ++ src/app/parties/party/party-routing.module.ts | 15 ++++++ src/utils/table/create-party-column.ts | 5 +- 21 files changed, 92 insertions(+), 149 deletions(-) delete mode 100644 src/app/organizations/components/invitations/invitations.component.html delete mode 100644 src/app/organizations/components/members/members.component.html delete mode 100644 src/app/organizations/organizations.component.html delete mode 100644 src/app/organizations/organizations.component.ts create mode 100644 src/app/parties/party/invitations/index.ts create mode 100644 src/app/parties/party/invitations/invitations.component.html rename src/app/{organizations/components => parties/party}/invitations/invitations.component.ts (62%) create mode 100644 src/app/parties/party/invitations/routing-config.ts create mode 100644 src/app/parties/party/members/index.ts create mode 100644 src/app/parties/party/members/members.component.html rename src/app/{organizations/components => parties/party}/members/members.component.ts (56%) create mode 100644 src/app/parties/party/members/routing-config.ts diff --git a/projects/matez/src/lib/components/value/value.component.html b/projects/matez/src/lib/components/value/value.component.html index 6c95c4303..c9c709e0f 100644 --- a/projects/matez/src/lib/components/value/value.component.html +++ b/projects/matez/src/lib/components/value/value.component.html @@ -66,7 +66,7 @@ vHighlight > } @else if (v.error) { - error + error } @else if (emptySymbol) { {{ emptySymbol diff --git a/projects/matez/src/lib/utils/observable-resource/paged-observable-resource.ts b/projects/matez/src/lib/utils/observable-resource/paged-observable-resource.ts index 7e357aaa6..24c0e8474 100644 --- a/projects/matez/src/lib/utils/observable-resource/paged-observable-resource.ts +++ b/projects/matez/src/lib/utils/observable-resource/paged-observable-resource.ts @@ -69,6 +69,7 @@ export class PagedObservableResource extends ObservableRe size, ...options }: PagedObservableResourceOptions) { + this.pagedOptions = { size: size || 20 }; // TODO: fix types super.init({ params: EMPTY, @@ -89,7 +90,6 @@ export class PagedObservableResource extends ObservableRe }, map: (value) => value.result, } as never); - this.pagedOptions = { size: size || 20 }; this.hasMore$ = this.accValue$.pipe(map((value) => !!value.continuationToken)); this.hasMore = toSignal(this.hasMore$, { initialValue: false }); this.result$ = this.value$; diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 33508b895..305e9d6ae 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -119,6 +119,14 @@ const createNavLinks = (): Link[] => [ label: 'Wallets', url: `${partyPath}/wallets`, }, + { + label: 'Members', + url: `${partyPath}/members`, + }, + { + label: 'Invitations', + url: `${partyPath}/invitations`, + }, { label: 'Shop webhooks', url: `${partyPath}/shop-webhooks`, @@ -155,16 +163,6 @@ const createNavLinks = (): Link[] => [ label: 'Organizations', url: '/organizations', isHidden: isHidden(ORGANIZATIONS_ROUTING_CONFIG.services), - children: [ - { - label: 'Invitations', - url: '/organizations/invitations', - }, - { - label: 'Members', - url: '/organizations/members', - }, - ], }, ], }, diff --git a/src/app/organizations/components/invitations/invitations.component.html b/src/app/organizations/components/invitations/invitations.component.html deleted file mode 100644 index 585ccf6cd..000000000 --- a/src/app/organizations/components/invitations/invitations.component.html +++ /dev/null @@ -1,10 +0,0 @@ - -
- -
- -
diff --git a/src/app/organizations/components/members/members.component.html b/src/app/organizations/components/members/members.component.html deleted file mode 100644 index 0d3bc658e..000000000 --- a/src/app/organizations/components/members/members.component.html +++ /dev/null @@ -1,10 +0,0 @@ - -
- -
- -
diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.ts b/src/app/organizations/components/organizations-list/organizations-list.component.ts index 96aa3c85a..bffb92608 100644 --- a/src/app/organizations/components/organizations-list/organizations-list.component.ts +++ b/src/app/organizations/components/organizations-list/organizations-list.component.ts @@ -106,7 +106,10 @@ export class OrganizationsListComponent { }, { field: 'name', - cell: (org) => ({ value: org.name }), + cell: (org) => ({ + value: org.name, + link: () => `/parties/${org.party_id}`, + }), }, createPartyColumn((org) => ({ id: org.party_id })), { @@ -135,17 +138,11 @@ export class OrganizationsListComponent { items: [ { label: 'Members', - click: () => - this.router.navigate(['/organizations/members'], { - queryParams: { orgId: org.id }, - }), + click: () => this.router.navigate([`/parties/${org.id}/members`]), }, { label: 'Invitations', - click: () => - this.router.navigate(['/organizations/invitations'], { - queryParams: { orgId: org.id }, - }), + click: () => this.router.navigate([`/parties/${org.id}/invitations`]), }, ], })), diff --git a/src/app/organizations/index.ts b/src/app/organizations/index.ts index 7a0c3670f..b6de570e7 100644 --- a/src/app/organizations/index.ts +++ b/src/app/organizations/index.ts @@ -1,3 +1,3 @@ -export * from './organizations.component'; +export * from './components/organizations-list/organizations-list.component'; export * from './routing-config'; export * from './organizations.routes'; diff --git a/src/app/organizations/organizations.component.html b/src/app/organizations/organizations.component.html deleted file mode 100644 index 0680b43f9..000000000 --- a/src/app/organizations/organizations.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/app/organizations/organizations.component.ts b/src/app/organizations/organizations.component.ts deleted file mode 100644 index a049b45e4..000000000 --- a/src/app/organizations/organizations.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; - -@Component({ - selector: 'cc-organizations', - imports: [RouterOutlet], - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './organizations.component.html', -}) -export class OrganizationsComponent {} diff --git a/src/app/organizations/organizations.routes.ts b/src/app/organizations/organizations.routes.ts index fba8d43da..25e01f9fb 100644 --- a/src/app/organizations/organizations.routes.ts +++ b/src/app/organizations/organizations.routes.ts @@ -2,31 +2,14 @@ import { Routes } from '@angular/router'; import { canActivateAuthRole } from '~/services'; -import { InvitationsComponent } from './components/invitations/invitations.component'; -import { MembersComponent } from './components/members/members.component'; import { OrganizationsListComponent } from './components/organizations-list/organizations-list.component'; -import { OrganizationsComponent } from './organizations.component'; import { ROUTING_CONFIG } from './routing-config'; export const ORGANIZATIONS_ROUTES: Routes = [ { path: '', - component: OrganizationsComponent, + component: OrganizationsListComponent, canActivate: [canActivateAuthRole], data: ROUTING_CONFIG, - children: [ - { - path: '', - component: OrganizationsListComponent, - }, - { - path: 'invitations', - component: InvitationsComponent, - }, - { - path: 'members', - component: MembersComponent, - }, - ], }, ]; diff --git a/src/app/parties/parties.component.ts b/src/app/parties/parties.component.ts index f2265e689..acc6c466d 100644 --- a/src/app/parties/parties.component.ts +++ b/src/app/parties/parties.component.ts @@ -19,6 +19,7 @@ import { import { getUnionKey } from '@vality/ng-thrift'; import { FetchFullDomainObjectsService } from '~/api/domain-config'; +import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; @Component({ @@ -31,6 +32,7 @@ import { PageLayoutModule } from '~/components/page-layout'; export class PartiesComponent implements OnInit { private qp = inject>(QueryParamsService<{ text: string }>); private fetchFullDomainObjectsService = inject(FetchFullDomainObjectsService); + private thriftOrganizationManagementService = inject(ThriftOrganizationManagementService); private router = inject(Router); initSearchParams$ = this.qp.params$.pipe(map((p) => p?.text ?? '')); @@ -61,6 +63,16 @@ export class PartiesComponent implements OnInit { .join(', '), }), }, + { + field: 'organization', + lazyCell: (party) => + this.thriftOrganizationManagementService.GetOrganizationByParty(party.ref.id).pipe( + map((org) => ({ + value: org.name, + description: org.id, + })), + ), + }, { field: 'blocking', cell: (party) => ({ diff --git a/src/app/parties/party/invitations/index.ts b/src/app/parties/party/invitations/index.ts new file mode 100644 index 000000000..9d5ccd4d7 --- /dev/null +++ b/src/app/parties/party/invitations/index.ts @@ -0,0 +1,2 @@ +export * from './invitations.component'; +export * from './routing-config'; diff --git a/src/app/parties/party/invitations/invitations.component.html b/src/app/parties/party/invitations/invitations.component.html new file mode 100644 index 000000000..3ee8a47e5 --- /dev/null +++ b/src/app/parties/party/invitations/invitations.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/organizations/components/invitations/invitations.component.ts b/src/app/parties/party/invitations/invitations.component.ts similarity index 62% rename from src/app/organizations/components/invitations/invitations.component.ts rename to src/app/parties/party/invitations/invitations.component.ts index bc96344a3..ffe2e4114 100644 --- a/src/app/organizations/components/invitations/invitations.component.ts +++ b/src/app/parties/party/invitations/invitations.component.ts @@ -1,10 +1,7 @@ import { of } from 'rxjs'; -import { catchError, map, tap } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { FormField, form, required } from '@angular/forms/signals'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { Column, @@ -17,46 +14,29 @@ import { ListInvitationsRequest, domain } from '@vality/org-management-proto/adm import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; -import { OrganizationFieldComponent } from '../organization-field/organization-field.component'; +import { PartyStoreService } from '../party-store.service'; @Component({ selector: 'cc-invitations', templateUrl: './invitations.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PageLayoutModule, TableResourceComponent, FormField, OrganizationFieldComponent], + imports: [PageLayoutModule, TableResourceComponent], }) -export class InvitationsComponent implements OnInit { +export class InvitationsComponent { private thriftOrgManagementService = inject(ThriftOrganizationManagementService); private log = inject(NotifyLogService); - private route = inject(ActivatedRoute); - private router = inject(Router); - - orgId = signal(''); - control = form(this.orgId, (schemaPath) => { - required(schemaPath); - }); - - private orgId$ = toObservable(this.orgId).pipe( - tap((orgId) => { - void this.router.navigate([], { - relativeTo: this.route, - queryParams: { orgId: orgId || null }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); - }), - ); + private partyStoreService = inject(PartyStoreService); invitations = pagedObservableResource< domain.Invitation, - { orgId: string } & Omit + { partyId: string } & Omit >({ - params: this.orgId$.pipe(map((orgId) => ({ orgId }))), - loader: ({ orgId, ...params }, options) => - !orgId + params: this.partyStoreService.id$.pipe(map((partyId) => ({ partyId }))), + loader: ({ partyId, ...params }, options) => + !partyId ? of({ result: [] }) : this.thriftOrgManagementService - .ListInvitations(orgId, { + .ListInvitations(partyId, { limit: options.size, continuation_token: options.continuationToken, ...params, @@ -110,19 +90,12 @@ export class InvitationsComponent implements OnInit { { field: 'created_at', header: 'Created at', - cell: (inv) => ({ value: inv.created_at }), + cell: (inv) => ({ value: inv.created_at, type: 'datetime' }), }, { field: 'expires_at', header: 'Expires at', - cell: (inv) => ({ value: inv.expires_at }), + cell: (inv) => ({ value: inv.expires_at, type: 'datetime' }), }, ]; - - ngOnInit(): void { - const initialOrgId = this.route.snapshot.queryParams['orgId']; - if (initialOrgId) { - this.orgId.set(initialOrgId); - } - } } diff --git a/src/app/parties/party/invitations/routing-config.ts b/src/app/parties/party/invitations/routing-config.ts new file mode 100644 index 000000000..943ad2d4f --- /dev/null +++ b/src/app/parties/party/invitations/routing-config.ts @@ -0,0 +1,5 @@ +import { RoutingConfig, Service } from '~/services'; + +export const ROUTING_CONFIG: RoutingConfig = { + services: [Service.OrganizationManagement], +}; diff --git a/src/app/parties/party/members/index.ts b/src/app/parties/party/members/index.ts new file mode 100644 index 000000000..9a6277800 --- /dev/null +++ b/src/app/parties/party/members/index.ts @@ -0,0 +1,2 @@ +export * from './members.component'; +export * from './routing-config'; diff --git a/src/app/parties/party/members/members.component.html b/src/app/parties/party/members/members.component.html new file mode 100644 index 000000000..6c5cc97d0 --- /dev/null +++ b/src/app/parties/party/members/members.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/organizations/components/members/members.component.ts b/src/app/parties/party/members/members.component.ts similarity index 56% rename from src/app/organizations/components/members/members.component.ts rename to src/app/parties/party/members/members.component.ts index 108be5517..19068a9fd 100644 --- a/src/app/organizations/components/members/members.component.ts +++ b/src/app/parties/party/members/members.component.ts @@ -1,10 +1,7 @@ import { of } from 'rxjs'; -import { catchError, map, tap } from 'rxjs/operators'; +import { catchError, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; -import { toObservable } from '@angular/core/rxjs-interop'; -import { FormField, form, required } from '@angular/forms/signals'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { Column, @@ -17,43 +14,26 @@ import { domain } from '@vality/org-management-proto/admin_management'; import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; -import { OrganizationFieldComponent } from '../organization-field/organization-field.component'; +import { PartyStoreService } from '../party-store.service'; @Component({ selector: 'cc-members', templateUrl: './members.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PageLayoutModule, TableResourceComponent, FormField, OrganizationFieldComponent], + imports: [PageLayoutModule, TableResourceComponent], }) -export class MembersComponent implements OnInit { +export class MembersComponent { private thriftOrgManagementService = inject(ThriftOrganizationManagementService); private log = inject(NotifyLogService); - private route = inject(ActivatedRoute); - private router = inject(Router); - - orgId = signal(''); - control = form(this.orgId, (schemaPath) => { - required(schemaPath); - }); - - private orgId$ = toObservable(this.orgId).pipe( - tap((orgId) => { - void this.router.navigate([], { - relativeTo: this.route, - queryParams: { orgId: orgId || null }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); - }), - ); + private partyStoreService = inject(PartyStoreService); members = pagedObservableResource({ - params: this.orgId$, - loader: (orgId, options) => - !orgId + params: this.partyStoreService.id$, + loader: (partyId, options) => + !partyId ? of({ result: [] }) : this.thriftOrgManagementService - .ListMembers(orgId, { + .ListMembers(partyId, { limit: options.size, continuation_token: options.continuationToken, }) @@ -86,11 +66,4 @@ export class MembersComponent implements OnInit { }), }, ]; - - ngOnInit(): void { - const initialOrgId = this.route.snapshot.queryParams['orgId']; - if (initialOrgId) { - this.orgId.set(initialOrgId); - } - } } diff --git a/src/app/parties/party/members/routing-config.ts b/src/app/parties/party/members/routing-config.ts new file mode 100644 index 000000000..943ad2d4f --- /dev/null +++ b/src/app/parties/party/members/routing-config.ts @@ -0,0 +1,5 @@ +import { RoutingConfig, Service } from '~/services'; + +export const ROUTING_CONFIG: RoutingConfig = { + services: [Service.OrganizationManagement], +}; diff --git a/src/app/parties/party/party-routing.module.ts b/src/app/parties/party/party-routing.module.ts index a133a7e26..5c73b4299 100644 --- a/src/app/parties/party/party-routing.module.ts +++ b/src/app/parties/party/party-routing.module.ts @@ -5,6 +5,8 @@ import { canActivateAuthRole } from '~/services'; import { ROUTING_CONFIG as WALLETS_ROUTING_CONFIG } from '../../wallets/routing-config'; +import { ROUTING_CONFIG as INVITATIONS_ROUTING_CONFIG } from './invitations/routing-config'; +import { ROUTING_CONFIG as MEMBERS_ROUTING_CONFIG } from './members/routing-config'; import { PartyComponent } from './party.component'; import { ROUTING_CONFIG } from './routing-config'; import { ROUTING_CONFIG as WALLET_WEBHOOKS_ROUTING_CONFIG } from './wallet-webhooks/routing-config'; @@ -37,6 +39,19 @@ import { ROUTING_CONFIG as WEBHOOKS_ROUTING_CONFIG } from './webhooks/routing-co canActivate: [canActivateAuthRole], data: WALLETS_ROUTING_CONFIG, }, + { + path: 'members', + loadComponent: () => import('./members').then((m) => m.MembersComponent), + canActivate: [canActivateAuthRole], + data: MEMBERS_ROUTING_CONFIG, + }, + { + path: 'invitations', + loadComponent: () => + import('./invitations').then((m) => m.InvitationsComponent), + canActivate: [canActivateAuthRole], + data: INVITATIONS_ROUTING_CONFIG, + }, { path: 'shop-webhooks', loadComponent: () => import('./webhooks').then((m) => m.WebhooksComponent), diff --git a/src/utils/table/create-party-column.ts b/src/utils/table/create-party-column.ts index a99cd2f19..a862b4d1c 100644 --- a/src/utils/table/create-party-column.ts +++ b/src/utils/table/create-party-column.ts @@ -1,5 +1,5 @@ import { of } from 'rxjs'; -import { map, startWith } from 'rxjs/operators'; +import { catchError, map, startWith } from 'rxjs/operators'; import { inject } from '@angular/core'; @@ -34,6 +34,9 @@ export const createPartyColumn = createColumn( ...partyCell, inProgress: true, }), + catchError(() => { + return of({ description: id, error: new Error('Party not found') }); + }), ); }, { header: 'Party' }, From f6ab41fb511cc3742e921ec85e321c342cbb29a5 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:32:28 +0800 Subject: [PATCH 08/22] feat: implement party details view and update navigation routing --- .../src/lib/components/nav/nav.component.html | 4 +- .../lib/components/value/value.component.html | 5 +- src/app/app.component.ts | 11 ++-- .../organizations-list.component.ts | 8 ++- src/app/parties/party/party-details/index.ts | 1 + .../party-details.component.html | 41 ++++++++++++ .../party-details/party-details.component.ts | 65 +++++++++++++++++++ src/app/parties/party/party-routing.module.ts | 5 ++ src/app/parties/party/party-store.service.ts | 2 +- 9 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 src/app/parties/party/party-details/index.ts create mode 100644 src/app/parties/party/party-details/party-details.component.html create mode 100644 src/app/parties/party/party-details/party-details.component.ts diff --git a/projects/matez/src/lib/components/nav/nav.component.html b/projects/matez/src/lib/components/nav/nav.component.html index d7869c5b7..7e84c8246 100644 --- a/projects/matez/src/lib/components/nav/nav.component.html +++ b/projects/matez/src/lib/components/nav/nav.component.html @@ -39,7 +39,9 @@ } @if (isActive && link.children?.length) { -
+
@for (childLink of link.children; track childLink) { + @if (childLink.divider) { + + } @else { + + } }
} diff --git a/projects/matez/src/lib/components/nav/nav.component.ts b/projects/matez/src/lib/components/nav/nav.component.ts index 229427e04..4a9233997 100644 --- a/projects/matez/src/lib/components/nav/nav.component.ts +++ b/projects/matez/src/lib/components/nav/nav.component.ts @@ -36,6 +36,7 @@ export interface BaseLink { icon?: string; children?: BaseLink[]; isHidden?: boolean; + divider?: boolean; } export type Link = PossiblyAsyncValue, [url: string]>; @@ -43,7 +44,8 @@ export type Link = PossiblyAsyncValue function isActiveLink(link: BaseLink, url: string): boolean { return ( !isNil(url) && - (url.startsWith(link.url || '') || (!!link.checkUrl && url.startsWith(link.checkUrl || ''))) + ((Boolean(link.url) && url.startsWith(link.url)) || + (Boolean(link.checkUrl) && url.startsWith(link.checkUrl))) ); } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 4a58c363d..d1792d14d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -120,6 +120,7 @@ const createNavLinks = (): Link[] => [ label: 'Wallets', url: `${partyPath}/wallets`, }, + { divider: true }, { label: 'Members', url: `${partyPath}/members`, @@ -128,6 +129,7 @@ const createNavLinks = (): Link[] => [ label: 'Invitations', url: `${partyPath}/invitations`, }, + { divider: true }, { label: 'Shop webhooks', url: `${partyPath}/shop-webhooks`, @@ -136,6 +138,7 @@ const createNavLinks = (): Link[] => [ label: 'Wallet webhooks', url: `${partyPath}/wallet-webhooks`, }, + { divider: true }, { label: 'Payment RR', url: `${partyPath}/routing-rules/payment/main`, From 958eb08d9c48ca7e1ab17185b03528f88a3531ab Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:53:40 +0800 Subject: [PATCH 11/22] refactor: update organizations filters to use DEFAULT_FILTERS and countChanged utility --- .../organizations-list/organizations-list.component.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.ts b/src/app/organizations/components/organizations-list/organizations-list.component.ts index 345b2ff93..39bdf38e3 100644 --- a/src/app/organizations/components/organizations-list/organizations-list.component.ts +++ b/src/app/organizations/components/organizations-list/organizations-list.component.ts @@ -17,6 +17,7 @@ import { SelectFieldModule, TableResourceComponent, clean, + countChanged, createMenuColumn, debounceTimeWithFirst, pagedObservableResource, @@ -62,14 +63,12 @@ export class OrganizationsListComponent { ]; filters = signal({ - status: this.qp.params.status ?? null, - owner_id: this.qp.params.owner_id || '', + status: this.qp.params.status ?? DEFAULT_FILTERS.status, + owner_id: this.qp.params.owner_id || DEFAULT_FILTERS.owner_id, }); filtersControl = form(this.filters); - active = computed( - () => Number(Boolean(this.filters().status)) + Number(Boolean(this.filters().owner_id)), - ); + active = computed(() => countChanged(this.filters(), DEFAULT_FILTERS)); private filters$ = toObservable(this.filters).pipe( debounceTimeWithFirst(300), From 7e15e0ba1c52c31fdbcd29548fdc67699b91d98a Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:56:26 +0800 Subject: [PATCH 12/22] feat: add CreateOrganizationDialog component and integrate it into the organizations list page --- .../create-organization-dialog.component.html | 15 ++++ .../create-organization-dialog.component.ts | 85 +++++++++++++++++++ .../create-organization-dialog/index.ts | 1 + .../organizations-list.component.html | 1 + .../organizations-list.component.ts | 17 ++++ src/app/organizations/index.ts | 1 + 6 files changed, 120 insertions(+) create mode 100644 src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.html create mode 100644 src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.ts create mode 100644 src/app/organizations/components/create-organization-dialog/index.ts diff --git a/src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.html b/src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.html new file mode 100644 index 000000000..0dcfefa5b --- /dev/null +++ b/src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.html @@ -0,0 +1,15 @@ + +
+ + @if (!partyId) { + + } + +
+ + + + +
diff --git a/src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.ts b/src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.ts new file mode 100644 index 000000000..ad110bd5b --- /dev/null +++ b/src/app/organizations/components/create-organization-dialog/create-organization-dialog.component.ts @@ -0,0 +1,85 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FormField, form, required } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; + +import { + DialogModule, + DialogSuperclass, + InputFieldModule, + NotifyLogService, + progressTo, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; +import { MerchantFieldModule } from '~/components/merchant-field'; + +export interface CreateOrganizationDialogData { + partyId?: domain.PartyID; +} + +interface CreateOrganizationModel { + name: string; + party_id: domain.PartyID; + owner_id: domain.UserID; +} + +@Component({ + selector: 'cc-create-organization-dialog', + imports: [ + CommonModule, + DialogModule, + ReactiveFormsModule, + MatButtonModule, + MerchantFieldModule, + InputFieldModule, + FormField, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './create-organization-dialog.component.html', +}) +export class CreateOrganizationDialogComponent extends DialogSuperclass< + CreateOrganizationDialogComponent, + CreateOrganizationDialogData | void +> { + private organizationsService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + + partyId = (this.dialogData as CreateOrganizationDialogData)?.partyId; + + controlModel = signal({ + name: '', + party_id: this.partyId || '', + owner_id: '', + }); + + control = form(this.controlModel, (schemaPath) => { + required(schemaPath.name); + required(schemaPath.party_id); + required(schemaPath.owner_id); + }); + + progress = signal(0); + + create() { + const { name, party_id, owner_id } = this.controlModel(); + this.organizationsService + .CreateOrganization({ + name, + party_id, + owner_id, + }) + .pipe(progressTo(this.progress)) + .subscribe({ + next: () => { + this.log.success('Organization created'); + this.closeWithSuccess(); + }, + error: (err) => { + this.log.error(err); + }, + }); + } +} diff --git a/src/app/organizations/components/create-organization-dialog/index.ts b/src/app/organizations/components/create-organization-dialog/index.ts new file mode 100644 index 000000000..fa5d99cdc --- /dev/null +++ b/src/app/organizations/components/create-organization-dialog/index.ts @@ -0,0 +1 @@ +export * from './create-organization-dialog.component'; diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.html b/src/app/organizations/components/organizations-list/organizations-list.component.html index 22fb0994d..e2f682dfd 100644 --- a/src/app/organizations/components/organizations-list/organizations-list.component.html +++ b/src/app/organizations/components/organizations-list/organizations-list.component.html @@ -1,5 +1,6 @@ + diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.ts b/src/app/organizations/components/organizations-list/organizations-list.component.ts index 39bdf38e3..3c6af28f7 100644 --- a/src/app/organizations/components/organizations-list/organizations-list.component.ts +++ b/src/app/organizations/components/organizations-list/organizations-list.component.ts @@ -5,10 +5,12 @@ import { catchError, distinctUntilChanged, map, shareReplay } from 'rxjs/operato import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormField, form } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; import { Router } from '@angular/router'; import { Column, + DialogService, FiltersModule, InputFieldModule, NotifyLogService, @@ -28,6 +30,8 @@ import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; import { createPartyColumn } from '~/utils'; +import { CreateOrganizationDialogComponent } from '../create-organization-dialog'; + export interface OrganizationsFilters { status: domain.OrganizationStatus | null; owner_id: domain.UserID; @@ -43,6 +47,7 @@ const DEFAULT_FILTERS: OrganizationsFilters = { templateUrl: './organizations-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + MatButtonModule, PageLayoutModule, TableResourceComponent, FiltersModule, @@ -56,6 +61,7 @@ export class OrganizationsListComponent { private log = inject(NotifyLogService); private router = inject(Router); private qp = inject>>(QueryParamsService); + private dialogService = inject(DialogService); statusOptions: Option[] = [ { label: 'Active', value: domain.OrganizationStatus.active }, @@ -157,6 +163,17 @@ export class OrganizationsListComponent { }); } + create(): void { + this.dialogService + .open(CreateOrganizationDialogComponent) + .afterClosed() + .subscribe((res) => { + if (res?.status === 'success') { + this.organizations.reload(); + } + }); + } + resetFilters(): void { this.filters.set(DEFAULT_FILTERS); } diff --git a/src/app/organizations/index.ts b/src/app/organizations/index.ts index b6de570e7..5c04b4f48 100644 --- a/src/app/organizations/index.ts +++ b/src/app/organizations/index.ts @@ -1,3 +1,4 @@ export * from './components/organizations-list/organizations-list.component'; +export * from './components/create-organization-dialog'; export * from './routing-config'; export * from './organizations.routes'; From 11f4603a1defcf96d0378856645a929b8a9b2f6a Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:23:18 +0800 Subject: [PATCH 13/22] feat: add organization creation flow and conditional view rendering for party members and invitations --- .../invitations/invitations.component.html | 17 ++++++++- .../invitations/invitations.component.ts | 27 +++++++------ .../party/members/members.component.html | 17 ++++++++- .../party/members/members.component.ts | 19 +++++++--- .../party-details.component.html | 5 ++- .../party-details/party-details.component.ts | 21 ++++------ src/app/parties/party/party-store.service.ts | 38 ++++++++++++++++++- 7 files changed, 106 insertions(+), 38 deletions(-) diff --git a/src/app/parties/party/invitations/invitations.component.html b/src/app/parties/party/invitations/invitations.component.html index 3ee8a47e5..887e7cd91 100644 --- a/src/app/parties/party/invitations/invitations.component.html +++ b/src/app/parties/party/invitations/invitations.component.html @@ -1,3 +1,16 @@ - - + + @if (organization.value()) { + + } @else if (!organization.isLoading()) { +
+

+ Organization not created +

+ +
+ }
diff --git a/src/app/parties/party/invitations/invitations.component.ts b/src/app/parties/party/invitations/invitations.component.ts index ffe2e4114..774ee60f5 100644 --- a/src/app/parties/party/invitations/invitations.component.ts +++ b/src/app/parties/party/invitations/invitations.component.ts @@ -1,7 +1,8 @@ import { of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { Column, @@ -9,7 +10,7 @@ import { TableResourceComponent, pagedObservableResource, } from '@vality/matez'; -import { ListInvitationsRequest, domain } from '@vality/org-management-proto/admin_management'; +import { domain } from '@vality/org-management-proto/admin_management'; import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; @@ -20,26 +21,24 @@ import { PartyStoreService } from '../party-store.service'; selector: 'cc-invitations', templateUrl: './invitations.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PageLayoutModule, TableResourceComponent], + imports: [MatButtonModule, PageLayoutModule, TableResourceComponent], }) export class InvitationsComponent { private thriftOrgManagementService = inject(ThriftOrganizationManagementService); private log = inject(NotifyLogService); private partyStoreService = inject(PartyStoreService); - invitations = pagedObservableResource< - domain.Invitation, - { partyId: string } & Omit - >({ - params: this.partyStoreService.id$.pipe(map((partyId) => ({ partyId }))), - loader: ({ partyId, ...params }, options) => - !partyId + organization = this.partyStoreService.organization; + + invitations = pagedObservableResource({ + params: computed(() => this.organization.value()?.id), + loader: (orgId, options) => + !orgId ? of({ result: [] }) : this.thriftOrgManagementService - .ListInvitations(partyId, { + .ListInvitations(orgId, { limit: options.size, continuation_token: options.continuationToken, - ...params, }) .pipe( map((res) => ({ @@ -98,4 +97,8 @@ export class InvitationsComponent { cell: (inv) => ({ value: inv.expires_at, type: 'datetime' }), }, ]; + + createOrganization(): void { + this.partyStoreService.createOrganization(); + } } diff --git a/src/app/parties/party/members/members.component.html b/src/app/parties/party/members/members.component.html index 6c5cc97d0..d5a86c896 100644 --- a/src/app/parties/party/members/members.component.html +++ b/src/app/parties/party/members/members.component.html @@ -1,3 +1,16 @@ - - + + @if (organization.value()) { + + } @else if (!organization.isLoading()) { +
+

+ Organization not created +

+ +
+ }
diff --git a/src/app/parties/party/members/members.component.ts b/src/app/parties/party/members/members.component.ts index 19068a9fd..337502b9b 100644 --- a/src/app/parties/party/members/members.component.ts +++ b/src/app/parties/party/members/members.component.ts @@ -1,7 +1,8 @@ import { of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { Column, @@ -20,20 +21,22 @@ import { PartyStoreService } from '../party-store.service'; selector: 'cc-members', templateUrl: './members.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PageLayoutModule, TableResourceComponent], + imports: [MatButtonModule, PageLayoutModule, TableResourceComponent], }) export class MembersComponent { private thriftOrgManagementService = inject(ThriftOrganizationManagementService); private log = inject(NotifyLogService); private partyStoreService = inject(PartyStoreService); + organization = this.partyStoreService.organization; + members = pagedObservableResource({ - params: this.partyStoreService.id$, - loader: (partyId, options) => - !partyId + params: computed(() => this.organization.value()?.id), + loader: (orgId, options) => + !orgId ? of({ result: [] }) : this.thriftOrgManagementService - .ListMembers(partyId, { + .ListMembers(orgId, { limit: options.size, continuation_token: options.continuationToken, }) @@ -66,4 +69,8 @@ export class MembersComponent { }), }, ]; + + createOrganization(): void { + this.partyStoreService.createOrganization(); + } } diff --git a/src/app/parties/party/party-details/party-details.component.html b/src/app/parties/party/party-details/party-details.component.html index e046cc491..f7e5f2896 100644 --- a/src/app/parties/party/party-details/party-details.component.html +++ b/src/app/parties/party/party-details/party-details.component.html @@ -33,7 +33,10 @@ type="Organization" > } @else { -

Not created

+
+

Not created

+ +
} diff --git a/src/app/parties/party/party-details/party-details.component.ts b/src/app/parties/party/party-details/party-details.component.ts index 3f8799c76..6a8ace58b 100644 --- a/src/app/parties/party/party-details/party-details.component.ts +++ b/src/app/parties/party/party-details/party-details.component.ts @@ -3,16 +3,15 @@ import { catchError } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { VersionedObject } from '@vality/domain-proto/domain_config_v2'; import { NotifyLogService, observableResource } from '@vality/matez'; import { ThriftViewerModule } from '@vality/ng-thrift'; import { metadata$ as orgManagementMetadata$ } from '@vality/org-management-proto'; -import { domain } from '@vality/org-management-proto/admin_management'; import { DomainService } from '~/api/domain-config'; -import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; import { DomainThriftViewerComponent } from '~/components/thrift-api-crud'; @@ -25,6 +24,7 @@ import { PartyStoreService } from '../party-store.service'; imports: [ CommonModule, MatCardModule, + MatButtonModule, PageLayoutModule, DomainThriftViewerComponent, ThriftViewerModule, @@ -33,7 +33,6 @@ import { PartyStoreService } from '../party-store.service'; export class PartyDetailsComponent { private partyStoreService = inject(PartyStoreService); private domainService = inject(DomainService); - private thriftOrgManagementService = inject(ThriftOrganizationManagementService); private log = inject(NotifyLogService); orgMetadata$ = orgManagementMetadata$; @@ -51,15 +50,9 @@ export class PartyDetailsComponent { : of(null), }); - organization = observableResource({ - params: this.partyStoreService.id$, - loader: (partyId) => - partyId - ? this.thriftOrgManagementService.GetOrganizationByParty(partyId).pipe( - catchError(() => { - return of(null); - }), - ) - : of(null), - }); + organization = this.partyStoreService.organization; + + createOrganization(): void { + this.partyStoreService.createOrganization(); + } } diff --git a/src/app/parties/party/party-store.service.ts b/src/app/parties/party/party-store.service.ts index 2b4722886..d301c5272 100644 --- a/src/app/parties/party/party-store.service.ts +++ b/src/app/parties/party/party-store.service.ts @@ -2,6 +2,7 @@ import { EMPTY, of } from 'rxjs'; import { catchError, distinctUntilChanged, + first, map, shareReplay, startWith, @@ -11,15 +12,21 @@ import { import { Injectable, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { NotifyLogService } from '@vality/matez'; +import { DialogService, NotifyLogService, observableResource } from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; import { PartiesStoreService } from '~/api/payment-processing'; +import { ThriftOrganizationManagementService } from '~/api/services'; + +import { CreateOrganizationDialogComponent } from '../../organizations'; @Injectable() export class PartyStoreService { private route = inject(ActivatedRoute); private partiesStoreService = inject(PartiesStoreService); + private thriftOrgManagementService = inject(ThriftOrganizationManagementService); private log = inject(NotifyLogService); + private dialogService = inject(DialogService); id$ = this.route.params.pipe( startWith(this.route.snapshot.params), @@ -41,4 +48,33 @@ export class PartyStoreService { distinctUntilChanged(), shareReplay({ refCount: true, bufferSize: 1 }), ); + + organization = observableResource({ + params: this.id$, + loader: (partyId) => + partyId + ? this.thriftOrgManagementService.GetOrganizationByParty(partyId).pipe( + catchError(() => { + return of(null); + }), + ) + : of(null), + }); + + createOrganization(): void { + this.id$ + .pipe( + first(), + switchMap((partyId) => + this.dialogService + .open(CreateOrganizationDialogComponent, { partyId }) + .afterClosed(), + ), + ) + .subscribe((result) => { + if (result?.status === 'success') { + this.organization.reload(); + } + }); + } } From 407148054238495da8de6c43d642b191fcb4a5a0 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:46:20 +0800 Subject: [PATCH 14/22] fix: disable Sentry replays on error sample rate in main.ts --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 7e78d507e..8f08f8719 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,7 +23,7 @@ if (SENTRY_DSN) { ], tracesSampleRate: 1, replaysSessionSampleRate: 0, - replaysOnErrorSampleRate: 1, + replaysOnErrorSampleRate: 0, enableLogs: false, }); } From 779448249bb1e742603e85d5669fca5406a14736 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:52:38 +0800 Subject: [PATCH 15/22] refactor: export ParsedThriftError and switch from Sentry exception capture to breadcrumb logging for thrift errors --- src/utils/thrift/provide-thrift-services.ts | 38 ++++++++------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/src/utils/thrift/provide-thrift-services.ts b/src/utils/thrift/provide-thrift-services.ts index a066aea93..1406743f7 100644 --- a/src/utils/thrift/provide-thrift-services.ts +++ b/src/utils/thrift/provide-thrift-services.ts @@ -18,7 +18,7 @@ export const LOGGING = { fullLogging: isDevMode(), }; -type ParsedThriftError = ReturnType; +export type ParsedThriftError = ReturnType; export function parseThriftError(error: unknown) { const traceId = error?.['info']?.headers?.['x-woody-trace-id']; @@ -74,30 +74,22 @@ export function parseThriftError(error: unknown) { } } -function captureThriftError( +function addThriftErrorBreadcrumb( params: Parameters>[0], error: ParsedThriftError, ) { - Sentry.withScope((scope) => { - scope.setTags({ - 'thrift.error_type': error.type, - 'thrift.namespace': params.namespace, - 'thrift.service': params.serviceName, - 'thrift.method': params.name, - }); - scope.setFingerprint([ - '{{ default }}', - error.type, - params.namespace, - params.serviceName, - params.name, - ]); - - if (error.traceId) { - scope.setTag('thrift.trace_id', error.traceId); - } - - Sentry.captureException(params.error); + Sentry.addBreadcrumb({ + category: 'thrift', + type: 'http', + level: 'warning', + message: `${params.name} (${params.namespace} ${params.serviceName}) failed: ${error.message || error.name || 'Unknown error'}`, + data: { + errorType: error.type, + namespace: params.namespace, + service: params.serviceName, + method: params.name, + ...(error.traceId ? { traceId: error.traceId } : {}), + }, }); } @@ -107,7 +99,7 @@ const logger: ConnectOptions['loggingFn'] = (params) => { switch (params.type) { case 'error': { const parsedError = parseThriftError(params.error); - captureThriftError(params, parsedError); + addThriftErrorBreadcrumb(params, parsedError); console.groupCollapsed( `šŸ”“\u00A0${info}`, `\nāš ļø\u00A0${parsedError.message || parsedError.name || 'Unknown error'}`, From 92ebe4481bc33074f233c480819324a5e84379a8 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:55:23 +0800 Subject: [PATCH 16/22] fix: update withdrawal list after adjustment and make reload options optional --- src/app/withdrawals/withdrawals.component.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/withdrawals/withdrawals.component.ts b/src/app/withdrawals/withdrawals.component.ts index 484e2e49f..93203cd3a 100644 --- a/src/app/withdrawals/withdrawals.component.ts +++ b/src/app/withdrawals/withdrawals.component.ts @@ -188,14 +188,19 @@ export class WithdrawalsComponent implements OnInit { this.fetchWithdrawalsService.more(); } - reload(options: UpdateOptions) { + reload(options?: UpdateOptions) { this.fetchWithdrawalsService.reload(options); } adjustment() { - this.dialogService.open(CreateAdjustmentDialogComponent, { - withdrawals: this.selected, - }); + this.dialogService + .open(CreateAdjustmentDialogComponent, { + withdrawals: this.selected, + }) + .afterClosed() + .subscribe(() => { + this.reload(); + }); } failMachines() { @@ -207,7 +212,7 @@ export class WithdrawalsComponent implements OnInit { .afterClosed() .subscribe((res) => { if (res.status === DialogResponseStatus.Success) { - this.fetchWithdrawalsService.reload(); + this.reload(); this.selected = []; } else if (res.data?.errors?.length) { this.selected = res.data.errors.map(({ index }) => this.selected[index]); From b43de2054c9e0581e10ea37b3ef664c5ef3d049f Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:18:30 +0800 Subject: [PATCH 17/22] feat: implement Keycloak auto-refresh and session re-login on 401 thrift errors --- src/app/app.config.ts | 9 +- src/utils/thrift/provide-thrift-services.ts | 115 +++++++++++--------- 2 files changed, 70 insertions(+), 54 deletions(-) diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a69e0e1fb..8d86bb54c 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,9 @@ -import { provideKeycloak } from 'keycloak-angular'; +import { + AutoRefreshTokenService, + UserActivityService, + provideKeycloak, + withAutoRefreshToken, +} from 'keycloak-angular'; import { provideMonacoEditor } from 'ngx-monaco-editor-v2'; import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay'; @@ -55,6 +60,8 @@ export const appConfig: ApplicationConfig = { provideKeycloak({ config: './assets/authConfig.json' as never, initOptions: { onLoad: 'login-required', checkLoginIframe: !isDevMode() }, + features: [withAutoRefreshToken({ onInactivityTimeout: 'login' })], + providers: [AutoRefreshTokenService, UserActivityService], }), provideHttpClient(withXhr(), withInterceptorsFromDi()), { provide: MAT_DATE_FORMATS, useValue: DEFAULT_MAT_DATE_FORMATS }, diff --git a/src/utils/thrift/provide-thrift-services.ts b/src/utils/thrift/provide-thrift-services.ts index 1406743f7..89d4e7daa 100644 --- a/src/utils/thrift/provide-thrift-services.ts +++ b/src/utils/thrift/provide-thrift-services.ts @@ -93,66 +93,72 @@ function addThriftErrorBreadcrumb( }); } -const logger: ConnectOptions['loggingFn'] = (params) => { - const info = `${params.name} (${params.namespace} ${params.serviceName})`; - - switch (params.type) { - case 'error': { - const parsedError = parseThriftError(params.error); - addThriftErrorBreadcrumb(params, parsedError); - console.groupCollapsed( - `šŸ”“\u00A0${info}`, - `\nāš ļø\u00A0${parsedError.message || parsedError.name || 'Unknown error'}`, - `\nšŸ†”\u00A0Trace:\u00A0${params.headers['x-woody-trace-id']}`, - ); - console.error(parsedError.error); - if (LOGGING.fullLogging) { - console.dir( - { - Arguments: params.args, - Headers: params.headers, - }, - { - depth: null, - compact: false, - maxArrayLength: null, - maxStringLength: null, - }, - ); - } - console.groupEnd(); - return; - } - case 'success': { - if (LOGGING.fullLogging) { - console.groupCollapsed(`🟢\u00A0${info}`); - console.dir( - { - Arguments: params.args, - Response: params.response, - Headers: params.headers, - }, - { - depth: null, - compact: false, - maxArrayLength: null, - maxStringLength: null, - }, +function createLogger(keycloak: Keycloak): ConnectOptions['loggingFn'] { + return (params) => { + const info = `${params.name} (${params.namespace} ${params.serviceName})`; + + switch (params.type) { + case 'error': { + const parsedError = parseThriftError(params.error); + addThriftErrorBreadcrumb(params, parsedError); + if (params.error === 401) { + void keycloak.login(); + } + console.groupCollapsed( + `šŸ”“\u00A0${info}`, + `\nāš ļø\u00A0${parsedError.message || parsedError.name || 'Unknown error'}`, + `\nšŸ†”\u00A0Trace:\u00A0${params.headers['x-woody-trace-id']}`, ); + console.error(parsedError.error); + if (LOGGING.fullLogging) { + console.dir( + { + Arguments: params.args, + Headers: params.headers, + }, + { + depth: null, + compact: false, + maxArrayLength: null, + maxStringLength: null, + }, + ); + } console.groupEnd(); + return; + } + case 'success': { + if (LOGGING.fullLogging) { + console.groupCollapsed(`🟢\u00A0${info}`); + console.dir( + { + Arguments: params.args, + Response: params.response, + Headers: params.headers, + }, + { + depth: null, + compact: false, + maxArrayLength: null, + maxStringLength: null, + }, + ); + console.groupEnd(); + } + return; + } + case 'call': { + return; } - return; - } - case 'call': { - return; } - } -}; + }; +} function createConnectOptions(serviceName: string) { const configService = inject(ConfigService); const keycloak = inject(Keycloak); const keycloakUserService = inject(KeycloakUserService); + const loggingFn = createLogger(keycloak); return combineLatest([keycloakUserService.user.value$, configService.config.value$]).pipe( map( @@ -164,9 +170,12 @@ function createConnectOptions(serviceName: string) { token: keycloak.token ?? '', }), logging: true, - loggingFn: logger, + loggingFn, createCallOptions: () => ({ - headers: createRequestWachterHeaders(), + headers: { + ...createRequestWachterHeaders(), + authorization: `Bearer ${keycloak.token ?? ''}`, + }, }), timeout: isDevMode() ? 15_000 : 60_000, ...config.api.wachter, From 5632ff7e03f6b19a977ef09b181cc2be417cadbb Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:21:51 +0800 Subject: [PATCH 18/22] refactor: update createRequestWachterHeaders to accept optional token for authorization header injection --- src/utils/thrift/create-wachter-headers.ts | 3 ++- src/utils/thrift/provide-thrift-services.ts | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/utils/thrift/create-wachter-headers.ts b/src/utils/thrift/create-wachter-headers.ts index b33658bb3..21b389689 100644 --- a/src/utils/thrift/create-wachter-headers.ts +++ b/src/utils/thrift/create-wachter-headers.ts @@ -13,10 +13,11 @@ export const createWachterHeaders = ( 'x-woody-parent-id': undefined, }); -export const createRequestWachterHeaders = () => { +export const createRequestWachterHeaders = (token?: string) => { const traceId = generateId(); return { 'x-woody-span-id': traceId, 'x-woody-trace-id': traceId, + ...(token ? { authorization: `Bearer ${token}` } : {}), }; }; diff --git a/src/utils/thrift/provide-thrift-services.ts b/src/utils/thrift/provide-thrift-services.ts index 89d4e7daa..340a9e395 100644 --- a/src/utils/thrift/provide-thrift-services.ts +++ b/src/utils/thrift/provide-thrift-services.ts @@ -172,10 +172,7 @@ function createConnectOptions(serviceName: string) { logging: true, loggingFn, createCallOptions: () => ({ - headers: { - ...createRequestWachterHeaders(), - authorization: `Bearer ${keycloak.token ?? ''}`, - }, + headers: createRequestWachterHeaders(keycloak.token), }), timeout: isDevMode() ? 15_000 : 60_000, ...config.api.wachter, From db7d0ffdeadac7bdf2006c1371ac0a77538f6320 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:11:43 +0800 Subject: [PATCH 19/22] fix: update keycloak token refresh logic and extend session timeout configuration --- src/app/app.config.ts | 7 ++++++- src/utils/thrift/provide-thrift-services.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 8d86bb54c..c82be9597 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -60,7 +60,12 @@ export const appConfig: ApplicationConfig = { provideKeycloak({ config: './assets/authConfig.json' as never, initOptions: { onLoad: 'login-required', checkLoginIframe: !isDevMode() }, - features: [withAutoRefreshToken({ onInactivityTimeout: 'login' })], + features: [ + withAutoRefreshToken({ + onInactivityTimeout: 'login', + sessionTimeout: 4 * 60 * 60_000, + }), + ], providers: [AutoRefreshTokenService, UserActivityService], }), provideHttpClient(withXhr(), withInterceptorsFromDi()), diff --git a/src/utils/thrift/provide-thrift-services.ts b/src/utils/thrift/provide-thrift-services.ts index 340a9e395..ed63f8025 100644 --- a/src/utils/thrift/provide-thrift-services.ts +++ b/src/utils/thrift/provide-thrift-services.ts @@ -102,7 +102,7 @@ function createLogger(keycloak: Keycloak): ConnectOptions['loggingFn'] { const parsedError = parseThriftError(params.error); addThriftErrorBreadcrumb(params, parsedError); if (params.error === 401) { - void keycloak.login(); + keycloak.updateToken(-1).catch(() => keycloak.login()); } console.groupCollapsed( `šŸ”“\u00A0${info}`, From 2bf7661e1237759a4f95134b31f5b82b097e46aa Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:45:56 +0800 Subject: [PATCH 20/22] feat: add .agents/ to .gitignore and create skills-lock.json for AI Agent Skills management --- .gitignore | 6 ++++++ skills-lock.json | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 skills-lock.json diff --git a/.gitignore b/.gitignore index e1bb39e32..1185d1494 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,9 @@ authConfig*.json # Env .env + +# AI Agent Skills (managed via https://skills.sh) +# Note: Officially, committing skills to VCS is recommended (for security and zero-install setup), +# but we intentionally do not commit them to keep the repository clean. +# Track dependencies via `skills-lock.json` and restore via: `npx skills install` +/.agents/ diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..9627bf9ef --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "angular-developer": { + "source": "angular/skills", + "sourceType": "github", + "skillPath": "angular-developer/SKILL.md", + "computedHash": "0850aa966b6a7da1151daa9d0ef2081bf397f052573a35c1643fb92b948f0f00" + } + } +} From 5f0fa158b1e08247a698c2c05d852b29b3090b7b Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:07:42 +0800 Subject: [PATCH 21/22] feat: implement organization modification and activation/deactivation functionality --- skills-lock.json | 16 ++-- .../modify-organization-dialog/index.ts | 1 + .../modify-organization-dialog.component.html | 11 +++ .../modify-organization-dialog.component.ts | 65 ++++++++++++++ .../organizations-list.component.ts | 47 +++++++--- src/app/organizations/index.ts | 2 + src/app/organizations/services/index.ts | 1 + .../services/organization-actions.service.ts | 89 +++++++++++++++++++ .../party-details.component.html | 12 +++ .../party-details/party-details.component.ts | 22 +++++ 10 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 src/app/organizations/components/modify-organization-dialog/index.ts create mode 100644 src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.html create mode 100644 src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.ts create mode 100644 src/app/organizations/services/index.ts create mode 100644 src/app/organizations/services/organization-actions.service.ts diff --git a/skills-lock.json b/skills-lock.json index 9627bf9ef..b9d0a8e69 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,11 +1,11 @@ { - "version": 1, - "skills": { - "angular-developer": { - "source": "angular/skills", - "sourceType": "github", - "skillPath": "angular-developer/SKILL.md", - "computedHash": "0850aa966b6a7da1151daa9d0ef2081bf397f052573a35c1643fb92b948f0f00" + "version": 1, + "skills": { + "angular-developer": { + "source": "angular/skills", + "sourceType": "github", + "skillPath": "angular-developer/SKILL.md", + "computedHash": "0850aa966b6a7da1151daa9d0ef2081bf397f052573a35c1643fb92b948f0f00" + } } - } } diff --git a/src/app/organizations/components/modify-organization-dialog/index.ts b/src/app/organizations/components/modify-organization-dialog/index.ts new file mode 100644 index 000000000..255417b46 --- /dev/null +++ b/src/app/organizations/components/modify-organization-dialog/index.ts @@ -0,0 +1 @@ +export * from './modify-organization-dialog.component'; diff --git a/src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.html b/src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.html new file mode 100644 index 000000000..950404fc8 --- /dev/null +++ b/src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.html @@ -0,0 +1,11 @@ + +
+ +
+ + + + +
diff --git a/src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.ts b/src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.ts new file mode 100644 index 000000000..d4ea666be --- /dev/null +++ b/src/app/organizations/components/modify-organization-dialog/modify-organization-dialog.component.ts @@ -0,0 +1,65 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormField, form, required } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; + +import { + DialogModule, + DialogSuperclass, + InputFieldModule, + NotifyLogService, + progressTo, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; + +export interface ModifyOrganizationDialogData { + organization: domain.Organization; +} + +interface ModifyOrganizationModel { + name: string; +} + +@Component({ + selector: 'cc-modify-organization-dialog', + imports: [CommonModule, DialogModule, MatButtonModule, InputFieldModule, FormField], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './modify-organization-dialog.component.html', +}) +export class ModifyOrganizationDialogComponent extends DialogSuperclass< + ModifyOrganizationDialogComponent, + ModifyOrganizationDialogData +> { + private organizationsService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + + controlModel = signal({ + name: this.dialogData.organization.name || '', + }); + + control = form(this.controlModel, (schemaPath) => { + required(schemaPath.name); + }); + + progress = signal(0); + + modify() { + const { name } = this.controlModel(); + this.organizationsService + .ModifyOrganization(this.dialogData.organization.id, { + name, + }) + .pipe(progressTo(this.progress)) + .subscribe({ + next: () => { + this.log.success('Organization updated'); + this.closeWithSuccess(); + }, + error: (err) => { + this.log.error(err); + }, + }); + } +} diff --git a/src/app/organizations/components/organizations-list/organizations-list.component.ts b/src/app/organizations/components/organizations-list/organizations-list.component.ts index 3c6af28f7..71a2c0828 100644 --- a/src/app/organizations/components/organizations-list/organizations-list.component.ts +++ b/src/app/organizations/components/organizations-list/organizations-list.component.ts @@ -10,7 +10,6 @@ import { Router } from '@angular/router'; import { Column, - DialogService, FiltersModule, InputFieldModule, NotifyLogService, @@ -30,7 +29,7 @@ import { ThriftOrganizationManagementService } from '~/api/services'; import { PageLayoutModule } from '~/components/page-layout'; import { createPartyColumn } from '~/utils'; -import { CreateOrganizationDialogComponent } from '../create-organization-dialog'; +import { OrganizationActionsService } from '../../services'; export interface OrganizationsFilters { status: domain.OrganizationStatus | null; @@ -61,7 +60,7 @@ export class OrganizationsListComponent { private log = inject(NotifyLogService); private router = inject(Router); private qp = inject>>(QueryParamsService); - private dialogService = inject(DialogService); + private organizationActions = inject(OrganizationActionsService); statusOptions: Option[] = [ { label: 'Active', value: domain.OrganizationStatus.active }, @@ -153,6 +152,19 @@ export class OrganizationsListComponent { label: 'Invitations', click: () => this.router.navigate([`/parties/${org.party_id}/invitations`]), }, + { + label: 'Edit', + click: () => this.modify(org), + }, + org.status === domain.OrganizationStatus.active + ? { + label: 'Deactivate', + click: () => this.deactivate(org), + } + : { + label: 'Activate', + click: () => this.activate(org), + }, ], })), ]; @@ -164,14 +176,27 @@ export class OrganizationsListComponent { } create(): void { - this.dialogService - .open(CreateOrganizationDialogComponent) - .afterClosed() - .subscribe((res) => { - if (res?.status === 'success') { - this.organizations.reload(); - } - }); + this.organizationActions.create().subscribe(() => { + this.organizations.reload(); + }); + } + + modify(org: domain.Organization): void { + this.organizationActions.modify(org).subscribe(() => { + this.organizations.reload(); + }); + } + + deactivate(org: domain.Organization): void { + this.organizationActions.deactivate(org).subscribe(() => { + this.organizations.reload(); + }); + } + + activate(org: domain.Organization): void { + this.organizationActions.activate(org).subscribe(() => { + this.organizations.reload(); + }); } resetFilters(): void { diff --git a/src/app/organizations/index.ts b/src/app/organizations/index.ts index 5c04b4f48..e7649fe5a 100644 --- a/src/app/organizations/index.ts +++ b/src/app/organizations/index.ts @@ -1,4 +1,6 @@ export * from './components/organizations-list/organizations-list.component'; export * from './components/create-organization-dialog'; +export * from './components/modify-organization-dialog'; export * from './routing-config'; export * from './organizations.routes'; +export * from './services'; diff --git a/src/app/organizations/services/index.ts b/src/app/organizations/services/index.ts new file mode 100644 index 000000000..cae4338a6 --- /dev/null +++ b/src/app/organizations/services/index.ts @@ -0,0 +1 @@ +export * from './organization-actions.service'; diff --git a/src/app/organizations/services/organization-actions.service.ts b/src/app/organizations/services/organization-actions.service.ts new file mode 100644 index 000000000..ad617f6c0 --- /dev/null +++ b/src/app/organizations/services/organization-actions.service.ts @@ -0,0 +1,89 @@ +import { EMPTY } from 'rxjs'; +import { catchError, filter, switchMap, tap } from 'rxjs/operators'; + +import { Injectable, inject } from '@angular/core'; + +import { + ConfirmDialogComponent, + DialogResponseStatus, + DialogService, + NotifyLogService, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; + +import { + CreateOrganizationDialogComponent, + CreateOrganizationDialogData, +} from '../components/create-organization-dialog'; +import { ModifyOrganizationDialogComponent } from '../components/modify-organization-dialog'; + +@Injectable({ + providedIn: 'root', +}) +export class OrganizationActionsService { + private dialogService = inject(DialogService); + private organizationsService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + + create(data?: CreateOrganizationDialogData) { + return this.dialogService + .open(CreateOrganizationDialogComponent, data) + .afterClosed() + .pipe(filter((res) => res?.status === 'success')); + } + + modify(org: domain.Organization) { + return this.dialogService + .open(ModifyOrganizationDialogComponent, { organization: org }) + .afterClosed() + .pipe(filter((res) => res?.status === 'success')); + } + + deactivate(org: domain.Organization) { + return this.dialogService + .open(ConfirmDialogComponent, { + title: `Deactivate organization "${org.name}"`, + confirmLabel: 'Deactivate', + }) + .afterClosed() + .pipe( + filter(({ status }) => status === DialogResponseStatus.Success), + switchMap(() => + this.organizationsService.DeactivateOrganization(org.id).pipe( + catchError((err) => { + this.log.error(err); + return EMPTY; + }), + ), + ), + tap(() => { + this.log.success('Organization deactivated'); + }), + ); + } + + activate(org: domain.Organization) { + return this.dialogService + .open(ConfirmDialogComponent, { + title: `Activate organization "${org.name}"`, + confirmLabel: 'Activate', + }) + .afterClosed() + .pipe( + filter(({ status }) => status === DialogResponseStatus.Success), + switchMap(() => + this.organizationsService.ActivateOrganization(org.id).pipe( + catchError((err) => { + this.log.error(err); + return EMPTY; + }), + ), + ), + tap(() => { + this.log.success('Organization activated'); + }), + ); + } +} diff --git a/src/app/parties/party/party-details/party-details.component.html b/src/app/parties/party/party-details/party-details.component.html index f7e5f2896..c8d0e4000 100644 --- a/src/app/parties/party/party-details/party-details.component.html +++ b/src/app/parties/party/party-details/party-details.component.html @@ -23,6 +23,18 @@ Organization + @if (organization.value(); as org) { +
+ + @if (org.status === orgStatus.active) { + + } @else { + + } +
+ }
@if (organization.value(); as org) { diff --git a/src/app/parties/party/party-details/party-details.component.ts b/src/app/parties/party/party-details/party-details.component.ts index 6a8ace58b..424aa0e44 100644 --- a/src/app/parties/party/party-details/party-details.component.ts +++ b/src/app/parties/party/party-details/party-details.component.ts @@ -10,11 +10,13 @@ import { VersionedObject } from '@vality/domain-proto/domain_config_v2'; import { NotifyLogService, observableResource } from '@vality/matez'; import { ThriftViewerModule } from '@vality/ng-thrift'; import { metadata$ as orgManagementMetadata$ } from '@vality/org-management-proto'; +import { domain } from '@vality/org-management-proto/admin_management'; import { DomainService } from '~/api/domain-config'; import { PageLayoutModule } from '~/components/page-layout'; import { DomainThriftViewerComponent } from '~/components/thrift-api-crud'; +import { OrganizationActionsService } from '../../../organizations'; import { PartyStoreService } from '../party-store.service'; @Component({ @@ -34,7 +36,9 @@ export class PartyDetailsComponent { private partyStoreService = inject(PartyStoreService); private domainService = inject(DomainService); private log = inject(NotifyLogService); + private organizationActions = inject(OrganizationActionsService); + orgStatus = domain.OrganizationStatus; orgMetadata$ = orgManagementMetadata$; party = observableResource({ @@ -55,4 +59,22 @@ export class PartyDetailsComponent { createOrganization(): void { this.partyStoreService.createOrganization(); } + + modifyOrganization(org: domain.Organization): void { + this.organizationActions.modify(org).subscribe(() => { + this.organization.reload(); + }); + } + + deactivateOrganization(org: domain.Organization): void { + this.organizationActions.deactivate(org).subscribe(() => { + this.organization.reload(); + }); + } + + activateOrganization(org: domain.Organization): void { + this.organizationActions.activate(org).subscribe(() => { + this.organization.reload(); + }); + } } From 56151ccdd1b1266246612643a939ba3538b258ea Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:24:23 +0800 Subject: [PATCH 22/22] feat: update withdrawal dialog subscription to reload on successful response --- src/app/withdrawals/withdrawals.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/withdrawals/withdrawals.component.ts b/src/app/withdrawals/withdrawals.component.ts index 93203cd3a..5a6b1d52a 100644 --- a/src/app/withdrawals/withdrawals.component.ts +++ b/src/app/withdrawals/withdrawals.component.ts @@ -198,8 +198,8 @@ export class WithdrawalsComponent implements OnInit { withdrawals: this.selected, }) .afterClosed() - .subscribe(() => { - this.reload(); + .subscribe((res) => { + if (res.status === DialogResponseStatus.Success) this.reload(); }); }