initial
This commit is contained in:
20
apps/backend/src/users/dto/user.dto.ts
Normal file
20
apps/backend/src/users/dto/user.dto.ts
Normal 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;
|
||||
}
|
||||
24
apps/backend/src/users/entities/user-settings.entity.ts
Normal file
24
apps/backend/src/users/entities/user-settings.entity.ts
Normal 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;
|
||||
}
|
||||
70
apps/backend/src/users/entities/user.entity.ts
Normal file
70
apps/backend/src/users/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
43
apps/backend/src/users/repositories/users.repository.ts
Normal file
43
apps/backend/src/users/repositories/users.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
41
apps/backend/src/users/tests/users.service.spec.ts
Normal file
41
apps/backend/src/users/tests/users.service.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
84
apps/backend/src/users/users.controller.ts
Normal file
84
apps/backend/src/users/users.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
23
apps/backend/src/users/users.module.ts
Normal file
23
apps/backend/src/users/users.module.ts
Normal 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 {}
|
||||
127
apps/backend/src/users/users.service.ts
Normal file
127
apps/backend/src/users/users.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user