diff --git a/apps/renderer/src/app/app.routes.prod.ts b/apps/renderer/src/app/app.routes.prod.ts new file mode 100644 index 0000000..ee6aa44 --- /dev/null +++ b/apps/renderer/src/app/app.routes.prod.ts @@ -0,0 +1,21 @@ +import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + { + path: 'sign-in', + loadComponent: () => + import('./features/auth-signin-bridge/auth-signin-bridge-page').then( + (m) => m.AuthSigninBridgePage, + ), + }, + { + path: '', + loadComponent: () => + import('./features/home/home-page').then((m) => m.HomePage), + }, + { + path: '**', + redirectTo: '', + pathMatch: 'full', + }, +]; diff --git a/apps/renderer/src/app/app.routes.ts b/apps/renderer/src/app/app.routes.ts index fc6f75e..4446777 100644 --- a/apps/renderer/src/app/app.routes.ts +++ b/apps/renderer/src/app/app.routes.ts @@ -1,150 +1,4 @@ import { Route } from '@angular/router'; -import { - jwtCanActivateChildGuard, - jwtCanActivateGuard, - jwtCanDeactivateGuard, - jwtCanMatchGuard, -} from './guards/jwt-route.guards'; +import { createAppRoutes } from './app-route-registry'; -export const appRoutes: Route[] = [ - { - path: 'sign-in', - loadComponent: () => - import('./features/auth-signin-bridge/auth-signin-bridge-page').then( - (m) => m.AuthSigninBridgePage, - ), - }, - { - path: '', - loadComponent: () => - import('./features/home/home-page').then((m) => m.HomePage), - }, - { - path: 'material-showcase', - loadComponent: () => - import('./features/material-showcase/material-showcase-page').then( - (m) => m.MaterialShowcasePage, - ), - }, - { - path: 'material-carbon-lab', - loadComponent: () => - import('./features/material-carbon-lab/material-carbon-lab-page').then( - (m) => m.MaterialCarbonLabPage, - ), - }, - { - path: 'carbon-showcase', - loadComponent: () => - import('./features/carbon-showcase/carbon-showcase-page').then( - (m) => m.CarbonShowcasePage, - ), - }, - { - path: 'tailwind-showcase', - loadComponent: () => - import('./features/tailwind-showcase/tailwind-showcase-page').then( - (m) => m.TailwindShowcasePage, - ), - }, - { - path: 'form-validation-lab', - loadComponent: () => - import('./features/form-validation-lab/form-validation-lab-page').then( - (m) => m.FormValidationLabPage, - ), - }, - { - path: 'async-validation-lab', - loadComponent: () => - import('./features/async-validation-lab/async-validation-lab-page').then( - (m) => m.AsyncValidationLabPage, - ), - }, - { - path: 'data-table-workbench', - loadComponent: () => - import('./features/data-table-workbench/data-table-workbench-page').then( - (m) => m.DataTableWorkbenchPage, - ), - }, - { - path: 'theme-tokens-playground', - loadComponent: () => - import( - './features/theme-tokens-playground/theme-tokens-playground-page' - ).then((m) => m.ThemeTokensPlaygroundPage), - }, - { - path: 'offline-retry-simulator', - loadComponent: () => - import( - './features/offline-retry-simulator/offline-retry-simulator-page' - ).then((m) => m.OfflineRetrySimulatorPage), - }, - { - path: 'file-workflow-studio', - loadComponent: () => - import('./features/file-workflow-studio/file-workflow-studio-page').then( - (m) => m.FileWorkflowStudioPage, - ), - }, - { - path: 'storage-explorer', - loadComponent: () => - import('./features/storage-explorer/storage-explorer-page').then( - (m) => m.StorageExplorerPage, - ), - }, - { - path: 'api-playground', - canMatch: [jwtCanMatchGuard], - canActivate: [jwtCanActivateGuard], - canActivateChild: [jwtCanActivateChildGuard], - children: [ - { - path: '', - canDeactivate: [jwtCanDeactivateGuard], - loadComponent: () => - import('./features/api-playground/api-playground-page').then( - (m) => m.ApiPlaygroundPage, - ), - }, - ], - }, - { - path: 'updates-release', - loadComponent: () => - import('./features/updates-release/updates-release-page').then( - (m) => m.UpdatesReleasePage, - ), - }, - { - path: 'telemetry-console', - loadComponent: () => - import('./features/telemetry-console/telemetry-console-page').then( - (m) => m.TelemetryConsolePage, - ), - }, - { - path: 'ipc-diagnostics', - loadComponent: () => - import('./features/ipc-diagnostics/ipc-diagnostics-page').then( - (m) => m.IpcDiagnosticsPage, - ), - }, - { - path: 'auth-session-lab', - loadComponent: () => - import('./features/auth-session-lab/auth-session-lab-page').then( - (m) => m.AuthSessionLabPage, - ), - }, - { - path: 'file-tools', - loadComponent: () => - import('./features/file-tools/file-tools-page').then( - (m) => m.FileToolsPage, - ), - }, -]; +export const appRoutes: Route[] = createAppRoutes(); diff --git a/apps/renderer/src/app/app.ts b/apps/renderer/src/app/app.ts index 76e2139..61ad0f7 100644 --- a/apps/renderer/src/app/app.ts +++ b/apps/renderer/src/app/app.ts @@ -17,14 +17,7 @@ import { MatButtonModule } from '@angular/material/button'; import { distinctUntilChanged, map } from 'rxjs'; import { getDesktopApi } from '@electron-foundation/desktop-api'; import { AuthSessionStateService } from './services/auth-session-state.service'; - -type NavLink = { - path: string; - label: string; - icon: string; - exact?: boolean; - lab?: boolean; -}; +import { APP_SHELL_CONFIG, type NavLink } from './app-shell.config'; const LABS_MODE_STORAGE_KEY = 'angulectron.labsMode'; @@ -48,116 +41,16 @@ export class App { private readonly breakpointObserver = inject(BreakpointObserver); private readonly destroyRef = inject(DestroyRef); private readonly authSessionState = inject(AuthSessionStateService); - private readonly navLinks: NavLink[] = [ - { path: '/', label: 'Home', icon: 'home', exact: true }, - { - path: '/material-showcase', - label: 'Material Showcase', - icon: 'palette', - lab: true, - }, - { - path: '/material-carbon-lab', - label: 'Material Carbon Lab', - icon: 'tune', - lab: true, - }, - { - path: '/carbon-showcase', - label: 'Carbon Showcase', - icon: 'view_quilt', - lab: true, - }, - { - path: '/tailwind-showcase', - label: 'Tailwind Showcase', - icon: 'waterfall_chart', - lab: true, - }, - { - path: '/form-validation-lab', - label: 'Form Validation Lab', - icon: 'fact_check', - lab: true, - }, - { - path: '/async-validation-lab', - label: 'Async Validation Lab', - icon: 'pending_actions', - lab: true, - }, - { - path: '/data-table-workbench', - label: 'Data Table Workbench', - icon: 'table_chart', - lab: true, - }, - { - path: '/theme-tokens-playground', - label: 'Theme Tokens Playground', - icon: 'format_paint', - lab: true, - }, - { - path: '/offline-retry-simulator', - label: 'Offline Retry Simulator', - icon: 'wifi_off', - lab: true, - }, - { - path: '/file-workflow-studio', - label: 'File Workflow Studio', - icon: 'schema', - lab: true, - }, - { - path: '/storage-explorer', - label: 'Storage Explorer', - icon: 'storage', - lab: true, - }, - { - path: '/api-playground', - label: 'API Playground', - icon: 'api', - lab: true, - }, - { - path: '/updates-release', - label: 'Updates & Release', - icon: 'system_update', - lab: true, - }, - { - path: '/telemetry-console', - label: 'Telemetry Console', - icon: 'analytics', - lab: true, - }, - { - path: '/ipc-diagnostics', - label: 'IPC Diagnostics', - icon: 'cable', - lab: true, - }, - { - path: '/auth-session-lab', - label: 'Auth Session Lab', - icon: 'badge', - lab: true, - }, - { - path: '/file-tools', - label: 'File Tools', - icon: 'folder_open', - lab: true, - }, - ]; + private readonly navLinks: ReadonlyArray = APP_SHELL_CONFIG.navLinks; protected readonly title = 'Angulectron'; protected readonly navOpen = signal(true); protected readonly mobileViewport = signal(false); protected readonly labsMode = signal(false); protected readonly labsModeLocked = signal(false); + protected readonly labsFeatureEnabled = APP_SHELL_CONFIG.labsFeatureEnabled; + protected readonly labsToggleLabel = APP_SHELL_CONFIG.labsToggleLabel; + protected readonly labsToggleOnLabel = APP_SHELL_CONFIG.labsToggleOnLabel; + protected readonly labsToggleOffLabel = APP_SHELL_CONFIG.labsToggleOffLabel; protected readonly visibleNavLinks = computed(() => this.navLinks.filter((item) => this.labsMode() || !item.lab), ); @@ -217,6 +110,13 @@ export class App { } private async initializeLabsModePolicy() { + if (!this.labsFeatureEnabled) { + this.labsModeLocked.set(true); + this.labsMode.set(false); + this.persistLabsModePreference(false); + return; + } + const desktop = getDesktopApi(); if (!desktop) { this.labsMode.set(this.loadLabsModePreference()); diff --git a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html index 10b6320..ff17667 100644 --- a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html +++ b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.html @@ -41,10 +41,18 @@ +

diff --git a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts index 4ebd950..18dd45e 100644 --- a/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts +++ b/apps/renderer/src/app/features/auth-session-lab/auth-session-lab-page.ts @@ -14,6 +14,7 @@ import { MatChipsModule } from '@angular/material/chips'; import { MatIconModule } from '@angular/material/icon'; import type { AuthGetTokenDiagnosticsResponse, + AuthSignOutMode, AuthSessionSummary, } from '@electron-foundation/contracts'; import { getDesktopApi } from '@electron-foundation/desktop-api'; @@ -59,7 +60,7 @@ export class AuthSessionLabPage { return JSON.stringify(diagnostics, null, 2); }); readonly isActive = this.authSessionState.isActive; - readonly returnUrl = signal('/'); + readonly returnUrl = signal(null); readonly scopes = computed(() => this.summary()?.scopes ?? []); readonly entitlements = computed(() => this.summary()?.entitlements ?? []); @@ -147,8 +148,9 @@ export class AuthSessionLabPage { ); this.statusTone.set(result.data.initiated ? 'success' : 'info'); await this.refreshSummary(); - if (this.isActive()) { - await this.router.navigateByUrl(this.returnUrl()); + const returnUrl = this.returnUrl(); + if (this.isActive() && returnUrl) { + await this.router.navigateByUrl(returnUrl); } } finally { clearTimeout(uiTimeoutHandle); @@ -164,7 +166,15 @@ export class AuthSessionLabPage { this.statusTone.set('info'); } - async signOut() { + async signOutLocal() { + await this.signOut('local'); + } + + async signOutGlobal() { + await this.signOut('global'); + } + + private async signOut(mode: AuthSignOutMode) { const desktop = getDesktopApi(); if (!desktop) { this.statusText.set('Desktop bridge unavailable in browser mode.'); @@ -179,14 +189,29 @@ export class AuthSessionLabPage { this.signOutPending.set(true); try { - const result = await desktop.auth.signOut(); + const result = await desktop.auth.signOut(mode); if (!result.ok) { this.statusText.set(result.error.message); this.statusTone.set('error'); return; } - this.statusText.set('Signed out.'); + const providerMessage = + result.data.mode === 'global' + ? result.data.endSessionSupported + ? result.data.endSessionInitiated + ? 'Provider end-session flow was launched in browser.' + : 'Provider end-session flow was not launched.' + : 'Provider does not advertise an end-session endpoint.' + : 'Local session cleared.'; + const revokeMessage = result.data.revocationSupported + ? result.data.refreshTokenPresent + ? result.data.refreshTokenRevoked + ? 'Refresh token was revoked.' + : 'Refresh token revocation failed.' + : 'No refresh token was present to revoke.' + : 'Provider does not advertise a revocation endpoint.'; + this.statusText.set(`${providerMessage} ${revokeMessage}`); this.statusTone.set('info'); await this.refreshSummary(); } finally { @@ -194,13 +219,17 @@ export class AuthSessionLabPage { } } - private toSafeInternalUrl(url: string | null): string { + private toSafeInternalUrl(url: string | null): string | null { if (!url || !url.startsWith('/')) { - return '/'; + return null; } if (url.startsWith('//') || /[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) { - return '/'; + return null; + } + + if (url === '/auth-session-lab') { + return null; } return url; @@ -208,15 +237,18 @@ export class AuthSessionLabPage { private async initializeSessionState() { await this.authSessionState.ensureInitialized(this.showTokenDiagnostics()); - await this.refreshSummary(); - const summary = this.summary(); - if (!summary) { - this.statusText.set('Desktop bridge unavailable in browser mode.'); - this.statusTone.set('warn'); + const result = await this.authSessionState.refreshSummary( + this.showTokenDiagnostics(), + ); + if (!result.ok) { + this.statusText.set(result.error.message); + this.statusTone.set( + result.error.code === 'DESKTOP/UNAVAILABLE' ? 'warn' : 'error', + ); return; } - this.applySummaryStatus(summary.state); + this.applySummaryStatus(result.data.state); } private applySummaryStatus(state: AuthSessionSummary['state']) { diff --git a/apps/renderer/src/app/features/home/home-page.html b/apps/renderer/src/app/features/home/home-page.html index 50926fb..98a8657 100644 --- a/apps/renderer/src/app/features/home/home-page.html +++ b/apps/renderer/src/app/features/home/home-page.html @@ -1,66 +1,66 @@

-

{{ labels.title }}

-

{{ labels.subtitle }}

+

{{ labels().title }}

+

{{ labels().subtitle }}

- {{ labels.desktopBridge }}: + {{ labels().desktopBridge }}: {{ statusBadge().label === 'Connected' ? labels.connected : - labels.disconnected }}{{ desktopAvailable() ? labels().connected : labels().disconnected + }}

- {{ labels.appVersion }}: + {{ labels().appVersion }}: {{ appVersion() }}

- {{ labels.contractVersion }}: + {{ labels().contractVersion }}: {{ contractVersion() }}

- {{ labels.activeLanguage }}: + {{ labels().activeLanguage }}: {{ language() }}

-
+
@if (fileResult()) {

- {{ labels.filePreview }}: + {{ labels().filePreview }}: {{ fileResult() }}

} @if (updateResult()) {

- {{ labels.updateStatus }}: + {{ labels().updateStatus }}: {{ updateResult() }}

} @if (telemetryResult()) {

- {{ labels.telemetryStatus }}: + {{ labels().telemetryStatus }}: {{ telemetryResult() }}

}
diff --git a/apps/renderer/src/app/features/home/home-page.ts b/apps/renderer/src/app/features/home/home-page.ts index aa294ac..7ab924a 100644 --- a/apps/renderer/src/app/features/home/home-page.ts +++ b/apps/renderer/src/app/features/home/home-page.ts @@ -1,13 +1,16 @@ import { ChangeDetectionStrategy, - computed, Component, + computed, + inject, signal, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { getDesktopApi } from '@electron-foundation/desktop-api'; import { createShellBadge } from '@electron-foundation/shell'; import { MatButtonModule } from '@angular/material/button'; +import { TranslocoService } from '@jsverse/transloco'; @Component({ selector: 'app-home-page', @@ -17,29 +20,36 @@ import { MatButtonModule } from '@angular/material/button'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class HomePage { - readonly labels = { - title: 'Workspace Home', - subtitle: 'Desktop bridge status and quick integration actions.', - desktopBridge: 'Desktop bridge', - connected: 'Connected', - disconnected: 'Disconnected', - appVersion: 'App version', - contractVersion: 'Contract version', - activeLanguage: 'Active language', - actions: 'Actions', - openFile: 'Open file', - checkUpdates: 'Check updates', - trackTelemetry: 'Track telemetry', - useEnglish: 'Use English', - filePreview: 'File preview', - updateStatus: 'Update status', - telemetryStatus: 'Telemetry status', - governanceTitle: 'Governance', - governanceBody: - 'Track build quality, CI health, and release readiness from this dashboard.', - } as const; - - readonly language = signal('en-US'); + private readonly transloco = inject(TranslocoService); + readonly labels = toSignal(this.transloco.selectTranslateObject('home'), { + initialValue: { + title: 'home.title', + subtitle: 'home.subtitle', + desktopBridge: 'home.desktopBridge', + connected: 'home.connected', + disconnected: 'home.disconnected', + appVersion: 'home.appVersion', + contractVersion: 'home.contractVersion', + activeLanguage: 'home.activeLanguage', + actions: 'home.actions', + openFile: 'home.openFile', + checkUpdates: 'home.checkUpdates', + trackTelemetry: 'home.trackTelemetry', + useEnglish: 'home.useEnglish', + filePreview: 'home.filePreview', + updateStatus: 'home.updateStatus', + telemetryStatus: 'home.telemetryStatus', + governanceTitle: 'home.governanceTitle', + governanceBody: 'home.governanceBody', + desktopUnavailable: 'home.desktopUnavailable', + selectTextFileTitle: 'home.selectTextFileTitle', + textFiles: 'home.textFiles', + noFileSelected: 'home.noFileSelected', + eventAccepted: 'home.eventAccepted', + }, + }); + + readonly language = signal(this.transloco.getActiveLang()); readonly desktopAvailable = signal(false); readonly appVersion = signal('N/A'); readonly contractVersion = signal('N/A'); @@ -56,19 +66,22 @@ export class HomePage { } setLanguage(language: 'en-US') { + this.transloco.setActiveLang(language); this.language.set(language); } async openFile() { const desktop = getDesktopApi(); if (!desktop) { - this.fileResult.set('Desktop bridge unavailable in browser mode.'); + this.fileResult.set(this.labels().desktopUnavailable); return; } const fileDialogResult = await desktop.dialog.openFile({ - title: 'Select a text file', - filters: [{ name: 'Text files', extensions: ['txt', 'md', 'json'] }], + title: this.labels().selectTextFileTitle, + filters: [ + { name: this.labels().textFiles, extensions: ['txt', 'md', 'json'] }, + ], }); if ( @@ -76,7 +89,7 @@ export class HomePage { fileDialogResult.data.canceled || !fileDialogResult.data.fileToken ) { - this.fileResult.set('No file selected.'); + this.fileResult.set(this.labels().noFileSelected); return; } @@ -95,7 +108,7 @@ export class HomePage { async checkUpdates() { const desktop = getDesktopApi(); if (!desktop) { - this.updateResult.set('Desktop bridge unavailable in browser mode.'); + this.updateResult.set(this.labels().desktopUnavailable); return; } @@ -112,7 +125,7 @@ export class HomePage { async trackTelemetry() { const desktop = getDesktopApi(); if (!desktop) { - this.telemetryResult.set('Desktop bridge unavailable in browser mode.'); + this.telemetryResult.set(this.labels().desktopUnavailable); return; } @@ -122,7 +135,7 @@ export class HomePage { }); this.telemetryResult.set( - result.ok ? 'Event accepted.' : result.error.message, + result.ok ? this.labels().eventAccepted : result.error.message, ); } diff --git a/apps/renderer/src/app/features/home/i18n/en-US.json b/apps/renderer/src/app/features/home/i18n/en-US.json new file mode 100644 index 0000000..7ad02a4 --- /dev/null +++ b/apps/renderer/src/app/features/home/i18n/en-US.json @@ -0,0 +1,27 @@ +{ + "home": { + "title": "Desktop Foundation Shell", + "subtitle": "Angular 21 + Electron baseline with governed architecture boundaries.", + "desktopBridge": "Desktop bridge", + "connected": "Connected", + "disconnected": "Disconnected", + "appVersion": "App version", + "contractVersion": "Contract version", + "activeLanguage": "Active language", + "actions": "Desktop actions", + "openFile": "Open file", + "checkUpdates": "Check updates", + "trackTelemetry": "Track telemetry", + "useEnglish": "Use English", + "filePreview": "File preview", + "updateStatus": "Update status", + "telemetryStatus": "Telemetry status", + "governanceTitle": "UI Governance", + "governanceBody": "Material is primary. Tailwind handles layout. Carbon is allowed only through approved adapters.", + "desktopUnavailable": "Desktop bridge unavailable in browser mode.", + "selectTextFileTitle": "Select a text file", + "textFiles": "Text files", + "noFileSelected": "No file selected.", + "eventAccepted": "Event accepted." + } +} diff --git a/apps/renderer/src/app/features/updates-release/updates-release-page.css b/apps/renderer/src/app/features/updates-release/updates-release-page.css index 23cff2d..af8e42f 100644 --- a/apps/renderer/src/app/features/updates-release/updates-release-page.css +++ b/apps/renderer/src/app/features/updates-release/updates-release-page.css @@ -19,6 +19,9 @@ } .actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; margin-bottom: 0.75rem; } diff --git a/apps/renderer/src/app/features/updates-release/updates-release-page.html b/apps/renderer/src/app/features/updates-release/updates-release-page.html index ca3f820..267e4d2 100644 --- a/apps/renderer/src/app/features/updates-release/updates-release-page.html +++ b/apps/renderer/src/app/features/updates-release/updates-release-page.html @@ -28,9 +28,28 @@ Check For Updates +

{{ updateState() }}

+

Update Source: {{ updateSource() }}

+

+ Current Version: {{ updateCurrentVersion() }} +

+

+ Latest Version: {{ updateLatestVersion() }} +

+ @if (demoFilePath()) { +

Demo File: {{ demoFilePath() }}

+ } +

{{ demoPatchState() }}

diff --git a/apps/renderer/src/app/features/updates-release/updates-release-page.ts b/apps/renderer/src/app/features/updates-release/updates-release-page.ts index 06a94bd..733bbe1 100644 --- a/apps/renderer/src/app/features/updates-release/updates-release-page.ts +++ b/apps/renderer/src/app/features/updates-release/updates-release-page.ts @@ -17,6 +17,11 @@ export class UpdatesReleasePage { readonly appVersion = signal('N/A'); readonly contractVersion = signal('N/A'); readonly updateState = signal('Idle.'); + readonly updateSource = signal<'native' | 'demo' | 'unknown'>('unknown'); + readonly updateCurrentVersion = signal('N/A'); + readonly updateLatestVersion = signal('N/A'); + readonly demoFilePath = signal(''); + readonly demoPatchState = signal('Idle.'); constructor() { void this.loadMetadata(); @@ -36,9 +41,35 @@ export class UpdatesReleasePage { return; } + this.updateSource.set(result.data.source ?? 'unknown'); + this.updateCurrentVersion.set(result.data.currentVersion ?? 'N/A'); + this.updateLatestVersion.set(result.data.latestVersion ?? 'N/A'); + this.demoFilePath.set(result.data.demoFilePath ?? ''); this.updateState.set(result.data.message ?? result.data.status); } + async applyDemoPatch() { + const desktop = getDesktopApi(); + if (!desktop) { + this.demoPatchState.set('Desktop bridge unavailable in browser mode.'); + return; + } + + this.demoPatchState.set('Applying demo patch...'); + const result = await desktop.updates.applyDemoPatch(); + if (!result.ok) { + this.demoPatchState.set(result.error.message); + return; + } + + this.updateSource.set(result.data.source); + this.updateCurrentVersion.set(result.data.currentVersion ?? 'N/A'); + this.updateLatestVersion.set(result.data.latestVersion ?? 'N/A'); + this.demoFilePath.set(result.data.demoFilePath ?? ''); + this.demoPatchState.set(result.data.message ?? result.data.status); + await this.checkForUpdates(); + } + private async loadMetadata() { const desktop = getDesktopApi(); if (!desktop) { diff --git a/apps/renderer/src/app/i18n/transloco-loader.ts b/apps/renderer/src/app/i18n/transloco-loader.ts index 806c1c2..185c5c1 100644 --- a/apps/renderer/src/app/i18n/transloco-loader.ts +++ b/apps/renderer/src/app/i18n/transloco-loader.ts @@ -1,12 +1,61 @@ import { HttpClient } from '@angular/common/http'; +import { DOCUMENT } from '@angular/common'; import { inject, Injectable } from '@angular/core'; import { TranslocoLoader, type Translation } from '@jsverse/transloco'; +import { catchError, forkJoin, map, of } from 'rxjs'; + +const featureTranslationPaths = ['home/i18n']; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const mergeTranslations = ( + base: Translation, + incoming: Translation, +): Translation => { + const result: Translation = { ...base }; + for (const [key, value] of Object.entries(incoming)) { + const baseValue = result[key]; + if (isRecord(baseValue) && isRecord(value)) { + result[key] = mergeTranslations(baseValue, value); + continue; + } + + result[key] = value; + } + + return result; +}; @Injectable({ providedIn: 'root' }) export class TranslocoHttpLoader implements TranslocoLoader { private readonly http = inject(HttpClient); + private readonly document = inject(DOCUMENT); + + private buildAssetUrl(path: string): string { + return new URL(path, this.document.baseURI).toString(); + } getTranslation(lang: string) { - return this.http.get(`i18n/${lang}.json`); + const baseTranslation$ = this.http + .get(this.buildAssetUrl(`i18n/${lang}.json`)) + .pipe(catchError(() => of({} as Translation))); + + const featureTranslations$ = featureTranslationPaths.map((featurePath) => + this.http + .get( + this.buildAssetUrl(`i18n/features/${featurePath}/${lang}.json`), + ) + .pipe(catchError(() => of({} as Translation))), + ); + + return forkJoin([baseTranslation$, ...featureTranslations$]).pipe( + map((translations) => + translations.reduce( + (merged, current) => mergeTranslations(merged, current), + {} as Translation, + ), + ), + ); } } diff --git a/apps/renderer/src/app/services/auth-session-state.service.ts b/apps/renderer/src/app/services/auth-session-state.service.ts index 38686d9..18138f5 100644 --- a/apps/renderer/src/app/services/auth-session-state.service.ts +++ b/apps/renderer/src/app/services/auth-session-state.service.ts @@ -57,12 +57,7 @@ export class AuthSessionStateService { this.refreshPending.set(true); try { - const [summaryResult, diagnosticsResult] = await Promise.all([ - desktop.auth.getSessionSummary(), - includeTokenDiagnostics - ? desktop.auth.getTokenDiagnostics() - : Promise.resolve(null), - ]); + const summaryResult = await desktop.auth.getSessionSummary(); if (!summaryResult.ok) { this.summary.set(null); @@ -71,10 +66,13 @@ export class AuthSessionStateService { } this.summary.set(summaryResult.data); - if (diagnosticsResult && diagnosticsResult.ok) { - this.tokenDiagnostics.set(diagnosticsResult.data); - } else if (includeTokenDiagnostics) { - this.tokenDiagnostics.set(null); + if (includeTokenDiagnostics) { + const diagnosticsResult = await desktop.auth.getTokenDiagnostics(); + if (diagnosticsResult.ok) { + this.tokenDiagnostics.set(diagnosticsResult.data); + } else { + this.tokenDiagnostics.set(null); + } } return summaryResult; diff --git a/docs/05-governance/backlog.md b/docs/05-governance/backlog.md index c336b16..8c18d35 100644 --- a/docs/05-governance/backlog.md +++ b/docs/05-governance/backlog.md @@ -8,7 +8,7 @@ Last reviewed: 2026-02-13 | ------ | --------------------------------------------------------------------- | -------- | -------- | ------------------------------ | ----------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | BL-001 | Linux packaging strategy for desktop | Deferred | Low | Delivery + Packaging | FEEDBACK.md | Platform | Add Electron Forge Linux makers and release notes once Linux is in scope. | | BL-002 | Enforce handshake contract-version mismatch path | Planned | Medium | IPC Contracts | FEEDBACK.md | Platform | Main process currently validates schema but does not return a dedicated mismatch error. | -| BL-003 | API operation compile-time typing hardening | Planned | Medium | Platform API | Transient FR document (API, archived) | Platform | Move from mostly string-based operation references to stricter compile-time typing. | +| BL-003 | API operation compile-time typing hardening | Done | Medium | Platform API | Transient FR document (API, archived) | Platform | Implemented via operation type maps and typed invoke signatures across contracts/preload/desktop API (baseline delivered and extended by `BL-025`). | | BL-004 | Enterprise proxy/TLS support matrix documentation | Deferred | Low | Security + Networking | Transient FR document (API, archived) | Platform | Document expected behaviors for proxy auth, TLS interception, and certificate errors. | | BL-005 | Offline API queue/replay capability | Proposed | Medium | Platform API | Transient FR document (API, archived) | Platform | Current behavior is fail-fast + retry classification; queue/replay not implemented. | | BL-006 | Storage capability-scoped authorization model | Proposed | Medium | Storage + Security | Transient FR document (Storage, archived) | Platform | Add explicit capability/role rules per storage operation. | @@ -17,20 +17,22 @@ Last reviewed: 2026-02-13 | BL-009 | Storage recovery UX flows | Proposed | Medium | UX + Reliability | Transient FR document (Storage, archived) | Frontend | Build user-facing reset/repair/recovery workflow for storage failures. | | BL-010 | Renderer structured logging adoption | Proposed | Medium | Observability | TASK.md | Frontend | Apply shared structured logs in renderer with IPC correlation IDs for key user flows. | | BL-011 | Failure UX pattern implementation | Planned | Medium | UX + Reliability | TASK.md | Frontend | Implement documented toast/dialog/inline/offline patterns consistently across features. | -| BL-012 | IPC real-handler contract harness expansion | Proposed | Medium | Testing + IPC Contracts | TASK.md | Platform | Extend contract tests to execute preload/main handlers incl. timeout and correlation checks. | +| BL-012 | IPC real-handler contract harness expansion | Done | Medium | Testing + IPC Contracts | TASK.md | Platform | Delivered via real-handler unauthorized sender integration coverage and preload invoke timeout/correlation tests; superseded by scoped execution under `BL-023`. | | BL-013 | OIDC auth platform (desktop PKCE + secure IPC) | Planned | High | Security + Identity | TASK.md | Platform | Phased backlog and acceptance tests tracked in `docs/05-governance/oidc-auth-backlog.md`. | | BL-014 | Remove temporary JWT-authorizer client-id audience compatibility | Planned | High | Security + Identity | OIDC integration | Platform + Security | Clerk OAuth access token currently omits `aud`; AWS authorizer temporarily allows both `YOUR_API_AUDIENCE` and OAuth client id. Remove client-id audience once Clerk emits API audience/scopes as required. | -| BL-015 | Add IdP global sign-out and token revocation flow | Planned | Medium | Security + Identity | OIDC integration | Platform + Security | Current sign-out clears local session only. Add provider logout/end-session and refresh-token revocation (where supported), plus clear UX state for local vs global sign-out. | -| BL-016 | Refactor desktop-main composition root and IPC modularization | Planned | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Split `apps/desktop-main/src/main.ts` into focused modules (`window`, `security`, `ipc/handlers`, `updates`, `startup`) and retain a thin composition root. | -| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Planned | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Split `apps/desktop-preload/src/main.ts` into shared invoke/correlation/timeout utility plus per-domain API modules. | -| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Planned | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Centralize sender authorization, schema validation, and error-envelope mapping to remove handler duplication. | +| BL-015 | Add IdP global sign-out and token revocation flow | Done | Medium | Security + Identity | OIDC integration | Platform + Security | Delivered with explicit `local` vs `global` sign-out mode, revocation/end-session capability reporting, and renderer-safe lifecycle messaging. | +| BL-016 | Refactor desktop-main composition root and IPC modularization | Done | High | Desktop Runtime + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; desktop-main composition root split and handler registration modularized. | +| BL-017 | Refactor preload bridge into domain modules with shared invoke client | Done | High | Preload + IPC | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; preload segmented into domain APIs with shared invoke/correlation/timeout client. | +| BL-018 | Introduce reusable validated IPC handler factory in desktop-main | Done | High | IPC Contracts + Reliability | Fresh workspace review (2026-02-13) | Platform | Completed in Sprint 1; validated handler factory centralizes sender auth, schema validation, and envelope handling. | | BL-019 | Decompose OIDC service into smaller capability-focused modules | Proposed | Medium | Security + Identity | Fresh workspace review (2026-02-13) | Platform + Security | Split sign-in flow, discovery/provider client, token lifecycle, and diagnostics concerns currently concentrated in `oidc-service.ts`. | | BL-020 | Complete renderer i18n migration for hardcoded user-facing strings | Proposed | Medium | Frontend + I18n | Fresh workspace review (2026-02-13) | Frontend | Replace hardcoded labels/messages in renderer feature pages with translation keys and locale entries. | -| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Proposed | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Create one source for route path, label, icon, and lab visibility used by router and nav shell. | +| BL-021 | Consolidate renderer route/nav metadata into a single typed registry | Done | Medium | Frontend Architecture | Fresh workspace review (2026-02-13) | Frontend | Delivered by introducing a typed renderer route registry that generates both router entries and shell nav links from one source while preserving production file-replacement exclusions. | | BL-022 | Rationalize thin shell/core/repository libraries | Proposed | Low | Architecture + Maintainability | Fresh workspace review (2026-02-13) | Platform | Either consolidate low-value wrappers or expand with meaningful domain behavior to reduce packaging overhead and clarify boundaries. | -| BL-023 | Expand IPC integration harness for preload-main real handler paths | Planned | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Add integration coverage for unauthorized sender rejection, timeout behavior, and correlation-id propagation with real handlers. | +| BL-023 | Expand IPC integration harness for preload-main real handler paths | Done | Medium | Testing + IPC Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered with real-handler unauthorized sender tests and preload invoke malformed/timeout/failure correlation assertions. | | BL-024 | Standardize structured renderer logging with shared helper adoption | Proposed | Medium | Observability | Fresh workspace review (2026-02-13) | Frontend | Apply structured logging in renderer flows with correlation IDs and redaction-safe details. | -| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Planned | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Extend BL-003 by introducing operation-to-request/response type maps across contracts, preload API, and main gateway. | +| BL-025 | Strengthen compile-time typing for API operation contracts end-to-end | Done | Medium | Platform API + Contracts | Fresh workspace review (2026-02-13) | Platform | Delivered by introducing operation-to-request/response type maps and consuming them in preload/desktop API invoke surfaces. | +| BL-026 | Exclude lab routes/features from production bundle surface | Done | High | Frontend + Security Posture | Sprint implementation (2026-02-13) | Frontend + Platform | Production route/shell config replacement now removes lab routes/nav/toggle from production artifacts to reduce discoverability/attack surface. | +| BL-027 | Provide deterministic bundled update demo patch cycle | Done | Medium | Delivery + Update Architecture | Sprint implementation (2026-02-13) | Platform | Added local bundled feed demo (`1.0.0-demo` -> `1.0.1-demo`) with hash validation and renderer diagnostics to prove end-to-end update model independent of installer updater infra. | ## Status Definitions diff --git a/libs/platform/desktop-api/src/lib/desktop-api.ts b/libs/platform/desktop-api/src/lib/desktop-api.ts index e7be451..c80daec 100644 --- a/libs/platform/desktop-api/src/lib/desktop-api.ts +++ b/libs/platform/desktop-api/src/lib/desktop-api.ts @@ -1,6 +1,8 @@ import type { ApiGetOperationDiagnosticsResponse, ApiOperationId, + ApiOperationParamsById, + ApiOperationResponseDataById, AuthGetTokenDiagnosticsResponse, AuthSessionSummary, ContractVersion, @@ -31,7 +33,17 @@ export interface DesktopDialogApi { export interface DesktopAuthApi { signIn: () => Promise>; - signOut: () => Promise>; + signOut: (mode?: 'local' | 'global') => Promise< + DesktopResult<{ + signedOut: boolean; + mode: 'local' | 'global'; + refreshTokenPresent: boolean; + refreshTokenRevoked: boolean; + revocationSupported: boolean; + endSessionSupported: boolean; + endSessionInitiated: boolean; + }> + >; getSessionSummary: () => Promise>; getTokenDiagnostics: () => Promise< DesktopResult @@ -47,6 +59,21 @@ export interface DesktopUpdatesApi { DesktopResult<{ status: 'available' | 'not-available' | 'error'; message?: string; + source?: 'native' | 'demo'; + currentVersion?: string; + latestVersion?: string; + demoFilePath?: string; + }> + >; + applyDemoPatch: () => Promise< + DesktopResult<{ + applied: boolean; + status: 'available' | 'not-available' | 'error'; + message?: string; + source: 'demo'; + currentVersion?: string; + latestVersion?: string; + demoFilePath?: string; }> >; } @@ -79,12 +106,16 @@ export interface DesktopStorageApi { } export interface DesktopExternalApi { - invoke: ( - operationId: ApiOperationId, - params?: Record, + invoke: ( + operationId: TOperationId, + params?: ApiOperationParamsById[TOperationId], options?: { headers?: Record }, ) => Promise< - DesktopResult<{ status: number; data: unknown; requestPath?: string }> + DesktopResult<{ + status: number; + data: ApiOperationResponseDataById[TOperationId]; + requestPath?: string; + }> >; getOperationDiagnostics: ( operationId: ApiOperationId, diff --git a/libs/shared/contracts/src/lib/api.contract.ts b/libs/shared/contracts/src/lib/api.contract.ts index b1077f3..f73bdcd 100644 --- a/libs/shared/contracts/src/lib/api.contract.ts +++ b/libs/shared/contracts/src/lib/api.contract.ts @@ -7,6 +7,7 @@ const apiParamValueSchema = z.union([ z.boolean(), z.null(), ]); +export type ApiParamValue = z.infer; const apiHeaderNameSchema = z .string() @@ -20,6 +21,16 @@ export const API_OPERATION_IDS = [ export const apiOperationIdSchema = z.enum(API_OPERATION_IDS); export type ApiOperationId = z.infer; +export type ApiOperationParamsById = { + 'status.github': Record | undefined; + 'call.secure-endpoint': Record | undefined; +}; + +export type ApiOperationResponseDataById = { + 'status.github': unknown; + 'call.secure-endpoint': unknown; +}; + export const apiInvokeRequestSchema = requestEnvelope( z .object({ @@ -61,6 +72,7 @@ export const apiInvokeResponseSchema = z.object({ }); export type ApiInvokeRequest = z.infer; +export type ApiInvokeRequestPayload = ApiInvokeRequest['payload']; export type ApiGetOperationDiagnosticsRequest = z.infer< typeof apiGetOperationDiagnosticsRequestSchema >; diff --git a/libs/shared/contracts/src/lib/auth.contract.ts b/libs/shared/contracts/src/lib/auth.contract.ts index c96bc26..6f5073a 100644 --- a/libs/shared/contracts/src/lib/auth.contract.ts +++ b/libs/shared/contracts/src/lib/auth.contract.ts @@ -27,10 +27,26 @@ export const authSignInResponseSchema = z }) .strict(); -export const authSignOutRequestSchema = requestEnvelope(emptyPayloadSchema); +export const authSignOutModeSchema = z.enum(['local', 'global']); + +export const authSignOutRequestPayloadSchema = z + .object({ + mode: authSignOutModeSchema.default('local'), + }) + .strict(); + +export const authSignOutRequestSchema = requestEnvelope( + authSignOutRequestPayloadSchema, +); export const authSignOutResponseSchema = z .object({ signedOut: z.boolean(), + mode: authSignOutModeSchema, + refreshTokenPresent: z.boolean().default(false), + refreshTokenRevoked: z.boolean().default(false), + revocationSupported: z.boolean().default(false), + endSessionSupported: z.boolean().default(false), + endSessionInitiated: z.boolean().default(false), }) .strict(); @@ -80,6 +96,10 @@ export type AuthSessionState = z.infer; export type AuthSessionSummary = z.infer; export type AuthSignInRequest = z.infer; export type AuthSignInResponse = z.infer; +export type AuthSignOutMode = z.infer; +export type AuthSignOutRequestPayload = z.infer< + typeof authSignOutRequestPayloadSchema +>; export type AuthSignOutRequest = z.infer; export type AuthSignOutResponse = z.infer; export type AuthGetSessionSummaryRequest = z.infer< diff --git a/libs/shared/contracts/src/lib/channels.ts b/libs/shared/contracts/src/lib/channels.ts index efa7995..ab177fa 100644 --- a/libs/shared/contracts/src/lib/channels.ts +++ b/libs/shared/contracts/src/lib/channels.ts @@ -15,6 +15,7 @@ export const IPC_CHANNELS = { apiInvoke: 'api:invoke', apiGetOperationDiagnostics: 'api:get-operation-diagnostics', updatesCheck: 'updates:check', + updatesApplyDemoPatch: 'updates:apply-demo-patch', telemetryTrack: 'telemetry:track', } as const; diff --git a/libs/shared/contracts/src/lib/contracts.spec.ts b/libs/shared/contracts/src/lib/contracts.spec.ts index 68814ce..7b29421 100644 --- a/libs/shared/contracts/src/lib/contracts.spec.ts +++ b/libs/shared/contracts/src/lib/contracts.spec.ts @@ -9,9 +9,15 @@ import { authGetTokenDiagnosticsResponseSchema, authGetSessionSummaryResponseSchema, authSignInRequestSchema, + authSignOutRequestSchema, + authSignOutResponseSchema, } from './auth.contract'; import { readTextFileRequestSchema } from './fs.contract'; import { storageSetRequestSchema } from './storage.contract'; +import { + updatesApplyDemoPatchResponseSchema, + updatesCheckResponseSchema, +} from './updates.contract'; describe('parseOrFailure', () => { it('should parse valid values', () => { @@ -202,6 +208,32 @@ describe('auth contracts', () => { expect(parsed.success).toBe(true); }); + it('accepts sign-out requests with explicit mode', () => { + const parsed = authSignOutRequestSchema.safeParse({ + contractVersion: '1.0.0', + correlationId: 'corr-auth-2', + payload: { + mode: 'global', + }, + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts sign-out responses with provider metadata', () => { + const parsed = authSignOutResponseSchema.safeParse({ + signedOut: true, + mode: 'global', + refreshTokenPresent: true, + refreshTokenRevoked: true, + revocationSupported: true, + endSessionSupported: true, + endSessionInitiated: true, + }); + + expect(parsed.success).toBe(true); + }); + it('accepts active session summary payloads', () => { const parsed = authGetSessionSummaryResponseSchema.safeParse({ state: 'active', @@ -254,3 +286,31 @@ describe('auth contracts', () => { expect(parsed.success).toBe(true); }); }); + +describe('updates contracts', () => { + it('accepts update check response with demo metadata', () => { + const parsed = updatesCheckResponseSchema.safeParse({ + status: 'available', + source: 'demo', + currentVersion: '1.0.0-demo', + latestVersion: '1.0.1-demo', + demoFilePath: 'C:\\Users\\demo\\update-demo\\runtime\\feature.txt', + }); + + expect(parsed.success).toBe(true); + }); + + it('accepts demo patch response payload', () => { + const parsed = updatesApplyDemoPatchResponseSchema.safeParse({ + applied: true, + status: 'not-available', + source: 'demo', + message: 'Demo patch applied.', + currentVersion: '1.0.1-demo', + latestVersion: '1.0.1-demo', + demoFilePath: 'C:\\Users\\demo\\update-demo\\runtime\\feature.txt', + }); + + expect(parsed.success).toBe(true); + }); +}); diff --git a/libs/shared/contracts/src/lib/updates.contract.ts b/libs/shared/contracts/src/lib/updates.contract.ts index 2dd50ae..f732a13 100644 --- a/libs/shared/contracts/src/lib/updates.contract.ts +++ b/libs/shared/contracts/src/lib/updates.contract.ts @@ -2,11 +2,33 @@ import { z } from 'zod'; import { requestEnvelope, emptyPayloadSchema } from './request-envelope'; export const updatesCheckRequestSchema = requestEnvelope(emptyPayloadSchema); +export const updatesApplyDemoPatchRequestSchema = + requestEnvelope(emptyPayloadSchema); export const updatesCheckResponseSchema = z.object({ status: z.enum(['available', 'not-available', 'error']), message: z.string().optional(), + source: z.enum(['native', 'demo']).optional(), + currentVersion: z.string().optional(), + latestVersion: z.string().optional(), + demoFilePath: z.string().optional(), +}); + +export const updatesApplyDemoPatchResponseSchema = z.object({ + applied: z.boolean(), + status: z.enum(['available', 'not-available', 'error']), + message: z.string().optional(), + source: z.enum(['demo']), + currentVersion: z.string().optional(), + latestVersion: z.string().optional(), + demoFilePath: z.string().optional(), }); export type UpdatesCheckRequest = z.infer; export type UpdatesCheckResponse = z.infer; +export type UpdatesApplyDemoPatchRequest = z.infer< + typeof updatesApplyDemoPatchRequestSchema +>; +export type UpdatesApplyDemoPatchResponse = z.infer< + typeof updatesApplyDemoPatchResponseSchema +>; diff --git a/tools/scripts/check-i18n.ts b/tools/scripts/check-i18n.ts index 22e9b61..897c8fe 100644 --- a/tools/scripts/check-i18n.ts +++ b/tools/scripts/check-i18n.ts @@ -26,15 +26,54 @@ const schema = z.object({ telemetryStatus: z.string().min(1), governanceTitle: z.string().min(1), governanceBody: z.string().min(1), + desktopUnavailable: z.string().min(1), + selectTextFileTitle: z.string().min(1), + textFiles: z.string().min(1), + noFileSelected: z.string().min(1), + eventAccepted: z.string().min(1), }), }); -const localePath = path.resolve( +const baseLocalePath = path.resolve( __dirname, '../../apps/renderer/public/i18n/en-US.json', ); -const source = readFileSync(localePath, 'utf8'); -const parsed = schema.safeParse(JSON.parse(source)); +const homeLocalePath = path.resolve( + __dirname, + '../../apps/renderer/src/app/features/home/i18n/en-US.json', +); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const mergeObjects = ( + base: Record, + incoming: Record, +): Record => { + const output: Record = { ...base }; + for (const [key, value] of Object.entries(incoming)) { + const baseValue = output[key]; + if (isRecord(baseValue) && isRecord(value)) { + output[key] = mergeObjects(baseValue, value); + continue; + } + output[key] = value; + } + + return output; +}; + +const baseLocale = JSON.parse(readFileSync(baseLocalePath, 'utf8')) as Record< + string, + unknown +>; +const homeLocale = JSON.parse(readFileSync(homeLocalePath, 'utf8')) as Record< + string, + unknown +>; +const mergedLocale = mergeObjects(baseLocale, homeLocale); + +const parsed = schema.safeParse(mergedLocale); if (!parsed.success) { console.error('i18n validation failed');