This commit is contained in:
Bastian Wagner
2026-07-16 09:49:22 +02:00
commit 543e8273a7
157 changed files with 22761 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
export class UpdateUserRolesDto {
@IsArray()
@IsString({ each: true })
roleIds!: string[];
}
export class UpdateUserActiveDto {
@IsBoolean()
active!: boolean;
}
export class UpdateSettingsDto {
@IsOptional()
tablePageSize?: number;
@IsOptional()
sidebarExpanded?: boolean;
}

View File

@@ -0,0 +1,24 @@
import {
Column,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('user_settings')
export class UserSettingsEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@OneToOne(() => UserEntity, (user) => user.settings, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: UserEntity;
@Column({ name: 'table_page_size', type: 'int', default: 20 })
tablePageSize!: number;
@Column({ name: 'sidebar_expanded', type: 'boolean', default: true })
sidebarExpanded!: boolean;
}

View File

@@ -0,0 +1,70 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { RoleEntity } from '../../roles/entities/role.entity';
import { SessionEntity } from '../../sessions/entities/session.entity';
import { UserSettingsEntity } from './user-settings.entity';
@Entity('users')
@Unique('uq_users_issuer_subject', ['issuer', 'subject'])
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index('idx_users_issuer')
@Column({ type: 'varchar', length: 255 })
issuer!: string;
@Column({ type: 'varchar', length: 255 })
subject!: string;
@Column({ type: 'varchar', length: 255 })
name!: string;
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ type: 'boolean', default: true })
active!: boolean;
@Column({
name: 'last_login_at',
type: 'datetime',
precision: 3,
nullable: true,
})
lastLoginAt!: Date | null;
@ManyToMany(() => RoleEntity, (role) => role.users, { eager: true })
@JoinTable({
name: 'user_roles',
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
})
roles!: RoleEntity[];
@OneToMany(() => SessionEntity, (session) => session.user)
sessions!: SessionEntity[];
@OneToOne(() => UserSettingsEntity, (settings) => settings.user, {
cascade: true,
eager: true,
})
settings!: UserSettingsEntity;
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'datetime', precision: 3 })
updatedAt!: Date;
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { UserEntity } from '../entities/user.entity';
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
) {}
findById(id: string): Promise<UserEntity | null> {
return this.repo.findOne({ where: { id } });
}
findByIdentity(issuer: string, subject: string): Promise<UserEntity | null> {
return this.repo.findOne({ where: { issuer, subject } });
}
async search(
query: string | undefined,
page: number,
pageSize: number,
): Promise<[UserEntity[], number]> {
const qb = this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.roles', 'role');
if (query) {
qb.where('user.name LIKE :query OR user.email LIKE :query', {
query: `%${query}%`,
});
}
return qb
.orderBy('user.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
}
async save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> {
return (manager?.getRepository(UserEntity) ?? this.repo).save(user);
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { ErrorCode } from '../../common/errors/error-codes';
import { UsersService } from '../users.service';
import type { AuditService } from '../../audit/audit.service';
import type { RolesService } from '../../roles/roles.service';
import type { SessionsService } from '../../sessions/sessions.service';
import type { UsersRepository } from '../repositories/users.repository';
import type { DataSource } from 'typeorm';
describe('UsersService', () => {
it('blocks changes that would remove the last active admin', async () => {
const dataSource = {
getRepository: () => ({
createQueryBuilder: () => ({
innerJoin: () => ({
where: () => ({
andWhere: () => ({
andWhere: () => ({
getCount: () => Promise.resolve(0),
}),
}),
}),
}),
}),
}),
} as unknown as DataSource;
const service = new UsersService(
{} as UsersRepository,
{} as RolesService,
{} as SessionsService,
{} as AuditService,
dataSource,
);
await expect(
service.assertAnotherActiveAdminRemains('user-1'),
).rejects.toMatchObject({
code: ErrorCode.LastAdminRequired,
});
});
});

View File

@@ -0,0 +1,84 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from '../roles/permissions';
import {
UpdateSettingsDto,
UpdateUserActiveDto,
UpdateUserRolesDto,
} from './dto/user.dto';
import { UsersService } from './users.service';
@Controller()
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get('me')
@RequirePermissions(Permission.SessionsReadOwn)
me(@Req() req: AuthenticatedRequest) {
return this.users.get(req.user?.id ?? '');
}
@Patch('me/settings')
@RequirePermissions(Permission.SessionsReadOwn)
updateSettings(
@Req() req: AuthenticatedRequest,
@Body() dto: UpdateSettingsDto,
) {
return this.users.updateSettings(req.user?.id ?? '', dto);
}
@Get('users')
@RequirePermissions(Permission.UsersRead)
list(
@Query('search') search?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.users.list(search, Number(page ?? 1), Number(pageSize ?? 20));
}
@Patch('users/:id/active')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
setActive(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateUserActiveDto,
) {
return this.users.setActive(this.requireUser(req), id, dto.active);
}
@Patch('users/:id/roles')
@RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit()
setRoles(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateUserRolesDto,
) {
return this.users.setRoles(this.requireUser(req), id, dto.roleIds);
}
private requireUser(req: AuthenticatedRequest) {
if (!req.user) {
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich an.',
401,
);
}
return req.user;
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { RolesModule } from '../roles/roles.module';
import { SessionsModule } from '../sessions/sessions.module';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import { UsersRepository } from './repositories/users.repository';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, UserSettingsEntity]),
RolesModule,
AuditModule,
SessionsModule,
],
controllers: [UsersController],
providers: [UsersRepository, UsersService],
exports: [UsersRepository, UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/entities/audit-log.entity';
import type { AuthenticatedUser } from '../auth/authenticated-request';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { RolesService } from '../roles/roles.service';
import { SessionsService } from '../sessions/sessions.service';
import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity';
import { UsersRepository } from './repositories/users.repository';
@Injectable()
export class UsersService {
constructor(
private readonly users: UsersRepository,
private readonly roles: RolesService,
private readonly sessions: SessionsService,
private readonly audit: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async list(search: string | undefined, page = 1, pageSize = 20) {
const [items, total] = await this.users.search(search, page, pageSize);
return { items, total, page, pageSize };
}
async get(id: string): Promise<UserEntity> {
const user = await this.users.findById(id);
if (!user) {
throw new ApiError(
ErrorCode.NotFound,
'Der Benutzer wurde nicht gefunden.',
404,
);
}
return user;
}
async setActive(
actor: AuthenticatedUser,
userId: string,
active: boolean,
): Promise<UserEntity> {
const user = await this.get(userId);
if (!active) {
await this.assertAnotherActiveAdminRemains(userId);
}
user.active = active;
const saved = await this.users.save(user);
if (!active) {
await this.sessions.revokeAllForUser(userId);
}
await this.audit.record(
actor.id,
active ? AuditAction.UserActivated : AuditAction.UserDeactivated,
'user',
userId,
);
return saved;
}
async setRoles(
actor: AuthenticatedUser,
userId: string,
roleIds: string[],
): Promise<UserEntity> {
const user = await this.get(userId);
const previousAdmin = this.hasRole(user, 'admin');
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id)),
);
user.roles = roles;
if (previousAdmin && !this.hasRole(user, 'admin')) {
await this.assertAnotherActiveAdminRemains(userId);
}
const saved = await this.users.save(user);
await this.audit.record(
actor.id,
AuditAction.UserRoleAssigned,
'user',
userId,
{
roleIds: roleIds.join(','),
},
);
return saved;
}
async updateSettings(
userId: string,
settings: Partial<
Pick<UserSettingsEntity, 'tablePageSize' | 'sidebarExpanded'>
>,
): Promise<UserEntity> {
const user = await this.get(userId);
user.settings.tablePageSize =
settings.tablePageSize ?? user.settings.tablePageSize;
user.settings.sidebarExpanded =
settings.sidebarExpanded ?? user.settings.sidebarExpanded;
return this.users.save(user);
}
async assertAnotherActiveAdminRemains(excludedUserId: string): Promise<void> {
const result = await this.dataSource
.getRepository(UserEntity)
.createQueryBuilder('user')
.innerJoin('user.roles', 'role')
.where('user.active = :active', { active: true })
.andWhere('user.id <> :excludedUserId', { excludedUserId })
.andWhere('role.name = :role', { role: 'admin' })
.getCount();
if (result < 1) {
throw new ApiError(
ErrorCode.LastAdminRequired,
'Mindestens ein anderer aktiver Administrator muss erhalten bleiben.',
409,
);
}
}
private hasRole(user: UserEntity, roleName: string): boolean {
return user.roles.some((role) => role.name === roleName);
}
}