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 index 5c4bd672..7db2e24d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,75 +1,11 @@ -# proxy-ui (36 Blocks frontend) +# Working on proxy-ui -An Nx monorepo (Angular 21, standalone components, NgRx, Tailwind v4) with two apps: -- `apps/36-blocks`: the customer dashboard and the marketing site. -- `apps/36-blocks-widget`: an embeddable auth widget, built into one script (proxy-auth.js) that customer sites load and call through `window.initVerification(config)`. +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. -Both apps are client-only and call a backend "proxy" API that lives in another repo. +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. -## Commands - -| Task | Command | -|---|---| -| Install | `npm ci --legacy-peer-deps` (Node 20; peer deps conflict without the flag) | -| Env setup | `cp .env.example .env` and fill in the values. `tools/set-env.js` generates the git-ignored env-variables.ts for each app ([docs/environment.md](docs/environment.md)) | -| Run dashboard | `npm start` (port 4200) | -| Run widget alone | `npm run start:proxy-auth` (also port 4200) | -| Build widget into dashboard assets | `npm run build:proxy-auth` | -| Full build | `npm run build:test`, `npm run build:stage` or `npm run build:prod`. Plain `npm run build` mixes a prod widget with a local dashboard | -| Format | `npm run format:code`. Prettier also runs on pre-commit through husky and lint-staged | - -`npm test` and `npm run lint` are not reliable at this commit: there is no test target on the dashboard, and `nx workspace-lint` is gone in Nx 21. Check a change by building the affected app: `npx nx build 36-blocks` or `npx nx build 36-blocks-widget` (run `node tools/set-env` first). - -## Read before you change - -- Anything → [docs/architecture.md](docs/architecture.md), then [docs/conventions.md](docs/conventions.md) -- The widget's public API, events or build → [docs/modules/widget-embed.md](docs/modules/widget-embed.md) and [docs/api.md](docs/api.md#widget-public-api-host-page--widget) -- Widget login, register or OTP → [docs/modules/widget-auth.md](docs/modules/widget-auth.md), [docs/flows/widget-verification.md](docs/flows/widget-verification.md) -- Widget account screens → [docs/modules/widget-account.md](docs/modules/widget-account.md) -- Dashboard login, token or guards → [docs/modules/app-shell.md](docs/modules/app-shell.md), [docs/flows/dashboard-login.md](docs/flows/dashboard-login.md) -- Blocks (features) wizard → [docs/modules/panel-features.md](docs/modules/panel-features.md), [docs/flows/feature-configuration.md](docs/flows/feature-configuration.md) -- Users, logs, billing, dashboard, layout → [docs/modules/panel-ops.md](docs/modules/panel-ops.md) -- `libs/**`, HTTP, the interceptor → [docs/modules/shared-libs.md](docs/modules/shared-libs.md) -- Routes, state, components, styling → [docs/ui/](docs/README.md#ui) -- A common change (page, backend call, block tab, widget key or screen, env value, lib, release) → [docs/recipes.md](docs/recipes.md) - -## Rules - -1. Never rename or remove any part of the widget's public contract: - - `window.initVerification` and the `proxy-auth` tag - - `window.__proxyAuth` and the `openAddUserDialog` event - - the `userProxyContainer` id and the assets/proxy-auth/proxy-auth.js path - - any value in `libs/constant/src/verification/verification.ts` - - Customer sites depend on all of these. Add new values instead ([docs/api.md](docs/api.md)). -2. No dynamic `import()` in `apps/36-blocks-widget`. `build-elements.js` leaves the `chunk-*` file it creates out of the single-file bundle, so the bundle breaks on host pages. -3. A new `initVerification` config key that must not be sent to the backend goes into `RESERVED_KEYS` in `apps/36-blocks-widget/src/app/init-verification.ts`. Every other key is POSTed as `otherData` ([recipe](docs/recipes.md#add-an-initverification-config-key)). -4. Put widget CSS in component styles. `apps/36-blocks-widget/src/styles.scss` is also injected into the host page's ``. Build overlays with `WidgetPortalService` / `WidgetDialogService`. -5. Set and clear the dashboard token only through `AuthService.setTokenSync` / `clearTokenSync`. They keep the `authToken` cookie and the in-memory `IToken` in sync. Guards read both, HttpWrapper reads memory, and ErrorInterceptor reads only the cookie ([docs/modules/app-shell.md](docs/modules/app-shell.md)). -6. A dashboard endpoint called without a session must be on the ErrorInterceptor allow-list. Otherwise it is silently dropped (`EMPTY`) ([docs/modules/shared-libs.md](docs/modules/shared-libs.md)). -7. A 200 can still be an error. Check `res.hasError` (dashboard) or the per-endpoint success field (widget) as well as using `catchError` ([docs/conventions.md](docs/conventions.md#http-and-errors)). -8. A block has two ids: numeric `id` for feature CRUD, OAuth and scopes, and string `reference_id` for the widget, billing and the Users/roles screens. Passing the wrong one fails silently ([docs/glossary.md](docs/glossary.md)). -9. In the widget, copy `OtpService.options` before adding per-call headers. It is one shared mutable object, and its headers leak into later calls. -10. The widget must not import `@proxy/services/proxy/*`, `@proxy/services/httpWrapper` or the login service. They need `AngularFireAuth` and `IToken`, which only the dashboard provides. -11. Add dashboard pages to `apps/36-blocks/src/app/panel/panel.routes.ts`. `apps/36-blocks/src/app/panel/layout/layout.routes.ts` is dead (nothing imports it). -12. Never send a client id. The current client is stored on the server against the token, and switching client is `POST switchclient` followed by a full page reload. -13. Everything `tools/set-env.js` writes ends up in a public browser bundle. Never add a real server-side secret there. When you add a key, also add it to `.env.example`. -14. Keep the `setTimeout(…, 100)` before block create/update in `create-feature.component.ts`. The payload is filled in by promises (file reads, chip lists) ([docs/modules/panel-features.md](docs/modules/panel-features.md)). -15. Branch flow is `-clean` → `stage` → `production`; the README's `development`/`main` flow is stale. PRs into `stage` get **no CI build**, so build locally first. Land any revert on `stage` too ([docs/conventions.md](docs/conventions.md#branching-and-release)). - -## Where things are - -- `apps/36-blocks/src/app/website/`: public marketing pages, login and register -- `apps/36-blocks/src/app/panel/`: the logged-in `/app` dashboard pages, each with a `.store.ts` ComponentStore -- `apps/36-blocks/src/app/core/`: the root NgRx store, the auth initializer, client registration -- `apps/36-blocks-widget/src/app/init-verification.ts`: the widget's global API -- `apps/36-blocks-widget/src/app/otp/`: widget screens, the `otp` store, `OtpService` -- `apps/36-blocks-widget/build-elements.js`: post-build step that produces the single proxy-auth.js -- `libs/`: shared code, imported only through the `@proxy/...` aliases in `tsconfig.base.json` -- `tools/set-env.js`, `tools/postbuild.js`: env generation and the version.json writer -- `apps/shared/scss/`: global SCSS and Tailwind entry shared by both apps - -## Docs map - -See [docs/README.md](docs/README.md) for every doc, when to read it, and the open questions for humans. -When you change behaviour that a doc describes, update that doc in the same change and run `/ai-docs update`. +- 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..535ca807 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 @@ -344,3 +348,9 @@ We have a server [Marlin](marlin.phone91.com) [for Prod] & [sailfish](sailfish.p If client origin is widget (origin = `chat`) [will implement for all in v2], we have an option to start meet from `client-card-component`. For selected client we use `call-session-creds` API to fetch `room` & `jwt` to init socket connection and Jitsi meet room with Jitsi Options. [Jitsi Reference Doc](https://developer.8x8.com/jaas/docs/iframe-api-integration) + +## Production integration — 29 October 2026 + +The V0 changes are reconciled with production commit `2b1deb17`. Production deliberately uses a browser-only build and removed `ProjectGuard`; this merge preserves both decisions and the newer production navigation, OAuth/client-secret features, and compact website spacing. The original feature branch's SSR and project-guard changes/tests are therefore not part of this production integration. + +Validation: 23 applicable `npm run test:v0` checks, Angular type/template checking, and `npm run build:prod` passed, including the widget bundle and post-build packaging. Build warnings remain; this does not certify configured Firebase/provider login. Activity and Login as require the companion backend API/migrations from [user-proxy PR #1476](https://github.com/Walkover-Web-Solution/user-proxy/pull/1476) before live use. See the canonical [work state](../user-proxy/docs/WORK-STATE.md) for remaining integration gates. 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..48035377 --- /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/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 c737474a..cfba2279 100644 --- a/apps/36-blocks/src/app/panel/panel.routes.ts +++ b/apps/36-blocks/src/app/panel/panel.routes.ts @@ -11,6 +11,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 dd3b1c90..03085c85 100644 --- a/apps/36-blocks/src/app/website/about/about.component.html +++ b/apps/36-blocks/src/app/website/about/about.component.html @@ -6,11 +6,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.

