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',
|
'/console/profile',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requireConsoleAdminGuard = requireAnyScopeGuard(
|
||||||
|
[StellaOpsScopes.UI_ADMIN],
|
||||||
|
'/console/profile',
|
||||||
|
);
|
||||||
|
|
||||||
function preserveAppRedirect(template: string) {
|
function preserveAppRedirect(template: string) {
|
||||||
return ({
|
return ({
|
||||||
params,
|
params,
|
||||||
@@ -245,7 +250,7 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'console-admin',
|
path: 'console-admin',
|
||||||
title: 'Console Admin',
|
title: 'Console Admin',
|
||||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireConsoleAdminGuard],
|
||||||
data: { breadcrumb: 'Console Admin' },
|
data: { breadcrumb: 'Console Admin' },
|
||||||
loadChildren: () => import('./features/console-admin/console-admin.routes').then((m) => m.consoleAdminRoutes),
|
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
|
* browser session once the user has logged in at least once. Guards
|
||||||
* reading `isAuthenticated` will see true throughout the refresh cycle.
|
* 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(() => {
|
readonly isAuthenticated = computed(() => {
|
||||||
const hasSession = this.sessionSignal() !== null;
|
const hasSession = this.sessionSignal() !== null;
|
||||||
const notLoading = this.statusSignal() !== 'loading';
|
const notLoading = this.statusSignal() !== 'loading';
|
||||||
@@ -55,8 +65,9 @@ export class AuthSessionStore {
|
|||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
this.wasEverAuthenticated = true;
|
this.wasEverAuthenticated = true;
|
||||||
}
|
}
|
||||||
// During transient 'loading' states, return true if the user was
|
// During transient 'loading' states (token refresh, page reload),
|
||||||
// previously authenticated — the session is being refreshed, not lost.
|
// 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');
|
return authenticated || (this.wasEverAuthenticated && this.statusSignal() === 'loading');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +100,7 @@ export class AuthSessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
this.wasEverAuthenticated = false;
|
||||||
this.sessionSignal.set(null);
|
this.sessionSignal.set(null);
|
||||||
this.statusSignal.set('unauthenticated');
|
this.statusSignal.set('unauthenticated');
|
||||||
this.persistedSignal.set(null);
|
this.persistedSignal.set(null);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export class BackendProbeService {
|
|||||||
readonly probeError = signal<string | null>(null);
|
readonly probeError = signal<string | null>(null);
|
||||||
|
|
||||||
private probePromise: Promise<void> | null = null;
|
private probePromise: Promise<void> | null = null;
|
||||||
|
private recoveryTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private wasEverReachable = false;
|
||||||
|
|
||||||
constructor(httpBackend: HttpBackend, configService: AppConfigService) {
|
constructor(httpBackend: HttpBackend, configService: AppConfigService) {
|
||||||
this.http = new HttpClient(httpBackend);
|
this.http = new HttpClient(httpBackend);
|
||||||
@@ -30,25 +32,71 @@ export class BackendProbeService {
|
|||||||
/**
|
/**
|
||||||
* Probes the authority OIDC discovery endpoint.
|
* Probes the authority OIDC discovery endpoint.
|
||||||
* Sets probeStatus to 'reachable' on success, 'unreachable' on failure.
|
* Sets probeStatus to 'reachable' on success, 'unreachable' on failure.
|
||||||
|
* When unreachable, automatically schedules recovery re-probes.
|
||||||
* Never throws — fails gracefully.
|
* Never throws — fails gracefully.
|
||||||
*/
|
*/
|
||||||
probe(): Promise<void> {
|
probe(): Promise<void> {
|
||||||
this.probeStatus.set('pending');
|
this.probeStatus.set('pending');
|
||||||
this.probeError.set(null);
|
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;
|
return this.probePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a promise that resolves once the probe status leaves 'pending'.
|
* 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> {
|
async waitForResult(): Promise<void> {
|
||||||
if (this.probeStatus() !== 'pending' || !this.probePromise) {
|
if (this.probeStatus() !== 'pending' && !this.probePromise) {
|
||||||
return Promise.resolve();
|
// 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> {
|
private async executeProbe(): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Routes } from '@angular/router';
|
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';
|
import { StellaOpsScopes } from '../../core/auth/scopes';
|
||||||
|
|
||||||
|
const requireConsoleAdminGuard = requireAnyScopeGuard(
|
||||||
|
[StellaOpsScopes.UI_ADMIN],
|
||||||
|
'/console/profile',
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Console Admin Routes
|
* Console Admin Routes
|
||||||
*
|
*
|
||||||
@@ -11,7 +16,7 @@ import { StellaOpsScopes } from '../../core/auth/scopes';
|
|||||||
export const consoleAdminRoutes: Routes = [
|
export const consoleAdminRoutes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
canMatch: [requireAuthGuard],
|
canMatch: [requireConsoleAdminGuard],
|
||||||
data: { requiredScopes: [StellaOpsScopes.UI_ADMIN] },
|
data: { requiredScopes: [StellaOpsScopes.UI_ADMIN] },
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./console-admin-layout.component').then(
|
import('./console-admin-layout.component').then(
|
||||||
|
|||||||
@@ -334,6 +334,22 @@ export class IntegrationListComponent implements OnInit {
|
|||||||
|
|
||||||
protected readonly IntegrationStatus = IntegrationStatus;
|
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[] = [];
|
integrations: Integration[] = [];
|
||||||
loading = true;
|
loading = true;
|
||||||
typeLabel = 'All';
|
typeLabel = 'All';
|
||||||
@@ -353,7 +369,8 @@ export class IntegrationListComponent implements OnInit {
|
|||||||
const typeFromRoute = this.route.snapshot.data['type'];
|
const typeFromRoute = this.route.snapshot.data['type'];
|
||||||
if (typeFromRoute) {
|
if (typeFromRoute) {
|
||||||
this.integrationType = this.parseType(typeFromRoute);
|
this.integrationType = this.parseType(typeFromRoute);
|
||||||
this.typeLabel = typeFromRoute;
|
this.typeLabel =
|
||||||
|
IntegrationListComponent.TYPE_DISPLAY_NAMES[typeFromRoute] ?? typeFromRoute;
|
||||||
}
|
}
|
||||||
this.loadIntegrations();
|
this.loadIntegrations();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,15 +171,15 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
|||||||
})
|
})
|
||||||
export class PolicyGovernanceComponent {
|
export class PolicyGovernanceComponent {
|
||||||
protected readonly tabs = [
|
protected readonly tabs = [
|
||||||
{ id: 'budget', label: 'Risk Budget', route: './', exact: true, 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: './trust-weights', exact: false, 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: './staleness', 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: './sealed-mode', 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: './profiles', 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: './validator', 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: './audit', 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: './conflicts', exact: false, badge: '2', badgeType: 'warning' },
|
{ id: 'conflicts', label: 'Conflicts', route: '/ops/policy/governance/conflicts', exact: false, badge: '2', badgeType: 'warning' },
|
||||||
{ id: 'schema-playground', label: 'Playground', route: './schema-playground', exact: false, badge: null, badgeType: null },
|
{ id: 'schema-playground', label: 'Playground', route: '/ops/policy/governance/schema-playground', exact: false, badge: null, badgeType: null },
|
||||||
{ id: 'schema-docs', label: 'Docs', route: './schema-docs', 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