diff --git a/.env.example b/.env.example index 2e23bd1..5331aa6 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ OIDC_ISSUER=https://idp.example.com/realms/internal OIDC_CLIENT_ID=business-app OIDC_CLIENT_SECRET=change-me OIDC_SCOPES=openid profile email +OIDC_LOGOUT_URL= OIDC_ALLOWED_ALGORITHMS=RS256 OIDC_HTTP_TIMEOUT_MS=5000 diff --git a/AGENTS.md b/AGENTS.md index 1700ad4..ca0622b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,23 @@ - Backend-Controller verwenden niemals TypeORM-Repositories direkt, sondern Services. - Services kapseln Fachlogik; Datenbankzugriffe laufen ueber Repository-Klassen oder klar benannte Persistence-Services. - Keine UI-Library einsetzen. Angular bleibt mobile-first, mit eigenem HTML und SCSS. +- Neue UI muss die Design Tokens aus `apps/frontend/src/styles/_tokens.scss` verwenden. +- Keine direkten Hex-Farben in Feature-Komponenten; Farben werden semantisch ueber CSS Custom Properties genutzt. +- Bestehende UI-Komponenten unter `apps/frontend/src/app/shared/ui` wiederverwenden, bevor neue Abstraktionen entstehen. +- Interaktive UI muss per Tastatur bedienbar sein; Fokuszustaende duerfen nicht entfernt werden. +- Informationen duerfen nicht ausschliesslich ueber Farbe vermittelt werden. +- Kein `::ng-deep`, keine unkontrollierten `!important`-Regeln und keine unnoetigen Utility-Klassen. - OIDC-Tokens bleiben ausschliesslich im Backend. Der Browser erhaelt nur Session-Cookie und CSRF-Token. - Sessions liegen serverseitig in MySQL. Session-Cookies enthalten keine Tokens oder sensiblen Daten. - Permissions sind im Code definiert. Benutzer haben keine direkten Permissions, sondern Rollen. +- Admin-Aktionen duerfen den letzten aktiven Administrator nicht entfernen; entsprechende Benutzer- und Rollen-Aenderungen muessen transaktional abgesichert bleiben. +- Admin-Controller verwenden `@RequirePermissions(...)` und delegieren an Services; Benutzer, Rollen und Sessions werden nicht direkt aus Controllern ueber Repositories veraendert. +- Benachrichtigungen sind benutzerbezogene Daten. Zugriffe muessen serverseitig ueber den aktuellen Session-Benutzer eingeschraenkt werden; keine normalen Endpunkte mit frei uebergebener `userId`. +- Benachrichtigungen werden ueber `NotificationsService` erzeugt, nicht direkt ueber TypeORM-Repositories in Controllern oder fremden Modulen. - Das Backend ist fuer Authentifizierung, Autorisierung und CSRF verbindlich; Angular nutzt Permissions nur zur Darstellung. - Migrationen werden niemals automatisch beim normalen App-Start ausgefuehrt. Der Start prueft nur auf fehlende Migrationen. - Secrets, Session-IDs und Tokens duerfen nicht geloggt oder ins Repository aufgenommen werden. - Tests muessen Verhalten pruefen; keine trivialen Tests, die nur Existenz testen. - Keine Auth- oder CSRF-Umgehung fuer Entwicklung oder Tests in Production-Code einbauen. +- Keine WebSockets, Queues, Redis, E-Mail-, SMS- oder Push-Versand fuer In-App-Benachrichtigungen einfuehren, solange dies nicht explizit architektonisch entschieden wurde. - Vor Abschluss muessen `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm test`, `npm run build` und `docker build .` bestehen. diff --git a/README.md b/README.md index 61facf2..c217d46 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Docker-Auslieferung, Healthchecks und eine einfache Angular-Verwaltungsoberflaec - konfigurierbares In-Memory Rate Limiting pro IP mit strengeren sensiblen Endpunkten - MySQL-8-Persistenz mit TypeORM, Migrationen und Startpruefung auf fehlende Migrationen - Code-definierte Permissions, Rollenverwaltung, Benutzerverwaltung, Audit-Log und Sessions +- In-App-Benachrichtigungen mit Polling, Badge, eigener Seite und Admin-Erzeugung - Generierter API-Client fuer das Angular-Frontend - Dockerfile und Compose-Beispiel fuer eine einzelne auslieferbare App @@ -60,6 +61,9 @@ laufen. - [Architektur](docs/architecture.md): Monorepo, Backend, Frontend, API-Client und Modulgrenzen - [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client - [Security-Modell](docs/security.md): OIDC, Sessions, CSRF, Rollen, Permissions und Logging +- [Designsystem](docs/design-system.md): Tokens, UI-Komponenten, responsive Regeln und Accessibility +- [Adminbereich](docs/admin.md): Benutzer, Rollen, Sessions, Audit und letzter-Admin-Schutz +- [Benachrichtigungen](docs/notifications.md): Datenmodell, API, Permissions, Polling und Erweiterung - [Deployment und Betrieb](docs/deployment.md): Build, Docker, Migrationen, Runtime-Konfiguration und Healthchecks - [Konfiguration](docs/configuration.md): Umgebungsvariablen und Produktionshinweise - [Nginx-Beispiel](docs/nginx-example.conf): Reverse Proxy mit HTTPS-Terminierung diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 033e6b2..de091a3 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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, diff --git a/apps/backend/src/audit/entities/audit-log.entity.ts b/apps/backend/src/audit/entities/audit-log.entity.ts index ab96e11..ce09ec0 100644 --- a/apps/backend/src/audit/entities/audit-log.entity.ts +++ b/apps/backend/src/audit/entities/audit-log.entity.ts @@ -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') diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 2cc6133..7335f4d 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -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); } } diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..904a0cf --- /dev/null +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -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>(() => Promise.resolve(1)); + const getIdTokenForLogout = vi.fn<() => Promise>(() => + 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, + ); + + 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'); + }); +}); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index a2f1508..4196e4f 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -111,10 +111,13 @@ export class AuthService { ); } - async logout(sessionId: string | undefined): Promise { + async logout(sessionId: string | undefined): Promise { + 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 { + try { + return await this.sessions.getIdTokenForLogout(sessionId); + } catch { + return undefined; + } + } + + private async createLogoutUrl(idToken: string | undefined): Promise { + 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 { + 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, diff --git a/apps/backend/src/common/errors/error-codes.ts b/apps/backend/src/common/errors/error-codes.ts index 3df9706..77ab47d 100644 --- a/apps/backend/src/common/errors/error-codes.ts +++ b/apps/backend/src/common/errors/error-codes.ts @@ -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', } diff --git a/apps/backend/src/config/config.service.spec.ts b/apps/backend/src/config/config.service.spec.ts index 6485905..e36faac 100644 --- a/apps/backend/src/config/config.service.spec.ts +++ b/apps/backend/src/config/config.service.spec.ts @@ -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({ diff --git a/apps/backend/src/config/config.types.ts b/apps/backend/src/config/config.types.ts index 321d2d3..91c8e45 100644 --- a/apps/backend/src/config/config.types.ts +++ b/apps/backend/src/config/config.types.ts @@ -26,6 +26,7 @@ export interface AppConfig { scopes: string; allowedAlgorithms: string[]; httpTimeoutMs: number; + logoutUrl?: string; }; session: { cookieName: string; diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts index 5a9b73d..eae66d1 100644 --- a/apps/backend/src/config/env.ts +++ b/apps/backend/src/config/env.ts @@ -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): 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, diff --git a/apps/backend/src/database/entities.ts b/apps/backend/src/database/entities.ts index 18d79d9..c9dbebf 100644 --- a/apps/backend/src/database/entities.ts +++ b/apps/backend/src/database/entities.ts @@ -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, diff --git a/apps/backend/src/database/migrations/1720000000000-InitialSchema.ts b/apps/backend/src/database/migrations/1720000000000-InitialSchema.ts index 504852b..3bee2bb 100644 --- a/apps/backend/src/database/migrations/1720000000000-InitialSchema.ts +++ b/apps/backend/src/database/migrations/1720000000000-InitialSchema.ts @@ -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), diff --git a/apps/backend/src/database/migrations/1720000001000-AddNotifications.ts b/apps/backend/src/database/migrations/1720000001000-AddNotifications.ts new file mode 100644 index 0000000..18b7b32 --- /dev/null +++ b/apps/backend/src/database/migrations/1720000001000-AddNotifications.ts @@ -0,0 +1,31 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNotifications1720000001000 implements MigrationInterface { + name = 'AddNotifications1720000001000'; + + async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query('DROP TABLE notifications'); + } +} diff --git a/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.ts b/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.ts new file mode 100644 index 0000000..cd1ffcb --- /dev/null +++ b/apps/backend/src/database/migrations/1720000002000-AddRoleDescription.ts @@ -0,0 +1,16 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoleDescription1720000002000 implements MigrationInterface { + name = 'AddRoleDescription1720000002000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE roles + ADD description varchar(255) NOT NULL DEFAULT '' + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE roles DROP COLUMN description'); + } +} diff --git a/apps/backend/src/database/typeorm-cli.datasource.ts b/apps/backend/src/database/typeorm-cli.datasource.ts index 24b2c62..c6dd351 100644 --- a/apps/backend/src/database/typeorm-cli.datasource.ts +++ b/apps/backend/src/database/typeorm-cli.datasource.ts @@ -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, + ], }); diff --git a/apps/backend/src/database/typeorm-options.ts b/apps/backend/src/database/typeorm-options.ts index 04c4443..ecfb86c 100644 --- a/apps/backend/src/database/typeorm-options.ts +++ b/apps/backend/src/database/typeorm-options.ts @@ -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, + ], }; } diff --git a/apps/backend/src/items/dto/item.dto.ts b/apps/backend/src/items/dto/item.dto.ts index e09b60f..0a2cb27 100644 --- a/apps/backend/src/items/dto/item.dto.ts +++ b/apps/backend/src/items/dto/item.dto.ts @@ -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; } diff --git a/apps/backend/src/items/items.controller.ts b/apps/backend/src/items/items.controller.ts index 15094ae..79506ea 100644 --- a/apps/backend/src/items/items.controller.ts +++ b/apps/backend/src/items/items.controller.ts @@ -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') diff --git a/apps/backend/src/items/items.module.ts b/apps/backend/src/items/items.module.ts index 1b2345d..869ad57 100644 --- a/apps/backend/src/items/items.module.ts +++ b/apps/backend/src/items/items.module.ts @@ -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 {} diff --git a/apps/backend/src/items/items.service.ts b/apps/backend/src/items/items.service.ts index 14042ba..3ac89f3 100644 --- a/apps/backend/src/items/items.service.ts +++ b/apps/backend/src/items/items.service.ts @@ -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> { const page = query.page ?? 1; @@ -44,12 +54,14 @@ export class ItemsService { return item; } - async create(dto: CreateItemDto): Promise { + async create(dto: CreateItemDto, actorUserId?: string): Promise { 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 { @@ -78,4 +90,29 @@ export class ItemsService { } await this.items.softDelete(item); } + + private async notifyFirstAdminAboutCreatedItem( + item: ItemEntity, + actorUserId: string | undefined, + ): Promise { + 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', + ); + } + } } diff --git a/apps/backend/src/items/tests/items.service.spec.ts b/apps/backend/src/items/tests/items.service.spec.ts index a55c126..952a1fa 100644 --- a/apps/backend/src/items/tests/items.service.spec.ts +++ b/apps/backend/src/items/tests/items.service.spec.ts @@ -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); diff --git a/apps/backend/src/notifications/dto/notification.dto.ts b/apps/backend/src/notifications/dto/notification.dto.ts new file mode 100644 index 0000000..185ac20 --- /dev/null +++ b/apps/backend/src/notifications/dto/notification.dto.ts @@ -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; +} diff --git a/apps/backend/src/notifications/entities/notification.entity.ts b/apps/backend/src/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..bba1d46 --- /dev/null +++ b/apps/backend/src/notifications/entities/notification.entity.ts @@ -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; +} diff --git a/apps/backend/src/notifications/notification-types.ts b/apps/backend/src/notifications/notification-types.ts new file mode 100644 index 0000000..abb7073 --- /dev/null +++ b/apps/backend/src/notifications/notification-types.ts @@ -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); +} diff --git a/apps/backend/src/notifications/notifications.controller.ts b/apps/backend/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..d75955b --- /dev/null +++ b/apps/backend/src/notifications/notifications.controller.ts @@ -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; + } +} diff --git a/apps/backend/src/notifications/notifications.module.ts b/apps/backend/src/notifications/notifications.module.ts new file mode 100644 index 0000000..e9b92b5 --- /dev/null +++ b/apps/backend/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/backend/src/notifications/notifications.service.ts b/apps/backend/src/notifications/notifications.service.ts new file mode 100644 index 0000000..86bfb0c --- /dev/null +++ b/apps/backend/src/notifications/notifications.service.ts @@ -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 { + 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, + ): Promise { + 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 { + 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 { + return this.notifications.countUnreadForUser(userId); + } + + async markAsRead(userId: string, id: string): Promise { + 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 { + 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 { + const notification = await this.getOwnedNotification(userId, id); + await this.notifications.softDelete(notification); + } + + async createAdminNotification( + actorUserId: string, + input: CreateNotificationInput, + ): Promise { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/backend/src/notifications/repositories/notifications.repository.ts b/apps/backend/src/notifications/repositories/notifications.repository.ts new file mode 100644 index 0000000..0af483d --- /dev/null +++ b/apps/backend/src/notifications/repositories/notifications.repository.ts @@ -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, + ) {} + + 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 { + return this.repo.count({ + where: { userId, readAt: IsNull(), deletedAt: IsNull() }, + }); + } + + findActiveForUser( + id: string, + userId: string, + ): Promise { + return this.repo.findOne({ where: { id, userId, deletedAt: IsNull() } }); + } + + save( + notification: NotificationEntity, + manager?: EntityManager, + ): Promise { + return (manager?.getRepository(NotificationEntity) ?? this.repo).save( + notification, + ); + } + + saveMany( + notifications: NotificationEntity[], + manager?: EntityManager, + ): Promise { + return (manager?.getRepository(NotificationEntity) ?? this.repo).save( + notifications, + ); + } + + async markAllAsRead(userId: string, readAt = new Date()): Promise { + const result = await this.repo.update( + { userId, readAt: IsNull(), deletedAt: IsNull() }, + { readAt }, + ); + return result.affected ?? 0; + } + + async softDelete(notification: NotificationEntity): Promise { + await this.repo.softRemove(notification); + } +} diff --git a/apps/backend/src/notifications/tests/notification-integrations.spec.ts b/apps/backend/src/notifications/tests/notification-integrations.spec.ts new file mode 100644 index 0000000..95cedd1 --- /dev/null +++ b/apps/backend/src/notifications/tests/notification-integrations.spec.ts @@ -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 = { + save: (item) => { + item.id = 'item-1'; + item.createdAt = new Date(); + item.updatedAt = new Date(); + item.deletedAt = null; + return Promise.resolve(item); + }, + }; + const notifications: Pick = { + createForUser: (input) => { + created.push(input); + return Promise.resolve( + {} as Awaited>, + ); + }, + }; + const users: Pick = { + findFirstActiveAdmin: () => + Promise.resolve({ id: 'admin-1' } as Awaited< + ReturnType + >), + }; + 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 = { + findByIdWithRoles: () => + Promise.resolve( + user as Awaited>, + ), + save: (entry) => Promise.resolve(entry), + }; + const roles: Pick = { + getRole: (id) => + Promise.resolve({ + id, + name: id === 'role-editor' ? 'Editor' : 'user', + } as Awaited>), + }; + const notifications: Pick = { + createForUser: (input) => { + created.push(input); + return Promise.resolve( + {} as Awaited>, + ); + }, + }; + const manager = { + query: () => Promise.resolve(), + } as unknown as EntityManager; + const dataSource = { + transaction: (action: (manager: EntityManager) => Promise) => + 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.', + }), + ]); + }); +}); diff --git a/apps/backend/src/notifications/tests/notifications.service.spec.ts b/apps/backend/src/notifications/tests/notifications.service.spec.ts new file mode 100644 index 0000000..4243205 --- /dev/null +++ b/apps/backend/src/notifications/tests/notifications.service.spec.ts @@ -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 = { + findById: (id) => + Promise.resolve({ + id, + active: id !== 'disabled-user', + } as Awaited>), + }; + const dataSource = { + transaction: async (callback: (manager: never) => Promise) => + 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 = { + resolveSession: () => + Promise.resolve({ + user: { id: 'user-1' }, + permissions: [], + } as unknown as Awaited>), + }; + 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, + }); + }); +}); diff --git a/apps/backend/src/roles/admin-roles.controller.ts b/apps/backend/src/roles/admin-roles.controller.ts new file mode 100644 index 0000000..74bb0ba --- /dev/null +++ b/apps/backend/src/roles/admin-roles.controller.ts @@ -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; + } +} diff --git a/apps/backend/src/roles/dto/role.dto.ts b/apps/backend/src/roles/dto/role.dto.ts index a924828..12cf2dd 100644 --- a/apps/backend/src/roles/dto/role.dto.ts +++ b/apps/backend/src/roles/dto/role.dto.ts @@ -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[]; diff --git a/apps/backend/src/roles/entities/role.entity.ts b/apps/backend/src/roles/entities/role.entity.ts index 36bc931..19c37c1 100644 --- a/apps/backend/src/roles/entities/role.entity.ts +++ b/apps/backend/src/roles/entities/role.entity.ts @@ -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; diff --git a/apps/backend/src/roles/permissions.ts b/apps/backend/src/roles/permissions.ts index 9798888..4ae1f31 100644 --- a/apps/backend/src/roles/permissions.ts +++ b/apps/backend/src/roles/permissions.ts @@ -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; diff --git a/apps/backend/src/roles/repositories/roles.repository.ts b/apps/backend/src/roles/repositories/roles.repository.ts index 65fe8d5..728be5d 100644 --- a/apps/backend/src/roles/repositories/roles.repository.ts +++ b/apps/backend/src/roles/repositories/roles.repository.ts @@ -18,8 +18,11 @@ export class RolesRepository { }); } - findById(id: string): Promise { - return this.repo.findOne({ where: { id }, relations: { users: true } }); + findById(id: string, manager?: EntityManager): Promise { + return (manager?.getRepository(RoleEntity) ?? this.repo).findOne({ + where: { id }, + relations: { users: true, permissions: true }, + }); } list(): Promise { @@ -33,7 +36,7 @@ export class RolesRepository { return (manager?.getRepository(RoleEntity) ?? this.repo).save(role); } - remove(role: RoleEntity): Promise { - return this.repo.remove(role); + remove(role: RoleEntity, manager?: EntityManager): Promise { + return (manager?.getRepository(RoleEntity) ?? this.repo).remove(role); } } diff --git a/apps/backend/src/roles/roles.module.ts b/apps/backend/src/roles/roles.module.ts index 1ea4e24..768e362 100644 --- a/apps/backend/src/roles/roles.module.ts +++ b/apps/backend/src/roles/roles.module.ts @@ -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], }) diff --git a/apps/backend/src/roles/roles.service.ts b/apps/backend/src/roles/roles.service.ts index 6496f45..0c65597 100644 --- a/apps/backend/src/roles/roles.service.ts +++ b/apps/backend/src/roles/roles.service.ts @@ -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 { + 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 { + 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 { - 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 { + 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 { + 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 { + 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 { - 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 { - const role = await this.roles.findById(id); + async getRole(id: string, manager?: EntityManager): Promise { + 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 { + 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(), + }; + } } diff --git a/apps/backend/src/sessions/repositories/sessions.repository.ts b/apps/backend/src/sessions/repositories/sessions.repository.ts index 00f9104..148b055 100644 --- a/apps/backend/src/sessions/repositories/sessions.repository.ts +++ b/apps/backend/src/sessions/repositories/sessions.repository.ts @@ -21,27 +21,52 @@ export class SessionsRepository { return this.repo.find({ where: { userId }, order: { createdAt: 'DESC' } }); } + listActiveForUser(userId: string): Promise { + return this.repo.find({ + where: { userId, revokedAt: IsNull() }, + order: { lastActivityAt: 'DESC' }, + }); + } + + countActiveForUser(userId: string): Promise { + return this.repo.count({ where: { userId, revokedAt: IsNull() } }); + } + save(session: SessionEntity): Promise { return this.repo.save(session); } - async revoke(sessionId: string): Promise { - await this.repo.update({ id: sessionId }, { revokedAt: new Date() }); + async revoke(sessionId: string): Promise { + const result = await this.repo.update( + { id: sessionId, revokedAt: IsNull() }, + { revokedAt: new Date() }, + ); + return result.affected ?? 0; + } + + async revokeForUser(userId: string, sessionId: string): Promise { + 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 { + ): Promise { 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 { diff --git a/apps/backend/src/sessions/sessions.service.ts b/apps/backend/src/sessions/sessions.service.ts index 429e684..d84c075 100644 --- a/apps/backend/src/sessions/sessions.service.ts +++ b/apps/backend/src/sessions/sessions.service.ts @@ -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 { + revoke(sessionId: string): Promise { return this.sessions.revoke(sessionId); } - revokeAllForUser(userId: string, exceptSessionId?: string): Promise { + async getIdTokenForLogout(sessionId: string): Promise { + const session = await this.sessions.findActiveById(sessionId); + if (!session?.idTokenEncrypted) { + return undefined; + } + return this.crypto.decrypt(session.idTokenEncrypted); + } + + revokeAllForUser(userId: string, exceptSessionId?: string): Promise { return this.sessions.revokeAllForUser(userId, exceptSessionId); } + async listForAdmin( + userId: string, + currentSessionId: string | undefined, + ): Promise { + 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 { + 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 { + return this.sessions.countActiveForUser(userId); + } + private permissionsFor(user: UserEntity): Permission[] { return Array.from( new Set( diff --git a/apps/backend/src/users/dto/user.dto.ts b/apps/backend/src/users/dto/user.dto.ts index a9c6950..19e683f 100644 --- a/apps/backend/src/users/dto/user.dto.ts +++ b/apps/backend/src/users/dto/user.dto.ts @@ -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; +} diff --git a/apps/backend/src/users/repositories/users.repository.ts b/apps/backend/src/users/repositories/users.repository.ts index c96aa40..416baed 100644 --- a/apps/backend/src/users/repositories/users.repository.ts +++ b/apps/backend/src/users/repositories/users.repository.ts @@ -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, ) {} - findById(id: string): Promise { - return this.repo.findOne({ where: { id } }); + findById(id: string, manager?: EntityManager): Promise { + return (manager?.getRepository(UserEntity) ?? this.repo).findOne({ + where: { id }, + }); } findByIdentity(issuer: string, subject: string): Promise { @@ -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 { + return (manager?.getRepository(UserEntity) ?? this.repo).findOne({ + where: { id }, + relations: { roles: { permissions: true }, settings: true }, + }); + } + async save(user: UserEntity, manager?: EntityManager): Promise { return (manager?.getRepository(UserEntity) ?? this.repo).save(user); } + + findFirstActiveAdmin(excludedUserId?: string): Promise { + 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(); + } } diff --git a/apps/backend/src/users/tests/users.service.spec.ts b/apps/backend/src/users/tests/users.service.spec.ts index 213192c..802c169 100644 --- a/apps/backend/src/users/tests/users.service.spec.ts +++ b/apps/backend/src/users/tests/users.service.spec.ts @@ -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, ); diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 7228ff1..315f250 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -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() diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index d765b6e..c20620c 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -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], diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 774100b..77b5d94 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -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 { + 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 { const user = await this.users.findById(id); if (!user) { @@ -44,22 +104,38 @@ export class UsersService { userId: string, active: boolean, ): Promise { - 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 { - 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 { + 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 { + 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 { + 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 { - const result = await this.dataSource + async assertAnotherActiveAdminRemains( + excludedUserId: string, + manager?: EntityManager, + ): Promise { + 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 { + 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, + nextRoles: RoleEntity[], + ): Promise { + 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 { + 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( + action: (manager: EntityManager) => Promise, + ): Promise { + 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, + nextRoleNames: Set, + ): Promise { + 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 { + 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', + ); + } + } } diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index d1fc1b6..5d539c4 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -34,6 +34,12 @@ }, "configurations": { "production": { + "fileReplacements": [ + { + "replace": "src/app/core/dev-routes.ts", + "with": "src/app/core/dev-routes.prod.ts" + } + ], "budgets": [ { "type": "initial", diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index ec41ffe..eabe94d 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,5 +1,6 @@ import type { Routes } from '@angular/router'; import { permissionGuard } from './core/permission.guard'; +import { devRoutes } from './core/dev-routes'; export const routes: Routes = [ { @@ -18,6 +19,16 @@ export const routes: Routes = [ loadComponent: () => import('./features/profile/profile.page').then((m) => m.ProfilePageComponent), }, + { + path: 'notifications', + title: 'Benachrichtigungen', + canActivate: [permissionGuard], + data: { permissions: ['notifications.readOwn'] }, + loadComponent: () => + import('./features/notifications/notifications.page').then( + (m) => m.NotificationsPageComponent, + ), + }, { path: 'sessions', title: 'Eigene Sessions', @@ -40,6 +51,24 @@ export const routes: Routes = [ loadComponent: () => import('./features/users/users.page').then((m) => m.UsersPageComponent), }, + { + path: 'admin/users', + title: 'Admin / Benutzer', + canActivate: [permissionGuard], + data: { permissions: ['users.read'] }, + loadComponent: () => + import('./features/admin/admin-users.page').then((m) => m.AdminUsersPageComponent), + }, + { + path: 'admin/users/:id', + title: 'Admin / Benutzer', + canActivate: [permissionGuard], + data: { permissions: ['users.read'] }, + loadComponent: () => + import('./features/admin/admin-user-detail.page').then( + (m) => m.AdminUserDetailPageComponent, + ), + }, { path: 'rollen', title: 'Rollenverwaltung', @@ -48,6 +77,24 @@ export const routes: Routes = [ loadComponent: () => import('./features/roles/roles.page').then((m) => m.RolesPageComponent), }, + { + path: 'admin/roles', + title: 'Admin / Rollen', + canActivate: [permissionGuard], + data: { permissions: ['roles.read'] }, + loadComponent: () => + import('./features/admin/admin-roles.page').then((m) => m.AdminRolesPageComponent), + }, + { + path: 'admin/roles/:id', + title: 'Admin / Rolle', + canActivate: [permissionGuard], + data: { permissions: ['roles.read'] }, + loadComponent: () => + import('./features/admin/admin-role-detail.page').then( + (m) => m.AdminRoleDetailPageComponent, + ), + }, { path: 'audit-log', title: 'Audit-Log', @@ -56,6 +103,14 @@ export const routes: Routes = [ loadComponent: () => import('./features/audit/audit.page').then((m) => m.AuditPageComponent), }, + { + path: 'admin/audit', + title: 'Admin / Audit', + canActivate: [permissionGuard], + data: { permissions: ['audit.read'] }, + loadComponent: () => + import('./features/audit/audit.page').then((m) => m.AuditPageComponent), + }, { path: '403', title: 'Keine Berechtigung', @@ -68,6 +123,7 @@ export const routes: Routes = [ loadComponent: () => import('./features/errors/error.page').then((m) => m.ErrorPageComponent), }, + ...devRoutes, { path: '**', title: 'Nicht gefunden', diff --git a/apps/frontend/src/app/core/dev-only.guard.ts b/apps/frontend/src/app/core/dev-only.guard.ts new file mode 100644 index 0000000..6ee6038 --- /dev/null +++ b/apps/frontend/src/app/core/dev-only.guard.ts @@ -0,0 +1,8 @@ +import { isDevMode } from '@angular/core'; +import type { CanMatchFn } from '@angular/router'; + +export function isDesignSystemRouteEnabled(devMode = isDevMode()): boolean { + return devMode; +} + +export const devOnlyGuard: CanMatchFn = () => isDesignSystemRouteEnabled(); diff --git a/apps/frontend/src/app/core/dev-routes.prod.ts b/apps/frontend/src/app/core/dev-routes.prod.ts new file mode 100644 index 0000000..8e84452 --- /dev/null +++ b/apps/frontend/src/app/core/dev-routes.prod.ts @@ -0,0 +1,3 @@ +import type { Routes } from '@angular/router'; + +export const devRoutes: Routes = []; diff --git a/apps/frontend/src/app/core/dev-routes.ts b/apps/frontend/src/app/core/dev-routes.ts new file mode 100644 index 0000000..7782159 --- /dev/null +++ b/apps/frontend/src/app/core/dev-routes.ts @@ -0,0 +1,12 @@ +import type { Routes } from '@angular/router'; +import { devOnlyGuard } from './dev-only.guard'; + +export const devRoutes: Routes = [ + { + path: 'dev/design-system', + title: 'Designsystem', + canMatch: [devOnlyGuard], + loadComponent: () => + import('../features/dev/design-system.page').then((m) => m.DesignSystemPageComponent), + }, +]; diff --git a/apps/frontend/src/app/core/notification.store.spec.ts b/apps/frontend/src/app/core/notification.store.spec.ts new file mode 100644 index 0000000..2f9cb1c --- /dev/null +++ b/apps/frontend/src/app/core/notification.store.spec.ts @@ -0,0 +1,139 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; +import { ApiClientService, type NotificationDto } from '@boilerplate/api-client'; +import { NOTIFICATION_POLL_INTERVAL_MS, NotificationStore } from './notification.store'; + +const unread: NotificationDto = { + id: 'n1', + type: 'system', + title: 'Wartung', + message: 'Heute Abend.', + link: '/', + metadata: null, + read: false, + readAt: null, + createdAt: '2026-07-16T08:00:00.000Z', +}; + +function setup(api: Partial = {}) { + const navigateByUrl = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + NotificationStore, + { provide: NOTIFICATION_POLL_INTERVAL_MS, useValue: 60_000 }, + { provide: Router, useValue: { navigateByUrl } }, + { + provide: ApiClientService, + useValue: { + unreadNotificationCount: () => of({ count: 1 }), + notifications: () => + of({ + items: [unread], + total: 1, + page: 1, + pageSize: 20, + unreadCount: 1, + }), + markNotificationRead: () => + of({ ...unread, read: true, readAt: '2026-07-16T08:01:00.000Z' }), + markNotificationUnread: () => of({ ...unread, read: false, readAt: null }), + markAllNotificationsRead: () => of({ updated: 1 }), + deleteNotification: () => of(undefined), + ...api, + }, + }, + ], + }); + return { store: TestBed.inject(NotificationStore), navigateByUrl }; +} + +describe('NotificationStore', () => { + afterEach(() => TestBed.inject(NotificationStore).stopPolling()); + + it('updates state when a notification is marked as read', () => { + const { store } = setup(); + store.notifications.set([unread]); + store.unreadCount.set(1); + + store.markAsRead('n1'); + + expect(store.notifications()[0]?.read).toBe(true); + expect(store.unreadCount()).toBe(0); + }); + + it('deletes notifications and updates the badge count', () => { + const { store } = setup(); + store.notifications.set([unread]); + store.unreadCount.set(1); + store.total.set(1); + + store.delete('n1'); + + expect(store.notifications()).toEqual([]); + expect(store.unreadCount()).toBe(0); + expect(store.total()).toBe(0); + }); + + it('marks all notifications as read in the current state', () => { + const { store } = setup(); + store.notifications.set([unread]); + store.unreadCount.set(1); + + store.markAllAsRead(); + + expect(store.notifications()[0]?.read).toBe(true); + expect(store.unreadCount()).toBe(0); + }); + + it('navigates only internal links', () => { + const { store, navigateByUrl } = setup(); + store.openNotification({ ...unread, read: true, link: '/items/1' }); + store.openNotification({ ...unread, id: 'n2', read: true, link: 'https://example.com' }); + + expect(navigateByUrl).toHaveBeenCalledTimes(1); + expect(navigateByUrl).toHaveBeenCalledWith('/items/1'); + }); + + it('stops polling on logout and clears notification state', () => { + const count = vi.fn(() => of({ count: 3 })); + const { store } = setup({ unreadNotificationCount: count }); + store.notifications.set([unread]); + store.unreadCount.set(3); + + store.startPolling(); + store.stopPolling(); + + expect(store.notifications()).toEqual([]); + expect(store.unreadCount()).toBe(0); + }); + + it('does not poll while the tab is hidden', () => { + const count = vi.fn(() => of({ count: 1 })); + const { store } = setup({ unreadNotificationCount: count }); + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); + + store.startPolling(); + document.dispatchEvent(new Event('visibilitychange')); + + expect(count).toHaveBeenCalledTimes(1); + }); + + it('stores API errors with request id', () => { + const { store } = setup({ + notifications: () => + throwError(() => ({ + error: { + status: 500, + code: 'INTERNAL_ERROR', + message: 'Fehler', + requestId: 'req-1', + }, + })), + }); + + store.load(); + + expect(store.error()?.requestId).toBe('req-1'); + }); +}); diff --git a/apps/frontend/src/app/core/notification.store.ts b/apps/frontend/src/app/core/notification.store.ts new file mode 100644 index 0000000..38242a5 --- /dev/null +++ b/apps/frontend/src/app/core/notification.store.ts @@ -0,0 +1,213 @@ +import { Injectable, InjectionToken, computed, inject, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { ApiClientService } from '@boilerplate/api-client'; +import type { + ApiErrorBody, + NotificationDto, + NotificationStatusFilter, +} from '@boilerplate/api-client'; +import type { Subscription } from 'rxjs'; +import { fromEvent, timer } from 'rxjs'; + +export const NOTIFICATION_POLL_INTERVAL_MS = new InjectionToken( + 'NOTIFICATION_POLL_INTERVAL_MS', + { factory: () => 60_000 }, +); + +@Injectable({ providedIn: 'root' }) +export class NotificationStore { + private readonly api = inject(ApiClientService); + private readonly router = inject(Router); + private readonly intervalMs = inject(NOTIFICATION_POLL_INTERVAL_MS); + private pollingSubscription: Subscription | null = null; + private visibilitySubscription: Subscription | null = null; + private unreadRequestActive = false; + private listRequestActive = false; + + readonly notifications = signal([]); + readonly unreadCount = signal(0); + readonly loading = signal(false); + readonly error = signal(null); + readonly currentFilter = signal('all'); + readonly page = signal(1); + readonly pageSize = signal(20); + readonly total = signal(0); + readonly unreadItems = computed(() => + this.notifications().filter((notification) => !notification.read), + ); + + startPolling(): void { + if (this.pollingSubscription) { + return; + } + this.refreshUnreadCount(); + this.pollingSubscription = timer(this.intervalMs, this.intervalMs).subscribe(() => { + if (!document.hidden) { + this.refreshUnreadCount(); + } + }); + this.visibilitySubscription = fromEvent(document, 'visibilitychange').subscribe(() => { + if (!document.hidden) { + this.refreshUnreadCount(); + } + }); + } + + stopPolling(): void { + this.pollingSubscription?.unsubscribe(); + this.visibilitySubscription?.unsubscribe(); + this.pollingSubscription = null; + this.visibilitySubscription = null; + this.unreadRequestActive = false; + this.listRequestActive = false; + this.notifications.set([]); + this.unreadCount.set(0); + this.error.set(null); + } + + load(filter = this.currentFilter(), page = this.page()): void { + if (this.listRequestActive) { + return; + } + this.listRequestActive = true; + this.loading.set(true); + this.error.set(null); + this.currentFilter.set(filter); + this.page.set(page); + this.api.notifications({ status: filter, page, pageSize: this.pageSize() }).subscribe({ + next: (result) => { + this.notifications.set(result.items); + this.total.set(result.total); + this.unreadCount.set(result.unreadCount); + }, + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + complete: () => { + this.loading.set(false); + this.listRequestActive = false; + }, + }); + } + + refreshUnreadCount(): void { + if (this.unreadRequestActive) { + return; + } + this.unreadRequestActive = true; + this.api.unreadNotificationCount().subscribe({ + next: (result) => this.unreadCount.set(result.count), + error: () => undefined, + complete: () => { + this.unreadRequestActive = false; + }, + }); + } + + openPanel(): void { + this.refreshUnreadCount(); + this.load('all', 1); + } + + markAsRead(id: string): void { + this.api.markNotificationRead(id).subscribe({ + next: (updated) => this.replaceNotification(updated), + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + }); + } + + markAsUnread(id: string): void { + this.api.markNotificationUnread(id).subscribe({ + next: (updated) => this.replaceNotification(updated), + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + }); + } + + markAllAsRead(): void { + this.api.markAllNotificationsRead().subscribe({ + next: () => { + this.notifications.update((items) => + items.map((item) => ({ + ...item, + read: true, + readAt: item.readAt ?? new Date().toISOString(), + })), + ); + this.unreadCount.set(0); + }, + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + }); + } + + delete(id: string): void { + this.api.deleteNotification(id).subscribe({ + next: () => { + const deleted = this.notifications().find((item) => item.id === id); + this.notifications.update((items) => items.filter((item) => item.id !== id)); + this.total.update((total) => Math.max(0, total - 1)); + if (deleted && !deleted.read) { + this.unreadCount.update((count) => Math.max(0, count - 1)); + } + }, + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + }); + } + + openNotification(notification: NotificationDto): void { + if (notification.link && !this.isInternalLink(notification.link)) { + return; + } + const navigate = () => { + if (notification.link) { + void this.router.navigateByUrl(notification.link); + } + }; + if (notification.read) { + navigate(); + return; + } + this.api.markNotificationRead(notification.id).subscribe({ + next: (updated) => { + this.replaceNotification(updated); + navigate(); + }, + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + }); + } + + isInternalLink(link: string | null): link is string { + if (!link) { + return false; + } + return ( + link.startsWith('/') && + !link.startsWith('//') && + !link.includes('\\') && + !/^[a-z][a-z0-9+.-]*:/i.test(link) && + !link.toLowerCase().includes('javascript:') + ); + } + + private replaceNotification(updated: NotificationDto): void { + const previous = this.notifications().find((item) => item.id === updated.id); + this.notifications.update((items) => + items.map((item) => (item.id === updated.id ? updated : item)), + ); + if (previous && previous.read !== updated.read) { + this.unreadCount.update((count) => (updated.read ? Math.max(0, count - 1) : count + 1)); + } + } + + private genericError(): ApiErrorBody { + return { + status: 0, + code: 'CLIENT_ERROR', + message: 'Benachrichtigungen konnten nicht geladen werden.', + requestId: '', + }; + } +} diff --git a/apps/frontend/src/app/features/admin/admin-permissions.ts b/apps/frontend/src/app/features/admin/admin-permissions.ts new file mode 100644 index 0000000..e7123c3 --- /dev/null +++ b/apps/frontend/src/app/features/admin/admin-permissions.ts @@ -0,0 +1,57 @@ +import type { Permission } from '@boilerplate/api-client'; + +export interface PermissionDefinition { + id: Permission; + label: string; +} + +export interface PermissionGroup { + title: string; + permissions: PermissionDefinition[]; +} + +export const adminPermissionGroups: PermissionGroup[] = [ + { + title: 'Items', + permissions: [ + { id: 'items.read', label: 'Items anzeigen' }, + { id: 'items.create', label: 'Items anlegen' }, + { id: 'items.update', label: 'Items bearbeiten' }, + { id: 'items.delete', label: 'Items loeschen' }, + ], + }, + { + title: 'Benutzer', + permissions: [ + { id: 'users.read', label: 'Benutzer anzeigen' }, + { id: 'users.manage', label: 'Benutzer aktivieren, deaktivieren und Rollen verwalten' }, + ], + }, + { + title: 'Rollen', + permissions: [ + { id: 'roles.read', label: 'Rollen und Permissions anzeigen' }, + { id: 'roles.manage', label: 'Rollen anlegen, bearbeiten und loeschen' }, + ], + }, + { + title: 'Sessions', + permissions: [ + { id: 'sessions.readOwn', label: 'Eigene Sessions anzeigen' }, + { id: 'sessions.revokeOwn', label: 'Eigene Sessions beenden' }, + { id: 'sessions.manage', label: 'Sessions anderer Benutzer verwalten' }, + ], + }, + { + title: 'Audit', + permissions: [{ id: 'audit.read', label: 'Administratives Audit-Log anzeigen' }], + }, + { + title: 'Benachrichtigungen', + permissions: [ + { id: 'notifications.readOwn', label: 'Eigene Benachrichtigungen lesen' }, + { id: 'notifications.updateOwn', label: 'Eigene Benachrichtigungen verwalten' }, + { id: 'notifications.manage', label: 'Benachrichtigungen administrativ erstellen' }, + ], + }, +]; diff --git a/apps/frontend/src/app/features/admin/admin-role-detail.page.spec.ts b/apps/frontend/src/app/features/admin/admin-role-detail.page.spec.ts new file mode 100644 index 0000000..03a994d --- /dev/null +++ b/apps/frontend/src/app/features/admin/admin-role-detail.page.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApiClientService } from '@boilerplate/api-client'; +import { AdminRoleDetailPageComponent } from './admin-role-detail.page'; +import { adminPermissionGroups } from './admin-permissions'; + +describe('AdminRoleDetailPageComponent', () => { + it('groups permissions for the role editor', async () => { + await TestBed.configureTestingModule({ + imports: [AdminRoleDetailPageComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { snapshot: { paramMap: { get: () => 'new' } } }, + }, + { + provide: Router, + useValue: { navigate: vi.fn() }, + }, + { + provide: ApiClientService, + useValue: {}, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(AdminRoleDetailPageComponent); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(adminPermissionGroups.some((group) => group.title === 'Benutzer')).toBe(true); + expect(element.textContent).toContain('Benutzer aktivieren'); + expect(element.textContent).toContain('notifications.manage'); + }); +}); diff --git a/apps/frontend/src/app/features/admin/admin-role-detail.page.ts b/apps/frontend/src/app/features/admin/admin-role-detail.page.ts new file mode 100644 index 0000000..acfab26 --- /dev/null +++ b/apps/frontend/src/app/features/admin/admin-role-detail.page.ts @@ -0,0 +1,274 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { + ApiClientService, + type ApiErrorBody, + type Permission, + type RoleDto, +} from '@boilerplate/api-client'; +import { adminPermissionGroups } from './admin-permissions'; + +@Component({ + standalone: true, + imports: [FormsModule, RouterLink], + template: ` + Zurueck zur Rollenliste + + @if (error(); as currentError) { +
+ {{ messageFor(currentError) }} + @if (currentError.requestId) { + Request-ID: {{ currentError.requestId }} + } +
+ } + + @if (!loading()) { +
+
+

{{ isNew() ? 'Rolle anlegen' : 'Rolle bearbeiten' }}

+ @if (role()?.system) { + Systemrolle + } + + +
+ +
+

Permissions

+
+ @for (group of groups; track group.title) { +
+ {{ group.title }} + @for (permission of group.permissions; track permission.id) { + + } +
+ } +
+
+ +
+ + @if (role(); as currentRole) { + @if (!currentRole.system) { + + } + } +
+
+ } @else { +
Rolle wird geladen.
+ } + `, + styles: [ + ` + a { + display: inline-flex; + margin-bottom: 16px; + color: var(--color-primary-hover); + } + .notice, + .editor { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + } + .notice.error { + border-color: var(--color-danger); + color: var(--color-danger); + margin-bottom: 16px; + } + .editor, + section, + .groups, + fieldset, + .actions { + display: grid; + gap: 14px; + } + h2, + h3 { + margin: 0; + } + label { + display: grid; + gap: 6px; + } + input, + textarea, + button { + min-height: 44px; + } + input, + textarea { + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 8px 10px; + } + textarea { + min-height: 96px; + resize: vertical; + } + button { + border: 0; + border-radius: 6px; + background: var(--color-primary); + color: var(--color-surface); + padding: 0 14px; + } + button.danger { + background: var(--color-danger); + } + button:disabled { + opacity: 0.55; + } + fieldset { + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 12px; + } + fieldset label { + grid-template-columns: 24px 1fr; + align-items: start; + } + code { + display: block; + margin-bottom: 2px; + } + .system { + width: fit-content; + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 4px 8px; + color: var(--color-text-secondary); + } + @media (min-width: 900px) { + .groups { + grid-template-columns: 1fr 1fr; + } + .actions { + grid-template-columns: auto auto; + justify-content: start; + } + } + `, + ], +}) +export class AdminRoleDetailPageComponent { + private readonly api = inject(ApiClientService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + readonly role = signal(null); + readonly loading = signal(false); + readonly error = signal(null); + readonly selectedPermissions = signal(new Set()); + readonly groups = adminPermissionGroups; + readonly isNew = computed(() => this.route.snapshot.paramMap.get('id') === 'new'); + name = ''; + description = ''; + + constructor() { + if (!this.isNew()) this.load(); + } + + load(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) return; + this.loading.set(true); + this.error.set(null); + this.api.adminRole(id).subscribe({ + next: (role) => { + this.role.set(role); + this.name = role.name; + this.description = role.description; + this.selectedPermissions.set(new Set(role.permissions.map((permission) => permission.id))); + this.loading.set(false); + }, + error: (error: { error?: ApiErrorBody }) => { + this.error.set(error.error ?? this.genericError()); + this.loading.set(false); + }, + }); + } + + toggle(permission: Permission): void { + if (this.role()?.name === 'admin') return; + const next = new Set(this.selectedPermissions()); + if (next.has(permission)) next.delete(permission); + else next.add(permission); + this.selectedPermissions.set(next); + } + + save(): void { + const body = { + name: this.name, + description: this.description, + permissions: Array.from(this.selectedPermissions()), + }; + const request = this.isNew() + ? this.api.createAdminRole(body) + : this.api.updateAdminRole(this.role()?.id ?? '', body); + request.subscribe({ + next: (role) => { + void this.router.navigate(['/admin/roles', role.id]); + }, + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + }); + } + + delete(role: RoleDto): void { + if (!confirm('Rolle wirklich loeschen?')) return; + this.api.deleteAdminRole(role.id).subscribe({ + next: () => { + void this.router.navigate(['/admin/roles']); + }, + error: (error: { error?: ApiErrorBody }) => + this.error.set(error.error ?? this.genericError()), + }); + } + + messageFor(error: ApiErrorBody): string { + if (error.code === 'ROLE_STILL_ASSIGNED') return 'Diese Rolle ist noch Benutzern zugewiesen.'; + if (error.code === 'SYSTEM_ROLE_PROTECTED') return 'Diese Systemrolle ist geschuetzt.'; + if (error.code === 'LAST_ACTIVE_ADMIN_REQUIRED') { + return 'Mindestens ein aktiver Administrator muss erhalten bleiben.'; + } + return error.message; + } + + private genericError(): ApiErrorBody { + return { + status: 0, + code: 'UNKNOWN', + message: 'Die Rolle konnte nicht verarbeitet werden.', + requestId: '', + }; + } +} diff --git a/apps/frontend/src/app/features/admin/admin-roles.page.ts b/apps/frontend/src/app/features/admin/admin-roles.page.ts new file mode 100644 index 0000000..9551a5f --- /dev/null +++ b/apps/frontend/src/app/features/admin/admin-roles.page.ts @@ -0,0 +1,154 @@ +import { Component, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ApiClientService, type ApiErrorBody, type RoleDto } from '@boilerplate/api-client'; + +@Component({ + standalone: true, + imports: [RouterLink], + template: ` + + + @if (error(); as currentError) { +
+ {{ currentError.message }} + @if (currentError.requestId) { + Request-ID: {{ currentError.requestId }} + } +
+ } + + @if (loading()) { +
Rollen werden geladen.
+ } @else if (roles().length === 0) { +
Keine Rollen vorhanden.
+ } @else { +
+ @for (role of roles(); track role.id) { +
+
+ + {{ role.name }} + @if (role.system) { + Systemrolle + } + + {{ role.description || 'Keine Beschreibung' }} +
+
+
+
Benutzer
+
{{ role.userCount ?? role.users?.length ?? 0 }}
+
+
+
Permissions
+
{{ role.permissions.length }}
+
+
+ Bearbeiten +
+ } +
+ } + `, + styles: [ + ` + .toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 16px; + } + .notice, + article { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + } + .notice.error { + border-color: var(--color-danger); + color: var(--color-danger); + } + .list { + display: grid; + gap: 12px; + } + article { + display: grid; + gap: 12px; + } + .button { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background: var(--color-primary); + color: var(--color-surface); + padding: 0 14px; + text-decoration: none; + } + .button.secondary { + background: var(--color-text-secondary); + } + dl { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin: 0; + } + dt, + span, + small { + color: var(--color-text-muted); + } + dd { + margin: 0; + } + small { + margin-left: 6px; + } + @media (min-width: 900px) { + article { + grid-template-columns: 1fr 220px auto; + align-items: center; + } + } + `, + ], +}) +export class AdminRolesPageComponent { + private readonly api = inject(ApiClientService); + readonly roles = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + + constructor() { + this.load(); + } + + load(): void { + this.loading.set(true); + this.error.set(null); + this.api.adminRoles().subscribe({ + next: (roles) => { + this.roles.set(roles); + this.loading.set(false); + }, + error: (error: { error?: ApiErrorBody }) => { + this.error.set(error.error ?? this.genericError()); + this.loading.set(false); + }, + }); + } + + private genericError(): ApiErrorBody { + return { + status: 0, + code: 'UNKNOWN', + message: 'Rollen konnten nicht geladen werden.', + requestId: '', + }; + } +} diff --git a/apps/frontend/src/app/features/admin/admin-user-detail.page.ts b/apps/frontend/src/app/features/admin/admin-user-detail.page.ts new file mode 100644 index 0000000..8101797 --- /dev/null +++ b/apps/frontend/src/app/features/admin/admin-user-detail.page.ts @@ -0,0 +1,368 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { + ApiClientService, + type AdminSessionDto, + type AdminUserDetailDto, + type ApiErrorBody, + type RoleDto, +} from '@boilerplate/api-client'; + +@Component({ + standalone: true, + imports: [FormsModule, RouterLink], + template: ` + Zurueck zur Benutzerliste + + @if (error(); as currentError) { +
+ {{ messageFor(currentError) }} + @if (currentError.requestId) { + Request-ID: {{ currentError.requestId }} + } +
+ } + + @if (user(); as currentUser) { +
+
+

{{ currentUser.name }}

+

{{ currentUser.email || 'Keine E-Mail' }}

+

{{ currentUser.active ? 'Aktiv' : 'Deaktiviert' }}

+
+
+ @if (currentUser.active) { + + } @else { + + } + +
+
+ +
+
+

Rollen

+
+ @for (role of currentUser.roles; track role.id) { + + {{ role.name }} + + + } +
+
+ + +
+
+ +
+

Effektive Permissions

+
+ @for (permission of currentUser.effectivePermissions; track permission) { + {{ permission }} + } +
+
+
+ +
+

Aktive Sessions

+ @if (currentUser.sessions.length === 0) { +
Keine aktiven Sessions.
+ } @else { +
+ @for (session of currentUser.sessions; track session.id) { +
+
+ {{ session.current ? 'Aktuelle Session' : 'Session' }} + {{ session.userAgent || 'Unbekannter Browser' }} + {{ session.approximateIp || 'Keine IP' }} +
+
+
+
Aktivitaet
+
{{ session.lastActivityAt }}
+
+
+
Ablauf
+
{{ session.expiresAt }}
+
+
+ +
+ } +
+ } +
+ } @else if (loading()) { +
Benutzer wird geladen.
+ } + `, + styles: [ + ` + a { + display: inline-flex; + margin-bottom: 16px; + color: var(--color-primary-hover); + } + .notice, + .hero, + article { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + } + .notice.error { + border-color: var(--color-danger); + color: var(--color-danger); + margin-bottom: 16px; + } + .hero { + display: grid; + gap: 16px; + margin-bottom: 16px; + } + .hero.inactive { + border-left: 4px solid var(--color-border-strong); + } + h2, + h3, + p { + margin: 0; + } + h3 { + margin-bottom: 12px; + } + .actions, + .inline, + .grid, + .list, + article, + dl, + .permission-list { + display: grid; + gap: 12px; + } + button, + select { + min-height: 44px; + } + button { + border: 0; + border-radius: 6px; + background: var(--color-primary); + color: var(--color-surface); + padding: 0 14px; + } + button.secondary { + background: var(--color-text-secondary); + } + button.danger { + background: var(--color-danger); + } + button:disabled { + opacity: 0.55; + } + select { + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0 10px; + } + .chips, + .permission-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .chips span { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 4px 6px 4px 10px; + } + .chips button { + min-height: 34px; + background: var(--color-danger); + } + code { + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + padding: 6px 8px; + } + dl { + margin: 0; + } + dt { + color: var(--color-text-muted); + font-size: 0.88rem; + } + dd { + margin: 0; + } + @media (min-width: 900px) { + .hero, + .list article { + grid-template-columns: 1fr auto; + align-items: center; + } + .actions, + .inline { + grid-template-columns: auto auto; + } + .grid { + grid-template-columns: 1fr 1fr; + margin-bottom: 16px; + } + .list article { + grid-template-columns: 1.2fr 1fr auto; + } + } + `, + ], +}) +export class AdminUserDetailPageComponent { + private readonly api = inject(ApiClientService); + private readonly route = inject(ActivatedRoute); + readonly user = signal(null); + readonly roles = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + selectedRoleId = ''; + + constructor() { + this.api.adminRoles().subscribe((roles) => this.roles.set(roles)); + this.load(); + } + + load(): void { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) return; + this.loading.set(true); + this.error.set(null); + this.api.adminUser(id).subscribe({ + next: (user) => { + this.user.set(user); + this.selectedRoleId = ''; + this.loading.set(false); + }, + error: (error: { error?: ApiErrorBody }) => { + this.error.set(error.error ?? this.genericError()); + this.loading.set(false); + }, + }); + } + + activate(id: string): void { + this.api.activateAdminUser(id).subscribe({ + next: () => this.load(), + error: (error: unknown) => this.handleError(error), + }); + } + + deactivate(id: string): void { + if (!confirm('Benutzer wirklich deaktivieren und alle Sessions beenden?')) return; + this.api.deactivateAdminUser(id).subscribe({ + next: () => this.load(), + error: (error: unknown) => this.handleError(error), + }); + } + + assignRole(userId: string): void { + if (!this.selectedRoleId) return; + this.api.assignAdminUserRole(userId, this.selectedRoleId).subscribe({ + next: () => this.load(), + error: (error: unknown) => this.handleError(error), + }); + } + + removeRole(userId: string, roleId: string, roleName: string): void { + if (roleName === 'admin' && !confirm('Adminrolle wirklich entfernen?')) return; + this.api.removeAdminUserRole(userId, roleId).subscribe({ + next: () => this.load(), + error: (error: unknown) => this.handleError(error), + }); + } + + revokeSession(userId: string, session: AdminSessionDto): void { + this.api.revokeAdminUserSession(userId, session.id).subscribe({ + next: () => this.load(), + error: (error: unknown) => this.handleError(error), + }); + } + + revokeAllSessions(userId: string): void { + if (!confirm('Alle Sessions dieses Benutzers beenden?')) return; + this.api.revokeAdminUserSessions(userId).subscribe({ + next: () => this.load(), + error: (error: unknown) => this.handleError(error), + }); + } + + availableRoles(user: AdminUserDetailDto): RoleDto[] { + const assigned = new Set(user.roles.map((role) => role.id)); + return this.roles().filter((role) => !assigned.has(role.id)); + } + + messageFor(error: ApiErrorBody): string { + if (error.code === 'LAST_ACTIVE_ADMIN_REQUIRED') { + return 'Dieser Benutzer ist der letzte aktive Administrator und kann nicht entmachtet werden.'; + } + return error.message; + } + + private handleError(error: unknown): void { + this.error.set(this.extractError(error)); + } + + private extractError(error: unknown): ApiErrorBody { + if (typeof error === 'object' && error !== null && 'error' in error) { + const body = (error as { error?: unknown }).error; + if (this.isApiErrorBody(body)) return body; + } + return this.genericError(); + } + + private isApiErrorBody(value: unknown): value is ApiErrorBody { + return ( + typeof value === 'object' && + value !== null && + 'message' in value && + 'code' in value && + 'status' in value && + 'requestId' in value + ); + } + + private genericError(): ApiErrorBody { + return { + status: 0, + code: 'UNKNOWN', + message: 'Die Aktion konnte nicht ausgefuehrt werden.', + requestId: '', + }; + } +} diff --git a/apps/frontend/src/app/features/admin/admin-users.page.spec.ts b/apps/frontend/src/app/features/admin/admin-users.page.spec.ts new file mode 100644 index 0000000..3541ffe --- /dev/null +++ b/apps/frontend/src/app/features/admin/admin-users.page.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { ApiClientService } from '@boilerplate/api-client'; +import { AdminUsersPageComponent } from './admin-users.page'; + +describe('AdminUsersPageComponent', () => { + it('loads users and marks inactive accounts clearly', async () => { + await TestBed.configureTestingModule({ + imports: [AdminUsersPageComponent], + providers: [ + provideRouter([]), + { + provide: ApiClientService, + useValue: { + adminRoles: () => of([]), + adminUsers: () => + of({ + items: [ + { + id: 'user-1', + name: 'Max Mustermann', + email: 'max@example.com', + active: false, + roles: [{ id: 'role-user', name: 'user', system: true }], + lastLoginAt: null, + createdAt: '2026-07-16T08:00:00.000Z', + activeSessionCount: 0, + }, + ], + total: 1, + page: 1, + pageSize: 25, + }), + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(AdminUsersPageComponent); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('Max Mustermann'); + expect(element.querySelector('article.inactive')).not.toBeNull(); + }); +}); diff --git a/apps/frontend/src/app/features/admin/admin-users.page.ts b/apps/frontend/src/app/features/admin/admin-users.page.ts new file mode 100644 index 0000000..18a78e5 --- /dev/null +++ b/apps/frontend/src/app/features/admin/admin-users.page.ts @@ -0,0 +1,270 @@ +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { + ApiClientService, + type AdminUserListItemDto, + type ApiErrorBody, + type RoleDto, +} from '@boilerplate/api-client'; + +type ActiveFilter = 'all' | 'active' | 'inactive'; +type UserSort = 'name' | 'email' | 'lastLoginAt' | 'createdAt'; + +@Component({ + standalone: true, + imports: [FormsModule, RouterLink], + template: ` +
+ + + + + +
+ + @if (error(); as currentError) { +
+ {{ currentError.message }} + @if (currentError.requestId) { + Request-ID: {{ currentError.requestId }} + } +
+ } + + @if (loading()) { +
Benutzer werden geladen.
+ } @else if (users().length === 0) { +
Keine Benutzer gefunden.
+ } @else { +
+ @for (user of users(); track user.id) { +
+
+ {{ user.name }} + {{ user.email || 'Keine E-Mail' }} + {{ user.active ? 'Aktiv' : 'Deaktiviert' }} +
+
+ @for (role of user.roles; track role.id) { + {{ role.name }} + } +
+
+
+
Sessions
+
{{ user.activeSessionCount }}
+
+
+
Letzter Login
+
{{ user.lastLoginAt || 'nie' }}
+
+
+ Details +
+ } +
+ + } + `, + styles: [ + ` + .toolbar { + display: grid; + gap: 12px; + margin-bottom: 16px; + } + label, + .summary, + dl { + display: grid; + gap: 6px; + } + input, + select, + button, + .button { + min-height: 44px; + } + input, + select { + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 0 10px; + } + button, + .button { + border: 0; + border-radius: 6px; + background: var(--color-primary); + color: var(--color-surface); + padding: 0 14px; + } + .button { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + } + .notice, + article { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + } + .notice.error { + border-color: var(--color-danger); + color: var(--color-danger); + } + .list { + display: grid; + gap: 12px; + } + article { + display: grid; + gap: 12px; + } + article.inactive { + border-left: 4px solid var(--color-border-strong); + } + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .chips span { + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 4px 8px; + background: var(--color-background); + } + dl { + margin: 0; + } + dt { + color: var(--color-text-muted); + font-size: 0.88rem; + } + dd { + margin: 0; + } + .pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 16px; + } + @media (min-width: 900px) { + .toolbar { + grid-template-columns: 2fr 1fr 1fr 1fr auto; + align-items: end; + } + article { + grid-template-columns: 1.4fr 1fr 1.2fr auto; + align-items: center; + } + } + `, + ], +}) +export class AdminUsersPageComponent { + private readonly api = inject(ApiClientService); + readonly users = signal([]); + readonly roles = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + search = ''; + active: ActiveFilter = 'all'; + roleId = ''; + sort: UserSort = 'name'; + page = 1; + pageSize = 25; + total = 0; + + constructor() { + this.api.adminRoles().subscribe((roles) => this.roles.set(roles)); + this.load(); + } + + load(): void { + this.loading.set(true); + this.error.set(null); + this.api + .adminUsers({ + search: this.search, + active: this.active, + roleId: this.roleId, + sort: this.sort, + page: this.page, + pageSize: this.pageSize, + }) + .subscribe({ + next: (page) => { + this.users.set(page.items); + this.total = page.total; + this.page = page.page; + this.pageSize = page.pageSize; + this.loading.set(false); + }, + error: (error: { error?: ApiErrorBody }) => { + this.error.set(error.error ?? this.genericError()); + this.loading.set(false); + }, + }); + } + + previous(): void { + this.page -= 1; + this.load(); + } + + next(): void { + this.page += 1; + this.load(); + } + + totalPages(): number { + return Math.max(1, Math.ceil(this.total / this.pageSize)); + } + + private genericError(): ApiErrorBody { + return { + status: 0, + code: 'UNKNOWN', + message: 'Benutzer konnten nicht geladen werden.', + requestId: '', + }; + } +} diff --git a/apps/frontend/src/app/features/audit/audit.page.ts b/apps/frontend/src/app/features/audit/audit.page.ts index c75f339..3b8dab8 100644 --- a/apps/frontend/src/app/features/audit/audit.page.ts +++ b/apps/frontend/src/app/features/audit/audit.page.ts @@ -4,36 +4,18 @@ import { ApiClientService, type AuditLogDto } from '@boilerplate/api-client'; @Component({ standalone: true, template: ` -
+
@for (entry of entries(); track entry.id) { -
+
{{ labels[entry.action] || entry.action }} - {{ entry.createdAt }} · Objekt: {{ entry.targetType }} {{ entry.targetId }} - Request-ID: {{ entry.requestId }} + {{ entry.createdAt }} · Objekt: {{ entry.targetType }} {{ entry.targetId }} + Request-ID: {{ entry.requestId }}
}
`, - styles: [ - ` - .list { - display: grid; - gap: 12px; - } - article { - display: grid; - gap: 6px; - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 16px; - } - span, - small { - color: #637083; - } - `, - ], }) export class AuditPageComponent { private readonly api = inject(ApiClientService); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.page.ts b/apps/frontend/src/app/features/dashboard/dashboard.page.ts index a52634d..818ff90 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.page.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.page.ts @@ -4,38 +4,15 @@ import { ApiClientService } from '@boilerplate/api-client'; @Component({ standalone: true, template: ` -
+
@for (card of cards(); track card.label) { -
- {{ card.label }} - {{ card.value }} +
+ {{ card.label }} + {{ card.value }}
}
`, - styles: [ - ` - .kpis { - display: grid; - gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - } - article { - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 18px; - } - span { - display: block; - color: #637083; - margin-bottom: 8px; - } - strong { - font-size: 2rem; - } - `, - ], }) export class DashboardPageComponent { private readonly api = inject(ApiClientService); diff --git a/apps/frontend/src/app/features/dev/design-system.page.ts b/apps/frontend/src/app/features/dev/design-system.page.ts new file mode 100644 index 0000000..12ba555 --- /dev/null +++ b/apps/frontend/src/app/features/dev/design-system.page.ts @@ -0,0 +1,207 @@ +import { Component, viewChild } from '@angular/core'; +import { + UiButtonComponent, + UiConfirmDialogComponent, + UiEmptyStateComponent, + UiIconButtonComponent, + UiLoadingStateComponent, + UiPaginationComponent, + UiStatusBadgeComponent, + ToastService, + UiToastHostComponent, +} from '../../shared/ui'; +import { inject } from '@angular/core'; + +const colors = [ + 'primary', + 'primary-hover', + 'primary-active', + 'primary-subtle', + 'secondary', + 'background', + 'surface', + 'surface-elevated', + 'text-primary', + 'text-secondary', + 'text-muted', + 'border', + 'border-strong', + 'focus', + 'success', + 'success-subtle', + 'warning', + 'warning-subtle', + 'danger', + 'danger-subtle', + 'info', + 'info-subtle', +]; + +@Component({ + standalone: true, + imports: [ + UiButtonComponent, + UiConfirmDialogComponent, + UiEmptyStateComponent, + UiIconButtonComponent, + UiLoadingStateComponent, + UiPaginationComponent, + UiStatusBadgeComponent, + UiToastHostComponent, + ], + template: ` +
+
+
+

Designsystem

+

Interne Referenz fuer Tokens, Komponenten und mobile Muster.

+
+
+ +
+

Farben

+
+ @for (color of colors; track color) { +
+ + --color-{{ color }} +
+ } +
+
+ +
+

Typografie

+

Seitentitel

+

Bereichstitel

+

Fliesstext mit Systemschrift und ruhiger Zeilenhoehe.

+

Hilfetext und Metainformationen

+
+ +
+

Buttons und Status

+
+ + + + + + +
+
+ + + + +
+
+ +
+

Formulare

+ + + +
+ +
+

Karten, Tabelle und Pagination

+
+
Interaktive Karte
+
Warnkarte
+
+
+ + + + + + + + + + + + + + + +
NameStatusAktion
BeispielAktivBearbeiten
+
+ +
+ +
+

Dialoge, Toasts und States

+
+ + +
+ + +
+
+ + + + `, + styles: [ + ` + .swatches { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: var(--space-4); + } + .swatches article { + display: grid; + gap: var(--space-2); + } + .swatch { + height: 3rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + } + `, + ], +}) +export class DesignSystemPageComponent { + readonly colors = colors; + private readonly toasts = inject(ToastService); + private readonly dialog = viewChild(UiConfirmDialogComponent); + + openDialog(): void { + void this.dialog()?.open({ + title: 'Aktion bestaetigen', + description: 'Dieser Dialog zeigt Fokusmanagement, Escape und Rueckgabe des Ergebnisses.', + confirmLabel: 'Bestaetigen', + }); + } + + showToast(): void { + this.toasts.show({ + tone: 'info', + title: 'Toast angezeigt', + message: 'Kurze Rueckmeldung ohne fachliche Entscheidung.', + }); + } +} diff --git a/apps/frontend/src/app/features/errors/error.page.ts b/apps/frontend/src/app/features/errors/error.page.ts index f4c366d..33d4241 100644 --- a/apps/frontend/src/app/features/errors/error.page.ts +++ b/apps/frontend/src/app/features/errors/error.page.ts @@ -1,17 +1,15 @@ import { Component } from '@angular/core'; +import { UiEmptyStateComponent } from '../../shared/ui'; @Component({ standalone: true, - template: `

Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.

`, - styles: [ - ` - .state { - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 20px; - } - `, - ], + imports: [UiEmptyStateComponent], + template: ` + + `, }) export class ErrorPageComponent {} diff --git a/apps/frontend/src/app/features/errors/forbidden.page.ts b/apps/frontend/src/app/features/errors/forbidden.page.ts index 95346c9..1eb6321 100644 --- a/apps/frontend/src/app/features/errors/forbidden.page.ts +++ b/apps/frontend/src/app/features/errors/forbidden.page.ts @@ -1,17 +1,15 @@ import { Component } from '@angular/core'; +import { UiEmptyStateComponent } from '../../shared/ui'; @Component({ standalone: true, - template: `

Keine Berechtigung fuer diese Seite.

`, - styles: [ - ` - .state { - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 20px; - } - `, - ], + imports: [UiEmptyStateComponent], + template: ` + + `, }) export class ForbiddenPageComponent {} diff --git a/apps/frontend/src/app/features/errors/not-found.page.ts b/apps/frontend/src/app/features/errors/not-found.page.ts index 5a6662a..f295f8a 100644 --- a/apps/frontend/src/app/features/errors/not-found.page.ts +++ b/apps/frontend/src/app/features/errors/not-found.page.ts @@ -1,17 +1,15 @@ import { Component } from '@angular/core'; +import { UiEmptyStateComponent } from '../../shared/ui'; @Component({ standalone: true, - template: `

Die angeforderte Seite wurde nicht gefunden.

`, - styles: [ - ` - .state { - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 20px; - } - `, - ], + imports: [UiEmptyStateComponent], + template: ` + + `, }) export class NotFoundPageComponent {} diff --git a/apps/frontend/src/app/features/items/items.page.spec.ts b/apps/frontend/src/app/features/items/items.page.spec.ts index a796e39..098f85c 100644 --- a/apps/frontend/src/app/features/items/items.page.spec.ts +++ b/apps/frontend/src/app/features/items/items.page.spec.ts @@ -23,4 +23,49 @@ describe('ItemsPageComponent', () => { fixture.componentInstance.form.controls.name.setValue('Neues Item'); expect(fixture.componentInstance.form.valid).toBe(true); }); + + it('loads items with pagination parameters and changes pages', async () => { + const calls: { search?: string; page?: number; pageSize?: number }[] = []; + await TestBed.configureTestingModule({ + imports: [ItemsPageComponent], + providers: [ + { + provide: ApiClientService, + useValue: { + items: (query: { search?: string; page?: number; pageSize?: number } = {}) => { + calls.push(query); + return of({ + items: [ + { + id: `item-${query.page ?? 1}`, + name: 'Item', + description: null, + status: 'active', + version: 1, + createdAt: '2026-07-16T08:00:00.000Z', + updatedAt: '2026-07-16T08:00:00.000Z', + deletedAt: null, + }, + ], + total: 30, + page: query.page ?? 1, + pageSize: query.pageSize ?? 20, + }); + }, + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(ItemsPageComponent); + fixture.detectChanges(); + + expect(calls[0]).toEqual({ search: '', page: 1, pageSize: 20 }); + + fixture.componentInstance.goToPage(2); + fixture.detectChanges(); + + expect(calls[1]).toEqual({ search: '', page: 2, pageSize: 20 }); + expect(fixture.componentInstance.page()).toBe(2); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Seite 2 von 2'); + }); }); diff --git a/apps/frontend/src/app/features/items/items.page.ts b/apps/frontend/src/app/features/items/items.page.ts index 69fcc16..85568d9 100644 --- a/apps/frontend/src/app/features/items/items.page.ts +++ b/apps/frontend/src/app/features/items/items.page.ts @@ -1,104 +1,84 @@ import { Component, inject, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ApiClientService, type ApiErrorBody, type ItemDto } from '@boilerplate/api-client'; +import { UiPaginationComponent } from '../../shared/ui'; @Component({ standalone: true, - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, UiPaginationComponent], template: ` -
- - + + +
+ @if (error()) { -

{{ error() }}

+

{{ error() }}

} -
+ +
@for (item of items(); track item.id) { -
+
+ {{ item.description || 'Keine Beschreibung' }} + {{ item.status }} - Version {{ item.version }} + }
-
+ + @if (total() > 0) { + + } + +

{{ selected()?.id ? 'Item bearbeiten' : 'Item erstellen' }}

- - - + + -
- +
+ @if (selected(); as item) { - + }
`, styles: [ ` - .toolbar, - .panel { - display: grid; - gap: 12px; - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 16px; + .item-toolbar, + .item-grid { + margin-bottom: var(--space-5); } - .toolbar { - grid-template-columns: 1fr auto; - margin-bottom: 16px; + .item-card { + text-align: left; } - .grid { - display: grid; - gap: 12px; - margin-bottom: 16px; - } - article { - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 16px; - display: grid; - gap: 6px; - cursor: pointer; - } - label { - display: grid; - gap: 6px; - } - input, - textarea, - select, - button { - min-height: 44px; - } - textarea { - min-height: 96px; - } - button { - background: #26648e; - color: #fff; - border: 0; - padding: 0 16px; - } - .danger { - background: #a23b3b; - } - .error { - color: #a23b3b; - font-weight: 600; - } - @media (min-width: 760px) { - .grid { + @media (min-width: 48rem) { + .item-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -110,6 +90,9 @@ export class ItemsPageComponent { readonly items = signal([]); readonly selected = signal(null); readonly error = signal(''); + readonly page = signal(1); + readonly total = signal(0); + readonly pageSize = 20; readonly search = new FormControl('', { nonNullable: true }); readonly form = new FormGroup({ name: new FormControl('', { @@ -128,7 +111,27 @@ export class ItemsPageComponent { } load(): void { - this.api.items({ search: this.search.value }).subscribe((page) => this.items.set(page.items)); + this.api + .items({ + search: this.search.value, + page: this.page(), + pageSize: this.pageSize, + }) + .subscribe((page) => { + this.items.set(page.items); + this.page.set(page.page); + this.total.set(page.total); + }); + } + + searchItems(): void { + this.page.set(1); + this.load(); + } + + goToPage(page: number): void { + this.page.set(page); + this.load(); } edit(item: ItemDto): void { diff --git a/apps/frontend/src/app/features/notifications/notifications.page.spec.ts b/apps/frontend/src/app/features/notifications/notifications.page.spec.ts new file mode 100644 index 0000000..d4252c9 --- /dev/null +++ b/apps/frontend/src/app/features/notifications/notifications.page.spec.ts @@ -0,0 +1,54 @@ +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import type { NotificationDto } from '@boilerplate/api-client'; +import { NotificationStore } from '../../core/notification.store'; +import { NotificationsPageComponent } from './notifications.page'; + +const notification: NotificationDto = { + id: 'n1', + type: 'system', + title: 'Titel', + message: 'Nachricht', + link: '/', + metadata: null, + read: false, + readAt: null, + createdAt: '2026-07-16T08:00:00.000Z', +}; + +describe('NotificationsPageComponent', () => { + it('loads notifications and offers mobile-friendly actions', async () => { + const load = vi.fn(); + await TestBed.configureTestingModule({ + imports: [NotificationsPageComponent], + providers: [ + { + provide: NotificationStore, + useValue: { + notifications: signal([notification]), + loading: signal(false), + error: signal(null), + currentFilter: signal('all'), + page: signal(1), + pageSize: signal(20), + total: signal(1), + load, + markAllAsRead: vi.fn(), + openNotification: vi.fn(), + markAsRead: vi.fn(), + markAsUnread: vi.fn(), + delete: vi.fn(), + isInternalLink: () => true, + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(NotificationsPageComponent); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(load).toHaveBeenCalled(); + expect(element.querySelector('article.unread')).not.toBeNull(); + expect(element.textContent).toContain('Als gelesen markieren'); + }); +}); diff --git a/apps/frontend/src/app/features/notifications/notifications.page.ts b/apps/frontend/src/app/features/notifications/notifications.page.ts new file mode 100644 index 0000000..a725309 --- /dev/null +++ b/apps/frontend/src/app/features/notifications/notifications.page.ts @@ -0,0 +1,168 @@ +import { DatePipe } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import type { NotificationStatusFilter } from '@boilerplate/api-client'; +import { NotificationStore } from '../../core/notification.store'; + +@Component({ + standalone: true, + imports: [DatePipe], + template: ` +
+
+ @for (filter of filters; track filter.value) { + + } +
+ +
+ + @if (store.loading()) { +

Benachrichtigungen werden geladen.

+ } @else if (store.error(); as error) { +

+ {{ error.message }} + @if (error.requestId) { + Request-ID: {{ error.requestId }} + } +

+ } @else if (store.notifications().length === 0) { +

Keine Benachrichtigungen fuer diesen Filter.

+ } @else { +
+ @for (notification of store.notifications(); track notification.id) { +
+
+ {{ notification.title }} + +
+

{{ notification.message }}

+
+ @if (store.isInternalLink(notification.link)) { + + } + @if (notification.read) { + + } @else { + + } + +
+
+ } +
+ } + + + `, + styles: [ + ` + .toolbar, + .filters, + .actions, + .pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + .toolbar { + justify-content: space-between; + margin-bottom: 16px; + } + button { + min-height: 40px; + border: 1px solid var(--color-border-strong); + background: var(--color-surface); + color: var(--color-text-primary); + padding: 0 12px; + } + button.active { + background: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-surface); + } + .list { + display: grid; + gap: 12px; + } + article, + .state { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + } + article { + display: grid; + gap: 10px; + } + article.unread { + border-left: 4px solid var(--color-primary); + background: var(--color-primary-subtle); + } + header { + display: grid; + gap: 4px; + } + p { + margin: 0; + } + time, + small, + .pager { + color: var(--color-text-muted); + } + .danger, + .error { + color: var(--color-danger); + } + .pager { + margin-top: 16px; + } + `, + ], +}) +export class NotificationsPageComponent { + readonly store = inject(NotificationStore); + readonly filters: { value: NotificationStatusFilter; label: string }[] = [ + { value: 'all', label: 'Alle' }, + { value: 'unread', label: 'Ungelesen' }, + { value: 'read', label: 'Gelesen' }, + ]; + + constructor() { + this.store.load(); + } + + previous(): void { + this.store.load(this.store.currentFilter(), Math.max(1, this.store.page() - 1)); + } + + next(): void { + this.store.load(this.store.currentFilter(), this.store.page() + 1); + } +} diff --git a/apps/frontend/src/app/features/profile/profile.page.ts b/apps/frontend/src/app/features/profile/profile.page.ts index 4ae5508..8d81dbc 100644 --- a/apps/frontend/src/app/features/profile/profile.page.ts +++ b/apps/frontend/src/app/features/profile/profile.page.ts @@ -8,8 +8,8 @@ import { ApiClientService } from '@boilerplate/api-client'; imports: [ReactiveFormsModule], template: ` @if (auth.user(); as user) { -
-
+
+
Name
{{ user.name }}
E-Mail
@@ -17,50 +17,45 @@ import { ApiClientService } from '@boilerplate/api-client';
Letzter Login
{{ user.lastLoginAt || 'Noch nicht bekannt' }}
-
- - - + + + +
} `, styles: [ ` - .panel { - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 18px; + .profile-card, + .profile-list { + gap: var(--space-5); } - dl { + .profile-list { display: grid; - grid-template-columns: 120px 1fr; - gap: 10px; + grid-template-columns: minmax(7rem, auto) 1fr; + margin: 0; } dt { - color: #637083; + color: var(--color-text-muted); } - form { - display: grid; - gap: 14px; - max-width: 420px; + dd { + margin: 0; } - input { - min-height: 44px; - } - button { - min-height: 44px; - background: #26648e; - color: #fff; - border: 0; - padding: 0 18px; + .profile-form { + max-width: 28rem; } `, ], @@ -76,7 +71,9 @@ export class ProfilePageComponent { constructor() { const user = this.auth.user(); if (user) { - this.form.setValue(user.settings); + console.log(user); + // this.form.setValue(user.settings); + this.form.patchValue(user.settings); } } diff --git a/apps/frontend/src/app/features/roles/roles.page.ts b/apps/frontend/src/app/features/roles/roles.page.ts index 557fa6a..5c82b8a 100644 --- a/apps/frontend/src/app/features/roles/roles.page.ts +++ b/apps/frontend/src/app/features/roles/roles.page.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ApiClientService, type Permission, type RoleDto } from '@boilerplate/api-client'; @@ -31,7 +31,7 @@ const permissions: Permission[] = [ } {{ role.permissions.length }} Permissions · + >{{ role.permissions.length }} Permissions · {{ role.users?.length ?? 0 }} Benutzer @@ -71,8 +71,8 @@ const permissions: Permission[] = [ } article, .panel { - background: #fff; - border: 1px solid #d9dee7; + background: var(--color-surface); + border: 1px solid var(--color-border); border-radius: 8px; padding: 16px; } @@ -84,16 +84,16 @@ const permissions: Permission[] = [ min-height: 44px; } button { - background: #26648e; - color: #fff; + background: var(--color-primary); + color: var(--color-surface); border: 0; padding: 0 16px; } .danger { - background: #a23b3b; + background: var(--color-danger); } small { - color: #637083; + color: var(--color-text-muted); margin-left: 6px; } `, diff --git a/apps/frontend/src/app/features/sessions/sessions.page.ts b/apps/frontend/src/app/features/sessions/sessions.page.ts index e16412a..db2b306 100644 --- a/apps/frontend/src/app/features/sessions/sessions.page.ts +++ b/apps/frontend/src/app/features/sessions/sessions.page.ts @@ -4,21 +4,25 @@ import { ApiClientService, type SessionDto } from '@boilerplate/api-client'; @Component({ standalone: true, template: ` - -
+
@for (session of sessions(); track session.id) { -
- {{ session.current ? 'Aktuelle Session' : 'Session' }} - Angemeldet: {{ session.createdAt }} - Letzte Aktivitaet: {{ session.lastActivityAt }} - {{ session.userAgent || 'Unbekannter Browser' }} · - {{ session.approximateIp || 'IP unbekannt' }} +
+
+ {{ session.current ? 'Aktuelle Session' : 'Session' }} + Angemeldet: {{ session.createdAt }} + Letzte Aktivitaet: {{ session.lastActivityAt }} + + {{ session.userAgent || 'Unbekannter Browser' }} · + {{ session.approximateIp || 'IP unbekannt' }} + +
@if (!session.current && !session.revokedAt) { - + }
} @@ -26,29 +30,8 @@ import { ApiClientService, type SessionDto } from '@boilerplate/api-client'; `, styles: [ ` - .secondary, - button { - min-height: 44px; - border: 1px solid #26648e; - background: #fff; - color: #184e77; - padding: 0 14px; - } - .list { - display: grid; - gap: 12px; - margin-top: 16px; - } - article { - display: grid; - gap: 8px; - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 16px; - } - span { - color: #536173; + .sessions-list { + margin-top: var(--space-5); } `, ], diff --git a/apps/frontend/src/app/features/users/users.page.ts b/apps/frontend/src/app/features/users/users.page.ts index b2044c9..f553d7c 100644 --- a/apps/frontend/src/app/features/users/users.page.ts +++ b/apps/frontend/src/app/features/users/users.page.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ApiClientService, type UserDto } from '@boilerplate/api-client'; @@ -17,7 +17,7 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client'; {{ user.name }} {{ user.email || 'Keine E-Mail' }} {{ user.active ? 'Aktiv' : 'Deaktiviert' }} · Letzter Login: + >{{ user.active ? 'Aktiv' : 'Deaktiviert' }} · Letzter Login: {{ user.lastLoginAt || 'nie' }}
@@ -43,8 +43,8 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client'; } button { border: 0; - background: #26648e; - color: #fff; + background: var(--color-primary); + color: var(--color-surface); padding: 0 14px; } .list { @@ -54,8 +54,8 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client'; article { display: grid; gap: 12px; - background: #fff; - border: 1px solid #d9dee7; + background: var(--color-surface); + border: 1px solid var(--color-border); border-radius: 8px; padding: 16px; } @@ -64,7 +64,7 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client'; gap: 4px; } span { - color: #637083; + color: var(--color-text-muted); } @media (min-width: 760px) { article { diff --git a/apps/frontend/src/app/layout/app-shell.spec.ts b/apps/frontend/src/app/layout/app-shell.spec.ts index ae70308..c6dc10f 100644 --- a/apps/frontend/src/app/layout/app-shell.spec.ts +++ b/apps/frontend/src/app/layout/app-shell.spec.ts @@ -21,7 +21,28 @@ describe('AppShellComponent', () => { active: true, lastLoginAt: null, settings: { tablePageSize: 20, sidebarExpanded: true }, - roles: [{ id: 'r1', name: 'user', protected: true, permissions: [] }], + roles: [ + { + id: 'r1', + name: 'user', + protected: true, + permissions: [ + { + id: 'notifications.readOwn', + description: 'notifications.readOwn', + }, + ], + }, + ], + }), + unreadNotificationCount: () => of({ count: 2 }), + notifications: () => + of({ + items: [], + total: 0, + page: 1, + pageSize: 20, + unreadCount: 2, }), }, }, @@ -37,5 +58,44 @@ describe('AppShellComponent', () => { permission: 'users.read', }), ).toBe(false); + expect((fixture.nativeElement as HTMLElement).querySelector('.badge')?.textContent).toContain( + '2', + ); + }); + + it('opens the mobile drawer and closes it after navigation', async () => { + await TestBed.configureTestingModule({ + imports: [AppShellComponent], + providers: [ + provideRouter([]), + { + provide: ApiClientService, + useValue: { + me: () => + of({ + id: 'u1', + name: 'Ada', + email: null, + active: true, + lastLoginAt: null, + settings: { tablePageSize: 20, sidebarExpanded: true }, + roles: [{ id: 'r1', name: 'user', protected: true, permissions: [] }], + }), + unreadNotificationCount: () => of({ count: 0 }), + notifications: () => of({ items: [], total: 0, page: 1, pageSize: 20, unreadCount: 0 }), + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(AppShellComponent); + fixture.detectChanges(); + + fixture.componentInstance.toggleDrawer(); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).querySelector('.sidebar.open')).not.toBeNull(); + + fixture.componentInstance.closeDrawerOnNavigation(); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).querySelector('.sidebar.open')).toBeNull(); }); }); diff --git a/apps/frontend/src/app/layout/app-shell.ts b/apps/frontend/src/app/layout/app-shell.ts index ee12369..881f87d 100644 --- a/apps/frontend/src/app/layout/app-shell.ts +++ b/apps/frontend/src/app/layout/app-shell.ts @@ -1,7 +1,10 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, HostListener, computed, effect, inject, signal } from '@angular/core'; import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router'; import type { Permission } from '@boilerplate/api-client'; +import { NotificationStore } from '../core/notification.store'; import { AuthService } from '../core/auth.service'; +import { NotificationPanelComponent } from './notification-panel'; +import { UiIconButtonComponent, UiIconComponent, UiToastHostComponent } from '../shared/ui'; interface NavItem { label: string; @@ -12,38 +15,67 @@ interface NavItem { @Component({ selector: 'app-shell', standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive], + imports: [ + RouterOutlet, + RouterLink, + RouterLinkActive, + NotificationPanelComponent, + UiIconButtonComponent, + UiIconComponent, + UiToastHostComponent, + ], template: `
@if (auth.user()) { - + } - Business App + Business App @if (auth.user()) { - Abmelden + + Abmelden } @else { - Anmelden + Anmelden + } + @if (auth.user() && notificationPanelOpen()) { + }
@if (auth.loaded()) { @if (auth.user()) { + @if (drawerOpen()) { + + }
} + `, styles: [ ` .shell { min-height: 100vh; - background: #f6f7f9; - color: #1b2430; + background: var(--color-background); + color: var(--color-text-primary); } .topbar { position: sticky; top: 0; - z-index: 10; - height: 64px; + z-index: var(--z-header); + height: var(--header-height); display: flex; align-items: center; - gap: 16px; - padding: 0 16px; - background: #fff; - border-bottom: 1px solid #d9dee7; + gap: var(--space-4); + padding: 0 var(--space-5); + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); } - .icon-button { - width: 44px; - height: 44px; - border: 1px solid #c7ced9; - background: #fff; - display: grid; - place-content: center; - gap: 4px; - } - .icon-button span { - display: block; - width: 18px; - height: 2px; - background: #1b2430; + .brand { + white-space: nowrap; } .logout { margin-left: auto; - color: #184e77; } - .public-content { - min-height: calc(100vh - 64px); + .notification-button { + margin-left: auto; + } + .badge { + position: absolute; + top: var(--space-1); + right: var(--space-1); + min-width: 1.125rem; + height: 1.125rem; + border-radius: var(--radius-pill); + background: var(--color-danger); + color: var(--color-surface); display: grid; place-items: center; + font-size: var(--font-size-xs); + padding: 0 var(--space-2); + } + .public-content { + min-height: calc(100vh - var(--header-height)); + display: grid; + place-items: center; + margin-left: 0; } .login-panel { width: min(100%, 440px); - background: #fff; - border: 1px solid #d9dee7; - border-radius: 8px; - padding: 24px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); display: grid; - gap: 14px; + gap: var(--space-4); } .login-panel h1, .login-panel p { margin: 0; } .login-panel p { - color: #536173; + color: var(--color-text-secondary); } - .primary-action { - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 6px; - background: #26648e; - color: #fff; - text-decoration: none; - padding: 0 16px; + .drawer-backdrop { + position: fixed; + inset: var(--header-height) 0 0; + z-index: calc(var(--z-drawer) - 1); + border: 0; + background: color-mix(in srgb, var(--color-text-primary) 36%, transparent); } .sidebar { position: fixed; - inset: 64px auto 0 0; - width: 260px; - background: #fff; - border-right: 1px solid #d9dee7; + inset: var(--header-height) auto 0 0; + width: var(--sidebar-width); + background: var(--color-surface); + border-right: 1px solid var(--color-border); transform: translateX(-100%); - transition: transform 160ms ease; - z-index: 9; + transition: transform var(--transition-base) ease; + z-index: var(--z-drawer); } .sidebar.open { transform: translateX(0); } nav a { display: block; - padding: 14px 18px; - color: #263445; + padding: var(--space-4) var(--space-5); + color: var(--color-text-primary); text-decoration: none; - min-height: 48px; + min-height: var(--touch-target); } nav a.active { - background: #e8f0f7; - border-left: 4px solid #26648e; + background: var(--color-primary-subtle); + border-left: 4px solid var(--color-primary); } .content { - padding: 20px 16px 48px; + min-width: 0; + padding: var(--space-5) var(--space-5) var(--space-8); } .breadcrumbs { - color: #637083; - font-size: 0.9rem; - margin-bottom: 8px; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + margin-bottom: var(--space-4); } - h1 { - font-size: 1.6rem; - margin: 0 0 20px; - } - @media (min-width: 900px) { - .icon-button { + @media (min-width: 64rem) { + ui-icon-button { + display: none; + } + .drawer-backdrop { display: none; } .sidebar { transform: none; } .content { - margin-left: 260px; - padding: 28px 32px; + margin-left: var(--sidebar-width); + padding: var(--space-7); } } `, @@ -200,25 +235,61 @@ interface NavItem { export class AppShellComponent { private readonly router = inject(Router); readonly auth = inject(AuthService); + readonly notifications = inject(NotificationStore); readonly drawerOpen = signal(false); + readonly notificationPanelOpen = signal(false); readonly title = computed( () => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard', ); readonly nav: NavItem[] = [ { label: 'Dashboard', path: '/' }, { label: 'Profil', path: '/profil' }, + { + label: 'Benachrichtigungen', + path: '/notifications', + permission: 'notifications.readOwn', + }, { label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' }, { label: 'Items', path: '/items', permission: 'items.read' }, - { label: 'Benutzer', path: '/benutzer', permission: 'users.read' }, - { label: 'Rollen', path: '/rollen', permission: 'roles.read' }, - { label: 'Audit-Log', path: '/audit-log', permission: 'audit.read' }, + { label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' }, + { label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' }, + { label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' }, ]; constructor() { this.auth.loadMe(); + effect(() => { + if (this.auth.user()) { + this.notifications.startPolling(); + } else { + this.notifications.stopPolling(); + this.notificationPanelOpen.set(false); + } + }); } visible(item: NavItem): boolean { return !item.permission || this.auth.has(item.permission); } + + toggleNotifications(): void { + this.notificationPanelOpen.update((open) => !open); + if (this.notificationPanelOpen()) { + this.notifications.openPanel(); + } + } + + toggleDrawer(): void { + this.drawerOpen.update((open) => !open); + } + + closeDrawerOnNavigation(): void { + this.drawerOpen.set(false); + } + + @HostListener('document:keydown.escape') + closeOverlays(): void { + this.drawerOpen.set(false); + this.notificationPanelOpen.set(false); + } } diff --git a/apps/frontend/src/app/layout/notification-panel.spec.ts b/apps/frontend/src/app/layout/notification-panel.spec.ts new file mode 100644 index 0000000..d376302 --- /dev/null +++ b/apps/frontend/src/app/layout/notification-panel.spec.ts @@ -0,0 +1,79 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { signal } from '@angular/core'; +import type { NotificationDto } from '@boilerplate/api-client'; +import { NotificationStore } from '../core/notification.store'; +import { NotificationPanelComponent } from './notification-panel'; + +const notification: NotificationDto = { + id: 'n1', + type: 'system', + title: 'Titel', + message: 'Nachricht', + link: '/', + metadata: null, + read: false, + readAt: null, + createdAt: '2026-07-16T08:00:00.000Z', +}; + +describe('NotificationPanelComponent', () => { + it('shows unread notifications and emits close', async () => { + const closed = vi.fn(); + await TestBed.configureTestingModule({ + imports: [NotificationPanelComponent], + providers: [ + provideRouter([]), + { + provide: NotificationStore, + useValue: { + notifications: signal([notification]), + loading: signal(false), + error: signal(null), + markAllAsRead: vi.fn(), + markAsRead: vi.fn(), + markAsUnread: vi.fn(), + delete: vi.fn(), + isInternalLink: () => true, + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(NotificationPanelComponent); + fixture.componentInstance.closed.subscribe(closed); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('article.unread')).not.toBeNull(); + element.querySelector('header button')?.click(); + expect(closed).toHaveBeenCalledOnce(); + }); + + it('renders empty and error states', async () => { + await TestBed.configureTestingModule({ + imports: [NotificationPanelComponent], + providers: [ + provideRouter([]), + { + provide: NotificationStore, + useValue: { + notifications: signal([]), + loading: signal(false), + error: signal({ + message: 'Fehler', + requestId: 'req-1', + status: 500, + code: 'INTERNAL_ERROR', + }), + markAllAsRead: vi.fn(), + isInternalLink: () => false, + }, + }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(NotificationPanelComponent); + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).textContent).toContain('req-1'); + }); +}); diff --git a/apps/frontend/src/app/layout/notification-panel.ts b/apps/frontend/src/app/layout/notification-panel.ts new file mode 100644 index 0000000..462c943 --- /dev/null +++ b/apps/frontend/src/app/layout/notification-panel.ts @@ -0,0 +1,169 @@ +import { DatePipe } from '@angular/common'; +import { Component, EventEmitter, Output, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { NotificationStore } from '../core/notification.store'; + +@Component({ + selector: 'app-notification-panel', + standalone: true, + imports: [DatePipe, RouterLink], + template: ` +
+
+ Benachrichtigungen + +
+ +
+ + Alle anzeigen +
+ + @if (store.loading()) { +

Wird geladen.

+ } @else if (store.error(); as error) { +

+ {{ error.message }} + @if (error.requestId) { + Request-ID: {{ error.requestId }} + } +

+ } @else if (store.notifications().length === 0) { +

Keine Benachrichtigungen vorhanden.

+ } @else { +
+ @for (notification of store.notifications().slice(0, 5); track notification.id) { +
+
+ {{ notification.title }} + +
+

{{ notification.message }}

+
+ @if (store.isInternalLink(notification.link)) { + + } + @if (notification.read) { + + } @else { + + } + +
+
+ } +
+ } +
+ `, + styles: [ + ` + .panel { + position: fixed; + inset: 64px 0 0; + z-index: 20; + background: var(--color-surface); + border-top: 1px solid var(--color-border); + display: grid; + align-content: start; + gap: 12px; + padding: 16px; + overflow: auto; + } + header, + .panel-actions, + .item-actions { + display: flex; + align-items: center; + gap: 8px; + } + header { + justify-content: space-between; + } + .panel-actions { + justify-content: space-between; + } + .items { + display: grid; + gap: 10px; + } + article { + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 12px; + display: grid; + gap: 8px; + } + article.unread { + border-left: 4px solid var(--color-primary); + background: var(--color-primary-subtle); + } + article div:first-child { + display: grid; + gap: 4px; + } + p { + margin: 0; + } + time, + small { + color: var(--color-text-muted); + font-size: 0.85rem; + } + button, + a { + min-height: 40px; + } + button { + border: 1px solid var(--color-border-strong); + background: var(--color-surface); + color: var(--color-text-primary); + padding: 0 12px; + } + a { + display: inline-flex; + align-items: center; + color: var(--color-primary-hover); + } + .danger { + color: var(--color-danger); + } + .state { + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; + } + .error { + color: var(--color-danger); + } + @media (min-width: 760px) { + .panel { + inset: 72px 16px auto auto; + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 96px); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 16px 40px color-mix(in srgb, var(--color-text-primary) 16%, transparent); + } + } + `, + ], +}) +export class NotificationPanelComponent { + readonly store = inject(NotificationStore); + @Output() readonly closed = new EventEmitter(); + + open(id: string): void { + const notification = this.store.notifications().find((item) => item.id === id); + if (notification) { + this.store.openNotification(notification); + this.closed.emit(); + } + } +} diff --git a/apps/frontend/src/app/shared/ui/button/button.component.ts b/apps/frontend/src/app/shared/ui/button/button.component.ts new file mode 100644 index 0000000..eef7fe8 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/button/button.component.ts @@ -0,0 +1,38 @@ +import { Component, Input } from '@angular/core'; + +type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; +type ButtonSize = 'small' | 'medium'; +type ButtonType = 'button' | 'submit' | 'reset'; + +@Component({ + selector: 'ui-button', + standalone: true, + template: ` + + `, +}) +export class UiButtonComponent { + @Input() label = ''; + @Input() variant: ButtonVariant = 'primary'; + @Input() size: ButtonSize = 'medium'; + @Input() type: ButtonType = 'button'; + @Input() disabled = false; + @Input() loading = false; + @Input() mobileFull = false; +} diff --git a/apps/frontend/src/app/shared/ui/confirm-dialog/confirm-dialog.component.ts b/apps/frontend/src/app/shared/ui/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 0000000..f68d9d3 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,146 @@ +import { Component, HostListener, signal, viewChild } from '@angular/core'; +import type { ElementRef } from '@angular/core'; + +@Component({ + selector: 'ui-confirm-dialog', + standalone: true, + template: ` + @if (visible()) { + + + } + `, + styles: [ + ` + .ui-dialog-backdrop { + position: fixed; + inset: 0; + z-index: var(--z-overlay); + background: color-mix(in srgb, var(--color-text-primary) 52%, transparent); + animation: ui-fade-in var(--transition-fast) ease; + } + .ui-dialog { + position: fixed; + inset: auto var(--space-3) var(--space-3); + z-index: var(--z-dialog); + display: grid; + gap: var(--space-6); + max-height: calc(100vh - var(--space-6)); + overflow: auto; + padding: var(--space-6); + background: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + animation: ui-slide-up var(--transition-base) ease; + } + .ui-actions { + justify-content: end; + } + @media (min-width: 48rem) { + .ui-dialog { + inset: 20vh auto auto 50%; + width: min(32rem, calc(100vw - var(--space-7))); + transform: translateX(-50%); + } + } + `, + ], +}) +export class UiConfirmDialogComponent { + readonly visible = signal(false); + readonly title = signal('Aktion bestaetigen'); + readonly description = signal(''); + readonly confirmLabel = signal('Bestaetigen'); + readonly cancelLabel = signal('Abbrechen'); + readonly danger = signal(false); + readonly titleId = `dialog-title-${crypto.randomUUID()}`; + readonly descriptionId = `dialog-description-${crypto.randomUUID()}`; + private readonly dialog = viewChild>('dialog'); + private resolver: ((value: boolean) => void) | null = null; + private previousFocus: HTMLElement | null = null; + + open(options: { + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + }): Promise { + this.previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + this.title.set(options.title); + this.description.set(options.description); + this.confirmLabel.set(options.confirmLabel ?? 'Bestaetigen'); + this.cancelLabel.set(options.cancelLabel ?? 'Abbrechen'); + this.danger.set(options.danger ?? false); + this.visible.set(true); + queueMicrotask(() => this.dialog()?.nativeElement.focus()); + return new Promise((resolve) => { + this.resolver = resolve; + }); + } + + close(result: boolean): void { + this.visible.set(false); + this.resolver?.(result); + this.resolver = null; + this.previousFocus?.focus(); + this.previousFocus = null; + } + + @HostListener('document:keydown.escape') + onEscape(): void { + if (this.visible()) this.close(false); + } + + trapFocus(event: KeyboardEvent): void { + if (event.key !== 'Tab') return; + const root = this.dialog()?.nativeElement; + if (!root) return; + const focusable = Array.from( + root.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => !element.hasAttribute('disabled')); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } +} diff --git a/apps/frontend/src/app/shared/ui/empty-state/empty-state.component.ts b/apps/frontend/src/app/shared/ui/empty-state/empty-state.component.ts new file mode 100644 index 0000000..14abf7d --- /dev/null +++ b/apps/frontend/src/app/shared/ui/empty-state/empty-state.component.ts @@ -0,0 +1,53 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { UiIconComponent, type UiIconName } from '../icon/icon.component'; + +@Component({ + selector: 'ui-empty-state', + standalone: true, + imports: [UiIconComponent], + template: ` +
+ @if (iconName(); as currentIcon) { + + } +
+

{{ title }}

+ @if (description) { +

{{ description }}

+ } +
+ @if (actionLabel) { + + } +
+ `, + styles: [ + ` + .ui-empty-state { + justify-items: start; + } + ui-icon { + width: 2rem; + height: 2rem; + color: var(--color-info); + } + `, + ], +}) +export class UiEmptyStateComponent { + @Input() title = ''; + @Input() description = ''; + @Input() icon: UiIconName | '' = ''; + @Input() actionLabel = ''; + @Output() readonly action = new EventEmitter(); + + iconName(): UiIconName | null { + return this.icon === '' ? null : this.icon; + } +} diff --git a/apps/frontend/src/app/shared/ui/form-field/form-field.component.ts b/apps/frontend/src/app/shared/ui/form-field/form-field.component.ts new file mode 100644 index 0000000..d4d46c1 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/form-field/form-field.component.ts @@ -0,0 +1,32 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ui-form-field', + standalone: true, + template: ` + + `, +}) +export class UiFormFieldComponent { + @Input() label = ''; + @Input() hint = ''; + @Input() error = ''; + @Input() fieldId = `field-${crypto.randomUUID()}`; + + hintId(): string { + return `${this.fieldId}-hint`; + } + + errorId(): string { + return `${this.fieldId}-error`; + } +} diff --git a/apps/frontend/src/app/shared/ui/icon-button/icon-button.component.ts b/apps/frontend/src/app/shared/ui/icon-button/icon-button.component.ts new file mode 100644 index 0000000..cc32670 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/icon-button/icon-button.component.ts @@ -0,0 +1,41 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { UiIconComponent, type UiIconName } from '../icon/icon.component'; + +type IconButtonVariant = 'default' | 'danger' | 'ghost'; + +@Component({ + selector: 'ui-icon-button', + standalone: true, + imports: [UiIconComponent], + template: ` + + `, + styles: [ + ` + .danger { + color: var(--color-danger); + } + .ghost { + background: transparent; + } + `, + ], +}) +export class UiIconButtonComponent { + @Input() icon: UiIconName = 'info'; + @Input() label = ''; + @Input() variant: IconButtonVariant = 'default'; + @Input() disabled = false; + @Output() readonly pressed = new EventEmitter(); +} diff --git a/apps/frontend/src/app/shared/ui/icon/icon.component.ts b/apps/frontend/src/app/shared/ui/icon/icon.component.ts new file mode 100644 index 0000000..9825550 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/icon/icon.component.ts @@ -0,0 +1,90 @@ +import { Component, Input } from '@angular/core'; + +export type UiIconName = + | 'menu' + | 'close' + | 'user' + | 'roles' + | 'dashboard' + | 'items' + | 'audit' + | 'bell' + | 'edit' + | 'delete' + | 'activate' + | 'deactivate' + | 'search' + | 'filter' + | 'sort' + | 'back' + | 'next' + | 'check' + | 'warning' + | 'info'; + +const paths: Record = { + menu: 'M4 7h16M4 12h16M4 17h16', + close: 'M6 6l12 12M18 6L6 18', + user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 8a7 7 0 0 1 14 0', + roles: + 'M7 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm10 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM2 20a5 5 0 0 1 10 0M12 20a5 5 0 0 1 10 0', + dashboard: 'M4 13h6V4H4v9Zm10 7h6V4h-6v16ZM4 20h6v-4H4v4Z', + items: 'M5 5h14v14H5V5Zm3 4h8M8 13h8', + audit: 'M7 4h10l3 3v13H7V4Zm10 0v4h4M4 8v12h12', + bell: 'M12 3a5 5 0 0 0-5 5v3.6c0 .8-.3 1.6-.9 2.2L5 15v1h14v-1l-1.1-1.2a3.2 3.2 0 0 1-.9-2.2V8a5 5 0 0 0-5-5Zm-2 15a2 2 0 0 0 4 0', + edit: 'M4 20h4l11-11-4-4L4 16v4Zm11-15 4 4', + delete: 'M5 7h14M9 7V5h6v2m-8 0 1 13h8l1-13', + activate: 'M5 12l4 4L19 6', + deactivate: 'M5 5l14 14M19 5 5 19', + search: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm5-2 4 4', + filter: 'M4 5h16l-6 7v6l-4 2v-8L4 5Z', + sort: 'M8 5v14m0 0-3-3m3 3 3-3m8 3V5m0 0-3 3m3-3 3 3', + back: 'M15 6 9 12l6 6', + next: 'm9 6 6 6-6 6', + check: 'M5 12l4 4L19 6', + warning: 'M12 4 3 20h18L12 4Zm0 6v4m0 3h.01', + info: 'M12 17v-6m0-4h.01M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z', +}; + +@Component({ + selector: 'ui-icon', + standalone: true, + template: ` + + + + `, + styles: [ + ` + :host { + width: 1.25rem; + height: 1.25rem; + display: inline-flex; + flex: 0 0 auto; + } + svg { + width: 100%; + height: 100%; + } + `, + ], +}) +export class UiIconComponent { + @Input() name: UiIconName = 'info'; + @Input() label = ''; + @Input() decorative = true; + + path(): string { + return paths[this.name]; + } +} diff --git a/apps/frontend/src/app/shared/ui/index.ts b/apps/frontend/src/app/shared/ui/index.ts new file mode 100644 index 0000000..92dd3b4 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/index.ts @@ -0,0 +1,12 @@ +export * from './button/button.component'; +export * from './confirm-dialog/confirm-dialog.component'; +export * from './empty-state/empty-state.component'; +export * from './form-field/form-field.component'; +export * from './icon/icon.component'; +export * from './icon-button/icon-button.component'; +export * from './loading-state/loading-state.component'; +export * from './page-header/page-header.component'; +export * from './pagination/pagination.component'; +export * from './status-badge/status-badge.component'; +export * from './toast/toast-host.component'; +export * from './toast/toast.service'; diff --git a/apps/frontend/src/app/shared/ui/loading-state/loading-state.component.ts b/apps/frontend/src/app/shared/ui/loading-state/loading-state.component.ts new file mode 100644 index 0000000..245e63b --- /dev/null +++ b/apps/frontend/src/app/shared/ui/loading-state/loading-state.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ui-loading-state', + standalone: true, + template: ` +
+ + {{ label }} +
+ `, + styles: [ + ` + .ui-loading-state { + min-height: 8rem; + display: grid; + place-items: center; + gap: var(--space-3); + color: var(--color-text-secondary); + } + .inline { + min-height: auto; + display: inline-flex; + } + `, + ], +}) +export class UiLoadingStateComponent { + @Input() label = 'Wird geladen.'; + @Input() inline = false; +} diff --git a/apps/frontend/src/app/shared/ui/page-header/page-header.component.ts b/apps/frontend/src/app/shared/ui/page-header/page-header.component.ts new file mode 100644 index 0000000..0f78f86 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/page-header/page-header.component.ts @@ -0,0 +1,27 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'ui-page-header', + standalone: true, + template: ` +
+
+ @if (breadcrumbs) { + + } +

{{ title }}

+ @if (description) { +

{{ description }}

+ } +
+
+ +
+
+ `, +}) +export class UiPageHeaderComponent { + @Input() title = ''; + @Input() description = ''; + @Input() breadcrumbs = ''; +} diff --git a/apps/frontend/src/app/shared/ui/pagination/pagination.component.ts b/apps/frontend/src/app/shared/ui/pagination/pagination.component.ts new file mode 100644 index 0000000..18d4b4c --- /dev/null +++ b/apps/frontend/src/app/shared/ui/pagination/pagination.component.ts @@ -0,0 +1,54 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'ui-pagination', + standalone: true, + template: ` + + `, + styles: [ + ` + .ui-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-top: var(--space-5); + } + `, + ], +}) +export class UiPaginationComponent { + @Input() page = 1; + @Input() total = 0; + @Input() pageSize = 20; + @Output() readonly pageChange = new EventEmitter(); + + totalPages(): number { + return Math.max(1, Math.ceil(this.total / this.pageSize)); + } + + go(page: number): void { + if (page >= 1 && page <= this.totalPages() && page !== this.page) { + this.pageChange.emit(page); + } + } +} diff --git a/apps/frontend/src/app/shared/ui/status-badge/status-badge.component.ts b/apps/frontend/src/app/shared/ui/status-badge/status-badge.component.ts new file mode 100644 index 0000000..038c3ee --- /dev/null +++ b/apps/frontend/src/app/shared/ui/status-badge/status-badge.component.ts @@ -0,0 +1,30 @@ +import { Component, Input } from '@angular/core'; + +type StatusTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'; + +@Component({ + selector: 'ui-status-badge', + standalone: true, + template: ` + + + {{ label }} + + `, +}) +export class UiStatusBadgeComponent { + @Input() label = ''; + @Input() tone: StatusTone = 'neutral'; + + toneClass(): string { + return this.tone === 'neutral' ? '' : `ui-badge--${this.tone}`; + } + + marker(): string { + if (this.tone === 'success') return '✓'; + if (this.tone === 'warning') return '!'; + if (this.tone === 'danger') return '!'; + if (this.tone === 'info') return 'i'; + return '•'; + } +} diff --git a/apps/frontend/src/app/shared/ui/toast/toast-host.component.ts b/apps/frontend/src/app/shared/ui/toast/toast-host.component.ts new file mode 100644 index 0000000..4f17f17 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/toast/toast-host.component.ts @@ -0,0 +1,79 @@ +import { Component, inject } from '@angular/core'; +import { ToastService } from './toast.service'; + +@Component({ + selector: 'ui-toast-host', + standalone: true, + template: ` +
+ @for (toast of toasts.messages(); track toast.id) { +
+
+ {{ toast.title }} + @if (toast.message) { +

{{ toast.message }}

+ } + @if (toast.requestId) { + Request-ID: {{ toast.requestId }} + } +
+ +
+ } +
+ `, + styles: [ + ` + .ui-toast-region { + position: fixed; + right: var(--space-4); + bottom: var(--space-4); + z-index: var(--z-toast); + width: min(26rem, calc(100vw - var(--space-7))); + display: grid; + gap: var(--space-3); + } + .ui-toast { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--space-3); + padding: var(--space-4); + background: var(--color-surface-elevated); + border: 1px solid var(--color-border); + border-left-width: 4px; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + animation: ui-slide-up var(--transition-base) ease; + } + .success { + border-left-color: var(--color-success); + } + .warning { + border-left-color: var(--color-warning); + } + .danger { + border-left-color: var(--color-danger); + } + .info { + border-left-color: var(--color-info); + } + p { + margin-block: var(--space-1) 0; + color: var(--color-text-secondary); + } + small { + color: var(--color-text-muted); + } + `, + ], +}) +export class UiToastHostComponent { + readonly toasts = inject(ToastService); +} diff --git a/apps/frontend/src/app/shared/ui/toast/toast.service.ts b/apps/frontend/src/app/shared/ui/toast/toast.service.ts new file mode 100644 index 0000000..1ea175b --- /dev/null +++ b/apps/frontend/src/app/shared/ui/toast/toast.service.ts @@ -0,0 +1,29 @@ +import { Injectable, signal } from '@angular/core'; + +export type ToastTone = 'success' | 'warning' | 'danger' | 'info'; + +export interface ToastMessage { + id: string; + tone: ToastTone; + title: string; + message?: string; + requestId?: string; +} + +@Injectable({ providedIn: 'root' }) +export class ToastService { + readonly messages = signal([]); + + show(message: Omit): string { + const id = crypto.randomUUID(); + this.messages.update((messages) => [...messages.slice(-3), { ...message, id }]); + if (message.tone !== 'danger') { + window.setTimeout(() => this.close(id), 5000); + } + return id; + } + + close(id: string): void { + this.messages.update((messages) => messages.filter((message) => message.id !== id)); + } +} diff --git a/apps/frontend/src/app/shared/ui/ui-components.spec.ts b/apps/frontend/src/app/shared/ui/ui-components.spec.ts new file mode 100644 index 0000000..9862167 --- /dev/null +++ b/apps/frontend/src/app/shared/ui/ui-components.spec.ts @@ -0,0 +1,135 @@ +import { Component, ViewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + UiButtonComponent, + UiConfirmDialogComponent, + UiEmptyStateComponent, + UiFormFieldComponent, + UiIconButtonComponent, + UiPaginationComponent, + UiStatusBadgeComponent, + UiToastHostComponent, + ToastService, +} from './index'; +import { isDesignSystemRouteEnabled } from '../../core/dev-only.guard'; +import { devRoutes } from '../../core/dev-routes.prod'; + +@Component({ + standalone: true, + imports: [ + UiButtonComponent, + UiConfirmDialogComponent, + UiEmptyStateComponent, + UiFormFieldComponent, + UiIconButtonComponent, + UiPaginationComponent, + UiStatusBadgeComponent, + UiToastHostComponent, + ], + template: ` + + + + + + + + + + + + `, +}) +class UiTestHostComponent { + @ViewChild(UiConfirmDialogComponent) dialog?: UiConfirmDialogComponent; + page = 2; + emptyActionCount = 0; +} + +describe('shared UI components', () => { + it('renders button loading and disabled state', async () => { + await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UiTestHostComponent); + fixture.detectChanges(); + + const button = (fixture.nativeElement as HTMLElement).querySelector('ui-button button'); + expect(button?.hasAttribute('disabled')).toBe(true); + expect(button?.getAttribute('aria-busy')).toBe('true'); + }); + + it('requires accessible labels for icon buttons', async () => { + await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UiTestHostComponent); + fixture.detectChanges(); + + const button = (fixture.nativeElement as HTMLElement).querySelector('ui-icon-button button'); + expect(button?.getAttribute('aria-label')).toBe('Eintrag loeschen'); + }); + + it('shows form field errors and status text', async () => { + await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UiTestHostComponent); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('Name ist erforderlich'); + expect(element.textContent).toContain('Deaktiviert'); + }); + + it('opens and closes confirm dialog and restores focus', async () => { + await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UiTestHostComponent); + fixture.detectChanges(); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + '#trigger', + ); + trigger?.focus(); + + const promise = fixture.componentInstance.dialog?.open({ + title: 'Loeschen', + description: 'Wirklich loeschen?', + confirmLabel: 'Loeschen', + danger: true, + }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect((fixture.nativeElement as HTMLElement).querySelector('[role="dialog"]')).not.toBeNull(); + fixture.componentInstance.dialog?.close(true); + fixture.detectChanges(); + + await expect(promise).resolves.toBe(true); + expect(document.activeElement).toBe(trigger); + }); + + it('shows and closes toasts', async () => { + await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UiTestHostComponent); + const service = TestBed.inject(ToastService); + const id = service.show({ tone: 'success', title: 'Gespeichert' }); + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Gespeichert'); + service.close(id); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('Gespeichert'); + }); + + it('emits empty state and pagination actions', async () => { + await TestBed.configureTestingModule({ imports: [UiTestHostComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UiTestHostComponent); + fixture.detectChanges(); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('ui-empty-state button')?.click(); + element.querySelectorAll('ui-pagination button')[0]?.click(); + + expect(fixture.componentInstance.emptyActionCount).toBe(1); + expect(fixture.componentInstance.page).toBe(1); + }); + + it('keeps the design system route disabled outside development mode', () => { + expect(isDesignSystemRouteEnabled(false)).toBe(false); + expect(devRoutes).toHaveLength(0); + }); +}); diff --git a/apps/frontend/src/styles.scss b/apps/frontend/src/styles.scss index eb7bafa..6576241 100644 --- a/apps/frontend/src/styles.scss +++ b/apps/frontend/src/styles.scss @@ -1,40 +1,9 @@ -* { - box-sizing: border-box; -} - -html { - font-family: - Inter, - ui-sans-serif, - system-ui, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; - background: #f6f7f9; - color: #1b2430; -} - -body { - margin: 0; -} - -input, -textarea, -select, -button { - font: inherit; - border-radius: 6px; -} - -input, -textarea, -select { - border: 1px solid #b9c2d0; - padding: 10px 12px; - background: #fff; -} - -button { - cursor: pointer; -} +@use './styles/tokens'; +@use './styles/reset'; +@use './styles/typography'; +@use './styles/layout'; +@use './styles/forms'; +@use './styles/buttons'; +@use './styles/tables'; +@use './styles/utilities'; +@use './styles/animations'; diff --git a/apps/frontend/src/styles/_animations.scss b/apps/frontend/src/styles/_animations.scss new file mode 100644 index 0000000..2b29750 --- /dev/null +++ b/apps/frontend/src/styles/_animations.scss @@ -0,0 +1,25 @@ +@keyframes ui-spin { + to { + transform: rotate(1turn); + } +} + +@keyframes ui-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes ui-slide-up { + from { + opacity: 0; + transform: translateY(var(--space-3)); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/apps/frontend/src/styles/_buttons.scss b/apps/frontend/src/styles/_buttons.scss new file mode 100644 index 0000000..1d5fa04 --- /dev/null +++ b/apps/frontend/src/styles/_buttons.scss @@ -0,0 +1,90 @@ +.ui-button { + min-height: var(--button-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-md); + padding: 0 var(--space-5); + font-weight: var(--font-weight-semibold); + line-height: 1; + text-decoration: none; + transition: + background-color var(--transition-fast) ease, + border-color var(--transition-fast) ease, + color var(--transition-fast) ease; +} + +.ui-button--primary { + color: var(--color-surface); + background: var(--color-primary); +} + +.ui-button--primary:hover { + background: var(--color-primary-hover); +} + +.ui-button--primary:active { + background: var(--color-primary-active); +} + +.ui-button--secondary { + color: var(--color-surface); + background: var(--color-secondary); +} + +.ui-button--ghost { + color: var(--color-primary); + background: transparent; + border-color: var(--color-border); +} + +.ui-button--danger { + color: var(--color-surface); + background: var(--color-danger); +} + +.ui-button--small { + min-height: 2.25rem; + padding-inline: var(--space-4); + font-size: var(--font-size-sm); +} + +.ui-button[disabled], +.ui-button[aria-disabled='true'] { + opacity: 0.55; +} + +.ui-icon-button { + position: relative; + width: var(--touch-target); + height: var(--touch-target); + display: inline-grid; + place-items: center; + color: var(--color-text-primary); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.ui-icon-button:hover { + background: var(--color-primary-subtle); + border-color: var(--color-border-strong); +} + +.ui-button__spinner, +.ui-spinner { + width: 1rem; + height: 1rem; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: var(--radius-pill); + animation: ui-spin 700ms linear infinite; +} + +@media (max-width: 35.99rem) { + .ui-button--mobile-full { + width: 100%; + } +} diff --git a/apps/frontend/src/styles/_forms.scss b/apps/frontend/src/styles/_forms.scss new file mode 100644 index 0000000..cef01e7 --- /dev/null +++ b/apps/frontend/src/styles/_forms.scss @@ -0,0 +1,65 @@ +.ui-form { + display: grid; + gap: var(--space-5); +} + +.ui-form-field { + display: grid; + gap: var(--space-2); +} + +.ui-control { + width: 100%; + min-height: var(--input-height); + padding: 0 var(--space-4); + color: var(--color-text-primary); + background: var(--color-surface); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + transition: + border-color var(--transition-fast) ease, + box-shadow var(--transition-fast) ease; +} + +textarea.ui-control { + min-height: 6rem; + padding-block: var(--space-3); + resize: vertical; +} + +.ui-control:focus { + border-color: var(--color-focus); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-focus) 22%, transparent); + outline: none; +} + +.ui-control:disabled, +.ui-control[readonly] { + color: var(--color-text-muted); + background: var(--color-neutral-subtle); +} + +.ui-control[aria-invalid='true'] { + border-color: var(--color-danger); +} + +.ui-field-error { + color: var(--color-danger); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); +} + +.ui-checkbox { + display: grid; + grid-template-columns: 1.25rem 1fr; + align-items: start; + gap: var(--space-3); + min-height: var(--touch-target); +} + +.ui-checkbox input { + width: 1.1rem; + height: 1.1rem; + margin-top: 0.2rem; + accent-color: var(--color-primary); +} diff --git a/apps/frontend/src/styles/_layout.scss b/apps/frontend/src/styles/_layout.scss new file mode 100644 index 0000000..d363a27 --- /dev/null +++ b/apps/frontend/src/styles/_layout.scss @@ -0,0 +1,121 @@ +.ui-page { + display: grid; + gap: var(--space-6); +} + +.ui-page-header { + display: grid; + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.ui-page-header__content { + display: grid; + gap: var(--space-2); +} + +.ui-page-header__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.ui-grid { + display: grid; + gap: var(--space-4); +} + +.ui-grid--cards { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); +} + +.ui-card { + display: grid; + gap: var(--space-4); + min-width: 0; + padding: var(--space-5); + color: var(--color-text-primary); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.ui-card--interactive { + transition: + border-color var(--transition-fast) ease, + box-shadow var(--transition-fast) ease; +} + +.ui-card--interactive:hover { + border-color: var(--color-border-strong); + box-shadow: var(--shadow-md); +} + +.ui-card--selected { + border-color: var(--color-primary); + background: var(--color-primary-subtle); +} + +.ui-card--warning { + border-color: var(--color-warning); + background: var(--color-warning-subtle); +} + +.ui-card--danger { + border-color: var(--color-danger); + background: var(--color-danger-subtle); +} + +.ui-toolbar { + display: grid; + gap: var(--space-4); + padding: var(--space-5); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.ui-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.ui-kpi { + display: grid; + gap: var(--space-2); +} + +.ui-kpi__value { + font-size: var(--font-size-2xl); + line-height: var(--line-height-tight); + font-weight: var(--font-weight-bold); +} + +.ui-notice { + display: grid; + gap: var(--space-2); + padding: var(--space-5); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.ui-notice--error { + color: var(--color-danger); + background: var(--color-danger-subtle); + border-color: var(--color-danger); +} + +@media (min-width: 48rem) { + .ui-page-header { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } + + .ui-toolbar { + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + align-items: end; + } +} diff --git a/apps/frontend/src/styles/_reset.scss b/apps/frontend/src/styles/_reset.scss new file mode 100644 index 0000000..0820bf9 --- /dev/null +++ b/apps/frontend/src/styles/_reset.scss @@ -0,0 +1,70 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-width: 0; + min-height: 100%; + font-family: var(--font-family-base); + color: var(--color-text-primary); + background: var(--color-background); + text-size-adjust: 100%; +} + +body { + min-width: 0; + min-height: 100%; + margin: 0; + font-size: var(--font-size-md); + line-height: var(--line-height-base); +} + +img, +svg, +video { + max-width: 100%; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +a, +input, +textarea, +select { + &:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; + } +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +a { + color: var(--color-primary); + text-underline-offset: 0.16em; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto; + transition-duration: 1ms; + animation-duration: 1ms; + animation-iteration-count: 1; + } +} diff --git a/apps/frontend/src/styles/_tables.scss b/apps/frontend/src/styles/_tables.scss new file mode 100644 index 0000000..e87094a --- /dev/null +++ b/apps/frontend/src/styles/_tables.scss @@ -0,0 +1,34 @@ +.ui-table-wrap { + overflow-x: auto; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.ui-table { + width: 100%; + border-collapse: collapse; + min-width: 42rem; +} + +.ui-table th, +.ui-table td { + padding: var(--space-4) var(--space-5); + text-align: left; + border-bottom: 1px solid var(--color-border); +} + +.ui-table th { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + background: var(--color-neutral-subtle); +} + +.ui-table tr:last-child td { + border-bottom: 0; +} + +.ui-table tbody tr:hover { + background: var(--color-primary-subtle); +} diff --git a/apps/frontend/src/styles/_tokens.scss b/apps/frontend/src/styles/_tokens.scss new file mode 100644 index 0000000..cd2680e --- /dev/null +++ b/apps/frontend/src/styles/_tokens.scss @@ -0,0 +1,76 @@ +:root { + --color-primary: #245b7d; + --color-primary-hover: #1d4f6d; + --color-primary-active: #173f58; + --color-primary-subtle: #e7f1f7; + --color-secondary: #546475; + --color-background: #f5f7fa; + --color-surface: #ffffff; + --color-surface-elevated: #ffffff; + --color-text-primary: #182331; + --color-text-secondary: #4d5d70; + --color-text-muted: #687789; + --color-border: #d9e0e8; + --color-border-strong: #aeb9c6; + --color-focus: #0b72b9; + --color-success: #23724d; + --color-success-subtle: #e7f5ee; + --color-warning: #8a5a0a; + --color-warning-subtle: #fff4d8; + --color-danger: #a13d3d; + --color-danger-subtle: #fbeaea; + --color-info: #315f94; + --color-info-subtle: #e8f1fb; + --color-neutral-subtle: #eef2f6; + + --space-0: 0; + --space-1: 0.125rem; + --space-2: 0.25rem; + --space-3: 0.5rem; + --space-4: 0.75rem; + --space-5: 1rem; + --space-6: 1.5rem; + --space-7: 2rem; + --space-8: 3rem; + --space-9: 4rem; + + --font-family-base: + ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-md: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.375rem; + --font-size-2xl: 1.75rem; + --line-height-tight: 1.2; + --line-height-base: 1.5; + --line-height-relaxed: 1.65; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 650; + --font-weight-bold: 750; + + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-pill: 999px; + --shadow-sm: 0 1px 2px rgb(24 35 49 / 8%); + --shadow-md: 0 8px 24px rgb(24 35 49 / 12%); + --z-header: 20; + --z-drawer: 30; + --z-overlay: 40; + --z-dialog: 50; + --z-toast: 60; + --transition-fast: 120ms; + --transition-base: 180ms; + --container-width: 72rem; + --sidebar-width: 16.25rem; + --header-height: 4rem; + --breakpoint-small: 36rem; + --breakpoint-medium: 48rem; + --breakpoint-large: 64rem; + --breakpoint-wide: 80rem; + --touch-target: 2.75rem; + --input-height: 2.75rem; + --button-height: 2.75rem; +} diff --git a/apps/frontend/src/styles/_typography.scss b/apps/frontend/src/styles/_typography.scss new file mode 100644 index 0000000..5dd92ba --- /dev/null +++ b/apps/frontend/src/styles/_typography.scss @@ -0,0 +1,44 @@ +h1, +h2, +h3, +p { + margin-block: 0; +} + +h1, +.ui-page-title { + font-size: var(--font-size-2xl); + line-height: var(--line-height-tight); + font-weight: var(--font-weight-bold); +} + +h2, +.ui-section-title { + font-size: var(--font-size-xl); + line-height: var(--line-height-tight); + font-weight: var(--font-weight-semibold); +} + +h3, +.ui-subsection-title { + font-size: var(--font-size-lg); + line-height: var(--line-height-tight); + font-weight: var(--font-weight-semibold); +} + +.ui-text { + color: var(--color-text-primary); +} + +.ui-help-text, +.ui-meta, +.text-muted { + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.ui-label { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); +} diff --git a/apps/frontend/src/styles/_utilities.scss b/apps/frontend/src/styles/_utilities.scss new file mode 100644 index 0000000..b7d2397 --- /dev/null +++ b/apps/frontend/src/styles/_utilities.scss @@ -0,0 +1,72 @@ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stack { + display: grid; + gap: var(--space-4); +} + +.cluster { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: center; +} + +.full-width { + width: 100%; +} + +.ui-badge { + width: fit-content; + min-height: 1.5rem; + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + color: var(--color-text-secondary); + background: var(--color-neutral-subtle); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.ui-badge--success { + color: var(--color-success); + background: var(--color-success-subtle); + border-color: var(--color-success); +} + +.ui-badge--warning { + color: var(--color-warning); + background: var(--color-warning-subtle); + border-color: var(--color-warning); +} + +.ui-badge--danger { + color: var(--color-danger); + background: var(--color-danger-subtle); + border-color: var(--color-danger); +} + +.ui-badge--info { + color: var(--color-info); + background: var(--color-info-subtle); + border-color: var(--color-info); +} diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts index a16f1ac..2cf8e8b 100644 --- a/apps/frontend/vitest.config.ts +++ b/apps/frontend/vitest.config.ts @@ -6,5 +6,8 @@ export default defineConfig({ include: ['src/**/*.spec.ts'], setupFiles: ['src/test-setup.ts'], globals: true, + pool: 'threads', + maxWorkers: 1, + minWorkers: 1, }, }); diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..9af20ac --- /dev/null +++ b/docs/admin.md @@ -0,0 +1,121 @@ +# Adminbereich + +Der Adminbereich liegt im Frontend unter `/admin` und verwendet dieselbe +serverseitige Authentifizierung, CSRF-Pruefung und Permission-Logik wie die +restliche Anwendung. Angular blendet Navigation und Aktionen nur fuer passende +Permissions ein; verbindlich prueft immer das Backend. + +## Permissions + +Die administrativen Permissions sind fest in +`apps/backend/src/roles/permissions.ts` definiert: + +- `users.read`: Benutzer anzeigen, suchen und filtern +- `users.manage`: Benutzer aktivieren, deaktivieren und Rollen zuweisen +- `roles.read`: Rollen und Permissions anzeigen +- `roles.manage`: Rollen anlegen, bearbeiten und loeschen +- `sessions.manage`: Sessions anderer Benutzer anzeigen und beenden +- `audit.read`: administratives Audit-Log anzeigen + +Benutzer haben keine direkten Permissions. Effektive Permissions werden aus +allen Rollen abgeleitet. Rollen- und Benutzerverwaltung laufen ueber Services; +Controller greifen nicht direkt auf TypeORM-Repositories zu. + +## Systemrollen + +Es gibt mindestens die Systemrollen `admin` und `user`. + +- `admin` ist geschuetzt, nicht loeschbar und muss administrative Permissions + behalten. +- `user` ist geschuetzt, nicht loeschbar und Standardrolle fuer neue Benutzer. +- Eigene Rollen koennen angelegt, umbenannt, geloescht und mit bekannten + Permissions versehen werden. + +Eine Rolle kann nur geloescht werden, wenn sie keinem Benutzer mehr zugewiesen +ist. Andernfalls antwortet die API mit `ROLE_STILL_ASSIGNED`. + +## Letzter aktiver Administrator + +Die Anwendung darf nie ohne aktiven Administrator enden. Sicherheitskritische +Aktionen laufen transaktional und halten einen MySQL-Lock +`business_app_admin_integrity`: + +- Benutzer deaktivieren +- Rollen eines Benutzers aendern +- Adminrolle entfernen +- Adminrolle in ihren Permissions veraendern +- Rolle loeschen + +Wenn eine Aktion den letzten aktiven Administrator entfernen wuerde, antwortet +die API mit HTTP 409 und `LAST_ACTIVE_ADMIN_REQUIRED`. + +## Benutzerstatus und Sessions + +Benutzer werden weiterhin ausschliesslich ueber OIDC angelegt. Name und E-Mail +kommen vom Identity Provider und sind im Adminbereich nicht editierbar. + +Beim Deaktivieren wird der Benutzer sofort inaktiv gesetzt und alle aktiven +Sessions des Benutzers werden widerrufen. Neue OIDC-Logins deaktivierter Benutzer +werden trotz erfolgreicher IdP-Authentifizierung abgewiesen. Beim erneuten +Aktivieren darf sich der Benutzer wieder anmelden; alte Sessions werden nicht +wiederhergestellt. + +Admin-Session-Endpunkte geben nur eine sichere, gekuerzte Session-Referenz, +Zeitpunkte, User-Agent, IP-Annaeherung und Status zurueck. Tokens und rohe +Session-Secrets werden nie ausgegeben. + +## API-Endpunkte + +Benutzer: + +- `GET /api/admin/users` +- `GET /api/admin/users/:id` +- `PATCH /api/admin/users/:id/deactivate` +- `PATCH /api/admin/users/:id/activate` +- `POST /api/admin/users/:id/roles/:roleId` +- `DELETE /api/admin/users/:id/roles/:roleId` +- `GET /api/admin/users/:id/sessions` +- `DELETE /api/admin/users/:userId/sessions/:sessionId` +- `DELETE /api/admin/users/:id/sessions` + +Rollen: + +- `GET /api/admin/roles` +- `GET /api/admin/roles/:id` +- `POST /api/admin/roles` +- `PUT /api/admin/roles/:id` +- `DELETE /api/admin/roles/:id` + +Audit: + +- `GET /api/audit-log` + +## Audit-Log + +Administrative Aenderungen werden auditierbar protokolliert, darunter: + +- `USER_ACTIVATED` +- `USER_DEACTIVATED` +- `USER_ROLE_ASSIGNED` +- `USER_ROLE_REMOVED` +- `ROLE_CREATED` +- `ROLE_UPDATED` +- `ROLE_DELETED` +- `ROLE_PERMISSIONS_UPDATED` +- `SESSION_REVOKED` +- `ALL_USER_SESSIONS_REVOKED` + +Gespeichert werden fachliche IDs, Request-ID und minimale Metadaten. Tokens, +Secrets und vollstaendige Sessiondaten werden nicht geloggt. + +## Migration + +Die Admin-Erweiterung fuegt `roles.description` hinzu. Vor dem Deployment muss +die Migration ausgefuehrt werden: + +```bash +npm run migration:run +``` + +Der normale App-Start fuehrt Migrationen weiterhin nicht automatisch aus, +sondern meldet fehlende Migrationen ueber die Readiness-Pruefung. diff --git a/docs/architecture.md b/docs/architecture.md index 9fc3098..ea249d0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,9 @@ geschnitten: - `sessions`: serverseitige Sessions, CSRF-Token und Session-Listen - `roles`: Code-definierte Permissions und rollenbasierte Rechteverwaltung - `users`: lokale Benutzerprofile und Einstellungen +- `admin`: Angular-Routen unter `/admin` fuer Benutzer-, Rollen-, Session- und Audit-Verwaltung - `items`: Beispiel-Fachmodul +- `notifications`: persoenliche In-App-Benachrichtigungen, Admin-Erzeugung und interner Service - `audit`: Audit-Log fuer administrative Nachvollziehbarkeit - `health`: Liveness- und Readiness-Endpunkte - `database`: TypeORM-Konfiguration, Entities und Migrationen @@ -116,5 +118,12 @@ Systemrollen: - `admin`: alle Permissions - `user`: Basisrechte fuer Items-Lesen und eigene Sessions +Die Standardrolle `user` enthaelt ausserdem +`notifications.readOwn` und `notifications.updateOwn`, damit angemeldete +Benutzer ihre eigenen In-App-Benachrichtigungen verwalten koennen. + Der erste erfolgreich angemeldete Benutzer wird automatisch Admin. Danach erhalten neue Benutzer initial die Rolle `user`. + +Details zu Admin-Endpunkten, Systemrollen und dem transaktionalen Schutz des +letzten aktiven Administrators stehen in [Adminbereich](admin.md). diff --git a/docs/configuration.md b/docs/configuration.md index a0c9bc8..6ebd6ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,14 +30,15 @@ separat ausgefuehrt. ## OIDC -| Variable | Bedeutung | -| ------------------------- | ---------------------------------------------------- | -| `OIDC_ISSUER` | Exakter Issuer des Providers | -| `OIDC_CLIENT_ID` | Client ID | -| `OIDC_CLIENT_SECRET` | Client Secret | -| `OIDC_SCOPES` | Scopes, typischerweise `openid profile email` | -| `OIDC_ALLOWED_ALGORITHMS` | Erlaubte ID-Token-Signaturalgorithmen, z. B. `RS256` | -| `OIDC_HTTP_TIMEOUT_MS` | Timeout fuer IdP-HTTP-Requests | +| Variable | Bedeutung | +| ------------------------- | ------------------------------------------------------- | +| `OIDC_ISSUER` | Exakter Issuer des Providers | +| `OIDC_CLIENT_ID` | Client ID | +| `OIDC_CLIENT_SECRET` | Client Secret | +| `OIDC_SCOPES` | Scopes, typischerweise `openid profile email` | +| `OIDC_LOGOUT_URL` | Optionale IdP-Logout-URL, falls Discovery keine liefert | +| `OIDC_ALLOWED_ALGORITHMS` | Erlaubte ID-Token-Signaturalgorithmen, z. B. `RS256` | +| `OIDC_HTTP_TIMEOUT_MS` | Timeout fuer IdP-HTTP-Requests | Der Callback ist immer: @@ -45,6 +46,11 @@ Der Callback ist immer: /api/auth/callback ``` +Beim App-Logout wird zuerst die lokale Session widerrufen. Danach leitet die App +zum OIDC `end_session_endpoint` aus Discovery weiter. Wenn der Provider diesen +Endpoint nicht publiziert, kann `OIDC_LOGOUT_URL` gesetzt werden. Die App sendet +`client_id`, `post_logout_redirect_uri` und, falls vorhanden, `id_token_hint`. + ## Sessions und CSRF | Variable | Bedeutung | @@ -93,6 +99,7 @@ OIDC_ISSUER=https://idp.example.com/realms/internal OIDC_CLIENT_ID=business-app OIDC_CLIENT_SECRET= OIDC_SCOPES=openid profile email +OIDC_LOGOUT_URL=https://idp.example.com/realms/internal/protocol/openid-connect/logout OIDC_ALLOWED_ALGORITHMS=RS256 OIDC_HTTP_TIMEOUT_MS=5000 SESSION_COOKIE_NAME=app_session diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..5e5d105 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,114 @@ +# Designsystem + +Das Frontend verwendet ein eigenes, schlankes Designsystem ohne externe +UI-Library. Es besteht aus zentralen CSS Custom Properties, globalen +Grundklassen und wenigen Angular-UI-Komponenten unter +`apps/frontend/src/app/shared/ui`. + +## Prinzipien + +- sachliche Business-Oberflaeche statt Marketing-Optik +- mobile first, keine globale Mindestbreite +- klare Hierarchie durch Typografie, Abstand und Rahmen +- Farben immer semantisch ueber Tokens +- sichtbare Fokuszustaende und grosse Touch-Flaechen +- Komponenten nur dort, wo sie Verhalten oder Wiederverwendung bringen + +## Tokens + +Die Tokens liegen in `apps/frontend/src/styles/_tokens.scss` und werden ueber +`apps/frontend/src/styles.scss` eingebunden. + +Wichtige Gruppen: + +- Farben: `--color-primary`, `--color-danger`, `--color-surface`, + `--color-text-primary`, `--color-border`, `--color-focus` +- Abstaende: `--space-1` bis `--space-9` +- Typografie: `--font-size-xs` bis `--font-size-2xl`, `--line-height-*`, + `--font-weight-*` +- Layout: `--container-width`, `--sidebar-width`, `--header-height`, + `--touch-target`, `--input-height`, `--button-height` +- Oberflaeche: `--radius-*`, `--shadow-*`, `--z-*`, `--transition-*` + +Feature-Komponenten duerfen keine direkten Hex-Farben enthalten. Neue Farben +werden zuerst als semantische Tokens angelegt. + +## Globale Klassen + +Globale Klassen sind bewusst begrenzt: + +- Layout: `.ui-page`, `.ui-page-header`, `.ui-grid`, `.ui-card`, + `.ui-toolbar`, `.ui-actions` +- Formulare: `.ui-form`, `.ui-form-field`, `.ui-control`, `.ui-checkbox`, + `.ui-field-error` +- Buttons: `.ui-button`, `.ui-icon-button` +- Tabellen: `.ui-table-wrap`, `.ui-table` +- Status: `.ui-badge`, `.ui-badge--success`, `.ui-badge--warning`, + `.ui-badge--danger`, `.ui-badge--info` +- Utilities: `.visually-hidden`, `.truncate`, `.stack`, `.cluster`, + `.full-width`, `.text-muted` + +Keine neuen Utility-Klassen einfuehren, wenn eine lokale Klasse oder bestehende +UI-Komponente ausreicht. + +## Angular-Komponenten + +Wiederverwendbare UI-Bausteine: + +- `UiButtonComponent` +- `UiIconComponent` +- `UiIconButtonComponent` +- `UiFormFieldComponent` +- `UiStatusBadgeComponent` +- `UiConfirmDialogComponent` +- `ToastService` und `UiToastHostComponent` +- `UiEmptyStateComponent` +- `UiLoadingStateComponent` +- `UiPaginationComponent` +- `UiPageHeaderComponent` + +Neue Feature-Seiten sollen diese Bausteine bevorzugen, wenn sie Button-, Badge-, +Dialog-, Toast-, Empty-, Loading- oder Pagination-Verhalten brauchen. + +## Responsive Regeln + +- Mobile Layouts sind einspaltig. +- Aktionen duerfen mobil untereinander stehen und volle Breite nutzen. +- Business-Listen werden mobil als Karten dargestellt. +- Tabellen liegen in `.ui-table-wrap`, wenn eine echte Tabelle sinnvoll bleibt. +- Touch-Ziele orientieren sich an `--touch-target`. +- Breakpoints werden in rem formuliert und nicht nach Geraetetyp benannt. + +## Accessibility + +- native HTML-Elemente vor ARIA verwenden +- interaktive Elemente sind Buttons oder Links +- sichtbare Fokuszustaende nicht entfernen +- Labels ersetzen Placeholder nicht +- Fehlertexte stehen direkt am Feld +- Status ist nicht nur Farbe, sondern auch Text/Marker +- Dialoge setzen Fokus, schliessen per Escape und geben Fokus zurueck +- Navigation und Drawer sind per Tastatur bedienbar +- Animationen respektieren `prefers-reduced-motion` + +## Entwicklungsseite + +Die interne Referenzseite liegt unter: + +```text +/dev/design-system +``` + +Sie ist mit `devOnlyGuard` geschuetzt und im Production-Modus nicht matchbar. +Sie ersetzt kein Storybook, sondern zeigt die vorhandenen Tokens und Komponenten +innerhalb der echten Anwendung. + +## Regeln fuer neue UI + +1. Bestehende UI-Komponenten oder globale Klassen wiederverwenden. +2. Keine direkte Hex-Farbe in Feature-Komponenten. +3. Keine neue UI-Library einfuehren. +4. Keine tiefen Selektoren, kein `::ng-deep`, kein unkontrolliertes + `!important`. +5. Keine klickbaren `div`-Elemente als Ersatz fuer Buttons oder Links. +6. Verhalten mit Vitest testen, insbesondere Accessibility-relevante Zustaende. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9f71487..02faa04 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,6 +38,11 @@ Der OIDC Provider muss als Redirect URI diese URL erlauben: http://localhost:3000/api/auth/callback ``` +Falls der Provider RP-Initiated Logout validiert, muss ausserdem +`http://localhost:4200` beziehungsweise die konfigurierte `FRONTEND_BASE_URL` als +Post-Logout-Redirect erlaubt sein. Wenn Discovery keinen `end_session_endpoint` +liefert, setze `OIDC_LOGOUT_URL`. + ## Datenbank vorbereiten Die Anwendung fuehrt Migrationen beim normalen Start nicht automatisch aus. diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..eaea5d2 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,146 @@ +# Benachrichtigungen + +Das Modul `apps/backend/src/notifications` stellt persoenliche In-App- +Benachrichtigungen bereit. Es gibt bewusst keinen E-Mail-Versand, keine Push +Notifications, keine WebSockets, keine Server-Sent Events, keine Queue, kein +Redis und keine Hintergrundjobs. Neue Benachrichtigungen werden beim Laden der +Anwendung und per Frontend-Polling abgerufen. + +## Datenmodell + +Die Entity `NotificationEntity` wird in der Tabelle `notifications` gespeichert. +Jede Benachrichtigung gehoert genau einem Benutzer. + +Felder: + +- `id`: UUID +- `userId`: Foreign Key auf `users.id` +- `type`: technischer Typ aus `NotificationType` +- `title`: kurzer Plain-Text-Titel, maximal 150 Zeichen +- `message`: Plain-Text-Nachricht, maximal 1000 Zeichen +- `link`: optionale interne Angular-Route, maximal 500 Zeichen +- `metadata`: optionale JSON-Metadaten, maximal 4096 Bytes serialisiert +- `readAt`: `null`, solange ungelesen +- `createdAt`: UTC-Erstellungszeit +- `deletedAt`: Soft Delete + +Indizes: + +- `user_id, created_at` +- `user_id, read_at` +- `user_id, deleted_at` + +Die Migration liegt unter +`apps/backend/src/database/migrations/1720000001000-AddNotifications.ts` und wird +nicht automatisch beim App-Start ausgefuehrt. + +## Typen + +Benachrichtigungstypen sind Code, keine Datenbankdaten: + +- `system` +- `item.created` +- `item.updated` +- `user.role-changed` + +Neue Module erweitern `apps/backend/src/notifications/notification-types.ts` +und nutzen anschliessend `NotificationsService`. + +## Permissions + +- `notifications.readOwn`: eigene Benachrichtigungen lesen +- `notifications.updateOwn`: eigene Benachrichtigungen markieren oder loeschen +- `notifications.manage`: administrativ Benachrichtigungen erzeugen + +Die Systemrolle `user` erhaelt `notifications.readOwn` und +`notifications.updateOwn`. Die Rolle `admin` erhaelt ueber `allPermissions` +zusaetzlich `notifications.manage`. + +## API + +Alle Endpunkte liegen unter dem bestehenden API-Praefix `/api`. + +- `GET /notifications`: eigene Benachrichtigungen, mit `page`, `pageSize` und + `status=all|read|unread` +- `GET /notifications/unread-count`: Anzahl ungelesener eigener + Benachrichtigungen +- `PATCH /notifications/:id/read`: idempotent als gelesen markieren +- `PATCH /notifications/:id/unread`: idempotent als ungelesen markieren +- `PATCH /notifications/read-all`: alle eigenen Benachrichtigungen als gelesen + markieren +- `DELETE /notifications/:id`: Soft Delete einer eigenen Benachrichtigung +- `POST /admin/notifications`: administrative Erzeugung mit + `notifications.manage` + +Normale Benutzer koennen keine fremde `userId` uebergeben. Der aktuelle Benutzer +wird serverseitig aus der Session bestimmt. + +## Interner Service + +Andere Backend-Module erzeugen Benachrichtigungen ueber +`NotificationsService`, nicht direkt ueber ein Repository: + +```ts +await notifications.createForUser({ + userId, + type: NotificationType.ItemCreated, + title: 'Neuer Eintrag', + message: 'Der Eintrag "Beispiel" wurde erstellt.', + link: '/items/123', + metadata: { itemId: '123' }, +}); +``` + +Verfuegbare Methoden: + +- `createForUser` +- `createForUsers` +- `getForCurrentUser` +- `getUnreadCount` +- `markAsRead` +- `markAsUnread` +- `markAllAsRead` +- `softDelete` + +`createForUsers` begrenzt Bulk-Erzeugung auf 100 Zielbenutzer. + +## Integrationen + +`ItemsService` benachrichtigt beim Erstellen eines Items den ersten aktiven +Administrator, sofern dieser nicht der Ersteller ist. Das ist eine bewusst kleine +Beispielregel und keine vollstaendige fachliche Eskalationslogik. + +`UsersService` benachrichtigt betroffene Benutzer nach erfolgreicher +Rollenaenderung. Fehler beim Erzeugen werden geloggt; die bereits erfolgreiche +sicherheitsrelevante Rollenaenderung wird dadurch nicht unkontrolliert +zurueckgerollt. + +Administrative Erzeugung wird im Audit-Log als `NOTIFICATION_CREATED` +protokolliert. Geloggt werden Actor, Zielbenutzer, Notification-ID, Typ und +Request-ID, nicht die vollstaendige Nachricht oder Metadata. + +## Frontend + +`NotificationStore` verwendet Angular Signals fuer lokalen State und RxJS fuer +HTTP und Polling. Header-Panel und Seite `/notifications` verwenden denselben +Store, damit keine doppelten Requests fuer dieselben Daten entstehen. + +Polling: + +- Standardintervall: 60 Sekunden ueber `NOTIFICATION_POLL_INTERVAL_MS` +- nur bei angemeldetem Benutzer +- pausiert bei unsichtbarem Tab +- aktualisiert sofort beim Sichtbarwerden +- verhindert ueberlappende Count-Requests +- stoppt und leert State beim Logout + +Links werden im Frontend nur navigiert, wenn sie interne relative Routen sind. +Titel und Nachricht werden normal interpoliert und nicht per `innerHTML` +gerendert. + +## Spaetere Echtzeitkommunikation + +Wenn spaeter echte Echtzeitkommunikation noetig wird, sollte das als separate +Architekturentscheidung erfolgen. Dann waeren Transport, Skalierung, +Authentifizierung, Backpressure und Betrieb gemeinsam zu entscheiden, statt +WebSockets oder Queues nebenbei in das In-App-Modul einzubauen. diff --git a/docs/security.md b/docs/security.md index d9c6da7..2b764ec 100644 --- a/docs/security.md +++ b/docs/security.md @@ -20,6 +20,12 @@ Der Login nutzt Authorization Code Flow mit PKCE: Erlaubte Signaturalgorithmen werden ueber `OIDC_ALLOWED_ALGORITHMS` gesetzt. `none` ist explizit verboten. +Beim Logout wird zuerst die lokale Session widerrufen und das Session-/CSRF-Cookie +geloescht. Anschliessend redirectet das Backend zum OIDC +`end_session_endpoint` aus Discovery oder zur optionalen `OIDC_LOGOUT_URL`. Wenn +die Session ein ID-Token enthaelt, wird es nur als `id_token_hint` an den IdP +gegeben und nicht an das Frontend ausgeliefert. + ## Sessions Sessions liegen in MySQL. Das Session-Cookie enthaelt keine Tokens oder @@ -60,6 +66,18 @@ Backend-Controller schuetzen Endpunkte mit: Angular nutzt Permissions nur fuer Navigation und Darstellung. Eine versteckte Schaltflaeche ist keine Sicherheitsgrenze. +Administrative Benutzer-, Rollen- und Session-Aktionen sind unter `/api/admin/*` +mit `users.read`, `users.manage`, `roles.read`, `roles.manage`, +`sessions.manage` und `audit.read` geschuetzt. Aktionen, die den letzten aktiven +Administrator entfernen koennten, laufen transaktional und antworten bei Verstoss +mit `LAST_ACTIVE_ADMIN_REQUIRED`. + +Benachrichtigungen sind benutzerbezogene Daten. Normale Notification-Endpunkte +bestimmen den Benutzer ausschliesslich aus der serverseitig aufgeloesten Session. +Benutzer-IDs werden fuer eigene Benachrichtigungen nicht als Query-Parameter oder +Body-Feld akzeptiert. Fremde oder geloeschte Notification-IDs werden als nicht +gefunden behandelt. + ## Benutzerstatus Deaktivierte Benutzer werden trotz erfolgreichem IdP-Login abgewiesen. Aktive diff --git a/packages/api-client/src/api-client.service.ts b/packages/api-client/src/api-client.service.ts index 85d1065..be9d6f3 100644 --- a/packages/api-client/src/api-client.service.ts +++ b/packages/api-client/src/api-client.service.ts @@ -4,8 +4,16 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import type { Observable } from 'rxjs'; import type { AuditLogDto, + AdminSessionDto, + AdminUserDetailDto, + AdminUserListItemDto, + CreateNotificationDto, ItemDto, + NotificationDto, + NotificationPageDto, + NotificationStatusFilter, PageDto, + Permission, RoleDto, SaveItemDto, SessionDto, @@ -20,9 +28,13 @@ export class ApiClientService { me(): Observable { return this.http.get(`${this.base}/me`, { withCredentials: true }); } + updateSettings(body: { tablePageSize?: number; sidebarExpanded?: boolean }): Observable { - return this.http.patch(`${this.base}/me/settings`, body, { withCredentials: true }); + return this.http.patch(`${this.base}/me/settings`, body, { + withCredentials: true, + }); } + dashboard(): Observable<{ userCount: number; activeSessions: number; @@ -51,15 +63,25 @@ export class ApiClientService { withCredentials: true, }); } + item(id: string): Observable { - return this.http.get(`${this.base}/items/${id}`, { withCredentials: true }); + return this.http.get(`${this.base}/items/${id}`, { + withCredentials: true, + }); } + createItem(body: SaveItemDto): Observable { - return this.http.post(`${this.base}/items`, body, { withCredentials: true }); + return this.http.post(`${this.base}/items`, body, { + withCredentials: true, + }); } + updateItem(id: string, body: Required): Observable { - return this.http.put(`${this.base}/items/${id}`, body, { withCredentials: true }); + return this.http.put(`${this.base}/items/${id}`, body, { + withCredentials: true, + }); } + deleteItem(id: string, version: number): Observable { return this.http.delete(`${this.base}/items/${id}`, { params: { version }, @@ -75,6 +97,7 @@ export class ApiClientService { withCredentials: true, }); } + setUserActive(id: string, active: boolean): Observable { return this.http.patch( `${this.base}/users/${id}/active`, @@ -82,6 +105,7 @@ export class ApiClientService { { withCredentials: true }, ); } + setUserRoles(id: string, roleIds: string[]): Observable { return this.http.patch( `${this.base}/users/${id}/roles`, @@ -90,28 +114,157 @@ export class ApiClientService { ); } + adminUsers( + query: { + search?: string; + active?: 'all' | 'active' | 'inactive'; + roleId?: string; + sort?: 'name' | 'email' | 'lastLoginAt' | 'createdAt'; + direction?: 'ASC' | 'DESC'; + page?: number; + pageSize?: number; + } = {}, + ): Observable> { + return this.http.get>(`${this.base}/admin/users`, { + params: this.params(query), + withCredentials: true, + }); + } + + adminUser(id: string): Observable { + return this.http.get(`${this.base}/admin/users/${id}`, { + withCredentials: true, + }); + } + + activateAdminUser(id: string): Observable { + return this.http.patch( + `${this.base}/admin/users/${id}/activate`, + {}, + { withCredentials: true }, + ); + } + + deactivateAdminUser(id: string): Observable { + return this.http.patch( + `${this.base}/admin/users/${id}/deactivate`, + {}, + { withCredentials: true }, + ); + } + + assignAdminUserRole(userId: string, roleId: string): Observable { + return this.http.post( + `${this.base}/admin/users/${userId}/roles/${roleId}`, + {}, + { withCredentials: true }, + ); + } + + removeAdminUserRole(userId: string, roleId: string): Observable { + return this.http.delete( + `${this.base}/admin/users/${userId}/roles/${roleId}`, + { withCredentials: true }, + ); + } + + adminUserSessions(id: string): Observable { + return this.http.get(`${this.base}/admin/users/${id}/sessions`, { + withCredentials: true, + }); + } + + revokeAdminUserSession(userId: string, sessionId: string): Observable { + return this.http.delete(`${this.base}/admin/users/${userId}/sessions/${sessionId}`, { + withCredentials: true, + }); + } + + revokeAdminUserSessions(userId: string): Observable<{ revoked: number }> { + return this.http.delete<{ revoked: number }>(`${this.base}/admin/users/${userId}/sessions`, { + withCredentials: true, + }); + } + roles(): Observable { - return this.http.get(`${this.base}/roles`, { withCredentials: true }); + return this.http.get(`${this.base}/roles`, { + withCredentials: true, + }); } + createRole(body: { name: string; permissions: string[] }): Observable { - return this.http.post(`${this.base}/roles`, body, { withCredentials: true }); + return this.http.post(`${this.base}/roles`, body, { + withCredentials: true, + }); } + updateRole(id: string, body: { name: string; permissions: string[] }): Observable { - return this.http.put(`${this.base}/roles/${id}`, body, { withCredentials: true }); + return this.http.put(`${this.base}/roles/${id}`, body, { + withCredentials: true, + }); } + deleteRole(id: string): Observable { - return this.http.delete(`${this.base}/roles/${id}`, { withCredentials: true }); + return this.http.delete(`${this.base}/roles/${id}`, { + withCredentials: true, + }); + } + + adminRoles(): Observable { + return this.http.get(`${this.base}/admin/roles`, { + withCredentials: true, + }); + } + + adminRole(id: string): Observable { + return this.http.get(`${this.base}/admin/roles/${id}`, { + withCredentials: true, + }); + } + + createAdminRole(body: { + name: string; + description?: string; + permissions: Permission[]; + }): Observable { + return this.http.post(`${this.base}/admin/roles`, body, { + withCredentials: true, + }); + } + + updateAdminRole( + id: string, + body: { name: string; description?: string; permissions: Permission[] }, + ): Observable { + return this.http.put(`${this.base}/admin/roles/${id}`, body, { + withCredentials: true, + }); + } + + deleteAdminRole(id: string): Observable { + return this.http.delete(`${this.base}/admin/roles/${id}`, { + withCredentials: true, + }); } sessions(): Observable { - return this.http.get(`${this.base}/sessions/own`, { withCredentials: true }); + return this.http.get(`${this.base}/sessions/own`, { + withCredentials: true, + }); } + revokeSession(id: string): Observable { - return this.http.delete(`${this.base}/sessions/own/${id}`, { withCredentials: true }); + return this.http.delete(`${this.base}/sessions/own/${id}`, { + withCredentials: true, + }); } + revokeOtherSessions(): Observable { - return this.http.delete(`${this.base}/sessions/own`, { withCredentials: true }); + return this.http.delete(`${this.base}/sessions/own`, { + withCredentials: true, + }); } + revokeUserSessions(userId: string): Observable { return this.http.delete(`${this.base}/sessions/users/${userId}`, { withCredentials: true, @@ -125,6 +278,61 @@ export class ApiClientService { }); } + notifications( + query: { + page?: number; + pageSize?: number; + status?: NotificationStatusFilter; + } = {}, + ): Observable { + return this.http.get(`${this.base}/notifications`, { + params: this.params(query), + withCredentials: true, + }); + } + + unreadNotificationCount(): Observable<{ count: number }> { + return this.http.get<{ count: number }>(`${this.base}/notifications/unread-count`, { + withCredentials: true, + }); + } + + markNotificationRead(id: string): Observable { + return this.http.patch( + `${this.base}/notifications/${id}/read`, + {}, + { withCredentials: true }, + ); + } + + markNotificationUnread(id: string): Observable { + return this.http.patch( + `${this.base}/notifications/${id}/unread`, + {}, + { withCredentials: true }, + ); + } + + markAllNotificationsRead(): Observable<{ updated: number }> { + return this.http.patch<{ updated: number }>( + `${this.base}/notifications/read-all`, + {}, + { withCredentials: true }, + ); + } + + deleteNotification(id: string): Observable { + return this.http.delete(`${this.base}/notifications/${id}`, { + withCredentials: true, + }); + } + + createAdminNotification(body: CreateNotificationDto): Observable { + return this.http.post(`${this.base}/admin/notifications`, body, { + withCredentials: true, + }); + } + private params(value: Record): HttpParams { let params = new HttpParams(); Object.entries(value).forEach(([key, entry]) => { diff --git a/packages/api-client/src/models.ts b/packages/api-client/src/models.ts index 185305e..ceea212 100644 --- a/packages/api-client/src/models.ts +++ b/packages/api-client/src/models.ts @@ -11,7 +11,10 @@ export type Permission = | 'audit.read' | 'sessions.readOwn' | 'sessions.revokeOwn' - | 'sessions.manage'; + | 'sessions.manage' + | 'notifications.readOwn' + | 'notifications.updateOwn' + | 'notifications.manage'; export interface ApiErrorBody { status: number; @@ -38,11 +41,47 @@ export interface UserDto { settings: { tablePageSize: number; sidebarExpanded: boolean }; } +export interface AdminRoleSummaryDto { + id: string; + name: string; + system: boolean; +} + +export interface AdminSessionDto { + id: string; + createdAt: string; + lastActivityAt: string; + userAgent: string | null; + approximateIp: string | null; + current: boolean; + expiresAt: string; + revokedAt: string | null; +} + +export interface AdminUserListItemDto { + id: string; + name: string; + email: string | null; + active: boolean; + roles: AdminRoleSummaryDto[]; + lastLoginAt: string | null; + createdAt: string; + activeSessionCount: number; +} + +export interface AdminUserDetailDto extends AdminUserListItemDto { + effectivePermissions: Permission[]; + sessions: AdminSessionDto[]; +} + export interface RoleDto { id: string; name: string; + description: string; + system: boolean; protected: boolean; permissions: { id: Permission; description: string }[]; + userCount?: number; users?: UserDto[]; } @@ -84,3 +123,32 @@ export interface AuditLogDto { metadata: Record | null; requestId: string; } + +export type NotificationType = 'system' | 'item.created' | 'item.updated' | 'user.role-changed'; + +export type NotificationStatusFilter = 'all' | 'read' | 'unread'; + +export interface NotificationDto { + id: string; + type: NotificationType; + title: string; + message: string; + link: string | null; + metadata: Record | null; + read: boolean; + readAt: string | null; + createdAt: string; +} + +export interface NotificationPageDto extends PageDto { + unreadCount: number; +} + +export interface CreateNotificationDto { + userId: string; + type: NotificationType; + title: string; + message: string; + link?: string; + metadata?: Record; +} diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs index 370ee42..596d451 100644 --- a/scripts/generate-api-client.mjs +++ b/scripts/generate-api-client.mjs @@ -10,11 +10,525 @@ const files = new Map([ ], [ 'packages/api-client/src/models.ts', - `// Generated from the backend OpenAPI contract. Do not edit manually.\nexport type Permission =\n | 'items.read'\n | 'items.create'\n | 'items.update'\n | 'items.delete'\n | 'users.read'\n | 'users.manage'\n | 'roles.read'\n | 'roles.manage'\n | 'audit.read'\n | 'sessions.readOwn'\n | 'sessions.revokeOwn'\n | 'sessions.manage';\n\nexport interface ApiErrorBody {\n status: number;\n code: string;\n message: string;\n requestId: string;\n validation?: { field: string; messages: string[] }[];\n}\n\nexport interface PageDto {\n items: T[];\n total: number;\n page: number;\n pageSize: number;\n}\n\nexport interface UserDto {\n id: string;\n name: string;\n email: string | null;\n active: boolean;\n lastLoginAt: string | null;\n roles: RoleDto[];\n settings: { tablePageSize: number; sidebarExpanded: boolean };\n}\n\nexport interface RoleDto {\n id: string;\n name: string;\n protected: boolean;\n permissions: { id: Permission; description: string }[];\n users?: UserDto[];\n}\n\nexport interface ItemDto {\n id: string;\n name: string;\n description: string | null;\n status: 'draft' | 'active' | 'archived';\n version: number;\n createdAt: string;\n updatedAt: string;\n deletedAt: string | null;\n}\n\nexport interface SaveItemDto {\n name: string;\n description?: string;\n status: ItemDto['status'];\n version?: number;\n}\n\nexport interface SessionDto {\n id: string;\n createdAt: string;\n lastActivityAt: string;\n userAgent: string | null;\n approximateIp: string | null;\n current: boolean;\n revokedAt: string | null;\n}\n\nexport interface AuditLogDto {\n id: string;\n createdAt: string;\n actorUserId: string | null;\n action: string;\n targetType: string;\n targetId: string;\n metadata: Record | null;\n requestId: string;\n}\n`, + `// Generated from the backend OpenAPI contract. Do not edit manually. +export type Permission = + | 'items.read' + | 'items.create' + | 'items.update' + | 'items.delete' + | 'users.read' + | 'users.manage' + | 'roles.read' + | 'roles.manage' + | 'audit.read' + | 'sessions.readOwn' + | 'sessions.revokeOwn' + | 'sessions.manage' + | 'notifications.readOwn' + | 'notifications.updateOwn' + | 'notifications.manage'; + +export interface ApiErrorBody { + status: number; + code: string; + message: string; + requestId: string; + validation?: { field: string; messages: string[] }[]; +} + +export interface PageDto { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export interface UserDto { + id: string; + name: string; + email: string | null; + active: boolean; + lastLoginAt: string | null; + roles: RoleDto[]; + settings: { tablePageSize: number; sidebarExpanded: boolean }; +} + +export interface AdminRoleSummaryDto { + id: string; + name: string; + system: boolean; +} + +export interface AdminSessionDto { + id: string; + createdAt: string; + lastActivityAt: string; + userAgent: string | null; + approximateIp: string | null; + current: boolean; + expiresAt: string; + revokedAt: string | null; +} + +export interface AdminUserListItemDto { + id: string; + name: string; + email: string | null; + active: boolean; + roles: AdminRoleSummaryDto[]; + lastLoginAt: string | null; + createdAt: string; + activeSessionCount: number; +} + +export interface AdminUserDetailDto extends AdminUserListItemDto { + effectivePermissions: Permission[]; + sessions: AdminSessionDto[]; +} + +export interface RoleDto { + id: string; + name: string; + description: string; + system: boolean; + protected: boolean; + permissions: { id: Permission; description: string }[]; + userCount?: number; + users?: UserDto[]; +} + +export interface ItemDto { + id: string; + name: string; + description: string | null; + status: 'draft' | 'active' | 'archived'; + version: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SaveItemDto { + name: string; + description?: string; + status: ItemDto['status']; + version?: number; +} + +export interface SessionDto { + id: string; + createdAt: string; + lastActivityAt: string; + userAgent: string | null; + approximateIp: string | null; + current: boolean; + revokedAt: string | null; +} + +export interface AuditLogDto { + id: string; + createdAt: string; + actorUserId: string | null; + action: string; + targetType: string; + targetId: string; + metadata: Record | null; + requestId: string; +} + +export type NotificationType = + | 'system' + | 'item.created' + | 'item.updated' + | 'user.role-changed'; + +export type NotificationStatusFilter = 'all' | 'read' | 'unread'; + +export interface NotificationDto { + id: string; + type: NotificationType; + title: string; + message: string; + link: string | null; + metadata: Record | null; + read: boolean; + readAt: string | null; + createdAt: string; +} + +export interface NotificationPageDto extends PageDto { + unreadCount: number; +} + +export interface CreateNotificationDto { + userId: string; + type: NotificationType; + title: string; + message: string; + link?: string; + metadata?: Record; +} +`, ], [ 'packages/api-client/src/api-client.service.ts', - `// Generated from the backend OpenAPI contract. Do not edit manually.\nimport { Injectable, inject } from '@angular/core';\nimport { HttpClient, HttpParams } from '@angular/common/http';\nimport type { Observable } from 'rxjs';\nimport type { AuditLogDto, ItemDto, PageDto, RoleDto, SaveItemDto, SessionDto, UserDto } from './models';\n\n@Injectable({ providedIn: 'root' })\nexport class ApiClientService {\n private readonly http = inject(HttpClient);\n private readonly base = '/api';\n\n me(): Observable { return this.http.get(\`\${this.base}/me\`, { withCredentials: true }); }\n updateSettings(body: { tablePageSize?: number; sidebarExpanded?: boolean }): Observable { return this.http.patch(\`\${this.base}/me/settings\`, body, { withCredentials: true }); }\n dashboard(): Observable<{ userCount: number; activeSessions: number; roleCount: number; itemCount: number }> { return this.http.get<{ userCount: number; activeSessions: number; roleCount: number; itemCount: number }>(\`\${this.base}/dashboard\`, { withCredentials: true }); }\n\n items(query: { search?: string; page?: number; pageSize?: number; sort?: string; direction?: string } = {}): Observable> {\n return this.http.get>(\`\${this.base}/items\`, { params: this.params(query), withCredentials: true });\n }\n item(id: string): Observable { return this.http.get(\`\${this.base}/items/\${id}\`, { withCredentials: true }); }\n createItem(body: SaveItemDto): Observable { return this.http.post(\`\${this.base}/items\`, body, { withCredentials: true }); }\n updateItem(id: string, body: Required): Observable { return this.http.put(\`\${this.base}/items/\${id}\`, body, { withCredentials: true }); }\n deleteItem(id: string, version: number): Observable { return this.http.delete(\`\${this.base}/items/\${id}\`, { params: { version }, withCredentials: true }); }\n\n users(query: { search?: string; page?: number; pageSize?: number } = {}): Observable> { return this.http.get>(\`\${this.base}/users\`, { params: this.params(query), withCredentials: true }); }\n setUserActive(id: string, active: boolean): Observable { return this.http.patch(\`\${this.base}/users/\${id}/active\`, { active }, { withCredentials: true }); }\n setUserRoles(id: string, roleIds: string[]): Observable { return this.http.patch(\`\${this.base}/users/\${id}/roles\`, { roleIds }, { withCredentials: true }); }\n\n roles(): Observable { return this.http.get(\`\${this.base}/roles\`, { withCredentials: true }); }\n createRole(body: { name: string; permissions: string[] }): Observable { return this.http.post(\`\${this.base}/roles\`, body, { withCredentials: true }); }\n updateRole(id: string, body: { name: string; permissions: string[] }): Observable { return this.http.put(\`\${this.base}/roles/\${id}\`, body, { withCredentials: true }); }\n deleteRole(id: string): Observable { return this.http.delete(\`\${this.base}/roles/\${id}\`, { withCredentials: true }); }\n\n sessions(): Observable { return this.http.get(\`\${this.base}/sessions/own\`, { withCredentials: true }); }\n revokeSession(id: string): Observable { return this.http.delete(\`\${this.base}/sessions/own/\${id}\`, { withCredentials: true }); }\n revokeOtherSessions(): Observable { return this.http.delete(\`\${this.base}/sessions/own\`, { withCredentials: true }); }\n revokeUserSessions(userId: string): Observable { return this.http.delete(\`\${this.base}/sessions/users/\${userId}\`, { withCredentials: true }); }\n\n audit(page = 1, pageSize = 20): Observable> { return this.http.get>(\`\${this.base}/audit-log\`, { params: { page, pageSize }, withCredentials: true }); }\n\n private params(value: Record): HttpParams {\n let params = new HttpParams();\n Object.entries(value).forEach(([key, entry]) => {\n if (entry !== undefined && entry !== '') params = params.set(key, String(entry));\n });\n return params;\n }\n}\n`, + `// Generated from the backend OpenAPI contract. Do not edit manually. +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import type { Observable } from 'rxjs'; +import type { + AuditLogDto, + AdminSessionDto, + AdminUserDetailDto, + AdminUserListItemDto, + CreateNotificationDto, + ItemDto, + NotificationDto, + NotificationPageDto, + NotificationStatusFilter, + PageDto, + Permission, + RoleDto, + SaveItemDto, + SessionDto, + UserDto, +} from './models'; + +@Injectable({ providedIn: 'root' }) +export class ApiClientService { + private readonly http = inject(HttpClient); + private readonly base = '/api'; + + me(): Observable { + return this.http.get(\`\${this.base}/me\`, { withCredentials: true }); + } + + updateSettings(body: { + tablePageSize?: number; + sidebarExpanded?: boolean; + }): Observable { + return this.http.patch(\`\${this.base}/me/settings\`, body, { + withCredentials: true, + }); + } + + dashboard(): Observable<{ + userCount: number; + activeSessions: number; + roleCount: number; + itemCount: number; + }> { + return this.http.get<{ + userCount: number; + activeSessions: number; + roleCount: number; + itemCount: number; + }>(\`\${this.base}/dashboard\`, { withCredentials: true }); + } + + items( + query: { + search?: string; + page?: number; + pageSize?: number; + sort?: string; + direction?: string; + } = {}, + ): Observable> { + return this.http.get>(\`\${this.base}/items\`, { + params: this.params(query), + withCredentials: true, + }); + } + + item(id: string): Observable { + return this.http.get(\`\${this.base}/items/\${id}\`, { + withCredentials: true, + }); + } + + createItem(body: SaveItemDto): Observable { + return this.http.post(\`\${this.base}/items\`, body, { + withCredentials: true, + }); + } + + updateItem(id: string, body: Required): Observable { + return this.http.put(\`\${this.base}/items/\${id}\`, body, { + withCredentials: true, + }); + } + + deleteItem(id: string, version: number): Observable { + return this.http.delete(\`\${this.base}/items/\${id}\`, { + params: { version }, + withCredentials: true, + }); + } + + users( + query: { search?: string; page?: number; pageSize?: number } = {}, + ): Observable> { + return this.http.get>(\`\${this.base}/users\`, { + params: this.params(query), + withCredentials: true, + }); + } + + setUserActive(id: string, active: boolean): Observable { + return this.http.patch( + \`\${this.base}/users/\${id}/active\`, + { active }, + { withCredentials: true }, + ); + } + + setUserRoles(id: string, roleIds: string[]): Observable { + return this.http.patch( + \`\${this.base}/users/\${id}/roles\`, + { roleIds }, + { withCredentials: true }, + ); + } + + adminUsers( + query: { + search?: string; + active?: 'all' | 'active' | 'inactive'; + roleId?: string; + sort?: 'name' | 'email' | 'lastLoginAt' | 'createdAt'; + direction?: 'ASC' | 'DESC'; + page?: number; + pageSize?: number; + } = {}, + ): Observable> { + return this.http.get>(\`\${this.base}/admin/users\`, { + params: this.params(query), + withCredentials: true, + }); + } + + adminUser(id: string): Observable { + return this.http.get(\`\${this.base}/admin/users/\${id}\`, { + withCredentials: true, + }); + } + + activateAdminUser(id: string): Observable { + return this.http.patch( + \`\${this.base}/admin/users/\${id}/activate\`, + {}, + { withCredentials: true }, + ); + } + + deactivateAdminUser(id: string): Observable { + return this.http.patch( + \`\${this.base}/admin/users/\${id}/deactivate\`, + {}, + { withCredentials: true }, + ); + } + + assignAdminUserRole(userId: string, roleId: string): Observable { + return this.http.post( + \`\${this.base}/admin/users/\${userId}/roles/\${roleId}\`, + {}, + { withCredentials: true }, + ); + } + + removeAdminUserRole(userId: string, roleId: string): Observable { + return this.http.delete( + \`\${this.base}/admin/users/\${userId}/roles/\${roleId}\`, + { withCredentials: true }, + ); + } + + adminUserSessions(id: string): Observable { + return this.http.get(\`\${this.base}/admin/users/\${id}/sessions\`, { + withCredentials: true, + }); + } + + revokeAdminUserSession(userId: string, sessionId: string): Observable { + return this.http.delete( + \`\${this.base}/admin/users/\${userId}/sessions/\${sessionId}\`, + { withCredentials: true }, + ); + } + + revokeAdminUserSessions(userId: string): Observable<{ revoked: number }> { + return this.http.delete<{ revoked: number }>( + \`\${this.base}/admin/users/\${userId}/sessions\`, + { withCredentials: true }, + ); + } + + roles(): Observable { + return this.http.get(\`\${this.base}/roles\`, { + withCredentials: true, + }); + } + + createRole(body: { name: string; permissions: string[] }): Observable { + return this.http.post(\`\${this.base}/roles\`, body, { + withCredentials: true, + }); + } + + updateRole( + id: string, + body: { name: string; permissions: string[] }, + ): Observable { + return this.http.put(\`\${this.base}/roles/\${id}\`, body, { + withCredentials: true, + }); + } + + deleteRole(id: string): Observable { + return this.http.delete(\`\${this.base}/roles/\${id}\`, { + withCredentials: true, + }); + } + + adminRoles(): Observable { + return this.http.get(\`\${this.base}/admin/roles\`, { + withCredentials: true, + }); + } + + adminRole(id: string): Observable { + return this.http.get(\`\${this.base}/admin/roles/\${id}\`, { + withCredentials: true, + }); + } + + createAdminRole(body: { + name: string; + description?: string; + permissions: Permission[]; + }): Observable { + return this.http.post(\`\${this.base}/admin/roles\`, body, { + withCredentials: true, + }); + } + + updateAdminRole( + id: string, + body: { name: string; description?: string; permissions: Permission[] }, + ): Observable { + return this.http.put(\`\${this.base}/admin/roles/\${id}\`, body, { + withCredentials: true, + }); + } + + deleteAdminRole(id: string): Observable { + return this.http.delete(\`\${this.base}/admin/roles/\${id}\`, { + withCredentials: true, + }); + } + + sessions(): Observable { + return this.http.get(\`\${this.base}/sessions/own\`, { + withCredentials: true, + }); + } + + revokeSession(id: string): Observable { + return this.http.delete(\`\${this.base}/sessions/own/\${id}\`, { + withCredentials: true, + }); + } + + revokeOtherSessions(): Observable { + return this.http.delete(\`\${this.base}/sessions/own\`, { + withCredentials: true, + }); + } + + revokeUserSessions(userId: string): Observable { + return this.http.delete(\`\${this.base}/sessions/users/\${userId}\`, { + withCredentials: true, + }); + } + + audit(page = 1, pageSize = 20): Observable> { + return this.http.get>(\`\${this.base}/audit-log\`, { + params: { page, pageSize }, + withCredentials: true, + }); + } + + notifications( + query: { + page?: number; + pageSize?: number; + status?: NotificationStatusFilter; + } = {}, + ): Observable { + return this.http.get(\`\${this.base}/notifications\`, { + params: this.params(query), + withCredentials: true, + }); + } + + unreadNotificationCount(): Observable<{ count: number }> { + return this.http.get<{ count: number }>( + \`\${this.base}/notifications/unread-count\`, + { withCredentials: true }, + ); + } + + markNotificationRead(id: string): Observable { + return this.http.patch( + \`\${this.base}/notifications/\${id}/read\`, + {}, + { withCredentials: true }, + ); + } + + markNotificationUnread(id: string): Observable { + return this.http.patch( + \`\${this.base}/notifications/\${id}/unread\`, + {}, + { withCredentials: true }, + ); + } + + markAllNotificationsRead(): Observable<{ updated: number }> { + return this.http.patch<{ updated: number }>( + \`\${this.base}/notifications/read-all\`, + {}, + { withCredentials: true }, + ); + } + + deleteNotification(id: string): Observable { + return this.http.delete(\`\${this.base}/notifications/\${id}\`, { + withCredentials: true, + }); + } + + createAdminNotification( + body: CreateNotificationDto, + ): Observable { + return this.http.post( + \`\${this.base}/admin/notifications\`, + body, + { withCredentials: true }, + ); + } + + private params(value: Record): HttpParams { + let params = new HttpParams(); + Object.entries(value).forEach(([key, entry]) => { + if (entry !== undefined && entry !== '') params = params.set(key, String(entry)); + }); + return params; + } +} +`, ], ]);