AI Implementation feature(823): Landing Page and Login/Register Modal (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import type { Router } from '@angular/router';
|
||||
|
||||
export interface LandingGuardInput {
|
||||
initialized: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export function decideLandingGuard(
|
||||
input: LandingGuardInput,
|
||||
router: Pick<Router, 'createUrlTree'>,
|
||||
): true | ReturnType<Router['createUrlTree']> {
|
||||
if (!input.initialized) {
|
||||
return router.createUrlTree(['/bootstrap']);
|
||||
}
|
||||
if (input.isAuthenticated) {
|
||||
return router.createUrlTree(['/']);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { BootstrapService } from '../services/bootstrap.service';
|
||||
import { decideLandingGuard } from './landing.guard.decision';
|
||||
|
||||
export { decideLandingGuard } from './landing.guard.decision';
|
||||
export type { LandingGuardInput } from './landing.guard.decision';
|
||||
|
||||
export const landingGuard: CanActivateFn = async () => {
|
||||
const auth = inject(AuthService);
|
||||
const bootstrap = inject(BootstrapService);
|
||||
const router = inject(Router);
|
||||
|
||||
await bootstrap.ready();
|
||||
await auth.waitUntilHydrated();
|
||||
|
||||
return decideLandingGuard(
|
||||
{
|
||||
initialized: bootstrap.initialized(),
|
||||
isAuthenticated: auth.isAuthenticated(),
|
||||
},
|
||||
router,
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
readStoredSession,
|
||||
@@ -20,6 +20,38 @@ interface RefreshResponse {
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
export interface LoginSuccess {
|
||||
ok: true;
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
export interface LoginFailure {
|
||||
ok: false;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type LoginResult = LoginSuccess | LoginFailure;
|
||||
|
||||
export interface RegisterSuccess {
|
||||
ok: true;
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
export interface RegisterFailure {
|
||||
ok: false;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RegisterResult = RegisterSuccess | RegisterFailure;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http: HttpClient;
|
||||
@@ -36,6 +68,64 @@ export class AuthService {
|
||||
readonly currentUser = computed(() => this.user());
|
||||
readonly isAuthenticated = computed(() => !!this.user());
|
||||
|
||||
async ensureCsrf(): Promise<void> {
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.http.get('/api/v1/auth/csrf', { withCredentials: true }),
|
||||
);
|
||||
} catch {
|
||||
// middleware will still mint the cookie on the next response
|
||||
}
|
||||
}
|
||||
|
||||
async login(creds: { username: string; password: string }): Promise<LoginResult> {
|
||||
await this.ensureCsrf();
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
|
||||
'/api/v1/auth/login',
|
||||
creds,
|
||||
{ withCredentials: true, observe: 'response' },
|
||||
),
|
||||
);
|
||||
const success: LoginSuccess = {
|
||||
ok: true,
|
||||
accessToken: res.body!.accessToken,
|
||||
expiresIn: res.body!.expiresIn,
|
||||
user: res.body!.user,
|
||||
};
|
||||
return success;
|
||||
} catch (e) {
|
||||
return mapAuthError(e as HttpErrorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
async register(creds: {
|
||||
username: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}): Promise<RegisterResult> {
|
||||
await this.ensureCsrf();
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.post<{ accessToken: string; expiresIn: number; user: CurrentUser }>(
|
||||
'/api/v1/auth/register',
|
||||
creds,
|
||||
{ withCredentials: true, observe: 'response' },
|
||||
),
|
||||
);
|
||||
const success: RegisterSuccess = {
|
||||
ok: true,
|
||||
accessToken: res.body!.accessToken,
|
||||
expiresIn: res.body!.expiresIn,
|
||||
user: res.body!.user,
|
||||
};
|
||||
return success;
|
||||
} catch (e) {
|
||||
return mapAuthError(e as HttpErrorResponse) as RegisterFailure;
|
||||
}
|
||||
}
|
||||
|
||||
setSession(token: string, user: CurrentUser): void {
|
||||
this.accessToken.set(token);
|
||||
this.user.set(user);
|
||||
@@ -104,4 +194,14 @@ export class AuthService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapAuthError(err: HttpErrorResponse): LoginFailure {
|
||||
const body = err.error as { code?: string; message?: string } | null;
|
||||
return {
|
||||
ok: false,
|
||||
status: err.status ?? 0,
|
||||
code: body?.code ?? 'INTERNAL',
|
||||
message: body?.message ?? err.message ?? 'Authentication failed',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Pure helper: render Markdown to a sanitized HTML string. Kept in its own
|
||||
* file so it can be unit-tested without pulling in Angular's ESM-only
|
||||
* runtime.
|
||||
*/
|
||||
export function renderMarkdownToHtml(md: string | null | undefined): string {
|
||||
const html = marked.parse(md ?? '', { async: false }) as string;
|
||||
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { renderMarkdownToHtml } from './markdown.pure';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MarkdownService {
|
||||
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||
|
||||
render(md: string | null | undefined): SafeHtml {
|
||||
return this.sanitizer.bypassSecurityTrustHtml(renderMarkdownToHtml(md));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user