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:
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user