This commit is contained in:
Bastian Wagner
2026-07-16 16:11:00 +02:00
parent c03b2e17f5
commit 28fc116992
5 changed files with 531 additions and 0 deletions

View File

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

View File

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

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

View File

@@ -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 () => {

View File

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