bump
This commit is contained in:
@@ -22,6 +22,7 @@ import { DatabaseModule } from './database/database.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ItemsModule } from './items/items.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { RolesModule } from './roles/roles.module';
|
||||
import { SessionsModule } from './sessions/sessions.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
@@ -81,6 +82,7 @@ const generateGlobalKey: ThrottlerGenerateKeyFunction = (
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
SessionsModule,
|
||||
NotificationsModule,
|
||||
AuditModule,
|
||||
ItemsModule,
|
||||
HealthModule,
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum AuditAction {
|
||||
RolePermissionsUpdated = 'ROLE_PERMISSIONS_UPDATED',
|
||||
SessionRevoked = 'SESSION_REVOKED',
|
||||
AllUserSessionsRevoked = 'ALL_USER_SESSIONS_REVOKED',
|
||||
NotificationCreated = 'NOTIFICATION_CREATED',
|
||||
}
|
||||
|
||||
@Entity('audit_logs')
|
||||
|
||||
@@ -61,9 +61,9 @@ export class AuthController {
|
||||
const sessionId = req.signedCookies?.[this.config.session.cookieName] as
|
||||
| string
|
||||
| undefined;
|
||||
await this.auth.logout(sessionId);
|
||||
const logoutUrl = await this.auth.logout(sessionId);
|
||||
res.clearCookie(this.config.session.cookieName, { path: '/' });
|
||||
res.clearCookie('csrf_token', { path: '/' });
|
||||
res.redirect(this.config.frontendBaseUrl);
|
||||
res.redirect(logoutUrl);
|
||||
}
|
||||
}
|
||||
|
||||
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
import type { ExternalHttpClient } from '../common/http/external-http-client';
|
||||
import type { AppConfigService } from '../config/config.service';
|
||||
import type { RolesService } from '../roles/roles.service';
|
||||
import type { SessionsService } from '../sessions/sessions.service';
|
||||
import type { UsersRepository } from '../users/repositories/users.repository';
|
||||
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
|
||||
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
|
||||
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
|
||||
Promise.resolve('id-token'),
|
||||
);
|
||||
const service = new AuthService(
|
||||
{
|
||||
frontendBaseUrl: 'https://app.example.test',
|
||||
appBaseUrl: 'https://app.example.test',
|
||||
oidc: {
|
||||
issuer: 'https://idp.example.test',
|
||||
clientId: 'business-app',
|
||||
clientSecret: 'secret',
|
||||
scopes: 'openid profile email',
|
||||
allowedAlgorithms: ['RS256'],
|
||||
httpTimeoutMs: 5000,
|
||||
logoutUrl: 'https://idp.example.test/logout',
|
||||
},
|
||||
} as AppConfigService,
|
||||
{} as ExternalHttpClient,
|
||||
{} as RolesService,
|
||||
{} as UsersRepository,
|
||||
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
|
||||
{} as DataSource,
|
||||
{} as Repository<OidcLoginStateEntity>,
|
||||
);
|
||||
|
||||
const url = new URL(await service.logout('session-1'));
|
||||
|
||||
expect(revoke).toHaveBeenCalledWith('session-1');
|
||||
expect(getIdTokenForLogout).toHaveBeenCalledWith('session-1');
|
||||
expect(url.origin + url.pathname).toBe('https://idp.example.test/logout');
|
||||
expect(url.searchParams.get('client_id')).toBe('business-app');
|
||||
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||
'https://app.example.test',
|
||||
);
|
||||
expect(url.searchParams.get('id_token_hint')).toBe('id-token');
|
||||
});
|
||||
});
|
||||
@@ -111,10 +111,13 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
async logout(sessionId: string | undefined): Promise<void> {
|
||||
async logout(sessionId: string | undefined): Promise<string> {
|
||||
let idToken: string | undefined;
|
||||
if (sessionId) {
|
||||
idToken = await this.readLogoutIdToken(sessionId);
|
||||
await this.sessions.revoke(sessionId);
|
||||
}
|
||||
return this.createLogoutUrl(idToken);
|
||||
}
|
||||
|
||||
private async upsertLocalUser(
|
||||
@@ -188,6 +191,45 @@ export class AuthService {
|
||||
return discovery;
|
||||
}
|
||||
|
||||
private async readLogoutIdToken(
|
||||
sessionId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
return await this.sessions.getIdTokenForLogout(sessionId);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async createLogoutUrl(idToken: string | undefined): Promise<string> {
|
||||
const endpoint = await this.resolveLogoutEndpoint();
|
||||
if (!endpoint) {
|
||||
return this.config.frontendBaseUrl;
|
||||
}
|
||||
|
||||
const url = new URL(endpoint);
|
||||
url.searchParams.set('client_id', this.config.oidc.clientId);
|
||||
url.searchParams.set(
|
||||
'post_logout_redirect_uri',
|
||||
this.config.frontendBaseUrl,
|
||||
);
|
||||
if (idToken) {
|
||||
url.searchParams.set('id_token_hint', idToken);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async resolveLogoutEndpoint(): Promise<string | undefined> {
|
||||
if (this.config.oidc.logoutUrl) {
|
||||
return this.config.oidc.logoutUrl;
|
||||
}
|
||||
try {
|
||||
return (await this.discovery()).end_session_endpoint;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async exchangeCode(
|
||||
discovery: OidcDiscovery,
|
||||
code: string,
|
||||
|
||||
@@ -6,8 +6,24 @@ export enum ErrorCode {
|
||||
Conflict = 'CONFLICT',
|
||||
CsrfInvalid = 'CSRF_INVALID',
|
||||
UserDisabled = 'USER_DISABLED',
|
||||
LastAdminRequired = 'LAST_ADMIN_REQUIRED',
|
||||
LastAdminRequired = 'LAST_ACTIVE_ADMIN_REQUIRED',
|
||||
MigrationMissing = 'MIGRATION_MISSING',
|
||||
RateLimitExceeded = 'RATE_LIMIT_EXCEEDED',
|
||||
NotificationNotFound = 'NOTIFICATION_NOT_FOUND',
|
||||
NotificationAccessDenied = 'NOTIFICATION_ACCESS_DENIED',
|
||||
NotificationTypeInvalid = 'NOTIFICATION_TYPE_INVALID',
|
||||
NotificationLinkInvalid = 'NOTIFICATION_LINK_INVALID',
|
||||
NotificationMetadataTooLarge = 'NOTIFICATION_METADATA_TOO_LARGE',
|
||||
UserNotFound = 'USER_NOT_FOUND',
|
||||
UserAlreadyActive = 'USER_ALREADY_ACTIVE',
|
||||
UserAlreadyInactive = 'USER_ALREADY_INACTIVE',
|
||||
RoleNotFound = 'ROLE_NOT_FOUND',
|
||||
RoleAlreadyAssigned = 'ROLE_ALREADY_ASSIGNED',
|
||||
RoleNotAssigned = 'ROLE_NOT_ASSIGNED',
|
||||
RoleNameAlreadyExists = 'ROLE_NAME_ALREADY_EXISTS',
|
||||
RoleStillAssigned = 'ROLE_STILL_ASSIGNED',
|
||||
SystemRoleProtected = 'SYSTEM_ROLE_PROTECTED',
|
||||
UnknownPermission = 'UNKNOWN_PERMISSION',
|
||||
SessionNotFound = 'SESSION_NOT_FOUND',
|
||||
InternalError = 'INTERNAL_ERROR',
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ describe('loadConfigFromEnv', () => {
|
||||
expect(config.frontendBaseUrl).toBe('http://localhost:4200');
|
||||
});
|
||||
|
||||
it('accepts an optional OIDC logout URL', () => {
|
||||
const config = loadConfigFromEnv({
|
||||
...validEnv,
|
||||
OIDC_LOGOUT_URL: 'https://idp.example.test/logout',
|
||||
});
|
||||
const withoutLogout = loadConfigFromEnv({
|
||||
...validEnv,
|
||||
OIDC_LOGOUT_URL: '',
|
||||
});
|
||||
|
||||
expect(config.oidc.logoutUrl).toBe('https://idp.example.test/logout');
|
||||
expect(withoutLogout.oidc.logoutUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects unsafe production secret placeholders', () => {
|
||||
expect(() =>
|
||||
loadConfigFromEnv({
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface AppConfig {
|
||||
scopes: string;
|
||||
allowedAlgorithms: string[];
|
||||
httpTimeoutMs: number;
|
||||
logoutUrl?: string;
|
||||
};
|
||||
session: {
|
||||
cookieName: string;
|
||||
|
||||
@@ -28,6 +28,10 @@ const envSchema = z.object({
|
||||
OIDC_CLIENT_ID: z.string().min(1),
|
||||
OIDC_CLIENT_SECRET: z.string().min(1),
|
||||
OIDC_SCOPES: z.string().min(1).default('openid profile email'),
|
||||
OIDC_LOGOUT_URL: z.preprocess(
|
||||
(value) => (value === '' ? undefined : value),
|
||||
z.url().optional(),
|
||||
),
|
||||
OIDC_ALLOWED_ALGORITHMS: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -121,6 +125,7 @@ export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
|
||||
scopes: value.OIDC_SCOPES,
|
||||
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
|
||||
httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS,
|
||||
...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}),
|
||||
},
|
||||
session: {
|
||||
cookieName: value.SESSION_COOKIE_NAME,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AuditLogEntity } from '../audit/entities/audit-log.entity';
|
||||
import { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity';
|
||||
import { ItemEntity } from '../items/entities/item.entity';
|
||||
import { NotificationEntity } from '../notifications/entities/notification.entity';
|
||||
import { RoleEntity } from '../roles/entities/role.entity';
|
||||
import { PermissionEntity } from '../roles/entities/permission.entity';
|
||||
import { SessionEntity } from '../sessions/entities/session.entity';
|
||||
@@ -11,6 +12,7 @@ export const entities = [
|
||||
AuditLogEntity,
|
||||
OidcLoginStateEntity,
|
||||
ItemEntity,
|
||||
NotificationEntity,
|
||||
RoleEntity,
|
||||
PermissionEntity,
|
||||
SessionEntity,
|
||||
|
||||
@@ -15,6 +15,7 @@ export class InitialSchema1720000000000 implements MigrationInterface {
|
||||
CREATE TABLE roles (
|
||||
id char(36) NOT NULL,
|
||||
name varchar(80) NOT NULL,
|
||||
description varchar(255) NOT NULL DEFAULT '',
|
||||
protected tinyint NOT NULL DEFAULT 0,
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddNotifications1720000001000 implements MigrationInterface {
|
||||
name = 'AddNotifications1720000001000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE notifications (
|
||||
id char(36) NOT NULL,
|
||||
user_id char(36) NOT NULL,
|
||||
type varchar(80) NOT NULL,
|
||||
title varchar(150) NOT NULL,
|
||||
message varchar(1000) NOT NULL,
|
||||
link varchar(500) NULL,
|
||||
metadata json NULL,
|
||||
read_at datetime(3) NULL,
|
||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
deleted_at datetime(3) NULL,
|
||||
KEY idx_notifications_user_created_at (user_id, created_at),
|
||||
KEY idx_notifications_user_read_at (user_id, read_at),
|
||||
KEY idx_notifications_user_deleted_at (user_id, deleted_at),
|
||||
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP TABLE notifications');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRoleDescription1720000002000 implements MigrationInterface {
|
||||
name = 'AddRoleDescription1720000002000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE roles
|
||||
ADD description varchar(255) NOT NULL DEFAULT ''
|
||||
`);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('ALTER TABLE roles DROP COLUMN description');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { DataSource } from 'typeorm';
|
||||
import { loadConfigForCli } from '../config/env';
|
||||
import { entities } from './entities';
|
||||
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
|
||||
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
|
||||
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
|
||||
|
||||
const config = loadConfigForCli();
|
||||
|
||||
@@ -19,5 +21,9 @@ export default new DataSource({
|
||||
synchronize: false,
|
||||
migrationsRun: false,
|
||||
entities,
|
||||
migrations: [InitialSchema1720000000000],
|
||||
migrations: [
|
||||
InitialSchema1720000000000,
|
||||
AddNotifications1720000001000,
|
||||
AddRoleDescription1720000002000,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import type { AppConfigService } from '../config/config.service';
|
||||
import { entities } from './entities';
|
||||
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
|
||||
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
|
||||
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
|
||||
|
||||
export function typeOrmOptionsFactory(
|
||||
config: AppConfigService,
|
||||
@@ -19,6 +21,10 @@ export function typeOrmOptionsFactory(
|
||||
synchronize: false,
|
||||
migrationsRun: false,
|
||||
entities,
|
||||
migrations: [InitialSchema1720000000000],
|
||||
migrations: [
|
||||
InitialSchema1720000000000,
|
||||
AddNotifications1720000001000,
|
||||
AddRoleDescription1720000002000,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ItemStatus } from '../entities/item.entity';
|
||||
|
||||
export class ItemListQueryDto {
|
||||
@@ -22,13 +24,16 @@ export class ItemListQueryDto {
|
||||
direction?: 'ASC' | 'DESC';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 20;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||
import { Permission } from '../roles/permissions';
|
||||
import { CreateItemDto, ItemListQueryDto, UpdateItemDto } from './dto/item.dto';
|
||||
@@ -33,8 +35,8 @@ export class ItemsController {
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.ItemsCreate)
|
||||
create(@Body() dto: CreateItemDto) {
|
||||
return this.items.create(dto);
|
||||
create(@Req() req: AuthenticatedRequest, @Body() dto: CreateItemDto) {
|
||||
return this.items.create(dto, req.user?.id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import { UsersRepository } from '../users/repositories/users.repository';
|
||||
import { ItemEntity } from './entities/item.entity';
|
||||
import { ItemsController } from './items.controller';
|
||||
import { ItemsService } from './items.service';
|
||||
import { ItemsRepository } from './repositories/items.repository';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ItemEntity])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ItemEntity, UserEntity]),
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [ItemsController],
|
||||
providers: [ItemsService, ItemsRepository],
|
||||
providers: [ItemsService, ItemsRepository, UsersRepository],
|
||||
})
|
||||
export class ItemsModule {}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ApiError } from '../common/errors/api-error';
|
||||
import { ErrorCode } from '../common/errors/error-codes';
|
||||
import type { PageDto } from '../common/dto/pagination.dto';
|
||||
import { NotificationType } from '../notifications/notification-types';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { UsersRepository } from '../users/repositories/users.repository';
|
||||
import { ItemEntity } from './entities/item.entity';
|
||||
import type {
|
||||
CreateItemDto,
|
||||
@@ -15,7 +19,13 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class ItemsService {
|
||||
constructor(private readonly items: ItemsRepository) {}
|
||||
private readonly logger = new Logger(ItemsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly items: ItemsRepository,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly users: UsersRepository,
|
||||
) {}
|
||||
|
||||
async list(query: ItemListQueryDto): Promise<PageDto<ItemEntity>> {
|
||||
const page = query.page ?? 1;
|
||||
@@ -44,12 +54,14 @@ export class ItemsService {
|
||||
return item;
|
||||
}
|
||||
|
||||
async create(dto: CreateItemDto): Promise<ItemEntity> {
|
||||
async create(dto: CreateItemDto, actorUserId?: string): Promise<ItemEntity> {
|
||||
const item = new ItemEntity();
|
||||
item.name = dto.name;
|
||||
item.description = dto.description ?? null;
|
||||
item.status = dto.status;
|
||||
return this.items.save(item);
|
||||
const saved = await this.items.save(item);
|
||||
await this.notifyFirstAdminAboutCreatedItem(saved, actorUserId);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateItemDto): Promise<ItemEntity> {
|
||||
@@ -78,4 +90,29 @@ export class ItemsService {
|
||||
}
|
||||
await this.items.softDelete(item);
|
||||
}
|
||||
|
||||
private async notifyFirstAdminAboutCreatedItem(
|
||||
item: ItemEntity,
|
||||
actorUserId: string | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const admin = await this.users.findFirstActiveAdmin(actorUserId);
|
||||
if (!admin) {
|
||||
return;
|
||||
}
|
||||
await this.notifications.createForUser({
|
||||
userId: admin.id,
|
||||
type: NotificationType.ItemCreated,
|
||||
title: 'Neuer Eintrag',
|
||||
message: `Der Eintrag "${item.name}" wurde erstellt.`,
|
||||
link: `/items/${item.id}`,
|
||||
metadata: { itemId: item.id },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ itemId: item.id, error },
|
||||
'Failed to create item notification',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import { ErrorCode } from '../../common/errors/error-codes';
|
||||
import { ItemEntity, ItemStatus } from '../entities/item.entity';
|
||||
import { ItemsService } from '../items.service';
|
||||
import type { NotificationsService } from '../../notifications/notifications.service';
|
||||
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||
import type { ItemsRepository } from '../repositories/items.repository';
|
||||
|
||||
function item(version = 2): ItemEntity {
|
||||
@@ -23,7 +25,11 @@ describe('ItemsService', () => {
|
||||
findById: () => Promise.resolve(item(3)),
|
||||
save: (entity) => Promise.resolve(entity),
|
||||
};
|
||||
const service = new ItemsService(repo as ItemsRepository);
|
||||
const service = new ItemsService(
|
||||
repo as ItemsRepository,
|
||||
{} as NotificationsService,
|
||||
{} as UsersRepository,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.update('item-1', {
|
||||
@@ -44,7 +50,11 @@ describe('ItemsService', () => {
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
const service = new ItemsService(repo as ItemsRepository);
|
||||
const service = new ItemsService(
|
||||
repo as ItemsRepository,
|
||||
{} as NotificationsService,
|
||||
{} as UsersRepository,
|
||||
);
|
||||
|
||||
await service.delete('item-1', 4);
|
||||
|
||||
|
||||
78
apps/backend/src/notifications/dto/notification.dto.ts
Normal file
78
apps/backend/src/notifications/dto/notification.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { allNotificationTypes } from '../notification-types';
|
||||
import type { NotificationMetadata } from '../entities/notification.entity';
|
||||
|
||||
export type NotificationStatusFilter = 'all' | 'read' | 'unread';
|
||||
|
||||
export class NotificationListQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['all', 'read', 'unread'])
|
||||
status: NotificationStatusFilter = 'all';
|
||||
}
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsUUID()
|
||||
userId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(allNotificationTypes)
|
||||
type!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, 150)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, 1000)
|
||||
message!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 500)
|
||||
link?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: NotificationMetadata;
|
||||
}
|
||||
|
||||
export interface NotificationDto {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
link: string | null;
|
||||
metadata: NotificationMetadata | null;
|
||||
read: boolean;
|
||||
readAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationPageDto {
|
||||
items: NotificationDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { UserEntity } from '../../users/entities/user.entity';
|
||||
import type { NotificationType } from '../notification-types';
|
||||
|
||||
export type NotificationMetadata = Record<
|
||||
string,
|
||||
string | number | boolean | null
|
||||
>;
|
||||
|
||||
@Entity('notifications')
|
||||
@Index('idx_notifications_user_created_at', ['userId', 'createdAt'])
|
||||
@Index('idx_notifications_user_read_at', ['userId', 'readAt'])
|
||||
@Index('idx_notifications_user_deleted_at', ['userId', 'deletedAt'])
|
||||
export class NotificationEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: UserEntity;
|
||||
|
||||
@Column({ name: 'user_id', type: 'char', length: 36 })
|
||||
userId!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
type!: NotificationType;
|
||||
|
||||
@Column({ type: 'varchar', length: 150 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000 })
|
||||
message!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
link!: string | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
metadata!: NotificationMetadata | null;
|
||||
|
||||
@Column({ name: 'read_at', type: 'datetime', precision: 3, nullable: true })
|
||||
readAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||
createdAt!: Date;
|
||||
|
||||
@DeleteDateColumn({
|
||||
name: 'deleted_at',
|
||||
type: 'datetime',
|
||||
precision: 3,
|
||||
nullable: true,
|
||||
})
|
||||
deletedAt!: Date | null;
|
||||
}
|
||||
15
apps/backend/src/notifications/notification-types.ts
Normal file
15
apps/backend/src/notifications/notification-types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const NotificationType = {
|
||||
System: 'system',
|
||||
ItemCreated: 'item.created',
|
||||
ItemUpdated: 'item.updated',
|
||||
UserRoleChanged: 'user.role-changed',
|
||||
} as const;
|
||||
|
||||
export type NotificationType =
|
||||
(typeof NotificationType)[keyof typeof NotificationType];
|
||||
|
||||
export const allNotificationTypes = Object.values(NotificationType);
|
||||
|
||||
export function isNotificationType(value: string): value is NotificationType {
|
||||
return allNotificationTypes.includes(value as NotificationType);
|
||||
}
|
||||
95
apps/backend/src/notifications/notifications.controller.ts
Normal file
95
apps/backend/src/notifications/notifications.controller.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
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 {
|
||||
CreateNotificationDto,
|
||||
NotificationListQueryDto,
|
||||
} from './dto/notification.dto';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
@Controller()
|
||||
export class NotificationsController {
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
|
||||
@Get('notifications')
|
||||
@RequirePermissions(Permission.NotificationsReadOwn)
|
||||
list(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query() query: NotificationListQueryDto,
|
||||
) {
|
||||
return this.notifications.getForCurrentUser(
|
||||
this.requireUser(req).id,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('notifications/unread-count')
|
||||
@RequirePermissions(Permission.NotificationsReadOwn)
|
||||
async unreadCount(@Req() req: AuthenticatedRequest) {
|
||||
return {
|
||||
count: await this.notifications.getUnreadCount(this.requireUser(req).id),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('notifications/:id/read')
|
||||
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||
markAsRead(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.notifications.markAsRead(this.requireUser(req).id, id);
|
||||
}
|
||||
|
||||
@Patch('notifications/:id/unread')
|
||||
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||
markAsUnread(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.notifications.markAsUnread(this.requireUser(req).id, id);
|
||||
}
|
||||
|
||||
@Patch('notifications/read-all')
|
||||
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||
markAllAsRead(@Req() req: AuthenticatedRequest) {
|
||||
return this.notifications.markAllAsRead(this.requireUser(req).id);
|
||||
}
|
||||
|
||||
@Delete('notifications/:id')
|
||||
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.notifications.softDelete(this.requireUser(req).id, id);
|
||||
}
|
||||
|
||||
@Post('admin/notifications')
|
||||
@RequirePermissions(Permission.NotificationsManage)
|
||||
@SensitiveRateLimit()
|
||||
createAdmin(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: CreateNotificationDto,
|
||||
) {
|
||||
return this.notifications.createAdminNotification(
|
||||
this.requireUser(req).id,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
private requireUser(req: AuthenticatedRequest) {
|
||||
if (!req.user) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'Bitte melden Sie sich an.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
20
apps/backend/src/notifications/notifications.module.ts
Normal file
20
apps/backend/src/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import { UsersRepository } from '../users/repositories/users.repository';
|
||||
import { NotificationEntity } from './entities/notification.entity';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { NotificationsRepository } from './repositories/notifications.repository';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([NotificationEntity, UserEntity]),
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsRepository, NotificationsService, UsersRepository],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
287
apps/backend/src/notifications/notifications.service.ts
Normal file
287
apps/backend/src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/entities/audit-log.entity';
|
||||
import { ApiError } from '../common/errors/api-error';
|
||||
import { ErrorCode } from '../common/errors/error-codes';
|
||||
import { UserEntity } from '../users/entities/user.entity';
|
||||
import { UsersRepository } from '../users/repositories/users.repository';
|
||||
import {
|
||||
NotificationEntity,
|
||||
type NotificationMetadata,
|
||||
} from './entities/notification.entity';
|
||||
import {
|
||||
type NotificationDto,
|
||||
type NotificationListQueryDto,
|
||||
type NotificationPageDto,
|
||||
type NotificationStatusFilter,
|
||||
} from './dto/notification.dto';
|
||||
import {
|
||||
isNotificationType,
|
||||
type NotificationType,
|
||||
} from './notification-types';
|
||||
import { NotificationsRepository } from './repositories/notifications.repository';
|
||||
|
||||
export interface CreateNotificationInput {
|
||||
userId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
link?: string | null;
|
||||
metadata?: NotificationMetadata | null;
|
||||
}
|
||||
|
||||
const maxBulkCreate = 100;
|
||||
const maxMetadataBytes = 4096;
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly notifications: NotificationsRepository,
|
||||
private readonly users: UsersRepository,
|
||||
private readonly audit: AuditService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async createForUser(
|
||||
input: CreateNotificationInput,
|
||||
manager?: EntityManager,
|
||||
): Promise<NotificationEntity> {
|
||||
const user = await this.findActiveUser(input.userId, manager);
|
||||
const notification = this.buildNotification(user.id, input);
|
||||
return this.notifications.save(notification, manager);
|
||||
}
|
||||
|
||||
async createForUsers(
|
||||
userIds: string[],
|
||||
input: Omit<CreateNotificationInput, 'userId'>,
|
||||
): Promise<NotificationEntity[]> {
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
if (uniqueUserIds.length > maxBulkCreate) {
|
||||
this.logger.warn(
|
||||
{ count: uniqueUserIds.length },
|
||||
'Large notification bulk create rejected',
|
||||
);
|
||||
throw new ApiError(
|
||||
ErrorCode.ValidationFailed,
|
||||
'Zu viele Zielbenutzer fuer eine Benachrichtigung.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const notifications: NotificationEntity[] = [];
|
||||
for (const userId of uniqueUserIds) {
|
||||
const user = await this.findActiveUser(userId, manager);
|
||||
notifications.push(
|
||||
this.buildNotification(user.id, { ...input, userId }),
|
||||
);
|
||||
}
|
||||
return this.notifications.saveMany(notifications, manager);
|
||||
});
|
||||
}
|
||||
|
||||
async getForCurrentUser(
|
||||
userId: string,
|
||||
query: NotificationListQueryDto,
|
||||
): Promise<NotificationPageDto> {
|
||||
const page = query.page;
|
||||
const pageSize = Math.min(query.pageSize, 100);
|
||||
const status: NotificationStatusFilter = query.status ?? 'all';
|
||||
const [items, total] = await this.notifications.listForUser(
|
||||
userId,
|
||||
status,
|
||||
page,
|
||||
pageSize,
|
||||
);
|
||||
const unreadCount = await this.getUnreadCount(userId);
|
||||
return {
|
||||
items: items.map((item) => this.toDto(item)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
unreadCount,
|
||||
};
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
return this.notifications.countUnreadForUser(userId);
|
||||
}
|
||||
|
||||
async markAsRead(userId: string, id: string): Promise<NotificationDto> {
|
||||
const notification = await this.getOwnedNotification(userId, id);
|
||||
notification.readAt ??= new Date();
|
||||
return this.toDto(await this.notifications.save(notification));
|
||||
}
|
||||
|
||||
async markAsUnread(userId: string, id: string): Promise<NotificationDto> {
|
||||
const notification = await this.getOwnedNotification(userId, id);
|
||||
notification.readAt = null;
|
||||
return this.toDto(await this.notifications.save(notification));
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<{ updated: number }> {
|
||||
return { updated: await this.notifications.markAllAsRead(userId) };
|
||||
}
|
||||
|
||||
async softDelete(userId: string, id: string): Promise<void> {
|
||||
const notification = await this.getOwnedNotification(userId, id);
|
||||
await this.notifications.softDelete(notification);
|
||||
}
|
||||
|
||||
async createAdminNotification(
|
||||
actorUserId: string,
|
||||
input: CreateNotificationInput,
|
||||
): Promise<NotificationDto> {
|
||||
const notification = await this.createForUser(input);
|
||||
await this.audit.record(
|
||||
actorUserId,
|
||||
AuditAction.NotificationCreated,
|
||||
'notification',
|
||||
notification.id,
|
||||
{
|
||||
targetUserId: input.userId,
|
||||
notificationId: notification.id,
|
||||
type: notification.type,
|
||||
},
|
||||
);
|
||||
return this.toDto(notification);
|
||||
}
|
||||
|
||||
private async getOwnedNotification(
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<NotificationEntity> {
|
||||
const notification = await this.notifications.findActiveForUser(id, userId);
|
||||
if (!notification) {
|
||||
throw new ApiError(
|
||||
ErrorCode.NotificationNotFound,
|
||||
'Die Benachrichtigung wurde nicht gefunden.',
|
||||
404,
|
||||
);
|
||||
}
|
||||
return notification;
|
||||
}
|
||||
|
||||
private async findActiveUser(
|
||||
userId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<UserEntity> {
|
||||
const user = await this.users.findById(userId, manager);
|
||||
if (!user || !user.active) {
|
||||
throw new ApiError(
|
||||
ErrorCode.UserNotFound,
|
||||
'Der Zielbenutzer wurde nicht gefunden.',
|
||||
404,
|
||||
);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private buildNotification(
|
||||
userId: string,
|
||||
input: CreateNotificationInput,
|
||||
): NotificationEntity {
|
||||
const type = this.normalizeType(input.type);
|
||||
const title = this.normalizePlainText(input.title, 150);
|
||||
const message = this.normalizePlainText(input.message, 1000);
|
||||
const link = this.normalizeLink(input.link ?? null);
|
||||
const metadata = this.normalizeMetadata(input.metadata ?? null);
|
||||
|
||||
const notification = new NotificationEntity();
|
||||
notification.userId = userId;
|
||||
notification.type = type;
|
||||
notification.title = title;
|
||||
notification.message = message;
|
||||
notification.link = link;
|
||||
notification.metadata = metadata;
|
||||
notification.readAt = null;
|
||||
return notification;
|
||||
}
|
||||
|
||||
private normalizeType(value: string): NotificationType {
|
||||
if (!isNotificationType(value)) {
|
||||
throw new ApiError(
|
||||
ErrorCode.NotificationTypeInvalid,
|
||||
'Der Benachrichtigungstyp ist ungueltig.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizePlainText(value: string, maxLength: number): string {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
trimmed.length < 1 ||
|
||||
trimmed.length > maxLength ||
|
||||
/<[^>]*>|[<>]/.test(trimmed)
|
||||
) {
|
||||
throw new ApiError(
|
||||
ErrorCode.ValidationFailed,
|
||||
'Benachrichtigungen duerfen nur Plain Text enthalten.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private normalizeLink(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (
|
||||
trimmed.length > 500 ||
|
||||
!trimmed.startsWith('/') ||
|
||||
trimmed.startsWith('//') ||
|
||||
trimmed.includes('\\') ||
|
||||
lower.includes('javascript:') ||
|
||||
/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
|
||||
) {
|
||||
throw new ApiError(
|
||||
ErrorCode.NotificationLinkInvalid,
|
||||
'Der Link muss eine interne Route sein.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private normalizeMetadata(
|
||||
value: NotificationMetadata | null,
|
||||
): NotificationMetadata | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const serialized = JSON.stringify(value);
|
||||
if (
|
||||
!serialized ||
|
||||
Buffer.byteLength(serialized, 'utf8') > maxMetadataBytes
|
||||
) {
|
||||
throw new ApiError(
|
||||
ErrorCode.NotificationMetadataTooLarge,
|
||||
'Die Metadaten sind zu gross.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private toDto(notification: NotificationEntity): NotificationDto {
|
||||
return {
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
link: notification.link,
|
||||
metadata: notification.metadata,
|
||||
read: notification.readAt !== null,
|
||||
readAt: notification.readAt?.toISOString() ?? null,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { EntityManager, IsNull, Repository } from 'typeorm';
|
||||
import { NotificationEntity } from '../entities/notification.entity';
|
||||
import type { NotificationStatusFilter } from '../dto/notification.dto';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsRepository {
|
||||
constructor(
|
||||
@InjectRepository(NotificationEntity)
|
||||
private readonly repo: Repository<NotificationEntity>,
|
||||
) {}
|
||||
|
||||
listForUser(
|
||||
userId: string,
|
||||
status: NotificationStatusFilter,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<[NotificationEntity[], number]> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('notification')
|
||||
.where('notification.userId = :userId', { userId })
|
||||
.andWhere('notification.deletedAt IS NULL');
|
||||
if (status === 'read') {
|
||||
qb.andWhere('notification.readAt IS NOT NULL');
|
||||
}
|
||||
if (status === 'unread') {
|
||||
qb.andWhere('notification.readAt IS NULL');
|
||||
}
|
||||
return qb
|
||||
.orderBy('notification.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
countUnreadForUser(userId: string): Promise<number> {
|
||||
return this.repo.count({
|
||||
where: { userId, readAt: IsNull(), deletedAt: IsNull() },
|
||||
});
|
||||
}
|
||||
|
||||
findActiveForUser(
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<NotificationEntity | null> {
|
||||
return this.repo.findOne({ where: { id, userId, deletedAt: IsNull() } });
|
||||
}
|
||||
|
||||
save(
|
||||
notification: NotificationEntity,
|
||||
manager?: EntityManager,
|
||||
): Promise<NotificationEntity> {
|
||||
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
|
||||
notification,
|
||||
);
|
||||
}
|
||||
|
||||
saveMany(
|
||||
notifications: NotificationEntity[],
|
||||
manager?: EntityManager,
|
||||
): Promise<NotificationEntity[]> {
|
||||
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
|
||||
notifications,
|
||||
);
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string, readAt = new Date()): Promise<number> {
|
||||
const result = await this.repo.update(
|
||||
{ userId, readAt: IsNull(), deletedAt: IsNull() },
|
||||
{ readAt },
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
async softDelete(notification: NotificationEntity): Promise<void> {
|
||||
await this.repo.softRemove(notification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AuditService } from '../../audit/audit.service';
|
||||
import type { AuthenticatedUser } from '../../auth/authenticated-request';
|
||||
import { ItemStatus } from '../../items/entities/item.entity';
|
||||
import { ItemsService } from '../../items/items.service';
|
||||
import { NotificationType } from '../notification-types';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import type { ItemsRepository } from '../../items/repositories/items.repository';
|
||||
import type { NotificationsService } from '../notifications.service';
|
||||
import type { RolesService } from '../../roles/roles.service';
|
||||
import type { SessionsService } from '../../sessions/sessions.service';
|
||||
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||
import type { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
describe('Notification integrations', () => {
|
||||
it('creates an item.created notification for the first active admin', async () => {
|
||||
const created: unknown[] = [];
|
||||
const items: Pick<ItemsRepository, 'save'> = {
|
||||
save: (item) => {
|
||||
item.id = 'item-1';
|
||||
item.createdAt = new Date();
|
||||
item.updatedAt = new Date();
|
||||
item.deletedAt = null;
|
||||
return Promise.resolve(item);
|
||||
},
|
||||
};
|
||||
const notifications: Pick<NotificationsService, 'createForUser'> = {
|
||||
createForUser: (input) => {
|
||||
created.push(input);
|
||||
return Promise.resolve(
|
||||
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
|
||||
);
|
||||
},
|
||||
};
|
||||
const users: Pick<UsersRepository, 'findFirstActiveAdmin'> = {
|
||||
findFirstActiveAdmin: () =>
|
||||
Promise.resolve({ id: 'admin-1' } as Awaited<
|
||||
ReturnType<UsersRepository['findFirstActiveAdmin']>
|
||||
>),
|
||||
};
|
||||
const service = new ItemsService(
|
||||
items as ItemsRepository,
|
||||
notifications as NotificationsService,
|
||||
users as UsersRepository,
|
||||
);
|
||||
|
||||
await service.create(
|
||||
{ name: 'Beispiel', status: ItemStatus.Active },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: 'admin-1',
|
||||
type: NotificationType.ItemCreated,
|
||||
link: '/items/item-1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a user.role-changed notification after role changes', async () => {
|
||||
const created: unknown[] = [];
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
active: true,
|
||||
roles: [{ id: 'role-user', name: 'user' }],
|
||||
};
|
||||
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
|
||||
findByIdWithRoles: () =>
|
||||
Promise.resolve(
|
||||
user as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
|
||||
),
|
||||
save: (entry) => Promise.resolve(entry),
|
||||
};
|
||||
const roles: Pick<RolesService, 'getRole'> = {
|
||||
getRole: (id) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
name: id === 'role-editor' ? 'Editor' : 'user',
|
||||
} as Awaited<ReturnType<RolesService['getRole']>>),
|
||||
};
|
||||
const notifications: Pick<NotificationsService, 'createForUser'> = {
|
||||
createForUser: (input) => {
|
||||
created.push(input);
|
||||
return Promise.resolve(
|
||||
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
|
||||
);
|
||||
},
|
||||
};
|
||||
const manager = {
|
||||
query: () => Promise.resolve(),
|
||||
} as unknown as EntityManager;
|
||||
const dataSource = {
|
||||
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
|
||||
action(manager),
|
||||
} as DataSource;
|
||||
const service = new UsersService(
|
||||
users as UsersRepository,
|
||||
roles as RolesService,
|
||||
{} as SessionsService,
|
||||
{ record: () => Promise.resolve() } as unknown as AuditService,
|
||||
notifications as NotificationsService,
|
||||
dataSource,
|
||||
);
|
||||
|
||||
await service.setRoles(
|
||||
{
|
||||
id: 'admin-1',
|
||||
sessionId: 'session-1',
|
||||
permissions: [],
|
||||
} satisfies AuthenticatedUser,
|
||||
'user-1',
|
||||
['role-user', 'role-editor'],
|
||||
);
|
||||
|
||||
expect(created).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
type: NotificationType.UserRoleChanged,
|
||||
message: 'Ihnen wurde die Rolle "Editor" zugewiesen.',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AuditService } from '../../audit/audit.service';
|
||||
import { PermissionsGuard } from '../../auth/guards/permissions.guard';
|
||||
import { ErrorCode } from '../../common/errors/error-codes';
|
||||
import { NotificationEntity } from '../entities/notification.entity';
|
||||
import { NotificationType } from '../notification-types';
|
||||
import { NotificationsController } from '../notifications.controller';
|
||||
import { NotificationsService } from '../notifications.service';
|
||||
import type { AppConfigService } from '../../config/config.service';
|
||||
import type { SessionsService } from '../../sessions/sessions.service';
|
||||
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||
import type { NotificationsRepository } from '../repositories/notifications.repository';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
const now = new Date('2026-07-16T08:00:00.000Z');
|
||||
|
||||
function notification(
|
||||
id: string,
|
||||
userId: string,
|
||||
readAt: Date | null = null,
|
||||
): NotificationEntity {
|
||||
const entity = new NotificationEntity();
|
||||
entity.id = id;
|
||||
entity.userId = userId;
|
||||
entity.type = NotificationType.System;
|
||||
entity.title = 'Titel';
|
||||
entity.message = 'Nachricht';
|
||||
entity.link = '/';
|
||||
entity.metadata = null;
|
||||
entity.readAt = readAt;
|
||||
entity.createdAt = now;
|
||||
entity.deletedAt = null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
function serviceWithStore(store: NotificationEntity[]) {
|
||||
const repo: Pick<
|
||||
NotificationsRepository,
|
||||
| 'listForUser'
|
||||
| 'countUnreadForUser'
|
||||
| 'findActiveForUser'
|
||||
| 'save'
|
||||
| 'saveMany'
|
||||
| 'markAllAsRead'
|
||||
| 'softDelete'
|
||||
> = {
|
||||
listForUser: (userId, status, page, pageSize) => {
|
||||
const filtered = store.filter(
|
||||
(entry) =>
|
||||
entry.userId === userId &&
|
||||
entry.deletedAt === null &&
|
||||
(status === 'all' ||
|
||||
(status === 'read' && entry.readAt !== null) ||
|
||||
(status === 'unread' && entry.readAt === null)),
|
||||
);
|
||||
return Promise.resolve([
|
||||
filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
filtered.length,
|
||||
]);
|
||||
},
|
||||
countUnreadForUser: (userId) =>
|
||||
Promise.resolve(
|
||||
store.filter(
|
||||
(entry) =>
|
||||
entry.userId === userId &&
|
||||
entry.readAt === null &&
|
||||
entry.deletedAt === null,
|
||||
).length,
|
||||
),
|
||||
findActiveForUser: (id, userId) =>
|
||||
Promise.resolve(
|
||||
store.find(
|
||||
(entry) =>
|
||||
entry.id === id &&
|
||||
entry.userId === userId &&
|
||||
entry.deletedAt === null,
|
||||
) ?? null,
|
||||
),
|
||||
save: (entry) => {
|
||||
entry.createdAt ??= now;
|
||||
const index = store.findIndex((candidate) => candidate.id === entry.id);
|
||||
if (index >= 0) {
|
||||
store[index] = entry;
|
||||
} else {
|
||||
entry.id = entry.id || `notification-${store.length + 1}`;
|
||||
store.push(entry);
|
||||
}
|
||||
return Promise.resolve(entry);
|
||||
},
|
||||
saveMany: (entries) => Promise.resolve(entries),
|
||||
markAllAsRead: (userId) => {
|
||||
let updated = 0;
|
||||
for (const entry of store) {
|
||||
if (
|
||||
entry.userId === userId &&
|
||||
entry.readAt === null &&
|
||||
entry.deletedAt === null
|
||||
) {
|
||||
entry.readAt = now;
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
return Promise.resolve(updated);
|
||||
},
|
||||
softDelete: (entry) => {
|
||||
entry.deletedAt = now;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
const users: Pick<UsersRepository, 'findById'> = {
|
||||
findById: (id) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
active: id !== 'disabled-user',
|
||||
} as Awaited<ReturnType<UsersRepository['findById']>>),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: async <T>(callback: (manager: never) => Promise<T>) =>
|
||||
callback(undefined as never),
|
||||
} as unknown as DataSource;
|
||||
const service = new NotificationsService(
|
||||
repo as NotificationsRepository,
|
||||
users as UsersRepository,
|
||||
{} as AuditService,
|
||||
dataSource,
|
||||
);
|
||||
return { service, store };
|
||||
}
|
||||
|
||||
describe('NotificationsService', () => {
|
||||
it('returns only current user notifications with pagination and status filter', async () => {
|
||||
const { service } = serviceWithStore([
|
||||
notification('own-unread', 'user-1'),
|
||||
notification('own-read', 'user-1', now),
|
||||
notification('other', 'user-2'),
|
||||
]);
|
||||
|
||||
const result = await service.getForCurrentUser('user-1', {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
status: 'unread',
|
||||
});
|
||||
|
||||
expect(result.items.map((entry) => entry.id)).toEqual(['own-unread']);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not reveal or mutate foreign notification ids', async () => {
|
||||
const { service } = serviceWithStore([notification('foreign', 'user-2')]);
|
||||
|
||||
await expect(service.markAsRead('user-1', 'foreign')).rejects.toMatchObject(
|
||||
{
|
||||
code: ErrorCode.NotificationNotFound,
|
||||
status: 404,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('marks read and unread idempotently', async () => {
|
||||
const { service } = serviceWithStore([notification('own', 'user-1')]);
|
||||
|
||||
await service.markAsRead('user-1', 'own');
|
||||
const readAgain = await service.markAsRead('user-1', 'own');
|
||||
expect(readAgain.read).toBe(true);
|
||||
|
||||
await service.markAsUnread('user-1', 'own');
|
||||
const unreadAgain = await service.markAsUnread('user-1', 'own');
|
||||
expect(unreadAgain.read).toBe(false);
|
||||
});
|
||||
|
||||
it('marks all unread notifications only for the current user', async () => {
|
||||
const { service, store } = serviceWithStore([
|
||||
notification('own', 'user-1'),
|
||||
notification('other', 'user-2'),
|
||||
]);
|
||||
|
||||
await expect(service.markAllAsRead('user-1')).resolves.toEqual({
|
||||
updated: 1,
|
||||
});
|
||||
expect(store.find((entry) => entry.id === 'own')?.readAt).toBe(now);
|
||||
expect(store.find((entry) => entry.id === 'other')?.readAt).toBeNull();
|
||||
});
|
||||
|
||||
it('soft deletes notifications from normal queries', async () => {
|
||||
const { service } = serviceWithStore([notification('own', 'user-1')]);
|
||||
|
||||
await service.softDelete('user-1', 'own');
|
||||
const result = await service.getForCurrentUser('user-1', {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
status: 'all',
|
||||
});
|
||||
|
||||
expect(result.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects unknown types and unsafe links', async () => {
|
||||
const { service } = serviceWithStore([]);
|
||||
|
||||
await expect(
|
||||
service.createForUser({
|
||||
userId: 'user-1',
|
||||
type: 'unknown',
|
||||
title: 'Titel',
|
||||
message: 'Nachricht',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: ErrorCode.NotificationTypeInvalid });
|
||||
|
||||
await expect(
|
||||
service.createForUser({
|
||||
userId: 'user-1',
|
||||
type: NotificationType.System,
|
||||
title: 'Titel',
|
||||
message: 'Nachricht',
|
||||
link: 'https://example.com',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: ErrorCode.NotificationLinkInvalid });
|
||||
});
|
||||
|
||||
it('rejects disabled target users', async () => {
|
||||
const { service } = serviceWithStore([]);
|
||||
|
||||
await expect(
|
||||
service.createForUser({
|
||||
userId: 'disabled-user',
|
||||
type: NotificationType.System,
|
||||
title: 'Titel',
|
||||
message: 'Nachricht',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: ErrorCode.UserNotFound });
|
||||
});
|
||||
|
||||
it('requires notifications.manage for administrative creation', async () => {
|
||||
const controller = new NotificationsController({} as NotificationsService);
|
||||
const reflector = new Reflector();
|
||||
const sessions: Pick<SessionsService, 'resolveSession'> = {
|
||||
resolveSession: () =>
|
||||
Promise.resolve({
|
||||
user: { id: 'user-1' },
|
||||
permissions: [],
|
||||
} as unknown as Awaited<ReturnType<SessionsService['resolveSession']>>),
|
||||
};
|
||||
const config = {
|
||||
session: { cookieName: 'app_session' },
|
||||
} as AppConfigService;
|
||||
const guard = new PermissionsGuard(
|
||||
reflector,
|
||||
sessions as SessionsService,
|
||||
config,
|
||||
);
|
||||
const context = {
|
||||
getHandler: () => controller.createAdmin,
|
||||
getClass: () => NotificationsController,
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
signedCookies: { app_session: 'session-id' },
|
||||
ip: '127.0.0.1',
|
||||
get: () => 'test-agent',
|
||||
}),
|
||||
}),
|
||||
} as unknown as ExecutionContext;
|
||||
|
||||
await expect(guard.canActivate(context)).rejects.toMatchObject({
|
||||
code: ErrorCode.PermissionDenied,
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
});
|
||||
71
apps/backend/src/roles/admin-roles.controller.ts
Normal file
71
apps/backend/src/roles/admin-roles.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
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 './permissions';
|
||||
import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@Controller('admin/roles')
|
||||
export class AdminRolesController {
|
||||
constructor(private readonly roles: RolesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions(Permission.RolesRead)
|
||||
list() {
|
||||
return this.roles.adminList();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions(Permission.RolesRead)
|
||||
get(@Param('id') id: string) {
|
||||
return this.roles.adminGet(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions(Permission.RolesManage)
|
||||
@SensitiveRateLimit()
|
||||
create(@Req() req: AuthenticatedRequest, @Body() dto: CreateRoleDto) {
|
||||
return this.roles.create(dto, this.requireUser(req));
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermissions(Permission.RolesManage)
|
||||
@SensitiveRateLimit()
|
||||
update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateRoleDto,
|
||||
) {
|
||||
return this.roles.update(id, dto, this.requireUser(req));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions(Permission.RolesManage)
|
||||
@SensitiveRateLimit()
|
||||
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
return this.roles.delete(id, this.requireUser(req));
|
||||
}
|
||||
|
||||
private requireUser(req: AuthenticatedRequest) {
|
||||
if (!req.user) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Unauthorized,
|
||||
'Bitte melden Sie sich an.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
return req.user;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsArray, IsEnum, IsString, Length } from 'class-validator';
|
||||
import { IsArray, IsEnum, IsOptional, IsString, Length } from 'class-validator';
|
||||
import { Permission } from '../permissions';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@@ -6,6 +6,11 @@ export class CreateRoleDto {
|
||||
@Length(2, 80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 255)
|
||||
description = '';
|
||||
|
||||
@IsArray()
|
||||
@IsEnum(Permission, { each: true })
|
||||
permissions!: Permission[];
|
||||
|
||||
@@ -20,6 +20,9 @@ export class RoleEntity {
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, default: '' })
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
protected!: boolean;
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export enum Permission {
|
||||
SessionsReadOwn = 'sessions.readOwn',
|
||||
SessionsRevokeOwn = 'sessions.revokeOwn',
|
||||
SessionsManage = 'sessions.manage',
|
||||
NotificationsReadOwn = 'notifications.readOwn',
|
||||
NotificationsUpdateOwn = 'notifications.updateOwn',
|
||||
NotificationsManage = 'notifications.manage',
|
||||
}
|
||||
|
||||
export const allPermissions = Object.values(Permission);
|
||||
@@ -22,4 +25,5 @@ export const administrativePermissions = [
|
||||
Permission.RolesManage,
|
||||
Permission.AuditRead,
|
||||
Permission.SessionsManage,
|
||||
Permission.NotificationsManage,
|
||||
] as const;
|
||||
|
||||
@@ -18,8 +18,11 @@ export class RolesRepository {
|
||||
});
|
||||
}
|
||||
|
||||
findById(id: string): Promise<RoleEntity | null> {
|
||||
return this.repo.findOne({ where: { id }, relations: { users: true } });
|
||||
findById(id: string, manager?: EntityManager): Promise<RoleEntity | null> {
|
||||
return (manager?.getRepository(RoleEntity) ?? this.repo).findOne({
|
||||
where: { id },
|
||||
relations: { users: true, permissions: true },
|
||||
});
|
||||
}
|
||||
|
||||
list(): Promise<RoleEntity[]> {
|
||||
@@ -33,7 +36,7 @@ export class RolesRepository {
|
||||
return (manager?.getRepository(RoleEntity) ?? this.repo).save(role);
|
||||
}
|
||||
|
||||
remove(role: RoleEntity): Promise<RoleEntity> {
|
||||
return this.repo.remove(role);
|
||||
remove(role: RoleEntity, manager?: EntityManager): Promise<RoleEntity> {
|
||||
return (manager?.getRepository(RoleEntity) ?? this.repo).remove(role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { PermissionEntity } from './entities/permission.entity';
|
||||
import { RoleEntity } from './entities/role.entity';
|
||||
import { AdminRolesController } from './admin-roles.controller';
|
||||
import { RolesController } from './roles.controller';
|
||||
import { RolesRepository } from './repositories/roles.repository';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([RoleEntity, PermissionEntity])],
|
||||
controllers: [RolesController],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity, PermissionEntity]),
|
||||
AuditModule,
|
||||
],
|
||||
controllers: [RolesController, AdminRolesController],
|
||||
providers: [RolesRepository, RolesService],
|
||||
exports: [RolesRepository, RolesService],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/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 { PermissionEntity } from './entities/permission.entity';
|
||||
@@ -16,6 +19,7 @@ const userRoleName = 'user';
|
||||
export class RolesService {
|
||||
constructor(
|
||||
private readonly roles: RolesRepository,
|
||||
private readonly audit: AuditService,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -23,50 +27,149 @@ export class RolesService {
|
||||
return this.roles.list();
|
||||
}
|
||||
|
||||
async create(dto: CreateRoleDto): Promise<RoleEntity> {
|
||||
async adminList() {
|
||||
const roles = await this.roles.list();
|
||||
return roles.map((role) => this.toAdminDto(role));
|
||||
}
|
||||
|
||||
async adminGet(id: string) {
|
||||
return this.toAdminDto(await this.getRole(id));
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateRoleDto,
|
||||
actor?: AuthenticatedUser,
|
||||
): Promise<RoleEntity> {
|
||||
const name = this.normalizeName(dto.name);
|
||||
await this.assertRoleNameAvailable(name);
|
||||
const role = new RoleEntity();
|
||||
role.name = dto.name;
|
||||
role.name = name;
|
||||
role.description = dto.description?.trim() ?? '';
|
||||
role.protected = false;
|
||||
role.permissions = await this.loadPermissionEntities(dto.permissions);
|
||||
return this.roles.save(role);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateRoleDto): Promise<RoleEntity> {
|
||||
const role = await this.getRole(id);
|
||||
if (
|
||||
role.name === adminRoleName &&
|
||||
dto.permissions.length !== allPermissions.length
|
||||
) {
|
||||
throw new ApiError(
|
||||
ErrorCode.PermissionDenied,
|
||||
'Die Adminrolle muss alle Rechte behalten.',
|
||||
403,
|
||||
const saved = await this.roles.save(role);
|
||||
if (actor) {
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.RoleCreated,
|
||||
'role',
|
||||
saved.id,
|
||||
{
|
||||
roleName: saved.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
role.name = role.protected ? role.name : dto.name;
|
||||
role.permissions = await this.loadPermissionEntities(
|
||||
role.name === adminRoleName ? allPermissions : dto.permissions,
|
||||
return saved;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateRoleDto,
|
||||
actor?: AuthenticatedUser,
|
||||
): Promise<RoleEntity> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query(
|
||||
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
|
||||
);
|
||||
try {
|
||||
const role = await this.getRole(id, manager);
|
||||
const normalizedName = this.normalizeName(dto.name);
|
||||
if (!role.protected && normalizedName !== role.name) {
|
||||
await this.assertRoleNameAvailable(normalizedName, id, manager);
|
||||
role.name = normalizedName;
|
||||
}
|
||||
if (role.name === adminRoleName) {
|
||||
this.assertAdminPermissions(dto.permissions);
|
||||
}
|
||||
role.description = dto.description?.trim() ?? '';
|
||||
role.permissions = await this.loadPermissionEntities(
|
||||
role.name === adminRoleName ? allPermissions : dto.permissions,
|
||||
manager,
|
||||
);
|
||||
const saved = await this.roles.save(role, manager);
|
||||
if (actor) {
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.RoleUpdated,
|
||||
'role',
|
||||
saved.id,
|
||||
{
|
||||
roleName: saved.name,
|
||||
},
|
||||
);
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.RolePermissionsUpdated,
|
||||
'role',
|
||||
saved.id,
|
||||
{
|
||||
permissions: saved.permissions
|
||||
.map((permission) => permission.id)
|
||||
.join(','),
|
||||
},
|
||||
);
|
||||
}
|
||||
return saved;
|
||||
} finally {
|
||||
await manager.query(
|
||||
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string, actor?: AuthenticatedUser): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.query(
|
||||
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
|
||||
);
|
||||
try {
|
||||
const role = await this.getRole(id, manager);
|
||||
if (role.protected) {
|
||||
throw new ApiError(
|
||||
ErrorCode.SystemRoleProtected,
|
||||
'Systemrollen koennen nicht geloescht werden.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (role.users.length > 0) {
|
||||
throw new ApiError(
|
||||
ErrorCode.RoleStillAssigned,
|
||||
'Die Rolle ist noch Benutzern zugewiesen.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
await this.roles.remove(role, manager);
|
||||
if (actor) {
|
||||
await this.audit.record(
|
||||
actor.id,
|
||||
AuditAction.RoleDeleted,
|
||||
'role',
|
||||
id,
|
||||
{
|
||||
roleName: role.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await manager.query(
|
||||
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getEffectivePermissions(userId: string): Promise<Permission[]> {
|
||||
const rows = await this.dataSource.query<{ permission: Permission }[]>(
|
||||
`
|
||||
SELECT DISTINCT rp.permission_id AS permission
|
||||
FROM user_roles ur
|
||||
INNER JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
`,
|
||||
[userId],
|
||||
);
|
||||
return this.roles.save(role);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const role = await this.getRole(id);
|
||||
if (role.protected) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Conflict,
|
||||
'Systemrollen koennen nicht geloescht werden.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (role.users.length > 0) {
|
||||
throw new ApiError(
|
||||
ErrorCode.Conflict,
|
||||
'Die Rolle ist noch Benutzern zugewiesen.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
await this.roles.remove(role);
|
||||
return rows.map((row) => row.permission);
|
||||
}
|
||||
|
||||
async ensureSystemRoles(
|
||||
@@ -81,7 +184,12 @@ export class RolesService {
|
||||
);
|
||||
const user = await this.ensureRole(
|
||||
userRoleName,
|
||||
[Permission.ItemsRead, Permission.SessionsReadOwn],
|
||||
[
|
||||
Permission.ItemsRead,
|
||||
Permission.SessionsReadOwn,
|
||||
Permission.NotificationsReadOwn,
|
||||
Permission.NotificationsUpdateOwn,
|
||||
],
|
||||
true,
|
||||
manager,
|
||||
);
|
||||
@@ -100,11 +208,11 @@ export class RolesService {
|
||||
);
|
||||
}
|
||||
|
||||
async getRole(id: string): Promise<RoleEntity> {
|
||||
const role = await this.roles.findById(id);
|
||||
async getRole(id: string, manager?: EntityManager): Promise<RoleEntity> {
|
||||
const role = await this.roles.findById(id, manager);
|
||||
if (!role) {
|
||||
throw new ApiError(
|
||||
ErrorCode.NotFound,
|
||||
ErrorCode.RoleNotFound,
|
||||
'Die Rolle wurde nicht gefunden.',
|
||||
404,
|
||||
);
|
||||
@@ -144,7 +252,7 @@ export class RolesService {
|
||||
.findOneBy({ id: permission });
|
||||
if (!entity) {
|
||||
throw new ApiError(
|
||||
ErrorCode.ValidationFailed,
|
||||
ErrorCode.UnknownPermission,
|
||||
'Unbekannte Permission.',
|
||||
400,
|
||||
);
|
||||
@@ -153,4 +261,51 @@ export class RolesService {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
private async assertRoleNameAvailable(
|
||||
name: string,
|
||||
exceptRoleId?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
const existing = await this.roles.findByName(name, manager);
|
||||
if (existing && existing.id !== exceptRoleId) {
|
||||
throw new ApiError(
|
||||
ErrorCode.RoleNameAlreadyExists,
|
||||
'Der Rollenname ist bereits vergeben.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertAdminPermissions(permissions: Permission[]): void {
|
||||
const submitted = new Set(permissions);
|
||||
const missing = allPermissions.filter(
|
||||
(permission) => !submitted.has(permission),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new ApiError(
|
||||
ErrorCode.LastAdminRequired,
|
||||
'Mindestens ein aktiver Administrator muss erhalten bleiben.',
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private toAdminDto(role: RoleEntity) {
|
||||
return {
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
system: role.protected,
|
||||
protected: role.protected,
|
||||
permissions: role.permissions,
|
||||
userCount: role.users?.length ?? 0,
|
||||
users: role.users,
|
||||
createdAt: role.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,27 +21,52 @@ export class SessionsRepository {
|
||||
return this.repo.find({ where: { userId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
listActiveForUser(userId: string): Promise<SessionEntity[]> {
|
||||
return this.repo.find({
|
||||
where: { userId, revokedAt: IsNull() },
|
||||
order: { lastActivityAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
countActiveForUser(userId: string): Promise<number> {
|
||||
return this.repo.count({ where: { userId, revokedAt: IsNull() } });
|
||||
}
|
||||
|
||||
save(session: SessionEntity): Promise<SessionEntity> {
|
||||
return this.repo.save(session);
|
||||
}
|
||||
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
await this.repo.update({ id: sessionId }, { revokedAt: new Date() });
|
||||
async revoke(sessionId: string): Promise<number> {
|
||||
const result = await this.repo.update(
|
||||
{ id: sessionId, revokedAt: IsNull() },
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
async revokeForUser(userId: string, sessionId: string): Promise<number> {
|
||||
const result = await this.repo.update(
|
||||
{ id: sessionId, userId, revokedAt: IsNull() },
|
||||
{ revokedAt: new Date() },
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
async revokeAllForUser(
|
||||
userId: string,
|
||||
exceptSessionId?: string,
|
||||
): Promise<void> {
|
||||
): Promise<number> {
|
||||
const sessions = await this.repo.find({
|
||||
where: { userId, revokedAt: IsNull() },
|
||||
});
|
||||
const now = new Date();
|
||||
await this.repo.save(
|
||||
sessions
|
||||
.filter((session) => session.id !== exceptSessionId)
|
||||
.map((session) => ({ ...session, revokedAt: now })),
|
||||
const targets = sessions.filter(
|
||||
(session) => session.id !== exceptSessionId,
|
||||
);
|
||||
await this.repo.save(
|
||||
targets.map((session) => ({ ...session, revokedAt: now })),
|
||||
);
|
||||
return targets.length;
|
||||
}
|
||||
|
||||
async cleanupExpired(now = new Date()): Promise<void> {
|
||||
|
||||
@@ -15,6 +15,19 @@ export interface ResolvedSession {
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
export interface AdminSessionDto {
|
||||
id: string;
|
||||
publicId: string;
|
||||
createdAt: string;
|
||||
lastActivityAt: string;
|
||||
expiresAt: string;
|
||||
absoluteExpiresAt: string;
|
||||
userAgent: string | null;
|
||||
approximateIp: string | null;
|
||||
current: boolean;
|
||||
revokedAt: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SessionsService {
|
||||
constructor(
|
||||
@@ -124,14 +137,56 @@ export class SessionsService {
|
||||
}));
|
||||
}
|
||||
|
||||
revoke(sessionId: string): Promise<void> {
|
||||
revoke(sessionId: string): Promise<number> {
|
||||
return this.sessions.revoke(sessionId);
|
||||
}
|
||||
|
||||
revokeAllForUser(userId: string, exceptSessionId?: string): Promise<void> {
|
||||
async getIdTokenForLogout(sessionId: string): Promise<string | undefined> {
|
||||
const session = await this.sessions.findActiveById(sessionId);
|
||||
if (!session?.idTokenEncrypted) {
|
||||
return undefined;
|
||||
}
|
||||
return this.crypto.decrypt(session.idTokenEncrypted);
|
||||
}
|
||||
|
||||
revokeAllForUser(userId: string, exceptSessionId?: string): Promise<number> {
|
||||
return this.sessions.revokeAllForUser(userId, exceptSessionId);
|
||||
}
|
||||
|
||||
async listForAdmin(
|
||||
userId: string,
|
||||
currentSessionId: string | undefined,
|
||||
): Promise<AdminSessionDto[]> {
|
||||
const sessions = await this.sessions.listForUser(userId);
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
publicId: `${session.id.slice(0, 8)}...${session.id.slice(-6)}`,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
lastActivityAt: session.lastActivityAt.toISOString(),
|
||||
expiresAt: session.expiresAt.toISOString(),
|
||||
absoluteExpiresAt: session.absoluteExpiresAt.toISOString(),
|
||||
userAgent: session.userAgent,
|
||||
approximateIp: this.maskIp(session.lastIp),
|
||||
current: session.id === currentSessionId,
|
||||
revokedAt: session.revokedAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async revokeForAdmin(userId: string, sessionId: string): Promise<void> {
|
||||
const affected = await this.sessions.revokeForUser(userId, sessionId);
|
||||
if (affected < 1) {
|
||||
throw new ApiError(
|
||||
ErrorCode.SessionNotFound,
|
||||
'Die Session wurde nicht gefunden.',
|
||||
404,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
countActiveForUser(userId: string): Promise<number> {
|
||||
return this.sessions.countActiveForUser(userId);
|
||||
}
|
||||
|
||||
private permissionsFor(user: UserEntity): Permission[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
|
||||
@@ -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