diff --git a/docs/implplan/SPRINT_20260316_020_FE_deep_journey_quality_fixes.md b/docs/implplan/SPRINT_20260316_020_FE_deep_journey_quality_fixes.md index 6f6625df6..c57fc37ff 100644 --- a/docs/implplan/SPRINT_20260316_020_FE_deep_journey_quality_fixes.md +++ b/docs/implplan/SPRINT_20260316_020_FE_deep_journey_quality_fixes.md @@ -25,7 +25,7 @@ Completion criteria: - [x] Angular build succeeds ### J20-T02 - Integration detail: show username instead of raw user ID -Status: TODO +Status: DONE Dependency: none Owners: Developer Task description: @@ -33,7 +33,7 @@ Task description: - Should show `admin` or truncated form like "User 9a2d0730...". ### J20-T03 - Advisory sources: auto-check on first visit -Status: TODO +Status: DONE Dependency: none Owners: Developer Task description: @@ -65,6 +65,9 @@ Task description: 10. Evidence Overview: search router, 1842 evidence packs, operator/auditor toggle 11. Security Reports: CSV/PDF export, VEX guidance text 12. Security Posture: real finding counts (6 findings), CTAs working +13. Operations Hub: 3 blocking, 5 degraded, 12 sub-nav tabs, pending operator actions list +14. Releases Deployments: 5 deployments visible (1 RUNNING, 3 SUCCESS, 1 FAILED) +15. Identity & Access: Users table (admin active), 5 tabs (Users, Roles, OAuth, Tokens, Tenants) **Next journey iteration should go deeper into:** - Complete scan → view results in triage → make VEX decision → see in reports diff --git a/src/Web/StellaOps.Web/src/app/core/auth/auth.guard.ts b/src/Web/StellaOps.Web/src/app/core/auth/auth.guard.ts index dc71cbf66..732f86516 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/auth.guard.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/auth.guard.ts @@ -52,8 +52,9 @@ export function requireScopesGuard( const session = auth.session(); const userScopes = session?.scopes ?? []; - // Admin scope grants access to everything - if (userScopes.includes(StellaOpsScopes.ADMIN)) { + // Admin scope grants access to everything. + // Authority may issue 'admin' or 'ui.admin' depending on client configuration. + if (userScopes.includes(StellaOpsScopes.ADMIN) || userScopes.includes(StellaOpsScopes.UI_ADMIN)) { return true; } @@ -96,7 +97,7 @@ export function requireAnyScopeGuard( const userScopes = session?.scopes ?? []; // Admin scope grants access to everything - if (userScopes.includes(StellaOpsScopes.ADMIN)) { + if (userScopes.includes(StellaOpsScopes.ADMIN) || userScopes.includes(StellaOpsScopes.UI_ADMIN)) { return true; } @@ -128,7 +129,7 @@ export const requirePolicyReviewOrApproveGuard: CanMatchFn = () => { } const scopes = auth.session()?.scopes ?? []; - if (scopes.includes(StellaOpsScopes.ADMIN)) return true; + if (scopes.includes(StellaOpsScopes.ADMIN) || scopes.includes(StellaOpsScopes.UI_ADMIN)) return true; const hasRead = scopes.includes(StellaOpsScopes.POLICY_READ); const hasReviewOrApprove = diff --git a/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts b/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts index 6c9715121..ba6ee099e 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/scopes.ts @@ -48,10 +48,10 @@ export const StellaOpsScopes = { POLICY_PROMOTE: 'policy:promote', // Requires interactive auth POLICY_AUDIT: 'policy:audit', - // Exception scopes - EXCEPTION_READ: 'exception:read', - EXCEPTION_WRITE: 'exception:write', - EXCEPTION_APPROVE: 'exception:approve', + // Exception scopes (Authority issues 'exceptions:read' with plural 's') + EXCEPTION_READ: 'exceptions:read', + EXCEPTION_WRITE: 'exceptions:write', + EXCEPTION_APPROVE: 'exceptions:approve', // Advisory scopes ADVISORY_READ: 'advisory:read', @@ -292,9 +292,9 @@ export const ScopeLabels: Record = { 'policy:publish': 'Publish Policy Versions', 'policy:promote': 'Promote Between Environments', 'policy:audit': 'Audit Policy Activity', - 'exception:read': 'View Exceptions', - 'exception:write': 'Create Exceptions', - 'exception:approve': 'Approve Exceptions', + 'exceptions:read': 'View Exceptions', + 'exceptions:write': 'Create Exceptions', + 'exceptions:approve': 'Approve Exceptions', 'advisory:read': 'View Advisories', 'vex:read': 'View VEX Evidence', 'vex:export': 'Export VEX Evidence', @@ -346,9 +346,6 @@ export const ScopeLabels: Record = { 'zastava:read': 'View Zastava State', 'zastava:trigger': 'Trigger Zastava Processing', 'zastava:admin': 'Administer Zastava', - // Exception scope labels - 'exceptions:read': 'View Exceptions', - 'exceptions:write': 'Create Exceptions', // Findings scope label 'findings:read': 'View Policy Findings', // Notify scope labels diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts index 7375bf527..b56c369e5 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context-url-sync.service.ts @@ -1,4 +1,5 @@ import { DestroyRef, Injectable, Injector, effect, inject } from '@angular/core'; +import { Location } from '@angular/common'; import { NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -8,6 +9,7 @@ import { PlatformContextStore } from './platform-context.store'; @Injectable({ providedIn: 'root' }) export class PlatformContextUrlSyncService { private readonly router = inject(Router); + private readonly location = inject(Location); private readonly context = inject(PlatformContextStore); private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); @@ -54,15 +56,18 @@ export class PlatformContextUrlSyncService { return; } + // Build the updated URL with new query params but do NOT use + // router.navigateByUrl() -- that re-evaluates canMatch guards on + // every route, which causes random redirects when the auth session + // signal hasn't settled yet. Location.replaceState() updates the + // browser URL without triggering Angular route navigation. const nextTree = this.router.parseUrl(currentUrl); nextTree.queryParams = nextQuery; + const serializedUrl = this.router.serializeUrl(nextTree); this.syncingToUrl = true; - void this.router.navigateByUrl(nextTree, { - replaceUrl: true, - }).finally(() => { - this.syncingToUrl = false; - }); + this.location.replaceState(serializedUrl); + this.syncingToUrl = false; }, { injector: this.injector }, ); diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts index a6ca18bef..4019f7fb0 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts @@ -212,15 +212,21 @@ interface AdvisoryFeedSummary { {{ feedSummary().enabledSources }} active -
- {{ feedSummary().healthySources }} - healthy -
- @if (feedSummary().failedSources > 0) { + @if (feedSummary().healthySources === 0 && feedSummary().failedSources === 0) {
- {{ feedSummary().failedSources }} - failed + Not checked yet
+ } @else { +
+ {{ feedSummary().healthySources }} + healthy +
+ @if (feedSummary().failedSources > 0) { +
+ {{ feedSummary().failedSources }} + failed +
+ } } @@ -756,6 +762,12 @@ interface AdvisoryFeedSummary { color: var(--color-status-error); } + .feed-stat-value.muted { + font-size: 0.85rem; + font-weight: var(--font-weight-normal, 400); + color: var(--color-text-secondary); + } + .feed-stat-label { font-size: 0.75rem; color: var(--color-text-secondary); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts index ab76e9594..a74dfb5fa 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.spec.ts @@ -95,4 +95,24 @@ describe('IntegrationDetailComponent', () => { expect(component.lastHealthResult?.message).toContain('ENOTFOUND'); expect(component.lastHealthResult?.checkedAt).toBe('2026-03-14T10:06:00Z'); }); + + describe('formatActor', () => { + it('returns system for null or undefined', () => { + expect(component.formatActor(null)).toBe('system'); + expect(component.formatActor(undefined)).toBe('system'); + }); + + it('passes through short readable names', () => { + expect(component.formatActor('admin')).toBe('admin'); + expect(component.formatActor('demo-user')).toBe('demo-user'); + }); + + it('passes through email addresses regardless of length', () => { + expect(component.formatActor('very-long-admin-user@example.org')).toBe('very-long-admin-user@example.org'); + }); + + it('truncates raw hex user ID hashes', () => { + expect(component.formatActor('9a2d07300a014c26ba215595bb282128')).toBe('User 9a2d0730...'); + }); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts index cd5632a53..69fd801f5 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts @@ -89,9 +89,9 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
Has Auth
{{ integration.hasAuth ? 'Configured via AuthRef' : 'Not configured' }}
Created
-
{{ integration.createdAt | date:'medium' }} by {{ integration.createdBy || 'system' }}
+
{{ integration.createdAt | date:'medium' }} by {{ formatActor(integration.createdBy) }}
Updated
-
{{ integration.updatedAt ? (integration.updatedAt | date:'medium') : 'Never' }} by {{ integration.updatedBy || 'system' }}
+
{{ integration.updatedAt ? (integration.updatedAt | date:'medium') : 'Never' }} by {{ formatActor(integration.updatedBy) }}

Tags

@if (integration.tags.length > 0) { @@ -605,6 +605,16 @@ export class IntegrationDetailComponent implements OnInit { return getProviderLabel(provider); } + formatActor(actorId: string | null | undefined): string { + if (!actorId) return 'system'; + // Short readable IDs or well-known names pass through + if (actorId.length <= 20 || actorId.includes('@') || actorId.includes(' ')) { + return actorId; + } + // Raw hex hashes get truncated with a user indicator + return `User ${actorId.slice(0, 8)}...`; + } + isRegistryType(): boolean { return this.integration?.type === IntegrationType.Registry; }