This commit is contained in:
Bastian Wagner
2026-07-15 14:09:28 +02:00
commit 3e5348b7ec
104 changed files with 30367 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { AuthService } from './shared/auth.service';
@Component({
selector: 'app-root',
imports: [RouterLink, RouterOutlet],
template: `
<header class="topbar">
<a class="brand" routerLink="/login">LDAP Portal</a>
<nav>
@if (auth.username()) {
<a routerLink="/account">Account</a>
<a routerLink="/account/password">Passwort</a>
<a routerLink="/admin/oidc-clients">OIDC</a>
<a routerLink="/admin/registrations">Registrierungen</a>
<a routerLink="/admin/users">Nutzer</a>
<a routerLink="/admin/groups">Gruppen</a>
<a routerLink="/admin/audit">Audit</a>
<button type="button" class="link-button" (click)="auth.logout()">Abmelden</button>
} @else {
<a routerLink="/login">Login</a>
<a routerLink="/register">Registrieren</a>
}
</nav>
</header>
<main>
<router-outlet />
</main>
`,
})
export class AppComponent {
constructor(readonly auth: AuthService) {}
}

View File

@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-account-edit',
imports: [ReactiveFormsModule],
template: `
<section class="panel wide">
<h1>Profil bearbeiten</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="grid">
<label>Anzeigename <input formControlName="displayName"></label>
<label>Vorname <input formControlName="firstName"></label>
</div>
<label>Nachname <input formControlName="lastName"></label>
<label>Avatar JPEG Base64 <textarea rows="5" formControlName="avatar"></textarea></label>
@if (message()) { <p class="message" [class.error]="failed()">{{ message() }}</p> }
<button type="submit" [disabled]="form.invalid || loading()">Speichern</button>
</form>
</section>
`,
})
export class AccountEditComponent implements OnInit {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
displayName: ['', Validators.required],
firstName: [''],
lastName: [''],
avatar: [''],
});
}
ngOnInit(): void {
this.http.get<any>(`${this.apiBaseUrl}/account/me`).subscribe({
next: (account) => this.form.patchValue(account),
error: (error) => this.message.set(apiErrorMessage(error)),
});
}
submit(): void {
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.patch(`${this.apiBaseUrl}/account/profile`, this.form.getRawValue()).subscribe({
next: () => this.message.set('Profil wurde aktualisiert.'),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,66 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-account-email',
imports: [ReactiveFormsModule],
template: `
<section class="panel">
<h1>E-Mail aendern</h1>
<form [formGroup]="form" (ngSubmit)="request()">
<label>Neue E-Mail <input type="email" formControlName="newEmail"></label>
@if (message()) { <p class="message" [class.error]="failed()">{{ message() }}</p> }
<button type="submit" [disabled]="form.invalid || loading()">Bestaetigung senden</button>
</form>
</section>
`,
})
export class AccountEmailComponent implements OnInit {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly route: ActivatedRoute,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
newEmail: ['', [Validators.required, Validators.email]],
});
}
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token');
if (token) {
this.http.post<{ message: string }>(`${this.apiBaseUrl}/account/email-change/confirm`, { token }).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
},
});
}
}
request(): void {
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.post<{ message: string }>(`${this.apiBaseUrl}/account/email-change/request`, this.form.getRawValue()).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,176 @@
import { HttpClient } from '@angular/common/http';
import { JsonPipe } from '@angular/common';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
interface AttributeSchema {
name: string;
attributeType: string;
isList: boolean;
isVisible: boolean;
isEditable: boolean;
isHardcoded: boolean;
isReadonly: boolean;
}
interface AttributeValue {
name: string;
value: string[];
schema: AttributeSchema;
}
interface AccountGroup {
id: number;
displayName: string;
creationDate: string;
uuid: string;
attributes: AttributeValue[];
}
interface AccountUser {
id: string;
email: string;
displayName: string;
firstName: string;
lastName: string;
avatar?: string | null;
creationDate: string;
uuid: string;
attributes: AttributeValue[];
groups: AccountGroup[];
}
@Component({
selector: 'app-account-overview',
imports: [JsonPipe, RouterLink],
template: `
<section class="account-layout">
<header class="account-header">
<div>
<h1>Account</h1>
@if (account()) {
<p>{{ account()?.displayName || account()?.id }}</p>
}
</div>
<div class="row-actions">
<a class="button-link" routerLink="/account/edit">Profil bearbeiten</a>
<a class="button-link" routerLink="/account/email">E-Mail aendern</a>
<a class="button-link" routerLink="/account/password">Passwort aendern</a>
</div>
</header>
@if (loading()) {
<section class="panel wide">
<p class="message">Accountdaten werden geladen.</p>
</section>
} @else if (message()) {
<section class="panel wide">
<p class="message error">{{ message() }}</p>
</section>
} @else if (account()) {
<section class="overview-grid">
<article class="info-panel">
<h2>Profil</h2>
@if (account()?.avatar) {
<img class="avatar" [src]="'data:image/jpeg;base64,' + account()?.avatar" alt="">
}
<dl>
<div><dt>Benutzername</dt><dd>{{ account()?.id }}</dd></div>
<div><dt>Anzeigename</dt><dd>{{ account()?.displayName || '-' }}</dd></div>
<div><dt>E-Mail</dt><dd>{{ account()?.email || '-' }}</dd></div>
<div><dt>Vorname</dt><dd>{{ account()?.firstName || '-' }}</dd></div>
<div><dt>Nachname</dt><dd>{{ account()?.lastName || '-' }}</dd></div>
<div><dt>UUID</dt><dd>{{ account()?.uuid }}</dd></div>
<div><dt>Erstellt</dt><dd>{{ account()?.creationDate }}</dd></div>
</dl>
</article>
<article class="info-panel">
<h2>Gruppen</h2>
@if (account()?.groups?.length) {
<div class="group-list">
@for (group of account()?.groups; track group.id) {
<div class="group-item">
<strong>{{ group.displayName }}</strong>
<span>#{{ group.id }}</span>
<small>{{ group.uuid }}</small>
@if (group.attributes.length) {
<div class="attribute-mini-list">
@for (attribute of group.attributes; track attribute.name) {
<span>{{ attribute.name }}: {{ formatValues(attribute.value) }}</span>
}
</div>
}
</div>
}
</div>
} @else {
<p class="muted">Keine Gruppenmitgliedschaften gefunden.</p>
}
</article>
</section>
<section class="info-panel full">
<h2>Attribute</h2>
@if (account()?.attributes?.length) {
<div class="attribute-table">
@for (attribute of account()?.attributes; track attribute.name) {
<div class="attribute-row">
<div>
<strong>{{ attribute.name }}</strong>
<small>{{ attribute.schema.attributeType }}{{ attribute.schema.isList ? ', Liste' : '' }}</small>
</div>
<div>{{ formatValues(attribute.value) }}</div>
<div class="flags">
@if (attribute.schema.isVisible) { <span>sichtbar</span> }
@if (attribute.schema.isEditable) { <span>editierbar</span> }
@if (attribute.schema.isReadonly) { <span>readonly</span> }
@if (attribute.schema.isHardcoded) { <span>system</span> }
</div>
</div>
}
</div>
} @else {
<p class="muted">Keine zusaetzlichen Attribute gefunden.</p>
}
</section>
<section class="info-panel full">
<h2>LLDAP Rohdaten</h2>
<pre>{{ account() | json }}</pre>
</section>
}
</section>
`,
})
export class AccountOverviewComponent implements OnInit {
readonly loading = signal(true);
readonly message = signal('');
readonly account = signal<AccountUser | null>(null);
constructor(
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
ngOnInit(): void {
this.http.get<AccountUser>(`${this.apiBaseUrl}/account/me`).subscribe({
next: (account) => this.account.set(account),
error: (error) => {
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
formatValues(values: string[]): string {
if (!values.length) {
return '-';
}
return values.map((value) => (value.length > 160 ? `${value.slice(0, 160)}...` : value)).join(', ');
}
}

View File

@@ -0,0 +1,73 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
import { AuthService } from '../shared/auth.service';
@Component({
selector: 'app-account-password',
imports: [ReactiveFormsModule],
template: `
<section class="panel">
<h1>Passwort aendern</h1>
<p class="account">{{ auth.username() }}</p>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
Aktuelles Passwort
<input type="password" formControlName="currentPassword" autocomplete="current-password">
</label>
<label>
Neues Passwort
<input type="password" formControlName="newPassword" autocomplete="new-password">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Speichern laeuft' : 'Passwort aendern' }}
</button>
</form>
</section>
`,
})
export class AccountPasswordComponent {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
readonly auth: AuthService,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
currentPassword: ['', Validators.required],
newPassword: ['', [Validators.required, Validators.minLength(12)]],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.post<{ message: string }>(`${this.apiBaseUrl}/password/change`, this.form.getRawValue()).subscribe({
next: (response) => {
this.message.set(response.message);
this.form.reset();
},
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,35 @@
import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-audit',
imports: [JsonPipe],
template: `
<section class="account-layout">
<h1>Audit</h1>
@if (message()) { <p class="message error">{{ message() }}</p> }
<section class="info-panel full">
<div class="client-list">
@for (event of events(); track event.id) {
<article class="client-item">
<strong>{{ event.type }}</strong>
<span>{{ event.username || '-' }} · {{ event.createdAt }}</span>
<pre>{{ event.metadata | json }}</pre>
</article>
}
</div>
</section>
</section>
`,
})
export class AdminAuditComponent implements OnInit {
readonly events = signal<any[]>([]);
readonly message = signal('');
constructor(private readonly http: HttpClient, @Inject(API_BASE_URL) private readonly apiBaseUrl: string) {}
ngOnInit(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/audit`).subscribe({ next: (events) => this.events.set(events), error: (e) => this.message.set(apiErrorMessage(e)) });
}
}

View File

@@ -0,0 +1,59 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-groups',
imports: [ReactiveFormsModule],
template: `
<section class="account-layout">
<h1>Gruppen</h1>
<section class="overview-grid">
<article class="info-panel">
<h2>Neue Gruppe</h2>
<form [formGroup]="form" (ngSubmit)="create()">
<label>Name <input formControlName="displayName"></label>
<button type="submit" [disabled]="form.invalid">Erstellen</button>
</form>
</article>
<article class="info-panel">
@if (message()) { <p class="message error">{{ message() }}</p> }
</article>
</section>
<section class="info-panel full">
<div class="client-list">
@for (group of groups(); track group.id) {
<article class="client-item">
<strong>{{ group.displayName }}</strong>
<span>#{{ group.id }} · {{ group.uuid }}</span>
<small>{{ group.users?.length || 0 }} Mitglieder</small>
<div class="row-actions">
<button type="button" class="danger-action" (click)="delete(group.id)">Loeschen</button>
</div>
</article>
}
</div>
</section>
</section>
`,
})
export class AdminGroupsComponent implements OnInit {
readonly groups = signal<any[]>([]);
readonly message = signal('');
readonly form;
constructor(private readonly fb: FormBuilder, private readonly http: HttpClient, @Inject(API_BASE_URL) private readonly apiBaseUrl: string) {
this.form = this.fb.nonNullable.group({ displayName: ['', Validators.required] });
}
ngOnInit(): void { this.load(); }
create(): void {
this.http.post(`${this.apiBaseUrl}/admin/groups`, this.form.getRawValue()).subscribe({ next: () => { this.form.reset(); this.load(); }, error: (e) => this.message.set(apiErrorMessage(e)) });
}
delete(id: number): void {
this.http.delete(`${this.apiBaseUrl}/admin/groups/${id}`).subscribe({ next: () => this.load(), error: (e) => this.message.set(apiErrorMessage(e)) });
}
private load(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/groups`).subscribe({ next: (groups) => this.groups.set(groups), error: (e) => this.message.set(apiErrorMessage(e)) });
}
}

View File

@@ -0,0 +1,218 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
interface OidcClient {
id: string;
clientId: string;
clientName: string;
tokenEndpointAuthMethod: string;
redirectUris: string[];
postLogoutRedirectUris: string[];
grantTypes: string[];
responseTypes: string[];
scope: string;
firstParty: boolean;
enabled: boolean;
includeGroups: boolean;
}
interface CreatedOidcClient extends OidcClient {
clientSecret?: string;
}
@Component({
selector: 'app-admin-oidc-clients',
imports: [ReactiveFormsModule],
template: `
<section class="account-layout">
<header class="account-header">
<div>
<h1>OIDC Clients</h1>
<p>Clients fuer OpenID Connect Web-SSO verwalten.</p>
</div>
</header>
<section class="overview-grid">
<article class="info-panel">
<h2>Neuer Client</h2>
<form [formGroup]="form" (ngSubmit)="create()">
<label>
Name
<input formControlName="clientName">
</label>
<label>
Redirect URIs
<textarea formControlName="redirectUris" rows="4"></textarea>
</label>
<label>
Logout Redirect URIs
<textarea formControlName="postLogoutRedirectUris" rows="3"></textarea>
</label>
<label>
Scopes
<input formControlName="scope">
</label>
<label class="check-row">
<input type="checkbox" formControlName="publicClient">
Public Client ohne Secret
</label>
<label class="check-row">
<input type="checkbox" formControlName="firstParty">
First-Party Client
</label>
<label class="check-row">
<input type="checkbox" formControlName="includeGroups">
Gruppen-Claim ausgeben
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">Client erstellen</button>
</form>
@if (createdSecret()) {
<div class="secret-box">
<strong>Client Secret</strong>
<code>{{ createdSecret() }}</code>
</div>
}
</article>
<article class="info-panel">
<h2>Discovery</h2>
<dl>
<div><dt>Configuration</dt><dd>{{ apiBaseUrl }}/.well-known/openid-configuration</dd></div>
<div><dt>Authorize</dt><dd>{{ apiBaseUrl }}/oidc/auth</dd></div>
<div><dt>Token</dt><dd>{{ apiBaseUrl }}/oidc/token</dd></div>
<div><dt>UserInfo</dt><dd>{{ apiBaseUrl }}/oidc/me</dd></div>
<div><dt>JWKS</dt><dd>{{ apiBaseUrl }}/oidc/jwks</dd></div>
</dl>
</article>
</section>
<section class="info-panel full">
<h2>Registrierte Clients</h2>
@if (clients().length) {
<div class="client-list">
@for (client of clients(); track client.id) {
<article class="client-item">
<div>
<strong>{{ client.clientName }}</strong>
<code>{{ client.clientId }}</code>
</div>
<div class="flags">
<span>{{ client.enabled ? 'aktiv' : 'deaktiviert' }}</span>
<span>{{ client.tokenEndpointAuthMethod }}</span>
@if (client.firstParty) { <span>first-party</span> }
@if (client.includeGroups) { <span>groups</span> }
</div>
<dl>
<div><dt>Redirect URIs</dt><dd>{{ client.redirectUris.join(', ') }}</dd></div>
<div><dt>Scopes</dt><dd>{{ client.scope }}</dd></div>
</dl>
<div class="row-actions">
<button type="button" class="secondary-action" (click)="toggle(client)">
{{ client.enabled ? 'Deaktivieren' : 'Aktivieren' }}
</button>
<button type="button" class="danger-action" (click)="delete(client)">Loeschen</button>
</div>
</article>
}
</div>
} @else {
<p class="muted">Noch keine OIDC-Clients vorhanden.</p>
}
</section>
</section>
`,
})
export class AdminOidcClientsComponent implements OnInit {
readonly clients = signal<OidcClient[]>([]);
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly createdSecret = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
clientName: ['', Validators.required],
redirectUris: ['http://localhost:8080/callback', Validators.required],
postLogoutRedirectUris: [''],
scope: ['openid profile email groups'],
publicClient: [false],
firstParty: [false],
includeGroups: [true],
});
}
ngOnInit(): void {
this.load();
}
create(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.createdSecret.set('');
const value = this.form.getRawValue();
this.http
.post<CreatedOidcClient>(`${this.apiBaseUrl}/admin/oidc/clients`, {
...value,
redirectUris: this.lines(value.redirectUris),
postLogoutRedirectUris: this.lines(value.postLogoutRedirectUris),
})
.subscribe({
next: (client) => {
this.createdSecret.set(client.clientSecret ?? '');
this.message.set('Client wurde erstellt.');
this.load();
},
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
toggle(client: OidcClient): void {
this.http
.patch<OidcClient>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`, { enabled: !client.enabled })
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
}
delete(client: OidcClient): void {
this.http
.delete<void>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`)
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
}
private load(): void {
this.http.get<OidcClient[]>(`${this.apiBaseUrl}/admin/oidc/clients`).subscribe({
next: (clients) => this.clients.set(clients),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
},
});
}
private lines(value: string): string[] {
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
}

View File

@@ -0,0 +1,57 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-registrations',
template: `
<section class="account-layout">
<h1>Registrierungen</h1>
@if (message()) { <p class="message" [class.error]="failed()">{{ message() }}</p> }
<section class="info-panel full">
<div class="client-list">
@for (item of registrations(); track item.id) {
<article class="client-item">
<strong>{{ item.username }}</strong>
<span>{{ item.email }} · {{ item.status }}</span>
<small>{{ item.createdAt }}</small>
@if (item.status === 'pending_approval') {
<div class="row-actions">
<button type="button" class="secondary-action" (click)="approve(item.id)">Freigeben</button>
<button type="button" class="danger-action" (click)="reject(item.id)">Ablehnen</button>
</div>
}
</article>
}
</div>
</section>
</section>
`,
})
export class AdminRegistrationsComponent implements OnInit {
readonly registrations = signal<any[]>([]);
readonly message = signal('');
readonly failed = signal(false);
constructor(private readonly http: HttpClient, @Inject(API_BASE_URL) private readonly apiBaseUrl: string) {}
ngOnInit(): void { this.load(); }
approve(id: string): void {
this.http.post(`${this.apiBaseUrl}/admin/registrations/${id}/approve`, {}).subscribe({ next: () => this.load(), error: (e) => this.error(e) });
}
reject(id: string): void {
this.http.post(`${this.apiBaseUrl}/admin/registrations/${id}/reject`, {}).subscribe({ next: () => this.load(), error: (e) => this.error(e) });
}
private load(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/registrations`).subscribe({ next: (items) => this.registrations.set(items), error: (e) => this.error(e) });
}
private error(error: unknown): void {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
}
}

View File

@@ -0,0 +1,94 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-admin-users',
imports: [ReactiveFormsModule],
template: `
<section class="account-layout">
<h1>Nutzer</h1>
@if (message()) { <p class="message error">{{ message() }}</p> }
<section class="info-panel full">
<div class="client-list">
@for (user of users(); track user.id) {
<article class="client-item">
<strong>{{ user.displayName || user.id }}</strong>
<span>{{ user.id }} · {{ user.email }}</span>
<div class="flags">
@for (group of user.groups; track group.id) { <span>{{ group.displayName }}</span> }
</div>
<form class="inline-form" [formGroup]="groupForms[user.id]" (ngSubmit)="addGroup(user.id)">
<select formControlName="groupId">
<option value="">Gruppe auswaehlen</option>
@for (group of groups(); track group.id) {
<option [value]="group.id">{{ group.displayName }}</option>
}
</select>
<button type="submit" class="secondary-action">Hinzufuegen</button>
</form>
<div class="row-actions">
@for (group of user.groups; track group.id) {
<button type="button" class="secondary-action" (click)="removeGroup(user.id, group.id)">
{{ group.displayName }} entfernen
</button>
}
<button type="button" class="danger-action" (click)="delete(user.id)">Loeschen</button>
</div>
</article>
}
</div>
</section>
</section>
`,
})
export class AdminUsersComponent implements OnInit {
readonly users = signal<any[]>([]);
readonly groups = signal<any[]>([]);
readonly message = signal('');
readonly groupForms: Record<string, any> = {};
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
ngOnInit(): void {
this.load();
this.http.get<any[]>(`${this.apiBaseUrl}/admin/groups`).subscribe({
next: (groups) => this.groups.set(groups),
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
delete(id: string): void {
this.http.delete(`${this.apiBaseUrl}/admin/users/${id}`).subscribe({ next: () => this.load(), error: (e) => this.message.set(apiErrorMessage(e)) });
}
addGroup(id: string): void {
const groupId = this.groupForms[id]?.value.groupId;
if (!groupId) {
return;
}
this.http.patch(`${this.apiBaseUrl}/admin/users/${id}/groups/${groupId}`, {}).subscribe({
next: () => this.load(),
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
removeGroup(id: string, groupId: number): void {
this.http.delete(`${this.apiBaseUrl}/admin/users/${id}/groups/${groupId}`).subscribe({
next: () => this.load(),
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
private load(): void {
this.http.get<any[]>(`${this.apiBaseUrl}/admin/users`).subscribe({
next: (users) => {
users.forEach((user) => {
this.groupForms[user.id] ??= this.fb.nonNullable.group({ groupId: ['', Validators.required] });
});
this.users.set(users);
},
error: (e) => this.message.set(apiErrorMessage(e)),
});
}
}

View File

@@ -0,0 +1,68 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-forgot-password',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel">
<h1>Passwort zuruecksetzen</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
E-Mail
<input type="email" formControlName="email" autocomplete="email">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Senden laeuft' : 'Reset-Link senden' }}
</button>
</form>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
</section>
`,
})
export class ForgotPasswordComponent {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http
.post<{ message: string }>(`${this.apiBaseUrl}/password/reset/request`, this.form.getRawValue())
.subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,68 @@
import { Component, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { AuthService } from '../shared/auth.service';
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel">
<h1>Anmelden</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
Benutzername
<input formControlName="username" autocomplete="username">
</label>
<label>
Passwort
<input type="password" formControlName="password" autocomplete="current-password">
</label>
@if (message()) {
<p class="message error">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Anmeldung laeuft' : 'Anmelden' }}
</button>
</form>
<div class="actions">
<a routerLink="/forgot-password">Passwort vergessen</a>
<a routerLink="/register">Neues Konto</a>
</div>
</section>
`,
})
export class LoginComponent {
readonly loading = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly auth: AuthService,
private readonly router: Router,
) {
this.form = this.fb.nonNullable.group({
username: ['', Validators.required],
password: ['', Validators.required],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.message.set('');
this.auth.login(this.form.value.username ?? '', this.form.value.password ?? '').subscribe({
next: () => void this.router.navigateByUrl('/account'),
error: (error) => {
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,83 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-register',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel wide">
<h1>Registrieren</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="grid">
<label>
Benutzername
<input formControlName="username" autocomplete="username">
</label>
<label>
Anzeigename
<input formControlName="displayName" autocomplete="name">
</label>
</div>
<label>
E-Mail
<input type="email" formControlName="email" autocomplete="email">
</label>
<label>
Passwort
<input type="password" formControlName="password" autocomplete="new-password">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">
{{ loading() ? 'Registrierung laeuft' : 'Registrieren' }}
</button>
</form>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
</section>
`,
})
export class RegisterComponent {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
username: ['', [Validators.required, Validators.minLength(3), Validators.pattern(/^[a-zA-Z0-9._-]+$/)]],
displayName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(12)]],
});
}
submit(): void {
if (this.form.invalid) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http.post<{ message: string }>(`${this.apiBaseUrl}/registration`, this.form.getRawValue()).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,81 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-reset-password',
imports: [ReactiveFormsModule, RouterLink],
template: `
<section class="panel">
<h1>Neues Passwort</h1>
<form [formGroup]="form" (ngSubmit)="submit()">
<label>
Passwort
<input type="password" formControlName="newPassword" autocomplete="new-password">
</label>
@if (message()) {
<p class="message" [class.error]="failed()">{{ message() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading() || !token()">
{{ loading() ? 'Speichern laeuft' : 'Passwort speichern' }}
</button>
</form>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
</section>
`,
})
export class ResetPasswordComponent implements OnInit {
readonly loading = signal(false);
readonly failed = signal(false);
readonly message = signal('');
readonly token = signal<string | null>(null);
readonly form;
constructor(
private readonly fb: FormBuilder,
private readonly route: ActivatedRoute,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {
this.form = this.fb.nonNullable.group({
newPassword: ['', [Validators.required, Validators.minLength(12)]],
});
}
ngOnInit(): void {
this.token.set(this.route.snapshot.queryParamMap.get('token'));
if (!this.token()) {
this.failed.set(true);
this.message.set('Der Reset-Link ist unvollstaendig.');
}
}
submit(): void {
if (this.form.invalid || !this.token()) {
return;
}
this.loading.set(true);
this.failed.set(false);
this.message.set('');
this.http
.post<{ message: string }>(`${this.apiBaseUrl}/password/reset/confirm`, {
token: this.token(),
newPassword: this.form.value.newPassword,
})
.subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,54 @@
import { HttpClient } from '@angular/common/http';
import { Component, Inject, OnInit, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { apiErrorMessage } from '../shared/api-error';
import { API_BASE_URL } from '../shared/api-base-url';
@Component({
selector: 'app-verify-email',
imports: [RouterLink],
template: `
<section class="panel">
<h1>E-Mail bestaetigen</h1>
@if (loading()) {
<p class="message">Bestaetigung wird verarbeitet.</p>
} @else {
<p class="message" [class.error]="failed()">{{ message() }}</p>
<div class="actions">
<a routerLink="/login">Zum Login</a>
</div>
}
</section>
`,
})
export class VerifyEmailComponent implements OnInit {
readonly loading = signal(true);
readonly failed = signal(false);
readonly message = signal('');
constructor(
private readonly route: ActivatedRoute,
private readonly http: HttpClient,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token');
if (!token) {
this.failed.set(true);
this.loading.set(false);
this.message.set('Der Bestaetigungslink ist unvollstaendig.');
return;
}
this.http.post<{ message: string }>(`${this.apiBaseUrl}/registration/verify`, { token }).subscribe({
next: (response) => this.message.set(response.message),
error: (error) => {
this.failed.set(true);
this.message.set(apiErrorMessage(error));
this.loading.set(false);
},
complete: () => this.loading.set(false),
});
}
}

View File

@@ -0,0 +1,3 @@
import { InjectionToken } from '@angular/core';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

View File

@@ -0,0 +1,15 @@
import { HttpErrorResponse } from '@angular/common/http';
export function apiErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const message = error.error?.message;
if (Array.isArray(message)) {
return message.join(' ');
}
if (typeof message === 'string') {
return message;
}
}
return 'Die Anfrage konnte nicht verarbeitet werden.';
}

View File

@@ -0,0 +1,42 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { tap } from 'rxjs';
import { API_BASE_URL } from './api-base-url';
interface LoginResponse {
accessToken: string;
user: {
username: string;
};
}
@Injectable({ providedIn: 'root' })
export class AuthService {
readonly username = signal<string | null>(localStorage.getItem('username'));
constructor(
private readonly http: HttpClient,
private readonly router: Router,
@Inject(API_BASE_URL) private readonly apiBaseUrl: string,
) {}
login(username: string, password: string) {
return this.http
.post<LoginResponse>(`${this.apiBaseUrl}/auth/login`, { username, password })
.pipe(
tap((response) => {
localStorage.setItem('accessToken', response.accessToken);
localStorage.setItem('username', response.user.username);
this.username.set(response.user.username);
}),
);
}
logout(): void {
localStorage.removeItem('accessToken');
localStorage.removeItem('username');
this.username.set(null);
void this.router.navigateByUrl('/login');
}
}

1
apps/web/src/favicon.ico Normal file
View File

@@ -0,0 +1 @@

12
apps/web/src/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>LDAP Portal</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<app-root></app-root>
</body>
</html>

63
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,63 @@
import { HttpInterceptorFn, provideHttpClient, withInterceptors } from '@angular/common/http';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, Routes } from '@angular/router';
import { AppComponent } from './app/app.component';
import { AccountPasswordComponent } from './app/pages/account-password.component';
import { AccountOverviewComponent } from './app/pages/account-overview.component';
import { AccountEditComponent } from './app/pages/account-edit.component';
import { AccountEmailComponent } from './app/pages/account-email.component';
import { AdminAuditComponent } from './app/pages/admin-audit.component';
import { AdminGroupsComponent } from './app/pages/admin-groups.component';
import { AdminOidcClientsComponent } from './app/pages/admin-oidc-clients.component';
import { AdminRegistrationsComponent } from './app/pages/admin-registrations.component';
import { AdminUsersComponent } from './app/pages/admin-users.component';
import { ForgotPasswordComponent } from './app/pages/forgot-password.component';
import { LoginComponent } from './app/pages/login.component';
import { RegisterComponent } from './app/pages/register.component';
import { ResetPasswordComponent } from './app/pages/reset-password.component';
import { VerifyEmailComponent } from './app/pages/verify-email.component';
import { API_BASE_URL } from './app/shared/api-base-url';
import { AuthService } from './app/shared/auth.service';
const authInterceptor: HttpInterceptorFn = (request, next) => {
const token = localStorage.getItem('accessToken');
if (!token) {
return next(request);
}
return next(
request.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
}),
);
};
const routes: Routes = [
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent },
{ path: 'verify-email', component: VerifyEmailComponent },
{ path: 'forgot-password', component: ForgotPasswordComponent },
{ path: 'reset-password', component: ResetPasswordComponent },
{ path: 'account', component: AccountOverviewComponent },
{ path: 'account/edit', component: AccountEditComponent },
{ path: 'account/email', component: AccountEmailComponent },
{ path: 'account/password', component: AccountPasswordComponent },
{ path: 'admin/oidc-clients', component: AdminOidcClientsComponent },
{ path: 'admin/registrations', component: AdminRegistrationsComponent },
{ path: 'admin/users', component: AdminUsersComponent },
{ path: 'admin/groups', component: AdminGroupsComponent },
{ path: 'admin/audit', component: AdminAuditComponent },
{ path: '**', redirectTo: 'login' },
];
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
AuthService,
{ provide: API_BASE_URL, useValue: 'http://localhost:3000' },
],
}).catch((error) => console.error(error));

