feat(02-02): user CRUD API + admin page, tenant CRUD API + admin page
- Create UserController with GET/POST/PATCH/DELETE endpoints at /users - ADMIN sees own-tenant users only; SUPER_ADMIN sees all (T-02-10) - ADMIN cannot escalate to SUPER_ADMIN role (T-02-08) - ADMIN cannot delete self or cross-tenant users - Create TenantController with GET/POST/PATCH/DELETE at /tenants - SUPER_ADMIN-only access (D-10) - Tenant deletion blocked if active users exist (T-02-09) - Create CreateUserDto, UpdateUserDto, CreateTenantDto with class-validator - Create admin/users page with user table, create/edit/delete modals - Create admin/tenants page with tenant table, create/edit/deactivate (SUPER_ADMIN only) - Add admin section to sidebar: Verwaltung > Benutzer + Mandanten - Verwaltung visible for ADMIN/SUPER_ADMIN; Tenants link SUPER_ADMIN only - Install @nestjs/mapped-types for PartialType DTO pattern Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class CreateTenantDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
slug!: string;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||
import { TenantService } from './tenant.service';
|
||||
|
||||
/**
|
||||
* Tenant CRUD controller.
|
||||
* D-10: Only Super-Admin can manage tenants.
|
||||
* T-02-09: Tenant deletion blocked if active users exist.
|
||||
*/
|
||||
@Controller('tenants')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.SUPER_ADMIN)
|
||||
export class TenantController {
|
||||
constructor(
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /tenants
|
||||
* Returns all tenants with user count.
|
||||
*/
|
||||
@Get()
|
||||
async findAll() {
|
||||
const tenants = await this.prisma.tenant.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { users: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return tenants.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
isActive: t.isActive,
|
||||
createdAt: t.createdAt,
|
||||
userCount: t._count.users,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /tenants/:id
|
||||
* Returns single tenant with user count.
|
||||
*/
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { users: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
isActive: tenant.isActive,
|
||||
createdAt: tenant.createdAt,
|
||||
userCount: tenant._count.users,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tenants
|
||||
* Create a new tenant.
|
||||
*/
|
||||
@Post()
|
||||
async create(@Body() dto: CreateTenantDto) {
|
||||
return this.tenantService.create({
|
||||
name: dto.name,
|
||||
slug: dto.slug,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /tenants/:id
|
||||
* Update tenant name or isActive.
|
||||
*/
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { name?: string; isActive?: boolean },
|
||||
) {
|
||||
const existing = await this.tenantService.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
|
||||
return this.tenantService.update(id, {
|
||||
name: dto.name,
|
||||
isActive: dto.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /tenants/:id
|
||||
* T-02-09: Only delete if no active users exist.
|
||||
*/
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
users: { where: { isActive: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
|
||||
if (tenant._count.users > 0) {
|
||||
throw new BadRequestException(
|
||||
'Cannot delete tenant with active users. Deactivate or reassign users first.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.tenant.delete({ where: { id } });
|
||||
return { message: 'Tenant deleted' };
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TenantController } from './tenant.controller';
|
||||
import { TenantService } from './tenant.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TenantController],
|
||||
providers: [TenantService],
|
||||
exports: [TenantService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
displayName?: string;
|
||||
|
||||
@IsEnum(Role)
|
||||
@IsOptional()
|
||||
role?: Role;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
tenantId?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
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';
|
||||
|
||||
@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) {
|
||||
const user = await this.userService.findById(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,
|
||||
) {
|
||||
const user = await this.userService.findById(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(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) {
|
||||
const user = await this.userService.findById(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(id);
|
||||
return { message: 'User deleted' };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminSeedService } from './admin-seed.service';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UserController],
|
||||
providers: [UserService, AdminSeedService],
|
||||
exports: [UserService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user