diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index e85367f64..52e66ad62 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -79,6 +79,11 @@ const requireSetupGuard = requireAnyScopeGuard( '/console/profile', ); +const requireConsoleAdminGuard = requireAnyScopeGuard( + [StellaOpsScopes.UI_ADMIN], + '/console/profile', +); + function preserveAppRedirect(template: string) { return ({ params, @@ -245,7 +250,7 @@ export const routes: Routes = [ { path: 'console-admin', title: 'Console Admin', - canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireConsoleAdminGuard], data: { breadcrumb: 'Console Admin' }, loadChildren: () => import('./features/console-admin/console-admin.routes').then((m) => m.consoleAdminRoutes), }, diff --git a/src/Web/StellaOps.Web/src/app/core/auth/auth-session.store.ts b/src/Web/StellaOps.Web/src/app/core/auth/auth-session.store.ts index eeca6daa8..30bb855f7 100644 --- a/src/Web/StellaOps.Web/src/app/core/auth/auth-session.store.ts +++ b/src/Web/StellaOps.Web/src/app/core/auth/auth-session.store.ts @@ -47,7 +47,17 @@ export class AuthSessionStore { * browser session once the user has logged in at least once. Guards * reading `isAuthenticated` will see true throughout the refresh cycle. */ - private wasEverAuthenticated = false; + private static readonly AUTH_LATCH_KEY = 'stellaops:wasEverAuth'; + private get wasEverAuthenticated(): boolean { + try { return sessionStorage.getItem(AuthSessionStore.AUTH_LATCH_KEY) === 'true'; } + catch { return false; } + } + private set wasEverAuthenticated(value: boolean) { + try { + if (value) sessionStorage.setItem(AuthSessionStore.AUTH_LATCH_KEY, 'true'); + else sessionStorage.removeItem(AuthSessionStore.AUTH_LATCH_KEY); + } catch { /* SSR or private browsing */ } + } readonly isAuthenticated = computed(() => { const hasSession = this.sessionSignal() !== null; const notLoading = this.statusSignal() !== 'loading'; @@ -55,8 +65,9 @@ export class AuthSessionStore { if (authenticated) { this.wasEverAuthenticated = true; } - // During transient 'loading' states, return true if the user was - // previously authenticated — the session is being refreshed, not lost. + // During transient 'loading' states (token refresh, page reload), + // return true if the user was previously authenticated in this browser + // session. The latch persists in sessionStorage so it survives F5. return authenticated || (this.wasEverAuthenticated && this.statusSignal() === 'loading'); }); @@ -89,6 +100,7 @@ export class AuthSessionStore { } clear(): void { + this.wasEverAuthenticated = false; this.sessionSignal.set(null); this.statusSignal.set('unauthenticated'); this.persistedSignal.set(null); diff --git a/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts b/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts index e129c6e5a..fe2383d53 100644 --- a/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/config/backend-probe.service.ts @@ -21,6 +21,8 @@ export class BackendProbeService { readonly probeError = signal(null); private probePromise: Promise | null = null; + private recoveryTimer: ReturnType | null = null; + private wasEverReachable = false; constructor(httpBackend: HttpBackend, configService: AppConfigService) { this.http = new HttpClient(httpBackend); @@ -30,25 +32,71 @@ export class BackendProbeService { /** * Probes the authority OIDC discovery endpoint. * Sets probeStatus to 'reachable' on success, 'unreachable' on failure. + * When unreachable, automatically schedules recovery re-probes. * Never throws — fails gracefully. */ probe(): Promise { this.probeStatus.set('pending'); this.probeError.set(null); - this.probePromise = this.executeProbe(); + this.probePromise = this.executeProbe().then(() => { + if (this.probeStatus() === 'reachable') { + this.wasEverReachable = true; + this.cancelRecovery(); + } else { + this.scheduleRecovery(); + } + }); return this.probePromise; } /** * Returns a promise that resolves once the probe status leaves 'pending'. - * If no probe is in flight, resolves immediately. + * If the probe was previously reachable in this session and is now + * unreachable, triggers an immediate re-probe before resolving. */ - waitForResult(): Promise { - if (this.probeStatus() !== 'pending' || !this.probePromise) { - return Promise.resolve(); + async waitForResult(): Promise { + if (this.probeStatus() !== 'pending' && !this.probePromise) { + // If previously reachable but now unreachable, try once more + if (this.probeStatus() === 'unreachable' && this.wasEverReachable) { + await this.executeProbe(); + if (this.probeStatus() === 'reachable') { + this.cancelRecovery(); + } + } + return; + } + if (this.probePromise) { + await this.probePromise; + } + } + + /** + * Schedules periodic re-probes when status is 'unreachable'. + * Retries every 10 seconds, up to 5 times. + */ + private scheduleRecovery(): void { + if (this.recoveryTimer) return; + let retries = 0; + this.recoveryTimer = setInterval(async () => { + if (this.probeStatus() === 'reachable' || retries >= 5) { + this.cancelRecovery(); + return; + } + retries++; + await this.executeProbe(); + if (this.probeStatus() === 'reachable') { + this.wasEverReachable = true; + this.cancelRecovery(); + } + }, 10_000); + } + + private cancelRecovery(): void { + if (this.recoveryTimer) { + clearInterval(this.recoveryTimer); + this.recoveryTimer = null; } - return this.probePromise; } private async executeProbe(): Promise { diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts index a306cdd24..57a6c4745 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin.routes.ts @@ -1,7 +1,12 @@ import { Routes } from '@angular/router'; -import { requireAuthGuard } from '../../core/auth/auth.guard'; +import { requireAnyScopeGuard } from '../../core/auth/auth.guard'; import { StellaOpsScopes } from '../../core/auth/scopes'; +const requireConsoleAdminGuard = requireAnyScopeGuard( + [StellaOpsScopes.UI_ADMIN], + '/console/profile', +); + /** * Console Admin Routes * @@ -11,7 +16,7 @@ import { StellaOpsScopes } from '../../core/auth/scopes'; export const consoleAdminRoutes: Routes = [ { path: '', - canMatch: [requireAuthGuard], + canMatch: [requireConsoleAdminGuard], data: { requiredScopes: [StellaOpsScopes.UI_ADMIN] }, loadComponent: () => import('./console-admin-layout.component').then( diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts index 8d3eedfd3..c8e8bf36a 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts @@ -334,6 +334,22 @@ export class IntegrationListComponent implements OnInit { protected readonly IntegrationStatus = IntegrationStatus; + /** Maps raw route data type strings to human-readable display names. */ + private static readonly TYPE_DISPLAY_NAMES: Record = { + Registry: 'Registry', + Scm: 'SCM Integration', + Ci: 'CI/CD Pipeline', + CiCd: 'CI/CD Pipeline', + RuntimeHost: 'Runtime Host', + Host: 'Runtime Host', + RepoSource: 'Secrets Vault', + FeedMirror: 'Feed Mirror', + Feed: 'Feed Mirror', + SymbolSource: 'Symbol Source', + Marketplace: 'Marketplace', + Notification: 'Notification Provider', + }; + integrations: Integration[] = []; loading = true; typeLabel = 'All'; @@ -353,7 +369,8 @@ export class IntegrationListComponent implements OnInit { const typeFromRoute = this.route.snapshot.data['type']; if (typeFromRoute) { this.integrationType = this.parseType(typeFromRoute); - this.typeLabel = typeFromRoute; + this.typeLabel = + IntegrationListComponent.TYPE_DISPLAY_NAMES[typeFromRoute] ?? typeFromRoute; } this.loadIntegrations(); } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts index f6daa1394..aab265c5a 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts @@ -171,15 +171,15 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; }) export class PolicyGovernanceComponent { protected readonly tabs = [ - { id: 'budget', label: 'Risk Budget', route: './', exact: true, badge: null, badgeType: null }, - { id: 'trust', label: 'Trust Weights', route: './trust-weights', exact: false, badge: null, badgeType: null }, - { id: 'staleness', label: 'Staleness', route: './staleness', exact: false, badge: null, badgeType: null }, - { id: 'sealed', label: 'Sealed Mode', route: './sealed-mode', exact: false, badge: null, badgeType: null }, - { id: 'profiles', label: 'Profiles', route: './profiles', exact: false, badge: null, badgeType: null }, - { id: 'validator', label: 'Validator', route: './validator', exact: false, badge: null, badgeType: null }, - { id: 'audit', label: 'Audit Log', route: './audit', exact: false, badge: null, badgeType: null }, - { id: 'conflicts', label: 'Conflicts', route: './conflicts', exact: false, badge: '2', badgeType: 'warning' }, - { id: 'schema-playground', label: 'Playground', route: './schema-playground', exact: false, badge: null, badgeType: null }, - { id: 'schema-docs', label: 'Docs', route: './schema-docs', exact: false, badge: null, badgeType: null }, + { id: 'budget', label: 'Risk Budget', route: '/ops/policy/governance', exact: true, badge: null, badgeType: null }, + { id: 'trust', label: 'Trust Weights', route: '/ops/policy/governance/trust-weights', exact: false, badge: null, badgeType: null }, + { id: 'staleness', label: 'Staleness', route: '/ops/policy/governance/staleness', exact: false, badge: null, badgeType: null }, + { id: 'sealed', label: 'Sealed Mode', route: '/ops/policy/governance/sealed-mode', exact: false, badge: null, badgeType: null }, + { id: 'profiles', label: 'Profiles', route: '/ops/policy/governance/profiles', exact: false, badge: null, badgeType: null }, + { id: 'validator', label: 'Validator', route: '/ops/policy/governance/validator', exact: false, badge: null, badgeType: null }, + { id: 'audit', label: 'Audit Log', route: '/ops/policy/governance/audit', exact: false, badge: null, badgeType: null }, + { id: 'conflicts', label: 'Conflicts', route: '/ops/policy/governance/conflicts', exact: false, badge: '2', badgeType: 'warning' }, + { id: 'schema-playground', label: 'Playground', route: '/ops/policy/governance/schema-playground', exact: false, badge: null, badgeType: null }, + { id: 'schema-docs', label: 'Docs', route: '/ops/policy/governance/schema-docs', exact: false, badge: null, badgeType: null }, ]; }