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 + '/') ); } }