diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c081dfd..bc4a975 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { languageGuard } from './guards/language.guard'; import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard'; import { adminAuthGuard } from './core/admin-auth/admin-auth.guard'; +import { authRoutes } from './core/auth/auth.routes'; import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard'; import { AdminLayoutComponent } from './features/admin/shell/admin-layout.component'; import { environment } from '../environments/environment'; @@ -45,10 +46,12 @@ const coreRoutes: Routes = [ }, { path: 'edit', + canActivate: [adminAuthGuard], loadComponent: () => import('./features/project-editor/pages/builder-overview-page.component').then(m => m.BuilderOverviewPageComponent) }, { path: 'edit/:section', + canActivate: [adminAuthGuard], loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent), canDeactivate: [projectEditorDirtyGuard] }, @@ -294,6 +297,7 @@ export const routes: Routes = [ path: '__diagnostics', loadComponent: () => import('./features/diagnostics/components/diagnostics-page.component').then(m => m.DiagnosticsPageComponent) }]), + ...authRoutes, { path: ':lang', canActivate: [languageGuard], diff --git a/src/app/core/auth/auth.routes.ts b/src/app/core/auth/auth.routes.ts new file mode 100644 index 0000000..901d13e --- /dev/null +++ b/src/app/core/auth/auth.routes.ts @@ -0,0 +1,19 @@ +import { Routes } from '@angular/router'; + +/** + * Ed25519 admin auth routes. Registered at top level (outside the `:lang` + * prefix, alongside `__diagnostics`) since login/error screens are + * infrastructure, not localized storefront content. Not linked from any + * live nav - reachable only by direct URL until the backend ships the + * challenge/verify endpoints and a guard is pointed here (see docs/AUTH.md). + */ +export const authRoutes: Routes = [ + { + path: 'admin-login', + loadComponent: () => import('./pages/admin-login-page.component').then(m => m.AdminLoginPageComponent) + }, + { + path: 'admin-login/error/:code', + loadComponent: () => import('./pages/auth-error-page.component').then(m => m.AuthErrorPageComponent) + } +]; diff --git a/src/app/core/auth/pages/admin-login-page.component.html b/src/app/core/auth/pages/admin-login-page.component.html new file mode 100644 index 0000000..279bc32 --- /dev/null +++ b/src/app/core/auth/pages/admin-login-page.component.html @@ -0,0 +1,27 @@ +
+
+

Admin sign-in

+ + + @if (!isEd25519Supported) { + + } + + @if (lastError()) { + + } + + @if (phaseLabel()) { + + } + + + Sign in + +
+
diff --git a/src/app/core/auth/pages/admin-login-page.component.scss b/src/app/core/auth/pages/admin-login-page.component.scss new file mode 100644 index 0000000..a4cfd32 --- /dev/null +++ b/src/app/core/auth/pages/admin-login-page.component.scss @@ -0,0 +1,45 @@ +.app-admin-login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: var(--space-xl, 2rem); + background: var(--bg-secondary, #f5f5f5); +} + +.app-admin-login-page__card { + width: 100%; + max-width: 24rem; + padding: var(--space-xl, 2rem); + border-radius: var(--radius-lg, 12px); + background: var(--bg-primary, #fff); + box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.15)); +} + +.app-admin-login-page__title { + margin: 0 0 var(--space-sm, 0.5rem); + font-size: var(--font-size-xl, 1.5rem); + font-weight: var(--font-weight-semibold, 600); +} + +.app-admin-login-page__description { + margin: 0 0 var(--space-lg, 1.5rem); + color: var(--text-secondary, #667a77); + font-size: var(--font-size-sm, 0.875rem); +} + +.app-admin-login-page__warning, +.app-admin-login-page__error { + margin: 0 0 var(--space-md, 1rem); + padding: var(--space-sm, 0.5rem) var(--space-md, 1rem); + border-radius: var(--radius-md, 6px); + background: rgba(239, 68, 68, 0.1); + color: var(--error-color, #ef4444); + font-size: var(--font-size-sm, 0.875rem); +} + +.app-admin-login-page__phase { + margin: 0 0 var(--space-md, 1rem); + color: var(--text-secondary, #667a77); + font-size: var(--font-size-sm, 0.875rem); +} diff --git a/src/app/core/auth/pages/admin-login-page.component.ts b/src/app/core/auth/pages/admin-login-page.component.ts new file mode 100644 index 0000000..8a661f0 --- /dev/null +++ b/src/app/core/auth/pages/admin-login-page.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ButtonComponent } from '../../../shared/ui/button/button.component'; +import { AuthFacade } from '../services/auth-facade.service'; +import { Ed25519KeypairService } from '../services/ed25519-keypair.service'; + +/** + * Ed25519 admin login page. Prepared UI for the flow described in + * docs/AUTH.md - not linked from any live nav yet, reachable only at + * `/admin-login` directly, since the backend endpoints it calls do not + * exist. Once the backend ships, wire `ed25519AuthGuard`/`adminAuthGuard` + * to redirect here instead of the Telegram dialog. + */ +@Component({ + selector: 'app-admin-login-page', + standalone: true, + imports: [ButtonComponent], + templateUrl: './admin-login-page.component.html', + styleUrl: './admin-login-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminLoginPageComponent { + private readonly auth = inject(AuthFacade); + private readonly keypair = inject(Ed25519KeypairService); + + readonly loginPhase = this.auth.loginPhase; + readonly lastError = this.auth.lastError; + readonly isEd25519Supported = this.keypair.isSupported(); + readonly isBusy = computed(() => this.loginPhase() !== 'idle' && this.loginPhase() !== 'done'); + + readonly phaseLabel = computed(() => { + switch (this.loginPhase()) { + case 'requesting-challenge': + return 'Requesting challenge…'; + case 'signing': + return 'Signing challenge with device key…'; + case 'verifying': + return 'Verifying signature…'; + default: + return ''; + } + }); + + signIn(): void { + this.auth.login('/backoffice'); + } +} diff --git a/src/app/core/auth/pages/auth-error-page.component.html b/src/app/core/auth/pages/auth-error-page.component.html new file mode 100644 index 0000000..0739c55 --- /dev/null +++ b/src/app/core/auth/pages/auth-error-page.component.html @@ -0,0 +1,7 @@ +
+ +
+ {{ copy().actionLabel }} +
+
+
diff --git a/src/app/core/auth/pages/auth-error-page.component.scss b/src/app/core/auth/pages/auth-error-page.component.scss new file mode 100644 index 0000000..e6b047f --- /dev/null +++ b/src/app/core/auth/pages/auth-error-page.component.scss @@ -0,0 +1,7 @@ +.app-auth-error-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: var(--space-xl, 2rem); +} diff --git a/src/app/core/auth/pages/auth-error-page.component.ts b/src/app/core/auth/pages/auth-error-page.component.ts new file mode 100644 index 0000000..20a6ea4 --- /dev/null +++ b/src/app/core/auth/pages/auth-error-page.component.ts @@ -0,0 +1,75 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { map } from 'rxjs'; +import { ButtonComponent } from '../../../shared/ui/button/button.component'; +import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component'; +import { AuthErrorCode } from '../models/auth-error.model'; + +interface AuthErrorCopy { + title: string; + description: string; + actionLabel: string; +} + +const COPY: Record = { + 'session-expired': { + title: 'Session expired', + description: 'Your admin session has expired. Sign in again to continue.', + actionLabel: 'Sign in again' + }, + 'invalid-signature': { + title: 'Invalid signature', + description: "The signed challenge could not be verified. Your device's key may not be registered, or the challenge expired before signing.", + actionLabel: 'Try again' + }, + unauthorized: { + title: 'Unauthorized', + description: 'You need to sign in with an authorized admin key to access this area.', + actionLabel: 'Sign in' + }, + forbidden: { + title: 'Forbidden', + description: 'Your account role does not have permission to access this page.', + actionLabel: 'Back to dashboard' + }, + 'backend-unavailable': { + title: 'Backend unavailable', + description: 'The authentication service could not be reached. Check your connection and try again shortly.', + actionLabel: 'Retry' + } +}; + +/** + * Single component renders all five error screens from Requirement §7, + * keyed by the `:code` route param - avoids five near-identical files for + * what is purely copy variation. + */ +@Component({ + selector: 'app-auth-error-page', + standalone: true, + imports: [EmptyStateComponent, ButtonComponent, RouterLink], + templateUrl: './auth-error-page.component.html', + styleUrl: './auth-error-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AuthErrorPageComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + private readonly code = toSignal( + this.route.paramMap.pipe(map(params => (params.get('code') as AuthErrorCode) ?? 'unauthorized')), + { initialValue: 'unauthorized' as AuthErrorCode } + ); + + readonly copy = computed(() => COPY[this.code()] ?? COPY.unauthorized); + readonly isForbidden = computed(() => this.code() === 'forbidden'); + + retry(): void { + if (this.isForbidden()) { + this.router.navigateByUrl('/backoffice'); + } else { + this.router.navigateByUrl('/admin-login'); + } + } +}