initial
This commit is contained in:
35
apps/web/src/app/app.component.ts
Normal file
35
apps/web/src/app/app.component.ts
Normal 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) {}
|
||||
}
|
||||
66
apps/web/src/app/pages/account-edit.component.ts
Normal file
66
apps/web/src/app/pages/account-edit.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
66
apps/web/src/app/pages/account-email.component.ts
Normal file
66
apps/web/src/app/pages/account-email.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
176
apps/web/src/app/pages/account-overview.component.ts
Normal file
176
apps/web/src/app/pages/account-overview.component.ts
Normal 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(', ');
|
||||
}
|
||||
}
|
||||
73
apps/web/src/app/pages/account-password.component.ts
Normal file
73
apps/web/src/app/pages/account-password.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
35
apps/web/src/app/pages/admin-audit.component.ts
Normal file
35
apps/web/src/app/pages/admin-audit.component.ts
Normal 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)) });
|
||||
}
|
||||
}
|
||||
59
apps/web/src/app/pages/admin-groups.component.ts
Normal file
59
apps/web/src/app/pages/admin-groups.component.ts
Normal 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)) });
|
||||
}
|
||||
}
|
||||
218
apps/web/src/app/pages/admin-oidc-clients.component.ts
Normal file
218
apps/web/src/app/pages/admin-oidc-clients.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
57
apps/web/src/app/pages/admin-registrations.component.ts
Normal file
57
apps/web/src/app/pages/admin-registrations.component.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
94
apps/web/src/app/pages/admin-users.component.ts
Normal file
94
apps/web/src/app/pages/admin-users.component.ts
Normal 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)),
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/web/src/app/pages/forgot-password.component.ts
Normal file
68
apps/web/src/app/pages/forgot-password.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/web/src/app/pages/login.component.ts
Normal file
68
apps/web/src/app/pages/login.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
83
apps/web/src/app/pages/register.component.ts
Normal file
83
apps/web/src/app/pages/register.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
81
apps/web/src/app/pages/reset-password.component.ts
Normal file
81
apps/web/src/app/pages/reset-password.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
54
apps/web/src/app/pages/verify-email.component.ts
Normal file
54
apps/web/src/app/pages/verify-email.component.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
3
apps/web/src/app/shared/api-base-url.ts
Normal file
3
apps/web/src/app/shared/api-base-url.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
|
||||
15
apps/web/src/app/shared/api-error.ts
Normal file
15
apps/web/src/app/shared/api-error.ts
Normal 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.';
|
||||
}
|
||||
42
apps/web/src/app/shared/auth.service.ts
Normal file
42
apps/web/src/app/shared/auth.service.ts
Normal 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
1
apps/web/src/favicon.ico
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
12
apps/web/src/index.html
Normal file
12
apps/web/src/index.html
Normal 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
63
apps/web/src/main.ts
Normal 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
441
apps/web/src/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user