security
This commit is contained in:
@@ -19,6 +19,14 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/profile/profile.page').then((m) => m.ProfilePageComponent),
|
import('./features/profile/profile.page').then((m) => m.ProfilePageComponent),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'account/security',
|
||||||
|
title: 'Account-Sicherheit',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/account/account-security.page').then(
|
||||||
|
(m) => m.AccountSecurityPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
title: 'Benachrichtigungen',
|
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(
|
expect((fixture.nativeElement as HTMLElement).querySelector('.badge')?.textContent).toContain(
|
||||||
'2',
|
'2',
|
||||||
);
|
);
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Sicherheit');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the mobile drawer and closes it after navigation', async () => {
|
it('opens the mobile drawer and closes it after navigation', async () => {
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export class AppShellComponent {
|
|||||||
readonly nav: NavItem[] = [
|
readonly nav: NavItem[] = [
|
||||||
{ label: 'Dashboard', path: '/' },
|
{ label: 'Dashboard', path: '/' },
|
||||||
{ label: 'Profil', path: '/profil' },
|
{ label: 'Profil', path: '/profil' },
|
||||||
|
{ label: 'Sicherheit', path: '/account/security' },
|
||||||
{
|
{
|
||||||
label: 'Benachrichtigungen',
|
label: 'Benachrichtigungen',
|
||||||
path: '/notifications',
|
path: '/notifications',
|
||||||
|
|||||||
Reference in New Issue
Block a user