generated from bastian/boilerplate
Initial commit
This commit is contained in:
17
apps/frontend/.editorconfig
Normal file
17
apps/frontend/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
44
apps/frontend/.gitignore
vendored
Normal file
44
apps/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
12
apps/frontend/.prettierrc
Normal file
12
apps/frontend/.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
apps/frontend/.vscode/extensions.json
vendored
Normal file
4
apps/frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
apps/frontend/.vscode/launch.json
vendored
Normal file
20
apps/frontend/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
apps/frontend/.vscode/tasks.json
vendored
Normal file
42
apps/frontend/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
35
apps/frontend/README.md
Normal file
35
apps/frontend/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Frontend Workspace
|
||||
|
||||
Angular Frontend fuer das Boilerplate. Der Workspace wird normalerweise ueber die
|
||||
Root-Scripts gesteuert.
|
||||
|
||||
## Befehle
|
||||
|
||||
```bash
|
||||
npm --workspace apps/frontend run start
|
||||
npm --workspace apps/frontend run build
|
||||
npm --workspace apps/frontend run typecheck
|
||||
npm --workspace apps/frontend run test
|
||||
```
|
||||
|
||||
Im lokalen Start laeuft Angular auf `http://localhost:4200` und nutzt
|
||||
`proxy.conf.json`, um `/api` an das Backend auf `http://localhost:3000` zu
|
||||
proxyn.
|
||||
|
||||
## Struktur
|
||||
|
||||
- `src/app/layout/app-shell.ts`: Hauptlayout, Navigation und Login-Zustand
|
||||
- `src/app/app.routes.ts`: Routen und Permission-Daten
|
||||
- `src/app/core/auth.service.ts`: aktueller Benutzer und Permissions
|
||||
- `src/app/core/csrf.interceptor.ts`: CSRF-Header fuer schreibende Requests
|
||||
- `src/app/core/permission.guard.ts`: UI-seitige Routensperre
|
||||
- `src/app/features`: fachliche Seiten
|
||||
|
||||
## Entwicklungsregeln
|
||||
|
||||
Das Frontend nutzt keine UI-Library. Komponenten bleiben mobile-first und
|
||||
verwenden eigenes HTML und SCSS. Permissions im Frontend dienen nur Darstellung
|
||||
und Navigation; die verbindliche Autorisierung findet im Backend statt.
|
||||
|
||||
API-Zugriffe laufen ueber `@boilerplate/api-client`. Der Client ist generiert
|
||||
und wird aus dem Root mit `npm run api:generate` aktualisiert.
|
||||
83
apps/frontend/angular.json
Normal file
83
apps/frontend/angular.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm",
|
||||
"analytics": false
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.scss"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/app/core/dev-routes.ts",
|
||||
"with": "src/app/core/dev-routes.prod.ts"
|
||||
}
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
apps/frontend/package.json
Normal file
24
apps/frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@boilerplate/frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "ng serve --host 0.0.0.0 --port 4200 --proxy-config proxy.conf.json",
|
||||
"build": "ng build --configuration production",
|
||||
"typecheck": "ng build --configuration development --no-progress",
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/common": "22.0.6",
|
||||
"@angular/compiler": "22.0.6",
|
||||
"@angular/core": "22.0.6",
|
||||
"@angular/forms": "22.0.6",
|
||||
"@angular/platform-browser": "22.0.6",
|
||||
"@angular/platform-browser-dynamic": "22.0.6",
|
||||
"@angular/router": "22.0.6",
|
||||
"@boilerplate/api-client": "1.0.0",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1"
|
||||
}
|
||||
}
|
||||
12
apps/frontend/proxy.conf.json
Normal file
12
apps/frontend/proxy.conf.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/health": {
|
||||
"target": "http://localhost:3000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
BIN
apps/frontend/public/favicon.ico
Normal file
BIN
apps/frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
17
apps/frontend/src/app/app.config.ts
Normal file
17
apps/frontend/src/app/app.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import type { ApplicationConfig } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { csrfInterceptor } from './core/csrf.interceptor';
|
||||
import { titleStrategyProvider } from './core/title.strategy';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideHttpClient(withInterceptors([csrfInterceptor])),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
titleStrategyProvider,
|
||||
],
|
||||
};
|
||||
1
apps/frontend/src/app/app.html
Normal file
1
apps/frontend/src/app/app.html
Normal file
@@ -0,0 +1 @@
|
||||
<router-outlet />
|
||||
143
apps/frontend/src/app/app.routes.ts
Normal file
143
apps/frontend/src/app/app.routes.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Routes } from '@angular/router';
|
||||
import { permissionGuard } from './core/permission.guard';
|
||||
import { devRoutes } from './core/dev-routes';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./layout/app-shell').then((m) => m.AppShellComponent),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
title: 'Dashboard',
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/dashboard.page').then((m) => m.DashboardPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'profil',
|
||||
title: 'Profil',
|
||||
loadComponent: () =>
|
||||
import('./features/profile/profile.page').then((m) => m.ProfilePageComponent),
|
||||
},
|
||||
{
|
||||
path: 'account/security',
|
||||
title: 'Account-Sicherheit',
|
||||
loadComponent: () =>
|
||||
import('./features/account/account-security.page').then(
|
||||
(m) => m.AccountSecurityPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
title: 'Benachrichtigungen',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['notifications.readOwn'] },
|
||||
loadComponent: () =>
|
||||
import('./features/notifications/notifications.page').then(
|
||||
(m) => m.NotificationsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sessions',
|
||||
title: 'Eigene Sessions',
|
||||
loadComponent: () =>
|
||||
import('./features/sessions/sessions.page').then((m) => m.SessionsPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'items',
|
||||
title: 'Items',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['items.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/items/items.page').then((m) => m.ItemsPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'benutzer',
|
||||
title: 'Benutzerverwaltung',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['users.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/users/users.page').then((m) => m.UsersPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
title: 'Admin / Benutzer',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['users.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/admin/admin-users.page').then((m) => m.AdminUsersPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'admin/users/:id',
|
||||
title: 'Admin / Benutzer',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['users.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/admin/admin-user-detail.page').then(
|
||||
(m) => m.AdminUserDetailPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'rollen',
|
||||
title: 'Rollenverwaltung',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['roles.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/roles/roles.page').then((m) => m.RolesPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'admin/roles',
|
||||
title: 'Admin / Rollen',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['roles.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/admin/admin-roles.page').then((m) => m.AdminRolesPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'admin/roles/:id',
|
||||
title: 'Admin / Rolle',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['roles.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/admin/admin-role-detail.page').then(
|
||||
(m) => m.AdminRoleDetailPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'audit-log',
|
||||
title: 'Audit-Log',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['audit.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/audit/audit.page').then((m) => m.AuditPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'admin/audit',
|
||||
title: 'Admin / Audit',
|
||||
canActivate: [permissionGuard],
|
||||
data: { permissions: ['audit.read'] },
|
||||
loadComponent: () =>
|
||||
import('./features/audit/audit.page').then((m) => m.AuditPageComponent),
|
||||
},
|
||||
{
|
||||
path: '403',
|
||||
title: 'Keine Berechtigung',
|
||||
loadComponent: () =>
|
||||
import('./features/errors/forbidden.page').then((m) => m.ForbiddenPageComponent),
|
||||
},
|
||||
{
|
||||
path: 'fehler',
|
||||
title: 'Fehler',
|
||||
loadComponent: () =>
|
||||
import('./features/errors/error.page').then((m) => m.ErrorPageComponent),
|
||||
},
|
||||
...devRoutes,
|
||||
{
|
||||
path: '**',
|
||||
title: 'Nicht gefunden',
|
||||
loadComponent: () =>
|
||||
import('./features/errors/not-found.page').then((m) => m.NotFoundPageComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
0
apps/frontend/src/app/app.scss
Normal file
0
apps/frontend/src/app/app.scss
Normal file
18
apps/frontend/src/app/app.spec.ts
Normal file
18
apps/frontend/src/app/app.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { App } from './app';
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the router outlet as application entry point', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(element.querySelector('router-outlet')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
9
apps/frontend/src/app/app.ts
Normal file
9
apps/frontend/src/app/app.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class App {}
|
||||
35
apps/frontend/src/app/core/auth.service.spec.ts
Normal file
35
apps/frontend/src/app/core/auth.service.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { ApiClientService, type UserDto } from '@boilerplate/api-client';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
const user: UserDto = {
|
||||
id: 'u1',
|
||||
name: 'Ada',
|
||||
email: 'ada@example.test',
|
||||
active: true,
|
||||
lastLoginAt: null,
|
||||
settings: { tablePageSize: 20, sidebarExpanded: true },
|
||||
roles: [
|
||||
{
|
||||
id: 'r1',
|
||||
name: 'user',
|
||||
protected: true,
|
||||
permissions: [{ id: 'items.read', description: 'items.read' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('derives permissions from roles for navigation decisions', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ApiClientService, useValue: { me: () => of(user) } }],
|
||||
});
|
||||
const service = TestBed.inject(AuthService);
|
||||
|
||||
service.loadMe();
|
||||
|
||||
expect(service.has('items.read')).toBe(true);
|
||||
expect(service.has('users.manage')).toBe(false);
|
||||
});
|
||||
});
|
||||
43
apps/frontend/src/app/core/auth.service.ts
Normal file
43
apps/frontend/src/app/core/auth.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import type { Permission, UserDto } from '@boilerplate/api-client';
|
||||
import { ApiClientService } from '@boilerplate/api-client';
|
||||
import { catchError, finalize, of, tap } from 'rxjs';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly user = signal<UserDto | null>(null);
|
||||
readonly loaded = signal(false);
|
||||
readonly permissions = computed(
|
||||
() =>
|
||||
new Set(
|
||||
(this.user()?.roles ?? []).flatMap((role) =>
|
||||
role.permissions.map((permission) => permission.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
loadMe(): void {
|
||||
this.ensureLoaded().subscribe();
|
||||
}
|
||||
|
||||
ensureLoaded(): Observable<UserDto | null> {
|
||||
if (this.loaded()) {
|
||||
return of(this.user());
|
||||
}
|
||||
|
||||
return this.api.me().pipe(
|
||||
tap((user) => this.user.set(user)),
|
||||
catchError(() => {
|
||||
this.user.set(null);
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => this.loaded.set(true)),
|
||||
);
|
||||
}
|
||||
|
||||
has(permission: Permission): boolean {
|
||||
return this.permissions().has(permission);
|
||||
}
|
||||
}
|
||||
17
apps/frontend/src/app/core/csrf.interceptor.ts
Normal file
17
apps/frontend/src/app/core/csrf.interceptor.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { HttpInterceptorFn } from '@angular/common/http';
|
||||
|
||||
const unsafeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||||
|
||||
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!unsafeMethods.has(req.method)) {
|
||||
return next(req);
|
||||
}
|
||||
const token = document.cookie
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
return next(
|
||||
token ? req.clone({ setHeaders: { 'X-CSRF-Token': decodeURIComponent(token) } }) : req,
|
||||
);
|
||||
};
|
||||
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: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
apps/frontend/src/app/core/permission.guard.ts
Normal file
21
apps/frontend/src/app/core/permission.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, type CanActivateFn } from '@angular/router';
|
||||
import type { Permission } from '@boilerplate/api-client';
|
||||
import { map } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const permissionGuard: CanActivateFn = (route) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const required = (route.data['permissions'] ?? []) as Permission[];
|
||||
return auth.ensureLoaded().pipe(
|
||||
map((user) => {
|
||||
if (!user) {
|
||||
return router.createUrlTree(['/']);
|
||||
}
|
||||
return required.every((permission) => auth.has(permission))
|
||||
? true
|
||||
: router.createUrlTree(['/403']);
|
||||
}),
|
||||
);
|
||||
};
|
||||
15
apps/frontend/src/app/core/title.strategy.ts
Normal file
15
apps/frontend/src/app/core/title.strategy.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { TitleStrategy, type RouterStateSnapshot } from '@angular/router';
|
||||
|
||||
@Injectable()
|
||||
export class AppTitleStrategy extends TitleStrategy {
|
||||
private readonly title = inject(Title);
|
||||
|
||||
override updateTitle(snapshot: RouterStateSnapshot): void {
|
||||
const title = this.buildTitle(snapshot);
|
||||
this.title.setTitle(title ? `${title} | Business App` : 'Business App');
|
||||
}
|
||||
}
|
||||
|
||||
export const titleStrategyProvider = { provide: TitleStrategy, useClass: AppTitleStrategy };
|
||||
@@ -0,0 +1,197 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type { SessionDto, UserDto } from '@boilerplate/api-client';
|
||||
import { ApiClientService } from '@boilerplate/api-client';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { AccountSecurityPageComponent } from './account-security.page';
|
||||
|
||||
const user: UserDto = {
|
||||
id: 'u1',
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
active: true,
|
||||
lastLoginAt: '2026-07-16T08:30:00.000Z',
|
||||
settings: { tablePageSize: 20, sidebarExpanded: true },
|
||||
roles: [
|
||||
{
|
||||
id: 'r1',
|
||||
name: 'user',
|
||||
description: 'Standardrolle',
|
||||
system: true,
|
||||
protected: true,
|
||||
permissions: [
|
||||
{ id: 'sessions.readOwn', description: 'Eigene Sessions anzeigen' },
|
||||
{ id: 'items.read', description: 'Items anzeigen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
name: 'editor',
|
||||
description: 'Editor',
|
||||
system: false,
|
||||
protected: false,
|
||||
permissions: [
|
||||
{ id: 'items.read', description: 'Items anzeigen' },
|
||||
{ id: 'items.update', description: 'Items bearbeiten' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const sessions: SessionDto[] = [
|
||||
{
|
||||
id: 'current',
|
||||
createdAt: '2026-07-16T08:00:00.000Z',
|
||||
lastActivityAt: '2026-07-16T08:30:00.000Z',
|
||||
userAgent: 'Firefox',
|
||||
approximateIp: '192.0.2.10',
|
||||
current: true,
|
||||
revokedAt: null,
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
createdAt: '2026-07-15T08:00:00.000Z',
|
||||
lastActivityAt: '2026-07-15T09:00:00.000Z',
|
||||
userAgent: 'Chrome',
|
||||
approximateIp: '192.0.2.11',
|
||||
current: false,
|
||||
revokedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe('AccountSecurityPageComponent', () => {
|
||||
it('shows account details, roles, deduplicated permissions and sessions', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AccountSecurityPageComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: { user: signal(user) } },
|
||||
{
|
||||
provide: ApiClientService,
|
||||
useValue: {
|
||||
sessions: () => of(sessions),
|
||||
revokeSession: vi.fn(),
|
||||
revokeOtherSessions: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(AccountSecurityPageComponent);
|
||||
fixture.detectChanges();
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(element.textContent).toContain('Ada Lovelace');
|
||||
expect(element.textContent).toContain('Identity Provider');
|
||||
expect(element.textContent).toContain('user - Systemrolle');
|
||||
expect(element.textContent).toContain('Aktuelle Session');
|
||||
expect(element.textContent).toContain('Weitere Session');
|
||||
expect(element.querySelectorAll('code').length).toBe(3);
|
||||
});
|
||||
|
||||
it('revokes one other session and reloads sessions', async () => {
|
||||
const sessionsSpy = vi.fn(() => of(sessions));
|
||||
const revokeSession = vi.fn(() => of(undefined));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AccountSecurityPageComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: { user: signal(user) } },
|
||||
{
|
||||
provide: ApiClientService,
|
||||
useValue: {
|
||||
sessions: sessionsSpy,
|
||||
revokeSession,
|
||||
revokeOtherSessions: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(AccountSecurityPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.revoke('other');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(revokeSession).toHaveBeenCalledWith('other');
|
||||
expect(sessionsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('revokes all other sessions and reloads sessions', async () => {
|
||||
const sessionsSpy = vi.fn(() => of(sessions));
|
||||
const revokeOtherSessions = vi.fn(() => of(undefined));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AccountSecurityPageComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: { user: signal(user) } },
|
||||
{
|
||||
provide: ApiClientService,
|
||||
useValue: {
|
||||
sessions: sessionsSpy,
|
||||
revokeSession: vi.fn(),
|
||||
revokeOtherSessions,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(AccountSecurityPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.revokeOthers();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(revokeOtherSessions).toHaveBeenCalled();
|
||||
expect(sessionsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows API errors with request id and an empty state for empty sessions', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AccountSecurityPageComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: { user: signal(user) } },
|
||||
{
|
||||
provide: ApiClientService,
|
||||
useValue: {
|
||||
sessions: () =>
|
||||
throwError(() => ({
|
||||
error: {
|
||||
message: 'Sessions konnten nicht geladen werden.',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
})),
|
||||
revokeSession: vi.fn(),
|
||||
revokeOtherSessions: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(AccountSecurityPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('request-1');
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AccountSecurityPageComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: { user: signal(user) } },
|
||||
{
|
||||
provide: ApiClientService,
|
||||
useValue: {
|
||||
sessions: () => of([]),
|
||||
revokeSession: vi.fn(),
|
||||
revokeOtherSessions: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const emptyFixture = TestBed.createComponent(AccountSecurityPageComponent);
|
||||
emptyFixture.detectChanges();
|
||||
expect((emptyFixture.nativeElement as HTMLElement).textContent).toContain(
|
||||
'Keine Sessions gefunden',
|
||||
);
|
||||
});
|
||||
});
|
||||
324
apps/frontend/src/app/features/account/account-security.page.ts
Normal file
324
apps/frontend/src/app/features/account/account-security.page.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import type { ApiErrorBody, Permission, SessionDto } from '@boilerplate/api-client';
|
||||
import { ApiClientService } from '@boilerplate/api-client';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { adminPermissionGroups } from '../admin/admin-permissions';
|
||||
import { UiEmptyStateComponent, UiStatusBadgeComponent } from '../../shared/ui';
|
||||
|
||||
interface PermissionGroupView {
|
||||
title: string;
|
||||
permissions: { id: Permission; label: string }[];
|
||||
}
|
||||
|
||||
interface AccountSecurityError {
|
||||
message: string;
|
||||
requestId: string | undefined;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [DatePipe, UiEmptyStateComponent, UiStatusBadgeComponent],
|
||||
template: `
|
||||
<section class="ui-page-header">
|
||||
<div>
|
||||
<h1>Account-Sicherheit</h1>
|
||||
<p>Uebersicht ueber Ihren Account, Rollen, Berechtigungen und aktive Sessions.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (auth.user(); as user) {
|
||||
<section class="ui-grid security-summary">
|
||||
<article class="ui-card">
|
||||
<h2>Account</h2>
|
||||
<dl class="security-list">
|
||||
<dt>Name</dt>
|
||||
<dd>{{ user.name }}</dd>
|
||||
<dt>E-Mail</dt>
|
||||
<dd>{{ user.email || 'Nicht gesetzt' }}</dd>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
<ui-status-badge
|
||||
[label]="user.active ? 'Aktiv' : 'Deaktiviert'"
|
||||
[tone]="user.active ? 'success' : 'danger'"
|
||||
/>
|
||||
</dd>
|
||||
<dt>Letzte Anmeldung</dt>
|
||||
<dd>
|
||||
{{ user.lastLoginAt ? (user.lastLoginAt | date: 'short') : 'Noch nicht bekannt' }}
|
||||
</dd>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="ui-card">
|
||||
<h2>Identity Provider</h2>
|
||||
<p class="ui-help-text">
|
||||
Name und E-Mail werden zentral vom Identity Provider verwaltet. Aenderungen erfolgen
|
||||
nicht in dieser Anwendung.
|
||||
</p>
|
||||
<p class="ui-meta">OIDC-Session mit serverseitig gespeicherten Tokens.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ui-card security-section">
|
||||
<h2>Rollen</h2>
|
||||
@if (user.roles.length > 0) {
|
||||
<div class="cluster">
|
||||
@for (role of user.roles; track role.id) {
|
||||
<span class="ui-badge" [class.ui-badge--info]="role.system">
|
||||
{{ role.name }}{{ role.system ? ' - Systemrolle' : '' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="ui-help-text">Keine Rollen zugewiesen.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="ui-card security-section">
|
||||
<h2>Effektive Berechtigungen</h2>
|
||||
@if (permissionGroups().length > 0) {
|
||||
<div class="permission-groups">
|
||||
@for (group of permissionGroups(); track group.title) {
|
||||
<article class="permission-group">
|
||||
<h3>{{ group.title }}</h3>
|
||||
<ul>
|
||||
@for (permission of group.permissions; track permission.id) {
|
||||
<li>
|
||||
<code>{{ permission.id }}</code>
|
||||
<span>{{ permission.label }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="ui-help-text">Keine Berechtigungen ueber Rollen zugewiesen.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="ui-card security-section">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
<h2>Aktive Sessions</h2>
|
||||
<p class="ui-help-text">Beenden Sie nicht mehr benoetigte Browser-Sessions.</p>
|
||||
</div>
|
||||
<button
|
||||
class="ui-button ui-button--danger"
|
||||
type="button"
|
||||
[disabled]="!hasOtherSessions() || loading()"
|
||||
(click)="revokeOthers()"
|
||||
>
|
||||
Alle anderen beenden
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="ui-notice">Sessions werden geladen.</p>
|
||||
} @else if (error(); as currentError) {
|
||||
<p class="ui-notice ui-notice--error">
|
||||
{{ currentError.message }}
|
||||
@if (currentError.requestId) {
|
||||
<small>Request-ID: {{ currentError.requestId }}</small>
|
||||
}
|
||||
</p>
|
||||
} @else if (sessions().length === 0) {
|
||||
<ui-empty-state
|
||||
title="Keine Sessions gefunden"
|
||||
description="Fuer Ihren Account wurden keine aktiven Sessions zurueckgegeben."
|
||||
/>
|
||||
} @else {
|
||||
<div class="session-list">
|
||||
@for (session of sessions(); track session.id) {
|
||||
<article class="session-card" [class.current]="session.current">
|
||||
<div class="session-card__body">
|
||||
<strong>{{ session.current ? 'Aktuelle Session' : 'Weitere Session' }}</strong>
|
||||
<span>Angemeldet: {{ session.createdAt | date: 'short' }}</span>
|
||||
<span>Letzte Aktivitaet: {{ session.lastActivityAt | date: 'short' }}</span>
|
||||
<span>{{ session.userAgent || 'Unbekannter Browser' }}</span>
|
||||
<span>{{ session.approximateIp || 'IP unbekannt' }}</span>
|
||||
</div>
|
||||
@if (session.current) {
|
||||
<p class="ui-meta">Diese Session beenden Sie ueber Abmelden.</p>
|
||||
} @else if (!session.revokedAt) {
|
||||
<button
|
||||
class="ui-button ui-button--danger"
|
||||
type="button"
|
||||
(click)="revoke(session.id)"
|
||||
>
|
||||
Session beenden
|
||||
</button>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.security-summary {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.security-summary h2,
|
||||
.security-section h2,
|
||||
.permission-group h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.security-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, auto) 1fr;
|
||||
gap: var(--space-3) var(--space-4);
|
||||
margin: 0;
|
||||
}
|
||||
.security-list dt {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.security-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
.security-section {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.permission-groups,
|
||||
.session-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.permission-group {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.permission-group ul {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.permission-group li {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
code {
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.section-heading {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.section-heading h2,
|
||||
.section-heading p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.session-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.session-card.current {
|
||||
border-left: 4px solid var(--color-primary);
|
||||
background: var(--color-primary-subtle);
|
||||
}
|
||||
.session-card__body {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.session-card__body span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.ui-notice small {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.security-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.section-heading,
|
||||
.session-card {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
}
|
||||
.permission-groups {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AccountSecurityPageComponent {
|
||||
readonly auth = inject(AuthService);
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly sessions = signal<SessionDto[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<AccountSecurityError | null>(null);
|
||||
readonly hasOtherSessions = computed(() =>
|
||||
this.sessions().some((session) => !session.current && !session.revokedAt),
|
||||
);
|
||||
readonly permissionGroups = computed(() => this.buildPermissionGroups());
|
||||
|
||||
constructor() {
|
||||
this.loadSessions();
|
||||
}
|
||||
|
||||
loadSessions(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.api.sessions().subscribe({
|
||||
next: (sessions) => this.sessions.set(sessions.filter((session) => !session.revokedAt)),
|
||||
error: (err: { error?: ApiErrorBody }) => {
|
||||
this.error.set({
|
||||
message: err.error?.message ?? 'Sessions konnten nicht geladen werden.',
|
||||
requestId: err.error?.requestId,
|
||||
});
|
||||
this.loading.set(false);
|
||||
},
|
||||
complete: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
revoke(id: string): void {
|
||||
this.api.revokeSession(id).subscribe({
|
||||
next: () => this.loadSessions(),
|
||||
error: (err: { error?: ApiErrorBody }) =>
|
||||
this.error.set({
|
||||
message: err.error?.message ?? 'Session konnte nicht beendet werden.',
|
||||
requestId: err.error?.requestId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
revokeOthers(): void {
|
||||
this.api.revokeOtherSessions().subscribe({
|
||||
next: () => this.loadSessions(),
|
||||
error: (err: { error?: ApiErrorBody }) =>
|
||||
this.error.set({
|
||||
message: err.error?.message ?? 'Sessions konnten nicht beendet werden.',
|
||||
requestId: err.error?.requestId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private buildPermissionGroups(): PermissionGroupView[] {
|
||||
const userPermissions = new Set(
|
||||
(this.auth.user()?.roles ?? []).flatMap((role) =>
|
||||
role.permissions.map((permission) => permission.id),
|
||||
),
|
||||
);
|
||||
|
||||
return adminPermissionGroups
|
||||
.map((group) => ({
|
||||
title: group.title,
|
||||
permissions: group.permissions.filter((permission) => userPermissions.has(permission.id)),
|
||||
}))
|
||||
.filter((group) => group.permissions.length > 0);
|
||||
}
|
||||
}
|
||||
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: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
39
apps/frontend/src/app/features/audit/audit.page.ts
Normal file
39
apps/frontend/src/app/features/audit/audit.page.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ApiClientService, type AuditLogDto } from '@boilerplate/api-client';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section class="ui-grid">
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<article class="ui-card">
|
||||
<strong>{{ labels[entry.action] || entry.action }}</strong>
|
||||
<span class="ui-help-text"
|
||||
>{{ entry.createdAt }} · Objekt: {{ entry.targetType }} {{ entry.targetId }}</span
|
||||
>
|
||||
<small class="ui-meta">Request-ID: {{ entry.requestId }}</small>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class AuditPageComponent {
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly entries = signal<AuditLogDto[]>([]);
|
||||
readonly labels: Record<string, string> = {
|
||||
USER_ACTIVATED: 'Benutzer aktiviert',
|
||||
USER_DEACTIVATED: 'Benutzer deaktiviert',
|
||||
USER_ROLE_ASSIGNED: 'Rolle zugewiesen',
|
||||
USER_ROLE_REMOVED: 'Rolle entfernt',
|
||||
ROLE_CREATED: 'Rolle erstellt',
|
||||
ROLE_UPDATED: 'Rolle aktualisiert',
|
||||
ROLE_DELETED: 'Rolle geloescht',
|
||||
ROLE_PERMISSIONS_UPDATED: 'Permissions aktualisiert',
|
||||
SESSION_REVOKED: 'Session beendet',
|
||||
ALL_USER_SESSIONS_REVOKED: 'Alle Sessions beendet',
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.api.audit().subscribe((page) => this.entries.set(page.items));
|
||||
}
|
||||
}
|
||||
36
apps/frontend/src/app/features/dashboard/dashboard.page.ts
Normal file
36
apps/frontend/src/app/features/dashboard/dashboard.page.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ApiClientService } from '@boilerplate/api-client';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section class="ui-grid ui-grid--cards">
|
||||
@for (card of cards(); track card.label) {
|
||||
<article class="ui-card ui-kpi">
|
||||
<span class="ui-meta">{{ card.label }}</span>
|
||||
<strong class="ui-kpi__value">{{ card.value }}</strong>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class DashboardPageComponent {
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly cards = signal([
|
||||
{ label: 'Benutzer', value: 0 },
|
||||
{ label: 'Aktive Sessions', value: 0 },
|
||||
{ label: 'Rollen', value: 0 },
|
||||
{ label: 'Items', value: 0 },
|
||||
]);
|
||||
|
||||
constructor() {
|
||||
this.api.dashboard().subscribe((data) =>
|
||||
this.cards.set([
|
||||
{ label: 'Benutzer', value: data.userCount },
|
||||
{ label: 'Aktive Sessions', value: data.activeSessions },
|
||||
{ label: 'Rollen', value: data.roleCount },
|
||||
{ label: 'Items', value: data.itemCount },
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
15
apps/frontend/src/app/features/errors/error.page.ts
Normal file
15
apps/frontend/src/app/features/errors/error.page.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { UiEmptyStateComponent } from '../../shared/ui';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [UiEmptyStateComponent],
|
||||
template: `
|
||||
<ui-empty-state
|
||||
title="Fehler"
|
||||
description="Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
|
||||
icon="warning"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
export class ErrorPageComponent {}
|
||||
15
apps/frontend/src/app/features/errors/forbidden.page.ts
Normal file
15
apps/frontend/src/app/features/errors/forbidden.page.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { UiEmptyStateComponent } from '../../shared/ui';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [UiEmptyStateComponent],
|
||||
template: `
|
||||
<ui-empty-state
|
||||
title="Keine Berechtigung"
|
||||
description="Sie haben keine Berechtigung fuer diese Seite."
|
||||
icon="warning"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
export class ForbiddenPageComponent {}
|
||||
15
apps/frontend/src/app/features/errors/not-found.page.ts
Normal file
15
apps/frontend/src/app/features/errors/not-found.page.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { UiEmptyStateComponent } from '../../shared/ui';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [UiEmptyStateComponent],
|
||||
template: `
|
||||
<ui-empty-state
|
||||
title="Seite nicht gefunden"
|
||||
description="Die angeforderte Seite wurde nicht gefunden."
|
||||
icon="info"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
export class NotFoundPageComponent {}
|
||||
71
apps/frontend/src/app/features/items/items.page.spec.ts
Normal file
71
apps/frontend/src/app/features/items/items.page.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { ApiClientService } from '@boilerplate/api-client';
|
||||
import { ItemsPageComponent } from './items.page';
|
||||
|
||||
describe('ItemsPageComponent', () => {
|
||||
it('keeps the save button disabled while the typed form is invalid', async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemsPageComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ApiClientService,
|
||||
useValue: {
|
||||
items: () => of({ items: [], total: 0, page: 1, pageSize: 20 }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(ItemsPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.form.invalid).toBe(true);
|
||||
fixture.componentInstance.form.controls.name.setValue('Neues Item');
|
||||
expect(fixture.componentInstance.form.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('loads items with pagination parameters and changes pages', async () => {
|
||||
const calls: { search?: string; page?: number; pageSize?: number }[] = [];
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemsPageComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ApiClientService,
|
||||
useValue: {
|
||||
items: (query: { search?: string; page?: number; pageSize?: number } = {}) => {
|
||||
calls.push(query);
|
||||
return of({
|
||||
items: [
|
||||
{
|
||||
id: `item-${query.page ?? 1}`,
|
||||
name: 'Item',
|
||||
description: null,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAt: '2026-07-16T08:00:00.000Z',
|
||||
updatedAt: '2026-07-16T08:00:00.000Z',
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
total: 30,
|
||||
page: query.page ?? 1,
|
||||
pageSize: query.pageSize ?? 20,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(ItemsPageComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(calls[0]).toEqual({ search: '', page: 1, pageSize: 20 });
|
||||
|
||||
fixture.componentInstance.goToPage(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(calls[1]).toEqual({ search: '', page: 2, pageSize: 20 });
|
||||
expect(fixture.componentInstance.page()).toBe(2);
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Seite 2 von 2');
|
||||
});
|
||||
});
|
||||
169
apps/frontend/src/app/features/items/items.page.ts
Normal file
169
apps/frontend/src/app/features/items/items.page.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ApiClientService, type ApiErrorBody, type ItemDto } from '@boilerplate/api-client';
|
||||
import { UiPaginationComponent } from '../../shared/ui';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, UiPaginationComponent],
|
||||
template: `
|
||||
<form class="ui-toolbar item-toolbar" (ngSubmit)="searchItems()">
|
||||
<label class="ui-form-field">
|
||||
<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>
|
||||
|
||||
@if (error()) {
|
||||
<p class="ui-notice ui-notice--error">{{ error() }}</p>
|
||||
}
|
||||
|
||||
<section class="ui-grid item-grid">
|
||||
@for (item of items(); track item.id) {
|
||||
<button class="ui-card ui-card--interactive item-card" type="button" (click)="edit(item)">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span class="ui-help-text">{{ item.description || 'Keine Beschreibung' }}</span>
|
||||
<small class="ui-meta">{{ item.status }} - Version {{ item.version }}</small>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
|
||||
@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>
|
||||
<label class="ui-form-field">
|
||||
<span class="ui-label">Name</span>
|
||||
<input class="ui-control" formControlName="name" />
|
||||
</label>
|
||||
<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="active">Aktiv</option>
|
||||
<option value="archived">Archiviert</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="ui-actions">
|
||||
<button class="ui-button ui-button--primary" type="submit" [disabled]="form.invalid">
|
||||
Speichern
|
||||
</button>
|
||||
@if (selected(); as item) {
|
||||
<button class="ui-button ui-button--danger" type="button" (click)="delete(item)">
|
||||
Loeschen
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.item-toolbar,
|
||||
.item-grid {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.item-card {
|
||||
text-align: left;
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.item-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ItemsPageComponent {
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly items = signal<ItemDto[]>([]);
|
||||
readonly selected = signal<ItemDto | null>(null);
|
||||
readonly error = signal('');
|
||||
readonly page = signal(1);
|
||||
readonly total = signal(0);
|
||||
readonly pageSize = 20;
|
||||
readonly search = new FormControl('', { nonNullable: true });
|
||||
readonly form = new FormGroup({
|
||||
name: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [
|
||||
(control) => Validators.required(control),
|
||||
(control) => Validators.maxLength(160)(control),
|
||||
],
|
||||
}),
|
||||
description: new FormControl('', { nonNullable: true }),
|
||||
status: new FormControl<ItemDto['status']>('draft', { nonNullable: true }),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
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 {
|
||||
this.selected.set(item);
|
||||
this.form.setValue({
|
||||
name: item.name,
|
||||
description: item.description ?? '',
|
||||
status: item.status,
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.error.set('');
|
||||
const value = this.form.getRawValue();
|
||||
const selected = this.selected();
|
||||
const request = selected
|
||||
? this.api.updateItem(selected.id, { ...value, version: selected.version })
|
||||
: this.api.createItem(value);
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.selected.set(null);
|
||||
this.form.reset({ name: '', description: '', status: 'draft' });
|
||||
this.load();
|
||||
},
|
||||
error: (err: { error?: ApiErrorBody }) =>
|
||||
this.error.set(err.error?.message ?? 'Speichern fehlgeschlagen.'),
|
||||
});
|
||||
}
|
||||
|
||||
delete(item: ItemDto): void {
|
||||
if (confirm('Item wirklich loeschen?')) {
|
||||
this.api.deleteItem(item.id, item.version).subscribe(() => this.load());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
83
apps/frontend/src/app/features/profile/profile.page.ts
Normal file
83
apps/frontend/src/app/features/profile/profile.page.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormControl, FormGroup } from '@angular/forms';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { ApiClientService } from '@boilerplate/api-client';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
template: `
|
||||
@if (auth.user(); as user) {
|
||||
<section class="ui-card profile-card">
|
||||
<dl class="profile-list">
|
||||
<dt>Name</dt>
|
||||
<dd>{{ user.name }}</dd>
|
||||
<dt>E-Mail</dt>
|
||||
<dd>{{ user.email || 'Nicht gesetzt' }}</dd>
|
||||
<dt>Letzter Login</dt>
|
||||
<dd>{{ user.lastLoginAt || 'Noch nicht bekannt' }}</dd>
|
||||
</dl>
|
||||
<form class="ui-form profile-form" [formGroup]="form" (ngSubmit)="save()">
|
||||
<label class="ui-form-field">
|
||||
<span class="ui-label">Tabellen-Seitengroesse</span>
|
||||
<input
|
||||
class="ui-control"
|
||||
type="number"
|
||||
formControlName="tablePageSize"
|
||||
min="5"
|
||||
max="100"
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.profile-card,
|
||||
.profile-list {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.profile-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, auto) 1fr;
|
||||
margin: 0;
|
||||
}
|
||||
dt {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
.profile-form {
|
||||
max-width: 28rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ProfilePageComponent {
|
||||
readonly auth = inject(AuthService);
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly form = new FormGroup({
|
||||
tablePageSize: new FormControl(20, { nonNullable: true }),
|
||||
sidebarExpanded: new FormControl(true, { nonNullable: true }),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const user = this.auth.user();
|
||||
if (user) {
|
||||
console.log(user);
|
||||
// this.form.setValue(user.settings);
|
||||
this.form.patchValue(user.settings);
|
||||
}
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.api.updateSettings(this.form.getRawValue()).subscribe((user) => this.auth.user.set(user));
|
||||
}
|
||||
}
|
||||
149
apps/frontend/src/app/features/roles/roles.page.ts
Normal file
149
apps/frontend/src/app/features/roles/roles.page.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ApiClientService, type Permission, type RoleDto } from '@boilerplate/api-client';
|
||||
|
||||
const permissions: Permission[] = [
|
||||
'items.read',
|
||||
'items.create',
|
||||
'items.update',
|
||||
'items.delete',
|
||||
'users.read',
|
||||
'users.manage',
|
||||
'roles.read',
|
||||
'roles.manage',
|
||||
'audit.read',
|
||||
'sessions.readOwn',
|
||||
'sessions.revokeOwn',
|
||||
'sessions.manage',
|
||||
];
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
template: `
|
||||
<section class="list">
|
||||
@for (role of roles(); track role.id) {
|
||||
<article (click)="edit(role)">
|
||||
<strong
|
||||
>{{ role.name }}
|
||||
@if (role.protected) {
|
||||
<small>Systemrolle</small>
|
||||
}
|
||||
</strong>
|
||||
<span
|
||||
>{{ role.permissions.length }} Permissions ·
|
||||
{{ role.users?.length ?? 0 }} Benutzer</span
|
||||
>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
<form [formGroup]="form" (ngSubmit)="save()" class="panel">
|
||||
<h2>{{ selected()?.id ? 'Rolle bearbeiten' : 'Rolle anlegen' }}</h2>
|
||||
<label>Name <input formControlName="name" /></label>
|
||||
<fieldset>
|
||||
<legend>Permissions</legend>
|
||||
@for (permission of allPermissions; track permission) {
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
[checked]="selectedPermissions().has(permission)"
|
||||
(change)="toggle(permission)"
|
||||
/>
|
||||
{{ permission }}</label
|
||||
>
|
||||
}
|
||||
</fieldset>
|
||||
<button type="submit" [disabled]="form.invalid">Speichern</button>
|
||||
@if (selected(); as role) {
|
||||
@if (!role.protected) {
|
||||
<button type="button" class="danger" (click)="delete(role.id)">Loeschen</button>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.list,
|
||||
.panel,
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
article,
|
||||
.panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
article {
|
||||
cursor: pointer;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
button {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-surface);
|
||||
border: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.danger {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
small {
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 6px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RolesPageComponent {
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly roles = signal<RoleDto[]>([]);
|
||||
readonly selected = signal<RoleDto | null>(null);
|
||||
readonly selectedPermissions = signal(new Set<Permission>());
|
||||
readonly allPermissions = permissions;
|
||||
readonly form = new FormGroup({
|
||||
name: new FormControl('', {
|
||||
nonNullable: true,
|
||||
validators: [(control) => Validators.required(control)],
|
||||
}),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.api.roles().subscribe((roles) => this.roles.set(roles));
|
||||
}
|
||||
|
||||
edit(role: RoleDto): void {
|
||||
this.selected.set(role);
|
||||
this.form.setValue({ name: role.name });
|
||||
this.selectedPermissions.set(new Set(role.permissions.map((permission) => permission.id)));
|
||||
}
|
||||
|
||||
toggle(permission: Permission): void {
|
||||
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.form.controls.name.value,
|
||||
permissions: Array.from(this.selectedPermissions()),
|
||||
};
|
||||
const selected = this.selected();
|
||||
const request = selected ? this.api.updateRole(selected.id, body) : this.api.createRole(body);
|
||||
request.subscribe(() => this.load());
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.api.deleteRole(id).subscribe(() => this.load());
|
||||
}
|
||||
}
|
||||
58
apps/frontend/src/app/features/sessions/sessions.page.ts
Normal file
58
apps/frontend/src/app/features/sessions/sessions.page.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { ApiClientService, type SessionDto } from '@boilerplate/api-client';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<button class="ui-button ui-button--ghost" type="button" (click)="revokeOthers()">
|
||||
Alle anderen Sessions beenden
|
||||
</button>
|
||||
<section class="ui-grid sessions-list">
|
||||
@for (session of sessions(); track session.id) {
|
||||
<article class="ui-card">
|
||||
<div class="stack">
|
||||
<strong>{{ session.current ? 'Aktuelle Session' : 'Session' }}</strong>
|
||||
<span class="ui-help-text">Angemeldet: {{ session.createdAt }}</span>
|
||||
<span class="ui-help-text">Letzte Aktivitaet: {{ session.lastActivityAt }}</span>
|
||||
<span class="ui-help-text">
|
||||
{{ session.userAgent || 'Unbekannter Browser' }} ·
|
||||
{{ session.approximateIp || 'IP unbekannt' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (!session.current && !session.revokedAt) {
|
||||
<button class="ui-button ui-button--danger" type="button" (click)="revoke(session.id)">
|
||||
Beenden
|
||||
</button>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.sessions-list {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class SessionsPageComponent {
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly sessions = signal<SessionDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.api.sessions().subscribe((sessions) => this.sessions.set(sessions));
|
||||
}
|
||||
|
||||
revoke(id: string): void {
|
||||
this.api.revokeSession(id).subscribe(() => this.load());
|
||||
}
|
||||
|
||||
revokeOthers(): void {
|
||||
this.api.revokeOtherSessions().subscribe(() => this.load());
|
||||
}
|
||||
}
|
||||
98
apps/frontend/src/app/features/users/users.page.ts
Normal file
98
apps/frontend/src/app/features/users/users.page.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ApiClientService, type UserDto } from '@boilerplate/api-client';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<form class="toolbar" (ngSubmit)="load()">
|
||||
<input name="search" [(ngModel)]="search" placeholder="Benutzer suchen" />
|
||||
<button type="submit">Suchen</button>
|
||||
</form>
|
||||
<section class="list">
|
||||
@for (user of users(); track user.id) {
|
||||
<article>
|
||||
<div>
|
||||
<strong>{{ user.name }}</strong>
|
||||
<span>{{ user.email || 'Keine E-Mail' }}</span>
|
||||
<span
|
||||
>{{ user.active ? 'Aktiv' : 'Deaktiviert' }} · Letzter Login:
|
||||
{{ user.lastLoginAt || 'nie' }}</span
|
||||
>
|
||||
</div>
|
||||
<button type="button" (click)="setActive(user)">
|
||||
{{ user.active ? 'Deaktivieren' : 'Aktivieren' }}
|
||||
</button>
|
||||
<button type="button" (click)="revokeSessions(user.id)">Sessions beenden</button>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
input,
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-surface);
|
||||
padding: 0 14px;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
article {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
article div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@media (min-width: 760px) {
|
||||
article {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class UsersPageComponent {
|
||||
private readonly api = inject(ApiClientService);
|
||||
readonly users = signal<UserDto[]>([]);
|
||||
search = '';
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.api.users({ search: this.search }).subscribe((page) => this.users.set(page.items));
|
||||
}
|
||||
|
||||
setActive(user: UserDto): void {
|
||||
this.api.setUserActive(user.id, !user.active).subscribe(() => this.load());
|
||||
}
|
||||
|
||||
revokeSessions(userId: string): void {
|
||||
this.api.revokeUserSessions(userId).subscribe();
|
||||
}
|
||||
}
|
||||
36
apps/frontend/src/app/layout/app-shell.html
Normal file
36
apps/frontend/src/app/layout/app-shell.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<button
|
||||
class="icon-button"
|
||||
type="button"
|
||||
(click)="drawerOpen.set(!drawerOpen())"
|
||||
aria-label="Navigation"
|
||||
>
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<strong>Business App</strong>
|
||||
<a class="logout" href="/api/auth/logout">Abmelden</a>
|
||||
</header>
|
||||
|
||||
<aside class="sidebar" [class.open]="drawerOpen()">
|
||||
<nav>
|
||||
@for (item of nav; track item.path) {
|
||||
@if (visible(item)) {
|
||||
<a
|
||||
[routerLink]="item.path"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<nav class="breadcrumbs">Start / {{ title() }}</nav>
|
||||
<h1>{{ title() }}</h1>
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
98
apps/frontend/src/app/layout/app-shell.scss
Normal file
98
apps/frontend/src/app/layout/app-shell.scss
Normal file
@@ -0,0 +1,98 @@
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: #f6f7f9;
|
||||
color: #1b2430;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d9dee7;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid #c7ced9;
|
||||
background: #ffffff;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon-button span {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background: #1b2430;
|
||||
}
|
||||
|
||||
.logout {
|
||||
margin-left: auto;
|
||||
color: #184e77;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 64px auto 0 0;
|
||||
width: 260px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #d9dee7;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 160ms ease;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: block;
|
||||
padding: 14px 18px;
|
||||
color: #263445;
|
||||
text-decoration: none;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
background: #e8f0f7;
|
||||
border-left: 4px solid #26648e;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px 16px 48px;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
color: #637083;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.icon-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-left: 260px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
}
|
||||
102
apps/frontend/src/app/layout/app-shell.spec.ts
Normal file
102
apps/frontend/src/app/layout/app-shell.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { ApiClientService } from '@boilerplate/api-client';
|
||||
import { AppShellComponent } from './app-shell';
|
||||
|
||||
describe('AppShellComponent', () => {
|
||||
it('hides navigation entries without matching permissions', 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: [
|
||||
{
|
||||
id: 'notifications.readOwn',
|
||||
description: 'notifications.readOwn',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
unreadNotificationCount: () => of({ count: 2 }),
|
||||
notifications: () =>
|
||||
of({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
unreadCount: 2,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(AppShellComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.componentInstance.visible({
|
||||
label: 'Benutzer',
|
||||
path: '/benutzer',
|
||||
permission: 'users.read',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.badge')?.textContent).toContain(
|
||||
'2',
|
||||
);
|
||||
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Sicherheit');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
296
apps/frontend/src/app/layout/app-shell.ts
Normal file
296
apps/frontend/src/app/layout/app-shell.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Component, HostListener, computed, effect, inject, signal } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
|
||||
import type { Permission } from '@boilerplate/api-client';
|
||||
import { NotificationStore } from '../core/notification.store';
|
||||
import { AuthService } from '../core/auth.service';
|
||||
import { NotificationPanelComponent } from './notification-panel';
|
||||
import { UiIconButtonComponent, UiIconComponent, UiToastHostComponent } from '../shared/ui';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
path: string;
|
||||
permission?: Permission;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
standalone: true,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
NotificationPanelComponent,
|
||||
UiIconButtonComponent,
|
||||
UiIconComponent,
|
||||
UiToastHostComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
@if (auth.user()) {
|
||||
<ui-icon-button icon="menu" label="Navigation" (pressed)="toggleDrawer()" />
|
||||
}
|
||||
<strong class="brand">Business App</strong>
|
||||
@if (auth.user()) {
|
||||
<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 {
|
||||
<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>
|
||||
|
||||
@if (auth.loaded()) {
|
||||
@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()">
|
||||
<nav aria-label="Hauptnavigation">
|
||||
@for (item of nav; track item.path) {
|
||||
@if (visible(item)) {
|
||||
<a
|
||||
[routerLink]="item.path"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: item.path === '/' }"
|
||||
(click)="closeDrawerOnNavigation()"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<nav class="breadcrumbs">Start / {{ title() }}</nav>
|
||||
<router-outlet />
|
||||
</main>
|
||||
} @else {
|
||||
<main class="content public-content">
|
||||
<section class="login-panel">
|
||||
<h1>Anmelden</h1>
|
||||
<p>Bitte melden Sie sich ueber den zentralen Identity Provider an.</p>
|
||||
<a class="ui-button ui-button--primary ui-button--mobile-full" href="/api/auth/login"
|
||||
>Mit OIDC anmelden</a
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
} @else {
|
||||
<main class="content public-content">
|
||||
<section class="login-panel">
|
||||
<h1>Session wird geprueft</h1>
|
||||
<p>Bitte warten.</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
<ui-toast-host />
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
height: var(--header-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-5);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.brand {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logout {
|
||||
margin-left: auto;
|
||||
}
|
||||
.notification-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
min-width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-danger);
|
||||
color: var(--color-surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
.public-content {
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
.login-panel {
|
||||
width: min(100%, 440px);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.login-panel h1,
|
||||
.login-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
.login-panel p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: var(--header-height) 0 0;
|
||||
z-index: calc(var(--z-drawer) - 1);
|
||||
border: 0;
|
||||
background: color-mix(in srgb, var(--color-text-primary) 36%, transparent);
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: var(--header-height) auto 0 0;
|
||||
width: var(--sidebar-width);
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--transition-base) ease;
|
||||
z-index: var(--z-drawer);
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
nav a {
|
||||
display: block;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
nav a.active {
|
||||
background: var(--color-primary-subtle);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
}
|
||||
.content {
|
||||
min-width: 0;
|
||||
padding: var(--space-5) var(--space-5) var(--space-8);
|
||||
}
|
||||
.breadcrumbs {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
@media (min-width: 64rem) {
|
||||
ui-icon-button {
|
||||
display: none;
|
||||
}
|
||||
.drawer-backdrop {
|
||||
display: none;
|
||||
}
|
||||
.sidebar {
|
||||
transform: none;
|
||||
}
|
||||
.content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: var(--space-7);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class AppShellComponent {
|
||||
private readonly router = inject(Router);
|
||||
readonly auth = inject(AuthService);
|
||||
readonly notifications = inject(NotificationStore);
|
||||
readonly drawerOpen = signal(false);
|
||||
readonly notificationPanelOpen = signal(false);
|
||||
readonly title = computed(
|
||||
() => this.router.routerState.snapshot.root.firstChild?.firstChild?.title ?? 'Dashboard',
|
||||
);
|
||||
readonly nav: NavItem[] = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Profil', path: '/profil' },
|
||||
{ label: 'Sicherheit', path: '/account/security' },
|
||||
{
|
||||
label: 'Benachrichtigungen',
|
||||
path: '/notifications',
|
||||
permission: 'notifications.readOwn',
|
||||
},
|
||||
{ label: 'Sessions', path: '/sessions', permission: 'sessions.readOwn' },
|
||||
{ label: 'Items', path: '/items', permission: 'items.read' },
|
||||
{ label: 'Admin Benutzer', path: '/admin/users', permission: 'users.read' },
|
||||
{ label: 'Admin Rollen', path: '/admin/roles', permission: 'roles.read' },
|
||||
{ label: 'Admin Audit', path: '/admin/audit', permission: 'audit.read' },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.auth.loadMe();
|
||||
effect(() => {
|
||||
if (this.auth.user()) {
|
||||
this.notifications.startPolling();
|
||||
} else {
|
||||
this.notifications.stopPolling();
|
||||
this.notificationPanelOpen.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
visible(item: NavItem): boolean {
|
||||
return !item.permission || this.auth.has(item.permission);
|
||||
}
|
||||
|
||||
toggleNotifications(): void {
|
||||
this.notificationPanelOpen.update((open) => !open);
|
||||
if (this.notificationPanelOpen()) {
|
||||
this.notifications.openPanel();
|
||||
}
|
||||
}
|
||||
|
||||
toggleDrawer(): void {
|
||||
this.drawerOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
closeDrawerOnNavigation(): void {
|
||||
this.drawerOpen.set(false);
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
closeOverlays(): void {
|
||||
this.drawerOpen.set(false);
|
||||
this.notificationPanelOpen.set(false);
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
13
apps/frontend/src/index.html
Normal file
13
apps/frontend/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Frontend</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
5
apps/frontend/src/main.ts
Normal file
5
apps/frontend/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||
9
apps/frontend/src/styles.scss
Normal file
9
apps/frontend/src/styles.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
@use './styles/tokens';
|
||||
@use './styles/reset';
|
||||
@use './styles/typography';
|
||||
@use './styles/layout';
|
||||
@use './styles/forms';
|
||||
@use './styles/buttons';
|
||||
@use './styles/tables';
|
||||
@use './styles/utilities';
|
||||
@use './styles/animations';
|
||||
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);
|
||||
}
|
||||
76
apps/frontend/src/styles/_tokens.scss
Normal file
76
apps/frontend/src/styles/_tokens.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
:root {
|
||||
--color-primary: #245b7d;
|
||||
--color-primary-hover: #1d4f6d;
|
||||
--color-primary-active: #173f58;
|
||||
--color-primary-subtle: #e7f1f7;
|
||||
--color-secondary: #546475;
|
||||
--color-background: #f5f7fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-text-primary: #182331;
|
||||
--color-text-secondary: #4d5d70;
|
||||
--color-text-muted: #687789;
|
||||
--color-border: #d9e0e8;
|
||||
--color-border-strong: #aeb9c6;
|
||||
--color-focus: #0b72b9;
|
||||
--color-success: #23724d;
|
||||
--color-success-subtle: #e7f5ee;
|
||||
--color-warning: #8a5a0a;
|
||||
--color-warning-subtle: #fff4d8;
|
||||
--color-danger: #a13d3d;
|
||||
--color-danger-subtle: #fbeaea;
|
||||
--color-info: #315f94;
|
||||
--color-info-subtle: #e8f1fb;
|
||||
--color-neutral-subtle: #eef2f6;
|
||||
|
||||
--space-0: 0;
|
||||
--space-1: 0.125rem;
|
||||
--space-2: 0.25rem;
|
||||
--space-3: 0.5rem;
|
||||
--space-4: 0.75rem;
|
||||
--space-5: 1rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-7: 2rem;
|
||||
--space-8: 3rem;
|
||||
--space-9: 4rem;
|
||||
|
||||
--font-family-base:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-md: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.375rem;
|
||||
--font-size-2xl: 1.75rem;
|
||||
--line-height-tight: 1.2;
|
||||
--line-height-base: 1.5;
|
||||
--line-height-relaxed: 1.65;
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 650;
|
||||
--font-weight-bold: 750;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-pill: 999px;
|
||||
--shadow-sm: 0 1px 2px rgb(24 35 49 / 8%);
|
||||
--shadow-md: 0 8px 24px rgb(24 35 49 / 12%);
|
||||
--z-header: 20;
|
||||
--z-drawer: 30;
|
||||
--z-overlay: 40;
|
||||
--z-dialog: 50;
|
||||
--z-toast: 60;
|
||||
--transition-fast: 120ms;
|
||||
--transition-base: 180ms;
|
||||
--container-width: 72rem;
|
||||
--sidebar-width: 16.25rem;
|
||||
--header-height: 4rem;
|
||||
--breakpoint-small: 36rem;
|
||||
--breakpoint-medium: 48rem;
|
||||
--breakpoint-large: 64rem;
|
||||
--breakpoint-wide: 80rem;
|
||||
--touch-target: 2.75rem;
|
||||
--input-height: 2.75rem;
|
||||
--button-height: 2.75rem;
|
||||
}
|
||||
44
apps/frontend/src/styles/_typography.scss
Normal file
44
apps/frontend/src/styles/_typography.scss
Normal file
@@ -0,0 +1,44 @@
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
.ui-page-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
line-height: var(--line-height-tight);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
h2,
|
||||
.ui-section-title {
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: var(--line-height-tight);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
h3,
|
||||
.ui-subsection-title {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: var(--line-height-tight);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ui-text {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.ui-help-text,
|
||||
.ui-meta,
|
||||
.text-muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ui-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
72
apps/frontend/src/styles/_utilities.scss
Normal file
72
apps/frontend/src/styles/_utilities.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-badge {
|
||||
width: fit-content;
|
||||
min-height: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-neutral-subtle);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.ui-badge--success {
|
||||
color: var(--color-success);
|
||||
background: var(--color-success-subtle);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.ui-badge--warning {
|
||||
color: var(--color-warning);
|
||||
background: var(--color-warning-subtle);
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ui-badge--danger {
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-subtle);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ui-badge--info {
|
||||
color: var(--color-info);
|
||||
background: var(--color-info-subtle);
|
||||
border-color: var(--color-info);
|
||||
}
|
||||
8
apps/frontend/src/test-setup.ts
Normal file
8
apps/frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import '@angular/compiler';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting,
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting());
|
||||
10
apps/frontend/tsconfig.app.json
Normal file
10
apps/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,10 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
28
apps/frontend/tsconfig.json
Normal file
28
apps/frontend/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"module": "preserve",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": [],
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"strict": true
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
apps/frontend/tsconfig.spec.json
Normal file
10
apps/frontend/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
13
apps/frontend/vitest.config.ts
Normal file
13
apps/frontend/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.spec.ts'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
globals: true,
|
||||
pool: 'threads',
|
||||
maxWorkers: 1,
|
||||
minWorkers: 1,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user