From 1314096c942e137a0289a813dd133145828de7c4 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 5 Jul 2026 05:26:45 +0200 Subject: [PATCH] Add account/session endpoints and typed instance settings with admin API Server halves of #17/#18/#19: PATCH /users/me and change-password (verifies the current password, logs out every other session), GET/DELETE /users/me/sessions with current-session flag and protection against revoking oneself; InstanceSettingsService as a typed, cached, Zod-validated registry over instance_settings (schema-default fallback for invalid stored values, audit-logged writes) consumed by the signup flow; /admin/settings behind the new SiteAdminGuard with strict unknown-key rejection. SessionsService moves to its own module to keep Auth/Users acyclic. Three new e2e suites bring the api to 42 tests. Part of #17, #18, #19 Co-Authored-By: Claude Fable 5 --- apps/api/package.json | 3 +- apps/api/src/admin/admin.controller.ts | 46 +++++++ apps/api/src/admin/admin.e2e.db.test.ts | 107 +++++++++++++++ apps/api/src/admin/admin.module.ts | 8 ++ apps/api/src/admin/site-admin.guard.ts | 16 +++ apps/api/src/app.module.ts | 4 + apps/api/src/auth/auth.e2e.db.test.ts | 12 -- apps/api/src/auth/auth.module.ts | 7 +- apps/api/src/auth/auth.service.ts | 15 +-- apps/api/src/auth/sessions.module.ts | 13 ++ .../src/settings/instance-settings.service.ts | 82 ++++++++++++ apps/api/src/settings/settings.module.ts | 11 ++ apps/api/src/users/account.e2e.db.test.ts | 123 ++++++++++++++++++ apps/api/src/users/users.controller.ts | 95 ++++++++++++++ apps/api/src/users/users.module.ts | 4 + pnpm-lock.yaml | 3 + 16 files changed, 520 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/admin/admin.controller.ts create mode 100644 apps/api/src/admin/admin.e2e.db.test.ts create mode 100644 apps/api/src/admin/admin.module.ts create mode 100644 apps/api/src/admin/site-admin.guard.ts create mode 100644 apps/api/src/auth/sessions.module.ts create mode 100644 apps/api/src/settings/instance-settings.service.ts create mode 100644 apps/api/src/settings/settings.module.ts create mode 100644 apps/api/src/users/account.e2e.db.test.ts create mode 100644 apps/api/src/users/users.controller.ts diff --git a/apps/api/package.json b/apps/api/package.json index f972d27..3383690 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -28,7 +28,8 @@ "pino-http": "^10.4.0", "prisma": "^6.3.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.0" + "rxjs": "^7.8.0", + "zod": "^3.25.76" }, "devDependencies": { "@nestjs/cli": "^11.0.0", diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts new file mode 100644 index 0000000..7c35e19 --- /dev/null +++ b/apps/api/src/admin/admin.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Patch, Req, UseGuards } from '@nestjs/common'; +import { z } from 'zod'; + +import type { AuthedRequest } from '../auth/auth.guard'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { + INSTANCE_SETTINGS, + InstanceSettingKey, + InstanceSettings, + InstanceSettingsService, +} from '../settings/instance-settings.service'; +import { SiteAdminGuard } from './site-admin.guard'; + +// Partial update: any subset of the known settings, each validated by +// its own schema inside the service (double validation is fine — this +// outer schema only gates unknown keys). +const patchSchema = z + .object( + Object.fromEntries( + Object.keys(INSTANCE_SETTINGS).map((key) => [key, z.unknown().optional()]), + ) as Record>, + ) + .strict(); + +@Controller('admin/settings') +@UseGuards(SiteAdminGuard) +export class AdminSettingsController { + constructor(private readonly settings: InstanceSettingsService) {} + + @Get() + getSettings(): Promise { + return this.settings.getAll(); + } + + @Patch() + async patchSettings( + @Body(new ZodValidationPipe(patchSchema)) input: Partial>, + @Req() request: AuthedRequest, + ): Promise { + for (const [key, value] of Object.entries(input)) { + if (value === undefined) continue; + await this.settings.set(key as InstanceSettingKey, value, request.user!.id); + } + return this.settings.getAll(); + } +} diff --git a/apps/api/src/admin/admin.e2e.db.test.ts b/apps/api/src/admin/admin.e2e.db.test.ts new file mode 100644 index 0000000..cca36e3 --- /dev/null +++ b/apps/api/src/admin/admin.e2e.db.test.ts @@ -0,0 +1,107 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +describe.skipIf(!hasTestDb)('admin settings (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + let adminCookie: string; + let memberCookie: string; + + const api = () => request(app.getHttpServer()); + const password = 'ein ordentliches admin passwort'; + + async function makeActiveUser(name: string, siteAdmin: boolean): Promise { + const users = app.get(UsersService); + const user = await users.createUser({ + username: `${name}-${suffix}`, + email: `${name}-${suffix}@example.org`, + displayName: name, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + if (siteAdmin) { + await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } }); + } + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: user.username, password }) + .expect(200); + return sessionCookieOf(res); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } }); + app = await createTestApp(); + adminCookie = await makeActiveUser('root', true); + memberCookie = await makeActiveUser('member', false); + }); + + afterAll(async () => { + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); + await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('denies the settings endpoints to non-admins and anonymous callers', async () => { + await api().get('/api/v1/admin/settings').expect(401); + await api().get('/api/v1/admin/settings').set('Cookie', memberCookie).expect(403); + }); + + it('returns typed defaults for unset settings', async () => { + const res = await api().get('/api/v1/admin/settings').set('Cookie', adminCookie).expect(200); + expect(res.body['auth.registrationMode']).toBe('open'); + expect(res.body['instance.name']).toBe('Dorfteich'); + }); + + it('rejects invalid values with field details', async () => { + const res = await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'auth.registrationMode': 'sideways' }) + .expect(400); + expect(res.body.details).toHaveProperty('auth.registrationMode'); + }); + + it('closing registration takes effect immediately, reopening too', async () => { + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'auth.registrationMode': 'closed' }) + .expect(200); + + const newcomer = { + username: `late-${suffix}`, + email: `late-${suffix}@example.org`, + displayName: 'Late', + password: 'auch ein gutes passwort', + }; + await api().post('/api/v1/auth/signup').send(newcomer).expect(403); + + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'auth.registrationMode': 'open' }) + .expect(200); + await api().post('/api/v1/auth/signup').send(newcomer).expect(201); + }); + + it('rejects unknown setting keys', async () => { + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'made.up': true }) + .expect(400); + }); +}); diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts new file mode 100644 index 0000000..ba6509e --- /dev/null +++ b/apps/api/src/admin/admin.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +import { AdminSettingsController } from './admin.controller'; + +@Module({ + controllers: [AdminSettingsController], +}) +export class AdminModule {} diff --git a/apps/api/src/admin/site-admin.guard.ts b/apps/api/src/admin/site-admin.guard.ts new file mode 100644 index 0000000..2b0c4ba --- /dev/null +++ b/apps/api/src/admin/site-admin.guard.ts @@ -0,0 +1,16 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; + +import type { AuthedRequest } from '../auth/auth.guard'; + +/** + * Second-stage guard for /admin routes: AuthGuard has already attached + * the user; this one requires the Site Admin flag (permissions.md). + */ +@Injectable() +export class SiteAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + if (!request.user?.isSiteAdmin) throw new ForbiddenException(); + return true; + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b267ce2..cfba086 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { APP_FILTER } from '@nestjs/core'; import { LoggerModule } from 'nestjs-pino'; +import { AdminModule } from './admin/admin.module'; import { AuthModule } from './auth/auth.module'; import { ApiExceptionFilter } from './common/api-exception.filter'; import { AppConfig } from './config/app-config.service'; @@ -10,6 +11,7 @@ import { HealthModule } from './health/health.module'; import { MailModule } from './mail/mail.module'; import { PrismaModule } from './prisma/prisma.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; +import { SettingsModule } from './settings/settings.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -18,8 +20,10 @@ import { UsersModule } from './users/users.module'; PrismaModule, RateLimitModule, MailModule, + SettingsModule, UsersModule, AuthModule, + AdminModule, LoggerModule.forRootAsync({ inject: [AppConfig], useFactory: (config: AppConfig) => ({ diff --git a/apps/api/src/auth/auth.e2e.db.test.ts b/apps/api/src/auth/auth.e2e.db.test.ts index 8915107..f60c2e8 100644 --- a/apps/api/src/auth/auth.e2e.db.test.ts +++ b/apps/api/src/auth/auth.e2e.db.test.ts @@ -47,7 +47,6 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { afterAll(async () => { await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } }); - await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } }); await prisma.$disconnect(); await app.close(); }); @@ -82,17 +81,6 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => { expect(user?.status).toBe('ACTIVE'); }); - it('refuses signup while registration is closed', async () => { - await prisma.instanceSetting.create({ - data: { key: 'auth.registrationMode', value: 'closed' }, - }); - await api() - .post('/api/v1/auth/signup') - .send({ ...account, username: `late-${suffix}`, email: `late-${suffix}@example.org` }) - .expect(403); - await prisma.instanceSetting.delete({ where: { key: 'auth.registrationMode' } }); - }); - // ---------------------------------------------------------------- #14 let cookie: string; diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index eff4bae..7a0e394 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -7,19 +7,18 @@ import { AuthController } from './auth.controller'; import { AuthGuard } from './auth.guard'; import { AuthService } from './auth.service'; import { AuthTokensService } from './auth-tokens.service'; -import { SessionsService } from './sessions.service'; +import { SessionsModule } from './sessions.module'; @Module({ - imports: [UsersModule, MailModule], + imports: [UsersModule, MailModule, SessionsModule], controllers: [AuthController], providers: [ AuthService, AuthTokensService, - SessionsService, // Global default-protected: every route needs a session unless it // opts out with @Public(). { provide: APP_GUARD, useClass: AuthGuard }, ], - exports: [SessionsService, AuthTokensService], + exports: [AuthTokensService], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index abc9fc0..edfb9d5 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -12,6 +12,7 @@ import { AppConfig } from '../config/app-config.service'; import { MailService } from '../mail/mail.service'; import { PrismaService } from '../prisma/prisma.service'; import { RateLimitService } from '../rate-limit/rate-limit.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { UsersService } from '../users/users.service'; import { AuthTokensService } from './auth-tokens.service'; import { SessionsService } from './sessions.service'; @@ -31,13 +32,14 @@ export class AuthService { private readonly mail: MailService, private readonly rateLimits: RateLimitService, private readonly config: AppConfig, + private readonly settings: InstanceSettingsService, private readonly logger: PinoLogger, ) { this.logger.setContext(AuthService.name); } async signup(input: SignupInput): Promise { - if ((await this.registrationMode()) === 'closed') { + if ((await this.settings.get('auth.registrationMode')) === 'closed') { throw new ForbiddenException({ code: 'registration_closed' }); } const user = await this.users.createUser(input); @@ -142,17 +144,6 @@ export class AuthService { asLocale(user.locale), ); } - - /** - * Registration mode straight from instance_settings; the typed - * InstanceSettingsService (issue #19) will replace this direct read. - */ - private async registrationMode(): Promise<'open' | 'closed'> { - const row = await this.prisma.instanceSetting.findUnique({ - where: { key: 'auth.registrationMode' }, - }); - return row?.value === 'closed' ? 'closed' : 'open'; - } } function asLocale(locale: string): 'de' | 'en' { diff --git a/apps/api/src/auth/sessions.module.ts b/apps/api/src/auth/sessions.module.ts new file mode 100644 index 0000000..9d27dbf --- /dev/null +++ b/apps/api/src/auth/sessions.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { SessionsService } from './sessions.service'; + +/** + * Own module so both AuthModule (guard, login) and UsersModule (session + * management endpoints) can use sessions without importing each other. + */ +@Module({ + providers: [SessionsService], + exports: [SessionsService], +}) +export class SessionsModule {} diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts new file mode 100644 index 0000000..c4e6c0d --- /dev/null +++ b/apps/api/src/settings/instance-settings.service.ts @@ -0,0 +1,82 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PinoLogger } from 'nestjs-pino'; +import { z } from 'zod'; + +import { PrismaService } from '../prisma/prisma.service'; + +/** + * The typed registry of instance settings. Adding a setting = adding a + * line here; readers get parsed, defaulted values and writers get + * validation for free. Secrets never go through this table + * (security.md §Secrets). + */ +export const INSTANCE_SETTINGS = { + 'auth.registrationMode': z.enum(['open', 'closed']).default('open'), + 'instance.name': z.string().trim().min(1).max(60).default('Dorfteich'), + 'instance.defaultLocale': z.enum(['de', 'en']).default('en'), +} as const; + +export type InstanceSettingKey = keyof typeof INSTANCE_SETTINGS; +export type InstanceSettingValue = z.infer< + (typeof INSTANCE_SETTINGS)[K] +>; +export type InstanceSettings = { [K in InstanceSettingKey]: InstanceSettingValue }; + +/** + * Typed, cached access to instance_settings. The in-process cache is + * invalidated on every write; with one api container per stage + * (ADR 0002) that is sufficient — no cross-instance bus needed yet. + */ +@Injectable() +export class InstanceSettingsService { + private cache = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(InstanceSettingsService.name); + } + + async get(key: K): Promise> { + if (this.cache.has(key)) return this.cache.get(key) as InstanceSettingValue; + const row = await this.prisma.instanceSetting.findUnique({ where: { key } }); + const parsed = INSTANCE_SETTINGS[key].safeParse(row?.value); + // Unknown/invalid stored values fall back to the schema default + // instead of breaking the instance. + const value = parsed.success ? parsed.data : INSTANCE_SETTINGS[key].parse(undefined); + this.cache.set(key, value); + return value as InstanceSettingValue; + } + + async getAll(): Promise { + const entries = await Promise.all( + (Object.keys(INSTANCE_SETTINGS) as InstanceSettingKey[]).map( + async (key) => [key, await this.get(key)] as const, + ), + ); + return Object.fromEntries(entries) as InstanceSettings; + } + + async set( + key: K, + value: unknown, + actorUserId: string, + ): Promise> { + const parsed = INSTANCE_SETTINGS[key].safeParse(value); + if (!parsed.success) { + throw new BadRequestException({ + code: 'bad_request', + details: { [key]: parsed.error.issues.map((i) => i.message) }, + }); + } + await this.prisma.instanceSetting.upsert({ + where: { key }, + create: { key, value: parsed.data }, + update: { value: parsed.data }, + }); + this.cache.set(key, parsed.data); + this.logger.info({ key, actorUserId }, 'audit: instance setting changed'); + return parsed.data as InstanceSettingValue; + } +} diff --git a/apps/api/src/settings/settings.module.ts b/apps/api/src/settings/settings.module.ts new file mode 100644 index 0000000..805938f --- /dev/null +++ b/apps/api/src/settings/settings.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; + +import { InstanceSettingsService } from './instance-settings.service'; + +/** Global: instance configuration is read across many feature modules. */ +@Global() +@Module({ + providers: [InstanceSettingsService], + exports: [InstanceSettingsService], +}) +export class SettingsModule {} diff --git a/apps/api/src/users/account.e2e.db.test.ts b/apps/api/src/users/account.e2e.db.test.ts new file mode 100644 index 0000000..3b064ab --- /dev/null +++ b/apps/api/src/users/account.e2e.db.test.ts @@ -0,0 +1,123 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from './users.service'; + +describe.skipIf(!hasTestDb)('account self-service (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const username = `nia-${suffix}`; + const password = 'nias ausgezeichnetes passwort'; + let cookie: string; + + const api = () => request(app.getHttpServer()); + + async function login(pw: string): Promise { + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password: pw }) + .expect(200); + return sessionCookieOf(res); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + const users = app.get(UsersService); + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: 'Nia', + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + cookie = await login(password); + }); + + afterAll(async () => { + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + // ---------------------------------------------------------------- #17 + it('updates display name and locale, visible via /auth/me', async () => { + await api() + .patch('/api/v1/users/me') + .set('Cookie', cookie) + .send({ displayName: 'Nia Neu', locale: 'de' }) + .expect(200); + const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); + expect(me.body.displayName).toBe('Nia Neu'); + expect(me.body.locale).toBe('de'); + }); + + it('validates profile input with field details', async () => { + const res = await api() + .patch('/api/v1/users/me') + .set('Cookie', cookie) + .send({ displayName: '' }) + .expect(400); + expect(res.body.details).toHaveProperty('displayName'); + }); + + it('changes the password only with the correct current password', async () => { + await api() + .post('/api/v1/users/me/change-password') + .set('Cookie', cookie) + .send({ currentPassword: 'geraten und falsch', newPassword: 'noch besseres passwort' }) + .expect(403); + + const otherSession = await login(password); + await api() + .post('/api/v1/users/me/change-password') + .set('Cookie', cookie) + .send({ currentPassword: password, newPassword: 'noch besseres passwort' }) + .expect(204); + + // The changing session survives, every other one is gone. + await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200); + await api().get('/api/v1/auth/me').set('Cookie', otherSession).expect(401); + cookie = await login('noch besseres passwort'); + }); + + // ---------------------------------------------------------------- #18 + it('lists active sessions with the current one flagged', async () => { + const second = await login('noch besseres passwort'); + const res = await api().get('/api/v1/users/me/sessions').set('Cookie', cookie).expect(200); + expect(res.body.length).toBeGreaterThanOrEqual(2); + expect(res.body.filter((s: { current: boolean }) => s.current)).toHaveLength(1); + expect(second).toBeTruthy(); + }); + + it('revokes another session; it is logged out on its next request', async () => { + const victim = await login('noch besseres passwort'); + const list = await api().get('/api/v1/users/me/sessions').set('Cookie', victim).expect(200); + const current = list.body.find((s: { current: boolean }) => s.current); + + await api().delete(`/api/v1/users/me/sessions/${current.id}`).set('Cookie', cookie).expect(204); + await api().get('/api/v1/auth/me').set('Cookie', victim).expect(401); + }); + + it('refuses to revoke the current session via the revoke endpoint', async () => { + const list = await api().get('/api/v1/users/me/sessions').set('Cookie', cookie).expect(200); + const current = list.body.find((s: { current: boolean }) => s.current); + await api().delete(`/api/v1/users/me/sessions/${current.id}`).set('Cookie', cookie).expect(403); + }); + + it('revokes all other sessions at once', async () => { + await login('noch besseres passwort'); + await login('noch besseres passwort'); + await api().delete('/api/v1/users/me/sessions').set('Cookie', cookie).expect(204); + const res = await api().get('/api/v1/users/me/sessions').set('Cookie', cookie).expect(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].current).toBe(true); + }); +}); diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..cf97525 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,95 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + NotFoundException, + Param, + Patch, + Post, + Req, +} from '@nestjs/common'; +import { + CurrentUser as CurrentUserShape, + changePasswordInputSchema, + updateProfileInputSchema, +} from '@dorfteich/shared'; + +import { AuthedRequest, toCurrentUser } from '../auth/auth.guard'; +import { SessionsService } from '../auth/sessions.service'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { UsersService } from './users.service'; + +export interface SessionView { + id: string; + createdAt: string; + lastSeenAt: string; + userAgent: string | null; + current: boolean; +} + +/** Self-service endpoints for the signed-in account (issues #17, #18). */ +@Controller('users/me') +export class UsersController { + constructor( + private readonly users: UsersService, + private readonly sessions: SessionsService, + ) {} + + @Patch() + async updateProfile( + @Body(new ZodValidationPipe(updateProfileInputSchema)) + input: { displayName?: string; locale?: 'de' | 'en' }, + @Req() request: AuthedRequest, + ): Promise { + const updated = await this.users.updateProfile(request.user!.id, input); + return toCurrentUser(updated); + } + + @Post('change-password') + @HttpCode(204) + async changePassword( + @Body(new ZodValidationPipe(changePasswordInputSchema)) + input: { currentPassword: string; newPassword: string }, + @Req() request: AuthedRequest, + ): Promise { + const userId = request.user!.id; + if (!(await this.users.checkPassword(userId, input.currentPassword))) { + throw new ForbiddenException({ code: 'password_incorrect' }); + } + await this.users.setPassword(userId, input.newPassword); + // Every other device gets logged out; the current session stays. + await this.sessions.destroyAllForUser(userId, request.sessionId); + } + + @Get('sessions') + async listSessions(@Req() request: AuthedRequest): Promise { + const sessions = await this.sessions.listForUser(request.user!.id); + return sessions.map((s) => ({ + id: s.id, + createdAt: s.createdAt.toISOString(), + lastSeenAt: s.lastSeenAt.toISOString(), + userAgent: s.userAgent, + current: s.id === request.sessionId, + })); + } + + @Delete('sessions/:id') + @HttpCode(204) + async revokeSession(@Param('id') id: string, @Req() request: AuthedRequest): Promise { + if (id === request.sessionId) { + // The current session is ended via logout, not revocation. + throw new ForbiddenException({ code: 'cannot_revoke_current_session' }); + } + const removed = await this.sessions.destroyById(id, request.user!.id); + if (!removed) throw new NotFoundException(); + } + + @Delete('sessions') + @HttpCode(204) + async revokeOtherSessions(@Req() request: AuthedRequest): Promise { + await this.sessions.destroyAllForUser(request.user!.id, request.sessionId); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 0a2bea4..6ecc4c1 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,8 +1,12 @@ import { Module } from '@nestjs/common'; +import { SessionsModule } from '../auth/sessions.module'; +import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ + imports: [SessionsModule], + controllers: [UsersController], providers: [UsersService], exports: [UsersService], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9632024..795c172 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: rxjs: specifier: ^7.8.0 version: 7.8.2 + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@nestjs/cli': specifier: ^11.0.0