feat: Landing Page and Login/Register Modal
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"dompurify": "^3.1.6",
|
||||
"marked": "^14.1.3",
|
||||
"rxjs": "^7.8.1",
|
||||
"tslib": "^2.6.2",
|
||||
"zone.js": "~0.14.4"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { authGuard } from './core/guards/auth.guard';
|
||||
import { adminGuard } from './core/guards/admin.guard';
|
||||
import { landingGuard } from './core/guards/landing.guard';
|
||||
|
||||
export const APP_ROUTES: Routes = [
|
||||
{
|
||||
@@ -12,7 +13,9 @@ export const APP_ROUTES: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./features/auth/login.component').then((m) => m.LoginComponent),
|
||||
loadComponent: () =>
|
||||
import('./features/landing/landing.component').then((m) => m.LandingComponent),
|
||||
canActivate: [landingGuard],
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<label>Username<input [(ngModel)]="username" name="u" /></label>
|
||||
<label>Password<input [(ngModel)]="password" name="p" type="password" /></label>
|
||||
<p style="color:var(--color-danger)">{{ error }}</p>
|
||||
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Signing in...' : 'Sign in' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private http = inject(HttpClient);
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
username = '';
|
||||
password = '';
|
||||
loading = false;
|
||||
error = '';
|
||||
|
||||
async submit(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
|
||||
const res: any = await this.http
|
||||
.post('/api/v1/auth/login', { username: this.username, password: this.password }, { withCredentials: true })
|
||||
.toPromise();
|
||||
this.auth.setSession(res.accessToken, res.user);
|
||||
await this.router.navigateByUrl('/');
|
||||
} catch (e: any) {
|
||||
this.error = e?.error?.message ?? 'Failed';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
.landing {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.landing-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.landing-logo {
|
||||
max-height: 120px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.landing-title {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-welcome {
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--color-surface, #eee);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: var(--color-surface, #fafafa);
|
||||
}
|
||||
|
||||
.landing-login-btn {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.landing-blog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.landing-blog-item {
|
||||
border-top: 1px solid var(--color-surface, #eee);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.landing-blog-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.landing-blog-date {
|
||||
display: block;
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.landing-blog-body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-blog-empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #222);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
width: min(420px, 92vw);
|
||||
position: relative;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.field input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-surface, #ccc);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--color-danger, #c00);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.server-error {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: rgba(204, 0, 0, 0.08);
|
||||
color: var(--color-danger, #c00);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.server-error-warn {
|
||||
background: rgba(204, 153, 0, 0.12);
|
||||
color: var(--color-warning, #b80);
|
||||
}
|
||||
|
||||
.server-error-hint {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-primary, #06c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-link-row {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-primary, #06c);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<section class="landing">
|
||||
<header class="landing-header">
|
||||
@if (logo()) {
|
||||
<img class="landing-logo" [src]="logo()" alt="" data-testid="landing-logo" />
|
||||
}
|
||||
<h1 class="landing-title" data-testid="landing-title">{{ pageTitle() }}</h1>
|
||||
</header>
|
||||
|
||||
<article class="landing-welcome" [innerHTML]="welcomeHtml" data-testid="landing-welcome"></article>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="landing-login-btn"
|
||||
(click)="openLogin()"
|
||||
data-testid="landing-open-login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
|
||||
@if (landing.posts().length > 0) {
|
||||
<section class="landing-blog" data-testid="landing-blog">
|
||||
@for (post of landing.posts(); track post.id) {
|
||||
<article class="landing-blog-item">
|
||||
<h2 class="landing-blog-title">{{ post.title }}</h2>
|
||||
<time class="landing-blog-date">{{ post.publishedAt | date: 'medium' }}</time>
|
||||
<div class="landing-blog-body" [innerHTML]="markdown.render(post.bodyMd)"></div>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
} @else if (!landing.loading()) {
|
||||
<p class="landing-blog-empty">No announcements yet.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (mode() !== 'closed') {
|
||||
<div
|
||||
class="modal-overlay"
|
||||
(click)="closeModal()"
|
||||
data-testid="landing-modal-overlay"
|
||||
>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="mode() === 'login' ? 'login-title' : 'register-title'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
(click)="closeModal()"
|
||||
aria-label="Close"
|
||||
data-testid="landing-modal-close"
|
||||
>×</button>
|
||||
|
||||
@if (mode() === 'login') {
|
||||
<h2 id="login-title">Sign in</h2>
|
||||
<form [formGroup]="loginForm" (ngSubmit)="submitLogin()" novalidate>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
data-testid="login-username"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
formControlName="password"
|
||||
data-testid="login-password"
|
||||
/>
|
||||
</label>
|
||||
@if (loginError()) {
|
||||
<small class="field-error" data-testid="login-field-error">{{ loginError() }}</small>
|
||||
}
|
||||
@if (errorText()) {
|
||||
<div
|
||||
class="server-error"
|
||||
role="alert"
|
||||
[class.server-error-warn]="errorCode() === 'RATE_LIMITED'"
|
||||
data-testid="login-server-error"
|
||||
>
|
||||
<span>{{ errorText() }}</span>
|
||||
@if (retryAfterSeconds() !== null) {
|
||||
<span class="server-error-hint">
|
||||
({{ retryAfterSeconds() }}s remaining)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
[disabled]="submitting()"
|
||||
data-testid="login-submit"
|
||||
>
|
||||
{{ submitting() ? 'Signing in...' : 'Login' }}
|
||||
</button>
|
||||
@if (registrationsEnabled()) {
|
||||
<p class="modal-link-row" data-testid="login-switch">
|
||||
No account?
|
||||
<button type="button" class="link" (click)="switchToRegister()" data-testid="login-switch-btn">
|
||||
Register here
|
||||
</button>
|
||||
</p>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (mode() === 'register') {
|
||||
<h2 id="register-title">Create your account</h2>
|
||||
<form [formGroup]="registerForm" (ngSubmit)="submitRegister()" novalidate>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
data-testid="register-username"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="password"
|
||||
data-testid="register-password"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Confirm password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="passwordConfirm"
|
||||
data-testid="register-confirm"
|
||||
/>
|
||||
</label>
|
||||
@if (registerError()) {
|
||||
<small class="field-error" data-testid="register-field-error">{{ registerError() }}</small>
|
||||
}
|
||||
@if (errorText()) {
|
||||
<div
|
||||
class="server-error"
|
||||
role="alert"
|
||||
[class.server-error-warn]="errorCode() === 'RATE_LIMITED' || errorCode() === 'REGISTRATIONS_DISABLED'"
|
||||
data-testid="register-server-error"
|
||||
>
|
||||
<span>{{ errorText() }}</span>
|
||||
@if (retryAfterSeconds() !== null) {
|
||||
<span class="server-error-hint">
|
||||
({{ retryAfterSeconds() }}s remaining)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
[disabled]="submitting()"
|
||||
data-testid="register-submit"
|
||||
>
|
||||
{{ submitting() ? 'Registering...' : 'Register' }}
|
||||
</button>
|
||||
<p class="modal-link-row" data-testid="register-switch">
|
||||
Already have an account?
|
||||
<button type="button" class="link" (click)="switchToLogin()" data-testid="register-switch-btn">
|
||||
Login here
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { MarkdownService } from '../../core/services/markdown.service';
|
||||
import { LandingService } from './landing.service';
|
||||
import { buildLoginFailureMessage } from './login-modal.service';
|
||||
import { passwordMatchValidator } from '../setup/setup-create-admin.validators';
|
||||
|
||||
type ModalMode = 'closed' | 'login' | 'register';
|
||||
|
||||
@Component({
|
||||
selector: 'app-landing',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './landing.component.html',
|
||||
styleUrls: ['./landing.component.css'],
|
||||
})
|
||||
export class LandingComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly bootstrap = inject(BootstrapService);
|
||||
private readonly markdown = inject(MarkdownService);
|
||||
readonly landing = inject(LandingService);
|
||||
|
||||
readonly mode = signal<ModalMode>('closed');
|
||||
readonly submitting = signal(false);
|
||||
readonly errorText = signal<string | null>(null);
|
||||
readonly errorCode = signal<string | null>(null);
|
||||
readonly retryAfterSeconds = signal<number | null>(null);
|
||||
|
||||
readonly pageTitle = computed(() => this.bootstrap.payload()?.pageTitle ?? 'HIPCTF');
|
||||
readonly logo = computed(() => this.bootstrap.payload()?.logo ?? '');
|
||||
readonly welcomeHtml = computed(() => this.markdown.render(this.bootstrap.payload()?.welcomeMarkdown ?? ''));
|
||||
readonly registrationsEnabled = computed(
|
||||
() => this.bootstrap.payload()?.registrationsEnabled ?? false,
|
||||
);
|
||||
|
||||
readonly loginForm = this.fb.nonNullable.group({
|
||||
username: this.fb.nonNullable.control('', [Validators.required]),
|
||||
password: this.fb.nonNullable.control('', [Validators.required]),
|
||||
});
|
||||
|
||||
readonly registerForm = this.fb.nonNullable.group(
|
||||
{
|
||||
username: this.fb.nonNullable.control('', [
|
||||
Validators.required,
|
||||
Validators.minLength(3),
|
||||
Validators.maxLength(32),
|
||||
Validators.pattern(/^[a-zA-Z0-9_.-]+$/),
|
||||
]),
|
||||
password: this.fb.nonNullable.control('', [Validators.required]),
|
||||
passwordConfirm: this.fb.nonNullable.control('', [Validators.required]),
|
||||
},
|
||||
{ validators: passwordMatchValidator },
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
void this.landing.refresh();
|
||||
}
|
||||
|
||||
openLogin(): void {
|
||||
this.mode.set('login');
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
openRegister(): void {
|
||||
this.mode.set('register');
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
closeModal(): void {
|
||||
if (this.submitting()) return;
|
||||
this.mode.set('closed');
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
switchToLogin(): void {
|
||||
this.mode.set('login');
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
switchToRegister(): void {
|
||||
this.mode.set('register');
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
this.closeModal();
|
||||
}
|
||||
|
||||
loginError(): string | null {
|
||||
const c = this.loginForm.controls;
|
||||
if (!c.username.touched && !c.password.touched) return null;
|
||||
if (c.username.errors?.['required']) return 'Username is required';
|
||||
if (c.password.errors?.['required']) return 'Password is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
registerError(): string | null {
|
||||
const c = this.registerForm.controls;
|
||||
if (!c.username.touched && !c.password.touched && !c.passwordConfirm.touched && !this.registerForm.errors) {
|
||||
return null;
|
||||
}
|
||||
if (c.username.errors?.['required']) return 'Username is required';
|
||||
if (c.username.errors?.['minlength']) return 'Username must be at least 3 characters';
|
||||
if (c.username.errors?.['maxlength']) return 'Username must be at most 32 characters';
|
||||
if (c.username.errors?.['pattern']) {
|
||||
return 'Username may only contain letters, digits, dot, dash and underscore';
|
||||
}
|
||||
if (c.password.errors?.['required']) return 'Password is required';
|
||||
if (c.passwordConfirm.errors?.['required']) return 'Please confirm your password';
|
||||
if (this.registerForm.errors?.['passwordMismatch']) return 'Passwords do not match';
|
||||
return null;
|
||||
}
|
||||
|
||||
async submitLogin(): Promise<void> {
|
||||
if (this.submitting()) return;
|
||||
this.loginForm.markAllAsTouched();
|
||||
if (this.loginForm.invalid) return;
|
||||
this.submitting.set(true);
|
||||
this.clearError();
|
||||
const { username, password } = this.loginForm.getRawValue();
|
||||
const result: { ok: true; accessToken: string; expiresIn: number; user: any } | {
|
||||
ok: false;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
} = await this.auth.login({ username, password });
|
||||
this.submitting.set(false);
|
||||
if (result.ok === true) {
|
||||
this.auth.setSession(result.accessToken, result.user);
|
||||
this.mode.set('closed');
|
||||
await this.router.navigateByUrl('/');
|
||||
return;
|
||||
}
|
||||
if (result.ok === false) {
|
||||
this.showFailure(result.code, result.message);
|
||||
}
|
||||
}
|
||||
|
||||
async submitRegister(): Promise<void> {
|
||||
if (this.submitting()) return;
|
||||
this.registerForm.markAllAsTouched();
|
||||
if (this.registerForm.invalid) return;
|
||||
this.submitting.set(true);
|
||||
this.clearError();
|
||||
const { username, password, passwordConfirm } = this.registerForm.getRawValue();
|
||||
const result: { ok: true; accessToken: string; expiresIn: number; user: any } | {
|
||||
ok: false;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
} = await this.auth.register({ username, password, passwordConfirm });
|
||||
this.submitting.set(false);
|
||||
if (result.ok === true) {
|
||||
this.auth.setSession(result.accessToken, result.user);
|
||||
this.mode.set('closed');
|
||||
await this.router.navigateByUrl('/');
|
||||
return;
|
||||
}
|
||||
if (result.ok === false) {
|
||||
this.showFailure(result.code, result.message);
|
||||
}
|
||||
}
|
||||
|
||||
private showFailure(code: string, message: string): void {
|
||||
this.errorCode.set(code);
|
||||
const built = buildLoginFailureMessage({ code, message });
|
||||
this.errorText.set(built.text);
|
||||
this.retryAfterSeconds.set(built.retryAfterSeconds ?? null);
|
||||
}
|
||||
|
||||
private clearError(): void {
|
||||
this.errorCode.set(null);
|
||||
this.errorText.set(null);
|
||||
this.retryAfterSeconds.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface PublicBlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
publishedAt: string;
|
||||
bodyMd: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LandingService {
|
||||
private readonly http = inject(HttpClient);
|
||||
readonly posts = signal<PublicBlogPost[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
this.http.get<{ posts: PublicBlogPost[] }>('/api/v1/blog/posts', {
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
this.posts.set(res.posts ?? []);
|
||||
} catch (e: any) {
|
||||
this.error.set(e?.error?.message ?? 'Failed to load blog posts');
|
||||
this.posts.set([]);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface FailureMessageInput {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FailureMessageOutput {
|
||||
text: string;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
const RETRY_REGEX = /try again in (\d+)\s*s/i;
|
||||
const WAIT_REGEX = /wait (\d+)\s*s/i;
|
||||
|
||||
/**
|
||||
* Pure helper that translates an API error envelope into user-facing copy.
|
||||
* Returns the wait-seconds for backoff-style errors so the UI can show a
|
||||
* "please wait N seconds" hint.
|
||||
*/
|
||||
export function buildLoginFailureMessage(input: FailureMessageInput): FailureMessageOutput {
|
||||
switch (input.code) {
|
||||
case 'INVALID_CREDENTIALS':
|
||||
return { text: 'Invalid username or password.' };
|
||||
case 'RATE_LIMITED': {
|
||||
const m = RETRY_REGEX.exec(input.message) || WAIT_REGEX.exec(input.message);
|
||||
const seconds = m ? parseInt(m[1], 10) : undefined;
|
||||
return seconds
|
||||
? {
|
||||
text: `Please wait ${seconds} seconds before trying again.`,
|
||||
retryAfterSeconds: seconds,
|
||||
}
|
||||
: { text: 'Too many attempts. Please wait a moment before trying again.' };
|
||||
}
|
||||
case 'CSRF_INVALID':
|
||||
return { text: 'Session expired. Please reload the page and try again.' };
|
||||
case 'USERNAME_TAKEN':
|
||||
return { text: 'Username already exists.' };
|
||||
case 'WEAK_PASSWORD':
|
||||
return { text: 'Password does not meet the security policy.' };
|
||||
case 'REGISTRATIONS_DISABLED':
|
||||
return { text: 'Registrations are currently disabled.' };
|
||||
case 'VALIDATION_FAILED':
|
||||
return { text: 'Please check the form fields and try again.' };
|
||||
default:
|
||||
return { text: input.message || 'Something went wrong. Please try again.' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user