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