login
This commit is contained in:
@@ -83,7 +83,7 @@ export class AccountController {
|
|||||||
const tokenHash = hashToken(dto.token, this.tokenSecret);
|
const tokenHash = hashToken(dto.token, this.tokenSecret);
|
||||||
const record = await this.emailChanges.findOne({ where: { tokenHash, consumedAt: IsNull() } });
|
const record = await this.emailChanges.findOne({ where: { tokenHash, consumedAt: IsNull() } });
|
||||||
if (!record || record.expiresAt.getTime() < Date.now() || record.username !== request.user.username) {
|
if (!record || record.expiresAt.getTime() < Date.now() || record.username !== request.user.username) {
|
||||||
return { message: 'Der Bestaetigungslink ist ungueltig oder abgelaufen.' };
|
return { message: 'Der Bestaetigungslink ist ungültig oder abgelaufen.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.lldap.updateUser(request.user.username, { email: record.newEmail });
|
await this.lldap.updateUser(request.user.username, { email: record.newEmail });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class AuthService {
|
|||||||
const valid = await this.ldapAuth.verifyPassword(username, password);
|
const valid = await this.ldapAuth.verifyPassword(username, password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
await this.audit.record({ type: 'auth.login_failed', username, ipAddress, userAgent });
|
await this.audit.record({ type: 'auth.login_failed', username, ipAddress, userAgent });
|
||||||
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
|
throw new UnauthorizedException('Ungültige Zugangsdaten.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.audit.record({ type: 'auth.login_success', username, ipAddress, userAgent });
|
await this.audit.record({ type: 'auth.login_success', username, ipAddress, userAgent });
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
request.user = await this.jwt.verifyAsync<RequestUser>(token);
|
request.user = await this.jwt.verifyAsync<RequestUser>(token);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('Session ist ungueltig oder abgelaufen.');
|
throw new UnauthorizedException('Session ist ungültig oder abgelaufen.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Req, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { OidcProviderService } from './oidc-provider.service';
|
import { OidcProviderService } from './oidc-provider.service';
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export class OidcInteractionController {
|
|||||||
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
|
async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) {
|
||||||
const details = await this.oidc.interactionDetails(request, response);
|
const details = await this.oidc.interactionDetails(request, response);
|
||||||
if (details.uid !== uid) {
|
if (details.uid !== uid) {
|
||||||
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>'));
|
response.status(400).send(this.page('Ungültige Anfrage', '<p>Die OIDC-Interaktion ist ungültig.</p>'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,16 +18,7 @@ export class OidcInteractionController {
|
|||||||
response.send(
|
response.send(
|
||||||
this.page(
|
this.page(
|
||||||
'Anmelden',
|
'Anmelden',
|
||||||
`
|
this.loginForm(uid),
|
||||||
<form method="post" action="/interaction/${encodeURIComponent(uid)}/login">
|
|
||||||
<label>Benutzername <input name="username" autocomplete="username" required></label>
|
|
||||||
<label>Passwort <input name="password" type="password" autocomplete="current-password" required></label>
|
|
||||||
<button type="submit">Anmelden</button>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="/interaction/${encodeURIComponent(uid)}/abort">
|
|
||||||
<button class="secondary" type="submit">Abbrechen</button>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -38,7 +29,7 @@ export class OidcInteractionController {
|
|||||||
this.page(
|
this.page(
|
||||||
'Zugriff erlauben',
|
'Zugriff erlauben',
|
||||||
`
|
`
|
||||||
<p>Client <strong>${this.escape(String(details.params.client_id ?? ''))}</strong> moechte Zugriff auf folgende Scopes:</p>
|
<p>Client <strong>${this.escape(String(details.params.name ?? ''))}</strong> möchte Zugriff auf folgende Scopes:</p>
|
||||||
<p class="scopes">${this.escape(String(details.params.scope ?? 'openid'))}</p>
|
<p class="scopes">${this.escape(String(details.params.scope ?? 'openid'))}</p>
|
||||||
<form method="post" action="/interaction/${encodeURIComponent(uid)}/confirm">
|
<form method="post" action="/interaction/${encodeURIComponent(uid)}/confirm">
|
||||||
<button type="submit">Erlauben</button>
|
<button type="submit">Erlauben</button>
|
||||||
@@ -62,7 +53,19 @@ export class OidcInteractionController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res() response: Response,
|
@Res() response: Response,
|
||||||
) {
|
) {
|
||||||
await this.oidc.finishLogin(request, response, uid, body.username ?? '', body.password ?? '');
|
const username = body.username ?? '';
|
||||||
|
try {
|
||||||
|
await this.oidc.finishLogin(request, response, uid, username, body.password ?? '');
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isInvalidCredentialsError(error)) {
|
||||||
|
response
|
||||||
|
.status(401)
|
||||||
|
.send(this.page('Anmelden', this.loginForm(uid, username, 'Ungültige Zugangsdaten.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':uid/confirm')
|
@Post(':uid/confirm')
|
||||||
@@ -75,6 +78,25 @@ export class OidcInteractionController {
|
|||||||
await this.oidc.abortInteraction(request, response);
|
await this.oidc.abortInteraction(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loginForm(uid: string, username = '', errorMessage = ''): string {
|
||||||
|
const encodedUid = encodeURIComponent(uid);
|
||||||
|
const error = errorMessage
|
||||||
|
? `<p class="message error" role="alert">${this.escape(errorMessage)}</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
${error}
|
||||||
|
<form method="post" action="/interaction/${encodedUid}/login">
|
||||||
|
<label>Benutzername <input name="username" autocomplete="username" value="${this.escape(username)}" required></label>
|
||||||
|
<label>Passwort <input name="password" type="password" autocomplete="current-password" required autofocus></label>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/interaction/${encodedUid}/abort">
|
||||||
|
<button class="secondary" type="submit">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private page(title: string, body: string): string {
|
private page(title: string, body: string): string {
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
@@ -91,6 +113,8 @@ export class OidcInteractionController {
|
|||||||
input { border: 1px solid #bcc8d3; border-radius: 6px; font: inherit; min-height: 44px; padding: 10px 12px; }
|
input { border: 1px solid #bcc8d3; border-radius: 6px; font: inherit; min-height: 44px; padding: 10px 12px; }
|
||||||
button { background: #0f6b6e; border: 1px solid #0f6b6e; border-radius: 6px; color: white; cursor: pointer; font: inherit; font-weight: 700; min-height: 44px; padding: 10px 14px; }
|
button { background: #0f6b6e; border: 1px solid #0f6b6e; border-radius: 6px; color: white; cursor: pointer; font: inherit; font-weight: 700; min-height: 44px; padding: 10px 14px; }
|
||||||
button.secondary { background: white; color: #0f6b6e; }
|
button.secondary { background: white; color: #0f6b6e; }
|
||||||
|
.message { background: #edf7f4; border: 1px solid #b8ddd3; border-radius: 6px; color: #24564f; margin: 0 0 16px; padding: 12px; }
|
||||||
|
.message.error { background: #fff1f0; border-color: #efb5ae; color: #8d2b20; }
|
||||||
.scopes { background: #edf2f5; border-radius: 6px; padding: 10px; word-break: break-word; }
|
.scopes { background: #edf2f5; border-radius: 6px; padding: 10px; word-break: break-word; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -106,4 +130,8 @@ export class OidcInteractionController {
|
|||||||
.replaceAll('"', '"')
|
.replaceAll('"', '"')
|
||||||
.replaceAll("'", ''');
|
.replaceAll("'", ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isInvalidCredentialsError(error: unknown): boolean {
|
||||||
|
return error instanceof UnauthorizedException && error.message === 'Ungültige Zugangsdaten.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const details = await this.interactionDetails(request, response);
|
const details = await this.interactionDetails(request, response);
|
||||||
if (details.uid !== uid || details.prompt.name !== 'login') {
|
if (details.uid !== uid || details.prompt.name !== 'login') {
|
||||||
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
|
throw new UnauthorizedException('Ungültige OIDC-Interaktion.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await this.ldapAuth.verifyPassword(username, password);
|
const valid = await this.ldapAuth.verifyPassword(username, password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
|
await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] });
|
||||||
throw new UnauthorizedException('Ungueltige Zugangsdaten.');
|
throw new UnauthorizedException('Ungültige Zugangsdaten.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await this.lldap.getAccount(username);
|
const account = await this.lldap.getAccount(username);
|
||||||
@@ -96,7 +96,7 @@ export class OidcProviderService implements OnModuleInit {
|
|||||||
async finishConsent(request: Request, response: Response, uid: string): Promise<void> {
|
async finishConsent(request: Request, response: Response, uid: string): Promise<void> {
|
||||||
const details = await this.interactionDetails(request, response);
|
const details = await this.interactionDetails(request, response);
|
||||||
if (details.uid !== uid || details.prompt.name !== 'consent') {
|
if (details.uid !== uid || details.prompt.name !== 'consent') {
|
||||||
throw new UnauthorizedException('Ungueltige OIDC-Interaktion.');
|
throw new UnauthorizedException('Ungültige OIDC-Interaktion.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = String(details.params.client_id ?? '');
|
const clientId = String(details.params.client_id ?? '');
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export class PasswordService {
|
|||||||
const tokenHash = hashToken(token, this.tokenSecret);
|
const tokenHash = hashToken(token, this.tokenSecret);
|
||||||
const record = await this.resetTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } });
|
const record = await this.resetTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } });
|
||||||
if (!record || record.expiresAt.getTime() < Date.now()) {
|
if (!record || record.expiresAt.getTime() < Date.now()) {
|
||||||
throw new BadRequestException('Der Reset-Link ist ungueltig oder abgelaufen.');
|
throw new BadRequestException('Der Reset-Link ist ungültig oder abgelaufen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.lldap.setPassword(record.username, newPassword);
|
await this.lldap.setPassword(record.username, newPassword);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export class RegistrationService {
|
|||||||
const tokenHash = hashToken(token, this.tokenSecret);
|
const tokenHash = hashToken(token, this.tokenSecret);
|
||||||
const tokenRecord = await this.emailTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } });
|
const tokenRecord = await this.emailTokens.findOne({ where: { tokenHash, consumedAt: IsNull() } });
|
||||||
if (!tokenRecord || tokenRecord.expiresAt.getTime() < Date.now()) {
|
if (!tokenRecord || tokenRecord.expiresAt.getTime() < Date.now()) {
|
||||||
throw new BadRequestException('Der Bestaetigungslink ist ungueltig oder abgelaufen.');
|
throw new BadRequestException('Der Bestaetigungslink ist ungültig oder abgelaufen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = await this.registrations.findOneByOrFail({ id: tokenRecord.registrationId });
|
const registration = await this.registrations.findOneByOrFail({ id: tokenRecord.registrationId });
|
||||||
|
|||||||
@@ -98,26 +98,73 @@ interface CreatedOidcClient extends OidcClient {
|
|||||||
<div class="client-list">
|
<div class="client-list">
|
||||||
@for (client of clients(); track client.id) {
|
@for (client of clients(); track client.id) {
|
||||||
<article class="client-item">
|
<article class="client-item">
|
||||||
<div>
|
@if (editingClientId() === client.id) {
|
||||||
<strong>{{ client.clientName }}</strong>
|
<form class="client-edit-form" [formGroup]="editForm" (ngSubmit)="save(client)">
|
||||||
<code>{{ client.clientId }}</code>
|
<div class="client-heading">
|
||||||
</div>
|
<div>
|
||||||
<div class="flags">
|
<strong>Client bearbeiten</strong>
|
||||||
<span>{{ client.enabled ? 'aktiv' : 'deaktiviert' }}</span>
|
<code>{{ client.clientId }}</code>
|
||||||
<span>{{ client.tokenEndpointAuthMethod }}</span>
|
</div>
|
||||||
@if (client.firstParty) { <span>first-party</span> }
|
<div class="flags">
|
||||||
@if (client.includeGroups) { <span>groups</span> }
|
<span>{{ client.enabled ? 'aktiv' : 'deaktiviert' }}</span>
|
||||||
</div>
|
<span>{{ client.tokenEndpointAuthMethod }}</span>
|
||||||
<dl>
|
</div>
|
||||||
<div><dt>Redirect URIs</dt><dd>{{ client.redirectUris.join(', ') }}</dd></div>
|
</div>
|
||||||
<div><dt>Scopes</dt><dd>{{ client.scope }}</dd></div>
|
<label>
|
||||||
</dl>
|
Name
|
||||||
<div class="row-actions">
|
<input formControlName="clientName">
|
||||||
<button type="button" class="secondary-action" (click)="toggle(client)">
|
</label>
|
||||||
{{ client.enabled ? 'Deaktivieren' : 'Aktivieren' }}
|
<label>
|
||||||
</button>
|
Redirect URIs
|
||||||
<button type="button" class="danger-action" (click)="delete(client)">Loeschen</button>
|
<textarea formControlName="redirectUris" rows="4"></textarea>
|
||||||
</div>
|
</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
|
||||||
|
</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>
|
||||||
|
<button type="button" class="danger-action" (click)="delete(client)">Loeschen</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,8 +181,10 @@ export class AdminOidcClientsComponent implements OnInit {
|
|||||||
readonly failed = signal(false);
|
readonly failed = signal(false);
|
||||||
readonly message = signal('');
|
readonly message = signal('');
|
||||||
readonly createdSecret = signal('');
|
readonly createdSecret = signal('');
|
||||||
|
readonly editingClientId = signal<string | null>(null);
|
||||||
readonly oidcBaseUrl: string;
|
readonly oidcBaseUrl: string;
|
||||||
readonly form;
|
readonly form;
|
||||||
|
readonly editForm;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly fb: FormBuilder,
|
private readonly fb: FormBuilder,
|
||||||
@@ -152,6 +201,14 @@ export class AdminOidcClientsComponent implements OnInit {
|
|||||||
firstParty: [false],
|
firstParty: [false],
|
||||||
includeGroups: [true],
|
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 {
|
ngOnInit(): void {
|
||||||
@@ -195,10 +252,67 @@ export class AdminOidcClientsComponent implements OnInit {
|
|||||||
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
|
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
delete(client: OidcClient): void {
|
||||||
this.http
|
this.http
|
||||||
.delete<void>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`)
|
.delete<void>(`${this.apiBaseUrl}/admin/oidc/clients/${client.id}`)
|
||||||
.subscribe({ next: () => this.load(), error: (error) => this.message.set(apiErrorMessage(error)) });
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (this.editingClientId() === client.id) {
|
||||||
|
this.editingClientId.set(null);
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (error) => this.message.set(apiErrorMessage(error)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private load(): void {
|
private load(): void {
|
||||||
@@ -217,4 +331,8 @@ export class AdminOidcClientsComponent implements OnInit {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private multiline(values: string[]): string {
|
||||||
|
return values.join('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,23 @@ dd {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-heading > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-edit-form {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.group-item span,
|
.group-item span,
|
||||||
.group-item small,
|
.group-item small,
|
||||||
.attribute-row small,
|
.attribute-row small,
|
||||||
|
|||||||
Reference in New Issue
Block a user