378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
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 (Consent ueberspringen)
|
|
</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>
|
|
<small>Dieses Secret wird nur einmal angezeigt.</small>
|
|
</div>
|
|
}
|
|
</article>
|
|
|
|
<article class="info-panel">
|
|
<h2>Discovery</h2>
|
|
<dl>
|
|
<div><dt>Configuration</dt><dd>{{ oidcBaseUrl }}/.well-known/openid-configuration</dd></div>
|
|
<div><dt>Authorize</dt><dd>{{ oidcBaseUrl }}/oidc/auth</dd></div>
|
|
<div><dt>Token</dt><dd>{{ oidcBaseUrl }}/oidc/token</dd></div>
|
|
<div><dt>UserInfo</dt><dd>{{ oidcBaseUrl }}/oidc/me</dd></div>
|
|
<div><dt>JWKS</dt><dd>{{ oidcBaseUrl }}/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">
|
|
@if (editingClientId() === client.id) {
|
|
<form class="client-edit-form" [formGroup]="editForm" (ngSubmit)="save(client)">
|
|
<div class="client-heading">
|
|
<div>
|
|
<strong>Client bearbeiten</strong>
|
|
<code>{{ client.clientId }}</code>
|
|
</div>
|
|
<div class="flags">
|
|
<span>{{ client.enabled ? 'aktiv' : 'deaktiviert' }}</span>
|
|
<span>{{ client.tokenEndpointAuthMethod }}</span>
|
|
</div>
|
|
</div>
|
|
<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="firstParty">
|
|
First-Party Client (Consent ueberspringen)
|
|
</label>
|
|
<label class="check-row">
|
|
<input type="checkbox" formControlName="includeGroups">
|
|
Gruppen-Claim ausgeben
|
|
</label>
|
|
<div class="row-actions">
|
|
<button type="submit" [disabled]="editForm.invalid || loading()">Speichern</button>
|
|
<button type="button" class="secondary-action" (click)="cancelEdit()">Abbrechen</button>
|
|
</div>
|
|
</form>
|
|
} @else {
|
|
<div class="client-heading">
|
|
<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>
|
|
</div>
|
|
<dl>
|
|
<div><dt>Redirect URIs</dt><dd>{{ client.redirectUris.join(', ') }}</dd></div>
|
|
<div><dt>Logout Redirect URIs</dt><dd>{{ client.postLogoutRedirectUris.join(', ') || '-' }}</dd></div>
|
|
<div><dt>Scopes</dt><dd>{{ client.scope }}</dd></div>
|
|
</dl>
|
|
<div class="row-actions">
|
|
<button type="button" class="secondary-action" (click)="startEdit(client)">Bearbeiten</button>
|
|
<button type="button" class="secondary-action" (click)="toggle(client)">
|
|
{{ client.enabled ? 'Deaktivieren' : 'Aktivieren' }}
|
|
</button>
|
|
@if (canRotateSecret(client)) {
|
|
<button type="button" class="secondary-action" (click)="rotateSecret(client)">Secret rotieren</button>
|
|
} @else {
|
|
<span class="muted-action">Public Client ohne Secret</span>
|
|
}
|
|
<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 editingClientId = signal<string | null>(null);
|
|
readonly oidcBaseUrl: string;
|
|
readonly form;
|
|
readonly editForm;
|
|
|
|
constructor(
|
|
private readonly fb: FormBuilder,
|
|
private readonly http: HttpClient,
|
|
@Inject(API_BASE_URL) readonly apiBaseUrl: string,
|
|
) {
|
|
this.oidcBaseUrl = apiBaseUrl.endsWith('/api') ? apiBaseUrl.slice(0, -4) : apiBaseUrl;
|
|
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],
|
|
});
|
|
this.editForm = this.fb.nonNullable.group({
|
|
clientName: ['', Validators.required],
|
|
redirectUris: ['', Validators.required],
|
|
postLogoutRedirectUris: [''],
|
|
scope: ['openid profile email groups'],
|
|
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)) });
|
|
}
|
|
|
|
canRotateSecret(client: OidcClient): boolean {
|
|
return client.tokenEndpointAuthMethod !== 'none';
|
|
}
|
|
|
|
rotateSecret(client: OidcClient): void {
|
|
const confirmed = window.confirm(
|
|
'Das Client Secret wird neu erzeugt und nur einmal angezeigt. Bestehende Apps muessen danach das neue Secret verwenden.',
|
|
);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
this.loading.set(true);
|
|
this.failed.set(false);
|
|
this.message.set('');
|
|
this.createdSecret.set('');
|
|
this.http
|
|
.post<CreatedOidcClient>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}/secret/rotate`, {})
|
|
.subscribe({
|
|
next: (updatedClient) => {
|
|
this.createdSecret.set(updatedClient.clientSecret ?? '');
|
|
this.message.set('Client Secret wurde rotiert.');
|
|
this.load();
|
|
},
|
|
error: (error) => {
|
|
this.failed.set(true);
|
|
this.message.set(apiErrorMessage(error));
|
|
this.loading.set(false);
|
|
},
|
|
complete: () => this.loading.set(false),
|
|
});
|
|
}
|
|
|
|
startEdit(client: OidcClient): void {
|
|
this.failed.set(false);
|
|
this.message.set('');
|
|
this.createdSecret.set('');
|
|
this.editingClientId.set(client.id);
|
|
this.editForm.setValue({
|
|
clientName: client.clientName,
|
|
redirectUris: this.multiline(client.redirectUris),
|
|
postLogoutRedirectUris: this.multiline(client.postLogoutRedirectUris),
|
|
scope: client.scope,
|
|
firstParty: client.firstParty,
|
|
includeGroups: client.includeGroups,
|
|
});
|
|
}
|
|
|
|
cancelEdit(): void {
|
|
this.editingClientId.set(null);
|
|
}
|
|
|
|
save(client: OidcClient): void {
|
|
if (this.editForm.invalid) {
|
|
return;
|
|
}
|
|
|
|
this.loading.set(true);
|
|
this.failed.set(false);
|
|
this.message.set('');
|
|
const value = this.editForm.getRawValue();
|
|
this.http
|
|
.patch<OidcClient>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`, {
|
|
...value,
|
|
redirectUris: this.lines(value.redirectUris),
|
|
postLogoutRedirectUris: this.lines(value.postLogoutRedirectUris),
|
|
})
|
|
.subscribe({
|
|
next: () => {
|
|
this.editingClientId.set(null);
|
|
this.message.set('Client wurde gespeichert.');
|
|
this.load();
|
|
},
|
|
error: (error) => {
|
|
this.failed.set(true);
|
|
this.message.set(apiErrorMessage(error));
|
|
this.loading.set(false);
|
|
},
|
|
complete: () => this.loading.set(false),
|
|
});
|
|
}
|
|
|
|
delete(client: OidcClient): void {
|
|
this.http
|
|
.delete<void>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`)
|
|
.subscribe({
|
|
next: () => {
|
|
if (this.editingClientId() === client.id) {
|
|
this.editingClientId.set(null);
|
|
}
|
|
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);
|
|
}
|
|
|
|
private multiline(values: string[]): string {
|
|
return values.join('\n');
|
|
}
|
|
}
|