229 lines
7.2 KiB
TypeScript
229 lines
7.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 { 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);
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.eventStatus.stop();
|
|
if (this.peerInvalidationUnsubscribe) {
|
|
this.peerInvalidationUnsubscribe();
|
|
this.peerInvalidationUnsubscribe = 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 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;
|
|
}
|
|
}
|
|
}
|