AI Implementation feature(823): Landing Page and Login/Register Modal (#11)

This commit was merged in pull request #11.
This commit is contained in:
2026-07-21 18:34:45 +00:00
parent 8476e4a59f
commit 685a8bca84
51 changed files with 2354 additions and 226 deletions
@@ -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.' };
}
}