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",
|
||||
"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",
|
||||
|
||||
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 { 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) => ({
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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<void> {
|
||||
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' {
|
||||
|
||||
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 { SessionsModule } from '../auth/sessions.module';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
imports: [SessionsModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user