888f66003c
- 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
372 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|