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/3] 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.

+ }

Logs Details

-
@@ -13,7 +13,12 @@

Logs Deta @if (data) { - @if (data.logData$ | async; as logdata) { + @if (data.error$ | async; as error) { +
+

{{ error }}

+ +
+ } @else if (data.logData$ | async; as logdata) { @if (!data?.logSettings || data?.logSettings?.store_headers) { Logs Deta {{ title }} -

+            
{{ jsonData | json }}
diff --git a/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.ts b/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.ts index 329a0964..ec55c2bd 100755 --- a/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.ts +++ b/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.ts @@ -32,6 +32,8 @@ export class LogsDetailsSideDialogComponent extends BaseComponent implements OnD public data = inject<{ logData$: Observable; isLoading$: Observable; + error$: Observable; + retry: () => void; logSettings: any; }>(MAT_DIALOG_DATA); diff --git a/apps/36-blocks/src/app/panel/logs/log/log.component.html b/apps/36-blocks/src/app/panel/logs/log/log.component.html index 6d98bda2..c89c60e3 100644 --- a/apps/36-blocks/src/app/panel/logs/log/log.component.html +++ b/apps/36-blocks/src/app/panel/logs/log/log.component.html @@ -195,6 +195,12 @@ + @if (logsError$ | async; as error) { + + }
- + @if (!(logsError$ | async) && !(isLoading$ | async)) { + + }
diff --git a/apps/36-blocks/src/app/panel/logs/log/log.component.ts b/apps/36-blocks/src/app/panel/logs/log/log.component.ts index cb4c06f0..73698b92 100644 --- a/apps/36-blocks/src/app/panel/logs/log/log.component.ts +++ b/apps/36-blocks/src/app/panel/logs/log/log.component.ts @@ -133,6 +133,7 @@ export class LogComponent extends BaseComponent implements OnDestroy, OnInit { }); // Observable public isLoading$: Observable = this.componentStore.isLoading$; + public logsError$ = this.componentStore.logsError$; public logs$: Observable> = this.componentStore.logsData$; public environments$: Observable> = this.componentStore.environments$; public projects$: Observable> = this.componentStore.projects$; @@ -252,6 +253,7 @@ export class LogComponent extends BaseComponent implements OnDestroy, OnInit { ...this.params, ...this.formatDateRange(), }; + this.params.pageNo = 1; this.getLogs(); } @@ -277,6 +279,7 @@ export class LogComponent extends BaseComponent implements OnDestroy, OnInit { project_id, environment_id, }); + this.params.pageNo = 1; this.getLogs(); } @@ -352,8 +355,8 @@ export class LogComponent extends BaseComponent implements OnDestroy, OnInit { this.params = CustomValidators.removeNullKeys({ ...this.params, range: formValue.range ? formValue.range : null, - from: formValue.from ? formValue.from : null, - to: formValue.to ? formValue.to : null, + from: formValue.from ?? null, + to: formValue.to ?? null, request_type: formValue.requestType?.length && formValue.requestType.length !== this.requestTypes.length ? formValue.requestType.join(',') @@ -391,6 +394,8 @@ export class LogComponent extends BaseComponent implements OnDestroy, OnInit { data: { logData$: this.reqLogs$, isLoading$: this.componentStore.reqLogsInProcess$, + error$: this.componentStore.reqLogsError$, + retry: () => this.componentStore.getLogsById(id), logSettings: clientSettings?.client?.settings?.logs, }, maxHeight: '100vh', diff --git a/apps/36-blocks/src/app/panel/logs/log/logs.store.ts b/apps/36-blocks/src/app/panel/logs/log/logs.store.ts index c28a6417..c9128305 100644 --- a/apps/36-blocks/src/app/panel/logs/log/logs.store.ts +++ b/apps/36-blocks/src/app/panel/logs/log/logs.store.ts @@ -10,12 +10,14 @@ import { EMPTY, Observable, catchError, switchMap } from 'rxjs'; export interface ILogsInitialState { logs: IPaginatedResponse; logsInProcess: boolean; + logsError: string | null; environments: IPaginatedResponse; environmentsInProcess: boolean; projects: IPaginatedResponse; projectsInProcess: boolean; reqLogs: ILogDetailRes; reqLogsInProcess: boolean; + reqLogsError: string | null; } @Injectable() @@ -27,12 +29,14 @@ export class LogsComponentStore extends ComponentStore { super({ logs: null, logsInProcess: false, + logsError: null, environments: null, environmentsInProcess: false, projects: null, projectsInProcess: false, reqLogs: null, reqLogsInProcess: false, + reqLogsError: null, }); } @@ -41,27 +45,33 @@ export class LogsComponentStore extends ComponentStore { (state) => state.environments ); readonly projects$: Observable> = this.select((state) => state.projects); - readonly reqLogs$: Observable = this.select((state) => state.reqLogs); + readonly logsError$ = this.select((state) => state.logsError); + readonly reqLogsError$ = this.select((state) => state.reqLogsError); + readonly reqLogs$: Observable = this.select((state) => state.reqLogs); readonly isLoading$: Observable = this.select( (state) => state.logsInProcess || state.environmentsInProcess || state.projectsInProcess ); readonly reqLogsInProcess$: Observable = this.select((state) => state.reqLogsInProcess); - readonly resetReqLog = this.updater((state) => ({ ...state, reqLogs: null })); + readonly resetReqLog = this.updater((state) => ({ ...state, reqLogs: null, reqLogsError: null })); readonly getLogs = this.effect((data: Observable) => { return data.pipe( switchMap((req: ILogsReq) => { - this.patchState({ logsInProcess: true }); + this.patchState({ logsInProcess: true, logsError: null, logs: null }); return this.service.getProxyLogs(req).pipe( tapResponse( (res: BaseResponse, ILogsReq>) => { - if (res.hasError) { + const failed = !res || res.hasError || !Array.isArray(res.data?.data); + if (res?.hasError) { this.showErrorMessages(res.errors); } return this.patchState({ logsInProcess: false, - logs: res?.data, + logs: failed ? null : res.data, + logsError: failed + ? 'Logs could not be loaded. Retry or check the logging service.' + : null, }); }, (error: any) => { @@ -69,6 +79,7 @@ export class LogsComponentStore extends ComponentStore { return this.patchState({ logsInProcess: false, logs: null, + logsError: 'Logs could not be loaded. Retry or check the logging service.', }); } ), @@ -81,21 +92,30 @@ export class LogsComponentStore extends ComponentStore { readonly getLogsById = this.effect((data: Observable) => { return data.pipe( switchMap((req: any) => { - this.patchState({ reqLogsInProcess: true, reqLogs: null }); + this.patchState({ reqLogsInProcess: true, reqLogs: null, reqLogsError: null }); return this.service.getProxyLogsById(req).pipe( tapResponse( (res: BaseResponse) => { - if (res.hasError) { + const failed = + !res || + res.hasError || + !res.data || + typeof res.data !== 'object' || + Array.isArray(res.data); + if (res?.hasError) { this.showErrorMessages(res.errors); } return this.patchState({ reqLogsInProcess: false, - reqLogs: { - ...res.data, - request_body: this.parseJson(res?.data?.request_body), - headers: this.parseJson(res?.data?.headers), - response: this.parseJson(res?.data?.response), - }, + reqLogsError: failed ? 'Log details could not be loaded. Retry this request.' : null, + reqLogs: failed + ? null + : { + ...res.data, + request_body: this.parseJson(res?.data?.request_body), + headers: this.parseJson(res?.data?.headers), + response: this.parseJson(res?.data?.response), + }, }); }, (error: any) => { @@ -103,6 +123,7 @@ export class LogsComponentStore extends ComponentStore { return this.patchState({ reqLogsInProcess: false, reqLogs: null, + reqLogsError: 'Log details could not be loaded. Retry this request.', }); } ), diff --git a/apps/36-blocks/src/app/panel/panel.routes.ts b/apps/36-blocks/src/app/panel/panel.routes.ts index 7541981e..28f9ce6c 100644 --- a/apps/36-blocks/src/app/panel/panel.routes.ts +++ b/apps/36-blocks/src/app/panel/panel.routes.ts @@ -15,6 +15,10 @@ export const panelRoutes: Route[] = [ path: 'logs', loadChildren: () => import('./logs/logs.routes').then((r) => r.logsRoutes), }, + { + path: 'activity', + loadComponent: () => import('./activity/activity.component').then((c) => c.ActivityComponent), + }, { path: 'dashboard', loadComponent: () => import('./dashboard/dashboard.component').then((c) => c.DashboardComponent), diff --git a/apps/36-blocks/src/app/website/about/about.component.html b/apps/36-blocks/src/app/website/about/about.component.html index d832d251..9385d18e 100644 --- a/apps/36-blocks/src/app/website/about/about.component.html +++ b/apps/36-blocks/src/app/website/about/about.component.html @@ -26,11 +26,12 @@ class="text-4xl sm:text-5xl lg:text-6xl font-bold text-[#f0f6fc] tracking-tight mb-6 leading-[1.1]" > Built for developers.
- Trusted by SaaS teams. + Shared identity for connected apps.

36Blocks was created to remove the burden of building authentication from scratch. We believe every - developer deserves a production-ready, secure auth stack they can drop in within minutes — not months. + developer should be able to configure authentication, understand failures, and enforce application access + with clear boundaries.

@@ -118,7 +119,9 @@

What We Bui

Ready to get started?

-

Ship auth in 5 minutes. Free forever on the Starter plan.

+

+ Explore the configured capabilities and read the integration guide before connecting your application. +

contact the team. Review + security boundaries before enabling access. +

+
+ diff --git a/apps/36-blocks/src/app/website/docs/docs.component.ts b/apps/36-blocks/src/app/website/docs/docs.component.ts new file mode 100644 index 00000000..de1dcddf --- /dev/null +++ b/apps/36-blocks/src/app/website/docs/docs.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { Meta, Title } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { WIDGET_EXAMPLE } from './widget-example'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'proxy-docs', + imports: [RouterModule], + templateUrl: './docs.component.html', +}) +export class DocsComponent implements OnInit { + private readonly title = inject(Title); + private readonly meta = inject(Meta); + public readonly example = WIDGET_EXAMPLE; + + public ngOnInit(): void { + this.title.setTitle('Integration guide — 36Blocks'); + this.meta.updateTag({ + name: 'description', + content: + 'Mount the 36Blocks authentication widget and understand application, session, and permission boundaries.', + }); + this.meta.updateTag({ property: 'og:title', content: 'Integration guide — 36Blocks' }); + this.meta.updateTag({ + property: 'og:description', + content: 'Widget configuration and application security boundaries.', + }); + } +} diff --git a/apps/36-blocks/src/app/website/docs/widget-example.ts b/apps/36-blocks/src/app/website/docs/widget-example.ts new file mode 100644 index 00000000..13ca3908 --- /dev/null +++ b/apps/36-blocks/src/app/website/docs/widget-example.ts @@ -0,0 +1,22 @@ +/** Authorization mode mounts at the configured reference ID. */ +export const WIDGET_EXAMPLE = `
+

+ +`; diff --git a/apps/36-blocks/src/app/website/home/home.component.html b/apps/36-blocks/src/app/website/home/home.component.html index e2f5b2cf..361b2f10 100644 --- a/apps/36-blocks/src/app/website/home/home.component.html +++ b/apps/36-blocks/src/app/website/home/home.component.html @@ -38,8 +38,8 @@

- Launch secure user signup, login, roles, and access control in minutes — without building complex - authentication systems from scratch. + Configure user signup, login, roles, and access controls for your application. Investigate failures and + manage users from one panel.

@@ -103,8 +103,9 @@

Pricing plans

- +
} @@ -144,7 +145,7 @@

