feat: Partner hierarchy backoffice page (F41-F44 partial)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

New nav entry, route, page. Renders the four-level tree
(Company/Project/Store/PaymentPoint) via AdminPartnerHierarchyFacade on top
of the core built in the previous commit - TEST/LIVE toggle, suspend/activate,
disable behind a confirmation (terminal + cascading, per contract §2), and a
read-only credentials list stating plainly that only the public key is ever
held.

Reload-after-action rather than local patch on suspend/activate/disable:
disable cascades to descendants server-side, so a local patch would leave
children showing a status they no longer have.

Company id is a constant for now (DEFAULT_COMPANY_ID) - company creation is
out of band per contract §4.3, and there is no company-selector UI yet.

Verified: 139/139 unit tests, arch:check clean, production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-18 21:00:46 +04:00
parent 7e7a015ff6
commit a116c4f592
10 changed files with 462 additions and 0 deletions

View File

@@ -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),

View File

@@ -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<Environment>('TEST');
readonly nodes = signal<ProvisioningNode[]>([]);
readonly credentials = signal<PartnerCredential[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(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<HierarchyRow[]>(() => {
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<typeof this.gateway.disable>): 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.'),
});
}
}

View File

@@ -0,0 +1,111 @@
<div class="partner-hierarchy-page">
<header class="partner-hierarchy-page__header">
<h1>Partner hierarchy</h1>
<p>
Company &rarr; Project &rarr; Store &rarr; 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.
</p>
</header>
<div class="partner-hierarchy-page__toolbar">
<div class="partner-hierarchy-page__envs" role="group" aria-label="Environment">
@for (env of environments; track env) {
<button
type="button"
class="partner-hierarchy-page__env"
[class.partner-hierarchy-page__env--active]="facade.environment() === env"
[attr.aria-pressed]="facade.environment() === env"
(click)="facade.setEnvironment(env)">
{{ env }}
</button>
}
</div>
<p class="partner-hierarchy-page__summary">
{{ facade.rows().length }} nodes &middot;
{{ facade.paymentPointCount() }} payment points &middot;
{{ facade.suspendedCount() }} suspended
</p>
</div>
@if (facade.error(); as message) {
<p class="partner-hierarchy-page__error" role="alert">{{ message }}</p>
}
@if (facade.loading()) {
<p class="partner-hierarchy-page__empty">Loading&hellip;</p>
} @else if (facade.rows().length === 0) {
<p class="partner-hierarchy-page__empty">
No nodes provisioned in {{ facade.environment() }} yet.
</p>
} @else {
<ul class="partner-hierarchy-page__tree">
@for (row of facade.rows(); track row.node.id) {
<li class="partner-hierarchy-page__row" [style.padding-left]="indentFor(row.depth)">
<div class="partner-hierarchy-page__node">
<span class="partner-hierarchy-page__level">{{ levelLabel(row.node.level) }}</span>
<strong class="partner-hierarchy-page__name">{{ row.node.displayName }}</strong>
<app-badge [variant]="badgeVariantFor(row.node.status)">{{ row.node.status }}</app-badge>
<code class="partner-hierarchy-page__ref">{{ row.node.externalReference }}</code>
</div>
<div class="partner-hierarchy-page__actions">
@if (row.node.status === 'active') {
<button type="button" (click)="facade.suspend(row.node.id)">Suspend</button>
}
@if (row.node.status === 'suspended') {
<button type="button" (click)="facade.activate(row.node.id)">Activate</button>
}
@if (row.node.status !== 'disabled') {
<button
type="button"
class="partner-hierarchy-page__danger"
(click)="requestDisable(row.node)">
Disable
</button>
}
</div>
</li>
}
</ul>
}
<section class="partner-hierarchy-page__credentials">
<h2>API credentials</h2>
<p>
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.
</p>
@if (facade.credentials().length === 0) {
<p class="partner-hierarchy-page__empty">No credentials registered.</p>
} @else {
<ul class="partner-hierarchy-page__creds">
@for (credential of facade.credentials(); track credential.keyId) {
<li>
<code>{{ credential.keyId }}</code>
<app-badge [variant]="credential.status === 'active' ? 'success' : 'neutral'">
{{ credential.status }}
</app-badge>
<span>scope: {{ credential.scopeNodeId }}</span>
<span>{{ credential.algorithm }}</span>
<span>{{ credential.environment }}</span>
</li>
}
</ul>
}
</section>
</div>
<app-confirm-dialog
[open]="pendingDisable() !== null"
titleText="Disable this node?"
[message]="
'Disabling is permanent and cascades to every node underneath it. ' +
'It cannot be undone - re-provisioning creates a new node with a new id.'
"
confirmLabel="Disable"
[destructive]="true"
(confirmed)="confirmDisable()"
(cancelled)="cancelDisable()" />

View File

@@ -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);
}
}
}

View File

@@ -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<ProvisioningNode | null>(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);
}
}

View File

@@ -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'] },

View File

@@ -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',

View File

@@ -2052,6 +2052,7 @@ export const hy: Translations = {
integrations: 'Ինտեգրումներ',
finance: 'Վճարումներ և ֆինանսներ',
marketplaces: 'Մարկետփլեյսներ',
partnerHierarchy: 'Գործընկերոջ հիերարխիա',
audit: 'Աուդիտ և անվտանգություն',
transactions: 'Գործարքներ',
reviews: 'Կարծիքներ',

View File

@@ -2052,6 +2052,7 @@ export const ru: Translations = {
integrations: 'Интеграции',
finance: 'Платежи и финансы',
marketplaces: 'Маркетплейсы',
partnerHierarchy: 'Иерархия партнёра',
audit: 'Аудит и безопасность',
transactions: 'Транзакции',
reviews: 'Отзывы',

View File

@@ -2067,6 +2067,7 @@ export interface Translations {
integrations: string;
finance: string;
marketplaces: string;
partnerHierarchy: string;
audit: string;
reports: string;
partnersGroup: string;