AI Implementation feature(823): Landing Page and Login/Register Modal (#11)

This commit was merged in pull request #11.
This commit is contained in:
2026-07-21 18:34:45 +00:00
parent 8476e4a59f
commit 685a8bca84
51 changed files with 2354 additions and 226 deletions
+24 -1
View File
@@ -13,7 +13,7 @@ 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 } from './dto/auth.dto';
import { LoginDtoSchema, RefreshDtoSchema, RegisterDtoSchema } from './dto/auth.dto';
import { AuthService } from './auth.service';
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
import { setRefreshCookie, clearRefreshCookie } from './cookie';
@@ -27,6 +27,29 @@ export class AuthController {
private readonly config: ConfigService,
) {}
@Public()
@Post('register')
@ApiOperation({ summary: 'Public player self-registration' })
@ApiResponse({ status: 201, description: 'Registration successful, returns login session' })
async register(
@Body(new ZodValidationPipe(RegisterDtoSchema)) body: {
username: string;
password: string;
passwordConfirm: string;
},
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
const session = await this.auth.registerPlayer(body, ip);
setRefreshCookie(res, session.refreshToken, this.config);
return {
accessToken: session.accessToken,
expiresIn: session.expiresIn,
user: session.user,
};
}
@Public()
@Post('login')
@ApiOperation({ summary: 'Login with username and password' })
+2
View File
@@ -9,6 +9,7 @@ import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AdminGuard } from '../../common/guards/admin.guard';
import { SettingsModule } from '../settings/settings.module';
@Module({
imports: [
@@ -22,6 +23,7 @@ import { AdminGuard } from '../../common/guards/admin.guard';
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
}),
}),
SettingsModule,
],
providers: [AuthService, JwtStrategy, AdminGuard],
controllers: [AuthController],
+50 -1
View File
@@ -16,8 +16,10 @@ 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 } from './dto/auth.dto';
import { 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';
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
@@ -34,6 +36,7 @@ export class AuthService implements OnModuleInit {
private readonly dataSource: DataSource,
private readonly backoff: LoginBackoffService,
private readonly registrationRateLimit: RegistrationRateLimitService,
private readonly settings: SettingsService,
) {}
onModuleInit(): void {
@@ -130,6 +133,52 @@ export class AuthService implements OnModuleInit {
});
}
async registerPlayer(dto: RegisterDto, ip: string): Promise<LoginResponse> {
validatePassword(dto.password, this.config);
if (!this.registrationRateLimit.isAllowed(ip)) {
throw new ApiError(
ERROR_CODES.RATE_LIMITED,
'Too many registration attempts; please wait a minute before trying again.',
429,
);
}
const enabled = (await this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true';
if (!enabled) {
throw new ApiError(
ERROR_CODES.REGISTRATIONS_DISABLED,
'Registrations are currently disabled',
403,
);
}
return this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(UserEntity, { where: { username: dto.username } });
if (existing) {
throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409);
}
const passwordHash = await argon2.hash(dto.password, {
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'),
});
const user = manager.create(UserEntity, {
id: uuid(),
username: dto.username,
passwordHash,
role: 'player',
status: 'enabled',
});
await manager.save(user);
this.registrationRateLimit.record(ip);
return this.mintSession(manager, user);
});
}
/**
* Mint a fresh access JWT and a new refresh token row inside the supplied
* transaction. The refresh token (raw) is returned alongside the access
+16
View File
@@ -16,6 +16,22 @@ export const CsrfResponseDtoSchema = z.object({
});
export type CsrfResponseDto = z.infer<typeof CsrfResponseDtoSchema>;
export const RegisterDtoSchema = z
.object({
username: z
.string()
.min(3)
.max(32)
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dot, dash and underscore'),
password: z.string().min(1).max(256),
passwordConfirm: z.string().min(1).max(256),
})
.refine((d) => d.password === d.passwordConfirm, {
path: ['passwordConfirm'],
message: 'Passwords do not match',
});
export type RegisterDto = z.infer<typeof RegisterDtoSchema>;
export interface LoginResponse {
accessToken: string;
refreshToken: string;
@@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator';
import { BlogService } from './blog.service';
import { PublicBlogList } from './dto/blog.dto';
@ApiTags('blog')
@Controller('api/v1/blog')
export class BlogController {
constructor(private readonly blog: BlogService) {}
@Public()
@Get('posts')
@ApiOperation({ summary: 'Public list of published blog posts' })
async listPosts(): Promise<PublicBlogList> {
const posts = await this.blog.listPublished();
return { posts };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { BlogController } from './blog.controller';
import { BlogService } from './blog.service';
@Module({
imports: [TypeOrmModule.forFeature([BlogPostEntity])],
controllers: [BlogController],
providers: [BlogService],
})
export class BlogModule {}
+26
View File
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { PublicBlogPost } from './dto/blog.dto';
@Injectable()
export class BlogService {
constructor(
@InjectRepository(BlogPostEntity)
private readonly posts: Repository<BlogPostEntity>,
) {}
async listPublished(): Promise<PublicBlogPost[]> {
const rows = await this.posts.find({
where: { status: 'published' },
order: { publishedAt: 'DESC' },
});
return rows.map((row) => ({
id: row.id,
title: row.title,
publishedAt: row.publishedAt ?? row.createdAt,
bodyMd: row.bodyMd ?? '',
}));
}
}
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const PublicBlogPostSchema = z.object({
id: z.string(),
title: z.string(),
publishedAt: z.string(),
bodyMd: z.string(),
});
export type PublicBlogPost = z.infer<typeof PublicBlogPostSchema>;
export const PublicBlogListSchema = z.object({
posts: z.array(PublicBlogPostSchema),
});
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;