- Everything you need to ship auth fast and scale confidently. + Understand your configuration and verify the integration before launch.

diff --git a/apps/36-blocks/src/app/website/pricing/pricing.component.ts b/apps/36-blocks/src/app/website/pricing/pricing.component.ts index 8076e43c..9e089a14 100644 --- a/apps/36-blocks/src/app/website/pricing/pricing.component.ts +++ b/apps/36-blocks/src/app/website/pricing/pricing.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; import { Meta, Title } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; export interface PricingFeature { @@ -36,7 +37,7 @@ export interface TrustItem { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'proxy-pricing', - imports: [CommonModule, MatIconModule], + imports: [CommonModule, MatIconModule, RouterModule], templateUrl: './pricing.component.html', }) export class PricingComponent implements OnInit { @@ -47,112 +48,57 @@ export class PricingComponent implements OnInit { this.titleService.setTitle('Pricing — 36Blocks'); this.metaService.updateTag({ name: 'description', - content: - 'Simple, transparent pricing for 36Blocks. Start free with 1,000 MAU. Upgrade as you grow. No hidden fees, no contracts.', + content: 'Confirm current 36Blocks pricing, usage limits, and service terms with the team.', }); this.metaService.updateTag({ name: 'robots', content: 'index, follow' }); this.metaService.updateTag({ property: 'og:title', content: 'Pricing — 36Blocks' }); this.metaService.updateTag({ property: 'og:description', - content: 'Start free. Scale with confidence. Transparent usage-based pricing.', + content: 'Contact the team for current pricing and service terms.', }); this.metaService.updateTag({ property: 'og:type', content: 'website' }); } public readonly plans: PricingPlan[] = [ { - name: 'Starter', - price: '$0', - period: '/ month', - description: 'Everything you need to get started with authentication.', + name: 'Plan and environment review', + price: 'Contact us', + period: '', + description: 'Confirm current commercial terms and enabled capabilities for your application.', highlight: false, features: [ - { text: '1,000 Monthly Active Users', included: true }, - { text: '3 Blocks', included: true }, - { text: '7-day logs', included: true }, - { text: '3-hour sessions', included: true }, - { text: 'Email authentication', included: true }, - { text: 'SMS (Not available)', included: false }, - { text: '36Blocks branding', included: true }, + { text: 'Review login methods and provider configuration', included: true }, + { text: 'Confirm usage limits and billing terms', included: true }, + { text: 'Confirm session and retention policies', included: true }, ], - cta: 'Start Free', - ctaStyle: 'outline', - }, - { - name: 'Growth', - price: '$49', - period: '/ month', - description: 'Scale your product with powerful auth and branding control.', - highlight: true, - badge: 'Most Popular', - features: [ - { text: '10,000 MAU included', included: true }, - { text: '10 Blocks', included: true }, - { text: '30-day logs', included: true }, - { text: '12-hour sessions', included: true }, - { text: 'Custom branding', included: true }, - { text: 'SMS available (usage-based)', included: true }, - { text: 'Auto multi-org mapping', included: true }, - { text: 'Extra Usage: +$10 per 5k MAU', included: true, bold: true }, - { text: '+$5 per extra Block', included: true, bold: true }, - ], - cta: 'Upgrade to Growth', - ctaStyle: 'primary', - }, - { - name: 'Scale', - price: '$149', - period: '/ month', - description: 'Enterprise-grade auth for high-volume, complex SaaS products.', - highlight: false, - features: [ - { text: '50,000 MAU included', included: true }, - { text: 'Unlimited Blocks', included: true }, - { text: '24-hour sessions', included: true }, - { text: 'Advanced org rules', included: true }, - { text: 'SMS available (usage-based)', included: true }, - { text: 'Extra Usage: +$25 per 25k MAU', included: true, bold: true }, - ], - cta: 'Choose Scale', + cta: 'Discuss your setup', ctaStyle: 'outline', }, ]; public readonly infoBanners: InfoBanner[] = [ { - icon: 'bar_chart', - title: 'Transparent usage-based pricing', + icon: 'info', + title: 'Current terms require confirmation', description: - 'MAU = unique logged-in users per calendar month. Usage visible in real time. No hidden fees. No contracts.', + 'Plan names and limits in older material may differ from your environment. Confirm pricing, usage allowances, and enabled providers with the team before purchase.', }, { icon: 'sms', - title: 'Optional Add-ons', + title: 'Provider dependencies', description: - 'Transactional SMS on paid plans. Billed per message by destination country. Requires a configurable monthly spending cap.', + 'SMS, email, and social login require the appropriate provider configuration. Confirm provider costs and availability for your environment.', }, ]; public readonly trustItems: TrustItem[] = [ + { title: 'Widget integration', description: 'Use your configured block reference ID.', icon: 'code' }, + { title: 'Application boundaries', description: 'Your product enforces business authorization.', icon: 'lock' }, + { title: 'Environment review', description: 'Confirm enabled providers and service terms.', icon: 'settings' }, { - title: '5-minute setup', - description: 'Minimal configuration. Production-ready instantly.', - icon: 'bolt', - }, - { - title: 'No vendor lock-in', - description: 'Simple architecture and predictable APIs.', - icon: 'lock_open', - }, - { - title: 'Cancel anytime', - description: 'No contracts. Upgrade or downgrade freely.', - icon: 'autorenew', - }, - { - title: 'Security-first', - description: 'Encrypted in transit and at rest.', - icon: 'security', + title: 'Investigation tools', + description: 'Inspect request failures and recorded activity.', + icon: 'search', }, ]; } diff --git a/apps/36-blocks/src/app/website/privacy/privacy.component.html b/apps/36-blocks/src/app/website/privacy/privacy.component.html index 405b0626..2a16ea2d 100644 --- a/apps/36-blocks/src/app/website/privacy/privacy.component.html +++ b/apps/36-blocks/src/app/website/privacy/privacy.component.html @@ -199,9 +199,9 @@

