32441d77c7
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar), GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile, Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig). Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste: Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert apps/api/assets. Lokal per MailHog nachgewiesen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
637 lines
23 KiB
TypeScript
637 lines
23 KiB
TypeScript
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<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');
|
|
}
|
|
|
|
/**
|
|
* 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<ReleaseNoticeResponse> {
|
|
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<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);
|
|
}
|
|
}
|