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/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ff069f006 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +## 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` 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/package-lock.json b/package-lock.json index e256ed69f..f27476ace 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-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", @@ -36,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", @@ -66,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", @@ -5010,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, @@ -5753,6 +5781,12 @@ "resolved": "projects/ng-thrift", "link": true }, + "node_modules/@vality/org-management-proto": { + "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": { "version": "2.0.2-07b2a51.0", "resolved": "https://registry.npmjs.org/@vality/repairer-proto/-/repairer-proto-2.0.2-07b2a51.0.tgz", @@ -10349,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", @@ -11176,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", @@ -11193,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 ffd8409fc..27fef7a18 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-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", @@ -51,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", @@ -81,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", diff --git a/projects/matez/src/lib/components/nav/nav.component.html b/projects/matez/src/lib/components/nav/nav.component.html index d7869c5b7..61173c2ca 100644 --- a/projects/matez/src/lib/components/nav/nav.component.html +++ b/projects/matez/src/lib/components/nav/nav.component.html @@ -9,7 +9,9 @@ @for (link of linkGroup.children; track link) { @let isActive = activeLinks.includes(link); - @if (link.children) { + @if (link.divider) { + + } @else if (link.children) { + @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/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/projects/matez/src/lib/components/value/value.component.html b/projects/matez/src/lib/components/value/value.component.html index 6c95c4303..9ed57d26d 100644 --- a/projects/matez/src/lib/components/value/value.component.html +++ b/projects/matez/src/lib/components/value/value.component.html @@ -66,7 +66,10 @@ 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/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..b9d0a8e69 --- /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" + } + } +} diff --git a/src/api/services.ts b/src/api/services.ts index 14bf21c42..c7f0c0a2c 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 } = @@ -210,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..d1792d14d 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'; @@ -110,6 +111,7 @@ const createNavLinks = (): Link[] => [ isHidden: isHidden(APP_ROUTES.parties.root.config.services), children: isPartyPath ? [ + { label: 'Details', url: `${partyPath}/details` }, { label: 'Shops', url: `${partyPath}/shops`, @@ -118,6 +120,16 @@ const createNavLinks = (): Link[] => [ label: 'Wallets', url: `${partyPath}/wallets`, }, + { divider: true }, + { + label: 'Members', + url: `${partyPath}/members`, + }, + { + label: 'Invitations', + url: `${partyPath}/invitations`, + }, + { divider: true }, { label: 'Shop webhooks', url: `${partyPath}/shop-webhooks`, @@ -126,6 +138,7 @@ const createNavLinks = (): Link[] => [ label: 'Wallet webhooks', url: `${partyPath}/wallet-webhooks`, }, + { divider: true }, { label: 'Payment RR', url: `${partyPath}/routing-rules/payment/main`, @@ -140,6 +153,11 @@ const createNavLinks = (): Link[] => [ : [], }; }, + { + label: 'Organizations', + url: '/organizations', + isHidden: isHidden(ORGANIZATIONS_ROUTING_CONFIG.services), + }, { label: 'Shops', url: '/shops', diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a69e0e1fb..c82be9597 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,13 @@ export const appConfig: ApplicationConfig = { provideKeycloak({ config: './assets/authConfig.json' as never, initOptions: { onLoad: 'login-required', checkLoginIframe: !isDevMode() }, + features: [ + withAutoRefreshToken({ + onInactivityTimeout: 'login', + sessionTimeout: 4 * 60 * 60_000, + }), + ], + providers: [AutoRefreshTokenService, UserActivityService], }), provideHttpClient(withXhr(), withInterceptorsFromDi()), { provide: MAT_DATE_FORMATS, useValue: DEFAULT_MAT_DATE_FORMATS }, 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/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/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/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..e2f682dfd --- /dev/null +++ b/src/app/organizations/components/organizations-list/organizations-list.component.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + 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..71a2c0828 --- /dev/null +++ b/src/app/organizations/components/organizations-list/organizations-list.component.ts @@ -0,0 +1,205 @@ +import isEqual from 'lodash-es/isEqual'; +import { of } from 'rxjs'; +import { catchError, distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; + +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, + FiltersModule, + InputFieldModule, + NotifyLogService, + Option, + QueryParamsService, + SelectFieldModule, + TableResourceComponent, + clean, + countChanged, + createMenuColumn, + debounceTimeWithFirst, + pagedObservableResource, +} 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'; + +import { OrganizationActionsService } from '../../services'; + +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: [ + MatButtonModule, + 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); + private organizationActions = inject(OrganizationActionsService); + + statusOptions: Option[] = [ + { label: 'Active', value: domain.OrganizationStatus.active }, + { label: 'Deactivated', value: domain.OrganizationStatus.deactivated }, + ]; + + filters = signal({ + 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(() => countChanged(this.filters(), DEFAULT_FILTERS)); + + 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[] = [ + { + field: 'id', + cell: (org) => ({ value: org.id }), + }, + { + field: 'name', + cell: (org) => ({ + value: org.name, + link: () => `/parties/${org.party_id}/members`, + }), + }, + createPartyColumn((org) => ({ id: org.party_id })), + { + field: 'owner_id', + header: 'Owner', + cell: (org) => ({ value: org.owner_id }), + }, + { + field: 'status', + cell: (org) => ({ + value: + org.status === domain.OrganizationStatus.active + ? 'Active' + : org.status === domain.OrganizationStatus.deactivated + ? 'Deactivated' + : String(org.status), + color: org.status === domain.OrganizationStatus.active ? 'success' : 'warn', + }), + }, + { + field: 'created_at', + header: 'Created at', + cell: (org) => ({ value: org.created_at, type: 'datetime' }), + }, + createMenuColumn((org) => ({ + items: [ + { + label: 'Details', + click: () => this.router.navigate([`/parties/${org.party_id}/details`]), + }, + { + label: 'Members', + click: () => this.router.navigate([`/parties/${org.party_id}/members`]), + }, + { + 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), + }, + ], + })), + ]; + + constructor() { + this.filters$.pipe(takeUntilDestroyed()).subscribe((filters) => { + void this.qp.set(clean(filters)); + }); + } + + create(): void { + 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 { + this.filters.set(DEFAULT_FILTERS); + } +} diff --git a/src/app/organizations/index.ts b/src/app/organizations/index.ts new file mode 100644 index 000000000..e7649fe5a --- /dev/null +++ b/src/app/organizations/index.ts @@ -0,0 +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/organizations.routes.ts b/src/app/organizations/organizations.routes.ts new file mode 100644 index 000000000..25e01f9fb --- /dev/null +++ b/src/app/organizations/organizations.routes.ts @@ -0,0 +1,15 @@ +import { Routes } from '@angular/router'; + +import { canActivateAuthRole } from '~/services'; + +import { OrganizationsListComponent } from './components/organizations-list/organizations-list.component'; +import { ROUTING_CONFIG } from './routing-config'; + +export const ORGANIZATIONS_ROUTES: Routes = [ + { + path: '', + component: OrganizationsListComponent, + canActivate: [canActivateAuthRole], + data: ROUTING_CONFIG, + }, +]; 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], +}; 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/parties.component.ts b/src/app/parties/parties.component.ts index f2265e689..cf03e1a7f 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,17 @@ 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, + link: () => `/parties/${party.ref.id}/members`, + })), + ), + }, { 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..887e7cd91 --- /dev/null +++ b/src/app/parties/party/invitations/invitations.component.html @@ -0,0 +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 new file mode 100644 index 000000000..774ee60f5 --- /dev/null +++ b/src/app/parties/party/invitations/invitations.component.ts @@ -0,0 +1,104 @@ +import { of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; + +import { + Column, + NotifyLogService, + TableResourceComponent, + pagedObservableResource, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; +import { PageLayoutModule } from '~/components/page-layout'; + +import { PartyStoreService } from '../party-store.service'; + +@Component({ + selector: 'cc-invitations', + templateUrl: './invitations.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButtonModule, PageLayoutModule, TableResourceComponent], +}) +export class InvitationsComponent { + private thriftOrgManagementService = inject(ThriftOrganizationManagementService); + private log = inject(NotifyLogService); + private partyStoreService = inject(PartyStoreService); + + organization = this.partyStoreService.organization; + + invitations = pagedObservableResource({ + params: computed(() => this.organization.value()?.id), + loader: (orgId, options) => + !orgId + ? of({ result: [] }) + : this.thriftOrgManagementService + .ListInvitations(orgId, { + limit: options.size, + continuation_token: options.continuationToken, + }) + .pipe( + map((res) => ({ + result: res.invitations, + continuationToken: res.continuation_token, + })), + catchError((err) => { + this.log.error(err); + return of({ result: [] }); + }), + ), + }); + + 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, type: 'datetime' }), + }, + { + field: 'expires_at', + header: 'Expires at', + cell: (inv) => ({ value: inv.expires_at, type: 'datetime' }), + }, + ]; + + createOrganization(): void { + this.partyStoreService.createOrganization(); + } +} 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..d5a86c896 --- /dev/null +++ b/src/app/parties/party/members/members.component.html @@ -0,0 +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 new file mode 100644 index 000000000..337502b9b --- /dev/null +++ b/src/app/parties/party/members/members.component.ts @@ -0,0 +1,76 @@ +import { of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; + +import { + Column, + NotifyLogService, + TableResourceComponent, + pagedObservableResource, +} from '@vality/matez'; +import { domain } from '@vality/org-management-proto/admin_management'; + +import { ThriftOrganizationManagementService } from '~/api/services'; +import { PageLayoutModule } from '~/components/page-layout'; + +import { PartyStoreService } from '../party-store.service'; + +@Component({ + selector: 'cc-members', + templateUrl: './members.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + 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: computed(() => this.organization.value()?.id), + loader: (orgId, options) => + !orgId + ? 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[] = [ + { + 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(', ') || '—', + }), + }, + ]; + + createOrganization(): void { + this.partyStoreService.createOrganization(); + } +} 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-details/index.ts b/src/app/parties/party/party-details/index.ts new file mode 100644 index 000000000..d0c04f9e8 --- /dev/null +++ b/src/app/parties/party/party-details/index.ts @@ -0,0 +1 @@ +export * from './party-details.component'; diff --git a/src/app/parties/party/party-details/party-details.component.html b/src/app/parties/party/party-details/party-details.component.html new file mode 100644 index 000000000..c8d0e4000 --- /dev/null +++ b/src/app/parties/party/party-details/party-details.component.html @@ -0,0 +1,56 @@ + +
+ + + Party + + + @if (party.value(); as partyObj) { + + } @else { +

Not found

+ } +
+
+ + + + Organization + @if (organization.value(); as org) { +
+ + @if (org.status === orgStatus.active) { + + } @else { + + } +
+ } +
+ + @if (organization.value(); as org) { + + } @else { +
+

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 new file mode 100644 index 000000000..424aa0e44 --- /dev/null +++ b/src/app/parties/party/party-details/party-details.component.ts @@ -0,0 +1,80 @@ +import { of } from 'rxjs'; +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 { PageLayoutModule } from '~/components/page-layout'; +import { DomainThriftViewerComponent } from '~/components/thrift-api-crud'; + +import { OrganizationActionsService } from '../../../organizations'; +import { PartyStoreService } from '../party-store.service'; + +@Component({ + selector: 'cc-party-details', + templateUrl: './party-details.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + PageLayoutModule, + DomainThriftViewerComponent, + ThriftViewerModule, + ], +}) +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({ + params: this.partyStoreService.id$, + loader: (partyId) => + partyId + ? this.domainService.get({ party_config: { id: partyId } }).pipe( + catchError((err) => { + this.log.error(err); + return of(null); + }), + ) + : of(null), + }); + + organization = this.partyStoreService.organization; + + 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(); + }); + } +} diff --git a/src/app/parties/party/party-routing.module.ts b/src/app/parties/party/party-routing.module.ts index a133a7e26..f9c1fe800 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'; @@ -19,6 +21,11 @@ import { ROUTING_CONFIG as WEBHOOKS_ROUTING_CONFIG } from './webhooks/routing-co canActivate: [canActivateAuthRole], data: ROUTING_CONFIG, children: [ + { + path: 'details', + loadComponent: () => + import('./party-details').then((m) => m.PartyDetailsComponent), + }, { path: 'shops', loadChildren: () => import('./shops').then((m) => m.PartyShopsModule), @@ -37,6 +44,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/app/parties/party/party-store.service.ts b/src/app/parties/party/party-store.service.ts index 528e2012c..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,19 +12,25 @@ 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), - map(({ partyID }) => partyID), + map(({ partyID }) => partyID as string), distinctUntilChanged(), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -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(); + } + }); + } } diff --git a/src/app/withdrawals/withdrawals.component.ts b/src/app/withdrawals/withdrawals.component.ts index 484e2e49f..5a6b1d52a 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((res) => { + if (res.status === DialogResponseStatus.Success) 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]); 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, }); } 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', } 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' }, 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 a066aea93..ed63f8025 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,93 +74,91 @@ 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 } : {}), + }, }); } -const logger: ConnectOptions['loggingFn'] = (params) => { - const info = `${params.name} (${params.namespace} ${params.serviceName})`; - - switch (params.type) { - case 'error': { - const parsedError = parseThriftError(params.error); - captureThriftError(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) { + keycloak.updateToken(-1).catch(() => 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( @@ -172,9 +170,9 @@ function createConnectOptions(serviceName: string) { token: keycloak.token ?? '', }), logging: true, - loggingFn: logger, + loggingFn, createCallOptions: () => ({ - headers: createRequestWachterHeaders(), + headers: createRequestWachterHeaders(keycloak.token), }), timeout: isDevMode() ? 15_000 : 60_000, ...config.api.wachter,