diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 93ce6f1..d021c94 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -228,6 +228,15 @@ const coreRoutes: Routes = [ breadcrumb: [{ labelKey: 'adminShell.nav.marketplaces' }] } }, + { + path: 'partner-hierarchy', + loadComponent: () => import('./features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component').then(m => m.AdminPartnerHierarchyPageComponent), + data: { + titleKey: 'adminShell.nav.partnerHierarchy', + descriptionKey: 'adminShell.nav.partnerHierarchy', + breadcrumb: [{ labelKey: 'adminShell.nav.partnerHierarchy' }] + } + }, { path: 'audit', loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent), diff --git a/src/app/features/admin/partner-hierarchy/facade/admin-partner-hierarchy.facade.ts b/src/app/features/admin/partner-hierarchy/facade/admin-partner-hierarchy.facade.ts new file mode 100644 index 0000000..9aab67c --- /dev/null +++ b/src/app/features/admin/partner-hierarchy/facade/admin-partner-hierarchy.facade.ts @@ -0,0 +1,135 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { take } from 'rxjs/operators'; +import { PARTNER_HIERARCHY_GATEWAY } from '../../../../core/partner-hierarchy/services/partner-hierarchy-gateway.token'; +import { PartnerCredential } from '../../../../core/partner-hierarchy/models/partner-credential.model'; +import { + Environment, + NodeLevel, + ProvisioningNode, + childLevelOf, + childrenOf, +} from '../../../../core/partner-hierarchy/models/provisioning-node.model'; + +/** One node plus its depth, flattened for rendering a tree as a list. */ +export interface HierarchyRow { + node: ProvisioningNode; + depth: number; + /** The level a child of this node would be, or null at the leaf. */ + childLevel: NodeLevel | null; +} + +/** + * Read/act surface for the partner-provisioned hierarchy. + * Contract: docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4. + * + * The company id is currently a constant: Company creation is out of band + * (§4.3 - partners provision INSIDE a company we create commercially), and + * there is no company-selector UI yet. When one exists this becomes an input. + */ +const DEFAULT_COMPANY_ID = 'cmp_1'; + +@Injectable({ providedIn: 'root' }) +export class AdminPartnerHierarchyFacade { + private readonly gateway = inject(PARTNER_HIERARCHY_GATEWAY); + + readonly companyId = signal(DEFAULT_COMPANY_ID); + readonly environment = signal('TEST'); + readonly nodes = signal([]); + readonly credentials = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + + /** + * Depth-first flattening, so the template renders a tree without recursion. + * Roots are nodes whose parent is absent from the loaded set - not + * necessarily level 'company', so a partially-loaded subtree still renders + * instead of coming back empty. + */ + readonly rows = computed(() => { + const all = this.nodes(); + const ids = new Set(all.map(n => n.id)); + const roots = all + .filter(n => n.parentId === null || !ids.has(n.parentId)) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); + + const rows: HierarchyRow[] = []; + const walk = (node: ProvisioningNode, depth: number): void => { + rows.push({ node, depth, childLevel: childLevelOf(node.level) }); + for (const child of childrenOf(node.id, all)) { + walk(child, depth + 1); + } + }; + roots.forEach(root => walk(root, 0)); + return rows; + }); + + readonly paymentPointCount = computed( + () => this.nodes().filter(n => n.level === 'payment_point').length, + ); + + readonly suspendedCount = computed( + () => this.nodes().filter(n => n.status === 'suspended').length, + ); + + load(): void { + this.loading.set(true); + this.error.set(null); + + this.gateway + .loadHierarchy(this.companyId(), this.environment()) + .pipe(take(1)) + .subscribe({ + next: nodes => { + this.nodes.set(nodes); + this.loading.set(false); + }, + error: () => { + // Empty the tree rather than leaving the previous environment's + // nodes on screen labelled as the newly selected one. + this.nodes.set([]); + this.loading.set(false); + this.error.set('Could not load the hierarchy.'); + }, + }); + + this.gateway + .loadCredentials(this.companyId()) + .pipe(take(1)) + .subscribe({ + next: credentials => this.credentials.set(credentials), + error: () => this.credentials.set([]), + }); + } + + setEnvironment(environment: Environment): void { + if (environment === this.environment()) { + return; + } + this.environment.set(environment); + this.load(); + } + + suspend(nodeId: string): void { + this.act(this.gateway.setStatus(nodeId, 'suspended')); + } + + activate(nodeId: string): void { + this.act(this.gateway.setStatus(nodeId, 'active')); + } + + /** Terminal and cascading - callers must confirm with the operator first. */ + disable(nodeId: string): void { + this.act(this.gateway.disable(nodeId)); + } + + private act(action: ReturnType): void { + this.error.set(null); + action.pipe(take(1)).subscribe({ + // Reload rather than patching one node locally: disable cascades to + // descendants server-side, so a local patch would leave children + // showing a status they no longer have. + next: () => this.load(), + error: (err: Error) => this.error.set(err.message || 'Action failed.'), + }); + } +} diff --git a/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.html b/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.html new file mode 100644 index 0000000..82c20f0 --- /dev/null +++ b/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.html @@ -0,0 +1,111 @@ +
+
+

Partner hierarchy

+

+ Company → Project → Store → Payment point, as provisioned through the + partner API. This is the same view a partner sees, read through the same endpoints, + so it cannot drift from what they have. +

+
+ +
+
+ @for (env of environments; track env) { + + } +
+ +

+ {{ facade.rows().length }} nodes · + {{ facade.paymentPointCount() }} payment points · + {{ facade.suspendedCount() }} suspended +

+
+ + @if (facade.error(); as message) { + + } + + @if (facade.loading()) { +

Loading…

+ } @else if (facade.rows().length === 0) { +

+ No nodes provisioned in {{ facade.environment() }} yet. +

+ } @else { +
    + @for (row of facade.rows(); track row.node.id) { +
  • +
    + {{ levelLabel(row.node.level) }} + {{ row.node.displayName }} + {{ row.node.status }} + {{ row.node.externalReference }} +
    + +
    + @if (row.node.status === 'active') { + + } + @if (row.node.status === 'suspended') { + + } + @if (row.node.status !== 'disabled') { + + } +
    +
  • + } +
+ } + +
+

API credentials

+

+ We hold the public key only. The private half is generated by the partner and is never + transmitted to us, never stored here, never logged. +

+ + @if (facade.credentials().length === 0) { +

No credentials registered.

+ } @else { +
    + @for (credential of facade.credentials(); track credential.keyId) { +
  • + {{ credential.keyId }} + + {{ credential.status }} + + scope: {{ credential.scopeNodeId }} + {{ credential.algorithm }} + {{ credential.environment }} +
  • + } +
+ } +
+
+ + diff --git a/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.scss b/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.scss new file mode 100644 index 0000000..7b806c1 --- /dev/null +++ b/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.scss @@ -0,0 +1,138 @@ +.partner-hierarchy-page { + display: flex; + flex-direction: column; + gap: 24px; + + &__header p { + max-width: 70ch; + color: var(--text-secondary); + } + + &__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + &__envs { + display: flex; + gap: 4px; + } + + &__env { + padding: 6px 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: transparent; + color: inherit; + cursor: pointer; + + &--active { + // Environment is a hard partition (contract §3), so the active one is + // stated with a filled background rather than a subtle outline - picking + // the wrong one here is not a cosmetic mistake. + background: var(--primary-color); + color: var(--primary-contrast, #fff); + border-color: var(--primary-color); + } + } + + &__summary { + margin: 0; + color: var(--text-secondary); + } + + &__error { + margin: 0; + padding: 12px 16px; + border: 1px solid var(--danger-color, #b3261e); + border-radius: var(--radius-md); + color: var(--danger-color, #b3261e); + } + + &__empty { + margin: 0; + color: var(--text-secondary); + } + + &__tree, + &__creds { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + &__row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + } + + &__node { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + } + + &__level { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + } + + &__ref { + font-size: 0.8rem; + color: var(--text-secondary); + } + + &__actions { + display: flex; + gap: 6px; + + button { + padding: 4px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm, 6px); + background: transparent; + color: inherit; + cursor: pointer; + } + } + + &__danger { + border-color: var(--danger-color, #b3261e) !important; + color: var(--danger-color, #b3261e); + } + + &__credentials { + padding-top: 8px; + border-top: 1px solid var(--border-color); + + p { + max-width: 70ch; + color: var(--text-secondary); + } + + li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 8px 16px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + } + } +} diff --git a/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.ts b/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.ts new file mode 100644 index 0000000..d889ad0 --- /dev/null +++ b/src/app/features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AdminPartnerHierarchyFacade } from '../facade/admin-partner-hierarchy.facade'; +import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; +import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component'; +import { Environment, NodeStatus, ProvisioningNode } from '../../../../core/partner-hierarchy/models/provisioning-node.model'; + +@Component({ + selector: 'app-admin-partner-hierarchy-page', + standalone: true, + imports: [CommonModule, BadgeComponent, ConfirmDialogComponent], + templateUrl: './admin-partner-hierarchy-page.component.html', + styleUrls: ['./admin-partner-hierarchy-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminPartnerHierarchyPageComponent { + readonly facade = inject(AdminPartnerHierarchyFacade); + + readonly environments: Environment[] = ['TEST', 'LIVE']; + + /** Node pending a disable confirmation, or null when no dialog is open. */ + readonly pendingDisable = signal(null); + + constructor() { + this.facade.load(); + } + + /** Indentation for a tree rendered as a flat list. */ + indentFor(depth: number): string { + return `${depth * 1.5}rem`; + } + + badgeVariantFor(status: NodeStatus): 'success' | 'warning' | 'neutral' { + switch (status) { + case 'active': return 'success'; + case 'suspended': return 'warning'; + case 'disabled': return 'neutral'; + } + } + + levelLabel(level: string): string { + return level.replace('_', ' '); + } + + /** + * Disable is terminal and cascades to every descendant, so it always goes + * through a confirmation - there is no undo on the other side of it. + */ + requestDisable(node: ProvisioningNode): void { + this.pendingDisable.set(node); + } + + confirmDisable(): void { + const node = this.pendingDisable(); + if (node) { + this.facade.disable(node.id); + } + this.pendingDisable.set(null); + } + + cancelDisable(): void { + this.pendingDisable.set(null); + } +} diff --git a/src/app/features/admin/shell/admin-nav.model.ts b/src/app/features/admin/shell/admin-nav.model.ts index 1a4396b..8c31e1c 100644 --- a/src/app/features/admin/shell/admin-nav.model.ts +++ b/src/app/features/admin/shell/admin-nav.model.ts @@ -32,6 +32,7 @@ export type AdminNavEntry = AdminNavLink | AdminNavGroup | AdminNavAction; export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [ { type: 'link', id: 'dashboard', icon: 'home', labelKey: 'adminShell.nav.dashboard', path: ['dashboard'] }, { type: 'link', id: 'marketplaces', icon: 'network', labelKey: 'adminShell.nav.marketplaces', path: ['marketplaces'] }, + { type: 'link', id: 'partnerHierarchy', icon: 'network', labelKey: 'adminShell.nav.partnerHierarchy', path: ['partner-hierarchy'] }, { type: 'group', labelKey: 'adminShell.nav.catalogGroup' }, { type: 'link', id: 'products', icon: 'package', labelKey: 'adminShell.nav.products', path: ['products'] }, { type: 'link', id: 'categories', icon: 'tags', labelKey: 'adminShell.nav.categories', path: ['categories'] }, diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index b690528..7022871 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -2058,6 +2058,7 @@ export const en: Translations = { integrations: 'Integrations', finance: 'Payments & Finance', marketplaces: 'Marketplaces', + partnerHierarchy: 'Partner hierarchy', audit: 'Audit & Security', reviews: 'Reviews', reports: 'Reports', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 4f2208e..3f95e06 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -2052,6 +2052,7 @@ export const hy: Translations = { integrations: 'Ինտեգրումներ', finance: 'Վճարումներ և ֆինանսներ', marketplaces: 'Մարկետփլեյսներ', + partnerHierarchy: 'Գործընկերոջ հիերարխիա', audit: 'Աուդիտ և անվտանգություն', transactions: 'Գործարքներ', reviews: 'Կարծիքներ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 3958031..1950cdf 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -2052,6 +2052,7 @@ export const ru: Translations = { integrations: 'Интеграции', finance: 'Платежи и финансы', marketplaces: 'Маркетплейсы', + partnerHierarchy: 'Иерархия партнёра', audit: 'Аудит и безопасность', transactions: 'Транзакции', reviews: 'Отзывы', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index fdecb2b..44d736b 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -2067,6 +2067,7 @@ export interface Translations { integrations: string; finance: string; marketplaces: string; + partnerHierarchy: string; audit: string; reports: string; partnersGroup: string;