Broad UI improvements spanning auth, branding, notifications, agents, analytics, approvals, audit-log, bundles, configuration, console-admin, dashboard, deployments, doctor, environments, evidence, feed-mirror, graph, integration-hub, issuer-trust, lineage, notify, offline-kit, policy, promotions, quota, registry, release-orchestrator, releases, sbom, scans, secret-detection, security, settings, setup-wizard, system-health, topology, triage, trust-admin, unknowns, vex-hub, vulnerabilities, and watchlist features. Adds new shared components (page-action-outlet, stella-action-card, stella-form-field), scripts feature module, audit-trust component, e2e test helpers, and release page e2e specs. Updates auth session model, branding service, color tokens, form styles, and i18n translations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
8.6 KiB
TypeScript
242 lines
8.6 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
|
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
DestroyRef,
|
|
effect,
|
|
inject,
|
|
ViewChild,
|
|
} from '@angular/core';
|
|
import { Router, RouterLink, RouterOutlet, NavigationEnd, TitleStrategy } from '@angular/router';
|
|
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
import { filter, map, startWith, take } from 'rxjs/operators';
|
|
|
|
import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
|
import { AuthSessionStore } from './core/auth/auth-session.store';
|
|
import { ConsoleSessionStore } from './core/console/console-session.store';
|
|
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
|
|
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
|
|
import { NavigationMenuComponent } from './shared/components/navigation-menu/navigation-menu.component';
|
|
import { UserMenuComponent } from './shared/components/user-menu/user-menu.component';
|
|
import { CommandPaletteComponent } from './shared/components/command-palette/command-palette.component';
|
|
import { ToastContainerComponent } from './shared/components/toast/toast-container.component';
|
|
import { BreadcrumbComponent } from './shared/components/breadcrumb/breadcrumb.component';
|
|
import { KeyboardShortcutsComponent } from './shared/components/keyboard-shortcuts/keyboard-shortcuts.component';
|
|
import { AppShellComponent } from './layout/app-shell/app-shell.component';
|
|
import { BrandingService } from './core/branding/branding.service';
|
|
import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service';
|
|
import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component';
|
|
import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service';
|
|
import { TranslatePipe } from './core/i18n';
|
|
|
|
@Component({
|
|
selector: 'app-root',
|
|
imports: [
|
|
CommonModule,
|
|
RouterOutlet,
|
|
RouterLink,
|
|
NavigationMenuComponent,
|
|
UserMenuComponent,
|
|
AppShellComponent,
|
|
CommandPaletteComponent,
|
|
ToastContainerComponent,
|
|
BreadcrumbComponent,
|
|
KeyboardShortcutsComponent,
|
|
LegacyUrlBannerComponent,
|
|
TranslatePipe,
|
|
],
|
|
templateUrl: './app.component.html',
|
|
styleUrl: './app.component.scss',
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
})
|
|
export class AppComponent {
|
|
private static readonly SHELL_EXCLUDED_ROUTES = [
|
|
'/setup-wizard',
|
|
'/welcome',
|
|
'/callback',
|
|
'/silent-refresh',
|
|
'/auth/callback',
|
|
'/auth/silent-refresh',
|
|
];
|
|
|
|
private readonly router = inject(Router);
|
|
private readonly titleStrategy = inject(TitleStrategy);
|
|
private readonly auth = inject(AuthorityAuthService);
|
|
private readonly sessionStore = inject(AuthSessionStore);
|
|
private readonly consoleStore = inject(ConsoleSessionStore);
|
|
private readonly brandingService = inject(BrandingService);
|
|
private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService);
|
|
private readonly contextUrlSync = inject(PlatformContextUrlSyncService);
|
|
private readonly doctorTrend = inject(DoctorTrendService);
|
|
private readonly doctorNotification = inject(DoctorNotificationService);
|
|
private shellBackgroundServicesActive = false;
|
|
|
|
@ViewChild(CommandPaletteComponent) private commandPalette!: CommandPaletteComponent;
|
|
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
|
|
constructor() {
|
|
const removeSplash = () => {
|
|
const splash = document.getElementById('stella-splash');
|
|
if (!splash) {
|
|
return;
|
|
}
|
|
splash.style.opacity = '0';
|
|
splash.style.transition = 'opacity 0.3s ease-out';
|
|
setTimeout(() => splash.remove(), 350);
|
|
};
|
|
|
|
// Remove the inline splash screen once the first route resolves.
|
|
// This keeps the splash visible while route guards (e.g. backend probe)
|
|
// are still pending, avoiding a blank screen.
|
|
this.router.events
|
|
.pipe(
|
|
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
|
|
take(1),
|
|
takeUntilDestroyed(this.destroyRef),
|
|
)
|
|
.subscribe(() => removeSplash());
|
|
|
|
// Defensive fallback: if first navigation never settles (e.g. test/misconfigured
|
|
// backend), remove splash so the shell remains interactive. Use 10s to match
|
|
// the silent-refresh iframe timeout so the splash stays visible while OIDC
|
|
// session restoration is in-flight.
|
|
setTimeout(() => removeSplash(), 10_000);
|
|
|
|
// Initialize branding on app start
|
|
this.brandingService.fetchBranding().subscribe();
|
|
|
|
effect(() => {
|
|
this.brandingService.currentBranding();
|
|
this.brandingService.isLoaded();
|
|
this.currentUrl();
|
|
this.titleStrategy.updateTitle(this.router.routerState.snapshot);
|
|
});
|
|
|
|
// Attempt to silently restore the auth session if the user was
|
|
// previously logged in (session cookie may still be active at the Authority).
|
|
void this.auth.trySilentRefresh();
|
|
|
|
// Initialize legacy route telemetry tracking (ROUTE-002)
|
|
this.legacyRouteTelemetry.initialize();
|
|
|
|
effect(() => {
|
|
const shouldRunShellBackgroundServices =
|
|
this.isAuthenticated() && !this.isShellExcludedRoute(this.currentUrl());
|
|
|
|
if (shouldRunShellBackgroundServices) {
|
|
if (this.shellBackgroundServicesActive) {
|
|
return;
|
|
}
|
|
|
|
// Keep global scope in sync with route query parameters only once the
|
|
// authenticated shell is active.
|
|
this.contextUrlSync.initialize();
|
|
|
|
// Start Doctor background services (deferred from APP_INITIALIZER
|
|
// to avoid NG0200 circular DI with Router during bootstrap).
|
|
this.doctorTrend.start();
|
|
this.doctorNotification.start();
|
|
this.shellBackgroundServicesActive = true;
|
|
return;
|
|
}
|
|
|
|
if (!this.shellBackgroundServicesActive) {
|
|
return;
|
|
}
|
|
|
|
this.doctorTrend.stop();
|
|
this.doctorNotification.stop();
|
|
this.shellBackgroundServicesActive = false;
|
|
});
|
|
}
|
|
|
|
readonly isAuthenticated = this.sessionStore.isAuthenticated;
|
|
readonly authStatus = this.sessionStore.status;
|
|
readonly activeTenant = this.consoleStore.selectedTenantId;
|
|
|
|
readonly freshAuthSummary = computed(() => {
|
|
const token = this.consoleStore.tokenInfo();
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
return {
|
|
active: token.freshAuthActive,
|
|
expiresAt: token.freshAuthExpiresAt,
|
|
};
|
|
});
|
|
|
|
// Legacy route info for banner (ROUTE-003)
|
|
readonly legacyRouteInfo = this.legacyRouteTelemetry.currentLegacyRoute;
|
|
|
|
private readonly currentUrl$ = this.router.events.pipe(
|
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
|
map(event => event.urlAfterRedirects.split('?')[0]),
|
|
startWith(this.router.url.split('?')[0])
|
|
);
|
|
|
|
private readonly currentUrl = toSignal(this.currentUrl$, {
|
|
initialValue: (typeof window !== 'undefined' ? window.location.pathname : '/'),
|
|
});
|
|
|
|
readonly useShellLayout = computed(() => {
|
|
const url = this.currentUrl();
|
|
return this.isAuthenticated() && !this.isShellExcludedRoute(url);
|
|
});
|
|
|
|
readonly showBreadcrumb = computed(() => {
|
|
const url = this.currentUrl();
|
|
const hideRoutes = [
|
|
'/',
|
|
'/welcome',
|
|
...AppComponent.SHELL_EXCLUDED_ROUTES,
|
|
];
|
|
if (hideRoutes.some(route => url === route || url.startsWith(route + '/'))) {
|
|
return false;
|
|
}
|
|
return url.split('/').filter(s => s).length > 0;
|
|
});
|
|
|
|
/** Setup wizard gets a completely chrome-free viewport. */
|
|
readonly isFullPageRoute = computed(() => {
|
|
const url = this.currentUrl();
|
|
return url === '/setup-wizard' || url.startsWith('/setup-wizard/');
|
|
});
|
|
|
|
/** Hide navigation on setup/auth pages and when not authenticated. */
|
|
readonly showNavigation = computed(() => {
|
|
const url = this.currentUrl();
|
|
if (this.isShellExcludedRoute(url)) {
|
|
return false;
|
|
}
|
|
return this.isAuthenticated();
|
|
});
|
|
|
|
/** Show sign-in only on pages where auth makes sense (not setup/callback). */
|
|
readonly showSignIn = computed(() => {
|
|
const url = this.currentUrl();
|
|
return !this.isShellExcludedRoute(url);
|
|
});
|
|
|
|
onSignIn(): void {
|
|
const returnUrl = this.router.url === '/' ? undefined : this.router.url;
|
|
void this.auth.beginLogin(returnUrl);
|
|
}
|
|
|
|
onLegacyBannerDismissed(): void {
|
|
this.legacyRouteTelemetry.clearCurrentLegacyRoute();
|
|
}
|
|
|
|
/** Triggered by the keyboard easter egg (typing d-e-m-o quickly) */
|
|
onDemoSeedRequested(): void {
|
|
this.commandPalette?.triggerSeedDemo();
|
|
}
|
|
|
|
private isShellExcludedRoute(url: string): boolean {
|
|
return AppComponent.SHELL_EXCLUDED_ROUTES.some(
|
|
(route) => url === route || url.startsWith(route + '/')
|
|
);
|
|
}
|
|
}
|