import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, HttpCode, Headers, HttpStatus, NotFoundException, Param, Patch, Post, Res, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { Role } from '@prisma/client'; import { compareReleaseVersions, type DashboardBackground, parseDashboardBackground, parseReleaseVersion, type ReleaseNoticeResponse, } from '@tessera/shared'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { Response } from 'express'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import { RolesGuard } from '../auth/guards/roles.guard'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { PrismaService } from '../prisma/prisma.service'; import { getRunningRelease } from '../health/app-version'; import { CreateUserDto } from './dto/create-user.dto'; import { ReleaseSeenDto } from './dto/release-seen.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserService } from './user.service'; import { WelcomeMailService } from './welcome-mail.service'; /** Map accepted MIME types to file extensions (T-gbh-01). */ const AVATAR_MIME_TO_EXT: Record = { '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'); } /** * Bindung an forTenant() (WINDOWS #20 Etappe 2, 260910-das, Aufgabe 3): alle * sieben Zugriffe dieses Controllers laufen entweder direkt ueber einen * gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`, * `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden * von `UserService`, deren Rumpf je Mandant gebunden ist. * * quick-260925-bow: dazu kommen drei gebundene Zugriffe der beiden * Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen` * ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit * `where: { id: currentUser.id }`. * * quick-260928-ujj: dazu kommt ein gebundener Zugriff des * Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls * `forTenant()` mit `where: { id: currentUser.id }`. * * Willkommensmail: `POST :id/welcome-mail` loest den Zielbenutzer ueber * `resolveTargetUser` auf; die beiden Schreibzugriffe (Token, Versandzeit) * liegen gebunden in `WelcomeMailService`, nicht in diesem Controller. */ @Controller('users') @UseGuards(RolesGuard) export class UserController { constructor( private readonly userService: UserService, private readonly prisma: PrismaService, private readonly welcomeMailService: WelcomeMailService, ) {} /** * Loest den Zielbenutzer rollenabhaengig auf (260910-das, Aufgabe 3): ein * Mandanten-Administrator sieht nur den eigenen Mandanten (gebunden ueber * `UserService.findById`), die oberste Rolle (SUPER_ADMIN) behaelt die * uebergreifende Sicht ueber `UserService.findByIdForPlatformAdmin()` -- * diese Verzweigung ist die Stelle, an der dieser Bereich die gewollte * uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie * darf nicht eingeebnet werden. */ private async resolveTargetUser(currentUser: AuthUser, id: string) { if (currentUser.role === Role.SUPER_ADMIN) { return this.userService.findByIdForPlatformAdmin(id); } return this.userService.findById(currentUser.tenantId, id); } /** * 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: AuthUser) { if (currentUser.role === Role.SUPER_ADMIN) { // Plattform-Administratorsicht (Befund F): die bestehende, gewollte // Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die // Schleife-je-Mandant-gebunden aus UserService.findAllForPlatformAdmin() // statt ueber ein ungebundenes findMany(). return this.userService.findAllForPlatformAdmin(); } // ADMIN: gebunden an den eigenen Mandanten. Die vorhandene // Mandantenbedingung im where BLEIBT erhalten -- nicht entfernen mit // dem Argument, das mache jetzt die Datenbank; dieselbe Regel, die die // Bereiche `tenders` und `dkv` aufgestellt haben. const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); return tenantPrisma.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, welcomeMailSentAt: true, }, orderBy: { username: 'asc' }, }); } /** * GET /users/me/release-notice (quick-260925-bow, D-03/D-04) * * Grundlage des "Was ist neu"-Fensters: die laufende freigegebene Version * der API (`getRunningRelease()`, `null` auf `dev`-Staenden) und der * gemerkte Stand des angemeldeten Benutzers (`null` = Bestandsbenutzer). * Welche Abschnitte der Aenderungsliste gezeigt werden, entscheidet das Web. * * Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung: gelesen wird * ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`) ueber * `forTenant(this.prisma, currentUser.tenantId)`; es gibt keinen * Kennungsparameter. Statische `me/...`-Route steht VOR der Kennungs-Route (GET :id). */ @Get('me/release-notice') async getReleaseNotice(@CurrentUser() currentUser: AuthUser): Promise { const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); const user = await tenantPrisma.user.findUnique({ where: { id: currentUser.id }, select: { lastSeenReleaseVersion: true }, }); if (!user) { throw new NotFoundException('User not found'); } return { currentRelease: getRunningRelease(), lastSeenReleaseVersion: user.lastSeenReleaseVersion, }; } /** * POST /users/me/release-seen (quick-260925-bow, D-05) * * Merkt beim Schliessen des Fensters die gesehene Version. Angenommen wird * nur die kanonische Form `X.Y.Z`, die nicht ueber der laufenden Version * liegt; ohne freigegebene laufende Version (`dev`) wird nichts gemerkt. * Ein gemerkter Stand wird nie abgesenkt; ein unparsebarer gemerkter Wert * wird ueberschrieben. Die DTO prueft das Format schon in der * ValidationPipe; die Pruefungen im Rumpf gelten zusaetzlich (Unit-Tests * rufen die Methode ohne Pipe auf). * * Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung wie oben: * Lesen und Schreiben nur der eigenen Zeile ueber den an * `currentUser.tenantId` gebundenen Klienten. */ @Post('me/release-seen') @HttpCode(HttpStatus.OK) async markReleaseSeen( @Body() body: ReleaseSeenDto, @CurrentUser() currentUser: AuthUser, ): Promise<{ success: true; lastSeenReleaseVersion: string }> { const version: unknown = body?.version; if (typeof version !== 'string' || parseReleaseVersion(version) !== version) { throw new BadRequestException('Invalid version. Use X.Y.Z.'); } const running = getRunningRelease(); if (running === null) { throw new BadRequestException('No released version is running.'); } if (compareReleaseVersions(version, running) > 0) { throw new BadRequestException('Version is newer than the running version.'); } const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); const user = await tenantPrisma.user.findUnique({ where: { id: currentUser.id }, select: { lastSeenReleaseVersion: true }, }); if (!user) { throw new NotFoundException('User not found'); } const stored = user.lastSeenReleaseVersion; if ( stored !== null && parseReleaseVersion(stored) !== null && compareReleaseVersions(version, stored) <= 0 ) { return { success: true, lastSeenReleaseVersion: stored }; } await tenantPrisma.user.update({ where: { id: currentUser.id }, data: { lastSeenReleaseVersion: version }, }); return { success: true, lastSeenReleaseVersion: version }; } /** * GET /users/welcome-mail/status (Willkommensmail) * * Ob fuer den Mandanten des Aufrufers ein Versandweg eingerichtet ist — * die Oberflaeche deaktiviert damit den Knopf "Willkommensmail senden" * und nennt den Grund. Die wirksame Pruefung bleibt beim Versand selbst * (`POST :id/welcome-mail`, dort je Mandant des ZIELS). Statische Route * steht VOR der Kennungs-Route (GET :id). */ @Get('welcome-mail/status') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async getWelcomeMailStatus( @CurrentUser() currentUser: AuthUser, ): Promise<{ available: boolean }> { return { available: await this.welcomeMailService.isAvailable(currentUser.tenantId) }; } /** * GET /users/:id */ @Get(':id') @Roles(Role.ADMIN, Role.SUPER_ADMIN) async findOne(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) { const user = await this.resolveTargetUser(currentUser, 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: AuthUser) { // 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: AuthUser, ) { const user = await this.resolveTargetUser(currentUser, 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'); } // Zielrollen-Riegel (WINDOWS #29, 260914-ebg): die Pruefung unten sichert // nur die NEUE Zuweisung der obersten Rolle (dto.role) — dieser Riegel // sichert das ZIEL, das die oberste Rolle bereits traegt, gegen JEDES // Feld dieses DTO (Kennwort, isActive, Rolle, Anmeldename, E-Mail). // Vorlage: `AuthService.adminResetPassword` (T-FH9-04). Die // Mandantengrenze bleibt DAVOR, damit die Meldung nichts ueber die // Rolle eines fremdmandantigen Benutzers verraet (T-EBG-04). if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) { throw new ForbiddenException('Cannot modify a SUPER_ADMIN user'); } // 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'); } // Der Schreibzugriff bindet an den Mandanten des ZIELBENUTZERS, wie er // aus der vorangegangenen Aufloesung hervorgeht — NICHT an den des // Aufrufers (260910-das, Aufgabe 3). Nur so bleibt die uebergreifende // Verwaltung durch die oberste Rolle erhalten und ist der // Schreibzugriff trotzdem gebunden. const updated = await this.userService.update(user.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; } /** * POST /users/:id/welcome-mail (Willkommensmail) * * Schickt einem Benutzer (egal ob schon angemeldet) eine * Willkommensmail (Adresse, Benutzername, Anmeldehinweis je Kontoart) und * merkt den Zeitpunkt in `welcomeMailSentAt`. Dieselben Regeln wie beim * Bearbeiten: ADMIN nur im eigenen Mandanten, SUPER_ADMIN uebergreifend; * ein Nicht-SUPER_ADMIN darf einem SUPER_ADMIN nichts schicken * (Zielrollen-Riegel wie in update()). Deaktiviert -> 409, ohne * Adresse -> 400, ohne Versandweg -> 409, Versandfehler -> 502 (alles in * `WelcomeMailService`). Der Origin-Kopf dient nur als Rueckfall fuer die * Adresse in der Mail, wenn `TESSERA_APP_URL` fehlt. */ @Post(':id/welcome-mail') @HttpCode(HttpStatus.OK) @Roles(Role.ADMIN, Role.SUPER_ADMIN) async sendWelcomeMail( @Param('id') id: string, @CurrentUser() currentUser: AuthUser, @Headers('origin') origin?: string, ): Promise<{ success: true; to: string; welcomeMailSentAt: Date }> { const user = await this.resolveTargetUser(currentUser, id); if (!user) { throw new NotFoundException('User not found'); } if ( currentUser.role !== Role.SUPER_ADMIN && user.tenantId !== currentUser.tenantId ) { throw new ForbiddenException('Cannot modify users from other tenants'); } if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) { throw new ForbiddenException('Cannot modify a SUPER_ADMIN user'); } const result = await this.welcomeMailService.send(user, origin); return { success: true, ...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: AuthUser) { const user = await this.resolveTargetUser(currentUser, id); if (!user) { throw new NotFoundException('User not found'); } // Cannot delete self (260910-das, Befund H): dieser Vergleich verglich // bisher gegen `currentUser.sub` — ein Feld, das der Sitzungsnachweis // GAR NICHT traegt (JwtStrategy.validate() liefert exakt { id, // username, role, tenantId }). Der Riegel hat deshalb NIE gegriffen: // ein Administrator konnte sich selbst loeschen und seinen Mandanten // ohne Verwaltung zuruecklassen. Die Reparatur ist eine // Verhaltensaenderung: ein Administrator kann sein eigenes Konto nun // nicht mehr loeschen — das ist die urspruengliche, im Code bereits // formulierte Absicht. if (user.id === currentUser.id) { 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'); } // Zielrollen-Riegel (WINDOWS #29, 260914-ebg): derselbe Riegel wie in // update() oben — ein Nicht-SUPER_ADMIN darf den SUPER_ADMIN seines // Mandanten nicht loeschen. if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) { throw new ForbiddenException('Cannot delete a SUPER_ADMIN user'); } // Gebunden an den Mandanten des ZIELBENUTZERS, derselbe Grund wie bei // update() oben. await this.userService.delete(user.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. // // Alle fuenf Zugriffe binden vollstaendig an die Mandantenkennung aus dem // Sitzungsnachweis (260910-das, Befund G, Aufgabe 3): der angemeldete // Benutzer liegt per Definition im Mandanten seiner eigenen Sitzung, die // Bindung aendert an Pfaden, Dateityp-Pruefung, Groessenbegrenzung und dem // Aufraeumen alter Bilddateien nichts. /** * 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: UploadedFileLike | undefined, @CurrentUser() currentUser: AuthUser, ) { if (!file?.buffer) { throw new BadRequestException('No file provided'); } const ext = AVATAR_MIME_TO_EXT[file.mimetype]; 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); // Persist relative path (relative to monorepo root) const relativePath = path.join('user-files', 'avatars', filename); const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); await tenantPrisma.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: AuthUser) { const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); const user = await tenantPrisma.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 tenantPrisma.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: AuthUser, ) { if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) { throw new BadRequestException('Invalid color format. Use hex (#rrggbb).'); } const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); await tenantPrisma.user.update({ where: { id: currentUser.id }, data: { accentColor: body.color ?? null }, }); return { success: true }; } /** * PATCH /users/me/dashboard-background (quick-260928-ujj) * * Speichert den gewaehlten Dashboard-Hintergrund des angemeldeten * Benutzers. Jeder angemeldete Benutzer, kein `@Roles`. * * T-ujj-01 (Tampering): der Wert wird spaeter als CSS-Hintergrund * gerendert. `parseDashboardBackground` (@tessera/shared) laesst nur * `kind` none/preset/image, bekannte Preset-Kennungen und eine UUID als * Bildkennung zu und baut ein frisches Objekt ohne Zusatzschluessel; * alles andere ergibt 400 ohne Schreibzugriff. Bewusst Inline-Body-Typ * statt DTO-Klasse (wie `me/accent-color`): die globale ValidationPipe mit * `whitelist` wuerde das verschachtelte Objekt sonst nicht pruefen. * * T-ujj-02 (Elevation of Privilege): kein Kennungsparameter; geschrieben * wird ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`) * ueber `forTenant(this.prisma, currentUser.tenantId)`. */ @Patch('me/dashboard-background') async updateDashboardBackground( @Body() body: { background: unknown }, @CurrentUser() currentUser: AuthUser, ): Promise<{ success: true; dashboardBackground: DashboardBackground }> { const background = parseDashboardBackground(body?.background); if (background === null) { throw new BadRequestException('Invalid dashboard background.'); } const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); await tenantPrisma.user.update({ where: { id: currentUser.id }, data: { dashboardBackground: background }, }); return { success: true, dashboardBackground: background }; } /** * 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: AuthUser, @Res() res: Response, ) { const tenantPrisma = forTenant(this.prisma, currentUser.tenantId); const user = await tenantPrisma.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 = { 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); } }