AI Implementation feature(821): First-Start Create Admin Modal (#2)

This commit was merged in pull request #2.
This commit is contained in:
2026-07-21 14:45:55 +00:00
parent 03bcb6b156
commit 9f67ec8c50
30 changed files with 1306 additions and 141 deletions
-101
View File
@@ -1,101 +0,0 @@
# Implementation Plan: WAL journal mode + OpenAPI 3.1 — Status check
## 0. Scope vs. Current Branch State
This reviewer request asks for three changes that were **already implemented
and committed** in `de27a8b` on the `feature-820-1784637300954` branch. The
code currently in the working tree (and in HEAD) matches the request verbatim:
1.`InitSchema` migration (`backend/src/database/migrations/1700000000000-InitSchema.ts`)
already contains the WAL PRAGMA at line 8 — immediately after the existing
`PRAGMA foreign_keys = ON` (line 7).
2.`DatabaseInitService` (`backend/src/database/database-init.service.ts`)
already enforces WAL on every startup via a private `ensureWalMode()`
method that runs after `dataSource.initialize()`. It checks the current
`journal_mode` and applies `PRAGMA journal_mode = WAL` if not already
`wal`; idempotent and safe to call repeatedly.
3.`main.ts` (`backend/src/main.ts` lines 8394) now pipes the
NestJS-generated OpenAPI document through a custom `toOpenApi31()`
converter (`backend/src/common/utils/openapi31.ts`) that emits
`openapi: '3.1.0'` and rewrites every legacy `nullable: true` marker
into the JSON Schema 2020-12 type union.
Per the system instruction "If the feature is ALREADY fully implemented, you
MUST include the exact marker text **[ALREADY_IMPLEMENTED]** at the top or in
the status section of your plan file. Explain in the plan exactly which
files/methods already contain the implementation", this plan documents the
existing implementation rather than adding duplicate code.
## 1. Architectural Reconnaissance
- **Migration** (`backend/src/database/migrations/1700000000000-InitSchema.ts:7-8`):
```ts
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA journal_mode = WAL;`);
```
- **Warm-DB enforcement** (`backend/src/database/database-init.service.ts:34-78`):
- `init()` calls `ensureWalMode()` after `dataSource.initialize()`.
- `ensureWalMode()` queries `PRAGMA journal_mode`, switches to WAL if not
already set, logs the transition.
- **OpenAPI 3.1 pipeline** (`backend/src/main.ts:83-94`):
```ts
const swaggerConfig = new DocumentBuilder()...
const rawDocument = SwaggerModule.createDocument(app, swaggerConfig) as Record<string, any>;
const document = toOpenApi31(rawDocument);
SwaggerModule.setup('api/docs', app, document, { jsonDocumentUrl: 'api/docs-json' });
```
- `backend/src/common/utils/openapi31.ts` exports `toOpenApi31(doc)`.
- Rewrites `nullable: true` → `type: ['<orig>', 'null']` recursively in
`components.schemas`, `paths`, parameters, request/response bodies,
`properties`, `items`, `oneOf/anyOf/allOf`, and nested `schema` keys.
- Preserves `$ref` nodes.
- **Tests** (`tests/backend/openapi31.spec.ts`, 7 tests): all pass.
- **Full suite**: 83/83 tests across 18 suites.
## 2. Impacted Files
None — all changes are already in place at HEAD.
| File | Status |
| --- | --- |
| `backend/src/database/migrations/1700000000000-InitSchema.ts` | ✅ line 8 |
| `backend/src/database/database-init.service.ts` | ✅ lines 34-78 |
| `backend/src/common/utils/openapi31.ts` | ✅ exists (created in `de27a8b`) |
| `backend/src/main.ts` | ✅ lines 83-94 |
| `tests/backend/openapi31.spec.ts` | ✅ exists, 7 tests passing |
## 3. Proposed Changes
No code changes required. If a follow-up task wants to extend the existing
implementation (e.g., add an `info.license` field, support `webhooks`, or
add a Swagger UI bundle switch), that would be a separate plan.
## 4. Test Strategy
- The existing `tests/backend/openapi31.spec.ts` (7 tests) locks the
conversion behaviour.
- The full suite `npm test` passes 83/83 across 18 suites.
- A live-server smoke (documented in the previous turn's commit message)
confirms `openapi: '3.1.0'`, 13 paths, zero `nullable: true` occurrences,
WAL applied on cold start AND confirmed on warm restart.
## 5. Persistent `/data` Usage
No change. WAL persists to the file-backed SQLite DB; the SQLite WAL mode is
persisted in the database header, so subsequent connections inherit it.
## 6. Definition of Done (already met)
- `git log --oneline -3` shows commit `de27a8b` "WAL journal mode + OpenAPI 3.1"
- `npm test` → 83/83 passing
- `npm run build` → backend + frontend succeed
- `GET /api/docs-json` returns `openapi: "3.1.0"`
- Cold start log: `PRAGMA journal_mode set (was='delete', now='wal')`
- Warm start log: `PRAGMA journal_mode=wal (already set)`
- Zero `nullable: true` occurrences in the served JSON document
**[ALREADY_IMPLEMENTED]** — All three requested changes exist on the branch
in commits `de27a8b` (WAL + OpenAPI 3.1). No further code changes are needed
unless the reviewer wants to extend the implementation with additional
OpenAPI 3.1 features (webhooks, license, contact, etc.) or additional SQLite
PRAGMAs.
+86
View File
@@ -0,0 +1,86 @@
# Implementation Plan: Fix Angular build error — Property 'code' does not exist on type 'CreateAdminResult'
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Angular 17 standalone components, signals, typed reactive forms. `frontend/tsconfig.json` uses `strict: false` and `strictTemplates: false`. The project still uses `@angular/compiler` (ngc) which performs its own type checking on top of TS narrowing rules.
- **Data Layer:** N/A — this is a frontend-only type-narrowing fix.
- **Test Framework & Structure:** `tests/jest.config.js`. Tests live under `tests/frontend/`. All compile via `npm test`.
- **Required Tools & Dependencies:** No new dependencies. The fix is purely TypeScript / component-internal.
## 2. Impacted Files
- **To Modify:** `/repo/frontend/src/app/features/setup/setup-create-admin.component.ts` — fix the type-narrowing that Angular's compiler refuses to accept.
- **No new files, no test changes, no docs changes.**
## 3. Proposed Changes
### Root cause
In `setup-create-admin.component.ts:88-117` the `submit()` method awaits the service, then reads `result.ok`. After the `if (result.ok) { ... return; }` branch, TS should narrow `result` to `CreateAdminFailure` (which has `code`). However Angular's compiler (used by `ng build`) reports:
```
TS2339: Property 'code' does not exist on type 'CreateAdminResult'.
Property 'code' does not exist on type 'CreateAdminSuccess'.
```
at the two reads `result.code` on lines 109 and 116. With `strict: false` + `strictTemplates: false`, narrowing across an awaited union *should* succeed, but Angular's compiler's narrowing model is stricter in some code paths (notably after `await` of an external service) and conservatively widens back to the full union. The error message proves it: type is `CreateAdminResult`, not `CreateAdminFailure`.
### Minimal fix
Replace the discriminated-union narrowing with an explicit type guard in the service and an `else` branch in the component. This avoids relying on the compiler's narrowing at all.
### Step-by-step
1. **Add a type predicate to the service** (`setup-create-admin.service.ts`):
- Add `export function isCreateAdminFailure(r: CreateAdminResult): r is CreateAdminFailure { return !r.ok; }`.
- No behaviour change.
2. **Refactor `submit()`** in `setup-create-admin.component.ts`:
- Replace the `if (result.ok) { ... return; } ... if (result.code === ...) ...` sequence with an `if (result.ok) { ... return; } const failure = result;` pattern. Because `result.ok` is `true | false`, the negation narrows the local copy identically. The safest cross-compiler approach is:
```ts
if (result.ok) {
this.auth.setSession(result.accessToken, result.user);
this.bootstrap.markInitialized();
await this.router.navigateByUrl('/challenges');
return;
}
const failure: CreateAdminFailure = result; // explicit local of narrowed type
if (failure.code === 'USERNAME_TAKEN') {
this.serverError.set('Username already exists');
this.serverErrorCode.set('USERNAME_TAKEN');
return;
}
this.serverError.set('Setup failed, please retry');
this.serverErrorCode.set(failure.code);
```
- Import `CreateAdminFailure` from `./setup-create-admin.service`.
- The cast `const failure: CreateAdminFailure = result` after the `return` is safe (TS will assert assignability; if the union ever changes it becomes a compile error here rather than two ambiguous ones).
Alternatively, an explicit cast: `const failure = result as CreateAdminFailure;`. Either is acceptable; the `as`-cast is one token shorter and more idiomatic. Choose:
```ts
const failure = result as CreateAdminFailure;
```
3. **No HTML/CSS/service file changes.**
## 4. Test Strategy
- **Target Unit Test File:** no changes. The existing `tests/frontend/setup-create-admin.spec.ts` already covers the validator and rules; the build error is a compile-time-only issue that the existing tests do not exercise.
- **Mocking Strategy:** N/A.
- **Verification:**
1. `npm --workspace frontend run build` must complete without `TS2339` errors.
2. `npm test` must still pass (20 suites, 97 tests).
- **Do not** touch any test file. The fix is internal to the component.
## 5. Risk / Non-Goals
- **No behaviour change.** The runtime control flow is identical; the change only adds an explicit local that the compiler can definitively narrow.
- **No public API change.** `CreateAdminResult`, `CreateAdminSuccess`, `CreateAdminFailure` shapes remain as defined.
- **No docs change required** — implementation detail only.
## 6. Execution Checklist
1. Edit `setup-create-admin.component.ts`: import `CreateAdminFailure`, refactor lines 102-116 to use `const failure = result as CreateAdminFailure;` after the success branch returns.
2. Run `npm --workspace frontend run build` and confirm 0 errors.
3. Run `npm test` and confirm `Test Suites: 20 passed, 20 total`.
+2
View File
@@ -6,6 +6,7 @@ import { DatabaseModule } from './database/database.module';
import { CommonModule } from './common/common.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { SetupModule } from './modules/setup/setup.module';
import { SystemModule } from './modules/system/system.module';
import { SettingsModule } from './modules/settings/settings.module';
import { AdminModule } from './modules/admin/admin.module';
@@ -26,6 +27,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
SettingsModule,
AuthModule,
UsersModule,
SetupModule,
SystemModule,
AdminModule,
UploadsModule,
+1
View File
@@ -9,6 +9,7 @@ export const ERROR_CODES = {
RATE_LIMITED: 'RATE_LIMITED',
CSRF_INVALID: 'CSRF_INVALID',
WEAK_PASSWORD: 'WEAK_PASSWORD',
USERNAME_TAKEN: 'USERNAME_TAKEN',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
TOKEN_REVOKED: 'TOKEN_REVOKED',
THEME_INVALID: 'THEME_INVALID',
@@ -8,7 +8,11 @@ import { ERROR_CODES } from '../errors/error-codes';
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const COOKIE_NAME = 'csrf';
const HEADER_NAME = 'x-csrf-token';
const SKIP_PATH_PREFIXES = ['/api/v1/auth/register-first-admin', '/api/v1/auth/login'];
const SKIP_PATH_PREFIXES = [
'/api/v1/auth/register-first-admin',
'/api/v1/auth/login',
'/api/v1/setup/create-admin',
];
function isSkipped(path: string): boolean {
return SKIP_PATH_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
+60 -5
View File
@@ -136,11 +136,31 @@ export class AuthService implements OnModuleInit {
* token so the caller can set the HttpOnly cookie.
*/
private async mintSession(manager: EntityManager, user: UserEntity): Promise<LoginResponse> {
const accessToken = await this.signAccess(user);
const refreshToken = this.generateRefreshToken();
const tokenHash = this.hashToken(refreshToken);
return AuthService.mintSessionWith(
manager,
this.jwt,
this.config,
this.refreshTtlSeconds,
user,
);
}
/**
* Stateless mint helper used by other modules (e.g. setup) that need to
* create a session without depending on the full AuthModule injection tree.
*/
static async mintSessionWith(
manager: EntityManager,
jwt: JwtService,
config: ConfigService,
refreshTtlSeconds: number,
user: UserEntity,
): Promise<LoginResponse> {
const accessToken = await jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
const refreshToken = AuthService.generateRefreshTokenStatic();
const tokenHash = AuthService.hashTokenStatic(refreshToken);
const now = new Date();
const expires = new Date(now.getTime() + this.refreshTtlSeconds * 1000);
const expires = new Date(now.getTime() + refreshTtlSeconds * 1000);
await manager.insert(RefreshTokenEntity, {
id: uuid(),
userId: user.id,
@@ -152,11 +172,46 @@ export class AuthService implements OnModuleInit {
return {
accessToken,
refreshToken,
expiresIn: this.accessTtlSeconds(),
expiresIn: AuthService.accessTtlSecondsStatic(config),
user: { id: user.id, username: user.username, role: user.role },
};
}
private static generateRefreshTokenStatic(): string {
return crypto.randomBytes(32).toString('base64url');
}
private static hashTokenStatic(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private static accessTtlSecondsStatic(config: ConfigService): number {
const ttl = config.get<string>('JWT_ACCESS_TTL', '15m');
return AuthService.parseTtlStatic(ttl);
}
private static parseTtlStatic(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return 900;
const n = parseInt(m[1], 10);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return n * mult;
}
/**
* Public access to the static session mint for cross-module reuse.
* The instance-based `mintSession(manager, user)` is preserved for
* existing callers and delegates here.
*/
static async createSession(
manager: EntityManager,
deps: { jwt: JwtService; config: ConfigService; refreshTtlSeconds: number },
user: UserEntity,
): Promise<LoginResponse> {
return AuthService.mintSessionWith(manager, deps.jwt, deps.config, deps.refreshTtlSeconds, user);
}
private async signAccess(user: UserEntity): Promise<string> {
return this.jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
}
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const CreateAdminDtoSchema = z
.object({
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(32, 'Username must be at most 32 characters')
.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, {
message: 'Passwords do not match',
path: ['passwordConfirm'],
});
export type CreateAdminDto = z.infer<typeof CreateAdminDtoSchema>;
@@ -0,0 +1,45 @@
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
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 { SetupService } from './setup.service';
import { CreateAdminDtoSchema } from './dto/create-admin.dto';
import { setRefreshCookie } from '../auth/cookie';
@ApiTags('setup')
@Controller('api/v1/setup')
export class SetupController {
constructor(
private readonly setup: SetupService,
private readonly config: ConfigService,
) {}
@Public()
@Post('create-admin')
@ApiOperation({
summary:
'Create the very first administrator. Only callable while no admin exists; returns 404 once initialized.',
})
async createAdmin(
@Body(new ZodValidationPipe(CreateAdminDtoSchema))
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.setup.createAdmin(
body.username,
body.password,
body.passwordConfirm,
ip,
);
setRefreshCookie(res, session.refreshToken, this.config);
return {
accessToken: session.accessToken,
expiresIn: session.expiresIn,
user: session.user,
};
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { SetupController } from './setup.controller';
import { SetupService } from './setup.service';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
}),
}),
],
controllers: [SetupController],
providers: [SetupService],
})
export class SetupModule {}
+104
View File
@@ -0,0 +1,104 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt';
import { DataSource, Repository } from 'typeorm';
import * as argon2 from 'argon2';
import { v4 as uuid } from 'uuid';
import { UserEntity } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { validatePassword } from '../../common/utils/password-policy';
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
import { AuthService } from '../auth/auth.service';
import type { LoginResponse } from '../auth/dto/auth.dto';
const REFRESH_TTL_DEFAULT = 7 * 24 * 60 * 60;
@Injectable()
export class SetupService implements OnModuleInit {
private readonly logger = new Logger(SetupService.name);
private refreshTtlSeconds = REFRESH_TTL_DEFAULT;
constructor(
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity) private readonly refreshTokens: Repository<RefreshTokenEntity>,
private readonly dataSource: DataSource,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly registrationRateLimit: RegistrationRateLimitService,
) {}
onModuleInit(): void {
const ttl = this.config.get<string>('JWT_REFRESH_TTL', '7d');
this.refreshTtlSeconds = this.parseTtl(ttl);
}
async createAdmin(
username: string,
password: string,
passwordConfirm: string,
ip: string,
): Promise<LoginResponse> {
if (password !== passwordConfirm) {
throw ApiError.validation('Passwords do not match');
}
validatePassword(password, this.config);
if (!this.registrationRateLimit.isAllowed(ip)) {
throw new ApiError(
ERROR_CODES.RATE_LIMITED,
'Too many registrations from this IP; try again later.',
429,
);
}
return this.dataSource.transaction(async (manager) => {
const adminCount = await manager.count(UserEntity, { where: { role: 'admin' } });
if (adminCount > 0) {
throw ApiError.notFound('Setup endpoint not available');
}
const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) {
throw ApiError.conflict(ERROR_CODES.USERNAME_TAKEN, 'Username already exists');
}
const passwordHash = await argon2.hash(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,
passwordHash,
role: 'admin',
status: 'enabled',
});
await manager.save(user);
this.registrationRateLimit.record(ip);
this.logger.log(`First admin created (username=${username})`);
return AuthService.createSession(
manager,
{ jwt: this.jwt, config: this.config, refreshTtlSeconds: this.refreshTtlSeconds },
user,
);
});
}
private parseTtl(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return REFRESH_TTL_DEFAULT;
const n = parseInt(m[1], 10);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return n * mult;
}
}
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../../database/entities/user.entity';
@@ -6,6 +7,12 @@ import { SettingEntity } from '../../database/entities/setting.entity';
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
import { SETTINGS_KEYS } from '../../config/env.schema';
export interface PasswordPolicyInfo {
minLength: number;
requireMixed: boolean;
description: string;
}
export interface BootstrapPayload {
initialized: boolean;
pageTitle: string;
@@ -14,6 +21,7 @@ export interface BootstrapPayload {
theme: any;
defaultChallengeIp: string;
registrationsEnabled: boolean;
passwordPolicy: PasswordPolicyInfo;
}
@Injectable()
@@ -22,6 +30,7 @@ export class SystemService {
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@InjectRepository(SettingEntity) private readonly settings: Repository<SettingEntity>,
private readonly themes: ThemeLoaderService,
private readonly config: ConfigService,
) {}
async bootstrap(): Promise<BootstrapPayload> {
@@ -35,9 +44,19 @@ export class SystemService {
theme: this.themes.getTheme(themeKey),
defaultChallengeIp: await this.getSetting(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'),
registrationsEnabled: (await this.getSetting(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true',
passwordPolicy: this.passwordPolicy(),
};
}
private passwordPolicy(): PasswordPolicyInfo {
const minLength = this.config.get<number>('PASSWORD_MIN_LENGTH', 12);
const requireMixed = this.config.get<boolean>('PASSWORD_REQUIRE_MIXED', true);
const description = requireMixed
? `At least ${minLength} characters with upper, lower, digit and symbol`
: `At least ${minLength} characters`;
return { minLength, requireMixed, description };
}
private async getSetting(key: string, fallback: string): Promise<string> {
const row = await this.settings.findOne({ where: { key } });
return row?.value ?? fallback;
+84
View File
@@ -0,0 +1,84 @@
---
type: api
title: REST API Overview
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
tags: [api, rest, overview, csrf]
timestamp: 2026-07-21T14:43:00Z
---
# Base URL & versioning
All endpoints live under `/api/v1`. The Angular SPA uses the same
prefix; the SPA fallback only kicks in for non-API routes.
| Path prefix | Purpose |
|-----------------|--------------------------------------------------------|
| `/api/v1/*` | Versioned JSON REST endpoints. |
| `/api/docs` | Swagger UI (OpenAPI 3.1). |
| `/api/docs-json`| Raw OpenAPI 3.1 document. |
| `/uploads/*` | Static uploads directory (`UPLOAD_DIR`). |
| `/*` | SPA fallback (`FRONTEND_DIST/index.html`). |
# Authentication
* `JwtAuthGuard` is registered as `APP_GUARD` and runs on every request
unless the handler is marked with `@Public()` from
`backend/src/common/decorators/public.decorator.ts`.
* Successful auth produces an access JWT in the response body and an
`HttpOnly` refresh cookie named `rt` (see `backend/src/modules/auth/cookie.ts`).
* `Authorization: Bearer <accessToken>` is attached by the
`authInterceptor` on the frontend.
# CSRF
* `CsrfMiddleware` (registered globally) mints a `csrf` cookie on every
response and, for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`),
compares the cookie against the `x-csrf-token` header using
`crypto.timingSafeEqual`.
* The frontend's `csrfInterceptor` runs *before* `authInterceptor` so
the header is attached on every unsafe call.
* Skip list (`SKIP_PATH_PREFIXES` in
`backend/src/common/middleware/csrf.middleware.ts`):
- `/api/v1/auth/register-first-admin`
- `/api/v1/auth/login`
- `/api/v1/setup/create-admin`
# Error envelope
Every error flows through `GlobalExceptionFilter` and is shaped as:
```json
{
"code": "USERNAME_TAKEN",
"message": "Username already exists",
"details": null,
"path": "/api/v1/setup/create-admin",
"timestamp": "2026-07-21T14:43:00.000Z"
}
```
| Field | Type | Notes |
|-------------|---------------------|--------------------------------------------------------|
| `code` | string | One of `ERROR_CODES` (`backend/src/common/errors/error-codes.ts`). |
| `message` | string | Human-readable summary. |
| `details` | unknown \| null | Optional structured details (e.g. zod field errors). |
| `path` | string | Original request path. |
| `timestamp` | ISO 8601 string | When the filter produced the response. |
# Canonical error codes
`USERNAME_TAKEN` is the most recently added code (used by the setup
endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, `INTERNAL`.
# See also
- [Auth Endpoints](/api/auth.md)
- [Admin Endpoints](/api/admin.md)
- [Setup Endpoint](/api/setup.md)
- [System Endpoints](/api/system.md)
- [Uploads Endpoints](/api/uploads.md)
+80
View File
@@ -0,0 +1,80 @@
---
type: api
title: Setup Endpoint
description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists.
tags: [api, setup, bootstrap, first-admin]
timestamp: 2026-07-21T14:43:00Z
---
# Endpoint
| Method | Path | Auth | CSRF | Rate limited |
|--------|-------------------------------|------|--------|--------------|
| `POST` | `/api/v1/setup/create-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`) | Yes (per-IP via `RegistrationRateLimitService`) |
# Request body
Validated by `backend/src/modules/setup/dto/create-admin.dto.ts` (zod):
| Field | Type | Constraints |
|-------------------|--------|------------------------------------------------------------------------------|
| `username` | string | 332 chars, regex `^[a-zA-Z0-9_.-]+$` |
| `password` | string | 1256 chars; additionally passes `validatePassword` (Argon2 policy). |
| `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED`, `passwordConfirm`).|
# Successful response (201)
```json
{
"accessToken": "<jwt>",
"expiresIn": 900,
"user": { "id": "<uuid>", "username": "first.admin", "role": "admin" }
}
```
The refresh token is set as an `HttpOnly` cookie via
`setRefreshCookie(res, session.refreshToken, config)` so the SPA can keep
refreshing without storing the raw token in JS.
# Error envelope
| HTTP | `code` | When |
|------|---------------------|----------------------------------------------------------------------|
| 400 | `VALIDATION_FAILED` | zod body validation failed (length, regex, or `passwordConfirm`). |
| 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password (length / mixed-case requirement). |
| 404 | `NOT_FOUND` | An admin already exists — the route is hidden once initialized. |
| 409 | `USERNAME_TAKEN` | Username collision (reachable only during a race before init). |
| 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. |
# Wiring
* Registered in `backend/src/app.module.ts` via `SetupModule`.
* `SetupController` (`backend/src/modules/setup/setup.controller.ts`) is
`@Public()` and uses `ZodValidationPipe(CreateAdminDtoSchema)`.
* `SetupService.createAdmin` runs everything inside a single
`DataSource.transaction`: admin count check → username uniqueness check
→ Argon2id hash → insert `UserEntity(role='admin', status='enabled')`
mint session via the static `AuthService.createSession`.
* CSRF is bypassed for `/api/v1/setup/create-admin` so the first caller
(before any cookie exists) is not blocked. The middleware still mints
a `csrf` cookie on the response so subsequent calls are covered.
# Frontend consumer
`frontend/src/app/features/setup/setup-create-admin.service.ts` calls
this endpoint and normalizes the response into the discriminated union
`CreateAdminResult`:
```ts
type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure;
```
`CreateAdminFailure` exposes `{ ok: false, status, code, message }` so
the component can switch on `code === 'USERNAME_TAKEN'` to surface a
non-retryable inline error and offer a Retry button for everything else.
# See also
- [First-Run Bootstrap Guide](/guides/bootstrap.md)
- [Backend Module Map](/architecture/backend-modules.md)
- [Bootstrap Service](/architecture/frontend-structure.md)
+3 -1
View File
@@ -3,7 +3,7 @@ type: architecture
title: Backend Module Map
description: NestJS modules, controllers, services, and how they are wired together.
tags: [architecture, backend, nestjs, modules]
timestamp: 2026-07-21T14:18:00Z
timestamp: 2026-07-21T14:43:00Z
---
# Module Map
@@ -25,6 +25,7 @@ also registers two global providers:
| `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). |
| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin).|
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). |
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 404 once any admin exists (route hidden). |
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. |
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
@@ -36,6 +37,7 @@ also registers two global providers:
|------------------------|-------------------------|--------------|--------------------------------------------------------------|
| `AuthController` | `/api/v1/auth` | Mostly public (login, refresh, logout, csrf) | `backend/src/modules/auth/auth.controller.ts` |
| `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` |
| `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 404 once an admin exists) | `backend/src/modules/setup/setup.controller.ts` |
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` |
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
+3 -3
View File
@@ -3,7 +3,7 @@ type: architecture
title: Frontend Structure
description: Angular routes, components, services, guards, and interceptors.
tags: [architecture, frontend, angular]
timestamp: 2026-07-21T14:18:00Z
timestamp: 2026-07-21T14:43:00Z
---
# Routes
@@ -12,7 +12,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| Path | Component | Guard | Notes |
|--------------|----------------------------------|---------------|----------------------------------------|
| `/bootstrap` | `CreateAdminComponent` | — | Rendered only when `initialized === false`. |
| `/bootstrap` | `SetupCreateAdminComponent` | — | Rendered only when `initialized === false`. Non-dismissible modal overlay. |
| `/login` | `LoginComponent` | — | Standard login form. |
| `/` | `HomeComponent` | `authGuard` | Requires auth + initialized. |
| `**` | Redirect to `/` | — | Wildcard fallback. |
@@ -27,7 +27,7 @@ All components are standalone (no NgModules). Each component imports
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>` and calls `BootstrapService.load()`. |
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Landing page shown after auth. |
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
| `CreateAdminComponent` | `frontend/src/app/features/bootstrap/create-admin.component.ts`| First-admin bootstrap form. |
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal (non-dismissible, typed reactive forms). |
# Services
+8 -2
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index
description: One-line responsibility for every important source file in the repository.
tags: [architecture, index, key-files]
timestamp: 2026-07-21T14:18:00Z
timestamp: 2026-07-21T14:43:00Z
---
# Backend
@@ -74,6 +74,10 @@ timestamp: 2026-07-21T14:18:00Z
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
| `backend/src/modules/users/users.service.ts` | `enforceLastAdminInvariant` helper. |
| `backend/src/modules/users/dto/create-first-admin.dto.ts` | Zod schema for first-admin registration. |
| `backend/src/modules/setup/setup.module.ts` | Wires `SetupController` + `SetupService` (dedicated first-admin bootstrap flow). |
| `backend/src/modules/setup/setup.controller.ts` | `POST /api/v1/setup/create-admin` (returns 404 once an admin exists). |
| `backend/src/modules/setup/setup.service.ts` | Hashes password (Argon2id), runs the create inside a transaction, mints session via `AuthService.createSession`. |
| `backend/src/modules/setup/dto/create-admin.dto.ts` | Zod schema with `password === passwordConfirm` refine. |
| `backend/src/modules/admin/admin.module.ts` | Wires `AdminController` + `AdminService`. |
| `backend/src/modules/admin/admin.controller.ts` | `/api/v1/admin/users[/{id}]`. |
| `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. |
@@ -110,7 +114,9 @@ timestamp: 2026-07-21T14:18:00Z
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
| `frontend/src/app/features/auth/login.component.ts` | Login form. |
| `frontend/src/app/features/bootstrap/create-admin.component.ts` | First-admin creation form. |
| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). |
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. |
| `frontend/src/app/features/home/home.component.ts` | Landing page after auth. |
# Tests
+11 -4
View File
@@ -3,7 +3,7 @@ type: database
title: User Table
description: The `user` table — accounts, roles, and statuses.
tags: [database, user, auth]
timestamp: 2026-07-21T14:18:00Z
timestamp: 2026-07-21T14:43:00Z
---
# Schema
@@ -34,9 +34,16 @@ timestamp: 2026-07-21T14:18:00Z
# First-admin bootstrap
When the `user` table is empty of admins, `POST /api/v1/auth/register-first-admin`
is the only endpoint that can create one. After that, the endpoint
returns `409 SYSTEM_INITIALIZED`.
When the `user` table is empty of admins, the dedicated
`POST /api/v1/setup/create-admin` endpoint (see
[Setup Endpoint](/api/setup.md)) creates the very first admin and
auto-issues a session. Once any admin row exists the endpoint becomes
inert and returns `404 NOT_FOUND`, so the route is effectively hidden
in production.
The legacy `POST /api/v1/auth/register-first-admin` route is preserved
for compatibility but the canonical first-admin flow now lives in the
`SetupModule`.
# See also
+105
View File
@@ -0,0 +1,105 @@
---
type: guide
title: First-Run Bootstrap
description: How a fresh HIPCTF instance is initialized by the very first administrator.
tags: [guide, bootstrap, first-admin, onboarding, tester]
timestamp: 2026-07-21T14:43:00Z
---
# When this flow runs
A brand-new deployment shows a non-dismissible modal asking the operator
to create the very first admin user. This is gated by the
`initialized` flag returned from `GET /api/v1/bootstrap`:
| `initialized` | User experience |
|---------------|---------------------------------------------------------------|
| `false` | Every route is redirected to `/bootstrap` until an admin is created. |
| `true` | The setup route becomes a 404 and the modal can never appear again. |
The `authGuard` enforces the redirect on the client side; the backend
independently rejects `/api/v1/setup/create-admin` with `404 NOT_FOUND`
once any admin row exists, so the flow is safe even if the SPA is
bypassed.
# How to access (tester steps)
1. Start the stack against an empty database:
```
npm run db:reset && npm run dev
```
2. Open the browser at `http://localhost:3000/`.
3. The app redirects to `/bootstrap` and shows the
**"Welcome to HIPCTF — Create the first administrator"** modal.
4. Fill in the three fields and submit:
- **Username** — 332 chars, letters/digits/`.`/`-`/`_` only. Helper
text is shown via `data-testid="setup-username-error"` when the
pattern fails.
- **Password** — must satisfy the policy shown directly below the
field (default: 12 chars with upper, lower, digit, symbol).
- **Confirm Password** — must equal the password above (verified by
the group-level `passwordMatchValidator`; mismatch shows
`data-testid="setup-confirm-error"`).
5. Click **Create admin** (`data-testid="setup-submit"`).
# Expected behavior
* On success the modal disappears, the app stores the access token via
`AuthService.setSession`, calls `BootstrapService.markInitialized()`,
and navigates to `/challenges`.
* On `USERNAME_TAKEN` the form shows a red alert:
`"Username already exists"`. The **Retry** button is intentionally
hidden because the username must be changed first.
* On any other failure (network, `WEAK_PASSWORD`, `VALIDATION_FAILED`,
`RATE_LIMITED`) the alert shows `"Setup failed, please retry"` and a
**Retry** link (`data-testid="setup-retry"`) re-submits the same
payload.
The modal **cannot** be dismissed by the user:
* Clicking the backdrop is a no-op (`swallowBackdrop`).
* Pressing **Esc** is swallowed by the `keydown.escape` host listener.
# Visual elements
| Element | Selector | Purpose |
|--------------------------------|----------------------------------|-----------------------------------------------|
| Modal overlay | `.overlay` | Full-screen backdrop. |
| Modal card | `.modal[role="dialog"]` | Contains the form; stops click propagation. |
| Username input | `[data-testid="setup-username"]` | Reactive control with length/pattern rules. |
| Password input | `[data-testid="setup-password"]` | Reactive control. |
| Confirm input | `[data-testid="setup-password-confirm"]` | Reactive control. |
| Submit button | `[data-testid="setup-submit"]` | Disabled while submitting or invalid. |
| Server error alert | `[data-testid="setup-server-error"]` | Displays `serverError()`. |
| Retry button | `[data-testid="setup-retry"]` | Re-submits; absent when the error is non-retryable. |
| Password policy hint | `.hint` | Live description from `BootstrapService.passwordPolicy()`. |
# Behind the scenes (architecture map)
| Step | Where | What happens |
|------|--------------------------------------------------------|-------------------------------------------------------------------------|
| 1 | `BootstrapService.load()` | Calls `GET /api/v1/bootstrap`, sets `initialized = false`. |
| 2 | `authGuard` | Sees `initialized() === false`, redirects to `/bootstrap`. |
| 3 | `SetupCreateAdminComponent` | Renders the modal; reactive form enforces username + password policy + passwordMatch. |
| 4 | `SetupCreateAdminService.ensureCsrf()` | Preflights `GET /api/v1/auth/csrf` so the response sets the `csrf` cookie. |
| 5 | `POST /api/v1/setup/create-admin` | `SetupService.createAdmin` runs in a TypeORM transaction; creates the user with `role='admin'`; mints session via `AuthService.createSession`. |
| 6 | `setRefreshCookie` | Sets the `rt` HttpOnly cookie. |
| 7 | Component on success | `AuthService.setSession`, `BootstrapService.markInitialized()`, navigate to `/challenges`. |
| 8 | Subsequent `authGuard` checks | `initialized === true`; require auth instead. |
# What the operator sees after init
Once the first admin exists, `/api/v1/bootstrap` returns
`initialized: true`. The app now:
* Skips `/bootstrap` entirely.
* Routes unauthenticated users to `/login`.
* Routes authenticated users to `/` (Home) which itself redirects to
`/challenges` once admin features are wired.
# See also
- [Setup Endpoint](/api/setup.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [Backend Module Map](/architecture/backend-modules.md)
- [Bootstrap payload source of truth](`/architecture/overview.md`)
+3 -1
View File
@@ -40,6 +40,8 @@ they need.
CSRF, and error envelope.
* [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, and
first-admin registration.
* [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin`
used only while no administrator exists.
* [Admin Endpoints](/api/admin.md) - User management endpoints
(admin role required).
* [System Endpoints](/api/system.md) - Bootstrap, event status, and SSE
@@ -49,7 +51,7 @@ they need.
# Guides
* [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is
initialized by the very first admin.
initialized by the very first admin via the non-dismissible modal.
* [Theming](/guides/theming.md) - How themes are loaded and applied.
* [Event Window](/guides/event-window.md) - How the event window and live
countdown work.
+4 -1
View File
@@ -4,7 +4,10 @@ import { authGuard } from './core/guards/auth.guard';
export const APP_ROUTES: Routes = [
{
path: 'bootstrap',
loadComponent: () => import('./features/bootstrap/create-admin.component').then((m) => m.CreateAdminComponent),
loadComponent: () =>
import('./features/setup/setup-create-admin.component').then(
(m) => m.SetupCreateAdminComponent,
),
},
{
path: 'login',
@@ -1,4 +1,10 @@
import { Injectable, signal } from '@angular/core';
import { Injectable, signal, computed } from '@angular/core';
export interface PasswordPolicyInfo {
minLength: number;
requireMixed: boolean;
description: string;
}
export interface BootstrapPayload {
initialized: boolean;
@@ -8,12 +14,22 @@ export interface BootstrapPayload {
theme: any;
defaultChallengeIp: string;
registrationsEnabled: boolean;
passwordPolicy: PasswordPolicyInfo;
}
const DEFAULT_POLICY: PasswordPolicyInfo = {
minLength: 12,
requireMixed: true,
description: 'At least 12 characters with upper, lower, digit and symbol',
};
@Injectable({ providedIn: 'root' })
export class BootstrapService {
readonly payload = signal<BootstrapPayload | null>(null);
readonly initialized = signal<boolean>(false);
readonly passwordPolicy = computed<PasswordPolicyInfo>(
() => this.payload()?.passwordPolicy ?? DEFAULT_POLICY,
);
async load(): Promise<BootstrapPayload | null> {
try {
@@ -29,6 +45,10 @@ export class BootstrapService {
}
}
markInitialized(): void {
this.initialized.set(true);
}
private applyTheme(theme: any): void {
if (!theme?.tokens) return;
const root = document.documentElement.style;
@@ -0,0 +1,135 @@
:host {
display: contents;
}
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family, system-ui, sans-serif);
}
.modal {
width: min(440px, calc(100vw - 32px));
background: var(--color-surface, #1b1b1f);
color: var(--color-text, #f5f5f7);
border-radius: var(--radius-md, 12px);
padding: 28px 28px 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.modal h1 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
.subtitle {
margin: 0 0 20px;
font-size: 13px;
color: var(--color-text, #f5f5f7);
opacity: 0.7;
}
form {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
}
.field > span {
font-weight: 500;
}
.field input {
font: inherit;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
outline: none;
}
.field input:focus {
border-color: var(--color-primary, #4f8cff);
}
.hint {
font-size: 11px;
opacity: 0.65;
}
.field-error {
color: var(--color-danger, #ff6b6b);
font-size: 12px;
}
.server-error {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 107, 107, 0.12);
border: 1px solid rgba(255, 107, 107, 0.35);
border-radius: var(--radius-sm, 6px);
padding: 8px 12px;
font-size: 13px;
color: var(--color-danger, #ff6b6b);
}
.server-error .link {
background: transparent;
border: 0;
color: inherit;
text-decoration: underline;
cursor: pointer;
font: inherit;
}
button.primary {
margin-top: 6px;
padding: 11px 14px;
font: inherit;
font-weight: 600;
border-radius: var(--radius-sm, 6px);
border: 0;
background: var(--color-primary, #4f8cff);
color: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
button.primary[disabled] {
cursor: not-allowed;
opacity: 0.65;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,69 @@
<div class="overlay" (click)="swallowBackdrop($event)">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="setup-title" (click)="$event.stopPropagation()">
<h1 id="setup-title">Welcome to HIPCTF — Create the first administrator</h1>
<p class="subtitle">This instance has not been initialized yet.</p>
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<label class="field">
<span>Username</span>
<input
type="text"
autocomplete="username"
formControlName="username"
[attr.aria-invalid]="!!usernameErrors()"
data-testid="setup-username"
/>
@if (usernameErrors()) {
<small class="field-error" data-testid="setup-username-error">{{ usernameErrors() }}</small>
}
</label>
<label class="field">
<span>Password</span>
<input
type="password"
autocomplete="new-password"
formControlName="password"
data-testid="setup-password"
/>
<small class="hint">{{ policyDescription() }}</small>
</label>
<label class="field">
<span>Confirm Password</span>
<input
type="password"
autocomplete="new-password"
formControlName="passwordConfirm"
data-testid="setup-password-confirm"
/>
@if (confirmErrors()) {
<small class="field-error" data-testid="setup-confirm-error">{{ confirmErrors() }}</small>
}
</label>
@if (serverError()) {
<div class="server-error" role="alert" data-testid="setup-server-error">
<span>{{ serverError() }}</span>
@if (canRetry()) {
<button type="button" class="link" (click)="submit()" data-testid="setup-retry">Retry</button>
}
</div>
}
<button
type="submit"
class="primary"
[disabled]="submitting() || form.invalid"
data-testid="setup-submit"
>
@if (submitting()) {
<span class="spinner" aria-hidden="true"></span>
<span>Creating...</span>
} @else {
<span>Create admin</span>
}
</button>
</form>
</div>
</div>
@@ -0,0 +1,119 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
inject,
signal,
} from '@angular/core';
import {
FormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../core/services/auth.service';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service';
import { passwordMatchValidator } from './setup-create-admin.validators';
@Component({
selector: 'app-setup-create-admin',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule],
templateUrl: './setup-create-admin.component.html',
styleUrls: ['./setup-create-admin.component.css'],
})
export class SetupCreateAdminComponent {
private readonly fb = inject(FormBuilder);
private readonly api = inject(SetupCreateAdminService);
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly bootstrap = inject(BootstrapService);
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
readonly form = this.fb.nonNullable.group(
{
username: this.fb.nonNullable.control('', [
Validators.required,
Validators.minLength(3),
Validators.maxLength(32),
Validators.pattern(/^[a-zA-Z0-9_.-]+$/),
]),
password: this.fb.nonNullable.control('', [Validators.required]),
passwordConfirm: this.fb.nonNullable.control('', [Validators.required]),
},
{ validators: passwordMatchValidator },
);
readonly submitting = signal(false);
readonly serverError = signal<string | null>(null);
readonly serverErrorCode = signal<string | null>(null);
readonly canRetry = computed(
() => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN',
);
readonly usernameErrors = computed(() => {
const c = this.form.controls.username;
if (!c.touched || c.valid) return null;
if (c.errors?.['required']) return 'Username is required';
if (c.errors?.['minlength']) return 'Username must be at least 3 characters';
if (c.errors?.['maxlength']) return 'Username must be at most 32 characters';
if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore';
return 'Invalid username';
});
readonly confirmErrors = computed(() => {
const c = this.form.controls.passwordConfirm;
const groupMismatch = this.form.errors?.['passwordMismatch'];
if ((!c.touched || c.valid) && !groupMismatch) return null;
if (c.errors?.['required']) return 'Please confirm your password';
if (groupMismatch) return 'Passwords do not match';
return 'Invalid value';
});
@HostListener('document:keydown.escape', ['$event'])
swallowEscape(event: KeyboardEvent): void {
event.preventDefault();
event.stopPropagation();
}
swallowBackdrop(_event: MouseEvent): void {
// intentionally no-op: modal cannot be dismissed by clicking the backdrop
}
async submit(): Promise<void> {
if (this.submitting()) return;
this.form.markAllAsTouched();
if (this.form.invalid) return;
this.submitting.set(true);
this.serverError.set(null);
this.serverErrorCode.set(null);
const { username, password, passwordConfirm } = this.form.getRawValue();
const result = await this.api.create({ username, password, passwordConfirm });
this.submitting.set(false);
if (result.ok) {
this.auth.setSession(result.accessToken, result.user);
this.bootstrap.markInitialized();
await this.router.navigateByUrl('/challenges');
return;
}
const failure = result as CreateAdminFailure;
if (failure.code === 'USERNAME_TAKEN') {
this.serverError.set('Username already exists');
this.serverErrorCode.set('USERNAME_TAKEN');
return;
}
this.serverError.set('Setup failed, please retry');
this.serverErrorCode.set(failure.code);
}
}
@@ -0,0 +1,66 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export interface CreateAdminRequest {
username: string;
password: string;
passwordConfirm: string;
}
export interface CreateAdminSuccess {
ok: true;
accessToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}
export interface CreateAdminFailure {
ok: false;
status: number;
code: string;
message: string;
}
export type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure;
@Injectable({ providedIn: 'root' })
export class SetupCreateAdminService {
private readonly http = inject(HttpClient);
async ensureCsrf(): Promise<void> {
try {
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
} catch {
// ignore: csrf middleware will mint a cookie if missing
}
}
async create(body: CreateAdminRequest): Promise<CreateAdminResult> {
await this.ensureCsrf();
try {
const res = await firstValueFrom(
this.http.post<{
accessToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}>('/api/v1/setup/create-admin', body, { withCredentials: true, observe: 'response' }),
);
return {
ok: true,
accessToken: res.body!.accessToken,
expiresIn: res.body!.expiresIn,
user: res.body!.user,
};
} catch (e) {
return mapError(e as HttpErrorResponse);
}
}
}
function mapError(err: HttpErrorResponse): CreateAdminFailure {
const body = err.error as { code?: string; message?: string } | null;
const code = body?.code ?? 'INTERNAL';
const message = body?.message ?? err.message ?? 'Setup failed';
return { ok: false, status: err.status ?? 0, code, message };
}
@@ -0,0 +1,20 @@
export interface PasswordMatchGroupLike {
get(name: 'password' | 'passwordConfirm'): { value: string | null | undefined } | null;
}
export interface PasswordMatchResult {
passwordMismatch?: boolean;
}
/**
* Group-level validator for the "Confirm Password" field.
* Returns `{ passwordMismatch: true }` when the two values differ,
* or `null` when they match (or when either is empty, so that
* `required`/`minlength` validators can take over).
*/
export function passwordMatchValidator(group: PasswordMatchGroupLike): PasswordMatchResult | null {
const password = group.get('password')?.value;
const confirm = group.get('passwordConfirm')?.value;
if (!password || !confirm) return null;
return password === confirm ? null : { passwordMismatch: true };
}
+30 -20
View File
@@ -39,6 +39,7 @@
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"helmet": "^7.1.0",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
@@ -52,6 +53,7 @@
"@types/better-sqlite3": "^7.6.10",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
@@ -2656,7 +2658,7 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
@@ -2669,7 +2671,7 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
@@ -3739,7 +3741,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -3760,7 +3762,7 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -5148,28 +5150,28 @@
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tufjs/canonical-json": {
@@ -5523,6 +5525,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -5965,7 +5977,7 @@
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -5999,7 +6011,7 @@
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
@@ -6227,7 +6239,7 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/argon2": {
@@ -7618,7 +7630,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/critters": {
@@ -8024,7 +8036,7 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@@ -8254,7 +8266,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -8265,7 +8276,6 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -11838,7 +11848,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/make-fetch-happen": {
@@ -15988,7 +15998,7 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
@@ -16367,7 +16377,7 @@
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -16602,7 +16612,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
@@ -17799,7 +17809,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
+5 -1
View File
@@ -2,7 +2,11 @@ import request from 'supertest';
import type { INestApplication } from '@nestjs/common';
import { CookieAccessInfo } from 'cookiejar';
const csrfSkipPaths = new Set<string>(['/api/v1/auth/login', '/api/v1/auth/register-first-admin']);
const csrfSkipPaths = new Set<string>([
'/api/v1/auth/login',
'/api/v1/auth/register-first-admin',
'/api/v1/setup/create-admin',
]);
function attachCsrfHeader(test: request.Test, method: string, path: string, agent: any): request.Test {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())) return test;
+115
View File
@@ -0,0 +1,115 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { csrfClient } from './csrf-client';
import { initDb } from './db-helper';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SetupService } from '../../backend/src/modules/setup/setup.service';
describe('Setup: POST /api/v1/setup/create-admin', () => {
let app: INestApplication;
let users: Repository<UserEntity>;
let setupService: SetupService;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
users = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
setupService = app.get(SetupService);
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
await users.clear();
});
it('creates the very first admin and exposes a session + cookie', async () => {
const c = csrfClient(app);
const res = await c
.post('/api/v1/setup/create-admin')
.send({ username: 'first.admin', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
.expect(201);
expect(res.body.accessToken).toEqual(expect.any(String));
expect(res.body.user.role).toBe('admin');
expect(res.body.user.username).toBe('first.admin');
const setCookieRaw = res.headers['set-cookie'] as unknown;
const setCookie: string[] = Array.isArray(setCookieRaw) ? setCookieRaw : setCookieRaw ? [String(setCookieRaw)] : [];
expect(setCookie.some((h) => String(h).startsWith('rt='))).toBe(true);
const adminCount = await users.count({ where: { role: 'admin' } });
expect(adminCount).toBe(1);
});
it('returns 404 once an admin already exists (route is hidden)', async () => {
const c = csrfClient(app);
await c
.post('/api/v1/setup/create-admin')
.send({ username: 'first', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
.expect(201);
const res = await c
.post('/api/v1/setup/create-admin')
.send({ username: 'second', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' });
expect(res.status).toBe(404);
expect(res.body).toBeDefined();
});
it('SetupService would map a username collision to USERNAME_TAKEN (reached only during a race before init)', () => {
// The current ordering (route hidden after init) means USERNAME_TAKEN is
// reachable only when two requests enter the transaction concurrently
// before any admin exists. Verify the error code is present in the
// error-codes enum so the frontend can switch on it.
const { ERROR_CODES } = require('../../backend/src/common/errors/error-codes');
expect(ERROR_CODES.USERNAME_TAKEN).toBe('USERNAME_TAKEN');
});
it('SetupService throws not-found (route hidden) after init', async () => {
await setupService.createAdmin('init', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1');
await expect(
setupService.createAdmin('newone', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('rejects weak password with WEAK_PASSWORD', async () => {
const c = csrfClient(app);
const res = await c
.post('/api/v1/setup/create-admin')
.send({ username: 'weakpw', password: 'short', passwordConfirm: 'short' })
.expect(400);
expect(res.body.code).toBe('WEAK_PASSWORD');
});
it('rejects passwordConfirm mismatch with VALIDATION_FAILED', async () => {
const c = csrfClient(app);
await c
.post('/api/v1/setup/create-admin')
.send({ username: 'mismatch', password: 'Sup3rSecret!Pass', passwordConfirm: 'Different!Pass1' })
.expect(400);
});
it('rejects invalid username characters with VALIDATION_FAILED', async () => {
const c = csrfClient(app);
await c
.post('/api/v1/setup/create-admin')
.send({ username: 'bad name!', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
.expect(400);
});
});
+60
View File
@@ -0,0 +1,60 @@
import {
passwordMatchValidator,
PasswordMatchGroupLike,
} from '../../frontend/src/app/features/setup/setup-create-admin.validators';
function makeGroup(password: string | null | undefined, confirm: string | null | undefined): PasswordMatchGroupLike {
return {
get: (name) => {
if (name === 'password') return { value: password };
if (name === 'passwordConfirm') return { value: confirm };
return null;
},
};
}
describe('passwordMatchValidator', () => {
it('returns null when either field is empty (let other validators handle required)', () => {
expect(passwordMatchValidator(makeGroup('', 'x'))).toBeNull();
expect(passwordMatchValidator(makeGroup('x', ''))).toBeNull();
expect(passwordMatchValidator(makeGroup(undefined, undefined))).toBeNull();
});
it('returns null when passwords match', () => {
expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup3r!'))).toBeNull();
});
it('returns { passwordMismatch: true } when passwords differ', () => {
expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup4r!'))).toEqual({
passwordMismatch: true,
});
});
});
describe('Username field rules (mirroring component validators)', () => {
const PATTERN = /^[a-zA-Z0-9_.-]+$/;
const MIN = 3;
const MAX = 32;
function isValidUsername(value: string): boolean {
return value.length >= MIN && value.length <= MAX && PATTERN.test(value);
}
it('accepts a 3-32 char username with allowed characters', () => {
expect(isValidUsername('first.admin_01')).toBe(true);
});
it('rejects usernames shorter than 3', () => {
expect(isValidUsername('a')).toBe(false);
});
it('rejects usernames with disallowed characters', () => {
expect(isValidUsername('bad name')).toBe(false);
expect(isValidUsername('user!')).toBe(false);
expect(isValidUsername('user@')).toBe(false);
});
it('rejects usernames longer than 32', () => {
expect(isValidUsername('a'.repeat(33))).toBe(false);
});
});