generated from bastian/boilerplate
304 lines
9.1 KiB
TypeScript
304 lines
9.1 KiB
TypeScript
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">HausPilot</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]="loginHref()">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]="loginHref()"
|
|
>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: 'Projekte', path: '/projekte', permission: 'projects.use' },
|
|
{ label: 'Einladungen', path: '/einladungen', permission: 'projects.use' },
|
|
{ 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);
|
|
}
|
|
|
|
loginHref(): string {
|
|
const returnTo = this.router.url.startsWith('/einladungen') ? this.router.url : '/';
|
|
return `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|