Files
tessera-ctl/apps/api/src/user/user.controller.ts
T
schalli 888f66003c feat(quick-260910-das): user-Dienst binden, Linie ziehen, Startsperre entschaerfen
- user.service.ts: findById/update/deactivate/delete bekommen einen
  Pflicht-Mandanten und laufen ueber forTenant(); create/update
  uebersetzen die plattformweite Eindeutigkeitsverletzung (P2002) in eine
  deutsche Konfliktmeldung ohne Halter/Mandant zu nennen; zwei neue
  Methoden (findAllForPlatformAdmin, findByIdForPlatformAdmin) bilden die
  Plattform-Administratorsicht als Schleife ueber alle Mandanten mit je
  einem gebundenen Lesezugriff nach; findByUsername bleibt bewusst
  ungebunden, Kopfkommentar richtiggestellt (Anmeldeweg laeuft seit
  Etappe 1 ueber SECURITY-DEFINER-Funktionen, kein Aufrufer mehr)
- admin-seed.service.ts: Erstanlage-Pruefung bleibt ungebunden (mit
  Begruendung), Erstanlage des Administrators bindet an den unmittelbar
  zuvor angelegten Mandanten (Befund J-Korrektur); P2002 bei der Anlage
  wird wie "Administrator existiert bereits" behandelt statt den Start
  abzubrechen -- jeder andere Fehler bricht weiterhin ab
- user.controller.ts: die vier Aufrufstellen der geaenderten Signaturen
  auf currentUser.tenantId umgestellt (Signatur-Minimalanpassung; die
  Rollenlogik inkl. Plattform-Administratorsicht folgt in Aufgabe 3)
- Zwei-Klienten-Nachweis in beiden Testdateien (Muster
  groups.service.spec.ts), Falsifizierungsnachweis fuer beide Bereiche
  durchgefuehrt und zurueckgenommen (siehe SUMMARY)
- docs/mandantentrennung-zugriffsklassifikation.md: Zwischenstand fuer
  (user.service.ts, user) und (admin-seed.service.ts, user) auf gemischt
  korrigiert, neue Zeile (user.service.ts, tenant) ergaenzt -- volle
  Klassenkorrektur mit Begruendung sowie die vier handgepflegten
  Uebersichtstabellen folgen in Aufgabe 3
- .planning/WINDOWS.md: offener Eintrag fuer die plattformweite
  Eindeutigkeit von username/email (Produktentscheidung fuer Etappe 3)
- 802 Tests gruen (13 neue in user.service.spec.ts, 4 neue in
  admin-seed.service.spec.ts), Typpruefung sauber, Wegwerf-Werkzeug
  meldet weiterhin alle 53 Pruefungen bestanden

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-10 10:27:14 +02:00

372 lines
11 KiB
TypeScript