4. Data Sharin >

5. Data Retention

- Account data is retained for the life of your account. End-user authentication logs are retained per - your plan (7 days on Starter, 30 days on Growth, 90 days on Scale). On account deletion, all data is - purged within 30 days. + Retention and deletion schedules depend on the deployed environment and applicable service terms. + Contact the team to confirm the current policy for account data, request logs, and activity records + before sending personal data. No fixed retention or deletion schedule is published here.

diff --git a/apps/36-blocks/src/app/website/security/security.component.html b/apps/36-blocks/src/app/website/security/security.component.html index b1a0d1f9..8b33e3c0 100644 --- a/apps/36-blocks/src/app/website/security/security.component.html +++ b/apps/36-blocks/src/app/website/security/security.component.html @@ -1,253 +1,58 @@
-
- -

- Security at 36Blocks -

-

- We take security seriously. Every layer of 36Blocks is designed with your users' data and trust in mind. +

Security at 36Blocks

+

+ Understand the access boundaries and configuration that protect your connected applications.

-

Last updated: January 2026

- - -
- -
-
- -
-

Infrastructure & Hosting

-

- 36Blocks runs on enterprise-grade cloud infrastructure with automatic failover, redundancy, and - geographic distribution. All services are containerised and isolated to prevent cross-tenant - data leakage. -

-
-
+
+
+

Authentication configuration

+

+ Blocks support configurable login methods, provider settings, session expiry, and hCaptcha integration. + Availability and enforcement depend on the selected block and deployment. Configure the providers and + verify failure, expiry, and recovery behavior for your application before launch. +

- -
-
- -
-

Data Encryption

-

- All data is encrypted both in transit and at rest. We use TLS 1.2+ for all network communication - and AES-256 for data stored on disk. Passwords are hashed using bcrypt and are never stored in - plaintext. -

-
    - @for ( - item of [ - 'TLS 1.2+ for all API and dashboard traffic', - 'AES-256 encryption at rest', - 'bcrypt password hashing', - 'Encrypted database backups', - ]; - track item - ) { -
  • - - {{ item }} -
  • - } -
-
-
+
+

Identity and application access

+

+ Authentication identifies a user. It does not grant access to every application or organization. Roles + and permissions help model membership, while each connected product remains responsible for resource + ownership and business authorization on its backend. +

+

+ Do not rely on a browser callback or hidden UI control to protect a resource. Your application must + verify authentication results, expiry, membership, and permissions before granting access. +

- -
-
- -
-

Authentication Security

-

- 36Blocks implements industry best practices for authentication flows including OTP expiry, rate - limiting, brute-force protection, and bot prevention via hCaptcha. -

-
    - @for ( - item of [ - 'hCaptcha bot protection on all public endpoints', - 'OTP codes expire within 10 minutes', - 'Rate limiting on login and OTP requests', - 'Session tokens with configurable expiry', - 'Secure, HttpOnly cookies where applicable', - ]; - track item - ) { -
  • - - {{ item }} -
  • - } -
-
-
+
+

Investigation and activity history

+

+ Request logs help investigate API failures. Sensitive-operation activity history records the operations + that have been instrumented, including Login as attempts. Coverage and availability depend on the + deployed version and permissions. +

+

+ Confirm retention, operator access, and the required investigation coverage with the team. Use activity + records within their documented coverage. +

- -
-
- -
-

- Access Control & Audit Logs -

-

- Role-Based Access Control (RBAC) is built into the core of 36Blocks. Every action is logged with - a full audit trail so you can see who did what, when. Logs are retained per your plan and are - tamper-evident. -

-
-
+
+

Deployment responsibilities

+

+ Transport protection, database encryption, backups, recovery, and availability depend on the hosting + environment. Confirm these controls and any service commitments for your deployment. Ask the team for + environment-specific availability and recovery commitments. +

- -
-
- -
-

Responsible Disclosure

-

- If you discover a security vulnerability, please contact us at - security@36blocks.com. We will respond within 48 hours and work with you to resolve the issue responsibly. -

-
-
+
+

Report a security concern

+

+ Contact the team through the contact page. + Include the affected flow and safe reproduction steps. Do not include passwords, session tokens, or + personal data in your initial message. +

