This commit is contained in:
Bastian Wagner
2026-07-16 15:23:53 +02:00
parent 543e8273a7
commit c03b2e17f5
114 changed files with 7631 additions and 506 deletions

View File

@@ -15,6 +15,7 @@ OIDC_ISSUER=https://idp.example.com/realms/internal
OIDC_CLIENT_ID=business-app OIDC_CLIENT_ID=business-app
OIDC_CLIENT_SECRET=change-me OIDC_CLIENT_SECRET=change-me
OIDC_SCOPES=openid profile email OIDC_SCOPES=openid profile email
OIDC_LOGOUT_URL=
OIDC_ALLOWED_ALGORITHMS=RS256 OIDC_ALLOWED_ALGORITHMS=RS256
OIDC_HTTP_TIMEOUT_MS=5000 OIDC_HTTP_TIMEOUT_MS=5000

View File

@@ -5,12 +5,23 @@
- Backend-Controller verwenden niemals TypeORM-Repositories direkt, sondern Services. - Backend-Controller verwenden niemals TypeORM-Repositories direkt, sondern Services.
- Services kapseln Fachlogik; Datenbankzugriffe laufen ueber Repository-Klassen oder klar benannte Persistence-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. - 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. - 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. - 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. - 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. - 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. - 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. - Secrets, Session-IDs und Tokens duerfen nicht geloggt oder ins Repository aufgenommen werden.
- Tests muessen Verhalten pruefen; keine trivialen Tests, die nur Existenz testen. - 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 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. - Vor Abschluss muessen `npm run lint`, `npm run format:check`, `npm run typecheck`, `npm test`, `npm run build` und `docker build .` bestehen.

View File

@@ -20,6 +20,7 @@ Docker-Auslieferung, Healthchecks und eine einfache Angular-Verwaltungsoberflaec
- konfigurierbares In-Memory Rate Limiting pro IP mit strengeren sensiblen Endpunkten - konfigurierbares In-Memory Rate Limiting pro IP mit strengeren sensiblen Endpunkten
- MySQL-8-Persistenz mit TypeORM, Migrationen und Startpruefung auf fehlende Migrationen - MySQL-8-Persistenz mit TypeORM, Migrationen und Startpruefung auf fehlende Migrationen
- Code-definierte Permissions, Rollenverwaltung, Benutzerverwaltung, Audit-Log und Sessions - 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 - Generierter API-Client fuer das Angular-Frontend
- Dockerfile und Compose-Beispiel fuer eine einzelne auslieferbare App - 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 - [Architektur](docs/architecture.md): Monorepo, Backend, Frontend, API-Client und Modulgrenzen
- [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client - [Entwicklung](docs/development.md): Workflows fuer Features, Migrationen, Tests und API-Client
- [Security-Modell](docs/security.md): OIDC, Sessions, CSRF, Rollen, Permissions und Logging - [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 - [Deployment und Betrieb](docs/deployment.md): Build, Docker, Migrationen, Runtime-Konfiguration und Healthchecks
- [Konfiguration](docs/configuration.md): Umgebungsvariablen und Produktionshinweise - [Konfiguration](docs/configuration.md): Umgebungsvariablen und Produktionshinweise
- [Nginx-Beispiel](docs/nginx-example.conf): Reverse Proxy mit HTTPS-Terminierung - [Nginx-Beispiel](docs/nginx-example.conf): Reverse Proxy mit HTTPS-Terminierung

View File

@@ -22,6 +22,7 @@ import { DatabaseModule } from './database/database.module';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { ItemsModule } from './items/items.module'; import { ItemsModule } from './items/items.module';
import { NotificationsModule } from './notifications/notifications.module';
import { RolesModule } from './roles/roles.module'; import { RolesModule } from './roles/roles.module';
import { SessionsModule } from './sessions/sessions.module'; import { SessionsModule } from './sessions/sessions.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@@ -81,6 +82,7 @@ const generateGlobalKey: ThrottlerGenerateKeyFunction = (
UsersModule, UsersModule,
RolesModule, RolesModule,
SessionsModule, SessionsModule,
NotificationsModule,
AuditModule, AuditModule,
ItemsModule, ItemsModule,
HealthModule, HealthModule,

View File

@@ -17,6 +17,7 @@ export enum AuditAction {
RolePermissionsUpdated = 'ROLE_PERMISSIONS_UPDATED', RolePermissionsUpdated = 'ROLE_PERMISSIONS_UPDATED',
SessionRevoked = 'SESSION_REVOKED', SessionRevoked = 'SESSION_REVOKED',
AllUserSessionsRevoked = 'ALL_USER_SESSIONS_REVOKED', AllUserSessionsRevoked = 'ALL_USER_SESSIONS_REVOKED',
NotificationCreated = 'NOTIFICATION_CREATED',
} }
@Entity('audit_logs') @Entity('audit_logs')

View File

@@ -61,9 +61,9 @@ export class AuthController {
const sessionId = req.signedCookies?.[this.config.session.cookieName] as const sessionId = req.signedCookies?.[this.config.session.cookieName] as
| string | string
| undefined; | undefined;
await this.auth.logout(sessionId); const logoutUrl = await this.auth.logout(sessionId);
res.clearCookie(this.config.session.cookieName, { path: '/' }); res.clearCookie(this.config.session.cookieName, { path: '/' });
res.clearCookie('csrf_token', { path: '/' }); res.clearCookie('csrf_token', { path: '/' });
res.redirect(this.config.frontendBaseUrl); res.redirect(logoutUrl);
} }
} }

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource, Repository } from 'typeorm';
import type { ExternalHttpClient } from '../common/http/external-http-client';
import type { AppConfigService } from '../config/config.service';
import type { RolesService } from '../roles/roles.service';
import type { SessionsService } from '../sessions/sessions.service';
import type { UsersRepository } from '../users/repositories/users.repository';
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthService } from './auth.service';
describe('AuthService', () => {
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
Promise.resolve('id-token'),
);
const service = new AuthService(
{
frontendBaseUrl: 'https://app.example.test',
appBaseUrl: 'https://app.example.test',
oidc: {
issuer: 'https://idp.example.test',
clientId: 'business-app',
clientSecret: 'secret',
scopes: 'openid profile email',
allowedAlgorithms: ['RS256'],
httpTimeoutMs: 5000,
logoutUrl: 'https://idp.example.test/logout',
},
} as AppConfigService,
{} as ExternalHttpClient,
{} as RolesService,
{} as UsersRepository,
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
{} as DataSource,
{} as Repository<OidcLoginStateEntity>,
);
const url = new URL(await service.logout('session-1'));
expect(revoke).toHaveBeenCalledWith('session-1');
expect(getIdTokenForLogout).toHaveBeenCalledWith('session-1');
expect(url.origin + url.pathname).toBe('https://idp.example.test/logout');
expect(url.searchParams.get('client_id')).toBe('business-app');
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
'https://app.example.test',
);
expect(url.searchParams.get('id_token_hint')).toBe('id-token');
});
});

View File