import {
BadRequestException,
Body,
Controller,
Delete,
ForbiddenException,
Get,
NotFoundException,
Param,
Patch,
Post,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
/** Map accepted MIME types to file extensions (T-gbh-01). */
const AVATAR_MIME_TO_EXT: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
};
/** Resolve the avatars storage directory relative to the monorepo root.
* At runtime __dirname = apps/api/dist/user/ → go up 4 levels. */
function resolveAvatarsDir(): string {
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'avatars');
}
@Controller('users')
@UseGuards(RolesGuard)
export class UserController {
constructor(
private readonly userService: UserService,
private readonly prisma: PrismaService,
) {}
/**
* GET /users
* ADMIN sees own-tenant users only. SUPER_ADMIN sees all users.
* T-02-10: ADMIN filtered by tenant at controller level.
*/
@Get()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findAll(@CurrentUser() currentUser: any) {
if (currentUser.role === Role.SUPER_ADMIN) {
return this.prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
displayName: true,
role: true,
isActive: true,
tenantId: true,
createdAt: true,
lastLoginAt: true,
},
orderBy: { username: 'asc' },
});
}
// ADMIN: filter by own tenant
return this.prisma.user.findMany({
where: { tenantId: currentUser.tenantId },
select: {
id: true,
username: true,
email: true,
displayName: true,
role: true,
isActive: true,
tenantId: true,
createdAt: true,
lastLoginAt: true,
},
orderBy: { username: 'asc' },
});
}
/**
* GET /users/:id
*/
@Get(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
// 260910-das, Aufgabe 2: UserService.findById() bekommt einen
// Pflicht-Mandanten (siehe user.service.ts). Nur die Signatur wird hier
// nachgezogen, damit die Typpruefung sauber bleibt -- die Rollenlogik
// (insbesondere die uebergreifende SUPER_ADMIN-Sicht ueber
// findByIdForPlatformAdmin()) wird erst in Aufgabe 3 vollstaendig
// verdrahtet.
const user = await this.userService.findById(currentUser.tenantId, id);
if (!user) {
throw new NotFoundException('User not found');
}
// ADMIN can only view users in own tenant
if (
currentUser.role !== Role.SUPER_ADMIN &&
user.tenantId !== currentUser.tenantId
) {
throw new ForbiddenException('Cannot access users from other tenants');
}
const { passwordHash, ...result } = user;
return result;
}
/**
* POST /users
* T-02-08: ADMIN can only create users in own tenant; cannot set SUPER_ADMIN role.
*/
@Post()
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) {
// ADMIN can only create users in own tenant
const tenantId =
currentUser.role === Role.SUPER_ADMIN && dto.tenantId
? dto.tenantId
: currentUser.tenantId;
// T-02-08: ADMIN cannot create SUPER_ADMIN users
if (currentUser.role !== Role.SUPER_ADMIN && dto.role === Role.SUPER_ADMIN) {
throw new ForbiddenException('Cannot assign SUPER_ADMIN role');
}
const user = await this.userService.create({
username: dto.username,
email: dto.email,
password: dto.password,
displayName: dto.displayName,
role: dto.role ?? Role.USER,
tenantId,
});
const { passwordHash, ...result } = user;
return result;
}
/**
* PATCH /users/:id
* T-02-08: ADMIN cannot escalate to SUPER_ADMIN or modify other tenants' users.
*/
@Patch(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async update(
@Param('id') id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() currentUser: any,
) {
// 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben.
const user = await this.userService.findById(currentUser.tenantId, id);
if (!user) {
throw new NotFoundException('User not found');
}
// ADMIN can only update users in own tenant
if (
currentUser.role !== Role.SUPER_ADMIN &&
user.tenantId !== currentUser.tenantId
) {
throw new ForbiddenException('Cannot modify users from other tenants');
}
// T-02-08: ADMIN cannot set role to SUPER_ADMIN
if (currentUser.role !== Role.SUPER_ADMIN && dto.role === Role.SUPER_ADMIN) {
throw new ForbiddenException('Cannot assign SUPER_ADMIN role');
}
const updated = await this.userService.update(currentUser.tenantId, id, {
username: dto.username,
email: dto.email,
password: dto.password,
displayName: dto.displayName,
role: dto.role,
isActive: dto.isActive,
});
const { passwordHash, ...result } = updated;
return result;
}
/**
* DELETE /users/:id
* ADMIN cannot delete self or users from other tenants.
*/
@Delete(':id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
// 260910-das, Aufgabe 2: siehe Kommentar in findOne() oben.
const user = await this.userService.findById(currentUser.tenantId, id);
if (!user) {
throw new NotFoundException('User not found');
}
// Cannot delete self
if (user.id === currentUser.sub) {
throw new ForbiddenException('Cannot delete your own account');
}
// ADMIN can only delete users in own tenant
if (
currentUser.role !== Role.SUPER_ADMIN &&
user.tenantId !== currentUser.tenantId
) {
throw new ForbiddenException('Cannot delete users from other tenants');
}
await this.userService.delete(currentUser.tenantId, id);
return { message: 'User deleted' };
}
// ─── Self-service avatar endpoints (all authenticated roles) ───────────────
// No @Roles() → RolesGuard.canActivate() returns true when requiredRoles is
// empty (see guards/roles.guard.ts). Global JwtAuthGuard still enforces auth.
/**
* POST /users/me/avatar
* Upload or replace the current user's profile picture.
* T-gbh-01: 2 MB size limit + image-only MIME allowlist.
* T-gbh-02: userId derived from @CurrentUser() only — never from request body.
* T-gbh-04: filename = {userId}.{ext} derived from MIME — no path traversal.
*/
@Post('me/avatar')
@UseInterceptors(
FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }),
)
async uploadAvatar(
@UploadedFile() file: any,
@CurrentUser() currentUser: any,
) {
if (!file || !file.buffer) {
throw new BadRequestException('No file provided');
}
const ext = AVATAR_MIME_TO_EXT[file.mimetype as string];
if (!ext) {
throw new BadRequestException(
'Invalid file type. Allowed: image/png, image/jpeg, image/webp',
);
}
const avatarsDir = resolveAvatarsDir();
fs.mkdirSync(avatarsDir, { recursive: true });
const filename = `${currentUser.id}.${ext}`;
const filePath = path.join(avatarsDir, filename);
// Remove any previous avatar files for this user (different extension)
for (const existingExt of Object.values(AVATAR_MIME_TO_EXT)) {
const candidate = path.join(avatarsDir, `${currentUser.id}.${existingExt}`);
if (candidate !== filePath && fs.existsSync(candidate)) {
fs.unlinkSync(candidate);
}
}
fs.writeFileSync(filePath, file.buffer as Buffer);
// Persist relative path (relative to monorepo root)
const relativePath = path.join('user-files', 'avatars', filename);
await this.prisma.user.update({
where: { id: currentUser.id },
data: { avatarPath: relativePath },
});
return { success: true };
}
/**
* DELETE /users/me/avatar
* Remove the current user's profile picture.
*/
@Delete('me/avatar')
async deleteAvatar(@CurrentUser() currentUser: any) {
const user = await this.prisma.user.findUnique({
where: { id: currentUser.id },
select: { avatarPath: true },
});
if (user?.avatarPath) {
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..');
const absolutePath = path.join(monorepoRoot, user.avatarPath);
if (fs.existsSync(absolutePath)) {
fs.unlinkSync(absolutePath);
}
await this.prisma.user.update({
where: { id: currentUser.id },
data: { avatarPath: null },
});
}
return { success: true };
}
/**
* PATCH /users/me/accent-color
* Set or clear the current user's accent color.
* T-acc-01: hex color validated server-side.
*/
@Patch('me/accent-color')
async updateAccentColor(
@Body() body: { color: string | null },
@CurrentUser() currentUser: any,
) {
if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) {
throw new BadRequestException('Invalid color format. Use hex (#rrggbb).');
}
await this.prisma.user.update({
where: { id: currentUser.id },
data: { accentColor: body.color ?? null },
});
return { success: true };
}
/**
* GET /users/me/avatar
* Stream the current user's avatar image.
* T-gbh-05: Only the authenticated user's own file is served here.
*/
@Get('me/avatar')
async getAvatar(
@CurrentUser() currentUser: any,
@Res() res: Response,
) {
const user = await this.prisma.user.findUnique({
where: { id: currentUser.id },
select: { avatarPath: true },
});
if (!user?.avatarPath) {
throw new NotFoundException('No avatar set');
}
// Resolve from monorepo root (same upward-walk pattern as DkvService)
const monorepoRoot = path.resolve(__dirname, '..', '..', '..', '..');
const absolutePath = path.join(monorepoRoot, user.avatarPath);
if (!fs.existsSync(absolutePath)) {
throw new NotFoundException('Avatar file not found');
}
const ext = path.extname(absolutePath).slice(1).toLowerCase();
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
webp: 'image/webp',
};
const contentType = mimeTypes[ext] ?? 'application/octet-stream';
const buffer = fs.readFileSync(absolutePath);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'no-store');
res.send(buffer);
}
}