Fix navigation root cause: probe auto-recovery + auth persistence + 4 bugs

Fix 1 (CRITICAL): BackendProbeService now auto-recovers from "unreachable".
  When probe fails, schedules retry every 10s (max 5 attempts). Guard
  re-probes before redirecting if probe was previously reachable. This
  fixes ALL 9 guarded route groups that redirected to /setup mid-session.

Fix 2: wasEverAuthenticated latch now persists in sessionStorage instead
  of class field. Survives page reloads (F5), cleared on logout.

Fix 3: Console-admin routes now use requireAnyScopeGuard with ui.admin
  bypass instead of plain requireAuthGuard. Admin user can access all
  /console-admin/* pages (tenants, clients, tokens, branding).

Fix 4: Governance sub-tab routerLinks changed from relative to absolute
  paths. Clicking Trust Weights, Staleness, etc. now stays on governance
  instead of navigating to random pages.

Fix 5: Integration empty-state buttons show proper display names
  ("Runtime Host" not "runtimehost", "SCM Integration" not "scm").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-17 00:58:56 +02:00
parent 5c24f18f50
commit e157563d05
6 changed files with 110 additions and 23 deletions

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ export class BackendProbeService {
readonly probeError = signal<string | null>(null);
private probePromise: Promise<void> | null = null;
private recoveryTimer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
if (this.probeStatus() !== 'pending' || !this.probePromise) {
return Promise.resolve();
async waitForResult(): Promise<void> {
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<void> {

View File

@@ -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(

View File

@@ -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<string, string> = {
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();
}

View File

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