From e7b371215cc1f1c61a923aa80d6085d7c6d00c69 Mon Sep 17 00:00:00 2001 From: Priyanka P Date: Thu, 29 Oct 2026 13:24:05 +0530 Subject: [PATCH 1/3] Add activity investigation, safer impersonation and product documentation --- .github/workflows/v0-behavior.yml | 25 ++ AGENTS.md | 11 + DESIGNGUIDE.md | 16 +- README.md | 10 +- apps/36-blocks/project.json | 8 +- apps/36-blocks/src/app/app.routes.ts | 7 + .../app/core/ngrx/effects/root/root.effect.ts | 2 +- .../core/ngrx/store/reducer/root.reducer.ts | 5 + .../ngrx/store/selectors/root.selector.ts | 7 + .../panel/activity/activity.component.html | 225 +++++++++++++ .../panel/activity/activity.component.scss | 104 ++++++ .../app/panel/activity/activity.component.ts | 162 ++++++++++ .../src/app/panel/activity/activity.models.ts | 78 +++++ .../app/panel/activity/activity.service.ts | 35 ++ .../guard/project-load-error.component.ts | 31 ++ .../src/app/panel/guard/project.guard.ts | 50 ++- .../main-left-side-nav.component.ts | 3 +- .../log-details-side-dialog.component.html | 11 +- .../log-details-side-dialog.component.ts | 2 + .../src/app/panel/logs/log/log.component.html | 14 +- .../src/app/panel/logs/log/log.component.ts | 9 +- .../src/app/panel/logs/log/logs.store.ts | 47 ++- apps/36-blocks/src/app/panel/panel.routes.ts | 4 + .../app/website/about/about.component.html | 9 +- .../src/app/website/about/about.component.ts | 39 +-- .../src/app/website/docs/docs.component.html | 80 +++++ .../src/app/website/docs/docs.component.ts | 30 ++ .../src/app/website/docs/widget-example.ts | 22 ++ .../src/app/website/home/home.component.html | 13 +- .../src/app/website/home/home.component.ts | 123 ++----- .../layout/website-layout.component.ts | 3 +- .../website/pricing/pricing.component.html | 17 +- .../app/website/pricing/pricing.component.ts | 100 ++---- .../website/privacy/privacy.component.html | 6 +- .../website/security/security.component.html | 289 +++-------------- .../website/security/security.component.ts | 4 +- .../app/website/terms/terms.component.html | 6 +- .../src/app/website/website.routes.ts | 4 + apps/36-blocks/src/server.ts | 4 +- docs/widget-integration-guide.md | 32 +- .../src/lib/services-proxy-root.module.ts | 4 +- package.json | 3 +- tools/set-env.js | 14 +- tools/tests/activity.test.cjs | 222 +++++++++++++ tools/tests/environment-logging.test.cjs | 35 ++ tools/tests/panel-investigation.test.cjs | 299 ++++++++++++++++++ tools/tests/ssr-bundle.test.cjs | 41 +++ tools/tests/ssr-routing.test.cjs | 117 +++++++ tools/tests/widget-example.test.cjs | 54 ++++ 49 files changed, 1886 insertions(+), 550 deletions(-) create mode 100644 .github/workflows/v0-behavior.yml create mode 100644 AGENTS.md create mode 100644 apps/36-blocks/src/app/panel/activity/activity.component.html create mode 100644 apps/36-blocks/src/app/panel/activity/activity.component.scss create mode 100644 apps/36-blocks/src/app/panel/activity/activity.component.ts create mode 100644 apps/36-blocks/src/app/panel/activity/activity.models.ts create mode 100644 apps/36-blocks/src/app/panel/activity/activity.service.ts create mode 100644 apps/36-blocks/src/app/panel/guard/project-load-error.component.ts create mode 100644 apps/36-blocks/src/app/website/docs/docs.component.html create mode 100644 apps/36-blocks/src/app/website/docs/docs.component.ts create mode 100644 apps/36-blocks/src/app/website/docs/widget-example.ts create mode 100644 tools/tests/activity.test.cjs create mode 100644 tools/tests/environment-logging.test.cjs create mode 100644 tools/tests/panel-investigation.test.cjs create mode 100644 tools/tests/ssr-bundle.test.cjs create mode 100644 tools/tests/ssr-routing.test.cjs create mode 100644 tools/tests/widget-example.test.cjs diff --git a/.github/workflows/v0-behavior.yml b/.github/workflows/v0-behavior.yml new file mode 100644 index 00000000..2d9cd2ff --- /dev/null +++ b/.github/workflows/v0-behavior.yml @@ -0,0 +1,25 @@ +name: V0 behavior checks +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + behavior: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '22' + cache: npm + - name: Install locked dependencies without lifecycle hooks + run: npm ci --ignore-scripts --legacy-peer-deps --no-audit --no-fund + - name: Run focused behavior and HTTP routing tests + run: npm run test:v0 + - name: Generate local environment modules without credentials + env: + CI: 'false' + run: node tools/set-env + - name: Check application types and templates + run: node_modules/.bin/ngc --noEmit -p apps/36-blocks/tsconfig.app.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7db2e24d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# Working on proxy-ui + +Owns the Angular public website, operator dashboard and embeddable authentication widget. Backend APIs, persistence and session policy belong to sibling `user-proxy`; connected applications own business authorization. + +Start with the canonical [work state](../user-proxy/docs/WORK-STATE.md), [architecture](../user-proxy/docs/ARCHITECTURE.md), [capabilities](../user-proxy/docs/CAPABILITIES.md), [development](../user-proxy/docs/DEVELOPMENT.md), [security](../user-proxy/docs/SECURITY-OPERATIONS.md) and [design](../user-proxy/docs/DESIGN.md). Preserve existing README contribution rules and DESIGNGUIDE.md. Read widget integration/event guides before changing the custom element contract. + +- Inspect status/diff; preserve unrelated changes. New application code uses TypeScript and existing standalone Angular patterns. Use Angular CLI scaffolding when practical; never change architecture solely to satisfy scaffolding. +- Install from lock using `npm ci --ignore-scripts --legacy-peer-deps`; generate ignored env modules with `node tools/set-env`. Review environment base URLs before starting: local config defaults to a remote test API. +- Run `node --test tools/tests/*.test.cjs`, affected builds and `git diff --check`. Existing Nx/Jest targets have known gaps: report exact commands and limitations in canonical work state. +- Keep error, denied, empty and loading states distinct; provide retry. Never render request data as HTML or log/store tokens. Client visibility does not replace server authorization. +- Update the single backend-owned capability matrix and work state alongside changes. Product/architecture guidance is linked, not duplicated here. Public claims require evidence; do not invent SLAs, guarantees or future functional controls. diff --git a/DESIGNGUIDE.md b/DESIGNGUIDE.md index f9a3dde3..811df2d1 100644 --- a/DESIGNGUIDE.md +++ b/DESIGNGUIDE.md @@ -1,20 +1,8 @@ # Proxy UI Design Guide -## Setup Guide +## Setup -1. Clone https://github.com/Walkover-Web-Solution/proxy-UI.git. - -2. This project requires Node version 14+, install Node from [here](https://nodejs.org/en/). - -3. Go inside the cloned directory `proxy-UI` in any terminal and run these following commands in the terminal to install dependency and start local serve. - -4. Run `npm i` for installation of dependencies. - -5. Serving the project locally: - * For proxy: Run `npm start` to local serve the project. - * For Admin Panel: Run `npm run start:admin` to local serve the project. - * For chat-widget: Run `npm run start:chat-widget` to local serve the project. - * For otp-provider: Run `npm run start:otp-provider` to local serve the project. +Use the current [README](README.md) and canonical [development guide](../user-proxy/docs/DEVELOPMENT.md) for runtime, install and startup commands. Legacy admin/chat/OTP application commands formerly listed here no longer describe the supplied workspace. The following styling guidance remains reference material; use the canonical [design standards](../user-proxy/docs/DESIGN.md) for product behavior. ## Prerequisites It is important to have a thorough understanding of **HTML**, **CSS** and its pre-processor **SASS** to contribute to this project. If unaware of any of the above then go through their documention first. diff --git a/README.md b/README.md index ccd8dd09..3a475d68 100755 --- a/README.md +++ b/README.md @@ -6,13 +6,17 @@ 1. Clone https://github.com/Walkover-Web-Solution/proxy-ui.git. -2. This project requires Node version 14+, install Node from [here](https://nodejs.org/en/). +2. This is Angular 21.2 / Nx 21.6 / TypeScript 5.9. Use a compatible Node runtime; see the [canonical development guide](../user-proxy/docs/DEVELOPMENT.md). Node 14 is unsupported. 3. Go inside the cloned directory `proxy-ui` in any terminal and run these following commands in the terminal to install dependency and start local serve. -4. Run `npm i` for installation of dependencies. +4. Run `npm ci --ignore-scripts --legacy-peer-deps` to install the locked dependencies. Review the development guide before enabling install hooks. -5. Run `npm start` to local serve the project. +5. Configure approved local environment values, run `node tools/set-env`, then `npm start`. The checked-in local API URL points to a remote test service; change it before exercising mutations. + +Read [AGENTS.md](AGENTS.md), [current work state](../user-proxy/docs/WORK-STATE.md), [architecture](../user-proxy/docs/ARCHITECTURE.md) and the authoritative [capability matrix](../user-proxy/docs/CAPABILITIES.md). The API is the sibling `user-proxy` repository. `node --test tools/tests/*.test.cjs` runs focused behavior tests; legacy Nx/Jest targets have known gaps recorded in the work state. + +The existing contribution and style guidance below remains applicable. Use the canonical development and design guides where obsolete runtime/module instructions conflict with the current standalone Angular architecture. ## Contributing Guide diff --git a/apps/36-blocks/project.json b/apps/36-blocks/project.json index ef93ea4c..1b6a0b1a 100644 --- a/apps/36-blocks/project.json +++ b/apps/36-blocks/project.json @@ -20,7 +20,7 @@ "index": "apps/36-blocks/src/index.html", "browser": "apps/36-blocks/src/main.ts", "server": "apps/36-blocks/src/main.server.ts", - "prerender": true, + "outputMode": "server", "ssr": { "entry": "apps/36-blocks/src/server.ts" }, @@ -124,11 +124,17 @@ "namedChunks": false }, "development": { + "security": { + "allowedHosts": ["localhost", "127.0.0.1", "[::1]"] + }, "optimization": false, "extractLicenses": false, "sourceMap": true }, "local": { + "security": { + "allowedHosts": ["localhost", "127.0.0.1", "[::1]"] + }, "optimization": false, "extractLicenses": false, "sourceMap": true diff --git a/apps/36-blocks/src/app/app.routes.ts b/apps/36-blocks/src/app/app.routes.ts index c167936d..159e2121 100644 --- a/apps/36-blocks/src/app/app.routes.ts +++ b/apps/36-blocks/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { Route, Router } from '@angular/router'; import { AngularFireAuthGuard, redirectUnauthorizedTo } from '@angular/fire/compat/auth-guard'; import { CookieService } from 'ngx-cookie-service'; import { AuthService } from '@proxy/services/proxy/auth'; +import { CanActivateRouteGuard } from './website/home/authguard'; @Component({ template: '', standalone: true }) class NotFoundRedirectComponent {} @@ -27,6 +28,12 @@ export const appRoutes: Route[] = [ data: { authGuardPipe: redirectUnauthorizedToLogin }, canActivate: [AngularFireAuthGuard], }, + { + path: 'project-load-error', + loadComponent: () => + import('./panel/guard/project-load-error.component').then((c) => c.ProjectLoadErrorComponent), + canActivate: [CanActivateRouteGuard], + }, { path: 'project', loadComponent: () => diff --git a/apps/36-blocks/src/app/core/ngrx/effects/root/root.effect.ts b/apps/36-blocks/src/app/core/ngrx/effects/root/root.effect.ts index f75e4a5d..8e86fe2e 100755 --- a/apps/36-blocks/src/app/core/ngrx/effects/root/root.effect.ts +++ b/apps/36-blocks/src/app/core/ngrx/effects/root/root.effect.ts @@ -82,7 +82,7 @@ export class RootEffects { switchMap((req) => { return this.rootService.getProjects().pipe( map((res) => { - if (res.hasError) { + if (res.hasError || !Array.isArray(res.data?.data)) { this.showError(res.errors); return rootActions.getAllProjectsError(); } diff --git a/apps/36-blocks/src/app/core/ngrx/store/reducer/root.reducer.ts b/apps/36-blocks/src/app/core/ngrx/store/reducer/root.reducer.ts index 6445d5d8..d8e4b2ae 100755 --- a/apps/36-blocks/src/app/core/ngrx/store/reducer/root.reducer.ts +++ b/apps/36-blocks/src/app/core/ngrx/store/reducer/root.reducer.ts @@ -18,6 +18,7 @@ export interface IRootState { //All project allProjects: IPaginatedResponse; projectInProcess: boolean; + projectLoadFailed: boolean; } export const initialState: IRootState = { @@ -33,6 +34,7 @@ export const initialState: IRootState = { clientsInProcess: false, switchClientSuccess: false, projectInProcess: false, + projectLoadFailed: false, allProjects: null, }; @@ -121,6 +123,7 @@ const _rootReducer = createReducer( return { ...state, projectInProcess: true, + projectLoadFailed: false, }; }), on(rootActions.getAllProjectSuccess, (state, { response }) => { @@ -128,6 +131,7 @@ const _rootReducer = createReducer( ...state, allProjects: response, projectInProcess: false, + projectLoadFailed: false, }; }), on(rootActions.getAllProjectsError, (state) => { @@ -135,6 +139,7 @@ const _rootReducer = createReducer( ...state, allProjects: null, projectInProcess: false, + projectLoadFailed: true, }; }) ); diff --git a/apps/36-blocks/src/app/core/ngrx/store/selectors/root.selector.ts b/apps/36-blocks/src/app/core/ngrx/store/selectors/root.selector.ts index c3ccb551..3a3e3efe 100755 --- a/apps/36-blocks/src/app/core/ngrx/store/selectors/root.selector.ts +++ b/apps/36-blocks/src/app/core/ngrx/store/selectors/root.selector.ts @@ -32,3 +32,10 @@ export const selectProjectInProcess = createSelector( selectRootState, (rootState: IRootState) => rootState.projectInProcess ); + +/** One selector keeps project data and its request outcome consistent for guards. */ +export const selectProjectAccessState = createSelector(selectRootState, (state: IRootState) => ({ + projects: state.allProjects, + loading: state.projectInProcess, + failed: state.projectLoadFailed, +})); diff --git a/apps/36-blocks/src/app/panel/activity/activity.component.html b/apps/36-blocks/src/app/panel/activity/activity.component.html new file mode 100644 index 00000000..0c6dfd67 --- /dev/null +++ b/apps/36-blocks/src/app/panel/activity/activity.component.html @@ -0,0 +1,225 @@ +
+
+

