From 88516dfada81ac9803f5fa35c4cd48c2f0c5bbc0 Mon Sep 17 00:00:00 2001 From: kanishkmorey <86414024+kanishkmorey@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:39:38 +0530 Subject: [PATCH 1/2] Kanishk | Add client secret mangement on block management panel. --- .../client-secrets.component.html | 207 +++++++++++++++++ .../client-secrets.component.scss | 216 ++++++++++++++++++ .../client-secrets.component.ts | 204 +++++++++++++++++ .../client-secrets/client-secrets.store.ts | 138 +++++++++++ .../create-feature.component.html | 5 + .../create-feature.component.ts | 2 + .../36-blocks/src/environments/environment.ts | 2 +- libs/models/features-model/src/index.ts | 22 ++ .../src/lib/services-proxy-features.module.ts | 27 +++ libs/urls/features-url/src/index.ts | 3 + 10 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.html create mode 100644 apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.scss create mode 100644 apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.ts create mode 100644 apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.store.ts diff --git a/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.html b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.html new file mode 100644 index 00000000..4380c3da --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.html @@ -0,0 +1,207 @@ +
+
+
+ vpn_key +
+

Client Secrets

+

+ Create and manage up to {{ maxSecrets }} client secrets. Secrets are shown only once. +

