bump
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
50
apps/backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { DataSource, Repository } from 'typeorm';
|
||||||
|
import type { ExternalHttpClient } from '../common/http/external-http-client';
|
||||||
|
import type { AppConfigService } from '../config/config.service';
|
||||||
|
import type { RolesService } from '../roles/roles.service';
|
||||||
|
import type { SessionsService } from '../sessions/sessions.service';
|
||||||
|
import type { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
|
||||||
|
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
|
||||||
|
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>
|
||||||
|
Promise.resolve('id-token'),
|
||||||
|
);
|
||||||
|
const service = new AuthService(
|
||||||
|
{
|
||||||
|
frontendBaseUrl: 'https://app.example.test',
|
||||||
|
appBaseUrl: 'https://app.example.test',
|
||||||
|
oidc: {
|
||||||
|
issuer: 'https://idp.example.test',
|
||||||
|
clientId: 'business-app',
|
||||||
|
clientSecret: 'secret',
|
||||||
|
scopes: 'openid profile email',
|
||||||
|
allowedAlgorithms: ['RS256'],
|
||||||
|
httpTimeoutMs: 5000,
|
||||||
|
logoutUrl: 'https://idp.example.test/logout',
|
||||||
|
},
|
||||||
|
} as AppConfigService,
|
||||||
|
{} as ExternalHttpClient,
|
||||||
|
{} as RolesService,
|
||||||
|
{} as UsersRepository,
|
||||||
|
{ revoke, getIdTokenForLogout } as unknown as SessionsService,
|
||||||
|
{} as DataSource,
|
||||||
|
{} as Repository<OidcLoginStateEntity>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = new URL(await service.logout('session-1'));
|
||||||
|
|
||||||
|
expect(revoke).toHaveBeenCalledWith('session-1');
|
||||||
|
expect(getIdTokenForLogout).toHaveBeenCalledWith('session-1');
|
||||||
|
expect(url.origin + url.pathname).toBe('https://idp.example.test/logout');
|
||||||
|
expect(url.searchParams.get('client_id')).toBe('business-app');
|
||||||
|
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||||
|
'https://app.example.test',
|
||||||
|
);
|
||||||
|
expect(url.searchParams.get('id_token_hint')).toBe('id-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,10 +111,13 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(sessionId: string | undefined): Promise<void> {
|
async logout(sessionId: string | undefined): Promise<string> {
|
||||||
|
let idToken: string | undefined;
|
||||||
if (sessionId) {
|
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,
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddNotifications1720000001000 implements MigrationInterface {
|
||||||
|
name = 'AddNotifications1720000001000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id char(36) NOT NULL,
|
||||||
|
user_id char(36) NOT NULL,
|
||||||
|
type varchar(80) NOT NULL,
|
||||||
|
title varchar(150) NOT NULL,
|
||||||
|
message varchar(1000) NOT NULL,
|
||||||
|
link varchar(500) NULL,
|
||||||
|
metadata json NULL,
|
||||||
|
read_at datetime(3) NULL,
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
deleted_at datetime(3) NULL,
|
||||||
|
KEY idx_notifications_user_created_at (user_id, created_at),
|
||||||
|
KEY idx_notifications_user_read_at (user_id, read_at),
|
||||||
|
KEY idx_notifications_user_deleted_at (user_id, deleted_at),
|
||||||
|
CONSTRAINT fk_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddRoleDescription1720000002000 implements MigrationInterface {
|
||||||
|
name = 'AddRoleDescription1720000002000';
|
||||||
|
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE roles
|
||||||
|
ADD description varchar(255) NOT NULL DEFAULT ''
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('ALTER TABLE roles DROP COLUMN description');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { DataSource } from 'typeorm';
|
|||||||
import { loadConfigForCli } from '../config/env';
|
import { 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,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
78
apps/backend/src/notifications/dto/notification.dto.ts
Normal file
78
apps/backend/src/notifications/dto/notification.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Length,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { allNotificationTypes } from '../notification-types';
|
||||||
|
import type { NotificationMetadata } from '../entities/notification.entity';
|
||||||
|
|
||||||
|
export type NotificationStatusFilter = 'all' | 'read' | 'unread';
|
||||||
|
|
||||||
|
export class NotificationListQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 20;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['all', 'read', 'unread'])
|
||||||
|
status: NotificationStatusFilter = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateNotificationDto {
|
||||||
|
@IsUUID()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(allNotificationTypes)
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 150)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 1000)
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 500)
|
||||||
|
link?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: NotificationMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationDto {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link: string | null;
|
||||||
|
metadata: NotificationMetadata | null;
|
||||||
|
read: boolean;
|
||||||
|
readAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPageDto {
|
||||||
|
items: NotificationDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UserEntity } from '../../users/entities/user.entity';
|
||||||
|
import type { NotificationType } from '../notification-types';
|
||||||
|
|
||||||
|
export type NotificationMetadata = Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | null
|
||||||
|
>;
|
||||||
|
|
||||||
|
@Entity('notifications')
|
||||||
|
@Index('idx_notifications_user_created_at', ['userId', 'createdAt'])
|
||||||
|
@Index('idx_notifications_user_read_at', ['userId', 'readAt'])
|
||||||
|
@Index('idx_notifications_user_deleted_at', ['userId', 'deletedAt'])
|
||||||
|
export class NotificationEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user!: UserEntity;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'char', length: 36 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
type!: NotificationType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 150 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 1000 })
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||||
|
link!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
metadata!: NotificationMetadata | null;
|
||||||
|
|
||||||
|
@Column({ name: 'read_at', type: 'datetime', precision: 3, nullable: true })
|
||||||
|
readAt!: Date | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'datetime', precision: 3 })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({
|
||||||
|
name: 'deleted_at',
|
||||||
|
type: 'datetime',
|
||||||
|
precision: 3,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
deletedAt!: Date | null;
|
||||||
|
}
|
||||||
15
apps/backend/src/notifications/notification-types.ts
Normal file
15
apps/backend/src/notifications/notification-types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export const NotificationType = {
|
||||||
|
System: 'system',
|
||||||
|
ItemCreated: 'item.created',
|
||||||
|
ItemUpdated: 'item.updated',
|
||||||
|
UserRoleChanged: 'user.role-changed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
(typeof NotificationType)[keyof typeof NotificationType];
|
||||||
|
|
||||||
|
export const allNotificationTypes = Object.values(NotificationType);
|
||||||
|
|
||||||
|
export function isNotificationType(value: string): value is NotificationType {
|
||||||
|
return allNotificationTypes.includes(value as NotificationType);
|
||||||
|
}
|
||||||
95
apps/backend/src/notifications/notifications.controller.ts
Normal file
95
apps/backend/src/notifications/notifications.controller.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { Permission } from '../roles/permissions';
|
||||||
|
import {
|
||||||
|
CreateNotificationDto,
|
||||||
|
NotificationListQueryDto,
|
||||||
|
} from './dto/notification.dto';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(private readonly notifications: NotificationsService) {}
|
||||||
|
|
||||||
|
@Get('notifications')
|
||||||
|
@RequirePermissions(Permission.NotificationsReadOwn)
|
||||||
|
list(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Query() query: NotificationListQueryDto,
|
||||||
|
) {
|
||||||
|
return this.notifications.getForCurrentUser(
|
||||||
|
this.requireUser(req).id,
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('notifications/unread-count')
|
||||||
|
@RequirePermissions(Permission.NotificationsReadOwn)
|
||||||
|
async unreadCount(@Req() req: AuthenticatedRequest) {
|
||||||
|
return {
|
||||||
|
count: await this.notifications.getUnreadCount(this.requireUser(req).id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('notifications/:id/read')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
markAsRead(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.notifications.markAsRead(this.requireUser(req).id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('notifications/:id/unread')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
markAsUnread(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.notifications.markAsUnread(this.requireUser(req).id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('notifications/read-all')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
markAllAsRead(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.notifications.markAllAsRead(this.requireUser(req).id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('notifications/:id')
|
||||||
|
@RequirePermissions(Permission.NotificationsUpdateOwn)
|
||||||
|
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.notifications.softDelete(this.requireUser(req).id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/notifications')
|
||||||
|
@RequirePermissions(Permission.NotificationsManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
createAdmin(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() dto: CreateNotificationDto,
|
||||||
|
) {
|
||||||
|
return this.notifications.createAdminNotification(
|
||||||
|
this.requireUser(req).id,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireUser(req: AuthenticatedRequest) {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/backend/src/notifications/notifications.module.ts
Normal file
20
apps/backend/src/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import { NotificationEntity } from './entities/notification.entity';
|
||||||
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { NotificationsRepository } from './repositories/notifications.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([NotificationEntity, UserEntity]),
|
||||||
|
AuditModule,
|
||||||
|
],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [NotificationsRepository, NotificationsService, UsersRepository],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
287
apps/backend/src/notifications/notifications.service.ts
Normal file
287
apps/backend/src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../audit/entities/audit-log.entity';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { UserEntity } from '../users/entities/user.entity';
|
||||||
|
import { UsersRepository } from '../users/repositories/users.repository';
|
||||||
|
import {
|
||||||
|
NotificationEntity,
|
||||||
|
type NotificationMetadata,
|
||||||
|
} from './entities/notification.entity';
|
||||||
|
import {
|
||||||
|
type NotificationDto,
|
||||||
|
type NotificationListQueryDto,
|
||||||
|
type NotificationPageDto,
|
||||||
|
type NotificationStatusFilter,
|
||||||
|
} from './dto/notification.dto';
|
||||||
|
import {
|
||||||
|
isNotificationType,
|
||||||
|
type NotificationType,
|
||||||
|
} from './notification-types';
|
||||||
|
import { NotificationsRepository } from './repositories/notifications.repository';
|
||||||
|
|
||||||
|
export interface CreateNotificationInput {
|
||||||
|
userId: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link?: string | null;
|
||||||
|
metadata?: NotificationMetadata | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBulkCreate = 100;
|
||||||
|
const maxMetadataBytes = 4096;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsRepository,
|
||||||
|
private readonly users: UsersRepository,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createForUser(
|
||||||
|
input: CreateNotificationInput,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<NotificationEntity> {
|
||||||
|
const user = await this.findActiveUser(input.userId, manager);
|
||||||
|
const notification = this.buildNotification(user.id, input);
|
||||||
|
return this.notifications.save(notification, manager);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createForUsers(
|
||||||
|
userIds: string[],
|
||||||
|
input: Omit<CreateNotificationInput, 'userId'>,
|
||||||
|
): Promise<NotificationEntity[]> {
|
||||||
|
const uniqueUserIds = Array.from(new Set(userIds));
|
||||||
|
if (uniqueUserIds.length > maxBulkCreate) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ count: uniqueUserIds.length },
|
||||||
|
'Large notification bulk create rejected',
|
||||||
|
);
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.ValidationFailed,
|
||||||
|
'Zu viele Zielbenutzer fuer eine Benachrichtigung.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const notifications: NotificationEntity[] = [];
|
||||||
|
for (const userId of uniqueUserIds) {
|
||||||
|
const user = await this.findActiveUser(userId, manager);
|
||||||
|
notifications.push(
|
||||||
|
this.buildNotification(user.id, { ...input, userId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.notifications.saveMany(notifications, manager);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getForCurrentUser(
|
||||||
|
userId: string,
|
||||||
|
query: NotificationListQueryDto,
|
||||||
|
): Promise<NotificationPageDto> {
|
||||||
|
const page = query.page;
|
||||||
|
const pageSize = Math.min(query.pageSize, 100);
|
||||||
|
const status: NotificationStatusFilter = query.status ?? 'all';
|
||||||
|
const [items, total] = await this.notifications.listForUser(
|
||||||
|
userId,
|
||||||
|
status,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
|
const unreadCount = await this.getUnreadCount(userId);
|
||||||
|
return {
|
||||||
|
items: items.map((item) => this.toDto(item)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
unreadCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: string): Promise<number> {
|
||||||
|
return this.notifications.countUnreadForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(userId: string, id: string): Promise<NotificationDto> {
|
||||||
|
const notification = await this.getOwnedNotification(userId, id);
|
||||||
|
notification.readAt ??= new Date();
|
||||||
|
return this.toDto(await this.notifications.save(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsUnread(userId: string, id: string): Promise<NotificationDto> {
|
||||||
|
const notification = await this.getOwnedNotification(userId, id);
|
||||||
|
notification.readAt = null;
|
||||||
|
return this.toDto(await this.notifications.save(notification));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(userId: string): Promise<{ updated: number }> {
|
||||||
|
return { updated: await this.notifications.markAllAsRead(userId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(userId: string, id: string): Promise<void> {
|
||||||
|
const notification = await this.getOwnedNotification(userId, id);
|
||||||
|
await this.notifications.softDelete(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdminNotification(
|
||||||
|
actorUserId: string,
|
||||||
|
input: CreateNotificationInput,
|
||||||
|
): Promise<NotificationDto> {
|
||||||
|
const notification = await this.createForUser(input);
|
||||||
|
await this.audit.record(
|
||||||
|
actorUserId,
|
||||||
|
AuditAction.NotificationCreated,
|
||||||
|
'notification',
|
||||||
|
notification.id,
|
||||||
|
{
|
||||||
|
targetUserId: input.userId,
|
||||||
|
notificationId: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this.toDto(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOwnedNotification(
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<NotificationEntity> {
|
||||||
|
const notification = await this.notifications.findActiveForUser(id, userId);
|
||||||
|
if (!notification) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationNotFound,
|
||||||
|
'Die Benachrichtigung wurde nicht gefunden.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findActiveUser(
|
||||||
|
userId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<UserEntity> {
|
||||||
|
const user = await this.users.findById(userId, manager);
|
||||||
|
if (!user || !user.active) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.UserNotFound,
|
||||||
|
'Der Zielbenutzer wurde nicht gefunden.',
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNotification(
|
||||||
|
userId: string,
|
||||||
|
input: CreateNotificationInput,
|
||||||
|
): NotificationEntity {
|
||||||
|
const type = this.normalizeType(input.type);
|
||||||
|
const title = this.normalizePlainText(input.title, 150);
|
||||||
|
const message = this.normalizePlainText(input.message, 1000);
|
||||||
|
const link = this.normalizeLink(input.link ?? null);
|
||||||
|
const metadata = this.normalizeMetadata(input.metadata ?? null);
|
||||||
|
|
||||||
|
const notification = new NotificationEntity();
|
||||||
|
notification.userId = userId;
|
||||||
|
notification.type = type;
|
||||||
|
notification.title = title;
|
||||||
|
notification.message = message;
|
||||||
|
notification.link = link;
|
||||||
|
notification.metadata = metadata;
|
||||||
|
notification.readAt = null;
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeType(value: string): NotificationType {
|
||||||
|
if (!isNotificationType(value)) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationTypeInvalid,
|
||||||
|
'Der Benachrichtigungstyp ist ungueltig.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePlainText(value: string, maxLength: number): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (
|
||||||
|
trimmed.length < 1 ||
|
||||||
|
trimmed.length > maxLength ||
|
||||||
|
/<[^>]*>|[<>]/.test(trimmed)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.ValidationFailed,
|
||||||
|
'Benachrichtigungen duerfen nur Plain Text enthalten.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLink(value: string | null): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (
|
||||||
|
trimmed.length > 500 ||
|
||||||
|
!trimmed.startsWith('/') ||
|
||||||
|
trimmed.startsWith('//') ||
|
||||||
|
trimmed.includes('\\') ||
|
||||||
|
lower.includes('javascript:') ||
|
||||||
|
/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationLinkInvalid,
|
||||||
|
'Der Link muss eine interne Route sein.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMetadata(
|
||||||
|
value: NotificationMetadata | null,
|
||||||
|
): NotificationMetadata | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
if (
|
||||||
|
!serialized ||
|
||||||
|
Buffer.byteLength(serialized, 'utf8') > maxMetadataBytes
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.NotificationMetadataTooLarge,
|
||||||
|
'Die Metadaten sind zu gross.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(notification: NotificationEntity): NotificationDto {
|
||||||
|
return {
|
||||||
|
id: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
link: notification.link,
|
||||||
|
metadata: notification.metadata,
|
||||||
|
read: notification.readAt !== null,
|
||||||
|
readAt: notification.readAt?.toISOString() ?? null,
|
||||||
|
createdAt: notification.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { EntityManager, IsNull, Repository } from 'typeorm';
|
||||||
|
import { NotificationEntity } from '../entities/notification.entity';
|
||||||
|
import type { NotificationStatusFilter } from '../dto/notification.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(NotificationEntity)
|
||||||
|
private readonly repo: Repository<NotificationEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listForUser(
|
||||||
|
userId: string,
|
||||||
|
status: NotificationStatusFilter,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<[NotificationEntity[], number]> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('notification')
|
||||||
|
.where('notification.userId = :userId', { userId })
|
||||||
|
.andWhere('notification.deletedAt IS NULL');
|
||||||
|
if (status === 'read') {
|
||||||
|
qb.andWhere('notification.readAt IS NOT NULL');
|
||||||
|
}
|
||||||
|
if (status === 'unread') {
|
||||||
|
qb.andWhere('notification.readAt IS NULL');
|
||||||
|
}
|
||||||
|
return qb
|
||||||
|
.orderBy('notification.createdAt', 'DESC')
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
countUnreadForUser(userId: string): Promise<number> {
|
||||||
|
return this.repo.count({
|
||||||
|
where: { userId, readAt: IsNull(), deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findActiveForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<NotificationEntity | null> {
|
||||||
|
return this.repo.findOne({ where: { id, userId, deletedAt: IsNull() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
save(
|
||||||
|
notification: NotificationEntity,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<NotificationEntity> {
|
||||||
|
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
|
||||||
|
notification,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMany(
|
||||||
|
notifications: NotificationEntity[],
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<NotificationEntity[]> {
|
||||||
|
return (manager?.getRepository(NotificationEntity) ?? this.repo).save(
|
||||||
|
notifications,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsRead(userId: string, readAt = new Date()): Promise<number> {
|
||||||
|
const result = await this.repo.update(
|
||||||
|
{ userId, readAt: IsNull(), deletedAt: IsNull() },
|
||||||
|
{ readAt },
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async softDelete(notification: NotificationEntity): Promise<void> {
|
||||||
|
await this.repo.softRemove(notification);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AuditService } from '../../audit/audit.service';
|
||||||
|
import type { AuthenticatedUser } from '../../auth/authenticated-request';
|
||||||
|
import { ItemStatus } from '../../items/entities/item.entity';
|
||||||
|
import { ItemsService } from '../../items/items.service';
|
||||||
|
import { NotificationType } from '../notification-types';
|
||||||
|
import { UsersService } from '../../users/users.service';
|
||||||
|
import type { ItemsRepository } from '../../items/repositories/items.repository';
|
||||||
|
import type { NotificationsService } from '../notifications.service';
|
||||||
|
import type { RolesService } from '../../roles/roles.service';
|
||||||
|
import type { SessionsService } from '../../sessions/sessions.service';
|
||||||
|
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||||
|
import type { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
describe('Notification integrations', () => {
|
||||||
|
it('creates an item.created notification for the first active admin', async () => {
|
||||||
|
const created: unknown[] = [];
|
||||||
|
const items: Pick<ItemsRepository, 'save'> = {
|
||||||
|
save: (item) => {
|
||||||
|
item.id = 'item-1';
|
||||||
|
item.createdAt = new Date();
|
||||||
|
item.updatedAt = new Date();
|
||||||
|
item.deletedAt = null;
|
||||||
|
return Promise.resolve(item);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const notifications: Pick<NotificationsService, 'createForUser'> = {
|
||||||
|
createForUser: (input) => {
|
||||||
|
created.push(input);
|
||||||
|
return Promise.resolve(
|
||||||
|
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const users: Pick<UsersRepository, 'findFirstActiveAdmin'> = {
|
||||||
|
findFirstActiveAdmin: () =>
|
||||||
|
Promise.resolve({ id: 'admin-1' } as Awaited<
|
||||||
|
ReturnType<UsersRepository['findFirstActiveAdmin']>
|
||||||
|
>),
|
||||||
|
};
|
||||||
|
const service = new ItemsService(
|
||||||
|
items as ItemsRepository,
|
||||||
|
notifications as NotificationsService,
|
||||||
|
users as UsersRepository,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{ name: 'Beispiel', status: ItemStatus.Active },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(created).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'admin-1',
|
||||||
|
type: NotificationType.ItemCreated,
|
||||||
|
link: '/items/item-1',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a user.role-changed notification after role changes', async () => {
|
||||||
|
const created: unknown[] = [];
|
||||||
|
const user = {
|
||||||
|
id: 'user-1',
|
||||||
|
active: true,
|
||||||
|
roles: [{ id: 'role-user', name: 'user' }],
|
||||||
|
};
|
||||||
|
const users: Pick<UsersRepository, 'findByIdWithRoles' | 'save'> = {
|
||||||
|
findByIdWithRoles: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
user as Awaited<ReturnType<UsersRepository['findByIdWithRoles']>>,
|
||||||
|
),
|
||||||
|
save: (entry) => Promise.resolve(entry),
|
||||||
|
};
|
||||||
|
const roles: Pick<RolesService, 'getRole'> = {
|
||||||
|
getRole: (id) =>
|
||||||
|
Promise.resolve({
|
||||||
|
id,
|
||||||
|
name: id === 'role-editor' ? 'Editor' : 'user',
|
||||||
|
} as Awaited<ReturnType<RolesService['getRole']>>),
|
||||||
|
};
|
||||||
|
const notifications: Pick<NotificationsService, 'createForUser'> = {
|
||||||
|
createForUser: (input) => {
|
||||||
|
created.push(input);
|
||||||
|
return Promise.resolve(
|
||||||
|
{} as Awaited<ReturnType<NotificationsService['createForUser']>>,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const manager = {
|
||||||
|
query: () => Promise.resolve(),
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
const dataSource = {
|
||||||
|
transaction: <T>(action: (manager: EntityManager) => Promise<T>) =>
|
||||||
|
action(manager),
|
||||||
|
} as DataSource;
|
||||||
|
const service = new UsersService(
|
||||||
|
users as UsersRepository,
|
||||||
|
roles as RolesService,
|
||||||
|
{} as SessionsService,
|
||||||
|
{ record: () => Promise.resolve() } as unknown as AuditService,
|
||||||
|
notifications as NotificationsService,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.setRoles(
|
||||||
|
{
|
||||||
|
id: 'admin-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
permissions: [],
|
||||||
|
} satisfies AuthenticatedUser,
|
||||||
|
'user-1',
|
||||||
|
['role-user', 'role-editor'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(created).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 'user-1',
|
||||||
|
type: NotificationType.UserRoleChanged,
|
||||||
|
message: 'Ihnen wurde die Rolle "Editor" zugewiesen.',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AuditService } from '../../audit/audit.service';
|
||||||
|
import { PermissionsGuard } from '../../auth/guards/permissions.guard';
|
||||||
|
import { ErrorCode } from '../../common/errors/error-codes';
|
||||||
|
import { NotificationEntity } from '../entities/notification.entity';
|
||||||
|
import { NotificationType } from '../notification-types';
|
||||||
|
import { NotificationsController } from '../notifications.controller';
|
||||||
|
import { NotificationsService } from '../notifications.service';
|
||||||
|
import type { AppConfigService } from '../../config/config.service';
|
||||||
|
import type { SessionsService } from '../../sessions/sessions.service';
|
||||||
|
import type { UsersRepository } from '../../users/repositories/users.repository';
|
||||||
|
import type { NotificationsRepository } from '../repositories/notifications.repository';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
const now = new Date('2026-07-16T08:00:00.000Z');
|
||||||
|
|
||||||
|
function notification(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
readAt: Date | null = null,
|
||||||
|
): NotificationEntity {
|
||||||
|
const entity = new NotificationEntity();
|
||||||
|
entity.id = id;
|
||||||
|
entity.userId = userId;
|
||||||
|
entity.type = NotificationType.System;
|
||||||
|
entity.title = 'Titel';
|
||||||
|
entity.message = 'Nachricht';
|
||||||
|
entity.link = '/';
|
||||||
|
entity.metadata = null;
|
||||||
|
entity.readAt = readAt;
|
||||||
|
entity.createdAt = now;
|
||||||
|
entity.deletedAt = null;
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceWithStore(store: NotificationEntity[]) {
|
||||||
|
const repo: Pick<
|
||||||
|
NotificationsRepository,
|
||||||
|
| 'listForUser'
|
||||||
|
| 'countUnreadForUser'
|
||||||
|
| 'findActiveForUser'
|
||||||
|
| 'save'
|
||||||
|
| 'saveMany'
|
||||||
|
| 'markAllAsRead'
|
||||||
|
| 'softDelete'
|
||||||
|
> = {
|
||||||
|
listForUser: (userId, status, page, pageSize) => {
|
||||||
|
const filtered = store.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.deletedAt === null &&
|
||||||
|
(status === 'all' ||
|
||||||
|
(status === 'read' && entry.readAt !== null) ||
|
||||||
|
(status === 'unread' && entry.readAt === null)),
|
||||||
|
);
|
||||||
|
return Promise.resolve([
|
||||||
|
filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||||
|
filtered.length,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
countUnreadForUser: (userId) =>
|
||||||
|
Promise.resolve(
|
||||||
|
store.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.readAt === null &&
|
||||||
|
entry.deletedAt === null,
|
||||||
|
).length,
|
||||||
|
),
|
||||||
|
findActiveForUser: (id, userId) =>
|
||||||
|
Promise.resolve(
|
||||||
|
store.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.id === id &&
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.deletedAt === null,
|
||||||
|
) ?? null,
|
||||||
|
),
|
||||||
|
save: (entry) => {
|
||||||
|
entry.createdAt ??= now;
|
||||||
|
const index = store.findIndex((candidate) => candidate.id === entry.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
store[index] = entry;
|
||||||
|
} else {
|
||||||
|
entry.id = entry.id || `notification-${store.length + 1}`;
|
||||||
|
store.push(entry);
|
||||||
|
}
|
||||||
|
return Promise.resolve(entry);
|
||||||
|
},
|
||||||
|
saveMany: (entries) => Promise.resolve(entries),
|
||||||
|
markAllAsRead: (userId) => {
|
||||||
|
let updated = 0;
|
||||||
|
for (const entry of store) {
|
||||||
|
if (
|
||||||
|
entry.userId === userId &&
|
||||||
|
entry.readAt === null &&
|
||||||
|
entry.deletedAt === null
|
||||||
|
) {
|
||||||
|
entry.readAt = now;
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(updated);
|
||||||
|
},
|
||||||
|
softDelete: (entry) => {
|
||||||
|
entry.deletedAt = now;
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const users: Pick<UsersRepository, 'findById'> = {
|
||||||
|
findById: (id) =>
|
||||||
|
Promise.resolve({
|
||||||
|
id,
|
||||||
|
active: id !== 'disabled-user',
|
||||||
|
} as Awaited<ReturnType<UsersRepository['findById']>>),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
transaction: async <T>(callback: (manager: never) => Promise<T>) =>
|
||||||
|
callback(undefined as never),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
const service = new NotificationsService(
|
||||||
|
repo as NotificationsRepository,
|
||||||
|
users as UsersRepository,
|
||||||
|
{} as AuditService,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
return { service, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotificationsService', () => {
|
||||||
|
it('returns only current user notifications with pagination and status filter', async () => {
|
||||||
|
const { service } = serviceWithStore([
|
||||||
|
notification('own-unread', 'user-1'),
|
||||||
|
notification('own-read', 'user-1', now),
|
||||||
|
notification('other', 'user-2'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getForCurrentUser('user-1', {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
status: 'unread',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items.map((entry) => entry.id)).toEqual(['own-unread']);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.unreadCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reveal or mutate foreign notification ids', async () => {
|
||||||
|
const { service } = serviceWithStore([notification('foreign', 'user-2')]);
|
||||||
|
|
||||||
|
await expect(service.markAsRead('user-1', 'foreign')).rejects.toMatchObject(
|
||||||
|
{
|
||||||
|
code: ErrorCode.NotificationNotFound,
|
||||||
|
status: 404,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks read and unread idempotently', async () => {
|
||||||
|
const { service } = serviceWithStore([notification('own', 'user-1')]);
|
||||||
|
|
||||||
|
await service.markAsRead('user-1', 'own');
|
||||||
|
const readAgain = await service.markAsRead('user-1', 'own');
|
||||||
|
expect(readAgain.read).toBe(true);
|
||||||
|
|
||||||
|
await service.markAsUnread('user-1', 'own');
|
||||||
|
const unreadAgain = await service.markAsUnread('user-1', 'own');
|
||||||
|
expect(unreadAgain.read).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all unread notifications only for the current user', async () => {
|
||||||
|
const { service, store } = serviceWithStore([
|
||||||
|
notification('own', 'user-1'),
|
||||||
|
notification('other', 'user-2'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.markAllAsRead('user-1')).resolves.toEqual({
|
||||||
|
updated: 1,
|
||||||
|
});
|
||||||
|
expect(store.find((entry) => entry.id === 'own')?.readAt).toBe(now);
|
||||||
|
expect(store.find((entry) => entry.id === 'other')?.readAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft deletes notifications from normal queries', async () => {
|
||||||
|
const { service } = serviceWithStore([notification('own', 'user-1')]);
|
||||||
|
|
||||||
|
await service.softDelete('user-1', 'own');
|
||||||
|
const result = await service.getForCurrentUser('user-1', {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
status: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown types and unsafe links', async () => {
|
||||||
|
const { service } = serviceWithStore([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createForUser({
|
||||||
|
userId: 'user-1',
|
||||||
|
type: 'unknown',
|
||||||
|
title: 'Titel',
|
||||||
|
message: 'Nachricht',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.NotificationTypeInvalid });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createForUser({
|
||||||
|
userId: 'user-1',
|
||||||
|
type: NotificationType.System,
|
||||||
|
title: 'Titel',
|
||||||
|
message: 'Nachricht',
|
||||||
|
link: 'https://example.com',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.NotificationLinkInvalid });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects disabled target users', async () => {
|
||||||
|
const { service } = serviceWithStore([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createForUser({
|
||||||
|
userId: 'disabled-user',
|
||||||
|
type: NotificationType.System,
|
||||||
|
title: 'Titel',
|
||||||
|
message: 'Nachricht',
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.UserNotFound });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires notifications.manage for administrative creation', async () => {
|
||||||
|
const controller = new NotificationsController({} as NotificationsService);
|
||||||
|
const reflector = new Reflector();
|
||||||
|
const sessions: Pick<SessionsService, 'resolveSession'> = {
|
||||||
|
resolveSession: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
user: { id: 'user-1' },
|
||||||
|
permissions: [],
|
||||||
|
} as unknown as Awaited<ReturnType<SessionsService['resolveSession']>>),
|
||||||
|
};
|
||||||
|
const config = {
|
||||||
|
session: { cookieName: 'app_session' },
|
||||||
|
} as AppConfigService;
|
||||||
|
const guard = new PermissionsGuard(
|
||||||
|
reflector,
|
||||||
|
sessions as SessionsService,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
const context = {
|
||||||
|
getHandler: () => controller.createAdmin,
|
||||||
|
getClass: () => NotificationsController,
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({
|
||||||
|
signedCookies: { app_session: 'session-id' },
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
get: () => 'test-agent',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
await expect(guard.canActivate(context)).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.PermissionDenied,
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/backend/src/roles/admin-roles.controller.ts
Normal file
71
apps/backend/src/roles/admin-roles.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { RequirePermissions } from '../auth/guards/require-permissions.decorator';
|
||||||
|
import { ApiError } from '../common/errors/api-error';
|
||||||
|
import { ErrorCode } from '../common/errors/error-codes';
|
||||||
|
import { SensitiveRateLimit } from '../common/rate-limit/sensitive-rate-limit.decorator';
|
||||||
|
import { Permission } from './permissions';
|
||||||
|
import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto';
|
||||||
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
|
@Controller('admin/roles')
|
||||||
|
export class AdminRolesController {
|
||||||
|
constructor(private readonly roles: RolesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permission.RolesRead)
|
||||||
|
list() {
|
||||||
|
return this.roles.adminList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions(Permission.RolesRead)
|
||||||
|
get(@Param('id') id: string) {
|
||||||
|
return this.roles.adminGet(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
create(@Req() req: AuthenticatedRequest, @Body() dto: CreateRoleDto) {
|
||||||
|
return this.roles.create(dto, this.requireUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
update(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateRoleDto,
|
||||||
|
) {
|
||||||
|
return this.roles.update(id, dto, this.requireUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions(Permission.RolesManage)
|
||||||
|
@SensitiveRateLimit()
|
||||||
|
delete(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.roles.delete(id, this.requireUser(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireUser(req: AuthenticatedRequest) {
|
||||||
|
if (!req.user) {
|
||||||
|
throw new ApiError(
|
||||||
|
ErrorCode.Unauthorized,
|
||||||
|
'Bitte melden Sie sich an.',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsArray, IsEnum, IsString, Length } from 'class-validator';
|
import { IsArray, IsEnum, IsOptional, IsString, Length } from 'class-validator';
|
||||||
import { Permission } from '../permissions';
|
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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
8
apps/frontend/src/app/core/dev-only.guard.ts
Normal file
8
apps/frontend/src/app/core/dev-only.guard.ts
Normal 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();
|
||||||
3
apps/frontend/src/app/core/dev-routes.prod.ts
Normal file
3
apps/frontend/src/app/core/dev-routes.prod.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const devRoutes: Routes = [];
|
||||||
12
apps/frontend/src/app/core/dev-routes.ts
Normal file
12
apps/frontend/src/app/core/dev-routes.ts
Normal 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),
|
||||||
|
},
|
||||||
|
];
|
||||||
139
apps/frontend/src/app/core/notification.store.spec.ts
Normal file
139
apps/frontend/src/app/core/notification.store.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
213
apps/frontend/src/app/core/notification.store.ts
Normal file
213
apps/frontend/src/app/core/notification.store.ts
Normal 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: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/frontend/src/app/features/admin/admin-permissions.ts
Normal file
57
apps/frontend/src/app/features/admin/admin-permissions.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
274
apps/frontend/src/app/features/admin/admin-role-detail.page.ts
Normal file
274
apps/frontend/src/app/features/admin/admin-role-detail.page.ts
Normal 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: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
154
apps/frontend/src/app/features/admin/admin-roles.page.ts
Normal file
154
apps/frontend/src/app/features/admin/admin-roles.page.ts
Normal 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: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
368
apps/frontend/src/app/features/admin/admin-user-detail.page.ts
Normal file
368
apps/frontend/src/app/features/admin/admin-user-detail.page.ts
Normal 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: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
270
apps/frontend/src/app/features/admin/admin-users.page.ts
Normal file
270
apps/frontend/src/app/features/admin/admin-users.page.ts
Normal 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: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
207
apps/frontend/src/app/features/dev/design-system.page.ts
Normal file
207
apps/frontend/src/app/features/dev/design-system.page.ts
Normal 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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
apps/frontend/src/app/layout/notification-panel.spec.ts
Normal file
79
apps/frontend/src/app/layout/notification-panel.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
169
apps/frontend/src/app/layout/notification-panel.ts
Normal file
169
apps/frontend/src/app/layout/notification-panel.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/frontend/src/app/shared/ui/button/button.component.ts
Normal file
38
apps/frontend/src/app/shared/ui/button/button.component.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
90
apps/frontend/src/app/shared/ui/icon/icon.component.ts
Normal file
90
apps/frontend/src/app/shared/ui/icon/icon.component.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/frontend/src/app/shared/ui/index.ts
Normal file
12
apps/frontend/src/app/shared/ui/index.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '•';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
29
apps/frontend/src/app/shared/ui/toast/toast.service.ts
Normal file
29
apps/frontend/src/app/shared/ui/toast/toast.service.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
135
apps/frontend/src/app/shared/ui/ui-components.spec.ts
Normal file
135
apps/frontend/src/app/shared/ui/ui-components.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
25
apps/frontend/src/styles/_animations.scss
Normal file
25
apps/frontend/src/styles/_animations.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
apps/frontend/src/styles/_buttons.scss
Normal file
90
apps/frontend/src/styles/_buttons.scss
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/frontend/src/styles/_forms.scss
Normal file
65
apps/frontend/src/styles/_forms.scss
Normal 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);
|
||||||
|
}
|
||||||
121
apps/frontend/src/styles/_layout.scss
Normal file
121
apps/frontend/src/styles/_layout.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/frontend/src/styles/_reset.scss
Normal file
70
apps/frontend/src/styles/_reset.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/frontend/src/styles/_tables.scss
Normal file
34
apps/frontend/src/styles/_tables.scss
Normal 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
Reference in New Issue
Block a user