diff --git a/apps/36-blocks/src/app/website/security/security.component.ts b/apps/36-blocks/src/app/website/security/security.component.ts index 61aa948e..92383c7e 100644 --- a/apps/36-blocks/src/app/website/security/security.component.ts +++ b/apps/36-blocks/src/app/website/security/security.component.ts @@ -17,13 +17,13 @@ export class SecurityComponent implements OnInit { this.metaService.updateTag({ name: 'description', content: - 'Learn how 36Blocks keeps your users safe with enterprise-grade encryption, hCaptcha bot protection, rate limiting, RBAC audit logs, and responsible disclosure.', + 'Understand authentication configuration, application access boundaries, and investigation coverage in 36Blocks.', }); this.metaService.updateTag({ name: 'robots', content: 'index, follow' }); this.metaService.updateTag({ property: 'og:title', content: 'Security — 36Blocks' }); this.metaService.updateTag({ property: 'og:description', - content: 'Enterprise-grade security built into every layer of 36Blocks.', + content: 'Authentication configuration and application security boundaries.', }); this.metaService.updateTag({ property: 'og:type', content: 'website' }); } diff --git a/apps/36-blocks/src/app/website/terms/terms.component.html b/apps/36-blocks/src/app/website/terms/terms.component.html index dead6ff8..546ae106 100644 --- a/apps/36-blocks/src/app/website/terms/terms.component.html +++ b/apps/36-blocks/src/app/website/terms/terms.component.html @@ -38,9 +38,9 @@

1. Acceptance of Terms

- By accessing or using the 36Blocks platform, APIs, SDKs, or any related services (collectively, the - "Service"), you agree to be bound by these Terms of Service ("Terms"). If you do not agree, do not use - the Service. + By accessing or using the 36Blocks platform, APIs, browser widget, or any related services + (collectively, the "Service"), you agree to be bound by these Terms of Service ("Terms"). If you do not + agree, do not use the Service.

