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
+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;