+
+
+
+ @if (isLoading() && secrets().length) { + + } + {{ activeCount() }} of {{ maxSecrets }} secrets + + + + +
+
+ +
+ @if (isLoading() && !secrets().length) { +
+ + Loading client secrets… +
+ } @else if (loadFailed() && !secrets().length) { + + } @else if (!secrets().length) { +
+ No client secrets yet + + Create one so your server can exchange login codes for sessions. + +
+ } @else { +
+ + + + + + + + + + + @for (secret of sortedSecrets(); track secret.id) { + + + + + + + } + +
NameSecret (last 4 characters shown)StatusActions
{{ secret.name }} + + {{ secret.last_four }} + + + @if (secret.revoked_at) { + + Revoked + + } @else { + + Active + + } + + @if (secret.revoked_at) { + + {{ secret.revoked_at | date: 'mediumDate' }} + + } @else { + + } +
+
+ } + +
+ info + + When you create a client secret, the full secret will be displayed only once. After that, only the last + 4 characters will be visible. + +
+
+
+ + + + @if (createdSecret(); as created) { +

Client secret created

+ + +

Name

+

{{ created.name }}

+

Client secret

+
+ {{ created.client_secret }} + +
+
+ + + + } @else { + +
+

Create client secret

+ +
+ +

+ Only the name is required. A secure secret will be generated automatically. +

+
+ + Name + + This is to help you identify this client secret. + @if ( + createForm.controls.name.hasError('required') || createForm.controls.name.hasError('pattern') + ) { + Name is required + } + +
+
+ + + + + } +
diff --git a/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.scss b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.scss new file mode 100644 index 00000000..60b4c797 --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.scss @@ -0,0 +1,216 @@ +:host { + display: block; +} + +.secret-counter { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border: 1px solid var(--color-common-border); + border-radius: 999px; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} + +.secrets-table-wrapper { + overflow-x: auto; +} + +.secrets-table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 10px 16px; + text-align: left; + vertical-align: middle; + font-size: 14px; + } + + thead th { + font-size: 13px; + font-weight: 600; + border-bottom: 1px solid var(--color-common-border); + } + + tbody tr + tr td { + border-top: 1px solid var(--color-common-border); + } + + &__actions { + text-align: right !important; + white-space: nowrap; + } + + &__name { + overflow-wrap: anywhere; + } + + &__row--revoked { + td:not(.secrets-table__actions) { + opacity: 0.6; + } + } +} + +.secret-mask { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 1.5; + + &__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + } + + &--active { + color: #15803d; + background: color-mix(in srgb, #22c55e 18%, transparent); + } + + &--revoked { + color: #6b7280; + background: color-mix(in srgb, #9ca3af 22%, transparent); + } +} + +.revoke-btn { + --mat-button-outlined-label-text-color: #dc2626; + --mat-button-outlined-outline-color: #dc2626; + min-width: 88px; +} + +/* + * mat-icon clips whatever falls outside its box. A Tailwind text-size class also sets a larger + * line-height (text-base = 24px), so forcing a smaller height on it cuts the glyph's bottom off. + * Size the glyph, the box and the line-height together instead. + */ +.icon-16, +.icon-20 { + flex-shrink: 0; +} + +.icon-16 { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} + +.icon-20 { + font-size: 20px; + width: 20px; + height: 20px; + line-height: 20px; +} + +.info-banner, +.warning-banner { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + border-radius: 8px; + font-size: 13px; + line-height: 19.5px; + + // centre the 16px icon on the first line of text (19.5px line box) + .icon-16 { + margin-top: 1.75px; + } +} + +.info-banner { + color: #1d4ed8; + background: color-mix(in srgb, #3b82f6 12%, transparent); +} + +.warning-banner { + margin-bottom: 16px; + color: #b45309; + background: color-mix(in srgb, #f59e0b 16%, transparent); + border: 1px solid color-mix(in srgb, #f59e0b 35%, transparent); +} + +.secret-reveal { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--color-common-border); + border-radius: 8px; + + code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + user-select: all; + } +} + +/* Stack the table into cards on narrow screens. */ +@media screen and (max-width: 640px) { + .secrets-table { + thead { + display: none; + } + + tr, + td { + display: block; + } + + tbody tr { + padding: 8px 0; + } + + tbody tr + tr { + border-top: 1px solid var(--color-common-border); + } + + tbody tr + tr td { + border-top: 0; + } + + td { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 16px; + + &::before { + content: attr(data-label); + font-size: 12px; + font-weight: 600; + opacity: 0.7; + } + } + } +} + +/* + * The create dialog is a MatDialog: centered in the viewport with a backdrop by default. + * Give it a fixed width (otherwise it shrink-wraps its longest line), and override the global + * `.mat-dialog { min-width: 500px }` so it still fits small screens. + */ +::ng-deep .mat-dialog.client-secret-dialog { + width: 520px !important; + min-width: min(500px, 92vw) !important; + max-width: 92vw !important; +} diff --git a/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.ts b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.ts new file mode 100644 index 00000000..9fff9142 --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.component.ts @@ -0,0 +1,204 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + Input, + OnChanges, + SimpleChanges, + TemplateRef, + ViewChild, + computed, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { IClientSecret, MAX_ACTIVE_CLIENT_SECRETS } from '@proxy/models/features-model'; +import { ConfirmDialogComponent } from '@proxy/ui/confirm-dialog'; +import { CopyButtonComponent } from '@proxy/ui/copy-button'; +import { ClientSecretsComponentStore } from './client-secrets.store'; + +/** + * Client secrets of a block, shown inside "Session & Token Settings". + * + * A client's backend uses one of these secrets, together with the one-time code from a + * 'code' mode login redirect, to exchange for the session. At most + * MAX_ACTIVE_CLIENT_SECRETS can be active at once so a secret can be rotated without + * downtime. The plaintext is shown exactly once, right after creation; afterwards only + * the last 4 characters are available. Revoked secrets stay listed as history. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'proxy-client-secrets', + imports: [ + CommonModule, + ReactiveFormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + MatTooltipModule, + CopyButtonComponent, + ], + templateUrl: './client-secrets.component.html', + styleUrls: ['./client-secrets.component.scss'], + providers: [ClientSecretsComponentStore], +}) +export class ClientSecretsComponent implements OnChanges { + private store = inject(ClientSecretsComponentStore); + private dialog = inject(MatDialog); + private destroyRef = inject(DestroyRef); + + /** Numeric feature configuration id (`features/{id}/clientSecrets`). */ + @Input() featureId: number | null = null; + + public readonly maxSecrets = MAX_ACTIVE_CLIENT_SECRETS; + + public readonly secrets = signal([]); + public readonly isLoading = signal(false); + public readonly loadFailed = signal(false); + public readonly isCreating = signal(false); + public readonly revokingId = signal(null); + /** The just-created secret, holding the one-time plaintext. Only set while the reveal step is open. */ + public readonly createdSecret = signal(null); + + /** Active secrets first, then revoked history; newest first within each group. */ + public readonly sortedSecrets = computed(() => + [...this.secrets()].sort((a, b) => { + const aRevoked = a.revoked_at ? 1 : 0; + const bRevoked = b.revoked_at ? 1 : 0; + return aRevoked - bRevoked || b.id - a.id; + }) + ); + public readonly activeCount = computed(() => this.secrets().filter((secret) => !secret.revoked_at).length); + public readonly limitReached = computed(() => this.activeCount() >= this.maxSecrets); + /** Creating is blocked at the limit, and until the list is known (the count would be wrong). */ + public readonly canCreate = computed(() => { + const listUnknown = !this.secrets().length && (this.isLoading() || this.loadFailed()); + return !this.limitReached() && !listUnknown; + }); + + public readonly createForm = new FormGroup({ + name: new FormControl('', [Validators.required, Validators.maxLength(100), Validators.pattern(/\S/)]), + }); + + private createDialogRef: MatDialogRef | null = null; + + @ViewChild('createDialogTemplate', { static: true }) createDialogTemplate: TemplateRef; + + constructor() { + this.store.secrets$.pipe(takeUntilDestroyed()).subscribe((secrets) => this.secrets.set(secrets)); + this.store.isLoading$.pipe(takeUntilDestroyed()).subscribe((value) => this.isLoading.set(value)); + this.store.loadFailed$.pipe(takeUntilDestroyed()).subscribe((value) => this.loadFailed.set(value)); + this.store.revokingId$.pipe(takeUntilDestroyed()).subscribe((value) => this.revokingId.set(value)); + + this.store.isCreating$.pipe(takeUntilDestroyed()).subscribe((value) => { + this.isCreating.set(value); + // don't let a stray click on the backdrop dismiss the dialog mid-request + if (this.createDialogRef && !this.createdSecret()) { + this.createDialogRef.disableClose = value; + } + }); + + this.store.createdSecret$.pipe(takeUntilDestroyed()).subscribe((secret) => { + if (!secret?.client_secret) { + return; + } + // Switch the open dialog to the one-time reveal step. It can only be closed with "Done", + // so the secret isn't lost to a misclick. + this.createdSecret.set(secret); + if (this.createDialogRef) { + this.createDialogRef.disableClose = true; + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['featureId'] && this.featureId) { + this.store.getSecrets(this.featureId); + } + } + + public reload(): void { + if (this.featureId) { + this.store.getSecrets(this.featureId); + } + } + + public trackById(_: number, secret: IClientSecret): number { + return secret.id; + } + + /** Opens the "Create client secret" form as a centered modal with a backdrop. */ + public openCreateDialog(): void { + if (!this.canCreate() || this.createDialogRef) { + return; + } + this.createForm.reset({ name: '' }); + this.createdSecret.set(null); + this.createDialogRef = this.dialog.open(this.createDialogTemplate, { + panelClass: ['mat-dialog', 'client-secret-dialog'], + hasBackdrop: true, + autoFocus: 'input', + restoreFocus: true, + }); + this.createDialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.createDialogRef = null; + // the plaintext must not outlive the reveal step + this.createdSecret.set(null); + this.store.clearCreatedSecret(); + }); + } + + public submitCreate(): void { + if (!this.featureId || this.isCreating()) { + return; + } + if (this.createForm.invalid) { + this.createForm.markAllAsTouched(); + return; + } + this.store.createSecret({ + featureId: this.featureId, + body: { name: this.createForm.getRawValue().name.trim() }, + }); + } + + public closeCreateDialog(): void { + this.createDialogRef?.close(); + } + + public revoke(secret: IClientSecret): void { + if (!this.featureId || secret.revoked_at || this.revokingId() !== null) { + return; + } + const confirmDialogRef: MatDialogRef = this.dialog.open(ConfirmDialogComponent, { + panelClass: ['mat-dialog'], + }); + confirmDialogRef.componentRef.setInput( + 'confirmationMessage', + `Revoke "${secret.name}" (ending ${secret.last_four})? It stops working immediately: any server still using it will no longer be able to exchange login codes. This cannot be undone.` + ); + confirmDialogRef.componentRef.setInput('confirmButtonText', 'Revoke'); + confirmDialogRef.componentRef.setInput('confirmButtonColor', 'warn'); + + confirmDialogRef.afterClosed().subscribe((action) => { + if (action === 'yes' && this.featureId) { + this.store.revokeSecret({ featureId: this.featureId, secretId: secret.id }); + } + }); + } +} diff --git a/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.store.ts b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.store.ts new file mode 100644 index 00000000..16948362 --- /dev/null +++ b/apps/36-blocks/src/app/panel/features/client-secrets/client-secrets.store.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { tapResponse } from '@ngrx/operators'; +import { FeaturesService } from '@proxy/services/proxy/features'; +import { IClientSecret, IClientSecretCreatePayload } from '@proxy/models/features-model'; +import { BaseResponse, errorResolver } from '@proxy/models/root-models'; +import { PrimeNgToastService } from '@proxy/ui/prime-ng-toast'; +import { EMPTY, Observable, catchError, switchMap } from 'rxjs'; + +export interface IClientSecretsState { + secrets: IClientSecret[]; + /** True while the list is being fetched. */ + isLoading: boolean; + /** Set when the list could not be fetched, so the UI can offer a retry. */ + loadFailed: boolean; + isCreating: boolean; + /** Id of the secret currently being revoked, if any. */ + revokingId: number | null; + /** The secret just created. Holds the one-time plaintext, cleared by the component once shown. */ + createdSecret: IClientSecret | null; +} + +@Injectable() +export class ClientSecretsComponentStore extends ComponentStore { + constructor( + private service: FeaturesService, + private toast: PrimeNgToastService + ) { + super({ + secrets: [], + isLoading: false, + loadFailed: false, + isCreating: false, + revokingId: null, + createdSecret: null, + }); + } + + readonly secrets$ = this.select((state) => state.secrets); + readonly isLoading$ = this.select((state) => state.isLoading); + readonly loadFailed$ = this.select((state) => state.loadFailed); + readonly isCreating$ = this.select((state) => state.isCreating); + readonly revokingId$ = this.select((state) => state.revokingId); + readonly createdSecret$ = this.select((state) => state.createdSecret); + + readonly getSecrets = this.effect((featureId$: Observable) => { + return featureId$.pipe( + switchMap((featureId) => { + this.patchState({ isLoading: true, loadFailed: false }); + return this.service.getClientSecrets(featureId).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + this.patchState({ isLoading: false, loadFailed: true }); + return; + } + this.patchState({ + isLoading: false, + secrets: Array.isArray(res?.data) ? res.data : [], + }); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isLoading: false, loadFailed: true }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + }); + + readonly createSecret = this.effect( + (data$: Observable<{ featureId: string | number; body: IClientSecretCreatePayload }>) => { + return data$.pipe( + switchMap(({ featureId, body }) => { + this.patchState({ isCreating: true, createdSecret: null }); + return this.service.createClientSecret(featureId, body).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + this.patchState({ isCreating: false }); + return; + } + this.patchState({ isCreating: false, createdSecret: res?.data }); + this.getSecrets(featureId); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ isCreating: false }); + // e.g. the limit was hit from another tab: make the list reflect reality + this.getSecrets(featureId); + } + ), + catchError(() => EMPTY) + ); + }) + ); + } + ); + + readonly revokeSecret = this.effect((data$: Observable<{ featureId: string | number; secretId: number }>) => { + return data$.pipe( + switchMap(({ featureId, secretId }) => { + this.patchState({ revokingId: secretId }); + return this.service.revokeClientSecret(featureId, secretId).pipe( + tapResponse( + (res: BaseResponse) => { + if (res?.hasError) { + this.showError(res?.errors); + } else { + this.toast.success('Client secret revoked'); + } + this.patchState({ revokingId: null }); + this.getSecrets(featureId); + }, + (error: any) => { + this.showError(error?.errors ?? error?.error?.errors); + this.patchState({ revokingId: null }); + } + ), + catchError(() => EMPTY) + ); + }) + ); + }); + + /** Drop the one-time plaintext from memory once the user has seen it. */ + clearCreatedSecret(): void { + this.patchState({ createdSecret: null }); + } + + private showError(error): void { + errorResolver(error).forEach((msg) => this.toast.error(msg)); + } +} diff --git a/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html b/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html index 7aae96f9..a5b22561 100644 --- a/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html +++ b/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html @@ -1178,6 +1178,11 @@

+ } @else { +

Save the block to create client secrets.

+ }