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