bump
This commit is contained in:
@@ -1,4 +1,15 @@
|
||||
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateUserRolesDto {
|
||||
@IsArray()
|
||||
@@ -18,3 +29,41 @@ export class UpdateSettingsDto {
|
||||
@IsOptional()
|
||||
sidebarExpanded?: boolean;
|
||||
}
|
||||
|
||||
export type AdminUserSortField = 'name' | 'email' | 'lastLoginAt' | 'createdAt';
|
||||
export type AdminUserActiveFilter = 'all' | 'active' | 'inactive';
|
||||
|
||||
export class AdminUserListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['all', 'active', 'inactive'])
|
||||
active: AdminUserActiveFilter = 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
roleId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['name', 'email', 'lastLoginAt', 'createdAt'])
|
||||
sort: AdminUserSortField = 'createdAt';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
direction: 'ASC' | 'DESC' = 'DESC';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 25;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import type {
|
||||
AdminUserActiveFilter,
|
||||
AdminUserSortField,
|
||||
} from '../dto/user.dto';
|
||||
import { UserEntity } from '../entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -9,8 +13,10 @@ export class UsersRepository {
|
||||
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
|
||||
) {}
|
||||
|
||||
findById(id: string): Promise<UserEntity | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
findById(id: string, manager?: EntityManager): Promise<UserEntity | null> {
|
||||
return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
findByIdentity(issuer: string, subject: string): Promise<UserEntity | null> {
|
||||
@@ -37,7 +43,70 @@ export class UsersRepository {
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
async adminSearch(
|
||||
query: {
|
||||
search?: string;
|
||||
active: AdminUserActiveFilter;
|
||||
roleId?: string;
|
||||
sort: AdminUserSortField;
|
||||
direction: 'ASC' | 'DESC';
|
||||
page: number;
|
||||
pageSize: number;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<[UserEntity[], number]> {
|
||||
const repo = manager?.getRepository(UserEntity) ?? this.repo;
|
||||
const qb = repo
|
||||
.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.roles', 'role')
|
||||
.leftJoinAndSelect('role.permissions', 'permission');
|
||||
if (query.search) {
|
||||
qb.andWhere('user.name LIKE :search OR user.email LIKE :search', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
if (query.active !== 'all') {
|
||||
qb.andWhere('user.active = :active', {
|
||||
active: query.active === 'active',
|
||||
});
|
||||
}
|
||||
if (query.roleId) {
|
||||
qb.andWhere(
|
||||
'EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id = user.id AND ur.role_id = :roleId)',
|
||||
{ roleId: query.roleId },
|
||||
);
|
||||
}
|
||||
return qb
|
||||
.orderBy(`user.${query.sort}`, query.direction)
|
||||
.skip((query.page - 1) * query.pageSize)
|
||||
.take(query.pageSize)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
findByIdWithRoles(
|
||||
id: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<UserEntity | null> {
|
||||
return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
|
||||
where: { id },
|
||||
relations: { roles: { permissions: true }, settings: true },
|
||||
});
|
||||
}
|
||||
|
||||
async save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> {
|
||||
return (manager?.getRepository(UserEntity) ?? this.repo).save(user);
|
||||
}
|
||||
|
||||
findFirstActiveAdmin(excludedUserId?: string): Promise<UserEntity | null> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('user')
|
||||
.innerJoin('user.roles', 'role')
|
||||
.where('user.active = :active', { active: true })
|
||||
.andWhere('role.name = :role', { role: 'admin' })
|
||||
.orderBy('user.createdAt', 'ASC');
|
||||
if (excludedUserId) {
|
||||
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });
|
||||
}
|
||||
return qb.getOne();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { NotificationsService } from '../../notifications/notifications.service';
|
||||
import type { RolesService } from '../../roles/roles.service';
|
||||
import type { SessionsService } from '../../sessions/sessions.service';
|
||||
import type { UsersRepository } from '../repositories/users.repository';
|
||||
@@ -10,25 +11,28 @@ 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: () => ({
|
||||
manager: {
|
||||
getRepository: () => ({
|
||||
createQueryBuilder: () => ({
|
||||
innerJoin: () => ({
|
||||
where: () => ({
|
||||
andWhere: () => ({
|
||||
getCount: () => Promise.resolve(0),
|
||||
andWhere: () => ({
|
||||
getCount: () => Promise.resolve(0),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
} as unknown as DataSource;
|
||||
const service = new UsersService(
|
||||
{} as UsersRepository,
|
||||
{} as RolesService,
|
||||
{} as SessionsService,
|
||||
{} as AuditService,
|
||||
{} as NotificationsService,
|
||||
dataSource,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
@@ -14,6 +16,7 @@ import { ErrorCode } from '../common/errors/error-codes';
|
||||
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||
import { Permission } from '../roles/permissions';
|
||||
import {
|
||||
AdminUserListQueryDto,
|
||||
UpdateSettingsDto,
|
||||
UpdateUserActiveDto,
|
||||
UpdateUserRolesDto,
|
||||
@@ -49,6 +52,84 @@ export class UsersController {
|
||||
return this.users.list(search, Number(page ?? 1), Number(pageSize ?? 20));
|
||||
}
|
||||
|
||||
@Get('admin/users')
|
||||
@RequirePermissions(Permission.UsersRead)
|
||||
adminList(@Query() query: AdminUserListQueryDto) {
|
||||
return this.users.adminList(query);
|
||||
}
|
||||
|
||||
@Get('admin/users/:id')
|
||||
@RequirePermissions(Permission.UsersRead)
|
||||
adminGet(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.users.adminGet(id, req.user?.sessionId);
|
||||
}
|
||||
|
||||
@Patch('admin/users/:id/deactivate')
|
||||
@RequirePermissions(Permission.UsersManage)
|
||||
@SensitiveRateLimit()
|
||||
deactivate(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.users.setActive(this.requireUser(req), id, false);
|
||||
}
|
||||
|
||||
@Patch('admin/users/:id/activate')
|
||||
@RequirePermissions(Permission.UsersManage)
|
||||
@SensitiveRateLimit()
|
||||
activate(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.users.setActive(this.requireUser(req), id, true);
|
||||
}
|
||||
|
||||
@Post('admin/users/:id/roles/:roleId')
|
||||
@RequirePermissions(Permission.UsersManage)
|
||||
@SensitiveRateLimit()
|
||||
assignRole(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('roleId') roleId: string,
|
||||
) {
|
||||
return this.users.assignRole(this.requireUser(req), id, roleId);
|
||||
}
|
||||
|
||||
@Delete('admin/users/:id/roles/:roleId')
|
||||
@RequirePermissions(Permission.UsersManage)
|
||||
@SensitiveRateLimit()
|
||||
removeRole(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Param('roleId') roleId: string,
|
||||
) {
|
||||
return this.users.removeRole(this.requireUser(req), id, roleId);
|
||||
}
|
||||
|
||||
@Get('admin/users/:id/sessions')
|
||||
@RequirePermissions(Permission.SessionsManage)
|
||||
adminSessions(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.users
|
||||
.adminGet(id, req.user?.sessionId)
|
||||
.then((user) => user.sessions);
|
||||
}
|
||||
|
||||
@Delete('admin/users/:userId/sessions/:sessionId')
|
||||
@RequirePermissions(Permission.SessionsManage)
|
||||
@SensitiveRateLimit()
|
||||
revokeSession(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('userId') userId: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
) {
|
||||
return this.users.revokeUserSession(
|
||||
this.requireUser(req),
|
||||
userId,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('admin/users/:id/sessions')
|
||||
@RequirePermissions(Permission.SessionsManage)
|
||||
@SensitiveRateLimit()
|
||||
revokeSessions(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.users.revokeUserSessions(this.requireUser(req), id);
|
||||
}
|
||||
|
||||
@Patch('users/:id/active')
|
||||
@RequirePermissions(Permission.UsersManage)
|
||||
@SensitiveRateLimit()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { RolesModule } from '../roles/roles.module';
|
||||
import { SessionsModule } from '../sessions/sessions.module';
|
||||
import { UserSettingsEntity } from './entities/user-settings.entity';
|
||||
@@ -15,6 +16,7 @@ import { UsersService } from './users.service';
|
||||
RolesModule,
|
||||
AuditModule,
|
||||
SessionsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersRepository, UsersService],
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, EntityManager } 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 { NotificationType } from '../notifications/notification-types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { RolesService } from '../roles/roles.service';
|
||||
import { RoleEntity } from '../roles/entities/role.entity';
|
||||
import { SessionsService } from '../sessions/sessions.service';
|
||||
import type { AdminSessionDto } from '../sessions/sessions.service';
|
||||
import { UserSettingsEntity } from './entities/user-settings.entity';
|
||||
import { UserEntity } from './entities/user.entity';
|
||||
import type { AdminUserListQueryDto } from './dto/user.dto';
|
||||
import { UsersRepository } from './repositories/users.repository';
|
||||
|
||||
interface AdminRoleSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
system: boolean;
|
||||
}
|
||||
|
||||
interface AdminUserListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
active: boolean;
|
||||
roles: AdminRoleSummary[];
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
activeSessionCount: number;
|
||||
}
|
||||
|
||||
interface AdminUserDetail extends AdminUserListItem {
|
||||
effectivePermissions: string[];
|
||||
sessions: AdminSessionDto[];
|
||||
settings: { tablePageSize: number; sidebarExpanded: boolean };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly users: UsersRepository,
|
||||
private readonly roles: RolesService,
|
||||
private readonly sessions: SessionsService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly notifications: NotificationsService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -27,6 +59,34 @@ export class UsersService {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async adminList(query: AdminUserListQueryDto) {
|
||||
const [users, total] = await this.users.adminSearch(query);
|
||||
const items = await Promise.all(
|
||||
users.map(async (user) => ({
|
||||
...this.toAdminListItem(user),
|
||||
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
||||
})),
|
||||
);
|
||||
return { items, total, page: query.page, pageSize: query.pageSize };
|
||||
}
|
||||
|
||||
async adminGet(
|
||||
id: string,
|
||||
currentSessionId: string | undefined,
|
||||
): Promise<AdminUserDetail> {
|
||||
const user = await this.getWithRoles(id);
|
||||
return {
|
||||
...this.toAdminListItem(user),
|
||||
activeSessionCount: await this.sessions.countActiveForUser(user.id),
|
||||
effectivePermissions: this.effectivePermissions(user),
|
||||
sessions: await this.sessions.listForAdmin(user.id, currentSessionId),
|
||||
settings: {
|
||||
tablePageSize: user.settings.tablePageSize,
|
||||
sidebarExpanded: user.settings.sidebarExpanded,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async get(id: string): Promise<UserEntity> {
|
||||
const user = await this.users.findById(id);
|
||||
if (!user) {
|
||||
@@ -44,22 +104,38 @@ export class UsersService {
|
||||
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;
|
||||
return this.withAdminLock(async (manager) => {
|
||||
const user = await this.getWithRoles(userId, manager);
|
||||
if (active && user.active) {
|
||||
throw new ApiError(
|
||||
ErrorCode.UserAlreadyActive,
|
||||
'Der Benutzer ist bereits aktiv.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (!active && !user.active) {
|
||||
throw new ApiError(
|
||||
ErrorCode.UserAlreadyInactive,
|
||||
'Der Benutzer ist bereits deaktiviert.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (!active && this.hasRole(user, 'admin')) {
|
||||
await this.assertAnotherActiveAdminRemains(userId, manager);
|
||||
}
|
||||
user.active = active;
|
||||
const saved = await this.users.save(user, manager);
|
||||
if (!active) {
|
||||
await this.sessions.revokeAllForUser(userId);
|
||||
}
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
active ? AuditAction.UserActivated : AuditAction.UserDeactivated,
|
||||
'user',
|
||||
userId,
|
||||
);
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
async setRoles(
|
||||
@@ -67,26 +143,72 @@ export class UsersService {
|
||||
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);
|
||||
return this.replaceRoles(actor, userId, roleIds);
|
||||
}
|
||||
|
||||
async assignRole(
|
||||
actor: AuthenticatedUser,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<UserEntity> {
|
||||
const user = await this.getWithRoles(userId);
|
||||
if (user.roles.some((role) => role.id === roleId)) {
|
||||
return user;
|
||||
}
|
||||
const saved = await this.users.save(user);
|
||||
return this.replaceRoles(actor, userId, [
|
||||
...user.roles.map((role) => role.id),
|
||||
roleId,
|
||||
]);
|
||||
}
|
||||
|
||||
async removeRole(
|
||||
actor: AuthenticatedUser,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<UserEntity> {
|
||||
const user = await this.getWithRoles(userId);
|
||||
if (!user.roles.some((role) => role.id === roleId)) {
|
||||
return user;
|
||||
}
|
||||
return this.replaceRoles(
|
||||
actor,
|
||||
userId,
|
||||
user.roles.filter((role) => role.id !== roleId).map((role) => role.id),
|
||||
);
|
||||
}
|
||||
|
||||
async revokeUserSession(
|
||||
actor: AuthenticatedUser,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
await this.get(userId);
|
||||
await this.sessions.revokeForAdmin(userId, sessionId);
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.UserRoleAssigned,
|
||||
'user',
|
||||
userId,
|
||||
AuditAction.SessionRevoked,
|
||||
'session',
|
||||
sessionId,
|
||||
{
|
||||
roleIds: roleIds.join(','),
|
||||
targetUserId: userId,
|
||||
},
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async revokeUserSessions(
|
||||
actor: AuthenticatedUser,
|
||||
userId: string,
|
||||
): Promise<{ revoked: number }> {
|
||||
await this.get(userId);
|
||||
const revoked = await this.sessions.revokeAllForUser(userId);
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.AllUserSessionsRevoked,
|
||||
'user',
|
||||
userId,
|
||||
{ revoked },
|
||||
);
|
||||
return { revoked };
|
||||
}
|
||||
|
||||
async updateSettings(
|
||||
@@ -103,8 +225,11 @@ export class UsersService {
|
||||
return this.users.save(user);
|
||||
}
|
||||
|
||||
async assertAnotherActiveAdminRemains(excludedUserId: string): Promise<void> {
|
||||
const result = await this.dataSource
|
||||
async assertAnotherActiveAdminRemains(
|
||||
excludedUserId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const result = await (manager ?? this.dataSource.manager)
|
||||
.getRepository(UserEntity)
|
||||
.createQueryBuilder('user')
|
||||
.innerJoin('user.roles', 'role')
|
||||
@@ -115,13 +240,177 @@ export class UsersService {
|
||||
if (result < 1) {
|
||||
throw new ApiError(
|
||||
ErrorCode.LastAdminRequired,
|
||||
'Mindestens ein anderer aktiver Administrator muss erhalten bleiben.',
|
||||
'Mindestens ein aktiver Administrator muss erhalten bleiben.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceRoles(
|
||||
actor: AuthenticatedUser,
|
||||
userId: string,
|
||||
roleIds: string[],
|
||||
): Promise<UserEntity> {
|
||||
return this.withAdminLock(async (manager) => {
|
||||
const user = await this.getWithRoles(userId, manager);
|
||||
const previousRoleNames = new Set(user.roles.map((role) => role.name));
|
||||
const previousRoleIds = new Set(user.roles.map((role) => role.id));
|
||||
const roles = await Promise.all(
|
||||
roleIds.map((id) => this.roles.getRole(id, manager)),
|
||||
);
|
||||
const nextRoleNames = new Set(roles.map((role) => role.name));
|
||||
user.roles = roles;
|
||||
if (previousRoleNames.has('admin') && !nextRoleNames.has('admin')) {
|
||||
await this.assertAnotherActiveAdminRemains(userId, manager);
|
||||
}
|
||||
const saved = await this.users.save(user, manager);
|
||||
await this.recordRoleAudit(actor, userId, previousRoleIds, roles);
|
||||
await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames);
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
private async recordRoleAudit(
|
||||
actor: AuthenticatedUser,
|
||||
userId: string,
|
||||
previousRoleIds: Set<string>,
|
||||
nextRoles: RoleEntity[],
|
||||
): Promise<void> {
|
||||
const nextRoleIds = new Set(nextRoles.map((role) => role.id));
|
||||
for (const previousRoleId of previousRoleIds) {
|
||||
if (!nextRoleIds.has(previousRoleId)) {
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.UserRoleRemoved,
|
||||
'user',
|
||||
userId,
|
||||
{ roleId: previousRoleId },
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const role of nextRoles) {
|
||||
if (!previousRoleIds.has(role.id)) {
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.UserRoleAssigned,
|
||||
'user',
|
||||
userId,
|
||||
{ roleId: role.id, roleName: role.name },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getWithRoles(
|
||||
userId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<UserEntity> {
|
||||
const user = await this.users.findByIdWithRoles(userId, manager);
|
||||
if (!user) {
|
||||
throw new ApiError(
|
||||
ErrorCode.UserNotFound,
|
||||
'Der Benutzer wurde nicht gefunden.',
|
||||
404,
|
||||
);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async withAdminLock<T>(
|
||||
action: (manager: EntityManager) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query(
|
||||
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
|
||||
);
|
||||
try {
|
||||
return await action(manager);
|
||||
} finally {
|
||||
await manager.query(
|
||||
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toAdminListItem(user: UserEntity): AdminUserListItem {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
active: user.active,
|
||||
roles: user.roles.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
system: role.protected,
|
||||
})),
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
activeSessionCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private effectivePermissions(user: UserEntity): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
user.roles.flatMap((role) =>
|
||||
role.permissions.map((permission) => permission.id),
|
||||
),
|
||||
),
|
||||
).sort();
|
||||
}
|
||||
|
||||
private hasRole(user: UserEntity, roleName: string): boolean {
|
||||
return user.roles.some((role) => role.name === roleName);
|
||||
}
|
||||
|
||||
private async notifyRoleChanges(
|
||||
userId: string,
|
||||
previousRoleNames: Set<string>,
|
||||
nextRoleNames: Set<string>,
|
||||
): Promise<void> {
|
||||
const added = [...nextRoleNames].filter(
|
||||
(role) => !previousRoleNames.has(role),
|
||||
);
|
||||
const removed = [...previousRoleNames].filter(
|
||||
(role) => !nextRoleNames.has(role),
|
||||
);
|
||||
for (const role of added) {
|
||||
await this.createRoleNotification(
|
||||
userId,
|
||||
role,
|
||||
`Ihnen wurde die Rolle "${role}" zugewiesen.`,
|
||||
);
|
||||
}
|
||||
for (const role of removed) {
|
||||
await this.createRoleNotification(
|
||||
userId,
|
||||
role,
|
||||
`Die Rolle "${role}" wurde Ihnen entzogen.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createRoleNotification(
|
||||
userId: string,
|
||||
role: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.notifications.createForUser({
|
||||
userId,
|
||||
type: NotificationType.UserRoleChanged,
|
||||
title: 'Rollen geaendert',
|
||||
message,
|
||||
link: '/profil',
|
||||
metadata: { role },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ userId, role, error },
|
||||
'Failed to create role change notification',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user