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
+3
View File
@@ -14,6 +14,9 @@ export const ERROR_CODES = {
TOKEN_REVOKED: 'TOKEN_REVOKED',
THEME_INVALID: 'THEME_INVALID',
REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED',
INVALID_OLD_PASSWORD: 'INVALID_OLD_PASSWORD',
PASSWORD_POLICY: 'PASSWORD_POLICY',
PASSWORDS_DO_NOT_MATCH: 'PASSWORDS_DO_NOT_MATCH',
INTERNAL: 'INTERNAL',
} as const;
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { SettingsService } from '../../modules/settings/settings.module';
export type EventStatusName = 'Stopped' | 'Running';
export type EventState = 'countdown' | 'running' | 'stopped' | 'unconfigured';
export interface EventStatus {
status: EventStatusName;
@@ -11,38 +12,94 @@ export interface EventStatus {
countdownMs: number;
}
export interface EventStatePayload {
state: EventState;
serverNowUtc: string;
eventStartUtc: string | null;
eventEndUtc: string | null;
secondsToStart: number | null;
secondsToEnd: number | null;
}
@Injectable()
export class EventStatusService {
constructor(private readonly settings: SettingsService) {}
async getStatus(now: Date = new Date()): Promise<EventStatus> {
const startStr = await this.settings.get('eventStartUtc');
const endStr = await this.settings.get('eventEndUtc');
const start = new Date(startStr);
const end = new Date(endStr);
const nowMs = now.getTime();
const startMs = start.getTime();
const endMs = end.getTime();
const s = await this.getState(now);
let status: EventStatusName = 'Stopped';
let countdownMs = 0;
if (nowMs < startMs) {
if (s.state === 'countdown' && s.secondsToStart != null) {
status = 'Stopped';
countdownMs = startMs - nowMs;
} else if (nowMs <= endMs) {
countdownMs = s.secondsToStart * 1000;
} else if (s.state === 'running' && s.secondsToEnd != null) {
status = 'Running';
countdownMs = endMs - nowMs;
countdownMs = s.secondsToEnd * 1000;
} else if (s.state === 'stopped') {
status = 'Stopped';
countdownMs = 0;
} else {
status = 'Stopped';
countdownMs = 0;
}
return {
status,
startUtc: start.toISOString(),
endUtc: end.toISOString(),
serverNowUtc: now.toISOString(),
startUtc: s.eventStartUtc ?? '',
endUtc: s.eventEndUtc ?? '',
serverNowUtc: s.serverNowUtc,
countdownMs,
};
}
}
async getState(now: Date = new Date()): Promise<EventStatePayload> {
const startStr = await this.settings.get('eventStartUtc');
const endStr = await this.settings.get('eventEndUtc');
if (!startStr || !endStr) {
return {
state: 'unconfigured',
serverNowUtc: now.toISOString(),
eventStartUtc: startStr ?? null,
eventEndUtc: endStr ?? null,
secondsToStart: null,
secondsToEnd: null,
};
}
const start = new Date(startStr);
const end = new Date(endStr);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return {
state: 'unconfigured',
serverNowUtc: now.toISOString(),
eventStartUtc: startStr,
eventEndUtc: endStr,
secondsToStart: null,
secondsToEnd: null,
};
}
const nowMs = now.getTime();
const startMs = start.getTime();
const endMs = end.getTime();
let state: EventState;
let secondsToStart: number | null = null;
let secondsToEnd: number | null = null;
if (nowMs < startMs) {
state = 'countdown';
secondsToStart = Math.max(0, Math.floor((startMs - nowMs) / 1000));
} else if (nowMs <= endMs) {
state = 'running';
secondsToEnd = Math.max(0, Math.floor((endMs - nowMs) / 1000));
} else {
state = 'stopped';
}
return {
state,
serverNowUtc: now.toISOString(),
eventStartUtc: start.toISOString(),
eventEndUtc: end.toISOString(),
secondsToStart,
secondsToEnd,
};
}
}
+45 -1
View File
@@ -13,7 +13,12 @@ import { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Public } from '../../common/decorators/public.decorator';
import { LoginDtoSchema, RefreshDtoSchema, RegisterDtoSchema } from './dto/auth.dto';
import {
ChangePasswordDtoSchema,
LoginDtoSchema,
RefreshDtoSchema,
RegisterDtoSchema,
} from './dto/auth.dto';
import { AuthService } from './auth.service';
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
import { setRefreshCookie, clearRefreshCookie } from './cookie';
@@ -101,6 +106,45 @@ export class AuthController {
clearRefreshCookie(res);
}
@Get('me')
@ApiOperation({ summary: 'Current authenticated user profile (id, username, role, rank, points)' })
@ApiResponse({ status: 200, description: 'Returns the current user projection.' })
async me(@Req() req: Request): Promise<{
id: string;
username: string;
role: 'admin' | 'player';
rank: number | null;
points: number;
}> {
const user = req.user as { sub: string } | undefined;
const userId = user?.sub;
if (!userId) {
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
}
return this.auth.getMe(userId);
}
@Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Change the current user password (requires old password)' })
@ApiBody({
schema: {
example: { oldPassword: 'Sup3rSecret!Pass', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'NewSecret!Pass1' },
},
})
async changePassword(
@Req() req: Request,
@Body(new ZodValidationPipe(ChangePasswordDtoSchema))
body: { oldPassword: string; newPassword: string; confirmNewPassword: string },
): Promise<void> {
const user = req.user as { sub: string } | undefined;
const userId = user?.sub;
if (!userId) {
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
}
await this.auth.changePassword(userId, body);
}
@Public()
@Get('csrf')
@ApiOperation({ summary: 'Issue a CSRF token (sets csrf cookie, returns token)' })
+3 -1
View File
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
@@ -10,6 +10,7 @@ import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AdminGuard } from '../../common/guards/admin.guard';
import { SettingsModule } from '../settings/settings.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
@@ -24,6 +25,7 @@ import { SettingsModule } from '../settings/settings.module';
}),
}),
SettingsModule,
forwardRef(() => UsersModule),
],
providers: [AuthService, JwtStrategy, AdminGuard],
controllers: [AuthController],
+63 -1
View File
@@ -2,6 +2,8 @@ import {
Injectable,
OnModuleInit,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@@ -16,10 +18,11 @@ import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { LoginBackoffService } from '../../common/services/login-backoff.service';
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
import { LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
import { ChangePasswordDto, LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
import { validatePassword } from '../../common/utils/password-policy';
import { SettingsService } from '../settings/settings.module';
import { SETTINGS_KEYS } from '../../config/env.schema';
import { UsersRankService } from '../users/users-rank.service';
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
@@ -37,6 +40,7 @@ export class AuthService implements OnModuleInit {
private readonly backoff: LoginBackoffService,
private readonly registrationRateLimit: RegistrationRateLimitService,
private readonly settings: SettingsService,
private readonly rank: UsersRankService,
) {}
onModuleInit(): void {
@@ -177,6 +181,64 @@ export class AuthService implements OnModuleInit {
});
}
async getMe(userId: string): Promise<{ id: string; username: string; role: 'admin' | 'player'; rank: number | null; points: number }> {
const user = await this.users.findOne({ where: { id: userId } });
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
const rankInfo = await this.rank.rankOfUser(this.dataSource.manager, user.id);
return {
id: user.id,
username: user.username,
role: user.role,
rank: rankInfo.rank,
points: rankInfo.points,
};
}
async changePassword(userId: string, dto: ChangePasswordDto): Promise<void> {
if (dto.newPassword !== dto.confirmNewPassword) {
throw new ApiError(ERROR_CODES.PASSWORDS_DO_NOT_MATCH, 'Passwords do not match', 400);
}
if (dto.oldPassword === dto.newPassword) {
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, 'New password must differ from the old one', 400);
}
return this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: userId } });
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
const ok = await argon2.verify(user.passwordHash, dto.oldPassword);
if (!ok) {
throw new ApiError(ERROR_CODES.INVALID_OLD_PASSWORD, 'Old password is incorrect', 401);
}
try {
validatePassword(dto.newPassword, this.config);
} catch (e) {
if (e instanceof ApiError) {
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, e.message, 400);
}
throw e;
}
user.passwordHash = await argon2.hash(dto.newPassword, {
type: argon2.argon2id,
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
});
await manager.save(user);
// Invalidate all existing refresh tokens for this user.
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
});
}
/**
* Mint a fresh access JWT and a new refresh token row inside the supplied
* transaction. The refresh token (raw) is returned alongside the access
+22 -1
View File
@@ -37,4 +37,25 @@ export interface LoginResponse {
refreshToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}
}
export const ChangePasswordDtoSchema = z
.object({
oldPassword: z.string().min(1).max(256),
newPassword: z.string().min(1).max(256),
confirmNewPassword: z.string().min(1).max(256),
})
.refine((d) => d.newPassword === d.confirmNewPassword, {
path: ['confirmNewPassword'],
message: 'Passwords do not match',
});
export type ChangePasswordDto = z.infer<typeof ChangePasswordDtoSchema>;
export const MeResponseDtoSchema = z.object({
id: z.string(),
username: z.string(),
role: z.enum(['admin', 'player']),
rank: z.number().int().nullable(),
points: z.number().int(),
});
export type MeResponseDto = z.infer<typeof MeResponseDtoSchema>;
@@ -1,10 +1,11 @@
import { Controller, Get, Sse, MessageEvent } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { interval, map, merge, mergeMap, Observable, startWith, defer } from 'rxjs';
import { interval, map, merge, mergeMap, Observable, startWith, defer, distinctUntilChanged } from 'rxjs';
import { Public } from '../../common/decorators/public.decorator';
import { SystemService } from './system.service';
import { EventStatusService } from '../../common/services/event-status.service';
import { SseHubService } from '../../common/services/sse-hub.service';
import { SettingsService } from '../settings/settings.module';
@ApiTags('system')
@Controller('api/v1')
@@ -13,6 +14,7 @@ export class SystemController {
private readonly system: SystemService,
private readonly statusSvc: EventStatusService,
private readonly hub: SseHubService,
private readonly settings: SettingsService,
) {}
@Public()
@@ -29,6 +31,15 @@ export class SystemController {
return this.statusSvc.getStatus();
}
@Public()
@Get('settings/event')
@ApiOperation({ summary: 'Public-readable event window timestamps (UTC)' })
async settingsEvent(): Promise<{ eventStartUtc: string | null; eventEndUtc: string | null }> {
const eventStartUtc = (await this.settings.get('eventStartUtc')) || null;
const eventEndUtc = (await this.settings.get('eventEndUtc')) || null;
return { eventStartUtc, eventEndUtc };
}
/**
* Global event status + countdown stream.
* Emits a FLATTENED object every second (and whenever the hub pushes).
@@ -62,6 +73,45 @@ export class SystemController {
return merge(tick$, hub$).pipe(map((src) => ({ data: src }) as MessageEvent));
}
/**
* Authenticated event status SSE.
* Path: GET /api/v1/events/status (note the plural 'events').
* Emits an initial state immediately on connect, then re-emits on transition
* and on a minimum 60s tick while in 'countdown' state.
* Protected by the global JwtAuthGuard (so unauthenticated clients get 401).
*/
@Sse('events/status')
eventsStatus(): Observable<MessageEvent> {
const flat = (s: any) => ({
state: s?.state,
serverNowUtc: s?.serverNowUtc,
eventStartUtc: s?.eventStartUtc,
eventEndUtc: s?.eventEndUtc,
secondsToStart: s?.secondsToStart,
secondsToEnd: s?.secondsToEnd,
});
const initial$ = defer(() =>
this.statusSvc.getState().then((s) => flat(s)),
);
const tick$ = interval(60_000).pipe(
startWith(0),
mergeMap(() =>
defer(() =>
this.statusSvc.getState().then((s) => flat(s)),
),
),
);
const hub$ = this.hub.event$().pipe(map((p) => flat(p)));
return merge(initial$, tick$, hub$).pipe(
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
map((src) => ({ data: src }) as MessageEvent),
);
}
/**
* Live solves stream.
* Emits a FLATTENED solve payload whenever a new solve is published.
+2 -1
View File
@@ -4,9 +4,10 @@ import { UserEntity } from '../../database/entities/user.entity';
import { SettingEntity } from '../../database/entities/setting.entity';
import { SystemController } from './system.controller';
import { SystemService } from './system.service';
import { SettingsModule } from '../settings/settings.module';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity, SettingEntity])],
imports: [TypeOrmModule.forFeature([UserEntity, SettingEntity]), SettingsModule],
controllers: [SystemController],
providers: [SystemService],
})
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { SolveEntity } from '../../database/entities/solve.entity';
export interface RankInfo {
rank: number | null;
points: number;
}
@Injectable()
export class UsersRankService {
async rankOfUser(manager: EntityManager, userId: string): Promise<RankInfo> {
const solveRepo = manager.getRepository(SolveEntity);
const pointsRow = await solveRepo
.createQueryBuilder('s')
.select('COALESCE(SUM(s.pointsAwarded), 0)', 'total')
.where('s.userId = :userId', { userId })
.getRawOne<{ total: string | number | null }>();
const points = Number(pointsRow?.total ?? 0);
if (points <= 0) {
return { rank: null, points: 0 };
}
const ahead = await solveRepo
.createQueryBuilder('s')
.select('s.userId', 'userId')
.addSelect('SUM(s.pointsAwarded)', 'total')
.groupBy('s.userId')
.having('SUM(s.pointsAwarded) > :points', { points })
.getRawMany<{ userId: string }>();
return { rank: ahead.length + 1, points };
}
}
+6 -4
View File
@@ -1,14 +1,16 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { SolveEntity } from '../../database/entities/solve.entity';
import { UsersService } from './users.service';
import { UsersRankService } from './users-rank.service';
import { UsersController } from './users.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule],
providers: [UsersService],
imports: [TypeOrmModule.forFeature([UserEntity, SolveEntity]), forwardRef(() => AuthModule)],
providers: [UsersService, UsersRankService],
controllers: [UsersController],
exports: [UsersService],
exports: [UsersService, UsersRankService],
})
export class UsersModule {}