441
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,441 @@
:root {
color-scheme: light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f7f9;
color: #18202a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
a {
color: #0f6b6e;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.topbar {
align-items: center;
background: #ffffff;
border-bottom: 1px solid #d8e0e7;
display: flex;
gap: 24px;
justify-content: space-between;
min-height: 64px;
padding: 0 32px;
}
.brand {
color: #18202a;
font-weight: 700;
}
nav {
align-items: center;
display: flex;
gap: 18px;
}
.link-button {
background: transparent;
border: 0;
color: #0f6b6e;
cursor: pointer;
font: inherit;
padding: 0;
}
main {
display: grid;
min-height: calc(100vh - 64px);
padding: 48px 20px;
place-items: start center;
}
.panel {
background: #ffffff;
border: 1px solid #d8e0e7;
border-radius: 8px;
box-shadow: 0 16px 40px rgb(24 32 42 / 8%);
max-width: 440px;
padding: 28px;
width: 100%;
}
.panel.wide {
max-width: 640px;
}
h1 {
font-size: 1.5rem;
line-height: 1.2;
margin: 0 0 24px;
}
form {
display: grid;
gap: 18px;
}
.grid {
display: grid;
gap: 18px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
color: #3a4551;
display: grid;
font-size: 0.92rem;
font-weight: 600;
gap: 7px;
}
input {
border: 1px solid #bcc8d3;
border-radius: 6px;
color: #18202a;
font: inherit;
min-height: 44px;
padding: 10px 12px;
width: 100%;
}
textarea {
border: 1px solid #bcc8d3;
border-radius: 6px;
color: #18202a;
font: inherit;
padding: 10px 12px;
resize: vertical;
width: 100%;
}
input:focus {
border-color: #0f6b6e;
box-shadow: 0 0 0 3px rgb(15 107 110 / 15%);
outline: 0;
}
textarea:focus {
border-color: #0f6b6e;
box-shadow: 0 0 0 3px rgb(15 107 110 / 15%);
outline: 0;
}
button[type="submit"] {
background: #0f6b6e;
border: 1px solid #0f6b6e;
border-radius: 6px;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
min-height: 44px;
padding: 10px 14px;
}
button[type="submit"]:disabled {
background: #a9b8c4;
border-color: #a9b8c4;
cursor: not-allowed;
}
.message {
background: #edf7f4;
border: 1px solid #b8ddd3;
border-radius: 6px;
color: #24564f;
margin: 0;
padding: 12px;
}
.message.error {
background: #fff1f0;
border-color: #efb5ae;
color: #8d2b20;
}
.actions {
display: flex;
gap: 18px;
justify-content: space-between;
margin-top: 22px;
}
.account {
color: #637083;
margin: -12px 0 24px;
}
.account-layout {
display: grid;
gap: 22px;
max-width: 1120px;
width: 100%;
}
.account-header {
align-items: end;
display: flex;
gap: 16px;
justify-content: space-between;
}
.account-header h1,
.account-header p {
margin: 0;
}
.account-header p {
color: #637083;
margin-top: 6px;
}
.button-link {
background: #0f6b6e;
border-radius: 6px;
color: #ffffff;
font-weight: 700;
padding: 10px 14px;
}
.button-link:hover {
text-decoration: none;
}
.overview-grid {
display: grid;
gap: 22px;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.info-panel {
background: #ffffff;
border: 1px solid #d8e0e7;
border-radius: 8px;
padding: 22px;
}
.info-panel.full {
width: 100%;
}
.info-panel h2 {
font-size: 1.08rem;
margin: 0 0 16px;
}
.avatar {
border-radius: 8px;
display: block;
height: 96px;
margin-bottom: 16px;
object-fit: cover;
width: 96px;
}
dl {
display: grid;
gap: 12px;
margin: 0;
}
dl div,
.attribute-row {
display: grid;
gap: 6px;
}
dt {
color: #637083;
font-size: 0.82rem;
font-weight: 700;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
.group-list,
.attribute-table,
.client-list {
display: grid;
gap: 12px;
}
.group-item,
.attribute-row,
.client-item {
border: 1px solid #d8e0e7;
border-radius: 6px;
padding: 12px;
}
.group-item {
display: grid;
gap: 5px;
}
.client-item {
display: grid;
gap: 12px;
}
.group-item span,
.group-item small,
.attribute-row small,
.muted {
color: #637083;
}
.attribute-mini-list,
.flags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.attribute-mini-list span,
.flags span {
background: #edf2f5;
border-radius: 999px;
color: #3a4551;
font-size: 0.78rem;
padding: 4px 8px;
}
pre {
background: #18202a;
border-radius: 6px;
color: #edf7f4;
margin: 0;
max-height: 420px;
overflow: auto;
padding: 14px;
white-space: pre-wrap;
word-break: break-word;
}
.check-row {
align-items: center;
display: flex;
flex-direction: row;
gap: 10px;
}
.check-row input {
min-height: auto;
width: auto;
}
.secret-box {
background: #fff9e8;
border: 1px solid #e7cf86;
border-radius: 6px;
display: grid;
gap: 8px;
margin-top: 18px;
padding: 12px;
}
code {
background: #edf2f5;
border-radius: 4px;
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
padding: 3px 6px;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inline-form {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
select {
border: 1px solid #bcc8d3;
border-radius: 6px;
color: #18202a;
font: inherit;
min-height: 38px;
padding: 8px 10px;
}
.secondary-action,
.danger-action {
border-radius: 6px;
cursor: pointer;
font: inherit;
min-height: 38px;
padding: 8px 12px;
}
.secondary-action {
background: #ffffff;
border: 1px solid #0f6b6e;
color: #0f6b6e;
}
.danger-action {
background: #fff1f0;
border: 1px solid #efb5ae;
color: #8d2b20;
}
@media (max-width: 560px) {
.topbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
padding: 16px 20px;
}
nav,
.actions {
flex-wrap: wrap;
}
main {
min-height: calc(100vh - 104px);
padding: 24px 14px;
}
.panel {
padding: 22px;
}
.grid {
grid-template-columns: 1fr;
}
.account-header,
.overview-grid {
align-items: stretch;
grid-template-columns: 1fr;
}
.account-header {
display: grid;
}
}