diff --git a/apps/36-blocks/src/app/website/website.routes.ts b/apps/36-blocks/src/app/website/website.routes.ts index 983247d7..4f4c6e80 100644 --- a/apps/36-blocks/src/app/website/website.routes.ts +++ b/apps/36-blocks/src/app/website/website.routes.ts @@ -12,6 +12,10 @@ export const websiteRoutes: Route[] = [ canActivate: [LoggedInGuard], loadComponent: () => import('./home/home.component').then((c) => c.HomeComponent), }, + { + path: 'docs', + loadComponent: () => import('./docs/docs.component').then((c) => c.DocsComponent), + }, { path: 'pricing', loadComponent: () => import('./pricing/pricing.component').then((c) => c.PricingComponent), diff --git a/apps/36-blocks/src/server.ts b/apps/36-blocks/src/server.ts index ab71732b..bed4df02 100644 --- a/apps/36-blocks/src/server.ts +++ b/apps/36-blocks/src/server.ts @@ -15,14 +15,14 @@ export function app(): express.Express { const angularApp = new AngularNodeAppEngine(); server.get( - '**', + '/{*path}', express.static(browserDistFolder, { maxAge: '1y', index: false, }) ); - server.get('**', (req, res, next) => { + server.get('/{*path}', (req, res, next) => { angularApp .handle(req, { server: res }) .then((response) => { diff --git a/docs/widget-integration-guide.md b/docs/widget-integration-guide.md index 0ab1bf06..852da1e9 100644 --- a/docs/widget-integration-guide.md +++ b/docs/widget-integration-guide.md @@ -1,5 +1,7 @@ # Proxy Auth Widget — Client Integration Guide +> **Verification:** Examples describe existing widget contracts; provider login and application session flows still require environment testing. Browser callbacks alone do not authorize business resources. +> > **Created:** Mar 25, 2026 > **Scope:** `apps/36-blocks-widget` — `initVerification` usage examples for all widget types > **Script output:** `dist/apps/36-blocks/assets/proxy-auth/proxy-auth.js` @@ -52,11 +54,12 @@ Default widget type. Shows OTP, social login, and/or password login buttons base type: 'authorization', success: (data) => { // get verified token in response - console.log('success response', data); + // Privately hand off to your application's verified session flow. + // Never log callback data: it may contain credentials. }, failure: (error) => { // handle error - console.log('failure reason', error); + // Show a safe failure message; do not log credential-bearing payloads. }, }; @@ -83,11 +86,12 @@ Displays the authenticated user's profile. Requires an `authToken` obtained from authToken: 'ENCRYPTED_AUTH_TOKEN', success: (data) => { // get verified token in response - console.log('success response', data); + // Privately hand off to your application's verified session flow. + // Never log callback data: it may contain credentials. }, failure: (error) => { // handle error - console.log('failure reason', error); + // Show a safe failure message; do not log credential-bearing payloads. }, }; @@ -115,11 +119,12 @@ Shows a role & user management panel. Requires an `authToken`. Use `isRolePermis isRolePermission: true, success: (data) => { // get verified token in response - console.log('success response', data); + // Privately hand off to your application's verified session flow. + // Never log callback data: it may contain credentials. }, failure: (error) => { // handle error - console.log('failure reason', error); + // Show a safe failure message; do not log credential-bearing payloads. }, }; @@ -147,11 +152,12 @@ Renders the organization details view. Use `showCompanyDetails` to include the c showCompanyDetails: true, success: (data) => { // get verified token in response - console.log('success response', data); + // Privately hand off to your application's verified session flow. + // Never log callback data: it may contain credentials. }, failure: (error) => { // handle error - console.log('failure reason', error); + // Show a safe failure message; do not log credential-bearing payloads. }, }; @@ -178,11 +184,12 @@ Shows subscription plans. Set `isLogin: true` to display a login flow alongside isLogin: true, success: (data) => { // get verified token in response - console.log('success response', data); + // Privately hand off to your application's verified session flow. + // Never log callback data: it may contain credentials. }, failure: (error) => { // handle error - console.log('failure reason', error); + // Show a safe failure message; do not log credential-bearing payloads. }, }; @@ -213,11 +220,12 @@ Uses `version: 'v2'` UI, icon-only social buttons, custom input field position, target: '_blank', success: (data) => { // get verified token in response - console.log('success response', data); + // Privately hand off to your application's verified session flow. + // Never log callback data: it may contain credentials. }, failure: (error) => { // handle error - console.log('failure reason', error); + // Show a safe failure message; do not log credential-bearing payloads. }, }; diff --git a/libs/services/proxy/root/src/lib/services-proxy-root.module.ts b/libs/services/proxy/root/src/lib/services-proxy-root.module.ts index 5ead552f..e1730370 100644 --- a/libs/services/proxy/root/src/lib/services-proxy-root.module.ts +++ b/libs/services/proxy/root/src/lib/services-proxy-root.module.ts @@ -36,7 +36,7 @@ export class RootService { public generateToken(params: { [key: string]: any } = {}): Observable> { return this.http.get>(RootUrls.generateToken(this.baseUrl), params); } - public getProjects(): Observable> { - return this.http.get>(RootUrls.getProject(this.baseUrl)); + public getProjects(): Observable, void>> { + return this.http.get, void>>(RootUrls.getProject(this.baseUrl)); } } diff --git a/package.json b/package.json index a820ad81..703bcbb4 100755 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "workspace-generator": "nx workspace-generator", "update-public-suffix": "node ./libs/ui/handle-domain/src/lib/updater.js", "format:code": "npx prettier --write --ignore-unknown .", - "prepare": "husky install" + "prepare": "husky install", + "test:v0": "node --test tools/tests/*.test.cjs" }, "private": true, "dependencies": { diff --git a/tools/set-env.js b/tools/set-env.js index b635de00..622416ec 100644 --- a/tools/set-env.js +++ b/tools/set-env.js @@ -18,7 +18,7 @@ const path = require('path'); // Object.assign ensures .env values override any stale/dev env vars pre-set by Amplify. try { const dotenvResult = require('dotenv').config({ - path: path.resolve(__dirname, '../.env') + path: path.resolve(__dirname, '../.env'), }); if (dotenvResult.parsed) { Object.assign(process.env, dotenvResult.parsed); @@ -45,20 +45,16 @@ if (DEBUG) { function get(key, fallback = undefined) { const value = env[key] !== undefined ? env[key] : fallback; - + // Debug logging to track credential source if (DEBUG) { if (value !== undefined) { - // Show first 8 chars for verification without exposing full secrets - const preview = typeof value === 'string' && value.length > 8 - ? `${value.substring(0, 8)}...` - : '***SET***'; - console.log(`[set-env DEBUG] ${key} = ${preview}`); + console.log(`[set-env DEBUG] ${key} = SET`); } else { console.log(`[set-env DEBUG] ${key} = MISSING`); } } - + if (value === undefined) { if (isCI) missingKeys.push(key); else console.warn(`[set-env] WARNING: env var "${key}" is not set`); @@ -117,4 +113,4 @@ const onlyAuth = args.includes('--auth'); if (!onlyAuth) writeProxyEnv(); if (!onlyProxy) writeProxyAuthEnv(); -assertNoMissingKeys(); \ No newline at end of file +assertNoMissingKeys(); diff --git a/tools/tests/activity.test.cjs b/tools/tests/activity.test.cjs new file mode 100644 index 00000000..8c0e87af --- /dev/null +++ b/tools/tests/activity.test.cjs @@ -0,0 +1,222 @@ +// Run: node --test tools/tests/activity.test.cjs +// Exercises actual component/service TypeScript with RxJS; Angular DI and signals +// are hosted locally. Browser rendering and backend authorization are separate checks. +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { Subject, of, throwError } = require('rxjs'); + +function load(relative, dependencies = {}) { + const source = readFileSync(path.resolve(__dirname, '../..', relative), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + experimentalDecorators: true, + }, + }).outputText; + const module = { exports: {} }; + vm.runInNewContext(compiled, { + module, + exports: module.exports, + URL, + require: (name) => { + if (Object.hasOwn(dependencies, name)) return dependencies[name]; + if (name === 'rxjs') return require(name); + if (name.startsWith('@angular/')) return {}; + throw new Error(`Unexpected runtime dependency: ${name}`); + }, + }); + return module.exports; +} +const modelPath = 'apps/36-blocks/src/app/panel/activity/activity.models.ts'; +const models = load(modelPath); +const componentPath = 'apps/36-blocks/src/app/panel/activity/activity.component.ts'; +function createComponent(service) { + const signal = (initial) => { + let value = initial; + const get = () => value; + get.set = (next) => { + value = next; + }; + return get; + }; + const { ActivityComponent } = load(componentPath, { + '@angular/core': { + Component: () => (target) => target, + ChangeDetectionStrategy: {}, + inject: () => service, + signal, + }, + './activity.models': models, + './activity.service': { ActivityService: class {} }, + }); + return new ActivityComponent(); +} +const empty = { hasError: false, data: { data: [], totalEntityCount: 0, totalPageCount: 0 } }; + +test('activity distinguishes denied, failed and empty responses and recovers on retry', () => { + let response = throwError(() => ({ errorCode: 403 })); + const component = createComponent({ getOperations: () => response }); + component.ngOnInit(); + assert.match(component.error(), /view_operations/); + assert.equal(component.loading(), false); + response = of({ hasError: true, data: empty.data }); + component.load(1); + assert.match(component.error(), /could not be loaded/); + response = of(empty); + component.load(1); + assert.equal(component.error(), null); + assert.equal(component.rows().length, 0); +}); + +test('new requests clear stale rows and cancel older work', () => { + const first = new Subject(); + const second = new Subject(); + let count = 0; + const component = createComponent({ getOperations: () => (++count === 1 ? first : second) }); + component.ngOnInit(); + first.next({ data: { data: [{ id: 1 }], totalEntityCount: 1, totalPageCount: 1 } }); + component.load(2); + assert.equal(first.observers.length, 0); + assert.equal(component.rows().length, 0); + assert.equal(component.loading(), true); + second.next(empty); + assert.equal(component.loading(), false); + component.ngOnDestroy(); + assert.equal(second.observers.length, 0); +}); + +test('pagination uses applied filters rather than unsaved form edits; clear resets page and filters', () => { + const requests = []; + const component = createComponent({ + getOperations: (params) => { + requests.push(params); + return of(empty); + }, + }); + component.filters.operation = ' Login as '; + component.filters.target_user_id = '17'; + component.applyFilters(); + component.filters.operation = 'Unsaved'; + component.load(2); + assert.equal(requests[1].operation, 'Login as'); + assert.equal(requests[1].target_user_id, '17'); + assert.equal(requests[1].pageNo, 2); + assert.equal(Object.hasOwn(requests[1], 'outcome'), false); + component.clearFilters(); + assert.equal(requests[2].pageNo, 1); + assert.equal(Object.hasOwn(requests[2], 'operation'), false); +}); + +test('reversed dates prevent a request', () => { + const component = createComponent({ + getOperations: () => { + throw new Error('must not send'); + }, + }); + component.filters.startDate = '2026-10-28'; + component.filters.endDate = '2026-10-27'; + component.applyFilters(); + assert.match(component.error(), /end date/); +}); + +test('Login as requires valid target, reason and explicit confirmation before sending credentials request', () => { + const calls = []; + const component = createComponent({ + loginAs: (request) => { + calls.push(request); + return of({ data: { redirect_url: 'https://app.example.test/?token=example' } }); + }, + getOperations: () => of(empty), + }); + component.loginTarget = '17'; + component.loginReason = ' Support ticket 123 '; + component.loginAs(); + assert.equal(calls.length, 0); + assert.match(component.loginError(), /confirm/); + component.loginConfirmed = true; + component.loginAs(); + assert.equal(calls[0].user_id, 17); + assert.equal(calls[0].reason, 'Support ticket 123'); + assert.equal(Object.hasOwn(calls[0], 'company_id'), false); + assert.equal(component.loginConfirmed, false); + assert.equal(component.loginRedirect(), 'https://app.example.test/?token=example'); +}); + +test('Login as never presents a returned raw token or unsafe redirect', () => { + const component = createComponent({ + loginAs: () => + of({ data: { redirect_url: 'javascript:alert(1)', proxy_auth_token: 'secret', refresh_token: 'secret' } }), + getOperations: () => of(empty), + }); + component.loginTarget = '17'; + component.loginReason = 'Support'; + component.loginConfirmed = true; + component.loginAs(); + assert.equal(component.loginRedirect(), null); + assert.match(component.loginMessage(), /no safe application redirect/); + assert.equal(component.loginMessage().includes('secret'), false); +}); + +test('Login as permission failures remain actionable and do not display backend errors', () => { + const component = createComponent({ + loginAs: () => throwError(() => ({ errorCode: 403, errors: 'secret' })), + getOperations: () => of(empty), + }); + component.loginTarget = '17'; + component.loginReason = 'Support'; + component.loginConfirmed = true; + component.loginAs(); + assert.match(component.loginError(), /Owner, Admin/); + assert.equal(component.loginError().includes('secret'), false); + assert.equal(component.loggingIn(), false); +}); + +test('credential redirects allow HTTPS and loopback HTTP only, without embedded userinfo', () => { + for (const value of [ + 'javascript:alert(1)', + '//app.test/path', + 'data:text/plain,a', + 'http://app.test/', + 'https://user:pass@app.test/', + null, + ]) { + assert.equal(models.safeLoginRedirect(value), null); + } + assert.equal(models.safeLoginRedirect('https://app.test/path#fragment'), 'https://app.test/path#fragment'); + assert.equal(models.safeLoginRedirect('http://localhost:4200/path'), 'http://localhost:4200/path'); +}); + +test('service uses authenticated wrapper and POST JSON for impersonation reason', () => { + const calls = []; + const http = { get: (...args) => calls.push(['get', ...args]), post: (...args) => calls.push(['post', ...args]) }; + class Wrapper {} + const { ActivityService } = load('apps/36-blocks/src/app/panel/activity/activity.service.ts', { + '@angular/core': { Injectable: () => (target) => target, Inject: () => () => {}, inject: () => http }, + '@proxy/models/root-models': { ProxyBaseUrls: { BaseURL: 'BASE_URL' } }, + '@proxy/service': { createUrl: (base, endpoint) => `${base}/${endpoint}` }, + '@proxy/services/httpWrapper': { HttpWrapperService: Wrapper }, + }); + const service = new ActivityService('https://api.example.test/api'); + service.getOperations({ pageNo: 2 }); + service.loginAs({ user_id: 17, reason: 'Support' }); + assert.equal(calls[0][1], 'https://api.example.test/api/operations'); + assert.equal(calls[1][0], 'post'); + assert.equal(calls[1][1], 'https://api.example.test/api/loginAs'); + assert.equal(calls[1][2].reason, 'Support'); +}); + +test('editing the impersonation target clears the previous credential link and confirmation', () => { + const component = createComponent({}); + component.loginRedirect.set('https://app.test/?token=example'); + component.loginMessage.set('Created'); + component.loginConfirmed = true; + component.clearLoginResult(); + assert.equal(component.loginRedirect(), null); + assert.equal(component.loginMessage(), null); + assert.equal(component.loginConfirmed, false); +}); diff --git a/tools/tests/environment-logging.test.cjs b/tools/tests/environment-logging.test.cjs new file mode 100644 index 00000000..3c92559d --- /dev/null +++ b/tools/tests/environment-logging.test.cjs @@ -0,0 +1,35 @@ +const assert = require('node:assert/strict'); +const { mkdtempSync, mkdirSync, copyFileSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +test('environment generation reports presence without leaking credential prefixes', () => { + const root = mkdtempSync(path.join(tmpdir(), '36blocks-env-')); + try { + mkdirSync(path.join(root, 'tools')); + mkdirSync(path.join(root, 'apps/36-blocks-widget/src/environments'), { recursive: true }); + copyFileSync(path.resolve(__dirname, '../set-env.js'), path.join(root, 'tools/set-env.js')); + const secret = 'SYNTHETIC-TEST-SECRET-not-a-real-credential'; + const result = spawnSync(process.execPath, [path.join(root, 'tools/set-env.js'), '--auth'], { + encoding: 'utf8', + env: { + DEBUG_SET_ENV: 'true', + AUTH_UI_ENCODE_KEY: secret, + AUTH_UI_IV_KEY: secret, + AUTH_API_ENCODE_KEY: secret, + AUTH_API_IV_KEY: secret, + HCAPTCHA_SITE_KEY: secret, + SEND_OTP_AUTH_KEY: secret, + }, + }); + assert.equal(result.status, 0); + const output = result.stdout + result.stderr; + assert.ok(output.includes('AUTH_UI_ENCODE_KEY = SET')); + assert.ok(!output.includes(secret)); + assert.ok(!output.includes(secret.substring(0, 8))); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tools/tests/panel-investigation.test.cjs b/tools/tests/panel-investigation.test.cjs new file mode 100644 index 00000000..dbbea465 --- /dev/null +++ b/tools/tests/panel-investigation.test.cjs @@ -0,0 +1,299 @@ +// Run: node --test tools/tests/panel-investigation.test.cjs +// Execute actual TypeScript classes with RxJS. Only framework DI/state hosting is +// stubbed, so these checks do not replace a browser or Angular template build. +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { BehaviorSubject, Subject, of, throwError, firstValueFrom, map, tap } = require('rxjs'); + +class StateHost { + constructor(state) { + this.state$ = new BehaviorSubject(state); + } + select(selector) { + return this.state$.pipe(map(selector)); + } + patchState(patch) { + const state = this.state$.value; + this.state$.next({ ...state, ...(typeof patch === 'function' ? patch(state) : patch) }); + } + updater(update) { + return () => this.state$.next(update(this.state$.value)); + } + effect(project) { + const requests = new Subject(); + project(requests).subscribe(); + return (request) => requests.next(request); + } +} + +function loadClass(relativePath, dependencies) { + const source = readFileSync(path.resolve(__dirname, '../..', relativePath), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + experimentalDecorators: true, + useDefineForClassFields: false, + }, + }).outputText; + const module = { exports: {} }; + const importModule = (name) => { + if (Object.hasOwn(dependencies, name)) return dependencies[name]; + if (name === 'rxjs' || name === 'rxjs/operators') return require(name); + throw new Error(`Unexpected runtime dependency: ${name}`); + }; + vm.runInNewContext(compiled, { module, exports: module.exports, require: importModule }); + return module.exports; +} + +const angular = { Injectable: () => (target) => target }; +const { LogsComponentStore } = loadClass('apps/36-blocks/src/app/panel/logs/log/logs.store.ts', { + '@angular/core': angular, + '@ngrx/component-store': { ComponentStore: StateHost }, + '@ngrx/operators': { tapResponse: (next, error) => tap({ next, error }) }, + '@proxy/models/root-models': { errorResolver: () => ['Request failed'] }, +}); +const { ProjectGuard } = loadClass('apps/36-blocks/src/app/panel/guard/project.guard.ts', { + '@angular/core': angular, + '@ngrx/store': { select: (selector) => map(selector) }, + '../../core/ngrx': { selectProjectAccessState: (state) => state }, + '../../core/ngrx/actions': { rootActions: { getAllProject: () => ({ type: 'load projects' }) } }, +}); + +function createLogsStore(service) { + return new LogsComponentStore(service, { error() {} }); +} + +test('a failed logs response remains distinguishable from an empty successful result and can recover', () => { + let response = { hasError: true, errors: ['Unavailable'], data: { data: [{ id: 'stale' }] } }; + const store = createLogsStore({ getProxyLogs: () => of(response) }); + store.getLogs({}); + assert.equal(store.state$.value.logs, null); + assert.match(store.state$.value.logsError, /could not be loaded/); + assert.equal(store.state$.value.logsInProcess, false); + response = { hasError: false, data: { data: [] } }; + store.getLogs({}); + assert.equal(store.state$.value.logsError, null); + assert.equal(store.state$.value.logs.data.length, 0); +}); + +test('transport failures clear loading and a later retry still runs', () => { + let failed = true; + const store = createLogsStore({ + getProxyLogs: () => (failed ? throwError(() => ({ errors: ['Offline'] })) : of({ data: { data: [] } })), + }); + store.getLogs({}); + assert.equal(store.state$.value.logsInProcess, false); + assert.match(store.state$.value.logsError, /could not be loaded/); + failed = false; + store.getLogs({}); + assert.equal(store.state$.value.logsError, null); +}); + +test('a new list request clears stale rows while it is pending', () => { + const pending = new Subject(); + const store = createLogsStore({ getProxyLogs: () => pending }); + store.patchState({ logs: { data: [{ id: 1 }] } }); + store.getLogs({}); + assert.equal(store.state$.value.logs, null); + assert.equal(store.state$.value.logsInProcess, true); + pending.next({ data: { data: [] } }); + assert.equal(store.state$.value.logsInProcess, false); +}); + +test('failed detail envelopes never become a fabricated successful detail object', () => { + const store = createLogsStore({ getProxyLogsById: () => of({ hasError: true, data: { headers: 'private' } }) }); + store.getLogsById('request-id'); + assert.equal(store.state$.value.reqLogs, null); + assert.match(store.state$.value.reqLogsError, /could not be loaded/); + store.resetReqLog(); + assert.equal(store.state$.value.reqLogsError, null); +}); + +test('detail retry clears errors and decodes nested JSON without altering literal markup', () => { + let failed = true; + const store = createLogsStore({ + getProxyLogsById: () => + failed + ? throwError(() => ({ errors: ['Offline'] })) + : of({ + data: { + headers: JSON.stringify(JSON.stringify({ label: 'literal' })), + request_body: 'plain text', + }, + }), + }); + store.getLogsById('request-id'); + assert.equal(store.state$.value.reqLogsInProcess, false); + assert.match(store.state$.value.reqLogsError, /could not be loaded/); + failed = false; + store.getLogsById('request-id'); + assert.equal(store.state$.value.reqLogsError, null); + assert.equal(store.state$.value.reqLogs.headers.label, 'literal'); + assert.equal(store.state$.value.reqLogs.request_body, 'plain text'); +}); + +function createGuard(projects) { + const dispatched = []; + const router = { createUrlTree: (segments) => ({ redirect: segments.join('/') }) }; + const state = new BehaviorSubject({ projects, loading: false, failed: false }); + const store = { + select: (selector) => state.pipe(map(selector)), + dispatch: (action) => { + dispatched.push(action); + state.next({ ...state.value, loading: true, failed: false }); + }, + }; + return { guard: new ProjectGuard(router, store), state, dispatched }; +} + +test('users with no projects receive a completed redirect result', async () => { + const { guard } = createGuard({ data: [] }); + const result = await firstValueFrom(guard.canActivate()); + assert.equal(result.redirect, '/project'); +}); + +test('users with projects enter the panel', async () => { + const { guard, dispatched } = createGuard({ data: [{ id: 1 }] }); + assert.equal(await firstValueFrom(guard.canActivate()), true); + assert.equal(dispatched.length, 0); +}); + +test('unloaded project state triggers fetch and waits for its result', async () => { + const { guard, state, dispatched } = createGuard(null); + const result = firstValueFrom(guard.canActivate()); + assert.equal(dispatched.length, 1); + state.next({ projects: { data: [] }, loading: false, failed: false }); + assert.equal((await result).redirect, '/project'); +}); + +test('project request failure completes navigation with a recovery route, not project creation', async () => { + const { guard, state } = createGuard(null); + const result = firstValueFrom(guard.canActivate()); + state.next({ projects: null, loading: false, failed: true }); + assert.equal((await result).redirect, '/project-load-error'); +}); + +test('retry after a failed project request waits for fresh results and can enter panel', async () => { + const { guard, state, dispatched } = createGuard(null); + state.next({ projects: null, loading: false, failed: true }); + const result = firstValueFrom(guard.canActivate()); + assert.equal(dispatched.length, 1); + assert.equal(state.value.failed, false); + state.next({ projects: { data: [{ id: 1 }] }, loading: false, failed: false }); + assert.equal(await result, true); +}); + +test('already pending project request is not duplicated and failure redirects', async () => { + const { guard, state, dispatched } = createGuard(null); + state.next({ projects: null, loading: true, failed: false }); + const result = firstValueFrom(guard.canActivate()); + assert.equal(dispatched.length, 0); + state.next({ projects: null, loading: false, failed: true }); + assert.equal((await result).redirect, '/project-load-error'); +}); + +const rootActionCreators = new Proxy({}, { get: (_, type) => Object.assign(() => ({ type }), { type }) }); +const { rootReducer, initialState } = loadClass('apps/36-blocks/src/app/core/ngrx/store/reducer/root.reducer.ts', { + '@ngrx/store': { + on: (action, update) => ({ type: action.type, update }), + createReducer: + (initial, ...handlers) => + (state = initial, action) => { + const handler = handlers.find((entry) => entry.type === action.type); + return handler ? handler.update(state, action) : state; + }, + }, + '../../actions': { rootActions: rootActionCreators }, +}); + +test('project reducer records failure separately from empty data and resets it on retry/success', () => { + let state = rootReducer(initialState, { type: 'getAllProjectsError' }); + assert.equal(state.projectLoadFailed, true); + assert.equal(state.allProjects, null); + assert.equal(state.projectInProcess, false); + state = rootReducer(state, { type: 'getAllProject' }); + assert.equal(state.projectLoadFailed, false); + assert.equal(state.projectInProcess, true); + state = rootReducer(state, { type: 'getAllProjectSuccess', response: { data: [] } }); + assert.equal(state.projectLoadFailed, false); + assert.equal(state.projectInProcess, false); + assert.equal(state.allProjects.data.length, 0); +}); + +const { RootEffects } = loadClass('apps/36-blocks/src/app/core/ngrx/effects/root/root.effect.ts', { + '@angular/core': angular, + '@ngrx/effects': { + createEffect: (factory) => factory(), + ofType: (creator) => require('rxjs').filter((action) => action.type === creator.type), + }, + '../../actions/root.action': rootActionCreators, + '@proxy/models/root-models': { errorResolver: () => ['Request failed'] }, +}); + +test('project effect emits recoverable failure for transport errors, error envelopes, and malformed data', () => { + const actions = new Subject(); + let response = throwError(() => ({ errors: ['Offline'] })); + const effects = new RootEffects(actions, { error() {} }, { getProjects: () => response }); + const results = []; + effects.getAllProject$.subscribe((action) => results.push(action)); + actions.next({ type: 'getAllProject' }); + response = of({ hasError: true }); + actions.next({ type: 'getAllProject' }); + response = of({ hasError: false, data: null }); + actions.next({ type: 'getAllProject' }); + response = of({ hasError: false, data: { data: [] } }); + actions.next({ type: 'getAllProject' }); + assert.deepEqual( + results.map((action) => action.type), + ['getAllProjectsError', 'getAllProjectsError', 'getAllProjectsError', 'getAllProjectSuccess'] + ); +}); + +test('malformed successful log list envelopes produce recoverable errors while valid empty results succeed', () => { + for (const malformed of [ + null, + {}, + { hasError: false, data: null }, + { hasError: false, data: {} }, + { hasError: false, data: { data: {} } }, + ]) { + let response = malformed; + const store = createLogsStore({ getProxyLogs: () => of(response) }); + store.getLogs({}); + assert.equal(store.state$.value.logs, null); + assert.equal(store.state$.value.logsInProcess, false); + assert.match(store.state$.value.logsError, /could not be loaded/); + response = { hasError: false, data: { data: [] } }; + store.getLogs({}); + assert.equal(store.state$.value.logsError, null); + assert.equal(store.state$.value.logs.data.length, 0); + assert.equal(store.state$.value.logsInProcess, false); + } +}); + +test('missing or malformed successful log details produce recoverable errors and retry accepts a valid object', () => { + for (const malformed of [ + null, + {}, + { hasError: false, data: null }, + { hasError: false, data: [] }, + { hasError: false, data: 'invalid' }, + ]) { + let response = malformed; + const store = createLogsStore({ getProxyLogsById: () => of(response) }); + store.getLogsById('request-id'); + assert.equal(store.state$.value.reqLogs, null); + assert.equal(store.state$.value.reqLogsInProcess, false); + assert.match(store.state$.value.reqLogsError, /could not be loaded/); + response = { hasError: false, data: { id: 1, headers: null, request_body: null, response: null } }; + store.getLogsById('request-id'); + assert.equal(store.state$.value.reqLogsError, null); + assert.equal(store.state$.value.reqLogs.id, 1); + assert.equal(store.state$.value.reqLogsInProcess, false); + } +}); diff --git a/tools/tests/ssr-bundle.test.cjs b/tools/tests/ssr-bundle.test.cjs new file mode 100644 index 00000000..52a19d24 --- /dev/null +++ b/tools/tests/ssr-bundle.test.cjs @@ -0,0 +1,41 @@ +// Requires npm run build:web first. Unlike ssr-routing.test.cjs, this starts the +// generated application with the actual Angular engine and generated manifests. +const assert = require('node:assert/strict'); +const { once } = require('node:events'); +const { existsSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const { pathToFileURL } = require('node:url'); + +const bundle = path.resolve(__dirname, '../../dist/apps/36-blocks/server/server.mjs'); + +test( + 'built SSR entry starts and serves prerendered home plus client-rendered public routes', + { + skip: !existsSync(bundle) && 'Run npm run build:web before the built SSR smoke check', + timeout: 20000, + }, + async () => { + const { app } = await import(pathToFileURL(bundle).href); + const server = app().listen(0, '127.0.0.1'); + try { + await once(server, 'listening'); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + const home = await fetch(baseUrl, { signal: AbortSignal.timeout(5000) }); + assert.equal(home.status, 200); + const homeHtml = await home.text(); + assert.match(homeHtml, / server.close(resolve)); + } + } +); diff --git a/tools/tests/ssr-routing.test.cjs b/tools/tests/ssr-routing.test.cjs new file mode 100644 index 00000000..abeb43b8 --- /dev/null +++ b/tools/tests/ssr-routing.test.cjs @@ -0,0 +1,117 @@ +// Run: node --test tools/tests/ssr-routing.test.cjs +// Loads the actual server entry with real Express; only Angular's rendering +// adapter is substituted so registration, static middleware and HTTP routing run. +const assert = require('node:assert/strict'); +const { once } = require('node:events'); +const { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const { pathToFileURL } = require('node:url'); +const vm = require('node:vm'); +const ts = require('typescript'); + +async function withServer(render, run) { + const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), '36blocks-ssr-')); + const browserFolder = path.join(fixtureRoot, 'browser'); + mkdirSync(browserFolder); + writeFileSync(path.join(browserFolder, 'widget.js'), 'window.widgetReady = true;'); + const sourcePath = path.resolve(__dirname, '../../apps/36-blocks/src/server.ts'); + // CommonJS VM execution needs the ESM entry URL supplied explicitly. Route + // declarations and middleware are executed directly from the source file. + const source = readFileSync(sourcePath, 'utf8').replaceAll( + 'import.meta.url', + JSON.stringify(pathToFileURL(path.join(fixtureRoot, 'server', 'server.mjs')).href) + ); + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, esModuleInterop: true }, + }).outputText; + const module = { exports: {} }; + let server; + try { + vm.runInNewContext(compiled, { + module, + exports: module.exports, + process, + console, + require: (name) => + name === '@angular/ssr/node' + ? { + AngularNodeAppEngine: class { + handle(req) { + return Promise.resolve().then(() => render(req)); + } + }, + createNodeRequestHandler: (app) => app, + isMainModule: () => false, + writeResponseToNodeResponse: (response, res) => { + res.status(response.status).send(response.body); + }, + } + : require(name), + }); + const app = module.exports.app(); + app.set('env', 'test'); + server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + await run(`http://127.0.0.1:${server.address().port}`); + } finally { + if (server) await new Promise((resolve) => server.close(resolve)); + rmSync(fixtureRoot, { recursive: true, force: true }); + } +} + +test('SSR server starts under Express 5 and renders root and nested browser routes', async () => { + await withServer( + (req) => ({ status: 200, body: `Rendered ${req.path}` }), + async (baseUrl) => { + for (const route of ['/', '/about', '/app/activity', '/deep/nested/path?filter=test']) { + const response = await fetch(`${baseUrl}${route}`); + assert.equal(response.status, 200); + assert.equal(await response.text(), `Rendered ${route.split('?')[0]}`); + } + } + ); +}); + +test('static assets are served before SSR with caching and HEAD support', async () => { + let renders = 0; + await withServer( + () => { + renders++; + return { status: 200, body: 'SSR fallback' }; + }, + async (baseUrl) => { + const response = await fetch(`${baseUrl}/widget.js`); + assert.equal(response.status, 200); + assert.equal(await response.text(), 'window.widgetReady = true;'); + assert.match(response.headers.get('cache-control'), /max-age=31536000/); + const head = await fetch(`${baseUrl}/widget.js`, { method: 'HEAD' }); + assert.equal(head.status, 200); + assert.equal(await head.text(), ''); + assert.equal(renders, 0); + } + ); +}); + +test('unhandled SSR requests fall through to 404', async () => { + await withServer( + () => null, + async (baseUrl) => { + const response = await fetch(`${baseUrl}/unhandled`); + assert.equal(response.status, 404); + } + ); +}); + +test('rendering failures reach Express error handling rather than leaving requests pending', async () => { + await withServer( + () => { + throw new Error('SSR test render failure'); + }, + async (baseUrl) => { + const response = await fetch(`${baseUrl}/broken`); + assert.equal(response.status, 500); + } + ); +}); diff --git a/tools/tests/widget-example.test.cjs b/tools/tests/widget-example.test.cjs new file mode 100644 index 00000000..35a82978 --- /dev/null +++ b/tools/tests/widget-example.test.cjs @@ -0,0 +1,54 @@ +// Checks the actual public example without making provider or network requests. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { test } = require('node:test'); +const ts = require('typescript'); + +const source = fs.readFileSync( + path.resolve(__dirname, '../../apps/36-blocks/src/app/website/docs/widget-example.ts'), + 'utf8' +); +const exportsObject = {}; +vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { + exports: exportsObject, +}); +const example = exportsObject.WIDGET_EXAMPLE; + +function executeExample() { + const status = { textContent: '' }; + let config; + const context = vm.createContext({ + document: { getElementById: (id) => (id === 'auth-status' ? status : null) }, + window: { + initVerification: (configuration) => { + config = configuration; + }, + }, + }); + const inlineScript = example.match(/