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 <noreply@anthropic.com>
This commit is contained in:
parent
bed9fc9307
commit
1314096c94
@ -28,7 +28,8 @@
|
|||||||
"pino-http": "^10.4.0",
|
"pino-http": "^10.4.0",
|
||||||
"prisma": "^6.3.0",
|
"prisma": "^6.3.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0"
|
"rxjs": "^7.8.0",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
|||||||
46
apps/api/src/admin/admin.controller.ts
Normal file
46
apps/api/src/admin/admin.controller.ts
Normal file
@ -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<InstanceSettingKey, z.ZodOptional<z.ZodUnknown>>,
|
||||||
|
)
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
@Controller('admin/settings')
|
||||||
|
@UseGuards(SiteAdminGuard)
|
||||||
|
export class AdminSettingsController {
|
||||||
|
constructor(private readonly settings: InstanceSettingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getSettings(): Promise<InstanceSettings> {
|
||||||
|
return this.settings.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
async patchSettings(
|
||||||
|
@Body(new ZodValidationPipe(patchSchema)) input: Partial<Record<InstanceSettingKey, unknown>>,
|
||||||
|
@Req() request: AuthedRequest,
|
||||||
|
): Promise<InstanceSettings> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
107
apps/api/src/admin/admin.e2e.db.test.ts
Normal file
107
apps/api/src/admin/admin.e2e.db.test.ts
Normal file
@ -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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
8
apps/api/src/admin/admin.module.ts
Normal file
8
apps/api/src/admin/admin.module.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AdminSettingsController } from './admin.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminSettingsController],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
16
apps/api/src/admin/site-admin.guard.ts
Normal file
16
apps/api/src/admin/site-admin.guard.ts
Normal file
@ -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<AuthedRequest>();
|
||||||
|
if (!request.user?.isSiteAdmin) throw new ForbiddenException();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { APP_FILTER } from '@nestjs/core';
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig } from './config/app-config.service';
|
||||||
@ -10,6 +11,7 @@ import { HealthModule } from './health/health.module';
|
|||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||||
|
import { SettingsModule } from './settings/settings.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -18,8 +20,10 @@ import { UsersModule } from './users/users.module';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
RateLimitModule,
|
RateLimitModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
SettingsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
AdminModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
inject: [AppConfig],
|
inject: [AppConfig],
|
||||||
useFactory: (config: AppConfig) => ({
|
useFactory: (config: AppConfig) => ({
|
||||||
|
|||||||
@ -47,7 +47,6 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
||||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
await prisma.instanceSetting.deleteMany({ where: { key: 'auth.registrationMode' } });
|
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@ -82,17 +81,6 @@ describe.skipIf(!hasTestDb)('auth flows (e2e)', () => {
|
|||||||
expect(user?.status).toBe('ACTIVE');
|
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
|
// ---------------------------------------------------------------- #14
|
||||||
let cookie: string;
|
let cookie: string;
|
||||||
|
|
||||||
|
|||||||
@ -7,19 +7,18 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthGuard } from './auth.guard';
|
import { AuthGuard } from './auth.guard';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthTokensService } from './auth-tokens.service';
|
import { AuthTokensService } from './auth-tokens.service';
|
||||||
import { SessionsService } from './sessions.service';
|
import { SessionsModule } from './sessions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule, MailModule],
|
imports: [UsersModule, MailModule, SessionsModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
AuthTokensService,
|
AuthTokensService,
|
||||||
SessionsService,
|
|
||||||
// Global default-protected: every route needs a session unless it
|
// Global default-protected: every route needs a session unless it
|
||||||
// opts out with @Public().
|
// opts out with @Public().
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
],
|
],
|
||||||
exports: [SessionsService, AuthTokensService],
|
exports: [AuthTokensService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { AppConfig } from '../config/app-config.service';
|
|||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
import { RateLimitService } from '../rate-limit/rate-limit.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuthTokensService } from './auth-tokens.service';
|
import { AuthTokensService } from './auth-tokens.service';
|
||||||
import { SessionsService } from './sessions.service';
|
import { SessionsService } from './sessions.service';
|
||||||
@ -31,13 +32,14 @@ export class AuthService {
|
|||||||
private readonly mail: MailService,
|
private readonly mail: MailService,
|
||||||
private readonly rateLimits: RateLimitService,
|
private readonly rateLimits: RateLimitService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(AuthService.name);
|
this.logger.setContext(AuthService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async signup(input: SignupInput): Promise<void> {
|
async signup(input: SignupInput): Promise<void> {
|
||||||
if ((await this.registrationMode()) === 'closed') {
|
if ((await this.settings.get('auth.registrationMode')) === 'closed') {
|
||||||
throw new ForbiddenException({ code: 'registration_closed' });
|
throw new ForbiddenException({ code: 'registration_closed' });
|
||||||
}
|
}
|
||||||
const user = await this.users.createUser(input);
|
const user = await this.users.createUser(input);
|
||||||
@ -142,17 +144,6 @@ export class AuthService {
|
|||||||
asLocale(user.locale),
|
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' {
|
function asLocale(locale: string): 'de' | 'en' {
|
||||||
|
|||||||
13
apps/api/src/auth/sessions.module.ts
Normal file
13
apps/api/src/auth/sessions.module.ts
Normal file
@ -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 {}
|
||||||
82
apps/api/src/settings/instance-settings.service.ts
Normal file
82
apps/api/src/settings/instance-settings.service.ts
Normal file
@ -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<K extends InstanceSettingKey> = z.infer<
|
||||||
|
(typeof INSTANCE_SETTINGS)[K]
|
||||||
|
>;
|
||||||
|
export type InstanceSettings = { [K in InstanceSettingKey]: InstanceSettingValue<K> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<InstanceSettingKey, unknown>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(InstanceSettingsService.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<K extends InstanceSettingKey>(key: K): Promise<InstanceSettingValue<K>> {
|
||||||
|
if (this.cache.has(key)) return this.cache.get(key) as InstanceSettingValue<K>;
|
||||||
|
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<K>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<InstanceSettings> {
|
||||||
|
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<K extends InstanceSettingKey>(
|
||||||
|
key: K,
|
||||||
|
value: unknown,
|
||||||
|
actorUserId: string,
|
||||||
|
): Promise<InstanceSettingValue<K>> {
|
||||||
|
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<K>;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/settings/settings.module.ts
Normal file
11
apps/api/src/settings/settings.module.ts
Normal file
@ -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 {}
|
||||||
123
apps/api/src/users/account.e2e.db.test.ts
Normal file
123
apps/api/src/users/account.e2e.db.test.ts
Normal file
@ -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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/api/src/users/users.controller.ts
Normal file
95
apps/api/src/users/users.controller.ts
Normal file
@ -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<CurrentUserShape> {
|
||||||
|
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<void> {
|
||||||
|
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<SessionView[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await this.sessions.destroyAllForUser(request.user!.id, request.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SessionsModule } from '../auth/sessions.module';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SessionsModule],
|
||||||
|
controllers: [UsersController],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -74,6 +74,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.0
|
specifier: ^7.8.0
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
zod:
|
||||||
|
specifier: ^3.25.76
|
||||||
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user