Activity

+

+ Investigate operator actions for the current client. Login as records include the actor, target, reason and + outcome. +

+

+ Older operations may have no audit details. A started attempt without a final outcome needs investigation. +

+
+ + + +
+ Operation + Actor name or email + Outcome + All outcomes + StartedSucceeded + DeniedFailed + + Target user ID + Block ID + From date + Through date +
+ +
+
+ + @if (loading()) { +

Loading activity…

+ } @else if (error(); as message) { +
+

{{ message }}

+ +
+ } @else if (rows().length === 0) { +

No activity matches these filters for the current client.

+ } @else { +

{{ total() }} matching operations · Page {{ page() }} of {{ totalPages() }}

+
+ + + + + + + + + + + + + @for (row of rows(); track row.id) { + + + + + + + + } + +
+ Operator activity in the current client +
WhenActorOperationOutcomeAudit details
+ {{ row.created_at | date: 'medium' + }}Operation #{{ row.id }} · Session {{ row.session_id ?? 'unavailable' }} + + {{ row.user?.name || row.user?.email || 'Unavailable' + }}{{ row.user?.email }}Actor ID: + {{ row.audit_context?.actor_user_id ?? row.user?.user_id ?? 'unavailable' }} + {{ row.operation }} + {{ row.audit_context?.outcome || 'Not recorded' }} + @if (row.audit_context?.failure_code) { + {{ row.audit_context.failure_code }} + } + + @if (row.audit_context; as audit) { +
+
Target user
+
{{ audit.target_user_id ?? 'Not recorded' }}
+
Company
+
{{ audit.target_company_id ?? 'Not recorded' }}
+
Block
+
{{ audit.feature_configuration_id ?? 'Not recorded' }}
+
Reason
+
{{ audit.reason || 'Not recorded' }}
+
Source IP
+
{{ audit.source_ip || 'Not recorded' }}
+
+ } @else { + No audit details were recorded for this operation. + } +
+
+ + } +
diff --git a/apps/36-blocks/src/app/panel/activity/activity.component.scss b/apps/36-blocks/src/app/panel/activity/activity.component.scss new file mode 100644 index 00000000..df6e1cfb --- /dev/null +++ b/apps/36-blocks/src/app/panel/activity/activity.component.scss @@ -0,0 +1,104 @@ +:host { + display: block; +} +.activity-page { + padding: 1.5rem; + max-width: 1500px; + margin: auto; +} +h1 { + font-size: 1.75rem; + margin-bottom: 0.5rem; +} +header p { + max-width: 80ch; +} +.login-as { + padding: 1rem; + border: 1px solid var(--mat-sys-outline-variant, #888); + border-radius: 0.5rem; + margin: 1rem 0; +} +summary { + cursor: pointer; + font-weight: 600; +} +.reason-field { + grid-column: span 2; +} +.confirmation { + display: flex; + align-items: start; + gap: 0.5rem; +} +.activity-filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 0.75rem; + margin: 1.5rem 0; +} +.filter-actions, +.pagination { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} +.table-scroll { + overflow-x: auto; + border: 1px solid var(--mat-sys-outline-variant, #888); + border-radius: 0.5rem; +} +table { + border-collapse: collapse; + width: 100%; + text-align: left; +} +th, +td { + padding: 1rem; + vertical-align: top; + border-bottom: 1px solid var(--mat-sys-outline-variant, #888); +} +th { + white-space: nowrap; +} +small { + display: block; + margin-top: 0.35rem; +} +dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.75rem; + min-width: 230px; + margin: 0; +} +dt { + font-weight: 600; +} +dd { + margin: 0; + overflow-wrap: anywhere; +} +.reason { + white-space: pre-wrap; +} +.pagination { + margin-top: 1rem; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} +@media (max-width: 600px) { + .activity-page { + padding: 1rem; + } + .reason-field { + grid-column: auto; + } +} diff --git a/apps/36-blocks/src/app/panel/activity/activity.component.ts b/apps/36-blocks/src/app/panel/activity/activity.component.ts new file mode 100644 index 00000000..318b0ab4 --- /dev/null +++ b/apps/36-blocks/src/app/panel/activity/activity.component.ts @@ -0,0 +1,162 @@ +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { Subscription } from 'rxjs'; +import { + ActivityOperation, + activityParams, + emptyActivityFilters, + isActivityForbidden, + safeLoginRedirect, +} from './activity.models'; +import { ActivityService } from './activity.service'; + +@Component({ + selector: 'proxy-activity', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DatePipe, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule], + templateUrl: './activity.component.html', + styleUrl: './activity.component.scss', +}) +export class ActivityComponent implements OnInit, OnDestroy { + private readonly service = inject(ActivityService); + private request?: Subscription; + private loginRequest?: Subscription; + filters = emptyActivityFilters(); + private appliedFilters = emptyActivityFilters(); + readonly rows = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + readonly page = signal(1); + readonly totalPages = signal(0); + readonly total = signal(0); + loginTarget = ''; + loginCompany = ''; + loginReason = ''; + loginConfirmed = false; + readonly loggingIn = signal(false); + readonly loginError = signal(null); + readonly loginMessage = signal(null); + readonly loginRedirect = signal(null); + + ngOnInit(): void { + this.load(1); + } + + ngOnDestroy(): void { + this.request?.unsubscribe(); + this.loginRequest?.unsubscribe(); + } + + applyFilters(): void { + if (this.filters.startDate && this.filters.endDate && this.filters.endDate < this.filters.startDate) { + this.error.set('The end date must be on or after the start date.'); + return; + } + this.appliedFilters = { ...this.filters }; + this.load(1); + } + + clearFilters(): void { + this.filters = emptyActivityFilters(); + this.appliedFilters = emptyActivityFilters(); + this.load(1); + } + + load(page: number): void { + this.request?.unsubscribe(); + this.loading.set(true); + this.error.set(null); + this.rows.set([]); + this.total.set(0); + this.totalPages.set(0); + this.page.set(page); + this.request = this.service.getOperations(activityParams(this.appliedFilters, page)).subscribe({ + next: (response) => { + this.loading.set(false); + if (response.hasError || !response.data || !Array.isArray(response.data.data)) { + this.error.set('Activity could not be loaded. Retry or contact an administrator.'); + return; + } + this.rows.set(response.data.data); + this.total.set(response.data.totalEntityCount); + this.totalPages.set(response.data.totalPageCount); + }, + error: (error: unknown) => { + this.loading.set(false); + this.error.set( + isActivityForbidden(error) + ? 'You need the view_operations permission for this client to view activity. Ask a client administrator.' + : 'Activity could not be loaded. Check your connection and retry.' + ); + }, + }); + } + + loginAs(): void { + if (this.loggingIn()) return; + this.loginError.set(null); + this.loginMessage.set(null); + this.loginRedirect.set(null); + const userId = Number(this.loginTarget); + const companyId = this.loginCompany.trim() ? Number(this.loginCompany) : undefined; + const reason = this.loginReason.trim(); + if ( + !Number.isSafeInteger(userId) || + userId < 1 || + (companyId !== undefined && (!Number.isSafeInteger(companyId) || companyId < 1)) || + !reason || + reason.length > 500 || + !this.loginConfirmed + ) { + this.loginError.set( + 'Enter a valid user ID, an optional company ID and a reason of 1–500 characters, then confirm the action.' + ); + return; + } + this.loggingIn.set(true); + this.loginRequest = this.service + .loginAs({ user_id: userId, ...(companyId === undefined ? {} : { company_id: companyId }), reason }) + .subscribe({ + next: (response) => { + this.loggingIn.set(false); + this.loginConfirmed = false; + if (response.hasError || !response.data) { + this.loginError.set( + 'Login as could not be completed. Check the target membership and your permissions.' + ); + } else { + const redirect = safeLoginRedirect(response.data.redirect_url); + this.loginRedirect.set(redirect); + this.loginMessage.set( + redirect + ? 'An impersonated session was created and audited. Open the connected application to continue.' + : 'An impersonated session was created and audited, but no safe application redirect is configured. Configure an HTTPS redirect on the block before using this journey.' + ); + } + this.load(1); + }, + error: (error: unknown) => { + this.loggingIn.set(false); + this.loginConfirmed = false; + this.loginError.set( + isActivityForbidden(error) + ? 'Login as requires an Owner, Admin or Super admin role in the current client and an eligible target membership.' + : 'Login as could not be completed. Check the reason and target, then investigate Activity before retrying.' + ); + this.load(1); + }, + }); + } + + clearLoginResult(): void { + this.loginRedirect.set(null); + this.loginMessage.set(null); + this.loginError.set(null); + this.loginConfirmed = false; + } +} diff --git a/apps/36-blocks/src/app/panel/activity/activity.models.ts b/apps/36-blocks/src/app/panel/activity/activity.models.ts new file mode 100644 index 00000000..60094d8f --- /dev/null +++ b/apps/36-blocks/src/app/panel/activity/activity.models.ts @@ -0,0 +1,78 @@ +export type AuditOutcome = 'started' | 'succeeded' | 'denied' | 'failed'; + +export interface AuditContext { + actor_user_id?: number; + target_user_id?: number; + target_company_id?: number; + feature_configuration_id?: number; + reason?: string; + source_ip?: string; + outcome?: AuditOutcome; + failure_code?: string; +} + +export interface ActivityOperation { + id: number; + session_id: number | null; + operation: string; + created_at: string; + user?: { user_id: number; name: string; email: string } | null; + audit_context?: AuditContext | null; +} + +export interface ActivityFilters { + operation: string; + name: string; + outcome: AuditOutcome | ''; + target_user_id: string; + feature_configuration_id: string; + startDate: string; + endDate: string; +} + +export function emptyActivityFilters(): ActivityFilters { + return { + operation: '', + name: '', + outcome: '', + target_user_id: '', + feature_configuration_id: '', + startDate: '', + endDate: '', + }; +} + +export function activityParams(filters: ActivityFilters, pageNo: number): Record { + const params: Record = { pageNo, itemsPerPage: 25 }; + for (const [key, value] of Object.entries(filters)) { + if (value.trim()) params[key] = value.trim(); + } + return params; +} + +/** Only explicit web redirects may carry the issued credentials to a connected application. */ +export function safeLoginRedirect(value: unknown): string | null { + if (typeof value !== 'string') return null; + try { + const url = new URL(value); + if (url.username || url.password) return null; + if ( + url.protocol === 'https:' || + (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) + ) { + return url.href; + } + } catch { + return null; + } + return null; +} + +/** The shared interceptor uses errorCode; direct HttpClient failures retain status. */ +export function isActivityForbidden(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (('status' in error && error.status === 403) || ('errorCode' in error && error.errorCode === 403)) + ); +} diff --git a/apps/36-blocks/src/app/panel/activity/activity.service.ts b/apps/36-blocks/src/app/panel/activity/activity.service.ts new file mode 100644 index 00000000..fb3bd42f --- /dev/null +++ b/apps/36-blocks/src/app/panel/activity/activity.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable, inject } from '@angular/core'; +import { BaseResponse, IPaginatedResponse, ProxyBaseUrls } from '@proxy/models/root-models'; +import { createUrl } from '@proxy/service'; +import { HttpWrapperService } from '@proxy/services/httpWrapper'; +import { Observable } from 'rxjs'; +import { ActivityOperation } from './activity.models'; + +export type ActivityResponse = BaseResponse, void>; + +export interface LoginAsRequest { + user_id: number; + company_id?: number; + reason: string; +} + +export interface LoginAsResult { + redirect_url?: string; + proxy_auth_token?: string; + refresh_token?: string; +} + +@Injectable({ providedIn: 'root' }) +export class ActivityService { + private readonly http = inject(HttpWrapperService); + + constructor(@Inject(ProxyBaseUrls.BaseURL) private readonly baseUrl: string) {} + + getOperations(params: Record): Observable { + return this.http.get(createUrl(this.baseUrl, 'operations'), params); + } + + loginAs(request: LoginAsRequest): Observable> { + return this.http.post>(createUrl(this.baseUrl, 'loginAs'), request); + } +} diff --git a/apps/36-blocks/src/app/panel/guard/project-load-error.component.ts b/apps/36-blocks/src/app/panel/guard/project-load-error.component.ts new file mode 100644 index 00000000..904bec4d --- /dev/null +++ b/apps/36-blocks/src/app/panel/guard/project-load-error.component.ts @@ -0,0 +1,31 @@ +import { AsyncPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { IAppState, selectProjectInProcess } from '../../core/ngrx'; + +@Component({ + selector: 'proxy-project-load-error', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [AsyncPipe], + template: ` +
+

Projects could not be loaded

+ + +
+ `, +}) +export class ProjectLoadErrorComponent { + private readonly store = inject>(Store); + private readonly router = inject(Router); + public readonly loading$ = this.store.select(selectProjectInProcess); + + public retry(): void { + void this.router.navigate(['/app']); + } +} diff --git a/apps/36-blocks/src/app/panel/guard/project.guard.ts b/apps/36-blocks/src/app/panel/guard/project.guard.ts index c37548ab..a4e1a56a 100644 --- a/apps/36-blocks/src/app/panel/guard/project.guard.ts +++ b/apps/36-blocks/src/app/panel/guard/project.guard.ts @@ -1,41 +1,31 @@ import { Injectable } from '@angular/core'; -import { UrlTree, Router } from '@angular/router'; -import { Observable, filter } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { Router, UrlTree } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Observable, filter, map, take } from 'rxjs'; import { rootActions } from '../../core/ngrx/actions'; -import { select, Store } from '@ngrx/store'; -import { IAppState, selectAllProjectList } from '../../core/ngrx'; -import { IPaginatedResponse } from '@proxy/models/root-models'; -import { IProjects } from '@proxy/models/logs-models'; +import { IAppState, selectProjectAccessState } from '../../core/ngrx'; -@Injectable({ - providedIn: 'root', -}) +@Injectable({ providedIn: 'root' }) export class ProjectGuard { - public getProject$: Observable>; - constructor( private router: Router, private store: Store - ) { - this.getProject$ = this.store.pipe(select(selectAllProjectList)); - } + ) {} - canActivate(): Observable | Promise | boolean | UrlTree { - return new Promise((promiseResolve) => { - this.getProject$.pipe(take(1)).subscribe((res) => !res && this.store.dispatch(rootActions.getAllProject())); - this.getProject$ - .pipe( - filter((value) => value != null), - take(1) - ) - .subscribe((res) => { - if (res.data.length === 0) { - this.router.navigate(['/project']); - } else { - promiseResolve(true); - } - }); + canActivate(): Observable { + const access$ = this.store.select(selectProjectAccessState); + access$.pipe(take(1)).subscribe(({ projects, loading }) => { + if (!projects && !loading) { + this.store.dispatch(rootActions.getAllProject()); + } }); + return access$.pipe( + filter(({ projects, loading, failed }) => !loading && (projects !== null || failed)), + take(1), + map(({ projects, failed }) => { + if (failed) return this.router.createUrlTree(['/project-load-error']); + return projects.data.length > 0 ? true : this.router.createUrlTree(['/project']); + }) + ); } } diff --git a/apps/36-blocks/src/app/panel/layout/main-left-side-nav/main-left-side-nav.component.ts b/apps/36-blocks/src/app/panel/layout/main-left-side-nav/main-left-side-nav.component.ts index cc1ebe40..bb7d9ec2 100644 --- a/apps/36-blocks/src/app/panel/layout/main-left-side-nav/main-left-side-nav.component.ts +++ b/apps/36-blocks/src/app/panel/layout/main-left-side-nav/main-left-side-nav.component.ts @@ -32,7 +32,8 @@ export class MainLeftSideNavComponent extends BaseComponent { { route: 'dashboard', icon: 'dashboard', label: 'Dashboard' }, { route: 'features', icon: 'featured_play_list', label: 'Blocks' }, { route: 'users', icon: 'person', label: 'User' }, - { route: 'logs', icon: 'description', label: 'Logs' }, // Temporary hide this page + { route: 'logs', icon: 'description', label: 'Logs' }, + { route: 'activity', icon: 'history', label: 'Activity' }, { route: 'chatbot', icon: 'face', label: 'Ask Ai' }, ]; } diff --git a/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.html b/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.html index 52c8a1a1..4e39050a 100755 --- a/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.html +++ b/apps/36-blocks/src/app/panel/logs/log-details-side-dialog/log-details-side-dialog.component.html @@ -4,7 +4,7 @@

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(/