Files
HIPCTF2/frontend/src/app/features/home/home.component.ts
T
2026-07-23 08:43:44 +00:00

250 lines
8.2 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
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 { EventStatusStore } from '../../core/services/event-status.store';
import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service';
import { UserStore } from '../../core/services/user.store';
import { ChallengesStore } from '../challenges/challenges.store';
import { NotificationService } from '../../core/services/notification.service';
import { CrossTabRoleChangeMessage } from '../../core/services/auth-session-events.pure';
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';
const TABS: ShellTab[] = [
{ id: 'challenges', label: 'Challenges' },
{ id: 'scoreboard', label: 'Scoreboard' },
{ id: 'blog', label: 'Blog' },
];
@Component({
selector: 'app-home',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
RouterOutlet,
ShellHeaderComponent,
ChangePasswordModalComponent,
QuickTabsComponent,
],
template: `
<div class="container">
<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()"
/>
<app-quick-tabs
[tabs]="TABS"
[active]="activeTabId()"
(tabChange)="navigateToSection($event.id)"
/>
<section class="shell-body">
<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 implements OnInit, OnDestroy {
readonly TABS = TABS;
readonly bootstrap = inject(BootstrapService);
readonly auth = inject(AuthService);
readonly eventStatus = inject(EventStatusStore);
private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService);
readonly userStore = inject(UserStore);
private readonly challengesStore = inject(ChallengesStore);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly notifications = inject(NotificationService);
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();
this.eventStatus.start(
() => this.authenticatedEventSource.open('/api/v1/events/status'),
() => this.handleSessionInvalidated('sse-unauthorized'),
);
this.peerInvalidationUnsubscribe = this.auth.onPeerInvalidation((reason) =>
this.handleSessionInvalidated(reason),
);
this.peerRoleChangeUnsubscribe = this.auth.onPeerRoleChange((msg) =>
this.handlePeerRoleChange(msg),
);
}
ngOnDestroy(): void {
this.eventStatus.stop();
if (this.peerInvalidationUnsubscribe) {
this.peerInvalidationUnsubscribe();
this.peerInvalidationUnsubscribe = null;
}
if (this.peerRoleChangeUnsubscribe) {
this.peerRoleChangeUnsubscribe();
this.peerRoleChangeUnsubscribe = null;
}
}
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();
this.challengesStore.reset();
await this.router.navigateByUrl('/login');
}
private peerInvalidationUnsubscribe: (() => void) | null = null;
private peerRoleChangeUnsubscribe: (() => void) | null = null;
private navigatingToLogin = false;
private async handleSessionInvalidated(
_reason: 'peer-logout' | 'sse-unauthorized',
): Promise<void> {
this.userStore.reset();
this.challengesStore.reset();
this.changePasswordOpen.set(false);
this.userMenuOpen.set(false);
if (this.navigatingToLogin) return;
if (this.router.url.startsWith('/login')) return;
this.navigatingToLogin = true;
try {
await this.router.navigateByUrl('/login');
} finally {
this.navigatingToLogin = false;
}
}
private handlePeerRoleChange(msg: CrossTabRoleChangeMessage): void {
const me = this.auth.currentUser();
if (!me || me.id !== msg.userId) return;
this.userStore.setRole(msg.newRole);
if (!this.router.url.startsWith('/admin/')) return;
this.notifications.info(
`Your role was changed to "${msg.newRole}" by another admin. You may need to refresh or log in again to continue.`,
);
}
}