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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user