From 28fc1169921f8e2383a4675cd87a617047aab01c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Thu, 16 Jul 2026 16:11:00 +0200 Subject: [PATCH] security --- apps/frontend/src/app/app.routes.ts | 8 + .../account/account-security.page.spec.ts | 197 +++++++++++ .../features/account/account-security.page.ts | 324 ++++++++++++++++++ .../frontend/src/app/layout/app-shell.spec.ts | 1 + apps/frontend/src/app/layout/app-shell.ts | 1 + 5 files changed, 531 insertions(+) create mode 100644 apps/frontend/src/app/features/account/account-security.page.spec.ts create mode 100644 apps/frontend/src/app/features/account/account-security.page.ts diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index eabe94d..622462b 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -19,6 +19,14 @@ export const routes: Routes = [ 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', diff --git a/apps/frontend/src/app/features/account/account-security.page.spec.ts b/apps/frontend/src/app/features/account/account-security.page.spec.ts new file mode 100644 index 0000000..d8b6710 --- /dev/null +++ b/apps/frontend/src/app/features/account/account-security.page.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/frontend/src/app/features/account/account-security.page.ts b/apps/frontend/src/app/features/account/account-security.page.ts new file mode 100644 index 0000000..618c024 --- /dev/null +++ b/apps/frontend/src/app/features/account/account-security.page.ts @@ -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: ` +
+
+

Account-Sicherheit

+

Uebersicht ueber Ihren Account, Rollen, Berechtigungen und aktive Sessions.

+
+
+ + @if (auth.user(); as user) { +
+
+

Account

+
+
Name
+
{{ user.name }}
+
E-Mail
+
{{ user.email || 'Nicht gesetzt' }}
+
Status
+
+ +
+
Letzte Anmeldung
+
+ {{ user.lastLoginAt ? (user.lastLoginAt | date: 'short') : 'Noch nicht bekannt' }} +
+
+
+ +
+

Identity Provider

+

+ Name und E-Mail werden zentral vom Identity Provider verwaltet. Aenderungen erfolgen + nicht in dieser Anwendung. +

+

OIDC-Session mit serverseitig gespeicherten Tokens.

+
+
+ +
+

Rollen

+ @if (user.roles.length > 0) { +
+ @for (role of user.roles; track role.id) { + + {{ role.name }}{{ role.system ? ' - Systemrolle' : '' }} + + } +
+ } @else { +

Keine Rollen zugewiesen.

+ } +
+ +
+

Effektive Berechtigungen

+ @if (permissionGroups().length > 0) { +
+ @for (group of permissionGroups(); track group.title) { +
+

{{ group.title }}

+
    + @for (permission of group.permissions; track permission.id) { +
  • + {{ permission.id }} + {{ permission.label }} +
  • + } +
+
+ } +
+ } @else { +

Keine Berechtigungen ueber Rollen zugewiesen.

+ } +
+ +
+
+
+

Aktive Sessions

+

Beenden Sie nicht mehr benoetigte Browser-Sessions.

+
+ +
+ + @if (loading()) { +

Sessions werden geladen.

+ } @else if (error(); as currentError) { +

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

+ } @else if (sessions().length === 0) { + + } @else { +
+ @for (session of sessions(); track session.id) { +
+
+ {{ session.current ? 'Aktuelle Session' : 'Weitere Session' }} + Angemeldet: {{ session.createdAt | date: 'short' }} + Letzte Aktivitaet: {{ session.lastActivityAt | date: 'short' }} + {{ session.userAgent || 'Unbekannter Browser' }} + {{ session.approximateIp || 'IP unbekannt' }} +
+ @if (session.current) { +

Diese Session beenden Sie ueber Abmelden.

+ } @else if (!session.revokedAt) { + + } +
+ } +
+ } +
+ } + `, + 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([]); + readonly loading = signal(false); + readonly error = signal(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); + } +} diff --git a/apps/frontend/src/app/layout/app-shell.spec.ts b/apps/frontend/src/app/layout/app-shell.spec.ts index c6dc10f..4007fb2 100644 --- a/apps/frontend/src/app/layout/app-shell.spec.ts +++ b/apps/frontend/src/app/layout/app-shell.spec.ts @@ -61,6 +61,7 @@ describe('AppShellComponent', () => { 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 () => { diff --git a/apps/frontend/src/app/layout/app-shell.ts b/apps/frontend/src/app/layout/app-shell.ts index 881f87d..45d621a 100644 --- a/apps/frontend/src/app/layout/app-shell.ts +++ b/apps/frontend/src/app/layout/app-shell.ts @@ -244,6 +244,7 @@ export class AppShellComponent { readonly nav: NavItem[] = [ { label: 'Dashboard', path: '/' }, { label: 'Profil', path: '/profil' }, + { label: 'Sicherheit', path: '/account/security' }, { label: 'Benachrichtigungen', path: '/notifications',