diff --git a/docs/implplan/SPRINT_20260315_006_Web_first_time_user_operator_journey_grouped_remediation.md b/docs/implplan/SPRINT_20260315_006_Web_first_time_user_operator_journey_grouped_remediation.md new file mode 100644 index 000000000..14dc14901 --- /dev/null +++ b/docs/implplan/SPRINT_20260315_006_Web_first_time_user_operator_journey_grouped_remediation.md @@ -0,0 +1,170 @@ +# Sprint 20260315_006 - First-Time User Operator Journey Grouped Remediation + +## Topic & Scope +- Turn the 54 findings in `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md` into a grouped remediation program instead of treating them as isolated page bugs. +- Reframe the Stella Ops QA loop around the real operator job: set up identity, trust, integrations, topology, and release confidence from the UI without source-code knowledge. +- Group defects by root cause and user journey: blank surfaces and route ownership, identity self-serve administration, trust/signing action design, onboarding and context guidance, and cross-cutting error/naming consistency. +- Working directory: `src/Web/StellaOps.Web`. +- Expected evidence: journey maps, grouped root-cause analysis, retained Playwright additions for newly discovered steps, focused regression coverage, live retest artifacts, and linked docs updates. + +Cross-module edits allowed for this sprint: +- `devops/compose/` +- `src/Platform/` +- `src/Authority/` +- `docs/qa/` +- `docs/operations/` +- `docs/modules/` + +## Dependencies & Concurrency +- Depends on the current intact live stack and the audit baseline in `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md`. +- Release-create contract repair in `SPRINT_20260315_005` is an immediate dependency because `/releases/versions/new` is one of the P0 findings and a critical operator journey. +- Safe parallelism: read-only discovery can continue in parallel, but mutations should be grouped by root cause so the same surfaces are not patched independently by multiple agents. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/operations/deployment/console.md` +- `docs/operations/deployment/docker.md` + +## Delivery Tracker + +### FTU-OPS-001 - Re-baseline the first-time user journey matrix before more fixes +Status: DONE +Dependency: none +Owners: QA, Product Manager +Task description: +- Convert the audit into an explicit operator journey matrix: setup/identity-access, setup/trust-signing, setup/integrations, setup/topology/system, releases, ops/operations, security, evidence, and admin affordances. Each route, page-load, and page action must be mapped to either retained Playwright coverage, an identified gap, or a grouped defect bucket before more implementation starts. + +Completion criteria: +- [ ] Every finding in `FIRST_TIME_USER_UX_AUDIT_20260315.md` is mapped to a route, journey, and root-cause bucket. +- [ ] Every route/page/action in the first-time operator journey is classified as covered, broken, or still requiring retained automation. +- [ ] The remediation order is driven by operator value and root cause, not by whichever page was most recently open. + +### FTU-OPS-002 - Repair the P0 blank-surface and route-contract blockers +Status: TODO +Dependency: FTU-OPS-001 +Owners: 3rd line support, Architect, Developer +Task description: +- Eliminate the three blank core surfaces and their contract mismatches: `/releases/versions/new`, `/releases/promotions`, and `/ops/operations`. Each route must render a truthful page shell, preserve user scope/context, and expose canonical guidance or actions rather than an empty `
`. + +Completion criteria: +- [ ] The release create surface is fully functional and lands on the created canonical resource. +- [ ] Promotions renders a real landing or list surface instead of an empty page. +- [ ] Operations landing renders a real overview and links into its child workflows. +- [ ] Retained Playwright journeys prove these pages render and their primary actions work on a live stack. + +### FTU-OPS-003 - Make identity and tenancy self-serve instead of source-code driven +Status: DONE +Dependency: FTU-OPS-001 +Owners: QA, Product Manager, Architect, Developer +Task description: +- Close the identity-admin gaps around roles, users, tenants, and scope discoverability. This includes a proper scope catalog/picker, role detail visibility, edit/delete/archive flows where allowed, least-privilege defaults, and explicit credential/onboarding guidance. + +Completion criteria: +- [ ] Role creation no longer depends on free-text scope knowledge. +- [ ] Existing roles can be understood from the UI through a detail view or equivalent surface. +- [ ] Users, roles, and tenants expose truthful edit/delete/archive semantics or explicit limitations. +- [ ] Add-user guidance explains credentials and defaults to least privilege. +- [ ] Retained Playwright coverage exercises the real create/view/edit flows. + +### FTU-OPS-004 - Repair trust/signing operator workflows and broken trust analytics +Status: DONE +Dependency: FTU-OPS-001 +Owners: QA, 3rd line support, Architect, Developer +Task description: +- Replace trust/signing admin anti-patterns with production-grade workflows. Broken analytics, raw `prompt()` destructive actions, no issuer actions, weak certificate affordances, and developer-note language all need to be corrected together so trust management feels operationally real. + +Completion criteria: +- [ ] Trust analytics loads correctly or shows a truthful error state with recovery guidance. +- [ ] Rotate/Revoke flows use real modals with reason capture and impact language. +- [ ] Issuers and certificates expose meaningful actions or explicit limitations. +- [ ] Trust copy is operator-facing rather than developer-facing. +- [ ] Retained Playwright journeys cover keys, issuers, certificates, analytics, and destructive-action confirmations. + +### FTU-OPS-005 - Align onboarding, context, empty states, and naming across the product +Status: TODO +Dependency: FTU-OPS-001 +Owners: Product Manager, Architect, Developer, Documentation author +Task description: +- Remove the cross-cutting confusion patterns: inconsistent page names, duplicate pages, silent API failures, misleading health/empty states, unexplained toggles, and missing onboarding guidance. This is a product-contract cleanup, not just a copy pass. + +Completion criteria: +- [ ] A first-time operator can discover the setup order from the product itself. +- [ ] Sidebar, breadcrumb, document title, and H1 use one name per surface. +- [ ] Silent API failures render truthful operator-facing error states. +- [ ] Empty states tell the operator what to do next. +- [ ] Retained Playwright journeys assert the corrected naming and error-state behavior on the affected routes. + +### FTU-OPS-006 - Expand retained Playwright to cover every newly discovered operator step +Status: DOING +Dependency: FTU-OPS-001 +Owners: QA, Test Automation +Task description: +- For every new route/page/action discovered during this operator remediation program, add retained Playwright coverage before the iteration closes. The retained suite must describe real operator journeys, not just route visits. + +Completion criteria: +- [ ] Every newly discovered operator step is either automated or explicitly logged as an open gap with reason. +- [ ] Aggregate audits include the new journey scripts. +- [ ] Future iterations would recheck the same first-time-user behavior automatically. + +## Grouped Remediation Matrix + +| Journey / Surface | Audit issues | Root-cause theme | Planned grouped repair | +| --- | --- | --- | --- | +| Releases and release confidence | P0-4, P0-5, CC-2, CC-3, P3-7 | Blank core routes, scope/context loss, inconsistent canonical route ownership | Finish release-create contract repair, restore promotions landing, preserve operator scope through release routes, add retained release-create and promotions journeys. | +| Operations landing and ops affordances | P0-6, P2-9, P2-10, P2-11, P2-21, P2-22, P3-11, P3-12 | Parent landing page missing, split canonical surfaces, contradictory status signals, weak empty/error guidance | Add truthful operations overview, cross-link notifications surfaces, fix contradictory runtime/status rendering, and retain the ops landing plus child actions as one operator journey. | +| Identity, roles, tenants, and access admin | P0-1, P0-2, P0-3, P1-1 through P1-7, P1-14, P2-1 through P2-6 | Identity admin is create-only and source-code dependent; permissions are undiscoverable; admin objects lack detail and lifecycle actions | Build scope catalog + picker, role detail surface, truthful CRUD/edit semantics, least-privilege defaults, and onboarding guidance; retain add/view/edit/delete journeys. | +| Trust and signing administration | P1-8, P1-9, P1-13, P2-7, P2-8, P3-2, P3-3, P3-4 | Broken analytics contract, destructive actions implemented as raw browser prompts, issuer/certificate workflows incomplete, operator copy not productized | Replace prompt flows with modal workflows, repair analytics API and UI states, add issuer/certificate affordances, and retain trust administration journeys end to end. | +| Onboarding, topology, and system setup | P1-10, P2-12, P2-13, P2-14, P2-15, P2-16, P2-17, P2-18, CC-4, CC-5, CC-6, CC-9 | Product does not teach setup order; system status and setup surfaces are misleading or under-explained | Introduce operator guidance/checklist, repair misleading health/status language, improve branding/topology explanations, and retain first-time setup journeys with seeded and empty states. | +| Security, evidence, naming, and error-state consistency | P1-11, P1-12, P2-19, P2-20, P3-5 through P3-10, CC-1, CC-7, CC-8, CC-10 | Naming contracts diverged across sidebar/title/H1, duplicate pages exist, API failures are silently swallowed, and demo tooling leaks into operator surfaces | Unify naming contracts, remove duplicate or dead-end routes, surface truthful error states, and retain the affected security/evidence journeys under one consistency sweep. | + +## 3rd-Line Support Findings +- Source-backed root cause: `/setup/identity-access` is still served by `AdminSettingsPageComponent`, a create-only administration surface with free-text permissions, no role detail, no edit flows, and no lifecycle actions. The Authority backend already exposes update, disable, suspend, resume, and impact-preview semantics, so the setup page is the limiting contract. +- Source-backed root cause: trust destructive actions still use raw `window.prompt(...)` in `signing-key-dashboard.component.ts`, which is not acceptable for signing-key rotation and revocation. +- Source-backed root cause: trust analytics calls `/api/v1/trust/analytics/*`, but the repo does not expose matching live backend endpoints. The current UI therefore presents a broken analytics tab instead of a truthful operational view. +- Source-backed root cause: issuer and certificate setup views intentionally omit actionable operator affordances and present that omission as contract-note copy, which reads like an internal developer limitation instead of a product workflow. +- Re-baselined audit note: the reported blank pages at `/releases/promotions` and `/ops/operations` are no longer source-backed. Current source already owns those routes with real components, so they must be revalidated live after deployment rather than treated as present-tense missing-page bugs. + +## Product / Architecture Decisions +- Decision: `Identity & Access` remains the canonical setup route, but it must surface the real Authority administration contract instead of a weaker create-only facade. +- Decision: unsupported hard-delete semantics will be handled truthfully. Where the backend supports update, disable, suspend, or resume, the UI must expose those actions. Where hard delete is not in contract, the UI must say so clearly and offer the supported lifecycle alternative. +- Decision: scope discoverability is a product requirement. Role create and edit flows must use a grouped in-app scope catalog with labels and descriptions instead of free-text scope entry. +- Decision: trust destructive actions must move from browser prompts to in-app confirmation workflows with reason capture, impact language, and visible success or error outcomes. +- Decision: trust analytics must not depend on dead endpoints. Until a richer analytics backend contract exists, the trust UI should derive operator-useful analytics from the live administration inventory instead of calling non-existent `/api/v1/trust/analytics/*` routes. + +## First Repair Order +- Batch 1: P0 blank surfaces and route-contract repair (`/releases/versions/new`, `/releases/promotions`, `/ops/operations`) because they block the main operator path. +- Batch 2: Identity self-serve administration because role/scope discoverability prevents safe delegated use of the product. +- Batch 3: Trust/signing workflows because broken analytics and raw prompt-based destructive actions block production readiness. +- Batch 4: Cross-cutting naming, error-state, onboarding, and consistency repair to remove repeated operator confusion after the core workflows are functional. + +## Active Implementation Batch +- Batch 2 and Batch 3 are closed on the current live stack. +- Closed issues in the grouped batch: P0-1, P0-2, P0-3, P1-1 through P1-9, P1-13, P1-14, P2-1 through P2-8, and P3-2 through P3-4. +- Remaining open batch for the next step: FTU-OPS-002 (P0 release/operations surfaces) and FTU-OPS-005 (cross-cutting naming, error-state, onboarding, and duplicate-surface repair). + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-15 | Sprint created from `docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md`, which documented 54 first-time-user issues across 40+ routes and showed that prior route- and journey-level closure claims were too narrow. | QA / Product Manager | +| 2026-03-15 | Adopted the audit as the remediation baseline. The release-create defect already in `SPRINT_20260315_005` is now treated as one P0 slice inside a broader grouped operator-remediation program. | QA / Product Manager | +| 2026-03-15 | Completed the 3rd-line support collapse of the UX audit into source-backed buckets. Confirmed identity self-serve and trust administration as the first major live source defects; reclassified promotions and operations blank-page claims as requiring live revalidation after deployment because current source already owns those routes. | 3rd line support | +| 2026-03-15 | Recorded the product and architecture decisions for the first grouped implementation batch: upgrade the setup identity surface to expose the real Authority admin contract, replace trust prompt-based actions with modal workflows, and stop relying on dead trust analytics endpoints. | Product / Architect | +| 2026-03-15 | Shipped the grouped identity/trust operator batch on the current live stack: scope catalog and role detail, truthful user and tenant lifecycle actions, in-app trust create/block/unblock/verify/revoke workflows, and derived trust analytics that no longer call dead endpoints. Focused backend/frontend test slices passed before live retest. | Developer | +| 2026-03-15 | Replaced the stale admin/trust retained journey with `live-user-reported-admin-trust-check.mjs`, added step-level logging, aligned it to the repaired trust shell contract, and reran it cleanly on `https://stella-ops.local` with `failedCheckCount=0`. | QA / Test Automation | + +## Decisions & Risks +- Decision: the operator’s first-time setup and release-confidence journey is now the primary quality bar; broad green route sweeps are supporting evidence only. +- Decision: findings will be fixed in grouped slices by root cause and journey, not one page at a time. +- Risk: prior retained Playwright coverage is biased toward route/action reachability and misses self-serve clarity, destructive-action design, scope discoverability, and onboarding guidance. +- Risk: some findings span frontend contracts, bootstrap auth configuration, and backend error handling, so frontend-only fixes may hide root causes instead of solving them. +- Risk: the Authority backend does not currently expose hard-delete semantics for users, roles, or tenants, so the audit expectation of delete or archive must be translated into truthful supported lifecycle actions rather than mirrored literally. +- Risk: the existing trust analytics UI assumes a backend contract that the repo does not implement. The derived-analytics fallback must remain obviously operator-focused and not pretend a richer backend exists. +- Evidence: current live-stack proof for the closed identity/trust batch is stored at `src/Web/StellaOps.Web/output/playwright/live-user-reported-admin-trust-check.json` with a full operator step log and `failedCheckCount=0`. + +## Next Checkpoints +- Close the active release-create P0 slice and fold it into the broader remediation status. +- Repair the remaining P0 release and operations surfaces on the intact stack before the next teardown. +- Expand retained Playwright again for the next operator batch before restarting the wipe/rebuild loop. diff --git a/docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md b/docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md new file mode 100644 index 000000000..aadc046b7 --- /dev/null +++ b/docs/qa/FIRST_TIME_USER_UX_AUDIT_20260315.md @@ -0,0 +1,313 @@ +# First-Time User UX Audit - 2026-03-15 + +**Auditor**: AI agent acting as first-time platform administrator +**Stack**: Live local (stella-ops.local), logged in as admin/Admin@Stella2026! +**Scope**: Every sidebar route and sub-tab, exercising create/edit/detail flows +**Sprint context**: SPRINT_20260315_003 (Identity/Trust) and SPRINT_20260315_004 (Integrations) + +--- + +## Summary + +- **Total routes inspected**: 40+ +- **P0 (critical UX blockers)**: 6 +- **P1 (significant UX gaps)**: 14 +- **P2 (moderate issues)**: 22 +- **P3 (minor polish)**: 12 +- **Cross-cutting patterns**: 10 + +--- + +## P0 - Critical UX Blockers + +### P0-1. Role permissions are free-text with no discoverability +**Route**: `/setup/identity-access` > Roles tab > + Create Role +**Detail**: The "Permissions" field is a plain ` - - -
- - -
+ + + +
+ + +
+ + @if (roleEditor) { +
+
+

{{ roleEditor.mode === 'create' ? 'Create Role' : 'Edit Role' }}

+
- } - @if (loading()) { -

Loading roles...

- } @else { - - - - - - - - - - - @for (role of roles(); track role.id) { - - - - - - - } @empty { - - } - -
NameDescriptionUsersBuilt-in
{{ role.name }}{{ role.description }}{{ role.userCount }}{{ role.isBuiltIn ? 'Yes' : 'No' }}
No roles found
- } + +
+ + +
+ +
+ @for (group of scopeCatalog; track group.id) { +
+

{{ group.label }}

+

{{ group.description }}

+ @for (item of group.items; track item.scope) { + + } +
+ } +
+ +
+ + +
+
+ } + +
+
+ @if (loading()) { +
Loading roles...
+ } @else { + + + + + + @for (role of filteredRoles(); track role.id) { + + + + + + + } @empty { + + } + +
RoleDescriptionUsersActions
{{ role.name }}
{{ role.isBuiltIn ? 'Built-in' : 'Custom' }}
{{ role.description }}{{ role.userCount }} +
+ + @if (!role.isBuiltIn) { + + } +
+
No roles match the current filters.
+ } +
+ +
} @case ('clients') { -
-
+
+

OAuth Clients

+

This setup tab stays read-only and points operators to the real client-management route.

-

OAuth clients are visible here, but registration and secret rotation remain outside this setup tab until the full guided flow is shipped.

- @if (loading()) { -

Loading clients...

- } @else { - - - - - - - - - - - - @for (client of clients(); track client.id) { - - - - - - - - } @empty { - - } - -
Client IDDisplay NameGrant TypesTenant ScopeStatus
{{ client.clientId }}{{ client.displayName }}{{ client.grantTypes.join(', ') }}{{ describeClientTenants(client) }}{{ client.status }}
No OAuth clients found
- } + Open Client Registrations
+
Register clients, rotate secrets, and change redirect settings under Console Admin → Clients.
+ @if (loading()) { +
Loading clients...
+ } @else { + + + + @for (client of clients(); track client.id) { + + + + + + + } @empty { + + } + +
ClientGrant TypesTenant ScopeStatus
{{ client.displayName }}
{{ client.clientId }}
{{ client.grantTypes.join(', ') }}{{ describeClientTenants(client) }}{{ client.status }}
No visible clients.
+ } } @case ('tokens') { -
-
+
+

API Tokens

+

Inventory lives here; issuance and revocation stay on the canonical token route.

-

Token issuance and revocation are not exposed on this setup route yet, so this view is intentionally read-only.

- @if (loading()) { -

Loading tokens...

- } @else { - - - - - - - - - - - - @for (token of tokens(); track token.id) { - - - - - - - - } @empty { - - } - -
NameClientScopesExpiresStatus
{{ token.name }}{{ token.clientId }}{{ token.scopes.join(', ') }}{{ token.expiresAt }}{{ token.status }}
No API tokens found
- } + Open Token Management
+
Issue and revoke API tokens under Console Admin → Tokens.
+ @if (loading()) { +
Loading tokens...
+ } @else { + + + + @for (token of tokens(); track token.id) { + + + + + + + + } @empty { + + } + +
NameClientScopesExpiresStatus
{{ token.name }}{{ token.clientId }}{{ token.scopes.join(', ') }}{{ token.expiresAt ? (token.expiresAt | date:'medium') : 'No expiry' }}{{ token.status }}
No visible tokens.
+ } } @case ('tenants') { -
-
+
+

Tenants

- +

Create tenants, explain isolation mode, and manage suspend or resume instead of leaving lifecycle blank.

- @if (addFormVisible() === 'tenants') { -
-

New Tenant

-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- } - @if (loading()) { -

Loading tenants...

- } @else { - - - - - - - - - - - - @for (tenant of tenants(); track tenant.id) { - - - - - - - - } @empty { - - } - -
NameStatusIsolationUsersLifecycle
{{ tenant.displayName }}{{ tenant.status }}{{ tenant.isolationMode }}{{ tenant.userCount }}Branding and policies are managed from the canonical setup surfaces.
No tenants found
- } +
+ +
+ + +
+ + @if (tenantEditor) { +
+
+

{{ tenantEditor.mode === 'create' ? 'Create Tenant' : 'Edit Tenant' }}

+ +
+
+ + + +
+
+ + +
+
+ } + + @if (loading()) { +
Loading tenants...
+ } @else { + + + + @for (tenant of filteredTenants(); track tenant.id) { + + + + + + + + } @empty { + + } + +
TenantStatusIsolationUsersActions
{{ tenant.displayName }}
{{ tenant.id }}
{{ formatTenantStatus(tenant.status) }}{{ formatIsolationMode(tenant.isolationMode) }}{{ tenant.userCount }} +
+ + @if (tenant.status === 'active') { + + } @else { + + } + Branding +
+
No tenants match the current filters.
+ } } } -
+
`, - styles: [` - .admin-settings { max-width: 1000px; } - .page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } - .page-subtitle { margin: 0 0 1.5rem; color: var(--color-text-secondary); } - .admin-tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1.5rem; - } - .admin-tab { - padding: 0.75rem 1rem; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - cursor: pointer; - } - .admin-tab:hover { color: var(--color-text-primary); } - .admin-tab--active { - color: var(--color-brand-primary); - border-bottom-color: var(--color-brand-primary); - } - .content-section { - padding: 1.5rem; - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - } - .section-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - } - .section-header h2 { margin: 0; font-size: 1.125rem; font-weight: var(--font-weight-semibold); } - .data-table { width: 100%; border-collapse: collapse; } - .data-table th, .data-table td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid var(--color-border-primary); - } - .data-table th { font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; } - .badge { - padding: 0.125rem 0.5rem; - border-radius: var(--radius-sm); - font-size: 0.75rem; - font-weight: var(--font-weight-medium); - } - .badge--active, .badge--success { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } - .badge--disabled, .badge--locked { background: var(--color-severity-none-bg); color: var(--color-text-secondary); } - .badge--expired { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } - .badge--revoked { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } - .btn { - padding: 0.375rem 0.75rem; - border-radius: var(--radius-md); - font-size: 0.875rem; - cursor: pointer; - } - .btn--primary { - background: var(--color-brand-primary); - border: none; - color: var(--color-text-heading); - } - .btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); } - .btn--sm:disabled { opacity: 0.5; cursor: not-allowed; } - .loading-text { color: var(--color-text-secondary); font-size: 0.875rem; } - .section-note { - margin: 0 0 1rem; - color: var(--color-text-secondary); - font-size: 0.8125rem; - } - .empty-cell { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; } - .muted-cell { color: var(--color-text-secondary); font-size: 0.8125rem; } - .error-banner { - padding: 1rem; - margin-bottom: 1rem; - background: var(--color-status-error-bg); - border: 1px solid rgba(248, 113, 113, 0.5); - color: var(--color-status-error); - border-radius: var(--radius-lg); - font-size: 0.875rem; - } - code { - padding: 0.125rem 0.25rem; - background: var(--color-surface-secondary); - border-radius: var(--radius-sm); - font-size: 0.8125rem; - } - .add-form { - margin-bottom: 1.5rem; - padding: 1.25rem; - background: var(--color-surface-secondary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - animation: slideDown 0.2s ease-out; - } - @keyframes slideDown { - from { opacity: 0; max-height: 0; transform: translateY(-8px); } - to { opacity: 1; max-height: 500px; transform: translateY(0); } - } - .add-form__title { margin: 0 0 1rem; font-size: 1rem; font-weight: var(--font-weight-semibold); } - .add-form__fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; } - .form-field { display: flex; flex-direction: column; gap: 0.25rem; } - .form-field--full { grid-column: 1 / -1; } - .form-label { font-size: 0.75rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); text-transform: uppercase; } - .form-input { - padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - font-size: 0.875rem; - background: var(--color-surface-primary); - color: var(--color-text-primary); - } - .form-input--textarea { min-height: 6rem; resize: vertical; } - .form-input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px rgba(245,166,35,0.15); } - .add-form__actions { display: flex; justify-content: flex-end; gap: 0.5rem; } - .btn--secondary { - padding: 0.375rem 0.75rem; - border-radius: var(--radius-md); - font-size: 0.875rem; - cursor: pointer; - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - color: var(--color-text-secondary); - } - .btn--secondary:hover { background: var(--color-nav-hover); } - .success-banner { - padding: 0.75rem 1rem; - margin-bottom: 1rem; - background: var(--color-status-success-bg); - border: 1px solid var(--color-status-success-border); - color: var(--color-status-success-text); - border-radius: var(--radius-lg); - font-size: 0.875rem; - } - `] + styles: [` + :host { display: block; } + .page, .panel, .editor, .detail, .catalog, .group { display: grid; gap: 1rem; } + .page { max-width: 1400px; } + .tabs, .toolbar, .actions, .chips { display: flex; flex-wrap: wrap; gap: .5rem; } + .two-col { display: grid; grid-template-columns: minmax(0,2fr) minmax(18rem,1fr); gap: 1rem; align-items: start; } + .section-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } + .panel, .banner, .editor, .detail, .note { padding: 1rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); } + .editor, .detail { background: var(--color-surface-secondary); } + .note { background: rgba(245,166,35,.08); } + .banner.error { background: var(--color-status-error-bg); color: var(--color-status-error); } + .banner.success { background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .tab { padding: .7rem 1rem; background: transparent; border: none; border-bottom: 2px solid transparent; cursor: pointer; color: var(--color-text-secondary); } + .tab.active { color: var(--color-brand-primary); border-bottom-color: var(--color-brand-primary); } + .muted { color: var(--color-text-secondary); } + .small { font-size: .82rem; } + .state { text-align: center; color: var(--color-text-secondary); } + .btn { padding: .45rem .8rem; border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-primary); text-decoration: none; cursor: pointer; font: inherit; } + .btn.primary { background: var(--color-brand-primary); color: var(--color-text-heading); border-color: transparent; } + .btn.sm { padding: .3rem .55rem; font-size: .82rem; } + .btn.danger { color: var(--color-status-error); border-color: rgba(239,68,68,.25); background: rgba(239,68,68,.08); } + .table { width: 100%; border-collapse: collapse; } + .table th, .table td { padding: .75rem; border-bottom: 1px solid var(--color-border-primary); text-align: left; vertical-align: top; } + .table th { font-size: .78rem; text-transform: uppercase; color: var(--color-text-secondary); } + .grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .75rem; } + .grid .wide { grid-column: 1 / -1; } + label { display: grid; gap: .3rem; } + label span { font-size: .78rem; text-transform: uppercase; color: var(--color-text-secondary); } + input, select { width: 100%; padding: .65rem .75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); } + .pick { display: flex; gap: .75rem; align-items: flex-start; padding: .7rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); cursor: pointer; } + .pick.selected, .selected-row { background: rgba(34,211,238,.06); border-color: var(--color-brand-primary); } + .tag { display: inline-flex; padding: .18rem .5rem; border-radius: 999px; background: rgba(34,211,238,.12); color: var(--color-status-info); font-size: .74rem; } + @media (max-width: 1024px) { .two-col, .grid { grid-template-columns: 1fr; } .grid .wide { grid-column: auto; } } + `], }) export class AdminSettingsPageComponent implements OnInit { - private readonly api = inject(AUTHORITY_ADMIN_API); + private readonly api = inject(AUTHORITY_ADMIN_API); private static readonly emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; private static readonly tenantIdPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; - tabs = [ - { id: 'users', label: 'Users' }, - { id: 'roles', label: 'Roles' }, - { id: 'clients', label: 'OAuth Clients' }, - { id: 'tokens', label: 'API Tokens' }, - { id: 'tenants', label: 'Tenants' }, - ]; + readonly tabs = TABS; + readonly scopeCatalog = buildAdminScopeCatalog(); + readonly activeTab = signal('users'); + readonly loading = signal(false); + readonly error = signal(null); + readonly successMessage = signal(null); + readonly users = signal([]); + readonly roles = signal([]); + readonly clients = signal([]); + readonly tokens = signal([]); + readonly tenants = signal([]); + readonly usersSearch = signal(''); + readonly usersStatusFilter = signal('all'); + readonly rolesSearch = signal(''); + readonly rolesFilter = signal<'all' | 'built-in' | 'custom'>('all'); + readonly tenantsSearch = signal(''); + readonly tenantsStatusFilter = signal('all'); + readonly selectedRoleId = signal(null); + readonly roleImpact = signal(null); + readonly roleImpactLoading = signal(false); - activeTab = signal('users'); - loading = signal(true); - error = signal(null); - addFormVisible = signal(null); - successMessage = signal(null); + readonly filteredUsers = computed(() => { + const query = this.usersSearch().trim().toLowerCase(); + const status = this.usersStatusFilter(); + return [...this.users()] + .filter((user) => status === 'all' || user.status === status) + .filter((user) => !query || `${user.displayName} ${user.username} ${user.email} ${user.roles.join(' ')}`.toLowerCase().includes(query)) + .sort((left, right) => (left.displayName || left.username).localeCompare(right.displayName || right.username)); + }); - users = signal([]); - roles = signal([]); - clients = signal([]); - tokens = signal([]); - tenants = signal([]); + readonly filteredRoles = computed(() => { + const query = this.rolesSearch().trim().toLowerCase(); + const roleFilter = this.rolesFilter(); + return [...this.roles()] + .filter((role) => roleFilter === 'all' || (roleFilter === 'built-in' ? role.isBuiltIn : !role.isBuiltIn)) + .filter((role) => !query || `${role.name} ${role.description}`.toLowerCase().includes(query)) + .sort((left, right) => left.name.localeCompare(right.name)); + }); + + readonly filteredTenants = computed(() => { + const query = this.tenantsSearch().trim().toLowerCase(); + const status = this.tenantsStatusFilter(); + return [...this.tenants()] + .filter((tenant) => status === 'all' || tenant.status === status) + .filter((tenant) => !query || `${tenant.id} ${tenant.displayName}`.toLowerCase().includes(query)) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); + }); + + readonly selectedRole = computed(() => this.roles().find((role) => role.id === this.selectedRoleId()) ?? null); + + userEditor: UserEditorState | null = null; + roleEditor: RoleEditorState | null = null; + tenantEditor: TenantEditorState | null = null; ngOnInit(): void { - this.loadTab('users'); - this.ensureRolesLoaded(); + this.loadUsers(); + this.loadRoles(); } - setTab(tabId: string): void { - this.activeTab.set(tabId); - this.addFormVisible.set(null); - this.loadTab(tabId); - } - - showAddForm(formId: string): void { - this.addFormVisible.set(this.addFormVisible() === formId ? null : formId); - if (formId === 'users') { - this.ensureRolesLoaded(); - } - if (this.addFormVisible()) { - this.error.set(null); - } - this.successMessage.set(null); - } - - hideAddForm(): void { - this.addFormVisible.set(null); - } - - createUser(username: string, email: string, displayName: string, role: string): void { - if (!username.trim() || !email.trim()) { - this.error.set('Username and email are required.'); - return; - } - if (!AdminSettingsPageComponent.emailPattern.test(email.trim())) { - this.error.set('Enter a valid email address before creating the user.'); - return; - } + setTab(tab: AdminTab): void { + this.activeTab.set(tab); this.error.set(null); - this.loading.set(true); - this.api.createUser({ username: username.trim(), email: email.trim(), displayName: displayName.trim(), roles: [role] }).pipe( - catchError((err) => { - this.error.set(err?.status === 403 - ? 'Your current session is missing the write scopes required to create users.' - : 'Failed to create user. The backend may be unavailable.'); - return of(null); - }) - ).subscribe((result) => { - this.loading.set(false); - if (result) { - this.addFormVisible.set(null); - this.successMessage.set(`Created user ${result.username}.`); - this.loadTab('users'); - } - }); + this.successMessage.set(null); + if (tab === 'users') { + this.loadUsers(); + this.loadRoles(); + } else if (tab === 'roles') { + this.loadRoles(); + } else if (tab === 'clients') { + this.loadClients(); + } else if (tab === 'tokens') { + this.loadTokens(); + } else { + this.loadTenants(); + } } - createRole(name: string, description: string, permissionsText: string): void { - const normalizedName = name.trim().toLowerCase(); - const normalizedDescription = description.trim(); - const permissions = permissionsText - .split(/[,\n]/) - .map((value) => value.trim()) - .filter((value) => value.length > 0); + openCreateUserEditor(): void { + this.userEditor = { + mode: 'create', + id: null, + username: '', + email: '', + displayName: '', + selectedRoles: this.leastPrivilegeRoleName() ? [this.leastPrivilegeRoleName()!] : [], + }; + } - if (!normalizedName) { + openEditUserEditor(user: AdminUser): void { + this.userEditor = { + mode: 'edit', + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + selectedRoles: [...user.roles], + }; + } + + cancelUserEditor(): void { + this.userEditor = null; + } + + toggleUserRole(roleName: string): void { + if (!this.userEditor) { + return; + } + + this.userEditor.selectedRoles = this.userEditor.selectedRoles.includes(roleName) + ? this.userEditor.selectedRoles.filter((role) => role !== roleName) + : [...this.userEditor.selectedRoles, roleName].sort((left, right) => left.localeCompare(right)); + } + + saveUser(): void { + if (!this.userEditor) { + return; + } + + if (!this.userEditor.username.trim() || !this.userEditor.email.trim()) { + this.error.set('Username and email are required before saving a user.'); + return; + } + + if (!AdminSettingsPageComponent.emailPattern.test(this.userEditor.email.trim())) { + this.error.set('Enter a valid email address before creating or updating a user.'); + return; + } + + if (this.userEditor.selectedRoles.length === 0) { + this.error.set('Assign at least one role before saving the user.'); + return; + } + + this.loading.set(true); + this.error.set(null); + + if (this.userEditor.mode === 'create') { + const request: CreateUserRequest = { + username: this.userEditor.username.trim(), + email: this.userEditor.email.trim(), + displayName: this.userEditor.displayName.trim(), + roles: [...this.userEditor.selectedRoles], + }; + + this.api.createUser(request).pipe( + tap((created) => { + this.userEditor = null; + this.successMessage.set(`Created user ${created.username}.`); + this.loadUsers(); + }), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to create the user.')); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + return; + } + + const update: UpdateUserRequest = { + displayName: this.userEditor.displayName.trim() || undefined, + roles: [...this.userEditor.selectedRoles], + }; + + this.api.updateUser(this.userEditor.id!, update).pipe( + tap((updated) => { + this.userEditor = null; + this.users.update((users) => users.map((entry) => entry.id === updated.id ? updated : entry)); + this.successMessage.set(`Updated user ${updated.username}.`); + }), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to update the user.')); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + } + + disableUser(user: AdminUser): void { + this.loading.set(true); + this.api.disableUser(user.id).pipe( + tap((updated) => { + this.users.update((users) => users.map((entry) => entry.id === updated.id ? updated : entry)); + this.successMessage.set(`Disabled ${updated.username}.`); + }), + catchError((err) => { + this.error.set(this.describeError(err, `Failed to disable ${user.username}.`)); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + } + + enableUser(user: AdminUser): void { + this.loading.set(true); + this.api.enableUser(user.id).pipe( + tap((updated) => { + this.users.update((users) => users.map((entry) => entry.id === updated.id ? updated : entry)); + this.successMessage.set(`Enabled ${updated.username}.`); + }), + catchError((err) => { + this.error.set(this.describeError(err, `Failed to enable ${user.username}.`)); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + } + + openCreateRoleEditor(): void { + this.roleEditor = { mode: 'create', id: null, name: '', description: '', selectedScopes: [] }; + } + + openEditRoleEditor(role: AdminRole): void { + if (role.isBuiltIn) { + this.error.set('Built-in roles are read-only. Create a custom role if you need a different permission bundle.'); + return; + } + + this.roleEditor = { + mode: 'edit', + id: role.id, + name: role.name, + description: role.description, + selectedScopes: [...role.permissions], + }; + } + + cancelRoleEditor(): void { + this.roleEditor = null; + } + + toggleScope(scope: string): void { + if (!this.roleEditor) { + return; + } + + this.roleEditor.selectedScopes = this.roleEditor.selectedScopes.includes(scope) + ? this.roleEditor.selectedScopes.filter((entry) => entry !== scope) + : [...this.roleEditor.selectedScopes, scope].sort((left, right) => left.localeCompare(right)); + } + + saveRole(): void { + if (!this.roleEditor) { + return; + } + + const name = this.roleEditor.name.trim().toLowerCase(); + const description = this.roleEditor.description.trim(); + if (!name) { this.error.set('Role name is required.'); return; } - - if (!normalizedDescription) { + if (!description) { this.error.set('Role description is required.'); return; } - - if (permissions.length === 0) { - this.error.set('At least one permission is required.'); + if (this.roleEditor.selectedScopes.length === 0) { + this.error.set('Choose at least one scope from the catalog.'); return; } - this.error.set(null); this.loading.set(true); - this.api.createRole({ - name: normalizedName, - description: normalizedDescription, - permissions, - }).pipe( + if (this.roleEditor.mode === 'create') { + const request: CreateRoleRequest = { name, description, permissions: [...this.roleEditor.selectedScopes] }; + this.api.createRole(request).pipe( + tap((created) => { + this.roleEditor = null; + this.roles.update((roles) => [...roles, created].sort((left, right) => left.name.localeCompare(right.name))); + this.selectedRoleId.set(created.id); + this.successMessage.set(`Created role ${created.name}.`); + this.refreshRoleImpact(created); + }), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to create the role.')); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + return; + } + + const update: UpdateRoleRequest = { + description, + permissions: [...this.roleEditor.selectedScopes], + }; + this.api.updateRole(this.roleEditor.id!, update).pipe( + tap((updated) => { + this.roleEditor = null; + this.roles.update((roles) => roles.map((role) => role.id === updated.id ? updated : role)); + this.selectedRoleId.set(updated.id); + this.successMessage.set(`Updated role ${updated.name}.`); + this.refreshRoleImpact(updated); + }), catchError((err) => { - this.error.set(err?.status === 403 - ? 'Your current session is missing the write scopes required to create roles.' - : 'Failed to create role.'); + this.error.set(this.describeError(err, 'Failed to update the role.')); + this.loading.set(false); return of(null); - }) - ).subscribe((result) => { - this.loading.set(false); - if (result) { - this.addFormVisible.set(null); - this.successMessage.set(`Created role ${result.name}.`); - this.roles.update((roles) => [...roles, result].sort((left, right) => left.name.localeCompare(right.name))); - if (this.activeTab() === 'roles') { - this.loadTab('roles'); - } - } - }); + }), + ).subscribe(() => this.loading.set(false)); } - createTenant(id: string, displayName: string, isolationMode: string): void { - const normalizedId = id.trim().toLowerCase(); - const normalizedDisplayName = displayName.trim(); + selectRole(role: AdminRole): void { + this.selectedRoleId.set(role.id); + this.refreshRoleImpact(role); + } - if (!normalizedId) { + openCreateTenantEditor(): void { + this.tenantEditor = { mode: 'create', id: '', displayName: '', isolationMode: 'shared' }; + } + + openEditTenantEditor(tenant: AdminTenant): void { + this.tenantEditor = { + mode: 'edit', + id: tenant.id, + displayName: tenant.displayName, + isolationMode: tenant.isolationMode === 'dedicated' ? 'dedicated' : 'shared', + }; + } + + cancelTenantEditor(): void { + this.tenantEditor = null; + } + + saveTenant(): void { + if (!this.tenantEditor) { + return; + } + + const id = this.tenantEditor.id.trim().toLowerCase(); + const displayName = this.tenantEditor.displayName.trim(); + if (!id) { this.error.set('Tenant ID is required.'); return; } - - if (!AdminSettingsPageComponent.tenantIdPattern.test(normalizedId)) { + if (!AdminSettingsPageComponent.tenantIdPattern.test(id)) { this.error.set('Tenant ID must use lowercase letters, digits, and hyphens only.'); return; } - - if (!normalizedDisplayName) { + if (!displayName) { this.error.set('Tenant display name is required.'); return; } - this.error.set(null); this.loading.set(true); - this.api.createTenant({ - id: normalizedId, - displayName: normalizedDisplayName, - isolationMode: isolationMode === 'dedicated' ? 'dedicated' : 'shared', - }).pipe( + if (this.tenantEditor.mode === 'create') { + const request: CreateTenantRequest = { id, displayName, isolationMode: this.tenantEditor.isolationMode }; + this.api.createTenant(request).pipe( + tap((created) => { + this.tenantEditor = null; + this.successMessage.set(`Created tenant ${created.displayName}.`); + this.loadTenants(); + }), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to create the tenant.')); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + return; + } + + const update: UpdateTenantRequest = { displayName, isolationMode: this.tenantEditor.isolationMode }; + this.api.updateTenant(this.tenantEditor.id, update).pipe( + tap((updated) => { + this.tenantEditor = null; + this.tenants.update((tenants) => tenants.map((entry) => entry.id === updated.id ? updated : entry)); + this.successMessage.set(`Updated tenant ${updated.displayName}.`); + }), catchError((err) => { - this.error.set(err?.status === 403 - ? 'Your current session is missing the write scopes required to create tenants.' - : 'Failed to create tenant.'); + this.error.set(this.describeError(err, 'Failed to update the tenant.')); + this.loading.set(false); return of(null); - }) - ).subscribe((result) => { - this.loading.set(false); - if (result) { - this.addFormVisible.set(null); - this.successMessage.set(`Created tenant ${result.displayName}.`); - this.loadTab('tenants'); - } - }); + }), + ).subscribe(() => this.loading.set(false)); } - availableUserRoles(): AdminRole[] { - return this.roles().length > 0 - ? this.roles() - : [{ id: 'admin', name: 'admin', description: 'Administrator', permissions: [], userCount: 0, isBuiltIn: true }]; + suspendTenant(tenant: AdminTenant): void { + this.loading.set(true); + this.api.suspendTenant(tenant.id).pipe( + tap((updated) => { + this.tenants.update((tenants) => tenants.map((entry) => entry.id === updated.id ? updated : entry)); + this.successMessage.set(`Suspended tenant ${updated.displayName}.`); + }), + catchError((err) => { + this.error.set(this.describeError(err, `Failed to suspend tenant ${tenant.displayName}.`)); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + } + + resumeTenant(tenant: AdminTenant): void { + this.loading.set(true); + this.api.resumeTenant(tenant.id).pipe( + tap((updated) => { + this.tenants.update((tenants) => tenants.map((entry) => entry.id === updated.id ? updated : entry)); + this.successMessage.set(`Resumed tenant ${updated.displayName}.`); + }), + catchError((err) => { + this.error.set(this.describeError(err, `Failed to resume tenant ${tenant.displayName}.`)); + this.loading.set(false); + return of(null); + }), + ).subscribe(() => this.loading.set(false)); + } + + leastPrivilegeRoleName(): string | null { + const viewer = this.roles().find((role) => /viewer|read-?only/i.test(`${role.name} ${role.description}`)); + if (viewer) { + return viewer.name; + } + + return [...this.roles()].sort((left, right) => left.permissions.length - right.permissions.length)[0]?.name ?? null; + } + + selectedRoleGroups(): Array<{ id: string; label: string; scopes: string[] }> { + const role = this.selectedRole(); + if (!role) { + return []; + } + + return this.scopeCatalog + .map((group) => ({ + id: group.id, + label: group.label, + scopes: group.items.map((item) => item.scope).filter((scope) => role.permissions.includes(scope)), + })) + .filter((group) => group.scopes.length > 0); + } + + defaultImpactMessage(impact: RoleImpactPreview): string { + return impact.affectedUsers === 0 + ? 'No users are currently assigned to this role.' + : `${impact.affectedUsers} users would be affected by changes to this role.`; } describeClientTenants(client: AdminClient): string { if (client.defaultTenant) { return client.defaultTenant; } - if (client.tenants && client.tenants.length > 0) { return client.tenants.join(', '); } - return 'All tenants'; + return 'All visible tenants'; } - private loadTab(tabId: string): void { - this.loading.set(true); - this.error.set(null); - - let obs$: Observable; - switch (tabId) { - case 'users': - obs$ = this.api.listUsers().pipe(tap(d => this.users.set(d))); - break; - case 'roles': - obs$ = this.api.listRoles().pipe(tap(d => this.roles.set(d))); - break; - case 'clients': - obs$ = this.api.listClients().pipe(tap(d => this.clients.set(d))); - break; - case 'tokens': - obs$ = this.api.listTokens().pipe(tap(d => this.tokens.set(d))); - break; - case 'tenants': - obs$ = this.api.listTenants().pipe(tap(d => this.tenants.set(d))); - break; - default: - return; - } - - obs$.pipe( - catchError(() => { - this.error.set(`Failed to load ${tabId}. The backend may be unavailable.`); - return of([]); - }) - ).subscribe(() => this.loading.set(false)); + formatUserStatus(status: AdminUser['status']): string { + return status === 'locked' ? 'Locked' : status === 'disabled' ? 'Disabled' : 'Active'; } - private ensureRolesLoaded(): void { - if (this.roles().length > 0) { + formatTenantStatus(status: AdminTenant['status']): string { + return status === 'disabled' ? 'Suspended' : 'Active'; + } + + formatIsolationMode(mode: string): string { + return mode === 'dedicated' ? 'Dedicated' : 'Shared'; + } + + private refreshRoleImpact(role: AdminRole): void { + if (role.isBuiltIn) { + this.roleImpact.set(null); + this.roleImpactLoading.set(false); return; } + this.roleImpactLoading.set(true); + this.api.previewRoleImpact(role.id).pipe( + tap((impact) => this.roleImpact.set(impact)), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to preview role impact.')); + this.roleImpactLoading.set(false); + return of(null); + }), + ).subscribe(() => this.roleImpactLoading.set(false)); + } + + private loadUsers(): void { + this.loading.set(true); + this.api.listUsers().pipe( + tap((users) => this.users.set(users)), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to load users.')); + this.loading.set(false); + return of([]); + }), + ).subscribe(() => this.loading.set(false)); + } + + private loadRoles(): void { + this.loading.set(true); this.api.listRoles().pipe( - catchError(() => of([])) - ).subscribe((roles) => this.roles.set(roles)); + tap((roles) => { + this.roles.set(roles); + const selected = roles.find((role) => role.id === this.selectedRoleId()) ?? roles[0] ?? null; + this.selectedRoleId.set(selected?.id ?? null); + if (selected) { + this.refreshRoleImpact(selected); + } + }), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to load roles.')); + this.loading.set(false); + return of([]); + }), + ).subscribe(() => this.loading.set(false)); + } + + private loadClients(): void { + this.loading.set(true); + this.api.listClients().pipe( + tap((clients) => this.clients.set(clients)), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to load clients.')); + this.loading.set(false); + return of([]); + }), + ).subscribe(() => this.loading.set(false)); + } + + private loadTokens(): void { + this.loading.set(true); + this.api.listTokens().pipe( + tap((tokens) => this.tokens.set(tokens)), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to load tokens.')); + this.loading.set(false); + return of([]); + }), + ).subscribe(() => this.loading.set(false)); + } + + private loadTenants(): void { + this.loading.set(true); + this.api.listTenants().pipe( + tap((tenants) => this.tenants.set(tenants)), + catchError((err) => { + this.error.set(this.describeError(err, 'Failed to load tenants.')); + this.loading.set(false); + return of([]); + }), + ).subscribe(() => this.loading.set(false)); + } + + private describeError(err: unknown, fallback: string): string { + const httpError = err as { error?: { error?: string; message?: string }; message?: string }; + return httpError?.error?.message || httpError?.error?.error || httpError?.message || fallback; } } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts index 24ff2865f..c7241053e 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.spec.ts @@ -1,382 +1,182 @@ -/** - * @file certificate-inventory.component.spec.ts - * @sprint SPRINT_20251229_018c_FE - * @description Unit tests for CertificateInventoryComponent - * Filter-bar adoption tests: SPRINT_20260308_015_FE (FE-OFB-004) - */ - -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; -import { CertificateInventoryComponent } from './certificate-inventory.component'; + import { TRUST_API, TrustApi } from '../../core/api/trust.client'; -import { Certificate, CertificateChain, CertificateExpiryAlert, PagedResult } from '../../core/api/trust.models'; +import { Certificate, CertificateChain, PagedResult, SigningKey, TrustedIssuer } from '../../core/api/trust.models'; +import { CertificateInventoryComponent } from './certificate-inventory.component'; describe('CertificateInventoryComponent', () => { let component: CertificateInventoryComponent; let fixture: ComponentFixture; let mockTrustApi: jasmine.SpyObj; - const mockCertificates: Certificate[] = [ - { - certificateId: 'cert-001', - tenantId: 'tenant-1', - name: 'Root CA', - certificateType: 'root_ca', - status: 'valid', - subject: { commonName: 'StellaOps Root CA', organization: 'StellaOps' }, - issuer: { commonName: 'StellaOps Root CA', organization: 'StellaOps' }, - validFrom: '2023-01-01T00:00:00Z', - validUntil: '2033-01-01T00:00:00Z', - serialNumber: 'ABC123', - fingerprintSha256: 'sha256:abc123', - keyUsage: ['digitalSignature', 'keyCertSign'], - extendedKeyUsage: [], - subjectAltNames: [], - isCA: true, - chainLength: 1, - createdAt: '2023-01-01T00:00:00Z', - }, - { - certificateId: 'cert-002', - tenantId: 'tenant-1', - name: 'mTLS Client', - certificateType: 'mtls_client', - status: 'expiring_soon', - subject: { commonName: 'scanner.stellaops.local' }, - issuer: { commonName: 'StellaOps Intermediate CA' }, - validFrom: '2024-01-01T00:00:00Z', - validUntil: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(), - serialNumber: 'DEF456', - fingerprintSha256: 'sha256:def456', - keyUsage: ['digitalSignature'], - extendedKeyUsage: ['clientAuth'], - subjectAltNames: ['DNS:scanner.stellaops.local'], - isCA: false, - chainLength: 3, - parentCertificateId: 'cert-int', - createdAt: '2024-01-01T00:00:00Z', - }, - ]; + const certificate: Certificate = { + certificateId: 'cert-001', + tenantId: 'tenant-1', + name: 'Certificate SER-001', + certificateType: 'leaf', + status: 'expiring_soon', + subject: { commonName: 'prod-attestation-k1', organization: 'Signing key binding' }, + issuer: { commonName: 'Core Root CA', organization: 'Issuer binding' }, + serialNumber: 'SER-001', + fingerprint: 'SER-001', + fingerprintSha256: 'sha256:abc', + validFrom: '2026-01-01T00:00:00Z', + validUntil: '2026-01-05T00:00:00Z', + keyUsage: [], + extendedKeyUsage: [], + subjectAltNames: [], + isCA: false, + chainLength: 1, + childCertificateIds: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + }; - const mockExpiryAlerts: CertificateExpiryAlert[] = [ - { - certificateId: 'cert-002', - certificateName: 'mTLS Client', - certificateType: 'mtls_client', - expiresAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000).toISOString(), - daysUntilExpiry: 20, - severity: 'warning', - affectedServices: ['Scanner'], - }, - ]; + const key: SigningKey = { + keyId: 'key-001', + tenantId: 'tenant-1', + name: 'prod-attestation-k1', + keyType: 'Ed25519', + algorithm: 'ed25519', + keySize: 256, + purpose: 'attestation', + status: 'active', + publicKeyFingerprint: 'sha256:key', + createdAt: '2026-01-01T00:00:00Z', + expiresAt: '2026-12-31T00:00:00Z', + usageCount: 0, + }; - const mockChain: CertificateChain = { + const issuer: TrustedIssuer = { + issuerId: 'issuer-001', + tenantId: 'tenant-1', + name: 'core-root-ca', + displayName: 'Core Root CA', + issuerType: 'attestation_authority', + trustLevel: 'partial', + trustScore: 70, + url: 'https://issuer.example/root', + publicKeyFingerprints: [], + validFrom: '2026-01-01T00:00:00Z', + verificationCount: 0, + documentCount: 0, + weights: { + baseWeight: 50, + recencyFactor: 10, + verificationBonus: 20, + volumePenalty: 5, + manualAdjustment: 0, + }, + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + }; + + const chain: CertificateChain = { + chainId: 'chain-cert-001', rootCertificateId: 'cert-001', - certificates: mockCertificates, - chainLength: 2, + leafCertificateId: 'cert-001', + certificates: [certificate], verificationStatus: 'valid', - verificationMessage: 'Chain is valid', - verifiedAt: new Date().toISOString(), + verificationMessage: 'Chain valid', + verifiedAt: '2026-01-03T00:00:00Z', }; beforeEach(async () => { mockTrustApi = jasmine.createSpyObj('TrustApi', [ 'listCertificates', - 'getCertificateExpiryAlerts', + 'listKeys', + 'listIssuers', + 'registerCertificate', 'getCertificateChain', 'verifyCertificateChain', + 'revokeCertificate', ]); mockTrustApi.listCertificates.and.returnValue(of({ - items: mockCertificates, - totalCount: mockCertificates.length, + items: [certificate], + totalCount: 1, pageNumber: 1, - pageSize: 20, + pageSize: 200, + totalPages: 1, } as PagedResult)); - - mockTrustApi.getCertificateExpiryAlerts.and.returnValue(of(mockExpiryAlerts)); - mockTrustApi.getCertificateChain.and.returnValue(of(mockChain)); - mockTrustApi.verifyCertificateChain.and.returnValue(of(mockChain)); + mockTrustApi.listKeys.and.returnValue(of({ + items: [key], + totalCount: 1, + pageNumber: 1, + pageSize: 200, + totalPages: 1, + } as PagedResult)); + mockTrustApi.listIssuers.and.returnValue(of({ + items: [issuer], + totalCount: 1, + pageNumber: 1, + pageSize: 200, + totalPages: 1, + } as PagedResult)); + mockTrustApi.registerCertificate.and.returnValue(of(certificate)); + mockTrustApi.getCertificateChain.and.returnValue(of(chain)); + mockTrustApi.verifyCertificateChain.and.returnValue(of(chain)); + mockTrustApi.revokeCertificate.and.returnValue(of({ ...certificate, status: 'revoked' })); await TestBed.configureTestingModule({ imports: [CertificateInventoryComponent], - providers: [ - { provide: TRUST_API, useValue: mockTrustApi }, - ], + providers: [{ provide: TRUST_API, useValue: mockTrustApi }], }).compileComponents(); fixture = TestBed.createComponent(CertificateInventoryComponent); component = fixture.componentInstance; }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load certificates on init', fakeAsync(() => { + it('loads certificates and reference data on init', () => { fixture.detectChanges(); - tick(); expect(mockTrustApi.listCertificates).toHaveBeenCalled(); - expect(component.certificates().length).toBe(2); - })); - - it('should load expiry alerts on init', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - expect(mockTrustApi.getCertificateExpiryAlerts).toHaveBeenCalledWith(30); - expect(component.expiryAlerts().length).toBe(1); - })); - - it('should handle load certificates error', fakeAsync(() => { - mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('Failed to load'))); - - fixture.detectChanges(); - tick(); - - expect(component.error()).toBe('Failed to load'); - })); - - it('should filter by status', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectedStatus.set('valid'); - component.onFilterChange(); - tick(); - - expect(mockTrustApi.listCertificates).toHaveBeenCalledWith( - jasmine.objectContaining({ status: 'valid' }) - ); - })); - - it('should filter by type', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectedType.set('root_ca'); - component.onFilterChange(); - tick(); - - expect(mockTrustApi.listCertificates).toHaveBeenCalledWith( - jasmine.objectContaining({ certificateType: 'root_ca' }) - ); - })); - - it('should search certificates', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.searchQuery.set('Root'); - component.onSearch(); - tick(); - - expect(mockTrustApi.listCertificates).toHaveBeenCalledWith( - jasmine.objectContaining({ search: 'Root' }) - ); - })); - - it('should clear filters', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.searchQuery.set('test'); - component.selectedStatus.set('valid'); - component.selectedType.set('root_ca'); - component.clearFilters(); - tick(); - - expect(component.searchQuery()).toBe(''); - expect(component.selectedStatus()).toBe('all'); - expect(component.selectedType()).toBe('all'); - })); - - it('should select certificate', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectCert(mockCertificates[0]); - expect(component.selectedCert()).toEqual(mockCertificates[0]); - })); - - it('should view certificate chain', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.viewChain(mockCertificates[1]); - tick(); - - expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-002'); - expect(component.chainView()).toEqual(mockChain); - })); - - it('should verify certificate chain', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.verifyChain(mockCertificates[1]); - tick(); - - expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-002'); - expect(component.chainView()).toEqual(mockChain); - })); - - it('should format type correctly', () => { - expect(component.formatType('root_ca')).toBe('Root CA'); - expect(component.formatType('intermediate_ca')).toBe('Intermediate CA'); - expect(component.formatType('leaf')).toBe('Leaf'); - expect(component.formatType('mtls_client')).toBe('mTLS Client'); - expect(component.formatType('mtls_server')).toBe('mTLS Server'); + expect(mockTrustApi.listKeys).toHaveBeenCalled(); + expect(mockTrustApi.listIssuers).toHaveBeenCalled(); + expect(component.certificates()).toEqual([certificate]); }); - it('should format status correctly', () => { - expect(component.formatStatus('valid')).toBe('Valid'); - expect(component.formatStatus('expiring_soon')).toBe('Expiring'); - expect(component.formatStatus('expired')).toBe('Expired'); - expect(component.formatStatus('revoked')).toBe('Revoked'); + it('registers a certificate with selected key and issuer bindings', () => { + fixture.detectChanges(); + + component.createOpen.set(true); + component.createSerialNumber.set('SER-002'); + component.createKeyId.set('key-001'); + component.createIssuerId.set('issuer-001'); + component.submitCreate(); + + expect(mockTrustApi.registerCertificate).toHaveBeenCalled(); + expect(component.banner()).toContain('Registered certificate'); }); - it('should format chain status correctly', () => { - expect(component.formatChainStatus('valid')).toBe('Chain Valid'); - expect(component.formatChainStatus('incomplete')).toBe('Incomplete'); - expect(component.formatChainStatus('invalid')).toBe('Invalid'); + it('loads and verifies certificate chains', () => { + fixture.detectChanges(); + + component.viewChain(certificate); + expect(mockTrustApi.getCertificateChain).toHaveBeenCalledWith('cert-001'); + expect(component.chainView()).toEqual(chain); + + component.verifyChain(certificate); + expect(mockTrustApi.verifyCertificateChain).toHaveBeenCalledWith('cert-001'); + expect(component.banner()).toContain('Verified certificate chain'); }); - it('should detect expiring soon certificates', () => { - expect(component.isExpiringSoon(mockCertificates[1])).toBeTrue(); - expect(component.isExpiringSoon(mockCertificates[0])).toBeFalse(); + it('requires a reason before revoking a certificate', () => { + fixture.detectChanges(); + + component.openRevoke(certificate); + component.submitRevoke(); + + expect(component.revokeError()).toBe('Reason is required.'); + expect(mockTrustApi.revokeCertificate).not.toHaveBeenCalled(); }); - it('should calculate days until expiry', () => { - const days = component.getDaysUntilExpiry(mockCertificates[1]); - expect(days).toBeCloseTo(20, 0); + it('surfaces load errors', () => { + mockTrustApi.listCertificates.and.returnValue(throwError(() => new Error('certificates unavailable'))); + component.loadCertificates(); + + expect(component.error()).toBe('certificates unavailable'); }); - - it('should handle pagination', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.onPageChange(2); - tick(); - - expect(component.pageNumber()).toBe(2); - expect(mockTrustApi.listCertificates).toHaveBeenCalledWith( - jasmine.objectContaining({ pageNumber: 2 }) - ); - })); - - // --- Filter-bar adoption tests (SPRINT_20260308_015_FE FE-OFB-004) --- - - it('should render the shared filter bar element', fakeAsync(() => { - fixture.detectChanges(); - tick(); - const filterBar = fixture.nativeElement.querySelector('app-filter-bar'); - expect(filterBar).toBeTruthy(); - })); - - it('should expose two filter option groups (status, type)', () => { - expect(component.certFilterOptions.length).toBe(2); - expect(component.certFilterOptions.map(f => f.key)).toEqual(['status', 'type']); - }); - - it('should have correct status filter options', () => { - const statusFilter = component.certFilterOptions.find(f => f.key === 'status'); - expect(statusFilter).toBeTruthy(); - expect(statusFilter!.options.length).toBe(4); - expect(statusFilter!.options.map(o => o.value)).toEqual(['valid', 'expiring_soon', 'expired', 'revoked']); - }); - - it('should have correct type filter options', () => { - const typeFilter = component.certFilterOptions.find(f => f.key === 'type'); - expect(typeFilter).toBeTruthy(); - expect(typeFilter!.options.length).toBe(5); - expect(typeFilter!.options.map(o => o.value)).toEqual(['root_ca', 'intermediate_ca', 'leaf', 'mtls_client', 'mtls_server']); - }); - - it('should update selectedStatus via onCertFilterChanged', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' }); - tick(); - expect(component.selectedStatus()).toBe('expired'); - })); - - it('should update selectedType via onCertFilterChanged', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'type', value: 'root_ca', label: 'Root CA' }); - tick(); - expect(component.selectedType()).toBe('root_ca'); - })); - - it('should reset selectedStatus on onCertFilterRemoved', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' }); - tick(); - component.onCertFilterRemoved({ key: 'status', value: 'valid', label: 'Status: Valid' }); - tick(); - expect(component.selectedStatus()).toBe('all'); - })); - - it('should reset selectedType on onCertFilterRemoved', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'type', value: 'leaf', label: 'Leaf' }); - tick(); - component.onCertFilterRemoved({ key: 'type', value: 'leaf', label: 'Type: Leaf' }); - tick(); - expect(component.selectedType()).toBe('all'); - })); - - it('should update searchQuery via onCertSearch', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertSearch('*.example.com'); - tick(); - expect(component.searchQuery()).toBe('*.example.com'); - })); - - it('should rebuild active cert filters when a status filter is applied', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'status', value: 'expiring_soon', label: 'Expiring Soon' }); - tick(); - const active = component.activeCertFilters(); - expect(active.length).toBe(1); - expect(active[0].key).toBe('status'); - expect(active[0].value).toBe('expiring_soon'); - })); - - it('should rebuild active cert filters with both status and type', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' }); - tick(); - component.onCertFilterChanged({ key: 'type', value: 'mtls_client', label: 'mTLS Client' }); - tick(); - const active = component.activeCertFilters(); - expect(active.length).toBe(2); - expect(active.find(f => f.key === 'status')).toBeTruthy(); - expect(active.find(f => f.key === 'type')).toBeTruthy(); - })); - - it('should clear active cert filters on clearFilters', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'status', value: 'expired', label: 'Expired' }); - tick(); - component.clearFilters(); - tick(); - expect(component.activeCertFilters().length).toBe(0); - })); - - it('should have hasFilters return true when a filter-bar filter is applied', fakeAsync(() => { - fixture.detectChanges(); - tick(); - component.onCertFilterChanged({ key: 'status', value: 'valid', label: 'Valid' }); - tick(); - expect(component.hasFilters()).toBe(true); - })); }); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts index e05545de3..8de4806db 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/certificate-inventory.component.ts @@ -1,15 +1,15 @@ /** * @file certificate-inventory.component.ts - * @sprint SPRINT_20251229_018c_FE - * @description Live certificate inventory aligned to the administration trust API + * @sprint SPRINT_20260315_006_FE + * @description Operator-facing certificate inventory with inspection and revocation workflows. */ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { TRUST_API, TrustApi } from '../../core/api/trust.client'; -import { Certificate, CertificateStatus } from '../../core/api/trust.models'; +import { TRUST_API } from '../../core/api/trust.client'; +import { Certificate, CertificateChain, CertificateStatus, RegisterCertificateRequest, SigningKey, TrustedIssuer } from '../../core/api/trust.models'; @Component({ selector: 'app-certificate-inventory', @@ -21,14 +21,23 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models';

Certificates

- This inventory reflects the live administration records: serial number, validity window, issuer binding, key binding, and lifecycle state. + Inspect certificate ownership, verify chain state, and revoke certificates when trust must be withdrawn.

- +
+ + +
+ @if (banner()) { + + } +
@@ -56,87 +65,274 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models'; }
-
- Chain verification, subject parsing, and PEM inspection are not provided by the current administration endpoint, so this page shows the owned inventory record instead. -
+ @if (createOpen()) { +
+
+
+

Register Certificate

+

Bind the certificate to a signing key and issuer so expiry and revocation are operationally traceable.

+
+
+ +
+ + + + + + + + + +
+ + @if (createError()) { +
{{ createError() }}
+ } + +
+ + +
+
+ } @if (loading()) {
Loading certificates...
} @else if (error()) {
{{ error() }}
} @else if (certificates().length === 0) { -
No certificates found.
+
No certificates found. Register certificates to track expiry and revocation risk.
} @else { - - - + + + @for (certificate of certificates(); track certificate.certificateId) { - + - + }
Serial Number StatusValid From Valid UntilIssuer ReferenceKey ReferenceIssuerKey Binding UpdatedActions
- {{ certificate.serialNumber }} + {{ certificate.certificateId }}
{{ formatStatus(certificate.status) }}{{ certificate.validFrom | date:'medium' }} {{ certificate.validUntil | date:'medium' }} {{ certificate.issuer.commonName }} {{ certificate.subject.commonName }} {{ certificate.updatedAt | date:'medium' }} + + + + +
} + + @if (selectedCertificate()) { +
+
+
+

{{ selectedCertificate()!.serialNumber }}

+

{{ selectedCertificate()!.certificateId }}

+
+ +
+ +
+
+
Status
+
{{ formatStatus(selectedCertificate()!.status) }}
+
+
+
Issuer
+
{{ selectedCertificate()!.issuer.commonName }}
+
+
+
Key Binding
+
{{ selectedCertificate()!.subject.commonName }}
+
+
+
Expires
+
{{ selectedCertificate()!.validUntil | date:'medium' }}
+
+
+
Operator Guidance
+
+ Expiring certificates should be renewed or replaced before promotion gates depend on them. +
+
+
+
+ } + + @if (chainView()) { +
+
+
+

Chain Verification

+

{{ chainView()!.verificationMessage || 'Live certificate chain projection.' }}

+
+ +
+ +

Status: {{ chainView()!.verificationStatus }}

+
    + @for (certificate of chainView()!.certificates; track certificate.certificateId) { +
  • {{ certificate.serialNumber }} · {{ certificate.issuer.commonName }}
  • + } +
+
+ } + + @if (revokeTarget()) { +
+
+
+

Revoke Certificate

+

{{ revokeTarget()!.serialNumber }} · {{ revokeTarget()!.certificateId }}

+
+ +
+ +

+ Revocation should be used when the certificate can no longer be trusted for future client, server, or evidence workflows. +

+ + + + @if (revokeError()) { +
{{ revokeError() }}
+ } + +
+ + +
+
+ } `, styles: [` .certificate-inventory { padding: 1.5rem; display: grid; gap: 1rem; } - .certificate-inventory__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; } - .certificate-inventory__header h2 { margin: 0 0 0.35rem; } - .certificate-inventory__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; } + .certificate-inventory__header, .workspace-card__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + } + .certificate-inventory__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; } + .certificate-inventory__header p, .workspace-card__header p, .impact-copy { + margin: 0; + color: var(--color-text-secondary); + max-width: 54rem; + } + .header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .certificate-inventory__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; } .filter-field { display: grid; gap: 0.25rem; min-width: 15rem; } .filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } - .filter-field input, .filter-field select { + .filter-field input, .filter-field select, .filter-field textarea { padding: 0.55rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); + font: inherit; } - .contract-note { + .workspace-card { + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + display: grid; + gap: 1rem; + } + .workspace-card--danger { + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.05); + } + .form-grid, .detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.85rem; + } + .detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } + .detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; } + .banner, .state, .inline-error { padding: 0.9rem 1rem; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - font-size: 0.88rem; - } - .state { - padding: 2rem; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - text-align: center; - color: var(--color-text-secondary); - background: var(--color-surface-primary); } - .state--error { + .banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); } + .banner--error, .inline-error, .state--error { color: var(--color-status-error); border-color: rgba(239, 68, 68, 0.35); background: rgba(239, 68, 68, 0.08); } + .state { + text-align: center; + color: var(--color-text-secondary); + background: var(--color-surface-primary); + } .certificate-table { width: 100%; border-collapse: collapse; } .certificate-table th, .certificate-table td { padding: 0.75rem 0.9rem; @@ -150,18 +346,35 @@ import { Certificate, CertificateStatus } from '../../core/api/trust.models'; color: var(--color-text-secondary); background: var(--color-surface-primary); } + .certificate-table tr.is-selected { background: rgba(34, 211, 238, 0.08); } .serial-cell { display: grid; gap: 0.2rem; } .serial-cell span { color: var(--color-text-secondary); font-family: monospace; font-size: 0.8rem; } - .btn-secondary, .btn-link { + .chain-list { margin: 0; padding-left: 1rem; color: var(--color-text-primary); } + .actions-cell { white-space: nowrap; } + .link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link { cursor: pointer; border-radius: var(--radius-sm); + font: inherit; } - .btn-secondary { - padding: 0.4rem 0.7rem; + .link-button { + padding: 0; + border: none; + background: transparent; + color: var(--color-status-info); + text-align: left; + } + .btn-sm, .btn-secondary, .btn-primary { + padding: 0.45rem 0.8rem; border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-primary); } + .btn-primary { + background: var(--color-status-info); + border-color: var(--color-status-info); + color: #04131a; + } + .btn-sm--danger { color: var(--color-status-error); } .btn-link { border: none; background: transparent; @@ -184,15 +397,40 @@ export class CertificateInventoryComponent { private readonly trustApi = inject(TRUST_API); readonly certificates = signal([]); + readonly availableKeys = signal([]); + readonly availableIssuers = signal([]); readonly loading = signal(true); readonly error = signal(null); + readonly banner = signal(null); + readonly bannerTone = signal<'success' | 'error'>('success'); + readonly selectedCertificate = signal(null); + readonly chainView = signal(null); readonly searchQuery = signal(''); readonly selectedStatus = signal('all'); + readonly createOpen = signal(false); + readonly createSerialNumber = signal(''); + readonly createKeyId = signal(''); + readonly createIssuerId = signal(''); + readonly createNotBefore = signal('2026-03-15T00:00'); + readonly createNotAfter = signal('2027-03-15T00:00'); + readonly createError = signal(null); + readonly creating = signal(false); + + readonly revokeTarget = signal(null); + readonly revokeReason = signal(''); + readonly revokeError = signal(null); + readonly revoking = signal(false); + readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all'); constructor() { + this.reload(); + } + + reload(): void { this.loadCertificates(); + this.loadReferenceData(); } loadCertificates(): void { @@ -209,6 +447,9 @@ export class CertificateInventoryComponent { }).subscribe({ next: (result) => { this.certificates.set([...result.items]); + if (this.selectedCertificate()) { + this.selectedCertificate.set(result.items.find((item) => item.certificateId === this.selectedCertificate()!.certificateId) ?? null); + } this.loading.set(false); }, error: (err) => { @@ -218,6 +459,15 @@ export class CertificateInventoryComponent { }); } + loadReferenceData(): void { + this.trustApi.listKeys({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({ + next: (result) => this.availableKeys.set([...result.items]), + }); + this.trustApi.listIssuers({ pageNumber: 1, pageSize: 200, sortBy: 'name', sortDirection: 'asc' }).subscribe({ + next: (result) => this.availableIssuers.set([...result.items]), + }); + } + applyFilters(): void { this.loadCertificates(); } @@ -228,6 +478,119 @@ export class CertificateInventoryComponent { this.loadCertificates(); } + toggleCreate(): void { + this.createOpen.update((value) => !value); + this.createError.set(null); + } + + resetCreate(): void { + this.createSerialNumber.set(''); + this.createKeyId.set(''); + this.createIssuerId.set(''); + this.createNotBefore.set('2026-03-15T00:00'); + this.createNotAfter.set('2027-03-15T00:00'); + this.createError.set(null); + } + + submitCreate(): void { + const serialNumber = this.createSerialNumber().trim(); + if (!serialNumber) { + this.createError.set('Serial number is required.'); + return; + } + + const request: RegisterCertificateRequest = { + serialNumber, + keyId: this.createKeyId().trim() || undefined, + issuerId: this.createIssuerId().trim() || undefined, + notBefore: new Date(this.createNotBefore()).toISOString(), + notAfter: new Date(this.createNotAfter()).toISOString(), + }; + + this.creating.set(true); + this.createError.set(null); + this.trustApi.registerCertificate(request).subscribe({ + next: (created) => { + this.creating.set(false); + this.createOpen.set(false); + this.resetCreate(); + this.bannerTone.set('success'); + this.banner.set(`Registered certificate ${created.serialNumber}.`); + this.selectedCertificate.set(created); + this.reload(); + }, + error: (err) => { + this.creating.set(false); + this.createError.set(err?.error?.error || err?.message || 'Failed to register certificate.'); + }, + }); + } + + viewChain(certificate: Certificate): void { + this.trustApi.getCertificateChain(certificate.certificateId).subscribe({ + next: (chain) => this.chainView.set(chain), + error: (err) => { + this.bannerTone.set('error'); + this.banner.set(err?.error?.error || err?.message || 'Failed to load certificate chain.'); + }, + }); + } + + verifyChain(certificate: Certificate): void { + this.trustApi.verifyCertificateChain(certificate.certificateId).subscribe({ + next: (chain) => { + this.chainView.set(chain); + this.bannerTone.set('success'); + this.banner.set(`Verified certificate chain for ${certificate.serialNumber}.`); + }, + error: (err) => { + this.bannerTone.set('error'); + this.banner.set(err?.error?.error || err?.message || 'Failed to verify certificate chain.'); + }, + }); + } + + openRevoke(certificate: Certificate): void { + this.revokeTarget.set(certificate); + this.revokeReason.set(''); + this.revokeError.set(null); + } + + closeRevoke(): void { + this.revokeTarget.set(null); + this.revokeReason.set(''); + this.revokeError.set(null); + } + + submitRevoke(): void { + const target = this.revokeTarget(); + const reason = this.revokeReason().trim(); + if (!target) { + return; + } + if (!reason) { + this.revokeError.set('Reason is required.'); + return; + } + + this.revoking.set(true); + this.revokeError.set(null); + this.trustApi.revokeCertificate(target.certificateId, { reason }).subscribe({ + next: (updated) => { + this.revoking.set(false); + this.bannerTone.set('success'); + this.banner.set(`Revoked certificate ${updated.serialNumber}.`); + this.selectedCertificate.set(updated); + this.closeRevoke(); + this.reload(); + }, + error: (err) => { + this.revoking.set(false); + this.revokeError.set(err?.error?.error || err?.message || 'Failed to revoke certificate.'); + }, + }); + } + formatStatus(status: CertificateStatus): string { switch (status) { case 'expiring_soon': diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.spec.ts index e6849ae9d..df1d672f4 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.spec.ts @@ -1,257 +1,126 @@ -/** - * @file issuer-trust-list.component.spec.ts - * @sprint SPRINT_20251229_018c_FE - * @description Unit tests for IssuerTrustListComponent - */ - -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; -import { IssuerTrustListComponent } from './issuer-trust-list.component'; + import { TRUST_API, TrustApi } from '../../core/api/trust.client'; -import { TrustedIssuer, PagedResult } from '../../core/api/trust.models'; +import { PagedResult, TrustedIssuer } from '../../core/api/trust.models'; +import { IssuerTrustListComponent } from './issuer-trust-list.component'; describe('IssuerTrustListComponent', () => { let component: IssuerTrustListComponent; let fixture: ComponentFixture; let mockTrustApi: jasmine.SpyObj; - const mockIssuers: TrustedIssuer[] = [ - { - issuerId: 'issuer-001', - tenantId: 'tenant-1', - name: 'vendor-a', - displayName: 'Vendor A', - description: 'Primary vendor', - issuerType: 'csaf_publisher', - trustLevel: 'full', - trustScore: 95, - documentCount: 150, - lastVerifiedAt: '2024-01-01T00:00:00Z', - createdAt: '2023-01-01T00:00:00Z', - weights: { - baseWeight: 50, - recencyFactor: 10, - verificationBonus: 20, - volumePenalty: 5, - manualAdjustment: 0, - }, + const issuer: TrustedIssuer = { + issuerId: 'issuer-001', + tenantId: 'tenant-1', + name: 'core-root-ca', + displayName: 'Core Root CA', + issuerType: 'attestation_authority', + trustLevel: 'partial', + trustScore: 72, + url: 'https://issuer.example/root', + publicKeyFingerprints: [], + validFrom: '2026-01-01T00:00:00Z', + verificationCount: 0, + documentCount: 0, + weights: { + baseWeight: 50, + recencyFactor: 10, + verificationBonus: 20, + volumePenalty: 5, + manualAdjustment: 0, }, - { - issuerId: 'issuer-002', - tenantId: 'tenant-1', - name: 'vendor-b', - displayName: 'Vendor B', - issuerType: 'vex_issuer', - trustLevel: 'partial', - trustScore: 70, - documentCount: 50, - createdAt: '2023-06-01T00:00:00Z', - weights: { - baseWeight: 40, - recencyFactor: 10, - verificationBonus: 15, - volumePenalty: 5, - manualAdjustment: 0, - }, + metadata: { + status: 'active', + updatedBy: 'operator', }, - { - issuerId: 'issuer-003', - tenantId: 'tenant-1', - name: 'blocked-vendor', - displayName: 'Blocked Vendor', - issuerType: 'sbom_producer', - trustLevel: 'blocked', - trustScore: 0, - documentCount: 10, - createdAt: '2023-03-01T00:00:00Z', - weights: { - baseWeight: 0, - recencyFactor: 0, - verificationBonus: 0, - volumePenalty: 0, - manualAdjustment: 0, - }, - }, - ]; + isActive: true, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + }; beforeEach(async () => { mockTrustApi = jasmine.createSpyObj('TrustApi', [ 'listIssuers', + 'registerIssuer', 'blockIssuer', 'unblockIssuer', ]); mockTrustApi.listIssuers.and.returnValue(of({ - items: mockIssuers, - totalCount: mockIssuers.length, + items: [issuer], + totalCount: 1, pageNumber: 1, - pageSize: 20, + pageSize: 200, + totalPages: 1, } as PagedResult)); - - mockTrustApi.blockIssuer.and.returnValue(of(void 0)); - mockTrustApi.unblockIssuer.and.returnValue(of(void 0)); + mockTrustApi.registerIssuer.and.returnValue(of(issuer)); + mockTrustApi.blockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'blocked', trustScore: 0, isActive: false, metadata: { status: 'blocked', updatedBy: 'operator' } })); + mockTrustApi.unblockIssuer.and.returnValue(of({ ...issuer, trustLevel: 'full', trustScore: 95 })); await TestBed.configureTestingModule({ imports: [IssuerTrustListComponent], - providers: [ - { provide: TRUST_API, useValue: mockTrustApi }, - ], + providers: [{ provide: TRUST_API, useValue: mockTrustApi }], }).compileComponents(); fixture = TestBed.createComponent(IssuerTrustListComponent); component = fixture.componentInstance; }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load issuers on init', fakeAsync(() => { + it('loads issuers on init', () => { fixture.detectChanges(); - tick(); expect(mockTrustApi.listIssuers).toHaveBeenCalled(); - expect(component.issuers().length).toBe(3); - })); - - it('should handle load issuers error', fakeAsync(() => { - mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('Failed to load'))); - - fixture.detectChanges(); - tick(); - - expect(component.error()).toBe('Failed to load'); - })); - - it('should filter by trust level', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectedTrustLevel.set('full'); - component.onFilterChange(); - tick(); - - expect(mockTrustApi.listIssuers).toHaveBeenCalledWith( - jasmine.objectContaining({ trustLevel: 'full' }) - ); - })); - - it('should filter by issuer type', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectedType.set('csaf_publisher'); - component.onFilterChange(); - tick(); - - expect(mockTrustApi.listIssuers).toHaveBeenCalledWith( - jasmine.objectContaining({ issuerType: 'csaf_publisher' }) - ); - })); - - it('should search issuers', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.searchQuery.set('Vendor'); - component.onSearch(); - tick(); - - expect(mockTrustApi.listIssuers).toHaveBeenCalledWith( - jasmine.objectContaining({ search: 'Vendor' }) - ); - })); - - it('should clear filters', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.searchQuery.set('test'); - component.selectedTrustLevel.set('full'); - component.selectedType.set('csaf_publisher'); - component.clearFilters(); - tick(); - - expect(component.searchQuery()).toBe(''); - expect(component.selectedTrustLevel()).toBe('all'); - expect(component.selectedType()).toBe('all'); - })); - - it('should compute hasFilters correctly', () => { - expect(component.hasFilters()).toBeFalse(); - - component.searchQuery.set('test'); - expect(component.hasFilters()).toBeTrue(); + expect(component.issuers()).toEqual([issuer]); }); - it('should compute average score', fakeAsync(() => { + it('registers an issuer through the operator form', () => { fixture.detectChanges(); - tick(); - const avg = component.averageScore(); - expect(avg).toBeCloseTo((95 + 70 + 0) / 3, 1); - })); + component.createOpen.set(true); + component.createName.set('Core Root CA'); + component.createUri.set('https://issuer.example/root'); + component.createTrustLevel.set('partial'); + component.submitCreate(); - it('should count by trust level', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - expect(component.countByLevel('full')).toBe(1); - expect(component.countByLevel('partial')).toBe(1); - expect(component.countByLevel('blocked')).toBe(1); - })); - - it('should select issuer', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectIssuer(mockIssuers[0]); - expect(component.selectedIssuer()).toEqual(mockIssuers[0]); - })); - - it('should toggle config panel', () => { - expect(component.showConfig()).toBeFalse(); - component.showConfig.set(true); - expect(component.showConfig()).toBeTrue(); + expect(mockTrustApi.registerIssuer).toHaveBeenCalledWith({ + name: 'Core Root CA', + issuerUri: 'https://issuer.example/root', + trustLevel: 'partial', + }); + expect(component.banner()).toContain('Registered issuer'); }); - it('should format type correctly', () => { - expect(component.formatType('csaf_publisher')).toBe('CSAF Publisher'); - expect(component.formatType('vex_issuer')).toBe('VEX Issuer'); - expect(component.formatType('sbom_producer')).toBe('SBOM Producer'); - expect(component.formatType('attestation_authority')).toBe('Attestation Authority'); + it('requires a reason before blocking an issuer', () => { + fixture.detectChanges(); + + component.openMutation(issuer, 'block'); + component.submitMutation(); + + expect(component.mutationError()).toBe('Reason is required.'); + expect(mockTrustApi.blockIssuer).not.toHaveBeenCalled(); }); - it('should format trust level correctly', () => { - expect(component.formatTrustLevel('full')).toBe('Full Trust'); - expect(component.formatTrustLevel('partial')).toBe('Partial'); - expect(component.formatTrustLevel('minimal')).toBe('Minimal'); - expect(component.formatTrustLevel('untrusted')).toBe('Untrusted'); - expect(component.formatTrustLevel('blocked')).toBe('Blocked'); + it('blocks and restores issuers through explicit lifecycle actions', () => { + fixture.detectChanges(); + + component.openMutation(issuer, 'block'); + component.mutationReason.set('publisher compromised'); + component.submitMutation(); + + expect(mockTrustApi.blockIssuer).toHaveBeenCalledWith('issuer-001', { reason: 'publisher compromised' }); + + component.openMutation({ ...issuer, trustLevel: 'blocked', isActive: false }, 'unblock'); + component.restoreTrustLevel.set('full'); + component.submitMutation(); + + expect(mockTrustApi.unblockIssuer).toHaveBeenCalledWith('issuer-001', { trustLevel: 'full' }); }); - it('should handle pagination', fakeAsync(() => { - fixture.detectChanges(); - tick(); + it('surfaces load errors', () => { + mockTrustApi.listIssuers.and.returnValue(throwError(() => new Error('issuers unavailable'))); + component.loadIssuers(); - component.onPageChange(2); - tick(); - - expect(component.pageNumber()).toBe(2); - expect(mockTrustApi.listIssuers).toHaveBeenCalledWith( - jasmine.objectContaining({ pageNumber: 2 }) - ); - })); - - it('should sort by column', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.onSort('trustScore'); - expect(component.sortBy()).toBe('trustScore'); - expect(component.sortDirection()).toBe('desc'); - - component.onSort('trustScore'); - expect(component.sortDirection()).toBe('asc'); - })); + expect(component.error()).toBe('issuers unavailable'); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts index fbf920fa9..4c00abdd6 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts @@ -1,15 +1,17 @@ /** * @file issuer-trust-list.component.ts - * @sprint SPRINT_20251229_018c_FE - * @description Live trusted issuer inventory aligned to the administration trust API + * @sprint SPRINT_20260315_006_FE + * @description Operator-facing trusted issuer inventory with registration and lifecycle controls. */ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { TRUST_API, TrustApi } from '../../core/api/trust.client'; -import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models'; +import { TRUST_API } from '../../core/api/trust.client'; +import { IssuerTrustLevel, RegisterIssuerRequest, TrustedIssuer } from '../../core/api/trust.models'; + +type IssuerMutationKind = 'block' | 'unblock'; @Component({ selector: 'app-issuer-trust-list', @@ -21,14 +23,23 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';

Trusted Issuers

- This view is bound to the live administration contract: issuer name, issuer URI, trust level, status, and update ownership. + Promote, quarantine, and review publisher trust without leaving the trust workspace.

- +
+ + +
+ @if (banner()) { + + } +
-
- Score tuning, issuer blocking, and document-volume analytics are not exposed by the current backend contract, so they are intentionally omitted here. -
+ @if (createOpen()) { +
+
+
+

Register Issuer

+

Add a publisher or attestation authority with an explicit initial trust level.

+
+
+ +
+ + + +
+ + + + @if (createError()) { +
{{ createError() }}
+ } + +
+ + +
+
+ } @if (loading()) {
Loading issuers...
} @else if (error()) {
{{ error() }}
} @else if (issuers().length === 0) { -
No issuers found.
+
No issuers found. Add a trusted publisher before relying on external advisory or attestation content.
} @else { @@ -75,64 +136,186 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models'; - + @for (issuer of issuers(); track issuer.issuerId) { - - + + - + }
Issuer URI Trust Level StatusCreated Updated Updated ByActions
{{ issuer.displayName }}
+ + {{ issuer.url }} {{ formatTrustLevel(issuer.trustLevel) }} {{ issuer.metadata?.['status'] || (issuer.isActive ? 'active' : 'inactive') }}{{ issuer.createdAt | date:'medium' }} {{ issuer.updatedAt | date:'medium' }} {{ issuer.metadata?.['updatedBy'] || 'system' }} + + @if (issuer.trustLevel === 'blocked') { + + } @else { + + } +
} + + @if (selectedIssuer()) { +
+
+
+

{{ selectedIssuer()!.displayName }}

+

{{ selectedIssuer()!.issuerId }}

+
+ +
+ +
+
+
Current Trust
+
{{ formatTrustLevel(selectedIssuer()!.trustLevel) }}
+
+
+
Publisher URI
+
{{ selectedIssuer()!.url }}
+
+
+
Status
+
{{ selectedIssuer()!.metadata?.['status'] || (selectedIssuer()!.isActive ? 'active' : 'inactive') }}
+
+
+
Operator Guidance
+
+ Block an issuer when published material must stop influencing release policy immediately. +
+
+
+
+ } + + @if (mutationIssuer() && mutationKind()) { +
+
+
+

{{ mutationKind() === 'block' ? 'Block Issuer' : 'Restore Issuer' }}

+

{{ mutationIssuer()!.displayName }} · {{ mutationIssuer()!.issuerId }}

+
+ +
+ +

+ @if (mutationKind() === 'block') { + Blocking stops this issuer from contributing trusted evidence until it is explicitly restored. + } @else { + Restoring returns the issuer to active use with the selected trust level. + } +

+ + @if (mutationKind() === 'unblock') { + + } + + + + @if (mutationError()) { +
{{ mutationError() }}
+ } + +
+ + +
+
+ } `, styles: [` .issuer-list { padding: 1.5rem; display: grid; gap: 1rem; } - .issuer-list__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; } - .issuer-list__header h2 { margin: 0 0 0.35rem; } - .issuer-list__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; } + .issuer-list__header, .workspace-card__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + } + .issuer-list__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; } + .issuer-list__header p, .workspace-card__header p, .impact-copy { + margin: 0; + color: var(--color-text-secondary); + max-width: 54rem; + } + .header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .issuer-list__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; } .filter-field { display: grid; gap: 0.25rem; min-width: 15rem; } .filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } - .filter-field input, .filter-field select { + .filter-field input, .filter-field select, .filter-field textarea { padding: 0.55rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); + font: inherit; } - .contract-note { + .workspace-card { + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + display: grid; + gap: 1rem; + } + .workspace-card--danger { + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.05); + } + .form-grid, .detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.85rem; + } + .detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } + .detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; } + .banner, .state, .inline-error { padding: 0.9rem 1rem; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - font-size: 0.88rem; - } - .state { - padding: 2rem; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - text-align: center; - color: var(--color-text-secondary); - background: var(--color-surface-primary); } - .state--error { + .banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); } + .banner--error, .inline-error, .state--error { color: var(--color-status-error); border-color: rgba(239, 68, 68, 0.35); background: rgba(239, 68, 68, 0.08); } + .state { + text-align: center; + color: var(--color-text-secondary); + background: var(--color-surface-primary); + } .issuer-table { width: 100%; border-collapse: collapse; } .issuer-table th, .issuer-table td { padding: 0.75rem 0.9rem; @@ -146,17 +329,33 @@ import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models'; color: var(--color-text-secondary); background: var(--color-surface-primary); } + .issuer-table tr.is-selected { background: rgba(34, 211, 238, 0.08); } .issuer-table a { color: var(--color-status-info); word-break: break-word; } - .btn-secondary, .btn-link { + .actions-cell { white-space: nowrap; } + .link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link { cursor: pointer; border-radius: var(--radius-sm); + font: inherit; } - .btn-secondary { - padding: 0.4rem 0.7rem; + .link-button { + padding: 0; + border: none; + background: transparent; + color: var(--color-status-info); + text-align: left; + } + .btn-sm, .btn-secondary, .btn-primary { + padding: 0.45rem 0.8rem; border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-primary); } + .btn-primary { + background: var(--color-status-info); + border-color: var(--color-status-info); + color: #04131a; + } + .btn-sm--danger { color: var(--color-status-error); } .btn-link { border: none; background: transparent; @@ -182,9 +381,26 @@ export class IssuerTrustListComponent { readonly issuers = signal([]); readonly loading = signal(true); readonly error = signal(null); + readonly banner = signal(null); + readonly bannerTone = signal<'success' | 'error'>('success'); + readonly selectedIssuer = signal(null); readonly searchQuery = signal(''); readonly selectedTrustLevel = signal('all'); + readonly createOpen = signal(false); + readonly createName = signal(''); + readonly createUri = signal(''); + readonly createTrustLevel = signal>('partial'); + readonly createError = signal(null); + readonly creating = signal(false); + + readonly mutationIssuer = signal(null); + readonly mutationKind = signal(null); + readonly mutationReason = signal(''); + readonly restoreTrustLevel = signal>('minimal'); + readonly mutationError = signal(null); + readonly mutating = signal(false); + readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedTrustLevel() !== 'all'); constructor() { @@ -205,6 +421,9 @@ export class IssuerTrustListComponent { }).subscribe({ next: (result) => { this.issuers.set([...result.items]); + if (this.selectedIssuer()) { + this.selectedIssuer.set(result.items.find((item) => item.issuerId === this.selectedIssuer()!.issuerId) ?? null); + } this.loading.set(false); }, error: (err) => { @@ -224,7 +443,117 @@ export class IssuerTrustListComponent { this.loadIssuers(); } + toggleCreate(): void { + this.createOpen.update((value) => !value); + this.createError.set(null); + } + + resetCreate(): void { + this.createName.set(''); + this.createUri.set(''); + this.createTrustLevel.set('partial'); + this.createError.set(null); + } + + submitCreate(): void { + const name = this.createName().trim(); + const issuerUri = this.createUri().trim(); + + if (!name) { + this.createError.set('Issuer name is required.'); + return; + } + if (!issuerUri) { + this.createError.set('Issuer URI is required.'); + return; + } + + const request: RegisterIssuerRequest = { + name, + issuerUri, + trustLevel: this.createTrustLevel(), + }; + + this.creating.set(true); + this.createError.set(null); + this.trustApi.registerIssuer(request).subscribe({ + next: (created) => { + this.creating.set(false); + this.createOpen.set(false); + this.resetCreate(); + this.bannerTone.set('success'); + this.banner.set(`Registered issuer ${created.displayName}.`); + this.selectedIssuer.set(created); + this.loadIssuers(); + }, + error: (err) => { + this.creating.set(false); + this.createError.set(err?.error?.error || err?.message || 'Failed to register issuer.'); + }, + }); + } + + openMutation(issuer: TrustedIssuer, kind: IssuerMutationKind): void { + this.mutationIssuer.set(issuer); + this.mutationKind.set(kind); + this.mutationReason.set(''); + this.restoreTrustLevel.set('minimal'); + this.mutationError.set(null); + } + + closeMutation(): void { + this.mutationIssuer.set(null); + this.mutationKind.set(null); + this.mutationReason.set(''); + this.mutationError.set(null); + } + + submitMutation(): void { + const issuer = this.mutationIssuer(); + const kind = this.mutationKind(); + if (!issuer || !kind) { + return; + } + + const reason = this.mutationReason().trim(); + if (kind === 'block' && !reason) { + this.mutationError.set('Reason is required.'); + return; + } + + this.mutating.set(true); + this.mutationError.set(null); + + const request$ = kind === 'block' + ? this.trustApi.blockIssuer(issuer.issuerId, { reason }) + : this.trustApi.unblockIssuer(issuer.issuerId, { trustLevel: this.restoreTrustLevel() }); + + request$.subscribe({ + next: (updated) => { + this.mutating.set(false); + this.bannerTone.set('success'); + this.banner.set(kind === 'block' + ? `Blocked issuer ${updated.displayName}.` + : `Restored issuer ${updated.displayName} at ${this.formatTrustLevel(updated.trustLevel)}.`); + this.closeMutation(); + this.selectedIssuer.set(updated); + this.loadIssuers(); + }, + error: (err) => { + this.mutating.set(false); + this.mutationError.set(err?.error?.error || err?.message || `Failed to ${kind} issuer.`); + }, + }); + } + formatTrustLevel(level: IssuerTrustLevel): string { - return level.charAt(0).toUpperCase() + level.slice(1); + switch (level) { + case 'full': + return 'Full Trust'; + case 'partial': + return 'Partial Trust'; + default: + return level.charAt(0).toUpperCase() + level.slice(1); + } } } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.spec.ts index 608345552..15f8dd466 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.spec.ts @@ -1,270 +1,119 @@ -/** - * @file signing-key-dashboard.component.spec.ts - * @sprint SPRINT_20251229_018c_FE - * @description Unit tests for SigningKeyDashboardComponent - */ - -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; -import { SigningKeyDashboardComponent } from './signing-key-dashboard.component'; + import { TRUST_API, TrustApi } from '../../core/api/trust.client'; -import { SigningKey, SigningKeyStatus, KeyExpiryAlert, PagedResult } from '../../core/api/trust.models'; +import { PagedResult, SigningKey } from '../../core/api/trust.models'; +import { SigningKeyDashboardComponent } from './signing-key-dashboard.component'; describe('SigningKeyDashboardComponent', () => { let component: SigningKeyDashboardComponent; let fixture: ComponentFixture; let mockTrustApi: jasmine.SpyObj; - const mockKeys: SigningKey[] = [ - { - keyId: 'key-001', - tenantId: 'tenant-1', - name: 'Production Key', - description: 'Main signing key', - keyType: 'asymmetric', - algorithm: 'RS256', - keySize: 2048, - purpose: 'attestation', - status: 'active', - publicKeyFingerprint: 'sha256:abc123', - createdAt: '2024-01-01T00:00:00Z', - expiresAt: '2025-01-01T00:00:00Z', - usageCount: 100, + const key: SigningKey = { + keyId: 'key-001', + tenantId: 'tenant-1', + name: 'prod-attestation-k1', + keyType: 'Ed25519', + algorithm: 'ed25519', + keySize: 256, + purpose: 'attestation', + status: 'active', + publicKeyFingerprint: 'sha256:abc', + createdAt: '2026-01-01T00:00:00Z', + expiresAt: '2026-12-31T00:00:00Z', + usageCount: 0, + metadata: { + currentVersion: '1', + updatedAt: '2026-01-02T00:00:00Z', + updatedBy: 'operator', }, - { - keyId: 'key-002', - tenantId: 'tenant-1', - name: 'Backup Key', - description: 'Backup signing key', - keyType: 'asymmetric', - algorithm: 'RS256', - keySize: 2048, - purpose: 'sbom_signing', - status: 'expiring_soon', - publicKeyFingerprint: 'sha256:def456', - createdAt: '2024-01-01T00:00:00Z', - expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - usageCount: 50, - }, - ]; - - const mockExpiryAlerts: KeyExpiryAlert[] = [ - { - keyId: 'key-002', - keyName: 'Backup Key', - purpose: 'sbom_signing', - expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - daysUntilExpiry: 15, - severity: 'warning', - suggestedAction: 'Rotate key soon', - }, - ]; + }; beforeEach(async () => { mockTrustApi = jasmine.createSpyObj('TrustApi', [ 'listKeys', - 'getKeyExpiryAlerts', - 'revokeKey', + 'createKey', 'rotateKey', + 'revokeKey', ]); mockTrustApi.listKeys.and.returnValue(of({ - items: mockKeys, - totalCount: mockKeys.length, + items: [key], + totalCount: 1, pageNumber: 1, - pageSize: 20, + pageSize: 200, + totalPages: 1, } as PagedResult)); - - mockTrustApi.getKeyExpiryAlerts.and.returnValue(of(mockExpiryAlerts)); + mockTrustApi.createKey.and.returnValue(of(key)); + mockTrustApi.rotateKey.and.returnValue(of({ ...key, metadata: { ...key.metadata, currentVersion: '2' } })); + mockTrustApi.revokeKey.and.returnValue(of(void 0)); await TestBed.configureTestingModule({ imports: [SigningKeyDashboardComponent], - providers: [ - { provide: TRUST_API, useValue: mockTrustApi }, - ], + providers: [{ provide: TRUST_API, useValue: mockTrustApi }], }).compileComponents(); fixture = TestBed.createComponent(SigningKeyDashboardComponent); component = fixture.componentInstance; }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load keys on init', fakeAsync(() => { + it('loads keys on init', () => { fixture.detectChanges(); - tick(); expect(mockTrustApi.listKeys).toHaveBeenCalled(); - expect(component.keys().length).toBe(2); - })); - - it('should load expiry alerts on init', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - expect(mockTrustApi.getKeyExpiryAlerts).toHaveBeenCalledWith(30); - expect(component.expiryAlerts().length).toBe(1); - })); - - it('should handle load keys error', fakeAsync(() => { - mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('Failed to load'))); - - fixture.detectChanges(); - tick(); - - expect(component.error()).toBe('Failed to load'); - })); - - it('should filter by status', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectedStatus.set('active'); - component.onFilterChange(); - tick(); - - expect(mockTrustApi.listKeys).toHaveBeenCalledWith( - jasmine.objectContaining({ status: 'active' }) - ); - })); - - it('should filter by purpose', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectedPurpose.set('attestation'); - component.onFilterChange(); - tick(); - - expect(mockTrustApi.listKeys).toHaveBeenCalledWith( - jasmine.objectContaining({ purpose: 'attestation' }) - ); - })); - - it('should search keys', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.searchQuery.set('Production'); - component.onSearch(); - tick(); - - expect(mockTrustApi.listKeys).toHaveBeenCalledWith( - jasmine.objectContaining({ search: 'Production' }) - ); - })); - - it('should clear filters', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.searchQuery.set('test'); - component.selectedStatus.set('active'); - component.selectedPurpose.set('attestation'); - component.clearFilters(); - tick(); - - expect(component.searchQuery()).toBe(''); - expect(component.selectedStatus()).toBe('all'); - expect(component.selectedPurpose()).toBe('all'); - })); - - it('should compute hasFilters correctly', () => { - expect(component.hasFilters()).toBeFalse(); - - component.searchQuery.set('test'); - expect(component.hasFilters()).toBeTrue(); - - component.searchQuery.set(''); - component.selectedStatus.set('active'); - expect(component.hasFilters()).toBeTrue(); + expect(component.keys()).toEqual([key]); }); - it('should toggle sort direction', fakeAsync(() => { - fixture.detectChanges(); - tick(); + it('validates create key input before calling the API', () => { + component.submitCreate(); - component.onSort('name'); - expect(component.sortBy()).toBe('name'); - expect(component.sortDirection()).toBe('asc'); - - component.onSort('name'); - expect(component.sortDirection()).toBe('desc'); - })); - - it('should select key', () => { - component.selectKey(mockKeys[0]); - expect(component.selectedKey()).toEqual(mockKeys[0]); + expect(component.createError()).toBe('Alias is required.'); + expect(mockTrustApi.createKey).not.toHaveBeenCalled(); }); - it('should open rotation wizard', fakeAsync(() => { + it('creates a key and reloads the inventory', () => { fixture.detectChanges(); - tick(); - component.openRotationWizard('key-001'); - expect(component.rotatingKey()?.keyId).toBe('key-001'); - expect(component.selectedKey()).toBeNull(); - })); + component.createOpen.set(true); + component.createAlias.set('prod-attestation-k2'); + component.createAlgorithm.set('ecdsa-p256'); + component.submitCreate(); - it('should close rotation wizard', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.openRotationWizard('key-001'); - component.closeRotationWizard(); - expect(component.rotatingKey()).toBeNull(); - })); - - it('should handle pagination', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.onPageChange(2); - tick(); - - expect(component.pageNumber()).toBe(2); - expect(mockTrustApi.listKeys).toHaveBeenCalledWith( - jasmine.objectContaining({ pageNumber: 2 }) - ); - })); - - it('should format status correctly', () => { - expect(component.formatStatus('active')).toBe('Active'); - expect(component.formatStatus('expiring_soon')).toBe('Expiring Soon'); - expect(component.formatStatus('expired')).toBe('Expired'); - expect(component.formatStatus('revoked')).toBe('Revoked'); - expect(component.formatStatus('pending_rotation')).toBe('Pending Rotation'); + expect(mockTrustApi.createKey).toHaveBeenCalledWith({ + alias: 'prod-attestation-k2', + algorithm: 'ecdsa-p256', + metadataJson: undefined, + }); + expect(component.banner()).toContain('Registered signing key'); + expect(mockTrustApi.listKeys).toHaveBeenCalledTimes(2); }); - it('should format purpose correctly', () => { - expect(component.formatPurpose('attestation')).toBe('Attestation'); - expect(component.formatPurpose('sbom_signing')).toBe('SBOM'); - expect(component.formatPurpose('vex_signing')).toBe('VEX'); - expect(component.formatPurpose('code_signing')).toBe('Code'); - expect(component.formatPurpose('tls')).toBe('TLS'); + it('requires a reason before rotating or revoking', () => { + fixture.detectChanges(); + + component.openMutation(key, 'rotate'); + component.submitMutation(); + + expect(component.mutationError()).toBe('Reason is required.'); + expect(mockTrustApi.rotateKey).not.toHaveBeenCalled(); }); - it('should calculate days until expiry', () => { - const futureKey: SigningKey = { - ...mockKeys[0], - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - }; - expect(component.getDaysUntilExpiry(futureKey)).toBeCloseTo(30, 0); + it('rotates a key through the in-app confirmation workflow', () => { + fixture.detectChanges(); + + component.openMutation(key, 'rotate'); + component.mutationReason.set('scheduled rotation'); + component.submitMutation(); + + expect(mockTrustApi.rotateKey).toHaveBeenCalledWith('key-001', { reason: 'scheduled rotation' }); + expect(component.banner()).toContain('Rotated signing key'); }); - it('should detect expiring soon keys', () => { - const expiringKey: SigningKey = { - ...mockKeys[0], - expiresAt: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - }; - expect(component.isExpiringSoon(expiringKey)).toBeTrue(); + it('surfaces load errors', () => { + mockTrustApi.listKeys.and.returnValue(throwError(() => new Error('inventory unavailable'))); + component.loadKeys(); - const validKey: SigningKey = { - ...mockKeys[0], - expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), - }; - expect(component.isExpiringSoon(validKey)).toBeFalse(); + expect(component.error()).toBe('inventory unavailable'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts index 1b7bea6f1..814d60e05 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts @@ -1,15 +1,17 @@ /** * @file signing-key-dashboard.component.ts - * @sprint SPRINT_20251229_018c_FE - * @description Live signing key inventory aligned to the administration trust API + * @sprint SPRINT_20260315_006_FE + * @description Operator-facing signing key inventory with create, rotate, and revoke workflows. */ -import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { TRUST_API, TrustApi } from '../../core/api/trust.client'; -import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; +import { TRUST_API } from '../../core/api/trust.client'; +import { CreateSigningKeyRequest, SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; + +type KeyMutationKind = 'rotate' | 'revoke'; @Component({ selector: 'app-signing-key-dashboard', @@ -21,14 +23,23 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';

Signing Keys

- Live administration exposes alias, algorithm, version, lifecycle state, and update ownership. + Rotate and retire signing material with explicit operator confirmation before release evidence is affected.

- +
+ + +
+ @if (banner()) { + + } +
-
- Usage statistics, fingerprint material, and expiry policy are not part of the current administration contract, - so this page only shows fields the backend actually owns. -
+ @if (createOpen()) { +
+
+
+

Register Signing Key

+

Create a key record before binding it into certificate or evidence workflows.

+
+
+ +
+ + + +
+ + + + @if (createError()) { +
{{ createError() }}
+ } + +
+ + +
+
+ } @if (loading()) {
Loading signing keys...
} @else if (error()) {
{{ error() }}
} @else if (keys().length === 0) { -
No signing keys found.
+
No signing keys found. Register the first key to enable evidence signing and release approvals.
} @else { @@ -76,7 +135,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; - @@ -93,7 +151,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; -
Algorithm Version StatusCreated Updated Updated By Actions{{ key.algorithm }} {{ key.metadata?.['currentVersion'] || '1' }} {{ formatStatus(key.status) }}{{ key.createdAt | date:'medium' }} {{ (key.metadata?.['updatedAt'] || key.createdAt) | date:'medium' }} {{ key.metadata?.['updatedBy'] || 'system' }} @@ -101,14 +158,14 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; @@ -120,8 +177,8 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; } @if (selectedKey()) { -
-
+
+

{{ selectedKey()!.name }}

{{ selectedKey()!.keyId }}

@@ -151,50 +208,118 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models';
{{ selectedKey()!.createdAt | date:'medium' }}
-
Updated
-
{{ (selectedKey()!.metadata?.['updatedAt'] || selectedKey()!.createdAt) | date:'medium' }}
+
Operator Guidance
+
+ Rotate when custody or policy changes. Revoke only when the key must stop signing immediately. +
} + + @if (mutationKey() && mutationKind()) { +
+
+
+

{{ mutationKind() === 'rotate' ? 'Rotate Signing Key' : 'Revoke Signing Key' }}

+

{{ mutationKey()!.name }} · {{ mutationKey()!.keyId }}

+
+ +
+ +

+ @if (mutationKind() === 'rotate') { + Rotation creates a new active version and shifts future evidence signing to the new key. + } @else { + Revocation immediately marks the key unusable for future signing operations. + } +

+ + + + @if (mutationError()) { +
{{ mutationError() }}
+ } + +
+ + +
+
+ }
`, styles: [` .key-dashboard { padding: 1.5rem; display: grid; gap: 1rem; } - .key-dashboard__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; } - .key-dashboard__header h2 { margin: 0 0 0.35rem; } - .key-dashboard__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; } + .key-dashboard__header, .workspace-card__header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + } + .key-dashboard__header h2, .workspace-card__header h3 { margin: 0 0 0.35rem; } + .key-dashboard__header p, .workspace-card__header p, .impact-copy { + margin: 0; + color: var(--color-text-secondary); + max-width: 54rem; + } + .header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .key-dashboard__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; } .filter-field { display: grid; gap: 0.25rem; min-width: 15rem; } .filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } - .filter-field input, .filter-field select { + .filter-field input, .filter-field select, .filter-field textarea { padding: 0.55rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); + font: inherit; } - .contract-note { + .workspace-card { + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + display: grid; + gap: 1rem; + } + .workspace-card--danger { + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.05); + } + .form-grid, .detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.85rem; + } + .detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } + .detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; } + .banner, .state, .inline-error { padding: 0.9rem 1rem; - border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - font-size: 0.88rem; - } - .state { - padding: 2rem; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - text-align: center; - color: var(--color-text-secondary); - background: var(--color-surface-primary); } - .state--error { + .banner { background: rgba(74, 222, 128, 0.12); color: var(--color-status-success-border); } + .banner--error, .inline-error, .state--error { color: var(--color-status-error); border-color: rgba(239, 68, 68, 0.35); background: rgba(239, 68, 68, 0.08); } + .state { + text-align: center; + color: var(--color-text-secondary); + background: var(--color-surface-primary); + } .key-table { width: 100%; border-collapse: collapse; } .key-table th, .key-table td { padding: 0.75rem 0.9rem; @@ -210,27 +335,30 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; } .key-table tr.is-selected { background: rgba(34, 211, 238, 0.08); } .actions-cell { white-space: nowrap; } - .link-button, .btn-sm, .btn-secondary, .btn-link { + .link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link { cursor: pointer; border-radius: var(--radius-sm); + font: inherit; } .link-button { padding: 0; border: none; background: transparent; color: var(--color-status-info); - font: inherit; text-align: left; } - .btn-sm, .btn-secondary { - padding: 0.4rem 0.7rem; + .btn-sm, .btn-secondary, .btn-primary { + padding: 0.45rem 0.8rem; border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-primary); - margin-right: 0.35rem; + } + .btn-primary { + background: var(--color-status-info); + border-color: var(--color-status-info); + color: #04131a; } .btn-sm--danger { color: var(--color-status-error); } - .btn-secondary { margin-right: 0; } .btn-link { border: none; background: transparent; @@ -248,30 +376,6 @@ import { SigningKey, SigningKeyStatus } from '../../core/api/trust.models'; .badge--expiring_soon { background: rgba(251, 191, 36, 0.16); color: var(--color-status-warning-border); } .badge--expired, .badge--revoked { background: rgba(239, 68, 68, 0.12); color: var(--color-status-error); } .badge--pending_rotation { background: rgba(167, 139, 250, 0.16); color: var(--color-status-excepted-border); } - .detail-card { - padding: 1rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - display: grid; - gap: 1rem; - } - .detail-card__header { - display: flex; - justify-content: space-between; - gap: 1rem; - align-items: flex-start; - } - .detail-card__header h3 { margin: 0 0 0.25rem; } - .detail-card__header p { margin: 0; color: var(--color-text-secondary); font-family: monospace; } - .detail-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 0.85rem; - margin: 0; - } - .detail-grid dt { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } - .detail-grid dd { margin: 0.2rem 0 0; color: var(--color-text-primary); word-break: break-word; } `], }) export class SigningKeyDashboardComponent { @@ -280,11 +384,26 @@ export class SigningKeyDashboardComponent { readonly keys = signal([]); readonly loading = signal(true); readonly error = signal(null); + readonly banner = signal(null); + readonly bannerTone = signal<'success' | 'error'>('success'); readonly selectedKey = signal(null); readonly mutatingKeyId = signal(null); readonly searchQuery = signal(''); readonly selectedStatus = signal('all'); + readonly createOpen = signal(false); + readonly createAlias = signal(''); + readonly createAlgorithm = signal('ed25519'); + readonly createMetadata = signal(''); + readonly createError = signal(null); + readonly creating = signal(false); + + readonly mutationKey = signal(null); + readonly mutationKind = signal(null); + readonly mutationReason = signal(''); + readonly mutationError = signal(null); + readonly mutating = signal(false); + readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedStatus() !== 'all'); constructor() { @@ -318,54 +437,102 @@ export class SigningKeyDashboardComponent { } applyFilters(): void { - this.selectedKey.set(null); this.loadKeys(); } clearFilters(): void { this.searchQuery.set(''); this.selectedStatus.set('all'); - this.applyFilters(); + this.loadKeys(); } - rotateKey(key: SigningKey): void { - const reason = window.prompt(`Rotation reason for "${key.name}"`, 'Routine rotation'); - if (reason === null) { + toggleCreate(): void { + this.createOpen.update((value) => !value); + this.createError.set(null); + } + + resetCreate(): void { + this.createAlias.set(''); + this.createAlgorithm.set('ed25519'); + this.createMetadata.set(''); + this.createError.set(null); + } + + submitCreate(): void { + const alias = this.createAlias().trim(); + if (!alias) { + this.createError.set('Alias is required.'); return; } - this.mutatingKeyId.set(key.keyId); - this.error.set(null); - this.trustApi.rotateKey(key.keyId, { reason }).subscribe({ - next: (updated) => { - this.keys.update((items) => items.map((item) => item.keyId === updated.keyId ? updated : item)); - this.selectedKey.set(updated); - this.mutatingKeyId.set(null); + const request: CreateSigningKeyRequest = { + alias, + algorithm: this.createAlgorithm(), + metadataJson: this.createMetadata().trim() || undefined, + }; + + this.creating.set(true); + this.createError.set(null); + this.trustApi.createKey(request).subscribe({ + next: (created) => { + this.creating.set(false); + this.createOpen.set(false); + this.resetCreate(); + this.bannerTone.set('success'); + this.banner.set(`Registered signing key ${created.name}.`); + this.selectedKey.set(created); + this.loadKeys(); }, error: (err) => { - this.error.set(err?.error?.error || err?.message || `Failed to rotate "${key.name}".`); - this.mutatingKeyId.set(null); + this.creating.set(false); + this.createError.set(err?.error?.error || err?.message || 'Failed to register signing key.'); }, }); } - revokeKey(key: SigningKey): void { - const reason = window.prompt(`Revocation reason for "${key.name}"`, 'Compromised or retired'); - if (reason === null) { + openMutation(key: SigningKey, kind: KeyMutationKind): void { + this.mutationKey.set(key); + this.mutationKind.set(kind); + this.mutationReason.set(''); + this.mutationError.set(null); + } + + closeMutation(): void { + this.mutationKey.set(null); + this.mutationKind.set(null); + this.mutationReason.set(''); + this.mutationError.set(null); + } + + submitMutation(): void { + const key = this.mutationKey(); + const kind = this.mutationKind(); + const reason = this.mutationReason().trim(); + + if (!key || !kind) { return; } + if (!reason) { + this.mutationError.set('Reason is required.'); + return; + } + + this.mutating.set(true); this.mutatingKeyId.set(key.keyId); - this.error.set(null); + this.mutationError.set(null); + + if (kind === 'rotate') { + this.trustApi.rotateKey(key.keyId, { reason }).subscribe({ + next: () => this.handleMutationSuccess(key.name, 'Rotated'), + error: (err) => this.handleMutationError(err, 'rotate'), + }); + return; + } + this.trustApi.revokeKey(key.keyId, reason).subscribe({ - next: () => { - this.loadKeys(); - this.mutatingKeyId.set(null); - }, - error: (err) => { - this.error.set(err?.error?.error || err?.message || `Failed to revoke "${key.name}".`); - this.mutatingKeyId.set(null); - }, + next: () => this.handleMutationSuccess(key.name, 'Revoked'), + error: (err) => this.handleMutationError(err, 'revoke'), }); } @@ -379,4 +546,20 @@ export class SigningKeyDashboardComponent { return status.charAt(0).toUpperCase() + status.slice(1); } } + + private handleMutationSuccess(keyName: string, verb: 'Rotated' | 'Revoked'): void { + this.mutating.set(false); + this.mutatingKeyId.set(null); + this.bannerTone.set('success'); + this.banner.set(`${verb} signing key ${keyName}.`); + this.closeMutation(); + this.loadKeys(); + } + + private handleMutationError(err: unknown, kind: KeyMutationKind): void { + const error = err as { error?: { error?: string }; message?: string } | undefined; + this.mutating.set(false); + this.mutatingKeyId.set(null); + this.mutationError.set(error?.error?.error || error?.message || `Failed to ${kind} signing key.`); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts index 2ac60a83f..f5cf5d440 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts @@ -75,7 +75,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ {{ overview()!.inventory.keys }} Signing Keys - Administration inventory projection + Keys available for current signing workflows @@ -86,7 +86,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ {{ overview()!.inventory.issuers }} Trusted Issuers - Routed from live administration projection + Publishers currently allowed to influence trust @@ -97,7 +97,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ {{ overview()!.inventory.certificates }} Certificates - Evidence and issuer trust consumers stay linked + Certificate expiry and revocation stay visible here diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts index 888919be0..c670a4a1f 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-analytics.component.spec.ts @@ -1,248 +1,199 @@ -/** - * @file trust-analytics.component.spec.ts - * @sprint SPRINT_20251229_018c_FE - * @description Unit tests for TrustAnalyticsComponent - */ - -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { of, throwError } from 'rxjs'; -import { TrustAnalyticsComponent } from './trust-analytics.component'; + import { TRUST_API, TrustApi } from '../../core/api/trust.client'; -import { - TrustAnalyticsSummary, - VerificationMetrics, - IssuerReliabilityData, - FailureAnalysis, - TrustAnalyticsAlert, -} from '../../core/api/trust.models'; +import { IssuerReliabilityAnalytics, TrustAnalyticsSummary, VerificationAnalytics } from '../../core/api/trust.models'; +import { TrustAnalyticsComponent } from './trust-analytics.component'; describe('TrustAnalyticsComponent', () => { let component: TrustAnalyticsComponent; let fixture: ComponentFixture; let mockTrustApi: jasmine.SpyObj; - const mockSummary: TrustAnalyticsSummary = { - totalVerifications: 10000, - successfulVerifications: 9500, - failedVerifications: 500, - successRate: 95.0, - averageVerificationTime: 125, - peakVerificationTime: 350, - activeSigningKeys: 5, - activeIssuers: 20, - trendDirection: 'up', - trendPercentage: 2.5, - }; - - const mockVerificationMetrics: VerificationMetrics = { - timeRange: 'last_24h', - dataPoints: [ - { timestamp: '2024-01-15T00:00:00Z', successCount: 400, failureCount: 20, avgTime: 120 }, - { timestamp: '2024-01-15T01:00:00Z', successCount: 450, failureCount: 15, avgTime: 115 }, + const summary: TrustAnalyticsSummary = { + verificationSuccessRate: 96.2, + issuerReliabilityScore: 88.3, + certificateHealthScore: 91.4, + keyHealthScore: 94.8, + overallTrustScore: 92.1, + alerts: [ + { + alertId: 'alert-001', + severity: 'warning', + category: 'certificate', + title: 'Gateway certificate expires soon', + message: 'Renew before the release window closes.', + createdAt: '2026-03-15T00:00:00Z', + acknowledged: false, + }, ], - aggregations: { - totalSuccess: 9500, - totalFailure: 500, - avgSuccessRate: 95.0, - avgTime: 125, + trends: { + verificationTrend: 'stable', + reliabilityTrend: 'improving', + certificateTrend: 'declining', + keyTrend: 'stable', }, }; - const mockIssuerReliability: IssuerReliabilityData[] = [ - { - issuerId: 'issuer-001', - issuerName: 'Vendor A', - totalDocuments: 500, - verifiedDocuments: 490, - failedDocuments: 10, - reliabilityScore: 98.0, - avgResponseTime: 100, - lastVerifiedAt: '2024-01-15T10:00:00Z', + const verificationAnalytics: VerificationAnalytics = { + timeRange: '7d', + granularity: 'daily', + summary: { + totalVerifications: 120, + successfulVerifications: 116, + failedVerifications: 4, + successRate: 96.7, + averageLatencyMs: 24, + p95LatencyMs: 64, + p99LatencyMs: 114, }, - ]; - - const mockFailureAnalysis: FailureAnalysis = { - timeRange: 'last_7d', - totalFailures: 500, - failuresByType: [ - { type: 'signature_invalid', count: 200, percentage: 40 }, - { type: 'certificate_expired', count: 150, percentage: 30 }, - { type: 'chain_incomplete', count: 100, percentage: 20 }, - { type: 'unknown', count: 50, percentage: 10 }, + trend: [ + { + timestamp: '2026-03-15T00:00:00Z', + totalVerifications: 17, + successfulVerifications: 16, + failedVerifications: 1, + successRate: 94.1, + averageLatencyMs: 23, + }, ], - failuresByIssuer: [ - { issuerId: 'issuer-002', issuerName: 'Vendor B', count: 100, percentage: 20 }, + byResourceType: { + keys: { + totalVerifications: 40, + successfulVerifications: 39, + failedVerifications: 1, + successRate: 97.5, + averageLatencyMs: 18, + p95LatencyMs: 58, + p99LatencyMs: 108, + }, + certificates: { + totalVerifications: 50, + successfulVerifications: 47, + failedVerifications: 3, + successRate: 94, + averageLatencyMs: 31, + p95LatencyMs: 71, + p99LatencyMs: 121, + }, + issuers: { + totalVerifications: 30, + successfulVerifications: 30, + failedVerifications: 0, + successRate: 100, + averageLatencyMs: 19, + p95LatencyMs: 59, + p99LatencyMs: 109, + }, + signatures: { + totalVerifications: 100, + successfulVerifications: 96, + failedVerifications: 4, + successRate: 96, + averageLatencyMs: 26, + p95LatencyMs: 66, + p99LatencyMs: 116, + }, + }, + failureReasons: [ + { + reason: 'Certificates expiring soon', + count: 2, + percentage: 1.7, + trend: 'increasing', + }, ], - recentFailures: [], }; - const mockAlerts: TrustAnalyticsAlert[] = [ - { - alertId: 'alert-001', - severity: 'warning', - title: 'Verification Rate Declining', - description: 'Verification success rate dropped below 95%', - timestamp: '2024-01-15T10:00:00Z', - acknowledged: false, - }, - ]; + const issuerReliabilityAnalytics: IssuerReliabilityAnalytics = { + timeRange: '7d', + granularity: 'daily', + issuers: [ + { + issuerId: 'issuer-001', + issuerName: 'core-root-ca', + issuerDisplayName: 'Core Root CA', + trustScore: 88, + trustLevel: 'partial', + totalDocuments: 18, + verifiedDocuments: 16, + verificationRate: 88.9, + averageResponseTime: 28, + uptimePercentage: 98.2, + lastVerificationAt: '2026-03-15T00:00:00Z', + reliabilityScore: 89.1, + trendDirection: 'improving', + }, + ], + aggregatedTrend: [ + { + timestamp: '2026-03-15T00:00:00Z', + reliabilityScore: 89.1, + verificationRate: 88.9, + trustScore: 88, + documentsProcessed: 18, + }, + ], + topPerformers: [], + underperformers: [], + averageReliabilityScore: 89.1, + averageVerificationRate: 88.9, + }; beforeEach(async () => { mockTrustApi = jasmine.createSpyObj('TrustApi', [ 'getAnalyticsSummary', - 'getVerificationMetrics', - 'getIssuerReliability', - 'getFailureAnalysis', - 'getAnalyticsAlerts', - 'acknowledgeAlert', - 'dismissAlert', + 'getVerificationAnalytics', + 'getIssuerReliabilityAnalytics', + 'acknowledgeAnalyticsAlert', ]); - mockTrustApi.getAnalyticsSummary.and.returnValue(of(mockSummary)); - mockTrustApi.getVerificationMetrics.and.returnValue(of(mockVerificationMetrics)); - mockTrustApi.getIssuerReliability.and.returnValue(of(mockIssuerReliability)); - mockTrustApi.getFailureAnalysis.and.returnValue(of(mockFailureAnalysis)); - mockTrustApi.getAnalyticsAlerts.and.returnValue(of(mockAlerts)); - mockTrustApi.acknowledgeAlert.and.returnValue(of(void 0)); - mockTrustApi.dismissAlert.and.returnValue(of(void 0)); + mockTrustApi.getAnalyticsSummary.and.returnValue(of(summary)); + mockTrustApi.getVerificationAnalytics.and.returnValue(of(verificationAnalytics)); + mockTrustApi.getIssuerReliabilityAnalytics.and.returnValue(of(issuerReliabilityAnalytics)); + mockTrustApi.acknowledgeAnalyticsAlert.and.returnValue(of(void 0)); await TestBed.configureTestingModule({ imports: [TrustAnalyticsComponent], - providers: [ - { provide: TRUST_API, useValue: mockTrustApi }, - ], + providers: [{ provide: TRUST_API, useValue: mockTrustApi }], }).compileComponents(); fixture = TestBed.createComponent(TrustAnalyticsComponent); component = fixture.componentInstance; }); - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should load analytics data on init', fakeAsync(() => { + it('loads derived analytics data on init', () => { fixture.detectChanges(); - tick(); expect(mockTrustApi.getAnalyticsSummary).toHaveBeenCalled(); - expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalled(); - expect(mockTrustApi.getIssuerReliability).toHaveBeenCalled(); - expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalled(); - expect(mockTrustApi.getAnalyticsAlerts).toHaveBeenCalled(); - })); + expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' }); + expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '7d', granularity: 'daily' }); + expect(component.summary()).toEqual(summary); + }); - it('should display summary data', fakeAsync(() => { + it('reloads analytics when the time range changes', () => { fixture.detectChanges(); - tick(); - expect(component.summary()).toEqual(mockSummary); - })); + component.onTimeRangeChange('24h'); - it('should display verification metrics', fakeAsync(() => { + expect(mockTrustApi.getVerificationAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' }); + expect(mockTrustApi.getIssuerReliabilityAnalytics).toHaveBeenCalledWith({ timeRange: '24h', granularity: 'hourly' }); + }); + + it('acknowledges alerts in local state after the API call', () => { fixture.detectChanges(); - tick(); - - expect(component.verificationMetrics()).toEqual(mockVerificationMetrics); - })); - - it('should display issuer reliability data', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - expect(component.issuerReliability().length).toBe(1); - expect(component.issuerReliability()[0].issuerName).toBe('Vendor A'); - })); - - it('should display failure analysis', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - expect(component.failureAnalysis()).toEqual(mockFailureAnalysis); - })); - - it('should display alerts', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - expect(component.alerts().length).toBe(1); - expect(component.alerts()[0].title).toBe('Verification Rate Declining'); - })); - - it('should change time range and reload data', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.selectedTimeRange.set('last_7d'); - component.onTimeRangeChange(); - tick(); - - expect(mockTrustApi.getVerificationMetrics).toHaveBeenCalledWith('last_7d'); - expect(mockTrustApi.getFailureAnalysis).toHaveBeenCalledWith('last_7d'); - })); - - it('should acknowledge alert', fakeAsync(() => { - fixture.detectChanges(); - tick(); component.acknowledgeAlert('alert-001'); - tick(); - expect(mockTrustApi.acknowledgeAlert).toHaveBeenCalledWith('alert-001'); - })); - - it('should dismiss alert', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - component.dismissAlert('alert-001'); - tick(); - - expect(mockTrustApi.dismissAlert).toHaveBeenCalledWith('alert-001'); - })); - - it('should handle loading state', () => { - expect(component.loading()).toBeTrue(); - fixture.detectChanges(); - expect(component.loading()).toBeFalse(); + expect(mockTrustApi.acknowledgeAnalyticsAlert).toHaveBeenCalledWith('alert-001'); + expect(component.unacknowledgedAlerts()).toEqual([]); }); - it('should handle error state', fakeAsync(() => { - mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('Failed'))); + it('surfaces load failures', () => { + mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('analytics unavailable'))); fixture.detectChanges(); - tick(); - expect(component.error()).toBe('Failed'); - })); - - it('should format time range correctly', () => { - expect(component.formatTimeRange('last_24h')).toBe('Last 24 Hours'); - expect(component.formatTimeRange('last_7d')).toBe('Last 7 Days'); - expect(component.formatTimeRange('last_30d')).toBe('Last 30 Days'); + expect(component.error()).toBe('Failed to load analytics data. Please try again.'); }); - - it('should format failure type correctly', () => { - expect(component.formatFailureType('signature_invalid')).toBe('Invalid Signature'); - expect(component.formatFailureType('certificate_expired')).toBe('Certificate Expired'); - expect(component.formatFailureType('chain_incomplete')).toBe('Incomplete Chain'); - }); - - it('should compute unacknowledged alerts count', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - expect(component.unacknowledgedAlerts()).toBe(1); - })); - - it('should refresh data', fakeAsync(() => { - fixture.detectChanges(); - tick(); - - const initialCalls = mockTrustApi.getAnalyticsSummary.calls.count(); - component.refreshData(); - tick(); - - expect(mockTrustApi.getAnalyticsSummary.calls.count()).toBe(initialCalls + 1); - })); }); diff --git a/src/Web/StellaOps.Web/src/tests/settings/admin-settings-page.component.spec.ts b/src/Web/StellaOps.Web/src/tests/settings/admin-settings-page.component.spec.ts index 3ce4433c8..4d001cbde 100644 --- a/src/Web/StellaOps.Web/src/tests/settings/admin-settings-page.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/settings/admin-settings-page.component.spec.ts @@ -3,8 +3,11 @@ import { of, throwError } from 'rxjs'; import { AUTHORITY_ADMIN_API, - type AuthorityAdminApi, + type AdminRole, + type AdminTenant, type AdminUser, + type AuthorityAdminApi, + type RoleImpactPreview, } from '../../app/core/api/authority-admin.client'; import { AdminSettingsPageComponent } from '../../app/features/settings/admin/admin-settings-page.component'; @@ -16,29 +19,140 @@ function createAuthorityApiSpy(): jasmine.SpyObj { listTokens: jasmine.createSpy('listTokens'), listTenants: jasmine.createSpy('listTenants'), createUser: jasmine.createSpy('createUser'), + updateUser: jasmine.createSpy('updateUser'), + disableUser: jasmine.createSpy('disableUser'), + enableUser: jasmine.createSpy('enableUser'), + createRole: jasmine.createSpy('createRole'), + updateRole: jasmine.createSpy('updateRole'), + previewRoleImpact: jasmine.createSpy('previewRoleImpact'), + createTenant: jasmine.createSpy('createTenant'), + updateTenant: jasmine.createSpy('updateTenant'), + suspendTenant: jasmine.createSpy('suspendTenant'), + resumeTenant: jasmine.createSpy('resumeTenant'), } as jasmine.SpyObj; } describe('AdminSettingsPageComponent (settings)', () => { let api: jasmine.SpyObj; let component: AdminSettingsPageComponent; + let users: AdminUser[]; + let roles: AdminRole[]; + let tenants: AdminTenant[]; + let roleImpact: RoleImpactPreview; beforeEach(async () => { api = createAuthorityApiSpy(); - api.listUsers.and.returnValue(of([])); - api.listRoles.and.returnValue(of([])); + users = [{ + id: 'u-1', + username: 'jane', + email: 'jane@example.com', + displayName: 'Jane Example', + roles: ['role/console-viewer'], + status: 'active', + createdAt: '2026-02-19T00:00:00Z', + }]; + roles = [ + { + id: 'role/console-admin', + name: 'role/console-admin', + description: 'Full platform administration.', + permissions: ['ui.admin', 'authority:users.write'], + userCount: 1, + isBuiltIn: true, + }, + { + id: 'role/console-viewer', + name: 'role/console-viewer', + description: 'Least privilege console access.', + permissions: ['ui.read', 'authority:users.read'], + userCount: 1, + isBuiltIn: true, + }, + { + id: 'role/custom-release-operator', + name: 'role/custom-release-operator', + description: 'Custom release operator role.', + permissions: ['release:read', 'release:write'], + userCount: 0, + isBuiltIn: false, + }, + ]; + tenants = [{ + id: 'default', + displayName: 'Default Tenant', + status: 'active', + isolationMode: 'shared', + userCount: 1, + createdAt: '2026-02-19T00:00:00Z', + }]; + roleImpact = { + affectedUsers: 2, + affectedClients: 0, + message: '2 users would be affected by changes to this role.', + }; + + api.listUsers.and.returnValue(of(users)); + api.listRoles.and.returnValue(of(roles)); api.listClients.and.returnValue(of([])); api.listTokens.and.returnValue(of([])); - api.listTenants.and.returnValue(of([])); + api.listTenants.and.returnValue(of(tenants)); api.createUser.and.returnValue(of({ id: 'u-created', username: 'created', email: 'created@example.com', displayName: 'Created User', - roles: ['viewer'], + roles: ['role/console-viewer'], status: 'active', createdAt: '2026-02-19T00:00:00Z', - } as AdminUser)); + })); + api.updateUser.and.callFake((userId, request) => of({ + ...users.find((entry) => entry.id === userId)!, + displayName: request.displayName ?? users[0].displayName, + roles: request.roles ?? users[0].roles, + })); + api.disableUser.and.callFake((userId) => of({ + ...users.find((entry) => entry.id === userId)!, + status: 'disabled', + })); + api.enableUser.and.callFake((userId) => of({ + ...users.find((entry) => entry.id === userId)!, + status: 'active', + })); + api.createRole.and.callFake((request) => of({ + id: request.name, + name: request.name, + description: request.description, + permissions: request.permissions, + userCount: 0, + isBuiltIn: false, + })); + api.updateRole.and.callFake((roleId, request) => of({ + ...roles.find((entry) => entry.id === roleId)!, + description: request.description ?? roles[2].description, + permissions: request.permissions ?? roles[2].permissions, + })); + api.previewRoleImpact.and.returnValue(of(roleImpact)); + api.createTenant.and.callFake((request) => of({ + id: request.id, + displayName: request.displayName, + status: 'active', + isolationMode: request.isolationMode, + userCount: 0, + createdAt: '2026-02-19T00:00:00Z', + })); + api.updateTenant.and.callFake((tenantId, request) => of({ + ...tenants.find((entry) => entry.id === tenantId)!, + displayName: request.displayName ?? tenants[0].displayName, + isolationMode: request.isolationMode ?? tenants[0].isolationMode, + })); + api.suspendTenant.and.callFake((tenantId) => of({ + ...tenants.find((entry) => entry.id === tenantId)!, + status: 'disabled', + })); + api.resumeTenant.and.callFake((tenantId) => of({ + ...tenants.find((entry) => entry.id === tenantId)!, + status: 'active', + })); await TestBed.configureTestingModule({ imports: [AdminSettingsPageComponent], @@ -49,6 +163,8 @@ describe('AdminSettingsPageComponent (settings)', () => { }); it('keeps empty state distinct from error state when users API returns empty list', () => { + api.listUsers.and.returnValue(of([])); + component.ngOnInit(); expect(component.users()).toEqual([]); @@ -62,7 +178,99 @@ describe('AdminSettingsPageComponent (settings)', () => { component.ngOnInit(); expect(component.users()).toEqual([]); - expect(component.error()).toContain('Failed to load users'); + expect(component.error()).toContain('authority unavailable'); expect(component.loading()).toBeFalse(); }); + + it('defaults new users to the least-privilege role and validates email format', () => { + component.ngOnInit(); + + component.openCreateUserEditor(); + + expect(component.userEditor?.selectedRoles).toEqual(['role/console-viewer']); + + component.userEditor!.username = 'sam'; + component.userEditor!.email = 'invalid-email'; + component.userEditor!.displayName = 'Sam Operator'; + component.saveUser(); + + expect(api.createUser).not.toHaveBeenCalled(); + expect(component.error()).toContain('valid email'); + }); + + it('updates users and toggles lifecycle actions from the setup surface', () => { + component.ngOnInit(); + + component.openEditUserEditor(users[0]); + component.userEditor!.displayName = 'Jane Updated'; + component.userEditor!.selectedRoles = ['role/console-admin']; + component.saveUser(); + + expect(api.updateUser).toHaveBeenCalledWith('u-1', { + displayName: 'Jane Updated', + roles: ['role/console-admin'], + }); + expect(component.users()[0].displayName).toBe('Jane Updated'); + expect(component.users()[0].roles).toEqual(['role/console-admin']); + + component.disableUser(component.users()[0]); + expect(api.disableUser).toHaveBeenCalledWith('u-1'); + expect(component.users()[0].status).toBe('disabled'); + + component.enableUser(component.users()[0]); + expect(api.enableUser).toHaveBeenCalledWith('u-1'); + expect(component.users()[0].status).toBe('active'); + }); + + it('shows role detail impact and allows editing custom roles', () => { + component.ngOnInit(); + + component.selectRole(roles[2]); + + expect(api.previewRoleImpact).toHaveBeenCalledWith('role/custom-release-operator'); + expect(component.roleImpact()).toEqual(roleImpact); + expect(component.selectedRole()?.name).toBe('role/custom-release-operator'); + + component.openEditRoleEditor(roles[2]); + component.roleEditor!.description = 'Release operator with approvals.'; + component.roleEditor!.selectedScopes = ['release:read', 'release:write', 'release:publish']; + component.saveRole(); + + expect(api.updateRole).toHaveBeenCalledWith('role/custom-release-operator', { + description: 'Release operator with approvals.', + permissions: ['release:read', 'release:write', 'release:publish'], + }); + expect(component.successMessage()).toContain('Updated role'); + }); + + it('validates tenant ids and supports tenant lifecycle actions', () => { + component.setTab('tenants'); + component.openCreateTenantEditor(); + + component.tenantEditor!.id = 'Bad Tenant'; + component.tenantEditor!.displayName = 'Bad Tenant'; + component.saveTenant(); + + expect(api.createTenant).not.toHaveBeenCalled(); + expect(component.error()).toContain('lowercase letters, digits, and hyphens'); + + component.tenantEditor!.id = 'sandbox'; + component.tenantEditor!.displayName = 'Sandbox'; + component.tenantEditor!.isolationMode = 'dedicated'; + component.saveTenant(); + + expect(api.createTenant).toHaveBeenCalledWith({ + id: 'sandbox', + displayName: 'Sandbox', + isolationMode: 'dedicated', + }); + + component.suspendTenant(tenants[0]); + expect(api.suspendTenant).toHaveBeenCalledWith('default'); + expect(component.tenants()[0].status).toBe('disabled'); + + component.resumeTenant(component.tenants()[0]); + expect(api.resumeTenant).toHaveBeenCalledWith('default'); + expect(component.tenants()[0].status).toBe('active'); + }); }); diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index e066a5abc..5a191a97f 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -8,6 +8,7 @@ "src/app/types/monaco-workers.d.ts", "src/app/core/branding/branding.service.spec.ts", "src/app/core/api/first-signal.client.spec.ts", + "src/app/core/api/trust.client.spec.ts", "src/app/core/api/vulnerability-http.client.spec.ts", "src/app/core/api/watchlist.client.spec.ts", "src/app/core/auth/tenant-activation.service.spec.ts", @@ -38,13 +39,18 @@ "src/app/features/releases/release-ops-overview-page.component.spec.ts", "src/app/features/registry-admin/components/plan-audit.component.spec.ts", "src/app/features/registry-admin/registry-admin.component.spec.ts", + "src/app/features/trust-admin/certificate-inventory.component.spec.ts", + "src/app/features/trust-admin/issuer-trust-list.component.spec.ts", "src/app/features/trust-admin/trust-admin.component.spec.ts", + "src/app/features/trust-admin/trust-analytics.component.spec.ts", + "src/app/features/trust-admin/signing-key-dashboard.component.spec.ts", "src/app/features/triage/services/ttfs-telemetry.service.spec.ts", "src/app/features/triage/triage-workspace.component.spec.ts", "src/app/features/vex-hub/vex-hub-stats.component.spec.ts", "src/app/features/vex-hub/vex-hub-source-contract.spec.ts", "src/app/shared/ui/filter-bar/filter-bar.component.spec.ts", "src/app/features/watchlist/watchlist-page.component.spec.ts", - "src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts" + "src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts", + "src/tests/settings/admin-settings-page.component.spec.ts" ] }