Skip to content
Merged

P0095 #668

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<section class="client-secrets border border-color rounded-lg" aria-labelledby="client-secrets-title">
<div class="flex items-start justify-between gap-4 flex-wrap px-4 pt-4 pb-3">
<div class="flex items-start gap-3 min-w-0">
<mat-icon class="icon-20 mt-0.5">vpn_key</mat-icon>
<div class="flex flex-col gap-0.5 min-w-0">
<h3 id="client-secrets-title" class="mat-body-2 font-semibold m-0">Client Secrets</h3>
<p class="text-xs secondary-text-color m-0">
Create and manage up to {{ maxSecrets }} client secrets. Secrets are shown only once.
</p>
</div>
</div>
<div class="flex items-center gap-3">
@if (isLoading() && secrets().length) {
<mat-spinner diameter="16" aria-label="Refreshing client secrets"></mat-spinner>
}
<span class="secret-counter" aria-live="polite">{{ activeCount() }} of {{ maxSecrets }} secrets</span>
<!-- a disabled button doesn't show tooltips, so the tooltip lives on the wrapper -->
<span
[matTooltip]="
limitReached()
? 'Maximum of ' + maxSecrets + ' active secrets reached. Revoke one to create another.'
: ''
"
matTooltipPosition="above"
>
<button type="button" matButton="filled" [disabled]="!canCreate()" (click)="openCreateDialog()">
<mat-icon>add</mat-icon>
Create client secret
</button>
</span>
</div>
</div>

<div class="flex flex-col gap-3 px-4 pb-4">
@if (isLoading() && !secrets().length) {
<div class="flex items-center justify-center gap-3 py-8 secondary-text-color" role="status">
<mat-spinner diameter="24"></mat-spinner>
<span class="text-sm">Loading client secrets…</span>
</div>
} @else if (loadFailed() && !secrets().length) {
<div class="flex flex-col items-center gap-3 py-8" role="alert">
<span class="text-sm secondary-text-color">Could not load client secrets.</span>
<button type="button" matButton="outlined" (click)="reload()">
<mat-icon>refresh</mat-icon>
Retry
</button>
</div>
} @else if (!secrets().length) {
<div class="flex flex-col items-center gap-1 py-8 text-center">
<span class="mat-body-2 font-medium">No client secrets yet</span>
<span class="text-xs secondary-text-color">
Create one so your server can exchange login codes for sessions.
</span>
</div>
} @else {
<div class="secrets-table-wrapper border border-color rounded-lg">
<table class="secrets-table" aria-label="Client secrets">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Secret (last 4 characters shown)</th>
<th scope="col">Status</th>
<th scope="col" class="secrets-table__actions">Actions</th>
</tr>
</thead>
<tbody>
@for (secret of sortedSecrets(); track secret.id) {
<tr [class.secrets-table__row--revoked]="!!secret.revoked_at">
<td data-label="Name" class="secrets-table__name">{{ secret.name }}</td>
<td data-label="Secret">
<span
class="secret-mask"
[attr.aria-label]="'Secret ending in ' + secret.last_four"
>
<span aria-hidden="true">••••••••••••</span>{{ secret.last_four }}
</span>
</td>
<td data-label="Status">
@if (secret.revoked_at) {
<span
class="status-pill status-pill--revoked"
[matTooltip]="'Revoked on ' + (secret.revoked_at | date: 'medium')"
matTooltipPosition="above"
>
<span class="status-pill__dot"></span>Revoked
</span>
} @else {
<span class="status-pill status-pill--active">
<span class="status-pill__dot"></span>Active
</span>
}
</td>
<td data-label="Actions" class="secrets-table__actions">
@if (secret.revoked_at) {
<span class="text-xs secondary-text-color">
{{ secret.revoked_at | date: 'mediumDate' }}
</span>
} @else {
<button
type="button"
matButton="outlined"
class="revoke-btn"
[disabled]="revokingId() !== null"
[attr.aria-label]="'Revoke client secret ' + secret.name"
(click)="revoke(secret)"
>
@if (revokingId() === secret.id) {
<mat-spinner diameter="16" class="mr-1"></mat-spinner>
Revoking…
} @else {
Revoke
}
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}

<div class="info-banner" role="note">
<mat-icon class="icon-16">info</mat-icon>
<span>
When you create a client secret, the full secret will be displayed only once. After that, only the last
4 characters will be visible.
</span>
</div>
</div>
</section>

<!-- Centered modal (MatDialog's default placement, with a backdrop); not anchored to the settings section. -->
<ng-template #createDialogTemplate>
@if (createdSecret(); as created) {
<h2 matDialogTitle>Client secret created</h2>
<mat-dialog-content>
<div class="warning-banner" role="alert">
<mat-icon class="icon-16">warning</mat-icon>
<span>
Copy this secret now. For security it will not be shown again; afterwards only the last 4 characters
are visible.
</span>
</div>
<p class="text-xs secondary-text-color m-0 mb-1">Name</p>
<p class="text-sm m-0 mb-3">{{ created.name }}</p>
<p class="text-xs secondary-text-color m-0 mb-1">Client secret</p>
<div class="secret-reveal">
<code class="break-all text-sm">{{ created.client_secret }}</code>
<proxy-copy-button [copyData]="created.client_secret" tooltip="Copy secret"></proxy-copy-button>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" matButton="filled" (click)="closeCreateDialog()">Done</button>
</mat-dialog-actions>
} @else {
<!-- pr-3: an icon button's 48px touch target is wider than the button, and would overflow the dialog surface -->
<div class="flex items-start justify-between gap-2 pr-3">
<h2 matDialogTitle>Create client secret</h2>
<button
type="button"
mat-icon-button
aria-label="Close"
[disabled]="isCreating()"
(click)="closeCreateDialog()"
>
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content>
<p class="text-sm secondary-text-color m-0 mb-3">
Only the name is required. A secure secret will be generated automatically.
</p>
<form id="create-client-secret-form" [formGroup]="createForm" (ngSubmit)="submitCreate()">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Name</mat-label>
<input
matInput
formControlName="name"
placeholder="e.g. Production API, Mobile App, etc"
maxlength="100"
autocomplete="off"
/>
<mat-hint>This is to help you identify this client secret.</mat-hint>
@if (
createForm.controls.name.hasError('required') || createForm.controls.name.hasError('pattern')
) {
<mat-error>Name is required</mat-error>
}
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" matButton="outlined" [disabled]="isCreating()" (click)="closeCreateDialog()">
Cancel
</button>
<button type="submit" form="create-client-secret-form" matButton="filled" [disabled]="isCreating()">
@if (isCreating()) {
<mat-spinner diameter="18" class="mr-2"></mat-spinner>
Creating…
} @else {
Create secret
}
</button>
</mat-dialog-actions>
}
</ng-template>
Loading
Loading