AI Implementation feature(857): Authenticated Shell: Header, Quick Tabs and Change Password (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-07-21 22:26:47 +00:00
parent 8dc8cee769
commit b6dbfcc511
46 changed files with 2705 additions and 211 deletions
+15
View File
@@ -22,6 +22,21 @@ export const APP_ROUTES: Routes = [
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
canActivate: [authGuard],
children: [
{ path: '', pathMatch: 'full', redirectTo: 'challenges' },
{
path: 'challenges',
loadComponent: () =>
import('./features/challenges/challenges.page').then((m) => m.ChallengesPage),
},
{
path: 'scoreboard',
loadComponent: () =>
import('./features/scoreboard/scoreboard.page').then((m) => m.ScoreboardPage),
},
{
path: 'blog',
loadComponent: () => import('./features/blog/blog.page').then((m) => m.BlogPage),
},
{
path: 'admin',
canActivate: [adminGuard],
+67 -1
View File
@@ -12,6 +12,8 @@ export interface CurrentUser {
id: string;
username: string;
role: 'admin' | 'player';
rank?: number | null;
points?: number;
}
interface RefreshResponse {
@@ -20,6 +22,14 @@ interface RefreshResponse {
user: CurrentUser;
}
export interface MeResponse {
id: string;
username: string;
role: 'admin' | 'player';
rank: number | null;
points: number;
}
export interface LoginSuccess {
ok: true;
accessToken: string;
@@ -52,6 +62,19 @@ export interface RegisterFailure {
export type RegisterResult = RegisterSuccess | RegisterFailure;
export interface ChangePasswordSuccess {
ok: true;
}
export interface ChangePasswordFailure {
ok: false;
status: number;
code: string;
message: string;
}
export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure;
@Injectable({ providedIn: 'root' })
export class AuthService {
private http: HttpClient;
@@ -126,6 +149,49 @@ export class AuthService {
}
}
async logout(): Promise<void> {
await this.ensureCsrf();
try {
await firstValueFrom(
this.http.post('/api/v1/auth/logout', {}, { withCredentials: true, observe: 'response' }),
);
} catch {
// ignore network/auth errors; we still clear local state
}
this.clear();
}
async me(): Promise<MeResponse> {
const res = await firstValueFrom(
this.http.get<MeResponse>('/api/v1/auth/me', { withCredentials: true }),
);
const current = this.user();
if (current && current.id === res.id) {
this.user.set({ ...current, rank: res.rank, points: res.points });
}
return res;
}
async changePassword(dto: {
oldPassword: string;
newPassword: string;
confirmNewPassword: string;
}): Promise<ChangePasswordResult> {
await this.ensureCsrf();
try {
await firstValueFrom(
this.http.post(
'/api/v1/auth/change-password',
dto,
{ withCredentials: true, observe: 'response' },
),
);
return { ok: true };
} catch (e) {
return mapAuthError(e as HttpErrorResponse);
}
}
setSession(token: string, user: CurrentUser): void {
this.accessToken.set(token);
this.user.set(user);
@@ -204,4 +270,4 @@ function mapAuthError(err: HttpErrorResponse): LoginFailure {
code: body?.code ?? 'INTERNAL',
message: body?.message ?? err.message ?? 'Authentication failed',
};
}
}
@@ -0,0 +1,38 @@
export type EventState = 'running' | 'countdown' | 'stopped' | 'unconfigured';
export interface EventStatePayload {
state: EventState;
serverNowUtc: string;
eventStartUtc: string | null;
eventEndUtc: string | null;
secondsToStart: number | null;
secondsToEnd: number | null;
}
export interface EventSourceLike {
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void;
close(): void;
}
export function formatDdHhMm(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00';
const s = Math.floor(totalSeconds);
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(days)}:${pad(hours)}:${pad(minutes)}`;
}
export function deriveCountdownText(
state: EventState,
secondsToStart: number | null,
secondsToEnd: number | null,
): string {
if (state === 'unconfigured') return '';
if (state === 'stopped') return 'Event ended';
if (state === 'countdown') {
return secondsToStart == null ? '00:00:00' : formatDdHhMm(secondsToStart);
}
return secondsToEnd == null ? '00:00:00' : formatDdHhMm(secondsToEnd);
}
@@ -0,0 +1,93 @@
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import {
deriveCountdownText,
EventSourceLike,
EventState,
EventStatePayload,
} from './event-status.pure';
export { EventState, EventStatePayload, EventSourceLike, deriveCountdownText, formatDdHhMm } from './event-status.pure';
@Injectable({ providedIn: 'root' })
export class EventStatusStore {
private readonly destroyRef = inject(DestroyRef);
readonly state = signal<EventState>('unconfigured');
readonly serverNowUtc = signal<string | null>(null);
readonly eventStartUtc = signal<string | null>(null);
readonly eventEndUtc = signal<string | null>(null);
private readonly anchorDeltaMs = signal<number>(0);
private readonly tick = signal<number>(0);
private readonly secondsToStart = computed<number | null>(() => {
void this.tick();
const s = this.eventStartUtc();
if (!s) return null;
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
return diff > 0 ? diff : 0;
});
private readonly secondsToEnd = computed<number | null>(() => {
void this.tick();
const s = this.eventEndUtc();
if (!s) return null;
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
return diff > 0 ? diff : 0;
});
readonly countdownText = computed<string>(() => {
return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd());
});
private intervalId: ReturnType<typeof setInterval> | null = null;
private source: EventSourceLike | null = null;
constructor() {
this.destroyRef.onDestroy(() => this.stop());
}
applyServerStatus(payload: EventStatePayload): void {
this.state.set(payload.state);
this.serverNowUtc.set(payload.serverNowUtc);
this.eventStartUtc.set(payload.eventStartUtc);
this.eventEndUtc.set(payload.eventEndUtc);
this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime());
}
start(createSource: () => EventSourceLike): void {
this.stop();
const src = createSource();
this.source = src;
src.addEventListener('message', (ev) => {
const me = ev as MessageEvent;
try {
const data = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
if (data && typeof data === 'object') {
this.applyServerStatus(data as EventStatePayload);
}
} catch {
// ignore malformed frame
}
});
if (!this.intervalId) {
this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000);
}
}
stop(): void {
if (this.source) {
try {
this.source.close();
} catch {
// ignore
}
this.source = null;
}
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
}
@@ -0,0 +1,55 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { AuthService, CurrentUser, MeResponse } from './auth.service';
@Injectable({ providedIn: 'root' })
export class UserStore {
private readonly auth = inject(AuthService);
readonly user = signal<CurrentUser | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly role = computed(() => this.user()?.role);
readonly isAdmin = computed(() => this.role() === 'admin');
readonly rankText = computed<string>(() => {
const u = this.user();
if (!u) return '';
const r = u.rank ?? '-';
const p = u.points ?? 0;
return `Rank: ${r} - Points: ${p}`;
});
hydrateFromAuth(): void {
this.user.set(this.auth.currentUser());
}
async loadMe(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const res: MeResponse = await this.auth.me();
const current = this.auth.currentUser();
if (current) {
this.user.set({ ...current, rank: res.rank, points: res.points });
} else {
this.user.set({
id: res.id,
username: res.username,
role: res.role,
rank: res.rank,
points: res.points,
});
}
} catch (e: any) {
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load user');
} finally {
this.loading.set(false);
}
}
reset(): void {
this.user.set(null);
this.loading.set(false);
this.error.set(null);
}
}
@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-blog-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page-blog">
<h2>Blog</h2>
<p>Blog posts will appear here.</p>
</section>
`,
})
export class BlogPage {}
@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-challenges-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page-challenges">
<h2>Challenges</h2>
<p>Challenge list will appear here.</p>
</section>
`,
})
export class ChallengesPage {}
+173 -22
View File
@@ -1,47 +1,198 @@
import { Component, computed, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterOutlet } from '@angular/router';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs/operators';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { AuthService } from '../../core/services/auth.service';
import { shouldShowAdminNav } from './home.shell';
import { EventStatusStore } from '../../core/services/event-status.store';
import { UserStore } from '../../core/services/user.store';
import { ShellHeaderComponent } from '../shell/header/shell-header.component';
import {
ChangePasswordModalComponent,
ChangePasswordSubmitPayload,
} from '../shell/change-password/change-password-modal.component';
import { ChangePasswordFailure, ChangePasswordResult } from '../../core/services/auth.service';
import { QuickTabsComponent, ShellTab } from '../shell/tabs/quick-tabs.component';
import { deriveActiveSectionFromUrl, shouldShowAdminNav } from './home.shell';
import { formatChangePasswordError } from '../shell/change-password/password-feedback';
export { shouldShowAdminNav } from './home.shell';
const TABS: ShellTab[] = [
{ id: 'challenges', label: 'Challenges' },
{ id: 'scoreboard', label: 'Scoreboard' },
{ id: 'blog', label: 'Blog' },
];
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, RouterLink, RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
RouterOutlet,
ShellHeaderComponent,
ChangePasswordModalComponent,
QuickTabsComponent,
],
template: `
<div class="container">
<header class="shell-header">
<h1 class="shell-title">{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
<div class="shell-user" *ngIf="auth.isAuthenticated()">
<span>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</span>
</div>
</header>
<app-shell-header
[pageTitle]="bootstrap.payload()?.pageTitle ?? 'HIPCTF'"
[activeSection]="activeSection()"
[eventState]="eventStatus.state()"
[countdownText]="eventStatus.countdownText()"
[username]="userStore.user()?.username ?? ''"
[rankText]="userStore.rankText()"
[canAccessAdmin]="canAccessAdmin()"
[userMenuOpen]="userMenuOpen()"
(titleClick)="goHome()"
(usernameMenuToggle)="toggleUserMenu()"
(rankClick)="navigateToSection('scoreboard')"
(changePasswordClick)="openChangePassword()"
(adminClick)="goAdmin()"
(logoutClick)="onLogout()"
/>
<nav class="shell-nav" *ngIf="showAdminNav()">
<a routerLink="/admin" data-testid="nav-admin">Admin</a>
</nav>
<app-quick-tabs
[tabs]="TABS"
[active]="activeTabId()"
(tabChange)="navigateToSection($event.id)"
/>
<section class="shell-body">
<div *ngIf="!auth.isAuthenticated()">
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
<a href="/login">Sign in</a>
</div>
<router-outlet></router-outlet>
</section>
<app-change-password-modal
[open]="changePasswordOpen()"
[mode]="'self'"
[policy]="bootstrap.passwordPolicy()"
[errorMessage]="changePasswordError()"
[submitting]="changePasswordSubmitting()"
(cancel)="closeChangePassword()"
(submit)="submitChangePassword($event)"
/>
</div>
`,
})
export class HomeComponent {
bootstrap = inject(BootstrapService);
auth = inject(AuthService);
export class HomeComponent implements OnInit, OnDestroy {
readonly TABS = TABS;
readonly bootstrap = inject(BootstrapService);
readonly auth = inject(AuthService);
readonly eventStatus = inject(EventStatusStore);
readonly userStore = inject(UserStore);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
readonly showAdminNav = computed(() =>
readonly userMenuOpen = signal(false);
readonly changePasswordOpen = signal(false);
readonly changePasswordSubmitting = signal(false);
readonly changePasswordError = signal<string | null>(null);
private readonly currentUrl = signal<string>(this.router.url || '/');
readonly activeSection = computed(() => deriveActiveSectionFromUrl(this.currentUrl()));
readonly activeTabId = computed(() => {
const url = this.currentUrl();
if (url.startsWith('/scoreboard')) return 'scoreboard';
if (url.startsWith('/blog')) return 'blog';
if (url.startsWith('/admin')) return 'admin';
return 'challenges';
});
readonly canAccessAdmin = computed(() =>
shouldShowAdminNav({
isAuthenticated: this.auth.isAuthenticated(),
role: this.auth.currentUser()?.role,
}),
);
constructor() {
this.router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe((e) => this.currentUrl.set(e.urlAfterRedirects || e.url));
this.destroyRef.onDestroy(() => {
this.eventStatus.stop();
});
}
ngOnInit(): void {
this.userStore.hydrateFromAuth();
void this.userStore.loadMe();
if (typeof window !== 'undefined' && typeof (window as any).EventSource !== 'undefined') {
const Ctor = (window as any).EventSource as new (url: string, init?: any) => any;
this.eventStatus.start(() => {
return new Ctor('/api/v1/events/status', { withCredentials: true }) as any;
});
}
}
ngOnDestroy(): void {
this.eventStatus.stop();
}
goHome(): void {
void this.router.navigateByUrl('/challenges');
this.userMenuOpen.set(false);
}
goAdmin(): void {
void this.router.navigateByUrl('/admin');
this.userMenuOpen.set(false);
}
toggleUserMenu(): void {
this.userMenuOpen.update((v) => !v);
}
navigateToSection(id: string): void {
if (id === 'admin') {
void this.router.navigateByUrl('/admin');
} else {
void this.router.navigateByUrl('/' + id);
}
this.userMenuOpen.set(false);
}
openChangePassword(): void {
this.changePasswordError.set(null);
this.changePasswordOpen.set(true);
this.userMenuOpen.set(false);
}
closeChangePassword(): void {
if (this.changePasswordSubmitting()) return;
this.changePasswordOpen.set(false);
this.changePasswordError.set(null);
}
async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise<void> {
this.changePasswordSubmitting.set(true);
this.changePasswordError.set(null);
const result: ChangePasswordResult = await this.auth.changePassword(payload);
this.changePasswordSubmitting.set(false);
if (result.ok === true) {
this.changePasswordOpen.set(false);
return;
}
const failure = result as ChangePasswordFailure;
this.changePasswordError.set(
formatChangePasswordError({ code: failure.code, message: failure.message }),
);
}
async onLogout(): Promise<void> {
await this.auth.logout();
this.userMenuOpen.set(false);
this.userStore.reset();
await this.router.navigateByUrl('/login');
}
}
@@ -1,3 +1,11 @@
export function deriveActiveSectionFromUrl(url: string): string {
const trimmed = (url || '').split('?')[0].split('#')[0];
if (trimmed.startsWith('/scoreboard')) return 'Scoreboard';
if (trimmed.startsWith('/blog')) return 'Blog';
if (trimmed.startsWith('/admin')) return 'Admin';
return 'Challenges';
}
export function shouldShowAdminNav(input: {
isAuthenticated: boolean;
role: 'admin' | 'player' | undefined;
@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-scoreboard-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page-scoreboard">
<h2>Scoreboard</h2>
<p>Live scoreboard will appear here.</p>
</section>
`,
})
export class ScoreboardPage {}
@@ -0,0 +1,163 @@
import {
ChangeDetectionStrategy,
Component,
computed,
HostListener,
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import {
passwordMatchValidator,
PasswordPolicy,
} from './change-password-modal.validators';
export type ChangePasswordMode = 'self' | 'admin-reset';
export interface ChangePasswordSubmitPayload {
oldPassword: string;
newPassword: string;
confirmNewPassword: string;
}
const DEFAULT_POLICY: PasswordPolicy = {
minLength: 12,
requireMixed: true,
description: 'At least 12 characters with upper, lower, digit and symbol',
};
@Component({
selector: 'app-change-password-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()">
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="cp-title"
data-testid="change-password-modal"
(click)="$event.stopPropagation()"
>
<h2 id="cp-title">Change password</h2>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
@if (mode() === 'self') {
<label class="field">
<span>Old password</span>
<input
type="password"
autocomplete="current-password"
formControlName="oldPassword"
data-testid="cp-old"
/>
</label>
}
<label class="field">
<span>New password</span>
<input
type="password"
autocomplete="new-password"
formControlName="newPassword"
data-testid="cp-new"
/>
</label>
<label class="field">
<span>Confirm new password</span>
<input
type="password"
autocomplete="new-password"
formControlName="confirmNewPassword"
data-testid="cp-confirm"
/>
</label>
@if (errorMessage()) {
<p class="error" data-testid="cp-error">{{ errorMessage() }}</p>
}
@if (formError()) {
<p class="error" data-testid="cp-form-error">{{ formError() }}</p>
}
<p class="hint" data-testid="cp-policy">{{ policy().description }}</p>
<div class="actions">
<button
type="button"
data-testid="cp-cancel"
(click)="onCancel()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
data-testid="cp-ok"
[disabled]="submitting()"
>
OK
</button>
</div>
</form>
</div>
</div>
}
`,
})
export class ChangePasswordModalComponent {
private readonly fb = inject(FormBuilder);
readonly open = input<boolean>(false);
readonly mode = input<ChangePasswordMode>('self');
readonly policy = input<PasswordPolicy>(DEFAULT_POLICY);
readonly errorMessage = input<string | null>(null);
readonly submitting = input<boolean>(false);
readonly cancel = output<void>();
readonly submit = output<ChangePasswordSubmitPayload>();
readonly form = this.fb.nonNullable.group(
{
oldPassword: this.fb.nonNullable.control(''),
newPassword: this.fb.nonNullable.control('', [Validators.required]),
confirmNewPassword: this.fb.nonNullable.control('', [Validators.required]),
},
{ validators: passwordMatchValidator },
);
readonly formError = computed<string | null>(() => {
const errs = this.form.errors;
if (errs?.['passwordMismatch']) return 'Passwords do not match';
return null;
});
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open()) this.onCancel();
}
onCancel(): void {
this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' });
this.cancel.emit();
}
onSubmit(): void {
if (this.submitting()) return;
const v = this.form.getRawValue();
if (this.mode() === 'self' && !v.oldPassword) return;
if (!v.newPassword || !v.confirmNewPassword) return;
if (this.form.invalid) return;
this.submit.emit({
oldPassword: v.oldPassword,
newPassword: v.newPassword,
confirmNewPassword: v.confirmNewPassword,
});
}
}
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const PasswordPolicySchema = z.object({
minLength: z.number().int().nonnegative(),
requireMixed: z.boolean(),
description: z.string(),
});
export type PasswordPolicy = z.infer<typeof PasswordPolicySchema>;
export function passwordMatchValidator(group: { get: (k: string) => any; errors?: any }): { passwordMismatch?: boolean } | null {
const newP = group.get('newPassword')?.value;
const conf = group.get('confirmNewPassword')?.value;
if (!newP || !conf) return null;
return newP === conf ? null : { passwordMismatch: true };
}
export function buildPasswordPolicyMessage(policy: PasswordPolicy): string {
return policy.description;
}
@@ -0,0 +1,19 @@
export function formatChangePasswordError(input: {
code: string | null | undefined;
message?: string | null;
}): string {
const code = (input.code ?? '').toString();
const message = (input.message ?? '').toString();
switch (code) {
case 'INVALID_OLD_PASSWORD':
return 'Old password is incorrect';
case 'PASSWORDS_DO_NOT_MATCH':
return 'New password and confirmation do not match';
case 'PASSWORD_POLICY':
return message || 'New password does not meet the policy requirements';
case 'UNAUTHORIZED':
return 'You must be signed in to change your password';
default:
return message || 'Failed to change password';
}
}
@@ -0,0 +1,108 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EventState } from '../../../core/services/event-status.store';
@Component({
selector: 'app-shell-header',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<header class="shell-header" data-testid="shell-header">
<button
type="button"
class="shell-title"
data-testid="shell-title"
(click)="titleClick.emit()"
>
{{ pageTitle() }}
</button>
<div class="shell-section" data-testid="shell-active-section">
{{ activeSection() }}
</div>
<div class="shell-right">
<span
class="led"
[class.led-running]="eventState() === 'running'"
[class.led-countdown]="eventState() === 'countdown'"
[class.led-stopped]="eventState() === 'stopped'"
[class.led-unconfigured]="eventState() === 'unconfigured'"
data-testid="shell-led"
[attr.aria-label]="'Event status: ' + eventState()"
></span>
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span>
<div class="user-menu-wrapper">
<button
type="button"
class="user-menu-trigger"
data-testid="user-menu-trigger"
(click)="usernameMenuToggle.emit()"
>
{{ username() }} <span aria-hidden="true">▾</span>
</button>
@if (userMenuOpen()) {
<div class="user-menu" role="menu" data-testid="user-menu">
<button
type="button"
class="user-menu-item"
data-testid="user-menu-rank"
(click)="rankClick.emit()"
>
{{ rankText() }}
</button>
<button
type="button"
class="user-menu-item"
data-testid="user-menu-change-password"
(click)="changePasswordClick.emit()"
>
Change password
</button>
@if (canAccessAdmin()) {
<button
type="button"
class="user-menu-item"
data-testid="user-menu-admin"
(click)="adminClick.emit()"
>
Admin area
</button>
}
<button
type="button"
class="user-menu-item"
data-testid="user-menu-logout"
(click)="logoutClick.emit()"
>
Logout
</button>
</div>
}
</div>
</div>
</header>
`,
})
export class ShellHeaderComponent {
readonly pageTitle = input<string>('HIPCTF');
readonly activeSection = input<string>('Challenges');
readonly eventState = input<EventState>('unconfigured');
readonly countdownText = input<string>('');
readonly username = input<string>('');
readonly rankText = input<string>('');
readonly canAccessAdmin = input<boolean>(false);
readonly userMenuOpen = input<boolean>(false);
readonly titleClick = output<void>();
readonly usernameMenuToggle = output<void>();
readonly rankClick = output<void>();
readonly changePasswordClick = output<void>();
readonly adminClick = output<void>();
readonly logoutClick = output<void>();
readonly ledClass = computed(() => `led led-${this.eventState()}`);
}
@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface ShellTab {
id: string;
label: string;
}
@Component({
selector: 'app-quick-tabs',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<nav class="quick-tabs" data-testid="quick-tabs">
@for (tab of tabs(); track tab.id) {
<button
type="button"
class="quick-tab"
[class.active]="active() === tab.id"
[attr.data-testid]="'quick-tab-' + tab.id"
(click)="onTabClick(tab)"
>
{{ tab.label }}
</button>
}
</nav>
`,
})
export class QuickTabsComponent {
readonly tabs = input.required<ShellTab[]>();
readonly active = input.required<string>();
readonly tabChange = output<{ id: string }>();
onTabClick(tab: ShellTab): void {
this.tabChange.emit({ id: tab.id });
}
}