security
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user