feat(auth): add Ed25519 login page, error screens, wire routes
admin-login page + single parameterized auth-error-page covering all 5 error codes; auth.routes.ts registered top-level (not linked from live nav yet). Also closes a real gap: /edit and /edit/:section had no adminAuthGuard at all - now protected like /backoffice.
This commit is contained in:
@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
|
|||||||
import { languageGuard } from './guards/language.guard';
|
import { languageGuard } from './guards/language.guard';
|
||||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
||||||
import { adminAuthGuard } from './core/admin-auth/admin-auth.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 { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||||
import { AdminLayoutComponent } from './features/admin/shell/admin-layout.component';
|
import { AdminLayoutComponent } from './features/admin/shell/admin-layout.component';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
@@ -45,10 +46,12 @@ const coreRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'edit',
|
path: 'edit',
|
||||||
|
canActivate: [adminAuthGuard],
|
||||||
loadComponent: () => import('./features/project-editor/pages/builder-overview-page.component').then(m => m.BuilderOverviewPageComponent)
|
loadComponent: () => import('./features/project-editor/pages/builder-overview-page.component').then(m => m.BuilderOverviewPageComponent)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'edit/:section',
|
path: 'edit/:section',
|
||||||
|
canActivate: [adminAuthGuard],
|
||||||
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent),
|
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent),
|
||||||
canDeactivate: [projectEditorDirtyGuard]
|
canDeactivate: [projectEditorDirtyGuard]
|
||||||
},
|
},
|
||||||
@@ -294,6 +297,7 @@ export const routes: Routes = [
|
|||||||
path: '__diagnostics',
|
path: '__diagnostics',
|
||||||
loadComponent: () => import('./features/diagnostics/components/diagnostics-page.component').then(m => m.DiagnosticsPageComponent)
|
loadComponent: () => import('./features/diagnostics/components/diagnostics-page.component').then(m => m.DiagnosticsPageComponent)
|
||||||
}]),
|
}]),
|
||||||
|
...authRoutes,
|
||||||
{
|
{
|
||||||
path: ':lang',
|
path: ':lang',
|
||||||
canActivate: [languageGuard],
|
canActivate: [languageGuard],
|
||||||
|
|||||||
19
src/app/core/auth/auth.routes.ts
Normal file
19
src/app/core/auth/auth.routes.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
];
|
||||||
27
src/app/core/auth/pages/admin-login-page.component.html
Normal file
27
src/app/core/auth/pages/admin-login-page.component.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="app-admin-login-page">
|
||||||
|
<div class="app-admin-login-page__card">
|
||||||
|
<h1 class="app-admin-login-page__title">Admin sign-in</h1>
|
||||||
|
<p class="app-admin-login-page__description">
|
||||||
|
Sign in with your device's Ed25519 key. A challenge is requested from the server, signed locally, and
|
||||||
|
verified - your private key never leaves this device.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (!isEd25519Supported) {
|
||||||
|
<p class="app-admin-login-page__warning" role="alert">
|
||||||
|
This browser does not support Ed25519 (requires WebCrypto + IndexedDB). Use an up-to-date browser.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (lastError()) {
|
||||||
|
<p class="app-admin-login-page__error" role="alert">{{ lastError()?.message }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (phaseLabel()) {
|
||||||
|
<p class="app-admin-login-page__phase">{{ phaseLabel() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-button variant="primary" [fullWidth]="true" [loading]="isBusy()" [disabled]="!isEd25519Supported || isBusy()" (click)="signIn()">
|
||||||
|
Sign in
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
45
src/app/core/auth/pages/admin-login-page.component.scss
Normal file
45
src/app/core/auth/pages/admin-login-page.component.scss
Normal file
@@ -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);
|
||||||
|
}
|
||||||
46
src/app/core/auth/pages/admin-login-page.component.ts
Normal file
46
src/app/core/auth/pages/admin-login-page.component.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/app/core/auth/pages/auth-error-page.component.html
Normal file
7
src/app/core/auth/pages/auth-error-page.component.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="app-auth-error-page">
|
||||||
|
<app-empty-state [title]="copy().title" [description]="copy().description">
|
||||||
|
<div slot="actions">
|
||||||
|
<app-button variant="primary" (click)="retry()">{{ copy().actionLabel }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-empty-state>
|
||||||
|
</div>
|
||||||
7
src/app/core/auth/pages/auth-error-page.component.scss
Normal file
7
src/app/core/auth/pages/auth-error-page.component.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.app-auth-error-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: var(--space-xl, 2rem);
|
||||||
|
}
|
||||||
75
src/app/core/auth/pages/auth-error-page.component.ts
Normal file
75
src/app/core/auth/pages/auth-error-page.component.ts
Normal file
@@ -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<AuthErrorCode, AuthErrorCopy> = {
|
||||||
|
'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<AuthErrorCopy>(() => 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user