@@ -98,7 +99,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 8b059d69..af3d3a1e 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.

Explore Platform - View Documentation - +
    @for (line of codeSnippetLines; track line.num) {{{ line.num }} -}@empty {} + >{{ codeSnippet }}
    - 5 min setup + Widget setup diff --git a/apps/36-blocks/src/app/website/home/home.component.ts b/apps/36-blocks/src/app/website/home/home.component.ts index 706f67a7..5a260f6f 100644 --- a/apps/36-blocks/src/app/website/home/home.component.ts +++ b/apps/36-blocks/src/app/website/home/home.component.ts @@ -1,7 +1,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, PLATFORM_ID, inject } from '@angular/core'; +import { WIDGET_EXAMPLE } from '../docs/widget-example'; import { WidgetTheme } from '@proxy/constant'; import { WidgetThemeService } from 'apps/36-blocks-widget/src/app/otp/service/widget-theme.service'; -import { DomSanitizer, Meta, SafeHtml, Title } from '@angular/platform-browser'; +import { Meta, Title } from '@angular/platform-browser'; import { isPlatformBrowser } from '@angular/common'; import { DOCUMENT } from '@angular/common'; import { RouterModule } from '@angular/router'; @@ -46,7 +47,6 @@ export class HomeComponent extends BaseComponent implements OnInit { private titleService = inject(Title); private platformId = inject(PLATFORM_ID); private document = inject(DOCUMENT); - private sanitizer = inject(DomSanitizer); // temp chagnes public readonly accent = { text: 'text-teal-400', @@ -129,7 +129,7 @@ export class HomeComponent extends BaseComponent implements OnInit { { icon: 'brush', title: 'Theme Editor', desc: 'Match your brand colors' }, { icon: 'dashboard', title: 'Layout Options', desc: 'Multiple form layouts' }, { icon: 'smartphone', title: 'Responsive', desc: 'Works on all devices' }, - { icon: 'translate', title: 'i18n Ready', desc: '30+ languages' }, + { icon: 'tune', title: 'Configurable', desc: 'Configure enabled login methods' }, ]; public readonly platformFeatures: { icon: string; title: string; desc: string }[] = [ @@ -151,9 +151,17 @@ export class HomeComponent extends BaseComponent implements OnInit { desc: 'Passwordless and social authentication for better conversion.', }, { icon: 'lock', title: 'Role-Based Access', desc: 'Fine-grained permissions using RBAC.' }, - { icon: 'trending_up', title: 'Activity Tracking', desc: 'Audit logs and user activity monitoring.' }, + { + icon: 'trending_up', + title: 'Activity Tracking', + desc: 'Request logs and instrumented sensitive-operation activity.', + }, { icon: 'schedule', title: 'Secure Sessions', desc: 'Token-based authentication with session expiry.' }, - { icon: 'menu_book', title: 'Developer Documentation', desc: 'Clear APIs, SDKs, and step-by-step guides.' }, + { + icon: 'menu_book', + title: 'Developer Documentation', + desc: 'Widget setup and application security boundaries.', + }, ]; public readonly buildCards: { icon: string; title: string; desc: string; highlight?: boolean }[] = [ @@ -162,7 +170,11 @@ export class HomeComponent extends BaseComponent implements OnInit { title: 'SaaS Applications', desc: 'Launch faster with secure authentication and access control.', }, - { icon: 'business', title: 'Enterprise Tools', desc: 'Manage teams, roles, and permissions at scale.' }, + { + icon: 'business', + title: 'Team Applications', + desc: 'Manage organization memberships, roles, and permissions.', + }, { icon: 'smartphone', title: 'Consumer Apps', @@ -173,8 +185,8 @@ export class HomeComponent extends BaseComponent implements OnInit { ]; public readonly heroStats: { value: string; label: string }[] = [ - { value: '5 min', label: 'Integration time' }, - { value: '99.9%', label: 'Uptime SLA' }, + { value: 'Widget', label: 'Browser integration' }, + { value: 'API', label: 'Server integration' }, ]; public readonly mockFormFields: { label: string; placeholder: string }[] = [ @@ -184,7 +196,7 @@ export class HomeComponent extends BaseComponent implements OnInit { public readonly floatingBadges: { side: 'left' | 'right'; dot?: string; icon?: string; text: string }[] = [ { side: 'left', dot: 'bg-green-500', text: 'JavaScript Ready' }, - { side: 'right', icon: 'verified', text: 'Enterprise-grade' }, + { side: 'right', icon: 'verified', text: 'Configurable UI' }, ]; public readonly macDots: string[] = ['bg-[#ff5f56]', 'bg-[#ffbd2e]', 'bg-[#27ca40]']; @@ -203,7 +215,7 @@ export class HomeComponent extends BaseComponent implements OnInit { headingId: 'features-heading', label: 'Everything You Need to Manage Users', accentWord: 'Securely', - desc: 'Replace months of custom authentication development with production-ready components built for security, scale, and speed.', + desc: 'Configure authentication, user management, and investigation tools for your connected applications.', gridCols: 'grid-cols-1 md:grid-cols-3 sm:grid-cols-2', cards: this.platformFeatures, }, @@ -218,64 +230,7 @@ export class HomeComponent extends BaseComponent implements OnInit { }, ]; - public readonly codeSnippetLines: { num: string; html: SafeHtml }[] = this.buildCodeLines([ - { - num: '01', - html: ` <script type="text/javascript">`, - }, - { - num: '02', - html: ` var configuration = {`, - }, - { - num: '03', - html: ` referenceId: '4512365a176770...',`, - }, - { - num: '04', - html: ` type: 'authorization',`, - }, - { - num: '05', - html: ` success: (data) => {`, - }, - { num: '06', html: ` // get verified token in response` }, - { - num: '07', - html: ` console.log('success response', data);`, - }, - { num: '08', html: ` },` }, - { - num: '09', - html: ` failure: (error) => {`, - }, - { num: '10', html: ` // handle error` }, - { - num: '11', - html: ` console.log('failure reason', error);`, - }, - { num: '12', html: ` },` }, - { num: '13', html: ` };` }, - { num: '14', html: ` </script>` }, - { num: '15', html: ` <script` }, - { - num: '16', - html: ` type="text/javascript"`, - }, - { - num: '17', - html: ` onload="initVerification(configuration)"`, - }, - { - num: '18', - html: ` src="https://36blocks.com/...">`, - }, - { num: '19', html: ` </script>` }, - ]); - - private buildCodeLines(lines: { num: string; html: string }[]): { num: string; html: SafeHtml }[] { - return lines.map((l) => ({ num: l.num, html: this.sanitizer.bypassSecurityTrustHtml(l.html) })); - } + public readonly codeSnippet = WIDGET_EXAMPLE; public currentlyBuilding: string = ''; private buildingTexts = [ @@ -318,12 +273,12 @@ export class HomeComponent extends BaseComponent implements OnInit { { name: 'description', content: - 'Launch secure user signup, login, roles, and access control in minutes with 36Blocks. Plug & play authentication with social login, OTP, RBAC, and analytics.', + 'Configure user signup, login, roles, and access controls with 36Blocks. Plug & play authentication with social login, OTP, RBAC, and analytics.', }, { name: 'keywords', content: - 'authentication platform, SSO, OTP login, social login, RBAC, user management, Angular auth, developer authentication, passwordless login, hCaptcha, role-based access control', + 'authentication platform, OTP login, social login, RBAC, user management, Angular auth, developer authentication, passwordless login, hCaptcha, role-based access control', }, { name: 'robots', content: 'index, follow' }, { name: 'author', content: '36Blocks' }, @@ -332,7 +287,7 @@ export class HomeComponent extends BaseComponent implements OnInit { { property: 'og:description', content: - 'Launch secure user signup, login, roles, and access control in minutes. Plug & play authentication platform with social login, OTP, and RBAC.', + 'Configure user signup, login, roles, and access controls. Plug & play authentication platform with social login, OTP, and RBAC.', }, { property: 'og:type', content: 'website' }, { property: 'og:url', content: 'https://36blocks.com/' }, @@ -345,7 +300,8 @@ export class HomeComponent extends BaseComponent implements OnInit { { name: 'twitter:title', content: '36Blocks — Secure Auth & User Management for Modern Apps' }, { name: 'twitter:description', - content: 'Plug & play authentication with social login, OTP, RBAC, and analytics. 5-minute setup.', + content: + 'Plug & play authentication with social login, OTP, RBAC, and analytics. Configurable widget integration.', }, { name: 'twitter:image', content: 'https://36blocks.com/assets/og-image.png' }, { name: 'twitter:image:alt', content: '36Blocks — Secure Auth & User Management' }, @@ -424,16 +380,7 @@ export class HomeComponent extends BaseComponent implements OnInit { '@type': 'WebSite', name: '36Blocks', url: 'https://36blocks.com/', - description: - 'Secure authentication and user management platform with social login, OTP, RBAC, and analytics.', - potentialAction: { - '@type': 'SearchAction', - target: { - '@type': 'EntryPoint', - urlTemplate: 'https://36blocks.com/?q={search_term_string}', - }, - 'query-input': 'required name=search_term_string', - }, + description: 'Authentication and user management with configurable login, roles, and analytics.', }, { '@context': 'https://schema.org', @@ -443,13 +390,7 @@ export class HomeComponent extends BaseComponent implements OnInit { applicationCategory: 'DeveloperApplication', operatingSystem: 'Web', description: - 'Plug & play authentication platform with social login, OTP, role-based access control, and user analytics. Integrate in 5 minutes.', - offers: { - '@type': 'Offer', - price: '0', - priceCurrency: 'USD', - availability: 'https://schema.org/InStock', - }, + 'Plug & play authentication platform with social login, OTP, role-based access control, and user analytics. Integrate using the configured widget.', featureList: [ 'Social Login (Google, Apple)', 'OTP Authentication', @@ -457,9 +398,9 @@ export class HomeComponent extends BaseComponent implements OnInit { 'hCaptcha Bot Protection', 'User Analytics Dashboard', 'Customizable Auth Forms', - 'Multi-language Support (30+ languages)', + 'Configurable Login Methods', 'Secure Sessions with Token Expiry', - 'Activity Tracking & Audit Logs', + 'Request Logs', ], author: { '@type': 'Organization', diff --git a/apps/36-blocks/src/app/website/layout/website-layout.component.ts b/apps/36-blocks/src/app/website/layout/website-layout.component.ts index 469cb791..2ab5f995 100644 --- a/apps/36-blocks/src/app/website/layout/website-layout.component.ts +++ b/apps/36-blocks/src/app/website/layout/website-layout.component.ts @@ -22,13 +22,14 @@ export class WebsiteLayoutComponent implements OnInit, OnDestroy { public readonly headerNavLinks: { label: string; ariaLabel: string; route: string }[] = [ { label: 'Home', ariaLabel: '36Blocks home', route: '/' }, + { label: 'Docs', ariaLabel: '36Blocks integration guide', route: '/docs' }, { label: 'Pricing', ariaLabel: '36Blocks Pricing plans', route: '/pricing' }, { label: 'Contact', ariaLabel: 'Contact 36Blocks', route: '/contact' }, ]; public readonly footerLinks: { label: string; ariaLabel: string; route?: string; href?: string }[] = [ { label: 'About Us', ariaLabel: '36Blocks About us', route: '/about' }, - { label: 'Documentation', ariaLabel: '36Blocks Documentation', href: 'https://help.36blocks.com/' }, + { label: 'Documentation', ariaLabel: '36Blocks Documentation', route: '/docs' }, { label: 'Security', ariaLabel: '36Blocks Security information', route: '/security' }, { label: 'Privacy', ariaLabel: '36Blocks Privacy policy', route: '/privacy' }, { label: 'Terms', ariaLabel: '36Blocks Terms of service', route: '/terms' }, diff --git a/apps/36-blocks/src/app/website/pricing/pricing.component.html b/apps/36-blocks/src/app/website/pricing/pricing.component.html index 388bab65..56a9786f 100644 --- a/apps/36-blocks/src/app/website/pricing/pricing.component.html +++ b/apps/36-blocks/src/app/website/pricing/pricing.component.html @@ -5,11 +5,11 @@ id="pricing-heading" class="text-4xl sm:text-5xl lg:text-6xl font-bold text-[#f0f6fc] tracking-tight mb-5 leading-[1.1]" > - Simple pricing. - No enterprise drama. + Plan your integration. + Confirm your service terms.

- Built for SaaS founders who want authentication done — without complexity. + Current prices, limits, and service commitments require confirmation for your environment.

@@ -96,9 +96,9 @@

Pricing plans

- +
} @@ -138,7 +138,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 1cb8bd34..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,8 +1,8 @@ import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; import { Meta, Title } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; -import { MatIconModule } from '@angular/material/icon'; import { RouterModule } from '@angular/router'; +import { MatIconModule } from '@angular/material/icon'; export interface PricingFeature { text: string; @@ -37,7 +37,7 @@ export interface TrustItem { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'proxy-pricing', - imports: [CommonModule, RouterModule, MatIconModule], + imports: [CommonModule, MatIconModule, RouterModule], templateUrl: './pricing.component.html', }) export class PricingComponent implements OnInit { @@ -48,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.', - 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 }, - ], - 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.', + name: 'Plan and environment review', + price: 'Contact us', + period: '', + description: 'Confirm current commercial terms and enabled capabilities for your application.', 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 }, + { 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: '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 7969cc56..68654959 100644 --- a/apps/36-blocks/src/app/website/privacy/privacy.component.html +++ b/apps/36-blocks/src/app/website/privacy/privacy.component.html @@ -179,9 +179,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 c0616ff5..ffebfb1a 100644 --- a/apps/36-blocks/src/app/website/security/security.component.html +++ b/apps/36-blocks/src/app/website/security/security.component.html @@ -1,5 +1,4 @@
-

- We take security seriously. Every layer of 36Blocks is designed with your users' data and trust in mind. + 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 c6174084..e8663699 100644 --- a/apps/36-blocks/src/app/website/terms/terms.component.html +++ b/apps/36-blocks/src/app/website/terms/terms.component.html @@ -18,9 +18,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 77e5f6af..bd31e274 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/docs/widget-integration-guide.md b/docs/widget-integration-guide.md new file mode 100644 index 00000000..852da1e9 --- /dev/null +++ b/docs/widget-integration-guide.md @@ -0,0 +1,275 @@ +# 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` + +--- + +## Table of Contents + +1. [How It Works](#how-it-works) +2. [Base Script Loader](#base-script-loader) +3. [Example 1 — Authorization (Login)](#example-1--authorization-login) +4. [Example 2 — User Profile](#example-2--user-profile) +5. [Example 3 — User Management](#example-3--user-management) +6. [Example 4 — Organization Details](#example-4--organization-details) +7. [Example 5 — Subscription](#example-5--subscription) +8. [Example 6 — Authorization (Advanced)](#example-6--authorization-advanced) +9. [All Config Properties](#all-config-properties) +10. [Notes & Rules](#notes--rules) + +--- + +## How It Works + +1. A `
` acts as the mount point for the widget. +2. The `proxy-auth.js` script registers a global `window.initVerification(config)` function. +3. Calling `initVerification` creates and appends a `` custom element inside the container div. +4. The widget fetches its configuration from the API using `referenceId`, renders buttons, and handles the full auth flow. + +--- + +## Base Script Loader + +The widget uses a **2-script pattern**. The first script defines the `configuration` object; the second loads `proxy-auth.js` and calls `initVerification(configuration)` via the `onload` attribute. + +Replace `SCRIPT_URL` with the actual hosted URL of `proxy-auth.js` (e.g. `https://yourdomain.com/assets/proxy-auth/proxy-auth.js`). + +--- + +## Example 1 — Authorization (Login) + +Default widget type. Shows OTP, social login, and/or password login buttons based on your dashboard configuration. + +```html + +
+ + + +``` + +--- + +## Example 2 — User Profile + +Displays the authenticated user's profile. Requires an `authToken` obtained from a previous login success callback. + +```html +
+ + + +``` + +--- + +## Example 3 — User Management + +Shows a role & user management panel. Requires an `authToken`. Use `isRolePermission`, `include_role_ids`, and `exclude_role_ids` to control visibility. + +```html +
+ + + +``` + +--- + +## Example 4 — Organization Details + +Renders the organization details view. Use `showCompanyDetails` to include the company info section. + +```html +
+ + + +``` + +--- + +## Example 5 — Subscription + +Shows subscription plans. Set `isLogin: true` to display a login flow alongside the plans. + +```html +
+ + + +``` + +--- + +## Example 6 — Authorization (Advanced) + +Uses `version: 'v2'` UI, icon-only social buttons, custom input field position, and registration-only mode. URL query parameters can also pre-fill registration fields. + +```html +
+ + + +``` + +--- + +## All Config Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `referenceId` | `string` | — | **Required.** Widget ID from the dashboard. Must also be the `id` of the container `
`. | +| `authToken` | `string` | — | Encrypted auth token for authenticated-user views (`user-profile`, `user-management`, `organization-details`). | +| `type` | `string` | `'authorization'` | Widget mode. One of: `'authorization'` · `'user-profile'` · `'user-management'` · `'organization-details'` · `'subscription'` | +| `theme` | `string` | `'system'` | Color theme. One of: `'system'` · `'light'` · `'dark'` | +| `target` | `string` | `'_self'` | Link open target. `'_self'` or `'_blank'`. | +| `version` | `string` | `'v1'` | Widget UI version. `'v1'` or `'v2'`. | +| `showCompanyDetails` | `boolean` | `false` | Show company details section in `organization-details` view. | +| `isHidden` | `boolean` | `false` | Mount widget hidden initially (useful for `user-management` dialog trigger). | +| `isRolePermission` | `boolean` | `false` | Enable role-based permission UI in `user-management`. | +| `isPreview` | `boolean` | `false` | Preview mode — no live data mutations. Useful for testing UI. | +| `isLogin` | `boolean` | `false` | Show login UI alongside subscription plans (`subscription` type). | +| `isRegisterFormOnly` | `boolean` | `false` | Show only the registration form, skipping the login/auth buttons. | +| `loginRedirectUrl` | `string` | — | URL to redirect to after a successful login. | +| `redirect_path` | `string` | — | Path to redirect after login (e.g. `'/login'`). Internally forwarded as `addInfo.redirect_path` in the API payload. Primarily used to obtain a `proxy_auth_token` in the admin panel during preview. | +| `input_fields` | `string` | `'top'` | Input field position. `'top'` or `'bottom'`. | +| `show_social_login_icons` | `boolean` | `false` | Show social login as icon-only buttons instead of full-width. | +| `exclude_role_ids` | `number[]` | — | Role IDs to hide in the `user-management` view. | +| `include_role_ids` | `number[]` | — | Role IDs to show in the `user-management` view. | +| `success` | `function(data)` | — | **Required.** Callback invoked on successful action. `data` contains the verified auth token or result. | +| `failure` | `function(error)` | — | Callback invoked on failure. `error` contains the failure reason. | + +--- + +## Notes & Rules + +- The container `
` **must match `referenceId` exactly**. The widget polls for the element up to 5 seconds after script load — it is safe to call `initVerification` before the DOM is fully painted. +- Calling `initVerification` a second time **replaces** the existing widget (the old `` element and any skeleton loaders are cleaned up automatically). +- `success` callback is **required** — omitting it throws `Error: success callback function missing!`. +- Any property not in the reserved list is forwarded as extra payload to the widget API (e.g. custom `otherData` fields). +- URL query parameters `first_name`, `last_name`, `email`, `signup_service_id` automatically pre-fill the registration form. +- `redirect_path` is **not** sent directly in the API payload — it is automatically moved into `addInfo.redirect_path` before the request is made. Do not nest it manually. 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 0959de66..16217761 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..56b291a0 --- /dev/null +++ b/tools/tests/panel-investigation.test.cjs @@ -0,0 +1,247 @@ +// 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, 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'] }, +}); + +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'); +}); + +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); + } +}); + +// Production removed project gating: preserve authenticated panel access even +// when the client has no projects, while exposing the new Activity route. +test('production panel keeps authentication without restoring project gating', () => { + const authGuard = class {}; + const { panelRoutes } = loadClass('apps/36-blocks/src/app/panel/panel.routes.ts', { + '../website/home/authguard': { CanActivateRouteGuard: authGuard }, + }); + const panel = panelRoutes[0]; + assert.equal(panel.canActivate.length, 1); + assert.equal(panel.canActivate[0], authGuard); + assert.equal(panel.children.find((route) => route.path === '').redirectTo, 'dashboard'); + assert.equal(typeof panel.children.find((route) => route.path === 'activity').loadComponent, 'function'); +}); 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(/