@@ -111,10 +111,13 @@ export class AuthService {
); );
} }
async logout(sessionId: string | undefined): Promise<void> { async logout(sessionId: string | undefined): Promise<string> {
let idToken: string | undefined;
if (sessionId) { if (sessionId) {
idToken = await this.readLogoutIdToken(sessionId);
await this.sessions.revoke(sessionId); await this.sessions.revoke(sessionId);
} }
return this.createLogoutUrl(idToken);
} }
private async upsertLocalUser( private async upsertLocalUser(
@@ -188,6 +191,45 @@ export class AuthService {
return discovery; return discovery;
} }
private async readLogoutIdToken(
sessionId: string,
): Promise<string | undefined> {
try {
return await this.sessions.getIdTokenForLogout(sessionId);
} catch {
return undefined;
}
}
private async createLogoutUrl(idToken: string | undefined): Promise<string> {
const endpoint = await this.resolveLogoutEndpoint();
if (!endpoint) {
return this.config.frontendBaseUrl;
}
const url = new URL(endpoint);
url.searchParams.set('client_id', this.config.oidc.clientId);
url.searchParams.set(
'post_logout_redirect_uri',
this.config.frontendBaseUrl,
);
if (idToken) {
url.searchParams.set('id_token_hint', idToken);
}
return url.toString();
}
private async resolveLogoutEndpoint(): Promise<string | undefined> {
if (this.config.oidc.logoutUrl) {
return this.config.oidc.logoutUrl;
}
try {
return (await this.discovery()).end_session_endpoint;
} catch {
return undefined;
}
}
private async exchangeCode( private async exchangeCode(
discovery: OidcDiscovery, discovery: OidcDiscovery,
code: string, code: string,

View File

@@ -6,8 +6,24 @@ export enum ErrorCode {
Conflict = 'CONFLICT', Conflict = 'CONFLICT',
CsrfInvalid = 'CSRF_INVALID', CsrfInvalid = 'CSRF_INVALID',
UserDisabled = 'USER_DISABLED', UserDisabled = 'USER_DISABLED',
LastAdminRequired = 'LAST_ADMIN_REQUIRED', LastAdminRequired = 'LAST_ACTIVE_ADMIN_REQUIRED',
MigrationMissing = 'MIGRATION_MISSING', MigrationMissing = 'MIGRATION_MISSING',
RateLimitExceeded = 'RATE_LIMIT_EXCEEDED', 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', InternalError = 'INTERNAL_ERROR',
} }

View File

@@ -60,6 +60,20 @@ describe('loadConfigFromEnv', () => {
expect(config.frontendBaseUrl).toBe('http://localhost:4200'); 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', () => { it('rejects unsafe production secret placeholders', () => {
expect(() => expect(() =>
loadConfigFromEnv({ loadConfigFromEnv({

View File

@@ -26,6 +26,7 @@ export interface AppConfig {
scopes: string; scopes: string;
allowedAlgorithms: string[]; allowedAlgorithms: string[];
httpTimeoutMs: number; httpTimeoutMs: number;
logoutUrl?: string;
}; };
session: { session: {
cookieName: string; cookieName: string;

View File

@@ -28,6 +28,10 @@ const envSchema = z.object({
OIDC_CLIENT_ID: z.string().min(1), OIDC_CLIENT_ID: z.string().min(1),
OIDC_CLIENT_SECRET: z.string().min(1), OIDC_CLIENT_SECRET: z.string().min(1),
OIDC_SCOPES: z.string().min(1).default('openid profile email'), 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 OIDC_ALLOWED_ALGORITHMS: z
.string() .string()
.min(1) .min(1)
@@ -121,6 +125,7 @@ export function loadConfigFromEnv(env: Record<string, unknown>): AppConfig {
scopes: value.OIDC_SCOPES, scopes: value.OIDC_SCOPES,
allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS, allowedAlgorithms: value.OIDC_ALLOWED_ALGORITHMS,
httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS, httpTimeoutMs: value.OIDC_HTTP_TIMEOUT_MS,
...(value.OIDC_LOGOUT_URL ? { logoutUrl: value.OIDC_LOGOUT_URL } : {}),
}, },
session: { session: {
cookieName: value.SESSION_COOKIE_NAME, cookieName: value.SESSION_COOKIE_NAME,

View File

@@ -1,6 +1,7 @@
import { AuditLogEntity } from '../audit/entities/audit-log.entity'; import { AuditLogEntity } from '../audit/entities/audit-log.entity';
import { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity'; import { OidcLoginStateEntity } from '../auth/entities/oidc-login-state.entity';
import { ItemEntity } from '../items/entities/item.entity'; import { ItemEntity } from '../items/entities/item.entity';
import { NotificationEntity } from '../notifications/entities/notification.entity';
import { RoleEntity } from '../roles/entities/role.entity'; import { RoleEntity } from '../roles/entities/role.entity';
import { PermissionEntity } from '../roles/entities/permission.entity'; import { PermissionEntity } from '../roles/entities/permission.entity';
import { SessionEntity } from '../sessions/entities/session.entity'; import { SessionEntity } from '../sessions/entities/session.entity';
@@ -11,6 +12,7 @@ export const entities = [
AuditLogEntity, AuditLogEntity,
OidcLoginStateEntity, OidcLoginStateEntity,
ItemEntity, ItemEntity,
NotificationEntity,
RoleEntity, RoleEntity,
PermissionEntity, PermissionEntity,
SessionEntity, SessionEntity,

View File

@@ -15,6 +15,7 @@ export class InitialSchema1720000000000 implements MigrationInterface {
CREATE TABLE roles ( CREATE TABLE roles (
id char(36) NOT NULL, id char(36) NOT NULL,
name varchar(80) NOT NULL, name varchar(80) NOT NULL,
description varchar(255) NOT NULL DEFAULT '',
protected tinyint NOT NULL DEFAULT 0, protected tinyint NOT NULL DEFAULT 0,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 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), updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),

View File

@@ -0,0 +1,31 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddNotifications1720000001000 implements MigrationInterface {
name = 'AddNotifications1720000001000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE notifications (
id char(36) NOT NULL,
user_id char(36) NOT NULL,
type varchar(80) NOT NULL,
title varchar(150) NOT NULL,
message varchar(1000) NOT NULL,
link varchar(500) NULL,
metadata json NULL,
read_at datetime(3) NULL,
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
deleted_at datetime(3) NULL,
KEY idx_notifications_user_created_at (user_id, created_at),
KEY idx_notifications_user_read_at (user_id, read_at),
KEY idx_notifications_user_deleted_at (user_id, deleted_at),
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE notifications');
}
}

View File

@@ -0,0 +1,16 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoleDescription1720000002000 implements MigrationInterface {
name = 'AddRoleDescription1720000002000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE roles
ADD description varchar(255) NOT NULL DEFAULT ''
`);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE roles DROP COLUMN description');
}
}

View File

@@ -3,6 +3,8 @@ import { DataSource } from 'typeorm';
import { loadConfigForCli } from '../config/env'; import { loadConfigForCli } from '../config/env';
import { entities } from './entities'; import { entities } from './entities';
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema'; import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
const config = loadConfigForCli(); const config = loadConfigForCli();
@@ -19,5 +21,9 @@ export default new DataSource({
synchronize: false, synchronize: false,
migrationsRun: false, migrationsRun: false,
entities, entities,
migrations: [InitialSchema1720000000000], migrations: [
InitialSchema1720000000000,
AddNotifications1720000001000,
AddRoleDescription1720000002000,
],
}); });

View File

@@ -2,6 +2,8 @@ import type { TypeOrmModuleOptions } from '@nestjs/typeorm';
import type { AppConfigService } from '../config/config.service'; import type { AppConfigService } from '../config/config.service';
import { entities } from './entities'; import { entities } from './entities';
import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema'; import { InitialSchema1720000000000 } from './migrations/1720000000000-InitialSchema';
import { AddNotifications1720000001000 } from './migrations/1720000001000-AddNotifications';
import { AddRoleDescription1720000002000 } from './migrations/1720000002000-AddRoleDescription';
export function typeOrmOptionsFactory( export function typeOrmOptionsFactory(
config: AppConfigService, config: AppConfigService,
@@ -19,6 +21,10 @@ export function typeOrmOptionsFactory(
synchronize: false, synchronize: false,
migrationsRun: false, migrationsRun: false,
entities, entities,
migrations: [InitialSchema1720000000000], migrations: [
InitialSchema1720000000000,
AddNotifications1720000001000,
AddRoleDescription1720000002000,
],
}; };
} }

View File

@@ -4,8 +4,10 @@ import {
IsOptional, IsOptional,
IsString, IsString,
Length, Length,
Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer';
import { ItemStatus } from '../entities/item.entity'; import { ItemStatus } from '../entities/item.entity';
export class ItemListQueryDto { export class ItemListQueryDto {
@@ -22,13 +24,16 @@ export class ItemListQueryDto {
direction?: 'ASC' | 'DESC'; direction?: 'ASC' | 'DESC';
@IsOptional() @IsOptional()
@Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
page = 1; page = 1;
@IsOptional() @IsOptional()
@Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
@Max(100)
pageSize = 20; pageSize = 20;
} }

View File

@@ -7,8 +7,10 @@ import {
Post, Post,
Put, Put,
Query, Query,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator'; import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { Permission } from '../roles/permissions'; import { Permission } from '../roles/permissions';
import { CreateItemDto, ItemListQueryDto, UpdateItemDto } from './dto/item.dto'; import { CreateItemDto, ItemListQueryDto, UpdateItemDto } from './dto/item.dto';
@@ -33,8 +35,8 @@ export class ItemsController {
@Post() @Post()
@RequirePermissions(Permission.ItemsCreate) @RequirePermissions(Permission.ItemsCreate)
create(@Body() dto: CreateItemDto) { create(@Req() req: AuthenticatedRequest, @Body() dto: CreateItemDto) {
return this.items.create(dto); return this.items.create(dto, req.user?.id);
} }
@Put(':id') @Put(':id')

View File

@@ -1,13 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; 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 { ItemEntity } from './entities/item.entity';
import { ItemsController } from './items.controller'; import { ItemsController } from './items.controller';
import { ItemsService } from './items.service'; import { ItemsService } from './items.service';
import { ItemsRepository } from './repositories/items.repository'; import { ItemsRepository } from './repositories/items.repository';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([ItemEntity])], imports: [
TypeOrmModule.forFeature([ItemEntity, UserEntity]),
NotificationsModule,
],
controllers: [ItemsController], controllers: [ItemsController],
providers: [ItemsService, ItemsRepository], providers: [ItemsService, ItemsRepository, UsersRepository],
}) })
export class ItemsModule {} export class ItemsModule {}

View File

@@ -1,7 +1,11 @@
import { Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ApiError } from '../common/errors/api-error'; import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes'; import { ErrorCode } from '../common/errors/error-codes';
import type { PageDto } from '../common/dto/pagination.dto'; 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 { ItemEntity } from './entities/item.entity';
import type { import type {
CreateItemDto, CreateItemDto,
@@ -15,7 +19,13 @@ import {
@Injectable() @Injectable()
export class ItemsService { export class ItemsService {
constructor(private readonly items: ItemsRepository) {} private readonly logger = new Logger(ItemsService.name);
constructor(
private readonly items: ItemsRepository,
private readonly notifications: NotificationsService,
private readonly users: UsersRepository,
) {}
async list(query: ItemListQueryDto): Promise<PageDto<ItemEntity>> { async list(query: ItemListQueryDto): Promise<PageDto<ItemEntity>> {
const page = query.page ?? 1; const page = query.page ?? 1;
@@ -44,12 +54,14 @@ export class ItemsService {
return item; return item;
} }
async create(dto: CreateItemDto): Promise<ItemEntity> { async create(dto: CreateItemDto, actorUserId?: string): Promise<ItemEntity> {
const item = new ItemEntity(); const item = new ItemEntity();
item.name = dto.name; item.name = dto.name;
item.description = dto.description ?? null; item.description = dto.description ?? null;
item.status = dto.status; item.status = dto.status;
return this.items.save(item); const saved = await this.items.save(item);
await this.notifyFirstAdminAboutCreatedItem(saved, actorUserId);
return saved;
} }
async update(id: string, dto: UpdateItemDto): Promise<ItemEntity> { async update(id: string, dto: UpdateItemDto): Promise<ItemEntity> {
@@ -78,4 +90,29 @@ export class ItemsService {
} }
await this.items.softDelete(item); await this.items.softDelete(item);
} }
private async notifyFirstAdminAboutCreatedItem(
item: ItemEntity,
actorUserId: string | undefined,
): Promise<void> {
try {
const admin = await this.users.findFirstActiveAdmin(actorUserId);
if (!admin) {
return;
}
await this.notifications.createForUser({
userId: admin.id,
type: NotificationType.ItemCreated,
title: 'Neuer Eintrag',
message: `Der Eintrag "${item.name}" wurde erstellt.`,
link: `/items/${item.id}`,
metadata: { itemId: item.id },
});
} catch (error) {
this.logger.error(
{ itemId: item.id, error },
'Failed to create item notification',
);
}
}
} }

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { ErrorCode } from '../../common/errors/error-codes'; import { ErrorCode } from '../../common/errors/error-codes';
import { ItemEntity, ItemStatus } from '../entities/item.entity'; import { ItemEntity, ItemStatus } from '../entities/item.entity';
import { ItemsService } from '../items.service'; 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'; import type { ItemsRepository } from '../repositories/items.repository';
function item(version = 2): ItemEntity { function item(version = 2): ItemEntity {
@@ -23,7 +25,11 @@ describe('ItemsService', () => {
findById: () => Promise.resolve(item(3)), findById: () => Promise.resolve(item(3)),
save: (entity) => Promise.resolve(entity), 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( await expect(
service.update('item-1', { service.update('item-1', {
@@ -44,7 +50,11 @@ describe('ItemsService', () => {
return Promise.resolve(); 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); await service.delete('item-1', 4);

View File

@@ -0,0 +1,78 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsObject,
IsOptional,
IsString,
IsUUID,
Length,
Max,
Min,
} from 'class-validator';
import { allNotificationTypes } from '../notification-types';
import type { NotificationMetadata } from '../entities/notification.entity';
export type NotificationStatusFilter = 'all' | 'read' | 'unread';
export class NotificationListQueryDto {
@IsOptional()
@Type(() => Number)
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@Min(1)
@Max(100)
pageSize = 20;
@IsOptional()
@IsIn(['all', 'read', 'unread'])
status: NotificationStatusFilter = 'all';
}
export class CreateNotificationDto {
@IsUUID()
userId!: string;
@IsString()
@IsIn(allNotificationTypes)
type!: string;
@IsString()
@Length(1, 150)
title!: string;
@IsString()
@Length(1, 1000)
message!: string;
@IsOptional()
@IsString()
@Length(1, 500)
link?: string;
@IsOptional()
@IsObject()
metadata?: NotificationMetadata;
}
export interface NotificationDto {
id: string;
type: string;
title: string;
message: string;
link: string | null;
metadata: NotificationMetadata | null;
read: boolean;
readAt: string | null;
createdAt: string;
}
export interface NotificationPageDto {
items: NotificationDto[];
total: number;
page: number;
pageSize: number;
unreadCount: number;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,15 @@
export const NotificationType = {
System: 'system',
ItemCreated: 'item.created',
ItemUpdated: 'item.updated',
UserRoleChanged: 'user.role-changed',
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
export const allNotificationTypes = Object.values(NotificationType);
export function isNotificationType(value: string): value is NotificationType {
return allNotificationTypes.includes(value as NotificationType);
}

View File

@@ -0,0 +1,95 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from '../roles/permissions';
import {
CreateNotificationDto,
NotificationListQueryDto,
} from './dto/notification.dto';
import { NotificationsService } from './notifications.service';
@Controller()
export class NotificationsController {
constructor(private readonly notifications: NotificationsService) {}
@Get('notifications')
@RequirePermissions(Permission.NotificationsReadOwn)
list(
@Req() req: AuthenticatedRequest,
@Query() query: NotificationListQueryDto,
) {
return this.notifications.getForCurrentUser(
this.requireUser(req).id,
query,
);
}
@Get('notifications/unread-count')
@RequirePermissions(Permission.NotificationsReadOwn)
async unreadCount(@Req() req: AuthenticatedRequest) {
return {
count: await this.notifications.getUnreadCount(this.requireUser(req).id),
};
}
@Patch('notifications/:id/read')
@RequirePermissions(Permission.NotificationsUpdateOwn)
markAsRead(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.notifications.markAsRead(this.requireUser(req).id, id);
}
@Patch('notifications/:id/unread')
@RequirePermissions(Permission.NotificationsUpdateOwn)
markAsUnread(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.notifications.markAsUnread(this.requireUser(req).id, id);
}
@Patch('notifications/read-all')
@RequirePermissions(Permission.NotificationsUpdateOwn)
markAllAsRead(@Req() req: AuthenticatedRequest) {
return this.notifications.markAllAsRead(this.requireUser(req).id);
}
@Delete('notifications/:id')
@RequirePermissions(Permission.NotificationsUpdateOwn)
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.notifications.softDelete(this.requireUser(req).id, id);
}
@Post('admin/notifications')
@RequirePermissions(Permission.NotificationsManage)
@SensitiveRateLimit()
createAdmin(
@Req() req: AuthenticatedRequest,
@Body() dto: CreateNotificationDto,
) {
return this.notifications.createAdminNotification(
this.requireUser(req).id,
dto,
);
}
private requireUser(req: AuthenticatedRequest) {
if (!req.user) {
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich an.',
401,
);
}
return req.user;
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { UserEntity } from '../users/entities/user.entity';
import { UsersRepository } from '../users/repositories/users.repository';
import { NotificationEntity } from './entities/notification.entity';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { NotificationsRepository } from './repositories/notifications.repository';
@Module({
imports: [
TypeOrmModule.forFeature([NotificationEntity, UserEntity]),
AuditModule,
],
controllers: [NotificationsController],
providers: [NotificationsRepository, NotificationsService, UsersRepository],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,287 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/entities/audit-log.entity';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { UserEntity } from '../users/entities/user.entity';
import { UsersRepository } from '../users/repositories/users.repository';
import {
NotificationEntity,
type NotificationMetadata,
} from './entities/notification.entity';
import {
type NotificationDto,
type NotificationListQueryDto,
type NotificationPageDto,
type NotificationStatusFilter,
} from './dto/notification.dto';
import {
isNotificationType,
type NotificationType,
} from './notification-types';
import { NotificationsRepository } from './repositories/notifications.repository';
export interface CreateNotificationInput {
userId: string;
type: string;
title: string;
message: string;
link?: string | null;
metadata?: NotificationMetadata | null;
}
const maxBulkCreate = 100;
const maxMetadataBytes = 4096;
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly notifications: NotificationsRepository,
private readonly users: UsersRepository,
private readonly audit: AuditService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async createForUser(
input: CreateNotificationInput,
manager?: EntityManager,
): Promise<NotificationEntity> {
const user = await this.findActiveUser(input.userId, manager);
const notification = this.buildNotification(user.id, input);
return this.notifications.save(notification, manager);
}
async createForUsers(
userIds: string[],
input: Omit<CreateNotificationInput, 'userId'>,
): Promise<NotificationEntity[]> {
const uniqueUserIds = Array.from(new Set(userIds));
if (uniqueUserIds.length > maxBulkCreate) {
this.logger.warn(
{ count: uniqueUserIds.length },
'Large notification bulk create rejected',
);
throw new ApiError(
ErrorCode.ValidationFailed,
'Zu viele Zielbenutzer fuer eine Benachrichtigung.',
400,
);
}
return this.dataSource.transaction(async (manager) => {
const notifications: NotificationEntity[] = [];
for (const userId of uniqueUserIds) {
const user = await this.findActiveUser(userId, manager);
notifications.push(
this.buildNotification(user.id, { ...input, userId }),
);
}
return this.notifications.saveMany(notifications, manager);
});
}
async getForCurrentUser(
userId: string,
query: NotificationListQueryDto,
): Promise<NotificationPageDto> {
const page = query.page;
const pageSize = Math.min(query.pageSize, 100);
const status: NotificationStatusFilter = query.status ?? 'all';
const [items, total] = await this.notifications.listForUser(
userId,
status,
page,
pageSize,
);
const unreadCount = await this.getUnreadCount(userId);
return {
items: items.map((item) => this.toDto(item)),
total,
page,
pageSize,
unreadCount,
};
}
async getUnreadCount(userId: string): Promise<number> {
return this.notifications.countUnreadForUser(userId);
}
async markAsRead(userId: string, id: string): Promise<NotificationDto> {
const notification = await this.getOwnedNotification(userId, id);
notification.readAt ??= new Date();
return this.toDto(await this.notifications.save(notification));
}
async markAsUnread(userId: string, id: string): Promise<NotificationDto> {
const notification = await this.getOwnedNotification(userId, id);
notification.readAt = null;
return this.toDto(await this.notifications.save(notification));
}
async markAllAsRead(userId: string): Promise<{ updated: number }> {
return { updated: await this.notifications.markAllAsRead(userId) };
}
async softDelete(userId: string, id: string): Promise<void> {
const notification = await this.getOwnedNotification(userId, id);
await this.notifications.softDelete(notification);
}
async createAdminNotification(
actorUserId: string,
input: CreateNotificationInput,
): Promise<NotificationDto> {
const notification = await this.createForUser(input);
await this.audit.record(
actorUserId,
AuditAction.NotificationCreated,
'notification',
notification.id,
{
targetUserId: input.userId,
notificationId: notification.id,
type: notification.type,
},
);
return this.toDto(notification);
}
private async getOwnedNotification(
userId: string,
id: string,
): Promise<NotificationEntity> {
const notification = await this.notifications.findActiveForUser(id, userId);
if (!notification) {
throw new ApiError(
ErrorCode.NotificationNotFound,
'Die Benachrichtigung wurde nicht gefunden.',
404,
);
}
return notification;
}
private async findActiveUser(
userId: string,
manager?: EntityManager,
): Promise<UserEntity> {
const user = await this.users.findById(userId, manager);
if (!user || !user.active) {
throw new ApiError(
ErrorCode.UserNotFound,
'Der Zielbenutzer wurde nicht gefunden.',
404,
);
}
return user;
}
private buildNotification(
userId: string,
input: CreateNotificationInput,
): NotificationEntity {
const type = this.normalizeType(input.type);
const title = this.normalizePlainText(input.title, 150);
const message = this.normalizePlainText(input.message, 1000);
const link = this.normalizeLink(input.link ?? null);
const metadata = this.normalizeMetadata(input.metadata ?? null);
const notification = new NotificationEntity();
notification.userId = userId;
notification.type = type;
notification.title = title;
notification.message = message;
notification.link = link;
notification.metadata = metadata;
notification.readAt = null;
return notification;
}
private normalizeType(value: string): NotificationType {
if (!isNotificationType(value)) {
throw new ApiError(
ErrorCode.NotificationTypeInvalid,
'Der Benachrichtigungstyp ist ungueltig.',
400,
);
}
return value;
}
private normalizePlainText(value: string, maxLength: number): string {
const trimmed = value.trim();
if (
trimmed.length < 1 ||
trimmed.length > maxLength ||
/<[^>]*>|[<>]/.test(trimmed)
) {
throw new ApiError(
ErrorCode.ValidationFailed,
'Benachrichtigungen duerfen nur Plain Text enthalten.',
400,
);
}
return trimmed;
}
private normalizeLink(value: string | null): string | null {
if (!value) {
return null;
}
const trimmed = value.trim();
const lower = trimmed.toLowerCase();
if (
trimmed.length > 500 ||
!trimmed.startsWith('/') ||
trimmed.startsWith('//') ||
trimmed.includes('\\') ||
lower.includes('javascript:') ||
/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
) {
throw new ApiError(
ErrorCode.NotificationLinkInvalid,
'Der Link muss eine interne Route sein.',
400,
);
}
return trimmed;
}
private normalizeMetadata(
value: NotificationMetadata | null,
): NotificationMetadata | null {
if (!value) {
return null;
}
const serialized = JSON.stringify(value);
if (
!serialized ||
Buffer.byteLength(serialized, 'utf8') > maxMetadataBytes
) {
throw new ApiError(
ErrorCode.NotificationMetadataTooLarge,
'Die Metadaten sind zu gross.',
400,
);
}
return value;
}
private toDto(notification: NotificationEntity): NotificationDto {
return {
id: notification.id,
type: notification.type,
title: notification.title,
message: notification.message,
link: notification.link,
metadata: notification.metadata,
read: notification.readAt !== null,
readAt: notification.readAt?.toISOString() ?? null,
createdAt: notification.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,79 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { NotificationEntity } from '../entities/notification.entity';
import type { NotificationStatusFilter } from '../dto/notification.dto';
@Injectable()
export class NotificationsRepository {
constructor(
@InjectRepository(NotificationEntity)
private readonly repo: Repository<NotificationEntity>,
) {}
listForUser(
userId: string,
status: NotificationStatusFilter,
page: number,
pageSize: number,
): Promise<[NotificationEntity[], number]> {
const qb = this.repo
.createQueryBuilder('notification')
.where('notification.userId = :userId', { userId })
.andWhere('notification.deletedAt IS NULL');
if (status === 'read') {
qb.andWhere('notification.readAt IS NOT NULL');
}
if (status === 'unread') {
qb.andWhere('notification.readAt IS NULL');
}
return qb
.orderBy('notification.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
}
countUnreadForUser(userId: string): Promise<number> {
return this.repo.count({
where: { userId, readAt: IsNull(), deletedAt: IsNull() },
});
}
findActiveForUser(
id: string,
userId: string,
): Promise<NotificationEntity | null> {
return this.repo.findOne({ where: { id, userId, deletedAt: IsNull() } });
}
save(
notification: NotificationEntity,
manager?: EntityManager,
): Promise<NotificationEntity> {
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
notification,
);
}
saveMany(
notifications: NotificationEntity[],
manager?: EntityManager,
): Promise<NotificationEntity[]> {
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
notifications,
);
}
async markAllAsRead(userId: string, readAt = new Date()): Promise<number> {
const result = await this.repo.update(
{ userId, readAt: IsNull(), deletedAt: IsNull() },
{ readAt },
);
return result.affected ?? 0;
}
async softDelete(notification: NotificationEntity): Promise<void> {
await this.repo.softRemove(notification);
}
}

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import type { AuditService } from '../../audit/audit.service';
import type { AuthenticatedUser } from '../../auth/authenticated-request';
import { ItemStatus } from '../../items/entities/item.entity';
import { ItemsService } from '../../items/items.service';
import { NotificationType } from '../notification-types';
import { UsersService } from '../../users/users.service';
import type { ItemsRepository } from '../../items/repositories/items.repository';
import type { NotificationsService } from '../notifications.service';
import type { RolesService } from '../../roles/roles.service';
import type { SessionsService } from '../../sessions/sessions.service';
import type { UsersRepository } from '../../users/repositories/users.repository';
import type { DataSource, EntityManager } from 'typeorm';
describe('Notification integrations', () => {
it('creates an item.created notification for the first active admin', async () => {
const created: unknown[] = [];
const items: Pick<ItemsRepository, 'save'> = {
save: (item) => {
item.id = 'item-1';
item.createdAt = new Date();
item.updatedAt = new Date();
item.deletedAt = null;
return Promise.resolve(item);
},
};
const notifications: Pick<NotificationsService, 'createForUser'> = {
createForUser: (input) => {
created.push(input);
return Promise.resolve(
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
);
},
};
const users: Pick<UsersRepository, 'findFirstActiveAdmin'> = {
findFirstActiveAdmin: () =>
Promise.resolve({ id: 'admin-1' } as Awaited<
ReturnType<UsersRepository['findFirstActiveAdmin']>
>),
};
const service = new ItemsService(
items as ItemsRepository,
notifications as NotificationsService,
users as UsersRepository,
);
await service.create(
{ name: 'Beispiel', status: ItemStatus.Active },
'user-1',
);
expect(created).toEqual([
expect.objectContaining({
userId: 'admin-1',
type: NotificationType.ItemCreated,
link: '/items/item-1',
}),
]);
});
it('creates a user.role-changed notification after role changes', async () => {
const created: unknown[] = [];
const user = {
id: 'user-1',
active: true,
roles: [{ id: 'role-user', name: 'user' }],
};
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
findByIdWithRoles: () =>
Promise.resolve(
user as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
),
save: (entry) => Promise.resolve(entry),
};
const roles: Pick<RolesService, 'getRole'> = {
getRole: (id) =>
Promise.resolve({
id,
name: id === 'role-editor' ? 'Editor' : 'user',
} as Awaited<ReturnType<RolesService['getRole']>>),
};
const notifications: Pick<NotificationsService, 'createForUser'> = {
createForUser: (input) => {
created.push(input);
return Promise.resolve(
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
);
},
};
const manager = {
query: () => Promise.resolve(),
} as unknown as EntityManager;
const dataSource = {
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
action(manager),
} as DataSource;
const service = new UsersService(
users as UsersRepository,
roles as RolesService,
{} as SessionsService,
{ record: () => Promise.resolve() } as unknown as AuditService,
notifications as NotificationsService,
dataSource,
);
await service.setRoles(
{
id: 'admin-1',
sessionId: 'session-1',
permissions: [],
} satisfies AuthenticatedUser,
'user-1',
['role-user', 'role-editor'],
);
expect(created).toEqual([
expect.objectContaining({
userId: 'user-1',
type: NotificationType.UserRoleChanged,
message: 'Ihnen wurde die Rolle "Editor" zugewiesen.',
}),
]);
});
});

View File

@@ -0,0 +1,271 @@
import { describe, expect, it } from 'vitest';
import type { AuditService } from '../../audit/audit.service';
import { PermissionsGuard } from '../../auth/guards/permissions.guard';
import { ErrorCode } from '../../common/errors/error-codes';
import { NotificationEntity } from '../entities/notification.entity';
import { NotificationType } from '../notification-types';
import { NotificationsController } from '../notifications.controller';
import { NotificationsService } from '../notifications.service';
import type { AppConfigService } from '../../config/config.service';
import type { SessionsService } from '../../sessions/sessions.service';
import type { UsersRepository } from '../../users/repositories/users.repository';
import type { NotificationsRepository } from '../repositories/notifications.repository';
import type { DataSource } from 'typeorm';
import { Reflector } from '@nestjs/core';
import type { ExecutionContext } from '@nestjs/common';
const now = new Date('2026-07-16T08:00:00.000Z');
function notification(
id: string,
userId: string,
readAt: Date | null = null,
): NotificationEntity {
const entity = new NotificationEntity();
entity.id = id;
entity.userId = userId;
entity.type = NotificationType.System;
entity.title = 'Titel';
entity.message = 'Nachricht';
entity.link = '/';
entity.metadata = null;
entity.readAt = readAt;
entity.createdAt = now;
entity.deletedAt = null;
return entity;
}
function serviceWithStore(store: NotificationEntity[]) {
const repo: Pick<
NotificationsRepository,
| 'listForUser'
| 'countUnreadForUser'
| 'findActiveForUser'
| 'save'
| 'saveMany'
| 'markAllAsRead'
| 'softDelete'
> = {
listForUser: (userId, status, page, pageSize) => {
const filtered = store.filter(
(entry) =>
entry.userId === userId &&
entry.deletedAt === null &&
(status === 'all' ||
(status === 'read' && entry.readAt !== null) ||
(status === 'unread' && entry.readAt === null)),
);
return Promise.resolve([
filtered.slice((page - 1) * pageSize, page * pageSize),
filtered.length,
]);
},
countUnreadForUser: (userId) =>
Promise.resolve(
store.filter(
(entry) =>
entry.userId === userId &&
entry.readAt === null &&
entry.deletedAt === null,
).length,
),
findActiveForUser: (id, userId) =>
Promise.resolve(
store.find(
(entry) =>
entry.id === id &&
entry.userId === userId &&
entry.deletedAt === null,
) ?? null,
),
save: (entry) => {
entry.createdAt ??= now;
const index = store.findIndex((candidate) => candidate.id === entry.id);
if (index >= 0) {
store[index] = entry;
} else {
entry.id = entry.id || `notification-${store.length + 1}`;
store.push(entry);
}
return Promise.resolve(entry);
},
saveMany: (entries) => Promise.resolve(entries),
markAllAsRead: (userId) => {
let updated = 0;
for (const entry of store) {
if (
entry.userId === userId &&
entry.readAt === null &&
entry.deletedAt === null
) {
entry.readAt = now;
updated += 1;
}
}
return Promise.resolve(updated);
},
softDelete: (entry) => {
entry.deletedAt = now;
return Promise.resolve();
},
};
const users: Pick<UsersRepository, 'findById'> = {
findById: (id) =>
Promise.resolve({
id,
active: id !== 'disabled-user',
} as Awaited<ReturnType<UsersRepository['findById']>>),
};
const dataSource = {
transaction: async <T>(callback: (manager: never) => Promise<T>) =>
callback(undefined as never),
} as unknown as DataSource;
const service = new NotificationsService(
repo as NotificationsRepository,
users as UsersRepository,
{} as AuditService,
dataSource,
);
return { service, store };
}
describe('NotificationsService', () => {
it('returns only current user notifications with pagination and status filter', async () => {
const { service } = serviceWithStore([
notification('own-unread', 'user-1'),
notification('own-read', 'user-1', now),
notification('other', 'user-2'),
]);
const result = await service.getForCurrentUser('user-1', {
page: 1,
pageSize: 10,
status: 'unread',
});
expect(result.items.map((entry) => entry.id)).toEqual(['own-unread']);
expect(result.total).toBe(1);
expect(result.unreadCount).toBe(1);
});
it('does not reveal or mutate foreign notification ids', async () => {
const { service } = serviceWithStore([notification('foreign', 'user-2')]);
await expect(service.markAsRead('user-1', 'foreign')).rejects.toMatchObject(
{
code: ErrorCode.NotificationNotFound,
status: 404,
},
);
});
it('marks read and unread idempotently', async () => {
const { service } = serviceWithStore([notification('own', 'user-1')]);
await service.markAsRead('user-1', 'own');
const readAgain = await service.markAsRead('user-1', 'own');
expect(readAgain.read).toBe(true);
await service.markAsUnread('user-1', 'own');
const unreadAgain = await service.markAsUnread('user-1', 'own');
expect(unreadAgain.read).toBe(false);
});
it('marks all unread notifications only for the current user', async () => {
const { service, store } = serviceWithStore([
notification('own', 'user-1'),
notification('other', 'user-2'),
]);
await expect(service.markAllAsRead('user-1')).resolves.toEqual({
updated: 1,
});
expect(store.find((entry) => entry.id === 'own')?.readAt).toBe(now);
expect(store.find((entry) => entry.id === 'other')?.readAt).toBeNull();
});
it('soft deletes notifications from normal queries', async () => {
const { service } = serviceWithStore([notification('own', 'user-1')]);
await service.softDelete('user-1', 'own');
const result = await service.getForCurrentUser('user-1', {
page: 1,
pageSize: 20,
status: 'all',
});
expect(result.items).toEqual([]);
});
it('rejects unknown types and unsafe links', async () => {
const { service } = serviceWithStore([]);
await expect(
service.createForUser({
userId: 'user-1',
type: 'unknown',
title: 'Titel',
message: 'Nachricht',
}),
).rejects.toMatchObject({ code: ErrorCode.NotificationTypeInvalid });
await expect(
service.createForUser({
userId: 'user-1',
type: NotificationType.System,
title: 'Titel',
message: 'Nachricht',
link: 'https://example.com',
}),
).rejects.toMatchObject({ code: ErrorCode.NotificationLinkInvalid });
});
it('rejects disabled target users', async () => {
const { service } = serviceWithStore([]);
await expect(
service.createForUser({
userId: 'disabled-user',
type: NotificationType.System,
title: 'Titel',
message: 'Nachricht',
}),
).rejects.toMatchObject({ code: ErrorCode.UserNotFound });
});
it('requires notifications.manage for administrative creation', async () => {
const controller = new NotificationsController({} as NotificationsService);
const reflector = new Reflector();
const sessions: Pick<SessionsService, 'resolveSession'> = {
resolveSession: () =>
Promise.resolve({
user: { id: 'user-1' },
permissions: [],
} as unknown as Awaited<ReturnType<SessionsService['resolveSession']>>),
};
const config = {
session: { cookieName: 'app_session' },
} as AppConfigService;
const guard = new PermissionsGuard(
reflector,
sessions as SessionsService,
config,
);
const context = {
getHandler: () => controller.createAdmin,
getClass: () => NotificationsController,
switchToHttp: () => ({
getRequest: () => ({
signedCookies: { app_session: 'session-id' },
ip: '127.0.0.1',
get: () => 'test-agent',
}),
}),
} as unknown as ExecutionContext;
await expect(guard.canActivate(context)).rejects.toMatchObject({
code: ErrorCode.PermissionDenied,
status: 403,
});
});
});

View File

@@ -0,0 +1,71 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/authenticated-request';
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes';
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from './permissions';
import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
import { RolesService } from './roles.service';
@Controller('admin/roles')
export class AdminRolesController {
constructor(private readonly roles: RolesService) {}
@Get()
@RequirePermissions(Permission.RolesRead)
list() {
return this.roles.adminList();
}
@Get(':id')
@RequirePermissions(Permission.RolesRead)
get(@Param('id') id: string) {
return this.roles.adminGet(id);
}
@Post()
@RequirePermissions(Permission.RolesManage)
@SensitiveRateLimit()
create(@Req() req: AuthenticatedRequest, @Body() dto: CreateRoleDto) {
return this.roles.create(dto, this.requireUser(req));
}
@Put(':id')
@RequirePermissions(Permission.RolesManage)
@SensitiveRateLimit()
update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateRoleDto,
) {
return this.roles.update(id, dto, this.requireUser(req));
}
@Delete(':id')
@RequirePermissions(Permission.RolesManage)
@SensitiveRateLimit()
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.roles.delete(id, this.requireUser(req));
}
private requireUser(req: AuthenticatedRequest) {
if (!req.user) {
throw new ApiError(
ErrorCode.Unauthorized,
'Bitte melden Sie sich an.',
401,
);
}
return req.user;
}
}

View File

@@ -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'; import { Permission } from '../permissions';
export class CreateRoleDto { export class CreateRoleDto {
@@ -6,6 +6,11 @@ export class CreateRoleDto {
@Length(2, 80) @Length(2, 80)
name!: string; name!: string;
@IsOptional()
@IsString()
@Length(0, 255)
description = '';
@IsArray() @IsArray()
@IsEnum(Permission, { each: true }) @IsEnum(Permission, { each: true })
permissions!: Permission[]; permissions!: Permission[];

View File

@@ -20,6 +20,9 @@ export class RoleEntity {
@Column({ type: 'varchar', length: 80 }) @Column({ type: 'varchar', length: 80 })
name!: string; name!: string;
@Column({ type: 'varchar', length: 255, default: '' })
description!: string;
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
protected!: boolean; protected!: boolean;

View File

@@ -11,6 +11,9 @@ export enum Permission {
SessionsReadOwn = 'sessions.readOwn', SessionsReadOwn = 'sessions.readOwn',
SessionsRevokeOwn = 'sessions.revokeOwn', SessionsRevokeOwn = 'sessions.revokeOwn',
SessionsManage = 'sessions.manage', SessionsManage = 'sessions.manage',
NotificationsReadOwn = 'notifications.readOwn',
NotificationsUpdateOwn = 'notifications.updateOwn',
NotificationsManage = 'notifications.manage',
} }
export const allPermissions = Object.values(Permission); export const allPermissions = Object.values(Permission);
@@ -22,4 +25,5 @@ export const administrativePermissions = [
Permission.RolesManage, Permission.RolesManage,
Permission.AuditRead, Permission.AuditRead,
Permission.SessionsManage, Permission.SessionsManage,
Permission.NotificationsManage,
] as const; ] as const;

View File

@@ -18,8 +18,11 @@ export class RolesRepository {
}); });
} }
findById(id: string): Promise<RoleEntity | null> { findById(id: string, manager?: EntityManager): Promise<RoleEntity | null> {
return this.repo.findOne({ where: { id }, relations: { users: true } }); return (manager?.getRepository(RoleEntity) ?? this.repo).findOne({
where: { id },
relations: { users: true, permissions: true },
});
} }
list(): Promise<RoleEntity[]> { list(): Promise<RoleEntity[]> {
@@ -33,7 +36,7 @@ export class RolesRepository {
return (manager?.getRepository(RoleEntity) ?? this.repo).save(role); return (manager?.getRepository(RoleEntity) ?? this.repo).save(role);
} }
remove(role: RoleEntity): Promise<RoleEntity> { remove(role: RoleEntity, manager?: EntityManager): Promise<RoleEntity> {
return this.repo.remove(role); return (manager?.getRepository(RoleEntity) ?? this.repo).remove(role);
} }
} }

View File

@@ -1,14 +1,19 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module';
import { PermissionEntity } from './entities/permission.entity'; import { PermissionEntity } from './entities/permission.entity';
import { RoleEntity } from './entities/role.entity'; import { RoleEntity } from './entities/role.entity';
import { AdminRolesController } from './admin-roles.controller';
import { RolesController } from './roles.controller'; import { RolesController } from './roles.controller';
import { RolesRepository } from './repositories/roles.repository'; import { RolesRepository } from './repositories/roles.repository';
import { RolesService } from './roles.service'; import { RolesService } from './roles.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([RoleEntity, PermissionEntity])], imports: [
controllers: [RolesController], TypeOrmModule.forFeature([RoleEntity, PermissionEntity]),
AuditModule,
],
controllers: [RolesController, AdminRolesController],
providers: [RolesRepository, RolesService], providers: [RolesRepository, RolesService],
exports: [RolesRepository, RolesService], exports: [RolesRepository, RolesService],
}) })

View File

@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager } 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 { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes'; import { ErrorCode } from '../common/errors/error-codes';
import { PermissionEntity } from './entities/permission.entity'; import { PermissionEntity } from './entities/permission.entity';
@@ -16,6 +19,7 @@ const userRoleName = 'user';
export class RolesService { export class RolesService {
constructor( constructor(
private readonly roles: RolesRepository, private readonly roles: RolesRepository,
private readonly audit: AuditService,
@InjectDataSource() private readonly dataSource: DataSource, @InjectDataSource() private readonly dataSource: DataSource,
) {} ) {}
@@ -23,50 +27,149 @@ export class RolesService {
return this.roles.list(); return this.roles.list();
} }
async create(dto: CreateRoleDto): Promise<RoleEntity> { async adminList() {
const roles = await this.roles.list();
return roles.map((role) => this.toAdminDto(role));
}
async adminGet(id: string) {
return this.toAdminDto(await this.getRole(id));
}
async create(
dto: CreateRoleDto,
actor?: AuthenticatedUser,
): Promise<RoleEntity> {
const name = this.normalizeName(dto.name);
await this.assertRoleNameAvailable(name);
const role = new RoleEntity(); const role = new RoleEntity();
role.name = dto.name; role.name = name;
role.description = dto.description?.trim() ?? '';
role.protected = false; role.protected = false;
role.permissions = await this.loadPermissionEntities(dto.permissions); role.permissions = await this.loadPermissionEntities(dto.permissions);
return this.roles.save(role); const saved = await this.roles.save(role);
} if (actor) {
await this.audit.record(
async update(id: string, dto: UpdateRoleDto): Promise<RoleEntity> { actor.id,
const role = await this.getRole(id); AuditAction.RoleCreated,
if ( 'role',
role.name === adminRoleName && saved.id,
dto.permissions.length !== allPermissions.length {
) { roleName: saved.name,
throw new ApiError( },
ErrorCode.PermissionDenied,
'Die Adminrolle muss alle Rechte behalten.',
403,
); );
} }
role.name = role.protected ? role.name : dto.name; return saved;
role.permissions = await this.loadPermissionEntities( }
role.name === adminRoleName ? allPermissions : dto.permissions,
async update(
id: string,
dto: UpdateRoleDto,
actor?: AuthenticatedUser,
): Promise<RoleEntity> {
return this.dataSource.transaction(async (manager) => {
await manager.query(
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
);
try {
const role = await this.getRole(id, manager);
const normalizedName = this.normalizeName(dto.name);
if (!role.protected && normalizedName !== role.name) {
await this.assertRoleNameAvailable(normalizedName, id, manager);
role.name = normalizedName;
}
if (role.name === adminRoleName) {
this.assertAdminPermissions(dto.permissions);
}
role.description = dto.description?.trim() ?? '';
role.permissions = await this.loadPermissionEntities(
role.name === adminRoleName ? allPermissions : dto.permissions,
manager,
);
const saved = await this.roles.save(role, manager);
if (actor) {
await this.audit.record(
actor.id,
AuditAction.RoleUpdated,
'role',
saved.id,
{
roleName: saved.name,
},
);
await this.audit.record(
actor.id,
AuditAction.RolePermissionsUpdated,
'role',
saved.id,
{
permissions: saved.permissions
.map((permission) => permission.id)
.join(','),
},
);
}
return saved;
} finally {
await manager.query(
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
);
}
});
}
async delete(id: string, actor?: AuthenticatedUser): Promise<void> {
await this.dataSource.transaction(async (manager) => {
await manager.query(
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
);
try {
const role = await this.getRole(id, manager);
if (role.protected) {
throw new ApiError(
ErrorCode.SystemRoleProtected,
'Systemrollen koennen nicht geloescht werden.',
409,
);
}
if (role.users.length > 0) {
throw new ApiError(
ErrorCode.RoleStillAssigned,
'Die Rolle ist noch Benutzern zugewiesen.',
409,
);
}
await this.roles.remove(role, manager);
if (actor) {
await this.audit.record(
actor.id,
AuditAction.RoleDeleted,
'role',
id,
{
roleName: role.name,
},
);
}
} finally {
await manager.query(
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
);
}
});
}
async getEffectivePermissions(userId: string): Promise<Permission[]> {
const rows = await this.dataSource.query<{ permission: Permission }[]>(
`
SELECT DISTINCT rp.permission_id AS permission
FROM user_roles ur
INNER JOIN role_permissions rp ON rp.role_id = ur.role_id
WHERE ur.user_id = ?
`,
[userId],
); );
return this.roles.save(role); return rows.map((row) => row.permission);
}
async delete(id: string): Promise<void> {
const role = await this.getRole(id);
if (role.protected) {
throw new ApiError(
ErrorCode.Conflict,
'Systemrollen koennen nicht geloescht werden.',
409,
);
}
if (role.users.length > 0) {
throw new ApiError(
ErrorCode.Conflict,
'Die Rolle ist noch Benutzern zugewiesen.',
409,
);
}
await this.roles.remove(role);
} }
async ensureSystemRoles( async ensureSystemRoles(
@@ -81,7 +184,12 @@ export class RolesService {
); );
const user = await this.ensureRole( const user = await this.ensureRole(
userRoleName, userRoleName,
[Permission.ItemsRead, Permission.SessionsReadOwn], [
Permission.ItemsRead,
Permission.SessionsReadOwn,
Permission.NotificationsReadOwn,
Permission.NotificationsUpdateOwn,
],
true, true,
manager, manager,
); );
@@ -100,11 +208,11 @@ export class RolesService {
); );
} }
async getRole(id: string): Promise<RoleEntity> { async getRole(id: string, manager?: EntityManager): Promise<RoleEntity> {
const role = await this.roles.findById(id); const role = await this.roles.findById(id, manager);
if (!role) { if (!role) {
throw new ApiError( throw new ApiError(
ErrorCode.NotFound, ErrorCode.RoleNotFound,
'Die Rolle wurde nicht gefunden.', 'Die Rolle wurde nicht gefunden.',
404, 404,
); );
@@ -144,7 +252,7 @@ export class RolesService {
.findOneBy({ id: permission }); .findOneBy({ id: permission });
if (!entity) { if (!entity) {
throw new ApiError( throw new ApiError(
ErrorCode.ValidationFailed, ErrorCode.UnknownPermission,
'Unbekannte Permission.', 'Unbekannte Permission.',
400, 400,
); );
@@ -153,4 +261,51 @@ export class RolesService {
}), }),
); );
} }
private normalizeName(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, '-');
}
private async assertRoleNameAvailable(
name: string,
exceptRoleId?: string,
manager?: EntityManager,
): Promise<void> {
const existing = await this.roles.findByName(name, manager);
if (existing && existing.id !== exceptRoleId) {
throw new ApiError(
ErrorCode.RoleNameAlreadyExists,
'Der Rollenname ist bereits vergeben.',
409,
);
}
}
private assertAdminPermissions(permissions: Permission[]): void {
const submitted = new Set(permissions);
const missing = allPermissions.filter(
(permission) => !submitted.has(permission),
);
if (missing.length > 0) {
throw new ApiError(
ErrorCode.LastAdminRequired,
'Mindestens ein aktiver Administrator muss erhalten bleiben.',
409,
);
}
}
private toAdminDto(role: RoleEntity) {
return {
id: role.id,
name: role.name,
description: role.description,
system: role.protected,
protected: role.protected,
permissions: role.permissions,
userCount: role.users?.length ?? 0,
users: role.users,
createdAt: role.createdAt.toISOString(),
};
}
} }

View File

@@ -21,27 +21,52 @@ export class SessionsRepository {
return this.repo.find({ where: { userId }, order: { createdAt: 'DESC' } }); return this.repo.find({ where: { userId }, order: { createdAt: 'DESC' } });
} }
listActiveForUser(userId: string): Promise<SessionEntity[]> {
return this.repo.find({
where: { userId, revokedAt: IsNull() },
order: { lastActivityAt: 'DESC' },
});
}
countActiveForUser(userId: string): Promise<number> {
return this.repo.count({ where: { userId, revokedAt: IsNull() } });
}
save(session: SessionEntity): Promise<SessionEntity> { save(session: SessionEntity): Promise<SessionEntity> {
return this.repo.save(session); return this.repo.save(session);
} }
async revoke(sessionId: string): Promise<void> { async revoke(sessionId: string): Promise<number> {
await this.repo.update({ id: sessionId }, { revokedAt: new Date() }); const result = await this.repo.update(
{ id: sessionId, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
return result.affected ?? 0;
}
async revokeForUser(userId: string, sessionId: string): Promise<number> {
const result = await this.repo.update(
{ id: sessionId, userId, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
return result.affected ?? 0;
} }
async revokeAllForUser( async revokeAllForUser(
userId: string, userId: string,
exceptSessionId?: string, exceptSessionId?: string,
): Promise<void> { ): Promise<number> {
const sessions = await this.repo.find({ const sessions = await this.repo.find({
where: { userId, revokedAt: IsNull() }, where: { userId, revokedAt: IsNull() },
}); });
const now = new Date(); const now = new Date();
await this.repo.save( const targets = sessions.filter(
sessions (session) => session.id !== exceptSessionId,
.filter((session) => session.id !== exceptSessionId)
.map((session) => ({ ...session, revokedAt: now })),
); );
await this.repo.save(
targets.map((session) => ({ ...session, revokedAt: now })),
);
return targets.length;
} }
async cleanupExpired(now = new Date()): Promise<void> { async cleanupExpired(now = new Date()): Promise<void> {

View File

@@ -15,6 +15,19 @@ export interface ResolvedSession {
permissions: Permission[]; 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() @Injectable()
export class SessionsService { export class SessionsService {
constructor( constructor(
@@ -124,14 +137,56 @@ export class SessionsService {
})); }));
} }
revoke(sessionId: string): Promise<void> { revoke(sessionId: string): Promise<number> {
return this.sessions.revoke(sessionId); return this.sessions.revoke(sessionId);
} }
revokeAllForUser(userId: string, exceptSessionId?: string): Promise<void> { async getIdTokenForLogout(sessionId: string): Promise<string | undefined> {
const session = await this.sessions.findActiveById(sessionId);
if (!session?.idTokenEncrypted) {
return undefined;
}
return this.crypto.decrypt(session.idTokenEncrypted);
}
revokeAllForUser(userId: string, exceptSessionId?: string): Promise<number> {
return this.sessions.revokeAllForUser(userId, exceptSessionId); return this.sessions.revokeAllForUser(userId, exceptSessionId);
} }
async listForAdmin(
userId: string,
currentSessionId: string | undefined,
): Promise<AdminSessionDto[]> {
const sessions = await this.sessions.listForUser(userId);
return sessions.map((session) => ({
id: session.id,
publicId: `${session.id.slice(0, 8)}...${session.id.slice(-6)}`,
createdAt: session.createdAt.toISOString(),
lastActivityAt: session.lastActivityAt.toISOString(),
expiresAt: session.expiresAt.toISOString(),
absoluteExpiresAt: session.absoluteExpiresAt.toISOString(),
userAgent: session.userAgent,
approximateIp: this.maskIp(session.lastIp),
current: session.id === currentSessionId,
revokedAt: session.revokedAt?.toISOString() ?? null,
}));
}
async revokeForAdmin(userId: string, sessionId: string): Promise<void> {
const affected = await this.sessions.revokeForUser(userId, sessionId);
if (affected < 1) {
throw new ApiError(
ErrorCode.SessionNotFound,
'Die Session wurde nicht gefunden.',
404,
);
}
}
countActiveForUser(userId: string): Promise<number> {
return this.sessions.countActiveForUser(userId);
}
private permissionsFor(user: UserEntity): Permission[] { private permissionsFor(user: UserEntity): Permission[] {
return Array.from( return Array.from(
new Set( new Set(

View File

@@ -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 { export class UpdateUserRolesDto {
@IsArray() @IsArray()
@@ -18,3 +29,41 @@ export class UpdateSettingsDto {
@IsOptional() @IsOptional()
sidebarExpanded?: boolean; 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;
}

View File

@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm'; import { EntityManager, Repository } from 'typeorm';
import type {
AdminUserActiveFilter,
AdminUserSortField,
} from '../dto/user.dto';
import { UserEntity } from '../entities/user.entity'; import { UserEntity } from '../entities/user.entity';
@Injectable() @Injectable()
@@ -9,8 +13,10 @@ export class UsersRepository {
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>, @InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
) {} ) {}
findById(id: string): Promise<UserEntity | null> { findById(id: string, manager?: EntityManager): Promise<UserEntity | null> {
return this.repo.findOne({ where: { id } }); return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
where: { id },
});
} }
findByIdentity(issuer: string, subject: string): Promise<UserEntity | null> { findByIdentity(issuer: string, subject: string): Promise<UserEntity | null> {
@@ -37,7 +43,70 @@ export class UsersRepository {
.getManyAndCount(); .getManyAndCount();
} }
async adminSearch(
query: {
search?: string;
active: AdminUserActiveFilter;
roleId?: string;
sort: AdminUserSortField;
direction: 'ASC' | 'DESC';
page: number;
pageSize: number;
},
manager?: EntityManager,
): Promise<[UserEntity[], number]> {
const repo = manager?.getRepository(UserEntity) ?? this.repo;
const qb = repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.roles', 'role')
.leftJoinAndSelect('role.permissions', 'permission');
if (query.search) {
qb.andWhere('user.name LIKE :search OR user.email LIKE :search', {
search: `%${query.search}%`,
});
}
if (query.active !== 'all') {
qb.andWhere('user.active = :active', {
active: query.active === 'active',
});
}
if (query.roleId) {
qb.andWhere(
'EXISTS (SELECT 1 FROM user_roles ur WHERE ur.user_id = user.id AND ur.role_id = :roleId)',
{ roleId: query.roleId },
);
}
return qb
.orderBy(`user.${query.sort}`, query.direction)
.skip((query.page - 1) * query.pageSize)
.take(query.pageSize)
.getManyAndCount();
}
findByIdWithRoles(
id: string,
manager?: EntityManager,
): Promise<UserEntity | null> {
return (manager?.getRepository(UserEntity) ?? this.repo).findOne({
where: { id },
relations: { roles: { permissions: true }, settings: true },
});
}
async save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> { async save(user: UserEntity, manager?: EntityManager): Promise<UserEntity> {
return (manager?.getRepository(UserEntity) ?? this.repo).save(user); return (manager?.getRepository(UserEntity) ?? this.repo).save(user);
} }
findFirstActiveAdmin(excludedUserId?: string): Promise<UserEntity | null> {
const qb = this.repo
.createQueryBuilder('user')
.innerJoin('user.roles', 'role')
.where('user.active = :active', { active: true })
.andWhere('role.name = :role', { role: 'admin' })
.orderBy('user.createdAt', 'ASC');
if (excludedUserId) {
qb.andWhere('user.id <> :excludedUserId', { excludedUserId });
}
return qb.getOne();
}
} }

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { ErrorCode } from '../../common/errors/error-codes'; import { ErrorCode } from '../../common/errors/error-codes';
import { UsersService } from '../users.service'; import { UsersService } from '../users.service';
import type { AuditService } from '../../audit/audit.service'; import type { AuditService } from '../../audit/audit.service';
import type { NotificationsService } from '../../notifications/notifications.service';
import type { RolesService } from '../../roles/roles.service'; import type { RolesService } from '../../roles/roles.service';
import type { SessionsService } from '../../sessions/sessions.service'; import type { SessionsService } from '../../sessions/sessions.service';
import type { UsersRepository } from '../repositories/users.repository'; import type { UsersRepository } from '../repositories/users.repository';
@@ -10,25 +11,28 @@ import type { DataSource } from 'typeorm';
describe('UsersService', () => { describe('UsersService', () => {
it('blocks changes that would remove the last active admin', async () => { it('blocks changes that would remove the last active admin', async () => {
const dataSource = { const dataSource = {
getRepository: () => ({ manager: {
createQueryBuilder: () => ({ getRepository: () => ({
innerJoin: () => ({ createQueryBuilder: () => ({
where: () => ({ innerJoin: () => ({
andWhere: () => ({ where: () => ({
andWhere: () => ({ andWhere: () => ({
getCount: () => Promise.resolve(0), andWhere: () => ({
getCount: () => Promise.resolve(0),
}),
}), }),
}), }),
}), }),
}), }),
}), }),
}), },
} as unknown as DataSource; } as unknown as DataSource;
const service = new UsersService( const service = new UsersService(
{} as UsersRepository, {} as UsersRepository,
{} as RolesService, {} as RolesService,
{} as SessionsService, {} as SessionsService,
{} as AuditService, {} as AuditService,
{} as NotificationsService,
dataSource, dataSource,
); );

View File

@@ -1,9 +1,11 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
Patch, Patch,
Post,
Query, Query,
Req, Req,
} from '@nestjs/common'; } 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 { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
import { Permission } from '../roles/permissions'; import { Permission } from '../roles/permissions';
import { import {
AdminUserListQueryDto,
UpdateSettingsDto, UpdateSettingsDto,
UpdateUserActiveDto, UpdateUserActiveDto,
UpdateUserRolesDto, UpdateUserRolesDto,
@@ -49,6 +52,84 @@ export class UsersController {
return this.users.list(search, Number(page ?? 1), Number(pageSize ?? 20)); 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') @Patch('users/:id/active')
@RequirePermissions(Permission.UsersManage) @RequirePermissions(Permission.UsersManage)
@SensitiveRateLimit() @SensitiveRateLimit()

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { RolesModule } from '../roles/roles.module'; import { RolesModule } from '../roles/roles.module';
import { SessionsModule } from '../sessions/sessions.module'; import { SessionsModule } from '../sessions/sessions.module';
import { UserSettingsEntity } from './entities/user-settings.entity'; import { UserSettingsEntity } from './entities/user-settings.entity';
@@ -15,6 +16,7 @@ import { UsersService } from './users.service';
RolesModule, RolesModule,
AuditModule, AuditModule,
SessionsModule, SessionsModule,
NotificationsModule,
], ],
controllers: [UsersController], controllers: [UsersController],
providers: [UsersRepository, UsersService], providers: [UsersRepository, UsersService],

View File

@@ -1,24 +1,56 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/entities/audit-log.entity'; import { AuditAction } from '../audit/entities/audit-log.entity';
import type { AuthenticatedUser } from '../auth/authenticated-request'; import type { AuthenticatedUser } from '../auth/authenticated-request';
import { ApiError } from '../common/errors/api-error'; import { ApiError } from '../common/errors/api-error';
import { ErrorCode } from '../common/errors/error-codes'; 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 { RolesService } from '../roles/roles.service';
import { RoleEntity } from '../roles/entities/role.entity';
import { SessionsService } from '../sessions/sessions.service'; import { SessionsService } from '../sessions/sessions.service';
import type { AdminSessionDto } from '../sessions/sessions.service';
import { UserSettingsEntity } from './entities/user-settings.entity'; import { UserSettingsEntity } from './entities/user-settings.entity';
import { UserEntity } from './entities/user.entity'; import { UserEntity } from './entities/user.entity';
import type { AdminUserListQueryDto } from './dto/user.dto';
import { UsersRepository } from './repositories/users.repository'; 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() @Injectable()
export class UsersService { export class UsersService {
private readonly logger = new Logger(UsersService.name);
constructor( constructor(
private readonly users: UsersRepository, private readonly users: UsersRepository,
private readonly roles: RolesService, private readonly roles: RolesService,
private readonly sessions: SessionsService, private readonly sessions: SessionsService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly notifications: NotificationsService,
@InjectDataSource() private readonly dataSource: DataSource, @InjectDataSource() private readonly dataSource: DataSource,
) {} ) {}
@@ -27,6 +59,34 @@ export class UsersService {
return { items, total, page, pageSize }; return { items, total, page, pageSize };
} }
async adminList(query: AdminUserListQueryDto) {
const [users, total] = await this.users.adminSearch(query);
const items = await Promise.all(
users.map(async (user) => ({
...this.toAdminListItem(user),
activeSessionCount: await this.sessions.countActiveForUser(user.id),
})),
);
return { items, total, page: query.page, pageSize: query.pageSize };
}
async adminGet(
id: string,
currentSessionId: string | undefined,
): Promise<AdminUserDetail> {
const user = await this.getWithRoles(id);
return {
...this.toAdminListItem(user),
activeSessionCount: await this.sessions.countActiveForUser(user.id),
effectivePermissions: this.effectivePermissions(user),
sessions: await this.sessions.listForAdmin(user.id, currentSessionId),
settings: {
tablePageSize: user.settings.tablePageSize,
sidebarExpanded: user.settings.sidebarExpanded,
},
};
}
async get(id: string): Promise<UserEntity> { async get(id: string): Promise<UserEntity> {
const user = await this.users.findById(id); const user = await this.users.findById(id);
if (!user) { if (!user) {
@@ -44,22 +104,38 @@ export class UsersService {
userId: string, userId: string,
active: boolean, active: boolean,
): Promise<UserEntity> { ): Promise<UserEntity> {
const user = await this.get(userId); return this.withAdminLock(async (manager) => {
if (!active) { const user = await this.getWithRoles(userId, manager);
await this.assertAnotherActiveAdminRemains(userId); if (active && user.active) {
} throw new ApiError(
user.active = active; ErrorCode.UserAlreadyActive,
const saved = await this.users.save(user); 'Der Benutzer ist bereits aktiv.',
if (!active) { 409,
await this.sessions.revokeAllForUser(userId); );
} }
await this.audit.record( if (!active && !user.active) {
actor.id, throw new ApiError(
active ? AuditAction.UserActivated : AuditAction.UserDeactivated, ErrorCode.UserAlreadyInactive,
'user', 'Der Benutzer ist bereits deaktiviert.',
userId, 409,
); );
return saved; }
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( async setRoles(
@@ -67,26 +143,72 @@ export class UsersService {
userId: string, userId: string,
roleIds: string[], roleIds: string[],
): Promise<UserEntity> { ): Promise<UserEntity> {
const user = await this.get(userId); return this.replaceRoles(actor, userId, roleIds);
const previousAdmin = this.hasRole(user, 'admin'); }
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id)), async assignRole(
); actor: AuthenticatedUser,
user.roles = roles; userId: string,
if (previousAdmin && !this.hasRole(user, 'admin')) { roleId: string,
await this.assertAnotherActiveAdminRemains(userId); ): Promise<UserEntity> {
const user = await this.getWithRoles(userId);
if (user.roles.some((role) => role.id === roleId)) {
return user;
} }
const saved = await this.users.save(user); return this.replaceRoles(actor, userId, [
...user.roles.map((role) => role.id),
roleId,
]);
}
async removeRole(
actor: AuthenticatedUser,
userId: string,
roleId: string,
): Promise<UserEntity> {
const user = await this.getWithRoles(userId);
if (!user.roles.some((role) => role.id === roleId)) {
return user;
}
return this.replaceRoles(
actor,
userId,
user.roles.filter((role) => role.id !== roleId).map((role) => role.id),
);
}
async revokeUserSession(
actor: AuthenticatedUser,
userId: string,
sessionId: string,
): Promise<void> {
await this.get(userId);
await this.sessions.revokeForAdmin(userId, sessionId);
await this.audit.record( await this.audit.record(
actor.id, actor.id,
AuditAction.UserRoleAssigned, AuditAction.SessionRevoked,
'user', 'session',
userId, 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( async updateSettings(
@@ -103,8 +225,11 @@ export class UsersService {
return this.users.save(user); return this.users.save(user);
} }
async assertAnotherActiveAdminRemains(excludedUserId: string): Promise<void> { async assertAnotherActiveAdminRemains(
const result = await this.dataSource excludedUserId: string,
manager?: EntityManager,
): Promise<void> {
const result = await (manager ?? this.dataSource.manager)
.getRepository(UserEntity) .getRepository(UserEntity)
.createQueryBuilder('user') .createQueryBuilder('user')
.innerJoin('user.roles', 'role') .innerJoin('user.roles', 'role')
@@ -115,13 +240,177 @@ export class UsersService {
if (result < 1) { if (result < 1) {
throw new ApiError( throw new ApiError(
ErrorCode.LastAdminRequired, ErrorCode.LastAdminRequired,
'Mindestens ein anderer aktiver Administrator muss erhalten bleiben.', 'Mindestens ein aktiver Administrator muss erhalten bleiben.',
409, 409,
); );
} }
} }
private async replaceRoles(
actor: AuthenticatedUser,
userId: string,
roleIds: string[],
): Promise<UserEntity> {
return this.withAdminLock(async (manager) => {
const user = await this.getWithRoles(userId, manager);
const previousRoleNames = new Set(user.roles.map((role) => role.name));
const previousRoleIds = new Set(user.roles.map((role) => role.id));
const roles = await Promise.all(
roleIds.map((id) => this.roles.getRole(id, manager)),
);
const nextRoleNames = new Set(roles.map((role) => role.name));
user.roles = roles;
if (previousRoleNames.has('admin') && !nextRoleNames.has('admin')) {
await this.assertAnotherActiveAdminRemains(userId, manager);
}
const saved = await this.users.save(user, manager);
await this.recordRoleAudit(actor, userId, previousRoleIds, roles);
await this.notifyRoleChanges(userId, previousRoleNames, nextRoleNames);
return saved;
});
}
private async recordRoleAudit(
actor: AuthenticatedUser,
userId: string,
previousRoleIds: Set<string>,
nextRoles: RoleEntity[],
): Promise<void> {
const nextRoleIds = new Set(nextRoles.map((role) => role.id));
for (const previousRoleId of previousRoleIds) {
if (!nextRoleIds.has(previousRoleId)) {
await this.audit.record(
actor.id,
AuditAction.UserRoleRemoved,
'user',
userId,
{ roleId: previousRoleId },
);
}
}
for (const role of nextRoles) {
if (!previousRoleIds.has(role.id)) {
await this.audit.record(
actor.id,
AuditAction.UserRoleAssigned,
'user',
userId,
{ roleId: role.id, roleName: role.name },
);
}
}
}
private async getWithRoles(
userId: string,
manager?: EntityManager,
): Promise<UserEntity> {
const user = await this.users.findByIdWithRoles(userId, manager);
if (!user) {
throw new ApiError(
ErrorCode.UserNotFound,
'Der Benutzer wurde nicht gefunden.',
404,
);
}
return user;
}
private async withAdminLock<T>(
action: (manager: EntityManager) => Promise<T>,
): Promise<T> {
return this.dataSource.transaction(async (manager) => {
await manager.query(
"SELECT GET_LOCK('business_app_admin_integrity', 10)",
);
try {
return await action(manager);
} finally {
await manager.query(
"SELECT RELEASE_LOCK('business_app_admin_integrity')",
);
}
});
}
private toAdminListItem(user: UserEntity): AdminUserListItem {
return {
id: user.id,
name: user.name,
email: user.email,
active: user.active,
roles: user.roles.map((role) => ({
id: role.id,
name: role.name,
description: role.description,
system: role.protected,
})),
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
createdAt: user.createdAt.toISOString(),
activeSessionCount: 0,
};
}
private effectivePermissions(user: UserEntity): string[] {
return Array.from(
new Set(
user.roles.flatMap((role) =>
role.permissions.map((permission) => permission.id),
),
),
).sort();
}
private hasRole(user: UserEntity, roleName: string): boolean { private hasRole(user: UserEntity, roleName: string): boolean {
return user.roles.some((role) => role.name === roleName); return user.roles.some((role) => role.name === roleName);
} }
private async notifyRoleChanges(
userId: string,
previousRoleNames: Set<string>,
nextRoleNames: Set<string>,
): Promise<void> {
const added = [...nextRoleNames].filter(
(role) => !previousRoleNames.has(role),
);
const removed = [...previousRoleNames].filter(
(role) => !nextRoleNames.has(role),
);
for (const role of added) {
await this.createRoleNotification(
userId,
role,
`Ihnen wurde die Rolle "${role}" zugewiesen.`,
);
}
for (const role of removed) {
await this.createRoleNotification(
userId,
role,
`Die Rolle "${role}" wurde Ihnen entzogen.`,
);
}
}
private async createRoleNotification(
userId: string,
role: string,
message: string,
): Promise<void> {
try {
await this.notifications.createForUser({
userId,
type: NotificationType.UserRoleChanged,
title: 'Rollen geaendert',
message,
link: '/profil',
metadata: { role },
});
} catch (error) {
this.logger.error(
{ userId, role, error },
'Failed to create role change notification',
);
}
}
} }

View File

@@ -34,6 +34,12 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"fileReplacements": [
{
"replace": "src/app/core/dev-routes.ts",
"with": "src/app/core/dev-routes.prod.ts"
}
],
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",

View File

@@ -1,5 +1,6 @@
import type { Routes } from '@angular/router'; import type { Routes } from '@angular/router';
import { permissionGuard } from './core/permission.guard'; import { permissionGuard } from './core/permission.guard';
import { devRoutes } from './core/dev-routes';
export const routes: Routes = [ export const routes: Routes = [
{ {
@@ -18,6 +19,16 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/profile/profile.page').then((m) => m.ProfilePageComponent), 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', path: 'sessions',
title: 'Eigene Sessions', title: 'Eigene Sessions',
@@ -40,6 +51,24 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/users/users.page').then((m) => m.UsersPageComponent), 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', path: 'rollen',
title: 'Rollenverwaltung', title: 'Rollenverwaltung',
@@ -48,6 +77,24 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/roles/roles.page').then((m) => m.RolesPageComponent), 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', path: 'audit-log',
title: 'Audit-Log', title: 'Audit-Log',
@@ -56,6 +103,14 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/audit/audit.page').then((m) => m.AuditPageComponent), 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', path: '403',
title: 'Keine Berechtigung', title: 'Keine Berechtigung',
@@ -68,6 +123,7 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/errors/error.page').then((m) => m.ErrorPageComponent), import('./features/errors/error.page').then((m) => m.ErrorPageComponent),
}, },
...devRoutes,
{ {
path: '**', path: '**',
title: 'Nicht gefunden', title: 'Nicht gefunden',

View File

@@ -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();

View File

@@ -0,0 +1,3 @@
import type { Routes } from '@angular/router';
export const devRoutes: Routes = [];

View File

@@ -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),
},
];

View File

@@ -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<ApiClientService> = {}) {
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');
});
});

View File

@@ -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<number>(
'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<NotificationDto[]>([]);
readonly unreadCount = signal(0);
readonly loading = signal(false);
readonly error = signal<ApiErrorBody | null>(null);
readonly currentFilter = signal<NotificationStatusFilter>('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: '',
};
}
}

View File

@@ -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' },
],
},
];

View File

@@ -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');
});
});

View File

@@ -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: `
<a routerLink="/admin/roles">Zurueck zur Rollenliste</a>
@if (error(); as currentError) {
<section class="notice error">
<strong>{{ messageFor(currentError) }}</strong>
@if (currentError.requestId) {
<small>Request-ID: {{ currentError.requestId }}</small>
}
</section>
}
@if (!loading()) {
<form class="editor" (ngSubmit)="save()">
<section>
<h2>{{ isNew() ? 'Rolle anlegen' : 'Rolle bearbeiten' }}</h2>
@if (role()?.system) {
<span class="system">Systemrolle</span>
}
<label>
Name
<input
name="name"
[(ngModel)]="name"
[readonly]="role()?.system"
required
maxlength="80"
/>
</label>
<label>
Beschreibung
<textarea name="description" [(ngModel)]="description" maxlength="255"></textarea>
</label>
</section>
<section>
<h3>Permissions</h3>
<div class="groups">
@for (group of groups; track group.title) {
<fieldset>
<legend>{{ group.title }}</legend>
@for (permission of group.permissions; track permission.id) {
<label>
<input
type="checkbox"
[checked]="selectedPermissions().has(permission.id)"
[disabled]="role()?.name === 'admin'"
(change)="toggle(permission.id)"
/>
<span>
<code>{{ permission.id }}</code>
{{ permission.label }}
</span>
</label>
}
</fieldset>
}
</div>
</section>
<div class="actions">
<button type="submit" [disabled]="!name.trim()">Speichern</button>
@if (role(); as currentRole) {
@if (!currentRole.system) {
<button type="button" class="danger" (click)="delete(currentRole)">Loeschen</button>
}
}
</div>
</form>
} @else {
<section class="notice">Rolle wird geladen.</section>
}
`,
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<RoleDto | null>(null);
readonly loading = signal(false);
readonly error = signal<ApiErrorBody | null>(null);
readonly selectedPermissions = signal(new Set<Permission>());
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: '',
};
}
}

View File

@@ -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: `
<div class="toolbar">
<a class="button" routerLink="/admin/roles/new">Neue Rolle</a>
</div>
@if (error(); as currentError) {
<section class="notice error">
<strong>{{ currentError.message }}</strong>
@if (currentError.requestId) {
<small>Request-ID: {{ currentError.requestId }}</small>
}
</section>
}
@if (loading()) {
<section class="notice">Rollen werden geladen.</section>
} @else if (roles().length === 0) {
<section class="notice">Keine Rollen vorhanden.</section>
} @else {
<section class="list">
@for (role of roles(); track role.id) {
<article>
<div>
<strong>
{{ role.name }}
@if (role.system) {
<small>Systemrolle</small>
}
</strong>
<span>{{ role.description || 'Keine Beschreibung' }}</span>
</div>
<dl>
<div>
<dt>Benutzer</dt>
<dd>{{ role.userCount ?? role.users?.length ?? 0 }}</dd>
</div>
<div>
<dt>Permissions</dt>
<dd>{{ role.permissions.length }}</dd>
</div>
</dl>
<a class="button secondary" [routerLink]="['/admin/roles', role.id]">Bearbeiten</a>
</article>
}
</section>
}
`,
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<RoleDto[]>([]);
readonly loading = signal(false);
readonly error = signal<ApiErrorBody | null>(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: '',
};
}
}

View File

@@ -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: `
<a routerLink="/admin/users">Zurueck zur Benutzerliste</a>
@if (error(); as currentError) {
<section class="notice error">
<strong>{{ messageFor(currentError) }}</strong>
@if (currentError.requestId) {
<small>Request-ID: {{ currentError.requestId }}</small>
}
</section>
}
@if (user(); as currentUser) {
<section class="hero" [class.inactive]="!currentUser.active">
<div>
<h2>{{ currentUser.name }}</h2>
<p>{{ currentUser.email || 'Keine E-Mail' }}</p>
<p>{{ currentUser.active ? 'Aktiv' : 'Deaktiviert' }}</p>
</div>
<div class="actions">
@if (currentUser.active) {
<button type="button" class="danger" (click)="deactivate(currentUser.id)">
Deaktivieren
</button>
} @else {
<button type="button" (click)="activate(currentUser.id)">Aktivieren</button>
}
<button type="button" class="secondary" (click)="revokeAllSessions(currentUser.id)">
Alle Sessions beenden
</button>
</div>
</section>
<section class="grid">
<article>
<h3>Rollen</h3>
<div class="chips">
@for (role of currentUser.roles; track role.id) {
<span>
{{ role.name }}
<button type="button" (click)="removeRole(currentUser.id, role.id, role.name)">
Entfernen
</button>
</span>
}
</div>
<form class="inline" (ngSubmit)="assignRole(currentUser.id)">
<select name="selectedRoleId" [(ngModel)]="selectedRoleId">
<option value="">Rolle waehlen</option>
@for (role of availableRoles(currentUser); track role.id) {
<option [value]="role.id">{{ role.name }}</option>
}
</select>
<button type="submit" [disabled]="!selectedRoleId">Zuweisen</button>
</form>
</article>
<article>
<h3>Effektive Permissions</h3>
<div class="permission-list">
@for (permission of currentUser.effectivePermissions; track permission) {
<code>{{ permission }}</code>
}
</div>
</article>
</section>
<section>
<h3>Aktive Sessions</h3>
@if (currentUser.sessions.length === 0) {
<div class="notice">Keine aktiven Sessions.</div>
} @else {
<div class="list">
@for (session of currentUser.sessions; track session.id) {
<article>
<div>
<strong>{{ session.current ? 'Aktuelle Session' : 'Session' }}</strong>
<span>{{ session.userAgent || 'Unbekannter Browser' }}</span>
<span>{{ session.approximateIp || 'Keine IP' }}</span>
</div>
<dl>
<div>
<dt>Aktivitaet</dt>
<dd>{{ session.lastActivityAt }}</dd>
</div>
<div>
<dt>Ablauf</dt>
<dd>{{ session.expiresAt }}</dd>
</div>
</dl>
<button
type="button"
class="danger"
(click)="revokeSession(currentUser.id, session)"
>
Beenden
</button>
</article>
}
</div>
}
</section>
} @else if (loading()) {
<section class="notice">Benutzer wird geladen.</section>
}
`,
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<AdminUserDetailDto | null>(null);
readonly roles = signal<RoleDto[]>([]);
readonly loading = signal(false);
readonly error = signal<ApiErrorBody | null>(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: '',
};
}
}

View File

@@ -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();
});
});

View File

@@ -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: `
<form class="toolbar" (ngSubmit)="load()">
<label>
Suche
<input name="search" [(ngModel)]="search" placeholder="Name oder E-Mail" />
</label>
<label>
Status
<select name="active" [(ngModel)]="active">
<option value="all">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Deaktiviert</option>
</select>
</label>
<label>
Rolle
<select name="roleId" [(ngModel)]="roleId">
<option value="">Alle Rollen</option>
@for (role of roles(); track role.id) {
<option [value]="role.id">{{ role.name }}</option>
}
</select>
</label>
<label>
Sortierung
<select name="sort" [(ngModel)]="sort">
<option value="name">Name</option>
<option value="email">E-Mail</option>
<option value="lastLoginAt">Letzter Login</option>
<option value="createdAt">Erstellt</option>
</select>
</label>
<button type="submit">Suchen</button>
</form>
@if (error(); as currentError) {
<section class="notice error">
<strong>{{ currentError.message }}</strong>
@if (currentError.requestId) {
<small>Request-ID: {{ currentError.requestId }}</small>
}
</section>
}
@if (loading()) {
<section class="notice">Benutzer werden geladen.</section>
} @else if (users().length === 0) {
<section class="notice">Keine Benutzer gefunden.</section>
} @else {
<section class="list">
@for (user of users(); track user.id) {
<article [class.inactive]="!user.active">
<div class="summary">
<strong>{{ user.name }}</strong>
<span>{{ user.email || 'Keine E-Mail' }}</span>
<span>{{ user.active ? 'Aktiv' : 'Deaktiviert' }}</span>
</div>
<div class="chips" aria-label="Rollen">
@for (role of user.roles; track role.id) {
<span>{{ role.name }}</span>
}
</div>
<dl>
<div>
<dt>Sessions</dt>
<dd>{{ user.activeSessionCount }}</dd>
</div>
<div>
<dt>Letzter Login</dt>
<dd>{{ user.lastLoginAt || 'nie' }}</dd>
</div>
</dl>
<a class="button" [routerLink]="['/admin/users', user.id]">Details</a>
</article>
}
</section>
<nav class="pagination" aria-label="Seitennavigation">
<button type="button" [disabled]="page <= 1" (click)="previous()">Zurueck</button>
<span>Seite {{ page }} von {{ totalPages() }}</span>
<button type="button" [disabled]="page >= totalPages()" (click)="next()">Weiter</button>
</nav>
}
`,
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<AdminUserListItemDto[]>([]);
readonly roles = signal<RoleDto[]>([]);
readonly loading = signal(false);
readonly error = signal<ApiErrorBody | null>(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: '',
};
}
}

View File

@@ -4,36 +4,18 @@ import { ApiClientService, type AuditLogDto } from '@boilerplate/api-client';
@Component({ @Component({
standalone: true, standalone: true,
template: ` template: `
<section class="list"> <section class="ui-grid">
@for (entry of entries(); track entry.id) { @for (entry of entries(); track entry.id) {
<article> <article class="ui-card">
<strong>{{ labels[entry.action] || entry.action }}</strong> <strong>{{ labels[entry.action] || entry.action }}</strong>
<span>{{ entry.createdAt }} · Objekt: {{ entry.targetType }} {{ entry.targetId }}</span> <span class="ui-help-text"
<small>Request-ID: {{ entry.requestId }}</small> >{{ entry.createdAt }} · Objekt: {{ entry.targetType }} {{ entry.targetId }}</span
>
<small class="ui-meta">Request-ID: {{ entry.requestId }}</small>
</article> </article>
} }
</section> </section>
`, `,
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 { export class AuditPageComponent {
private readonly api = inject(ApiClientService); private readonly api = inject(ApiClientService);

View File

@@ -4,38 +4,15 @@ import { ApiClientService } from '@boilerplate/api-client';
@Component({ @Component({
standalone: true, standalone: true,
template: ` template: `
<section class="kpis"> <section class="ui-grid ui-grid--cards">
@for (card of cards(); track card.label) { @for (card of cards(); track card.label) {
<article> <article class="ui-card ui-kpi">
<span>{{ card.label }}</span> <span class="ui-meta">{{ card.label }}</span>
<strong>{{ card.value }}</strong> <strong class="ui-kpi__value">{{ card.value }}</strong>
</article> </article>
} }
</section> </section>
`, `,
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 { export class DashboardPageComponent {
private readonly api = inject(ApiClientService); private readonly api = inject(ApiClientService);

View File

@@ -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: `
<section class="ui-page">
<header class="ui-page-header">
<div class="ui-page-header__content">
<h1>Designsystem</h1>
<p class="ui-help-text">Interne Referenz fuer Tokens, Komponenten und mobile Muster.</p>
</div>
</header>
<section class="ui-card">
<h2>Farben</h2>
<div class="swatches">
@for (color of colors; track color) {
<article>
<span class="swatch" [style.background]="'var(--color-' + color + ')'"></span>
<code>--color-{{ color }}</code>
</article>
}
</div>
</section>
<section class="ui-card stack">
<h2>Typografie</h2>
<h1>Seitentitel</h1>
<h2>Bereichstitel</h2>
<p>Fliesstext mit Systemschrift und ruhiger Zeilenhoehe.</p>
<p class="ui-help-text">Hilfetext und Metainformationen</p>
</section>
<section class="ui-card stack">
<h2>Buttons und Status</h2>
<div class="cluster">
<ui-button label="Primaer" />
<ui-button label="Sekundaer" variant="secondary" />
<ui-button label="Ghost" variant="ghost" />
<ui-button label="Gefahr" variant="danger" />
<ui-button label="Laedt" [loading]="true" />
<ui-icon-button icon="delete" label="Eintrag loeschen" variant="danger" />
</div>
<div class="cluster">
<ui-status-badge label="Aktiv" tone="success" />
<ui-status-badge label="Warnung" tone="warning" />
<ui-status-badge label="Fehler" tone="danger" />
<ui-status-badge label="Info" tone="info" />
</div>
</section>
<section class="ui-card stack">
<h2>Formulare</h2>
<label class="ui-form-field">
<span class="ui-label">Textfeld</span>
<input class="ui-control" value="Beispiel" />
<span class="ui-help-text">Hilfetext direkt am Feld.</span>
</label>
<label class="ui-form-field">
<span class="ui-label">Auswahl</span>
<select class="ui-control">
<option>Option</option>
</select>
</label>
<label class="ui-checkbox">
<input type="checkbox" checked />
<span>Checkbox mit ausreichend grosser Touch-Flaeche</span>
</label>
</section>
<section class="ui-card stack">
<h2>Karten, Tabelle und Pagination</h2>
<div class="ui-grid ui-grid--cards">
<article class="ui-card ui-card--interactive">Interaktive Karte</article>
<article class="ui-card ui-card--warning">Warnkarte</article>
</div>
<div class="ui-table-wrap">
<table class="ui-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<tr>
<td>Beispiel</td>
<td>Aktiv</td>
<td>Bearbeiten</td>
</tr>
</tbody>
</table>
</div>
<ui-pagination [page]="1" [pageSize]="10" [total]="24" />
</section>
<section class="ui-card stack">
<h2>Dialoge, Toasts und States</h2>
<div class="cluster">
<button class="ui-button ui-button--primary" type="button" (click)="openDialog()">
Dialog oeffnen
</button>
<button class="ui-button ui-button--ghost" type="button" (click)="showToast()">
Toast anzeigen
</button>
</div>
<ui-loading-state label="Daten werden geladen." [inline]="true" />
<ui-empty-state
title="Keine Daten"
description="Filter liefern keine Treffer."
icon="info"
actionLabel="Filter zuruecksetzen"
/>
</section>
</section>
<ui-confirm-dialog />
<ui-toast-host />
`,
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.',
});
}
}

View File

@@ -1,17 +1,15 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { UiEmptyStateComponent } from '../../shared/ui';
@Component({ @Component({
standalone: true, standalone: true,
template: `<p class="state">Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.</p>`, imports: [UiEmptyStateComponent],
styles: [ template: `
` <ui-empty-state
.state { title="Fehler"
background: #fff; description="Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
border: 1px solid #d9dee7; icon="warning"
border-radius: 8px; />
padding: 20px; `,
}
`,
],
}) })
export class ErrorPageComponent {} export class ErrorPageComponent {}

View File

@@ -1,17 +1,15 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { UiEmptyStateComponent } from '../../shared/ui';
@Component({ @Component({
standalone: true, standalone: true,
template: `<p class="state">Keine Berechtigung fuer diese Seite.</p>`, imports: [UiEmptyStateComponent],
styles: [ template: `
` <ui-empty-state
.state { title="Keine Berechtigung"
background: #fff; description="Sie haben keine Berechtigung fuer diese Seite."
border: 1px solid #d9dee7; icon="warning"
border-radius: 8px; />
padding: 20px; `,
}
`,
],
}) })
export class ForbiddenPageComponent {} export class ForbiddenPageComponent {}

View File

@@ -1,17 +1,15 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { UiEmptyStateComponent } from '../../shared/ui';
@Component({ @Component({
standalone: true, standalone: true,
template: `<p class="state">Die angeforderte Seite wurde nicht gefunden.</p>`, imports: [UiEmptyStateComponent],
styles: [ template: `
` <ui-empty-state
.state { title="Seite nicht gefunden"
background: #fff; description="Die angeforderte Seite wurde nicht gefunden."
border: 1px solid #d9dee7; icon="info"
border-radius: 8px; />
padding: 20px; `,
}
`,
],
}) })
export class NotFoundPageComponent {} export class NotFoundPageComponent {}

View File

@@ -23,4 +23,49 @@ describe('ItemsPageComponent', () => {
fixture.componentInstance.form.controls.name.setValue('Neues Item'); fixture.componentInstance.form.controls.name.setValue('Neues Item');
expect(fixture.componentInstance.form.valid).toBe(true); 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');
});
}); });

View File

@@ -1,104 +1,84 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ApiClientService, type ApiErrorBody, type ItemDto } from '@boilerplate/api-client'; import { ApiClientService, type ApiErrorBody, type ItemDto } from '@boilerplate/api-client';
import { UiPaginationComponent } from '../../shared/ui';
@Component({ @Component({
standalone: true, standalone: true,
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule, UiPaginationComponent],
template: ` template: `
<form class="toolbar" (ngSubmit)="load()"> <form class="ui-toolbar item-toolbar" (ngSubmit)="searchItems()">
<input [formControl]="search" placeholder="Suchen" /> <label class="ui-form-field">
<button type="submit">Suchen</button> <span class="ui-label">Suche</span>
<input class="ui-control" [formControl]="search" placeholder="Item suchen" />
</label>
<button class="ui-button ui-button--primary" type="submit">Suchen</button>
</form> </form>
@if (error()) { @if (error()) {
<p class="error">{{ error() }}</p> <p class="ui-notice ui-notice--error">{{ error() }}</p>
} }
<section class="grid">
<section class="ui-grid item-grid">
@for (item of items(); track item.id) { @for (item of items(); track item.id) {
<article (click)="edit(item)"> <button class="ui-card ui-card--interactive item-card" type="button" (click)="edit(item)">
<strong>{{ item.name }}</strong> <strong>{{ item.name }}</strong>
<span>{{ item.description || 'Keine Beschreibung' }}</span> <span class="ui-help-text">{{ item.description || 'Keine Beschreibung' }}</span>
<small>{{ item.status }} · Version {{ item.version }}</small> <small class="ui-meta">{{ item.status }} - Version {{ item.version }}</small>
</article> </button>
} }
</section> </section>
<form class="panel" [formGroup]="form" (ngSubmit)="save()">
@if (total() > 0) {
<ui-pagination
[page]="page()"
[pageSize]="pageSize"
[total]="total()"
(pageChange)="goToPage($event)"
/>
}
<form class="ui-card ui-form" [formGroup]="form" (ngSubmit)="save()">
<h2>{{ selected()?.id ? 'Item bearbeiten' : 'Item erstellen' }}</h2> <h2>{{ selected()?.id ? 'Item bearbeiten' : 'Item erstellen' }}</h2>
<label>Name <input formControlName="name" /></label> <label class="ui-form-field">
<label>Beschreibung <textarea formControlName="description"></textarea></label> <span class="ui-label">Name</span>
<label <input class="ui-control" formControlName="name" />
>Status </label>
<select formControlName="status"> <label class="ui-form-field">
<span class="ui-label">Beschreibung</span>
<textarea class="ui-control" formControlName="description"></textarea>
</label>
<label class="ui-form-field">
<span class="ui-label">Status</span>
<select class="ui-control" formControlName="status">
<option value="draft">Entwurf</option> <option value="draft">Entwurf</option>
<option value="active">Aktiv</option> <option value="active">Aktiv</option>
<option value="archived">Archiviert</option> <option value="archived">Archiviert</option>
</select> </select>
</label> </label>
<div class="actions"> <div class="ui-actions">
<button type="submit" [disabled]="form.invalid">Speichern</button> <button class="ui-button ui-button--primary" type="submit" [disabled]="form.invalid">
Speichern
</button>
@if (selected(); as item) { @if (selected(); as item) {
<button type="button" class="danger" (click)="delete(item)">Loeschen</button> <button class="ui-button ui-button--danger" type="button" (click)="delete(item)">
Loeschen
</button>
} }
</div> </div>
</form> </form>
`, `,
styles: [ styles: [
` `
.toolbar, .item-toolbar,
.panel { .item-grid {
display: grid; margin-bottom: var(--space-5);
gap: 12px;
background: #fff;
border: 1px solid #d9dee7;
border-radius: 8px;
padding: 16px;
} }
.toolbar { .item-card {
grid-template-columns: 1fr auto; text-align: left;
margin-bottom: 16px;
} }
.grid { @media (min-width: 48rem) {
display: grid; .item-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 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@@ -110,6 +90,9 @@ export class ItemsPageComponent {
readonly items = signal<ItemDto[]>([]); readonly items = signal<ItemDto[]>([]);
readonly selected = signal<ItemDto | null>(null); readonly selected = signal<ItemDto | null>(null);
readonly error = signal(''); readonly error = signal('');
readonly page = signal(1);
readonly total = signal(0);
readonly pageSize = 20;
readonly search = new FormControl('', { nonNullable: true }); readonly search = new FormControl('', { nonNullable: true });
readonly form = new FormGroup({ readonly form = new FormGroup({
name: new FormControl('', { name: new FormControl('', {
@@ -128,7 +111,27 @@ export class ItemsPageComponent {
} }
load(): void { 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 { edit(item: ItemDto): void {

View File

@@ -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');
});
});

View File

@@ -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: `
<section class="toolbar">
<div class="filters" aria-label="Benachrichtigungen filtern">
@for (filter of filters; track filter.value) {
<button
type="button"
[class.active]="store.currentFilter() === filter.value"
(click)="store.load(filter.value, 1)"
>
{{ filter.label }}
</button>
}
</div>
<button type="button" (click)="store.markAllAsRead()">Alle gelesen</button>
</section>
@if (store.loading()) {
<p class="state">Benachrichtigungen werden geladen.</p>
} @else if (store.error(); as error) {
<p class="state error">
{{ error.message }}
@if (error.requestId) {
<small>Request-ID: {{ error.requestId }}</small>
}
</p>
} @else if (store.notifications().length === 0) {
<p class="state">Keine Benachrichtigungen fuer diesen Filter.</p>
} @else {
<section class="list">
@for (notification of store.notifications(); track notification.id) {
<article [class.unread]="!notification.read">
<header>
<strong>{{ notification.title }}</strong>
<time>{{ notification.createdAt | date: 'short' }}</time>
</header>
<p>{{ notification.message }}</p>
<div class="actions">
@if (store.isInternalLink(notification.link)) {
<button type="button" (click)="store.openNotification(notification)">
Oeffnen
</button>
}
@if (notification.read) {
<button type="button" (click)="store.markAsUnread(notification.id)">
Als ungelesen markieren
</button>
} @else {
<button type="button" (click)="store.markAsRead(notification.id)">
Als gelesen markieren
</button>
}
<button type="button" class="danger" (click)="store.delete(notification.id)">
Loeschen
</button>
</div>
</article>
}
</section>
}
<nav class="pager" aria-label="Benachrichtigungsseiten">
<button type="button" [disabled]="store.page() <= 1" (click)="previous()">Zurueck</button>
<span>Seite {{ store.page() }}</span>
<button
type="button"
[disabled]="store.page() * store.pageSize() >= store.total()"
(click)="next()"
>
Weiter
</button>
</nav>
`,
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);
}
}

View File

@@ -8,8 +8,8 @@ import { ApiClientService } from '@boilerplate/api-client';
imports: [ReactiveFormsModule], imports: [ReactiveFormsModule],
template: ` template: `
@if (auth.user(); as user) { @if (auth.user(); as user) {
<section class="panel"> <section class="ui-card profile-card">
<dl> <dl class="profile-list">
<dt>Name</dt> <dt>Name</dt>
<dd>{{ user.name }}</dd> <dd>{{ user.name }}</dd>
<dt>E-Mail</dt> <dt>E-Mail</dt>
@@ -17,50 +17,45 @@ import { ApiClientService } from '@boilerplate/api-client';
<dt>Letzter Login</dt> <dt>Letzter Login</dt>
<dd>{{ user.lastLoginAt || 'Noch nicht bekannt' }}</dd> <dd>{{ user.lastLoginAt || 'Noch nicht bekannt' }}</dd>
</dl> </dl>
<form [formGroup]="form" (ngSubmit)="save()"> <form class="ui-form profile-form" [formGroup]="form" (ngSubmit)="save()">
<label <label class="ui-form-field">
>Tabellen-Seitengroesse <span class="ui-label">Tabellen-Seitengroesse</span>
<input type="number" formControlName="tablePageSize" min="5" max="100" <input
/></label> class="ui-control"
<label type="number"
><input type="checkbox" formControlName="sidebarExpanded" /> Sidebar standardmaessig formControlName="tablePageSize"
ausgeklappt</label min="5"
> max="100"
<button type="submit">Speichern</button> />
</label>
<label class="ui-checkbox">
<input type="checkbox" formControlName="sidebarExpanded" />
<span>Sidebar standardmaessig ausgeklappt</span>
</label>
<button class="ui-button ui-button--primary" type="submit">Speichern</button>
</form> </form>
</section> </section>
} }
`, `,
styles: [ styles: [
` `
.panel { .profile-card,
background: #fff; .profile-list {
border: 1px solid #d9dee7; gap: var(--space-5);
border-radius: 8px;
padding: 18px;
} }
dl { .profile-list {
display: grid; display: grid;
grid-template-columns: 120px 1fr; grid-template-columns: minmax(7rem, auto) 1fr;
gap: 10px; margin: 0;
} }
dt { dt {
color: #637083; color: var(--color-text-muted);
} }
form { dd {
display: grid; margin: 0;
gap: 14px;
max-width: 420px;
} }
input { .profile-form {
min-height: 44px; max-width: 28rem;
}
button {
min-height: 44px;
background: #26648e;
color: #fff;
border: 0;
padding: 0 18px;
} }
`, `,
], ],
@@ -76,7 +71,9 @@ export class ProfilePageComponent {
constructor() { constructor() {
const user = this.auth.user(); const user = this.auth.user();
if (user) { if (user) {
this.form.setValue(user.settings); console.log(user);
// this.form.setValue(user.settings);
this.form.patchValue(user.settings);
} }
} }

View File

@@ -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 { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ApiClientService, type Permission, type RoleDto } from '@boilerplate/api-client'; import { ApiClientService, type Permission, type RoleDto } from '@boilerplate/api-client';
@@ -31,7 +31,7 @@ const permissions: Permission[] = [
} }
</strong> </strong>
<span <span
>{{ role.permissions.length }} Permissions · >{{ role.permissions.length }} Permissions ·
{{ role.users?.length ?? 0 }} Benutzer</span {{ role.users?.length ?? 0 }} Benutzer</span
> >
</article> </article>
@@ -71,8 +71,8 @@ const permissions: Permission[] = [
} }
article, article,
.panel { .panel {
background: #fff; background: var(--color-surface);
border: 1px solid #d9dee7; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
} }
@@ -84,16 +84,16 @@ const permissions: Permission[] = [
min-height: 44px; min-height: 44px;
} }
button { button {
background: #26648e; background: var(--color-primary);
color: #fff; color: var(--color-surface);
border: 0; border: 0;
padding: 0 16px; padding: 0 16px;
} }
.danger { .danger {
background: #a23b3b; background: var(--color-danger);
} }
small { small {
color: #637083; color: var(--color-text-muted);
margin-left: 6px; margin-left: 6px;
} }
`, `,

View File

@@ -4,21 +4,25 @@ import { ApiClientService, type SessionDto } from '@boilerplate/api-client';
@Component({ @Component({
standalone: true, standalone: true,
template: ` template: `
<button class="secondary" type="button" (click)="revokeOthers()"> <button class="ui-button ui-button--ghost" type="button" (click)="revokeOthers()">
Alle anderen Sessions beenden Alle anderen Sessions beenden
</button> </button>
<section class="list"> <section class="ui-grid sessions-list">
@for (session of sessions(); track session.id) { @for (session of sessions(); track session.id) {
<article> <article class="ui-card">
<strong>{{ session.current ? 'Aktuelle Session' : 'Session' }}</strong> <div class="stack">
<span>Angemeldet: {{ session.createdAt }}</span> <strong>{{ session.current ? 'Aktuelle Session' : 'Session' }}</strong>
<span>Letzte Aktivitaet: {{ session.lastActivityAt }}</span> <span class="ui-help-text">Angemeldet: {{ session.createdAt }}</span>
<span <span class="ui-help-text">Letzte Aktivitaet: {{ session.lastActivityAt }}</span>
>{{ session.userAgent || 'Unbekannter Browser' }} · <span class="ui-help-text">
{{ session.approximateIp || 'IP unbekannt' }}</span {{ session.userAgent || 'Unbekannter Browser' }} ·
> {{ session.approximateIp || 'IP unbekannt' }}
</span>
</div>
@if (!session.current && !session.revokedAt) { @if (!session.current && !session.revokedAt) {
<button type="button" (click)="revoke(session.id)">Beenden</button> <button class="ui-button ui-button--danger" type="button" (click)="revoke(session.id)">
Beenden
</button>
} }
</article> </article>
} }
@@ -26,29 +30,8 @@ import { ApiClientService, type SessionDto } from '@boilerplate/api-client';
`, `,
styles: [ styles: [
` `
.secondary, .sessions-list {
button { margin-top: var(--space-5);
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;
} }
`, `,
], ],

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ApiClientService, type UserDto } from '@boilerplate/api-client'; import { ApiClientService, type UserDto } from '@boilerplate/api-client';
@@ -17,7 +17,7 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client';
<strong>{{ user.name }}</strong> <strong>{{ user.name }}</strong>
<span>{{ user.email || 'Keine E-Mail' }}</span> <span>{{ user.email || 'Keine E-Mail' }}</span>
<span <span
>{{ user.active ? 'Aktiv' : 'Deaktiviert' }} · Letzter Login: >{{ user.active ? 'Aktiv' : 'Deaktiviert' }} · Letzter Login:
{{ user.lastLoginAt || 'nie' }}</span {{ user.lastLoginAt || 'nie' }}</span
> >
</div> </div>
@@ -43,8 +43,8 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client';
} }
button { button {
border: 0; border: 0;
background: #26648e; background: var(--color-primary);
color: #fff; color: var(--color-surface);
padding: 0 14px; padding: 0 14px;
} }
.list { .list {
@@ -54,8 +54,8 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client';
article { article {
display: grid; display: grid;
gap: 12px; gap: 12px;
background: #fff; background: var(--color-surface);
border: 1px solid #d9dee7; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
} }
@@ -64,7 +64,7 @@ import { ApiClientService, type UserDto } from '@boilerplate/api-client';
gap: 4px; gap: 4px;
} }
span { span {
color: #637083; color: var(--color-text-muted);
} }
@media (min-width: 760px) { @media (min-width: 760px) {
article { article {

View File

@@ -21,7 +21,28 @@ describe('AppShellComponent', () => {
active: true, active: true,
lastLoginAt: null, lastLoginAt: null,
settings: { tablePageSize: 20, sidebarExpanded: true }, 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', permission: 'users.read',
}), }),
).toBe(false); ).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();
}); });
}); });

View File

@@ -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 { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
import type { Permission } from '@boilerplate/api-client'; import type { Permission } from '@boilerplate/api-client';
import { NotificationStore } from '../core/notification.store';
import { AuthService } from '../core/auth.service'; import { AuthService } from '../core/auth.service';
import { NotificationPanelComponent } from './notification-panel';
import { UiIconButtonComponent, UiIconComponent, UiToastHostComponent } from '../shared/ui';
interface NavItem { interface NavItem {
label: string; label: string;
@@ -12,38 +15,67 @@ interface NavItem {
@Component({ @Component({
selector: 'app-shell', selector: 'app-shell',
standalone: true, standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive], imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
NotificationPanelComponent,
UiIconButtonComponent,
UiIconComponent,
UiToastHostComponent,
],
template: ` template: `
<div class="shell"> <div class="shell">
<header class="topbar"> <header class="topbar">
@if (auth.user()) { @if (auth.user()) {
<button <ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
class="icon-button"
type="button"
(click)="drawerOpen.set(!drawerOpen())"
aria-label="Navigation"
>
<span></span><span></span><span></span>
</button>
} }
<strong>Business App</strong> <strong class="brand">Business App</strong>
@if (auth.user()) { @if (auth.user()) {
<a class="logout" href="/api/auth/logout">Abmelden</a> <button
class="ui-icon-button notification-button"
type="button"
aria-label="Benachrichtigungen"
[attr.aria-expanded]="notificationPanelOpen()"
aria-controls="notification-panel"
(click)="toggleNotifications()"
>
<ui-icon name="bell" />
@if (notifications.unreadCount() > 0) {
<span class="badge">{{ notifications.unreadCount() }}</span>
}
</button>
<a class="ui-button ui-button--ghost logout" href="/api/auth/logout">Abmelden</a>
} @else { } @else {
<a class="logout" href="/api/auth/login">Anmelden</a> <a class="ui-button ui-button--primary logout" href="/api/auth/login">Anmelden</a>
}
@if (auth.user() && notificationPanelOpen()) {
<app-notification-panel
id="notification-panel"
(closed)="notificationPanelOpen.set(false)"
/>
} }
</header> </header>
@if (auth.loaded()) { @if (auth.loaded()) {
@if (auth.user()) { @if (auth.user()) {
@if (drawerOpen()) {
<button
class="drawer-backdrop"
type="button"
aria-label="Navigation schliessen"
(click)="drawerOpen.set(false)"
></button>
}
<aside class="sidebar" [class.open]="drawerOpen()"> <aside class="sidebar" [class.open]="drawerOpen()">
<nav> <nav aria-label="Hauptnavigation">
@for (item of nav; track item.path) { @for (item of nav; track item.path) {
@if (visible(item)) { @if (visible(item)) {
<a <a
[routerLink]="item.path" [routerLink]="item.path"
routerLinkActive="active" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: item.path === '/' }" [routerLinkActiveOptions]="{ exact: item.path === '/' }"
(click)="closeDrawerOnNavigation()"
> >
{{ item.label }} {{ item.label }}
</a> </a>
@@ -54,7 +86,6 @@ interface NavItem {
<main class="content"> <main class="content">
<nav class="breadcrumbs">Start / {{ title() }}</nav> <nav class="breadcrumbs">Start / {{ title() }}</nav>
<h1>{{ title() }}</h1>
<router-outlet /> <router-outlet />
</main> </main>
} @else { } @else {
@@ -62,7 +93,9 @@ interface NavItem {
<section class="login-panel"> <section class="login-panel">
<h1>Anmelden</h1> <h1>Anmelden</h1>
<p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p> <p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p>
<a class="primary-action" href="/api/auth/login">Mit OIDC anmelden</a> <a class="ui-button ui-button--primary ui-button--mobile-full" href="/api/auth/login"
>Mit OIDC anmelden</a
>
</section> </section>
</main> </main>
} }
@@ -74,124 +107,126 @@ interface NavItem {
</section> </section>
</main> </main>
} }
<ui-toast-host />
</div> </div>
`, `,
styles: [ styles: [
` `
.shell { .shell {
min-height: 100vh; min-height: 100vh;
background: #f6f7f9; background: var(--color-background);
color: #1b2430; color: var(--color-text-primary);
} }
.topbar { .topbar {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: var(--z-header);
height: 64px; height: var(--header-height);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: var(--space-4);
padding: 0 16px; padding: 0 var(--space-5);
background: #fff; background: var(--color-surface);
border-bottom: 1px solid #d9dee7; border-bottom: 1px solid var(--color-border);
} }
.icon-button { .brand {
width: 44px; white-space: nowrap;
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;
} }
.logout { .logout {
margin-left: auto; margin-left: auto;
color: #184e77;
} }
.public-content { .notification-button {
min-height: calc(100vh - 64px); 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; display: grid;
place-items: center; 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 { .login-panel {
width: min(100%, 440px); width: min(100%, 440px);
background: #fff; background: var(--color-surface);
border: 1px solid #d9dee7; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-lg);
padding: 24px; padding: var(--space-6);
display: grid; display: grid;
gap: 14px; gap: var(--space-4);
} }
.login-panel h1, .login-panel h1,
.login-panel p { .login-panel p {
margin: 0; margin: 0;
} }
.login-panel p { .login-panel p {
color: #536173; color: var(--color-text-secondary);
} }
.primary-action { .drawer-backdrop {
min-height: 44px; position: fixed;
display: inline-flex; inset: var(--header-height) 0 0;
align-items: center; z-index: calc(var(--z-drawer) - 1);
justify-content: center; border: 0;
border-radius: 6px; background: color-mix(in srgb, var(--color-text-primary) 36%, transparent);
background: #26648e;
color: #fff;
text-decoration: none;
padding: 0 16px;
} }
.sidebar { .sidebar {
position: fixed; position: fixed;
inset: 64px auto 0 0; inset: var(--header-height) auto 0 0;
width: 260px; width: var(--sidebar-width);
background: #fff; background: var(--color-surface);
border-right: 1px solid #d9dee7; border-right: 1px solid var(--color-border);
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 160ms ease; transition: transform var(--transition-base) ease;
z-index: 9; z-index: var(--z-drawer);
} }
.sidebar.open { .sidebar.open {
transform: translateX(0); transform: translateX(0);
} }
nav a { nav a {
display: block; display: block;
padding: 14px 18px; padding: var(--space-4) var(--space-5);
color: #263445; color: var(--color-text-primary);
text-decoration: none; text-decoration: none;
min-height: 48px; min-height: var(--touch-target);
} }
nav a.active { nav a.active {
background: #e8f0f7; background: var(--color-primary-subtle);
border-left: 4px solid #26648e; border-left: 4px solid var(--color-primary);
} }
.content { .content {
padding: 20px 16px 48px; min-width: 0;
padding: var(--space-5) var(--space-5) var(--space-8);
} }
.breadcrumbs { .breadcrumbs {
color: #637083; color: var(--color-text-muted);
font-size: 0.9rem; font-size: var(--font-size-sm);
margin-bottom: 8px; margin-bottom: var(--space-4);
} }
h1 { @media (min-width: 64rem) {
font-size: 1.6rem; ui-icon-button {
margin: 0 0 20px; display: none;
} }
@media (min-width: 900px) { .drawer-backdrop {
.icon-button {
display: none; display: none;
} }
.sidebar { .sidebar {
transform: none; transform: none;
} }
.content { .content {
margin-left: 260px; margin-left: var(--sidebar-width);
padding: 28px 32px; padding: var(--space-7);
} }
} }
`, `,
@@ -200,25 +235,61 @@ interface NavItem {
export class AppShellComponent { export class AppShellComponent {
private readonly router = inject(Router); private readonly router = inject(Router);
readonly auth = inject(AuthService); readonly auth = inject(AuthService);
readonly notifications = inject(NotificationStore);
readonly drawerOpen = signal(false); readonly drawerOpen = signal(false);
readonly notificationPanelOpen = signal(false);
readonly title = computed( readonly title = computed(
() => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard', () => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard',
); );
readonly nav: NavItem[] = [ readonly nav: NavItem[] = [
{ label: 'Dashboard', path: '/' }, { label: 'Dashboard', path: '/' },
{ label: 'Profil', path: '/profil' }, { label: 'Profil', path: '/profil' },
{
label: 'Benachrichtigungen',
path: '/notifications',
permission: 'notifications.readOwn',
},
{ label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' }, { label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' },
{ label: 'Items', path: '/items', permission: 'items.read' }, { label: 'Items', path: '/items', permission: 'items.read' },
{ label: 'Benutzer', path: '/benutzer', permission: 'users.read' }, { label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' },
{ label: 'Rollen', path: '/rollen', permission: 'roles.read' }, { label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' },
{ label: 'Audit-Log', path: '/audit-log', permission: 'audit.read' }, { label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' },
]; ];
constructor() { constructor() {
this.auth.loadMe(); this.auth.loadMe();
effect(() => {
if (this.auth.user()) {
this.notifications.startPolling();
} else {
this.notifications.stopPolling();
this.notificationPanelOpen.set(false);
}
});
} }
visible(item: NavItem): boolean { visible(item: NavItem): boolean {
return !item.permission || this.auth.has(item.permission); 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);
}
} }

View File

@@ -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<HTMLButtonElement>('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');
});
});

View File

@@ -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: `
<section class="panel" aria-label="Benachrichtigungen">
<header>
<strong>Benachrichtigungen</strong>
<button type="button" (click)="closed.emit()" aria-label="Benachrichtigungen schliessen">
Schliessen
</button>
</header>
<div class="panel-actions">
<button type="button" (click)="store.markAllAsRead()">Alle gelesen</button>
<a routerLink="/notifications" (click)="closed.emit()">Alle anzeigen</a>
</div>
@if (store.loading()) {
<p class="state">Wird geladen.</p>
} @else if (store.error(); as error) {
<p class="state error">
{{ error.message }}
@if (error.requestId) {
<small>Request-ID: {{ error.requestId }}</small>
}
</p>
} @else if (store.notifications().length === 0) {
<p class="state">Keine Benachrichtigungen vorhanden.</p>
} @else {
<div class="items">
@for (notification of store.notifications().slice(0, 5); track notification.id) {
<article [class.unread]="!notification.read">
<div>
<strong>{{ notification.title }}</strong>
<time>{{ notification.createdAt | date: 'short' }}</time>
</div>
<p>{{ notification.message }}</p>
<div class="item-actions">
@if (store.isInternalLink(notification.link)) {
<button type="button" (click)="open(notification.id)">Oeffnen</button>
}
@if (notification.read) {
<button type="button" (click)="store.markAsUnread(notification.id)">
Ungelesen
</button>
} @else {
<button type="button" (click)="store.markAsRead(notification.id)">Gelesen</button>
}
<button type="button" class="danger" (click)="store.delete(notification.id)">
Loeschen
</button>
</div>
</article>
}
</div>
}
</section>
`,
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<void>();
open(id: string): void {
const notification = this.store.notifications().find((item) => item.id === id);
if (notification) {
this.store.openNotification(notification);
this.closed.emit();
}
}
}

View File

@@ -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: `
<button
class="ui-button"
[class.ui-button--primary]="variant === 'primary'"
[class.ui-button--secondary]="variant === 'secondary'"
[class.ui-button--ghost]="variant === 'ghost'"
[class.ui-button--danger]="variant === 'danger'"
[class.ui-button--small]="size === 'small'"
[class.ui-button--mobile-full]="mobileFull"
[type]="type"
[disabled]="disabled || loading"
[attr.aria-busy]="loading"
>
@if (loading) {
<span class="ui-button__spinner" aria-hidden="true"></span>
}
<span>{{ label }}</span>
</button>
`,
})
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;
}

View File

@@ -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()) {
<div class="ui-dialog-backdrop" aria-hidden="true"></div>
<section
#dialog
class="ui-dialog"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="titleId"
[attr.aria-describedby]="descriptionId"
tabindex="-1"
(keydown)="trapFocus($event)"
>
<header class="stack">
<h2 [id]="titleId">{{ title() }}</h2>
<p [id]="descriptionId" class="ui-help-text">{{ description() }}</p>
</header>
<div class="ui-actions">
<button class="ui-button ui-button--ghost" type="button" (click)="close(false)">
{{ cancelLabel() }}
</button>
<button
class="ui-button"
[class.ui-button--danger]="danger()"
[class.ui-button--primary]="!danger()"
type="button"
(click)="close(true)"
>
{{ confirmLabel() }}
</button>
</div>
</section>
}
`,
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<ElementRef<HTMLElement>>('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<boolean> {
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<boolean>((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<HTMLElement>(
'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();
}
}
}

View File

@@ -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: `
<section class="ui-empty-state ui-card">
@if (iconName(); as currentIcon) {
<ui-icon [name]="currentIcon" />
}
<div class="stack">
<h2 class="ui-section-title">{{ title }}</h2>
@if (description) {
<p class="ui-help-text">{{ description }}</p>
}
</div>
@if (actionLabel) {
<button
class="ui-button ui-button--primary ui-button--mobile-full"
type="button"
(click)="action.emit()"
>
{{ actionLabel }}
</button>
}
</section>
`,
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<void>();
iconName(): UiIconName | null {
return this.icon === '' ? null : this.icon;
}
}

View File

@@ -0,0 +1,32 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-form-field',
standalone: true,
template: `
<label class="ui-form-field">
<span class="ui-label">{{ label }}</span>
<ng-content />
@if (hint) {
<span class="ui-help-text" [id]="hintId()">{{ hint }}</span>
}
@if (error) {
<span class="ui-field-error" [id]="errorId()" role="alert">{{ error }}</span>
}
</label>
`,
})
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`;
}
}

View File

@@ -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: `
<button
class="ui-icon-button"
[class.danger]="variant === 'danger'"
[class.ghost]="variant === 'ghost'"
type="button"
[attr.aria-label]="label"
[title]="label"
[disabled]="disabled"
(click)="pressed.emit()"
>
<ui-icon [name]="icon" />
</button>
`,
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<void>();
}

View File

@@ -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<UiIconName, string> = {
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: `
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
focusable="false"
[attr.aria-hidden]="decorative"
[attr.aria-label]="decorative ? null : label"
>
<path [attr.d]="path()" />
</svg>
`,
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];
}
}

View File

@@ -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';

View File

@@ -0,0 +1,31 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-loading-state',
standalone: true,
template: `
<section class="ui-loading-state" [class.inline]="inline" aria-live="polite">
<span class="ui-spinner" aria-hidden="true"></span>
<span>{{ label }}</span>
</section>
`,
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;
}

View File

@@ -0,0 +1,27 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'ui-page-header',
standalone: true,
template: `
<header class="ui-page-header">
<div class="ui-page-header__content">
@if (breadcrumbs) {
<nav class="ui-meta" aria-label="Breadcrumb">{{ breadcrumbs }}</nav>
}
<h1>{{ title }}</h1>
@if (description) {
<p class="ui-help-text">{{ description }}</p>
}
</div>
<div class="ui-page-header__actions">
<ng-content />
</div>
</header>
`,
})
export class UiPageHeaderComponent {
@Input() title = '';
@Input() description = '';
@Input() breadcrumbs = '';
}

View File

@@ -0,0 +1,54 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'ui-pagination',
standalone: true,
template: `
<nav class="ui-pagination" aria-label="Seitennavigation">
<button
class="ui-button ui-button--ghost"
type="button"
[disabled]="page <= 1"
(click)="go(page - 1)"
>
Zurueck
</button>
<span aria-live="polite">Seite {{ page }} von {{ totalPages() }}</span>
<button
class="ui-button ui-button--ghost"
type="button"
[disabled]="page >= totalPages()"
(click)="go(page + 1)"
>
Weiter
</button>
</nav>
`,
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<number>();
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);
}
}
}

View File

@@ -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: `
<span class="ui-badge" [class]="toneClass()">
<span aria-hidden="true">{{ marker() }}</span>
<span>{{ label }}</span>
</span>
`,
})
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 '•';
}
}

View File

@@ -0,0 +1,79 @@
import { Component, inject } from '@angular/core';
import { ToastService } from './toast.service';
@Component({
selector: 'ui-toast-host',
standalone: true,
template: `
<section class="ui-toast-region" aria-live="polite" aria-label="Meldungen">
@for (toast of toasts.messages(); track toast.id) {
<article class="ui-toast" [class]="toast.tone">
<div>
<strong>{{ toast.title }}</strong>
@if (toast.message) {
<p>{{ toast.message }}</p>
}
@if (toast.requestId) {
<small>Request-ID: {{ toast.requestId }}</small>
}
</div>
<button
type="button"
class="ui-icon-button"
aria-label="Meldung schliessen"
(click)="toasts.close(toast.id)"
>
x
</button>
</article>
}
</section>
`,
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);
}

View File

@@ -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<ToastMessage[]>([]);
show(message: Omit<ToastMessage, 'id'>): 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));
}
}

View File

@@ -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: `
<button id="trigger" type="button">Trigger</button>
<ui-button label="Speichern" [loading]="true" />
<ui-icon-button icon="delete" label="Eintrag loeschen" />
<ui-form-field label="Name" error="Name ist erforderlich">
<input class="ui-control" aria-invalid="true" />
</ui-form-field>
<ui-status-badge label="Deaktiviert" tone="danger" />
<ui-empty-state title="Keine Daten" actionLabel="Neu" (action)="emptyActionCount += 1" />
<ui-pagination [page]="2" [pageSize]="10" [total]="35" (pageChange)="page = $event" />
<ui-confirm-dialog />
<ui-toast-host />
`,
})
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<HTMLButtonElement>(
'#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<HTMLButtonElement>('ui-empty-state button')?.click();
element.querySelectorAll<HTMLButtonElement>('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);
});
});

View File

@@ -1,40 +1,9 @@
* { @use './styles/tokens';
box-sizing: border-box; @use './styles/reset';
} @use './styles/typography';
@use './styles/layout';
html { @use './styles/forms';
font-family: @use './styles/buttons';
Inter, @use './styles/tables';
ui-sans-serif, @use './styles/utilities';
system-ui, @use './styles/animations';
-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;
}

View File

@@ -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);
}
}

View File

@@ -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%;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More