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