diff --git a/README.md b/README.md index a6bf522..25a2714 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ The **Admin Portal** is the out-of-the-box, self-service portal for a Norbix project's **end users**. When a developer doesn't have time to build their own user dashboard, this gives their users a ready-made one: sign in, manage -password & 2FA, edit their profile, control marketing preferences, and handle -data-privacy requests. It also hosts the project's public terms & privacy +password & passkeys, edit their profile, and choose which messages they get +(the communication structure the project defines in Cloud). The Privacy & data +page (export / delete) is hidden until the gateway has its endpoints. It also hosts the project's public terms & privacy policy at a stable URL (usable in app-store listings). It is open source (MIT) and talks **only to the Norbix API gateway** (the @@ -12,17 +13,21 @@ end-user data plane), never to the Hub. ## How a project is selected -One deployment serves many projects. The project id lives in the **subdomain**: +One deployment serves many projects. The project lives in the **host label** +— the gateway's `ProjectId.HostLabel`, `pr-` + the project Guid as 32 +lower-case hex digits (DNS-safe: no `_`, one case): ``` -pr_{base62}.admin.norbix.ai → that project's login screen -admin.norbix.ai → blank placeholder (no project) +pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai → project pr_5R4dlqJeXx943tOzSDEwbS +admin.norbix.ai → blank placeholder (no project) ``` -Point a custom domain at it with a CNAME: +The portal turns the label back into the `pr_{base62}` id +(`ProjectId.ViewId`) the gateway reads (`src/config/project.ts`). Point a +custom domain at it with a CNAME: ``` -admin.yourapp.com CNAME pr_7Hk2.admin.norbix.ai +admin.yourapp.com CNAME pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai ``` See [`docs/`](./docs) for the full architecture, login-config resolution, @@ -50,9 +55,10 @@ The portal uses **`@norbix.ai/react-redux`** (RTK Query hooks over the typed Both come from npm. To try an SDK change before it is published, see [`docs/sdk-local-development.md`](./docs/sdk-local-development.md). -For local dev without a `pr_` subdomain, pin a project with -`VITE_ADMIN_PROJECT_ID` in `.env`, or add a static config at -`config/projects/{id}.json` and visit with that id. +For local dev without a `pr-` host, pin a project with +`NEXT_PUBLIC_ADMIN_PROJECT_ID` in `.env` (a Docker image takes it as +`--build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_…`: Next inlines it at build +time). ## Tech diff --git a/app/api/structure/route.test.ts b/app/api/structure/route.test.ts new file mode 100644 index 0000000..f2dfe87 --- /dev/null +++ b/app/api/structure/route.test.ts @@ -0,0 +1,58 @@ +// Structure route, self-hosted path (F40): with API_KEY set, the backend calls +// the gateway's GET /{v}/account/projects/{projectId}/admin-portal/structure +// (GetAdminPortalStructure) as the service user. A real local HTTP server +// stands in for the Hub, so the test sees the exact path and headers. +import { createServer, IncomingHttpHeaders, Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('server-only', () => ({})); + +let hub: Server; +let seen: { url?: string; headers?: IncomingHttpHeaders } = {}; +let route: typeof import('./route'); + +beforeAll(async () => { + hub = createServer((req, res) => { + seen = { url: req.url, headers: req.headers }; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + projectId: 'pr_1', + adminPortalEnabled: true, + displayName: 'Shop', + modules: [{ key: 'profile', displayName: 'Profile', enabled: true }], + }), + ); + }); + await new Promise((resolve) => hub.listen(0, '127.0.0.1', resolve)); + const { port } = hub.address() as AddressInfo; + vi.stubEnv('HUB_BASE_URL', `http://127.0.0.1:${port}`); + vi.stubEnv('API_BASE_URL', `http://127.0.0.1:${port}`); + vi.stubEnv('API_KEY', 'svc-key'); + route = await import('./route'); +}); + +afterAll(() => { + hub.close(); + vi.unstubAllEnvs(); +}); + +describe('GET /api/structure (self-hosted)', () => { + it('calls /v3/account/projects/{projectId}/admin-portal/structure with the service key', async () => { + const res = await route.GET( + new NextRequest('http://portal.test/api/structure?projectId=pr_1'), + ); + + expect(seen.url).toBe('/v3/account/projects/pr_1/admin-portal/structure'); + expect(seen.headers?.authorization).toBe('Bearer svc-key'); + expect(seen.headers?.['norbix-project-id']).toBe('pr_1'); + expect(await res.json()).toEqual({ + projectId: 'pr_1', + adminPortalEnabled: true, + displayName: 'Shop', + modules: [{ key: 'profile', displayName: 'Profile', enabled: true }], + }); + }); +}); diff --git a/app/api/structure/route.ts b/app/api/structure/route.ts index 4f86655..729c5bd 100644 --- a/app/api/structure/route.ts +++ b/app/api/structure/route.ts @@ -3,8 +3,9 @@ // own config — the presence of API_KEY — NOT a client hint and NOT a mode flag: // // API_KEY set → self-hosted → call the PUBLIC Hub endpoint -// GET {hub}/{v}/admin-portal/structure -// authenticated as the project's AdminPortalManager service user. +// GET {hub}/{v}/account/projects/{projectId}/admin-portal/structure +// (gateway GetAdminPortalStructure) through the SDK, authenticated as the +// project's AdminPortalManager service user (its API key, task P3). // // API_KEY unset → managed → call the PRIVATE internal endpoint // GET {hub}/internal/admin-portal/structure?projectId=… @@ -17,6 +18,7 @@ import 'server-only'; import { NextRequest, NextResponse } from 'next/server'; import { NORBIX_HUB_URL } from '@norbix.ai/ts'; +import { serviceClientForProject } from '../../lib/serverNorbix'; // Type-only import (erased at build) — the single admin-repo definition of the // structure shapes lives in portalApi. Both will be replaced by the generated // @norbix.ai/ts DTOs after the gateway SDK regen (G7). @@ -32,7 +34,6 @@ const HUB_BASE = (process.env.HUB_BASE_URL ?? NORBIX_HUB_URL).replace( /\/$/, '', ); -const HUB_VERSION = process.env.HUB_VERSION ?? 'v3'; const API_KEY = process.env.API_KEY; const NORBIX_ENV = process.env.ENV && process.env.ENV.length > 0 ? process.env.ENV : undefined; @@ -73,29 +74,32 @@ export async function GET(req: NextRequest): Promise { // per-project key. const isSelfHosted = Boolean(API_KEY); - const headers: Record = { accept: 'application/json' }; - if (NORBIX_ENV) headers['norbix-env'] = NORBIX_ENV; - - let url: string; - if (isSelfHosted) { - // Public endpoint — authenticate as the service user. - headers.authorization = `Bearer ${API_KEY}`; - headers['x-cm-projectid'] = projectId; - url = `${HUB_BASE}/${HUB_VERSION}/admin-portal/structure`; - } else { - // Managed: private internal endpoint — network-trusted, no key. - url = `${HUB_BASE}/internal/admin-portal/structure?projectId=${encodeURIComponent(projectId)}`; - } - try { - const res = await fetch(url, { headers, cache: 'no-store' }); - if (!res.ok) { - return NextResponse.json( - { ...defaultStructure(projectId), warnings: ['structure-unavailable'] }, - { status: 200 }, - ); + let structure: Partial; + if (isSelfHosted) { + // Public endpoint, as the service user. The SDK fills {projectId} into + // /{v}/account/projects/{projectId}/admin-portal/structure and sends the + // key as the bearer token, plus the project and env headers. + structure = await serviceClientForProject( + projectId, + ).hub.account.getAdminPortalStructure({ projectId }); + } else { + // Managed: private internal endpoint — network-trusted, no key. + const headers: Record = { accept: 'application/json' }; + if (NORBIX_ENV) headers['norbix-env'] = NORBIX_ENV; + const url = `${HUB_BASE}/internal/admin-portal/structure?projectId=${encodeURIComponent(projectId)}`; + const res = await fetch(url, { headers, cache: 'no-store' }); + if (!res.ok) { + return NextResponse.json( + { + ...defaultStructure(projectId), + warnings: ['structure-unavailable'], + }, + { status: 200 }, + ); + } + structure = (await res.json()) as Partial; } - const structure = (await res.json()) as Partial; // Backfill a usable shape if the gateway returned a sparse/empty body. return NextResponse.json( { diff --git a/deployments/Dockerfile b/deployments/Dockerfile index ba1de52..53ef52a 100644 --- a/deployments/Dockerfile +++ b/deployments/Dockerfile @@ -12,9 +12,15 @@ # HUB_BASE_URL — e.g. https://hub.norbix.ai # API_BASE_URL — e.g. https://api.norbix.ai # ENV — optional Norbix environment (staging/test); unset = PROD -# NEXT_PUBLIC_* — anything the browser needs (config mode, etc.) # -# docker build -f deployments/Dockerfile -t norbix-admin . +# Browser values (NEXT_PUBLIC_*) are the exception: Next inlines them into the +# bundle at BUILD time, so they are build args, not runtime env: +# NEXT_PUBLIC_ADMIN_PROJECT_ID — pin one project (self-hosted / custom +# domain); skips host-label resolution +# NEXT_PUBLIC_ADMIN_CONFIG_MODE — 'dynamic' (default) or 'static' +# +# docker build -f deployments/Dockerfile -t norbix-admin \ +# --build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_xxxx . # docker run -p 3100:3100 -e API_KEY=... norbix-admin ARG NODE_VERSION=22 @@ -33,7 +39,13 @@ RUN --mount=type=cache,target=/root/.npm \ # ───────────────────────────────────────────────────────────────── FROM node:${NODE_VERSION}-alpine AS build WORKDIR /app -ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 +# Inlined into the browser bundle by `next build` (see the header). Empty = +# not pinned: the project comes from the host label / custom-domain lookup. +ARG NEXT_PUBLIC_ADMIN_PROJECT_ID="" +ARG NEXT_PUBLIC_ADMIN_CONFIG_MODE="" +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 \ + NEXT_PUBLIC_ADMIN_PROJECT_ID=${NEXT_PUBLIC_ADMIN_PROJECT_ID} \ + NEXT_PUBLIC_ADMIN_CONFIG_MODE=${NEXT_PUBLIC_ADMIN_CONFIG_MODE} COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build diff --git a/docs/README.md b/docs/README.md index 74752d7..4ce976d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,22 +6,24 @@ out-of-the-box, self-service portal that a Norbix project's **end users** | Doc | What it covers | | --- | --- | -| [`architecture.md`](./architecture.md) | The full picture: the four portals (Hub, API, Cloud, Admin), how Admin fits, the `pr_{base62}` subdomain model, dynamic vs static login config, CNAME mapping, deployment flavors. | +| [`architecture.md`](./architecture.md) | The full picture: the four portals (Hub, API, Cloud, Admin), how Admin fits, the `pr-` host-label model, dynamic vs static login config, CNAME mapping, deployment flavors. | | [`login-resolution.md`](./login-resolution.md) | How the unauthenticated login screen is built — dynamic (Hub) vs static (YAML/JSON) config, the resolution order, and caching. | -| [`features.md`](./features.md) | The four feature areas: Auth management, Profile / contact info, Marketing preferences, Compliance / privacy. Plus the planned "own records" feature. | +| [`features.md`](./features.md) | The four feature areas: Auth management, Profile / contact info, Communication preferences (rendered from Cloud's structure), Compliance / privacy (hidden until the gateway has it). Plus the planned "own records" feature. | | [`implementation-plan.md`](./implementation-plan.md) | Phased build plan. What we reuse from Cloud, what we rewrite (clean-room), and the open-source licensing rules. | -| [`devops.md`](./devops.md) | Docker image, nginx, and Kubernetes manifests (managed-service + self-hosted), wildcard ingress for `pr_{base62}`. | +| [`devops.md`](./devops.md) | Docker image, nginx, and Kubernetes manifests (managed-service + self-hosted), wildcard ingress for `pr-`. | ## One-paragraph summary The Admin Portal is a single React SPA, open-source, served from `admin.norbix.ai` (managed service) or `admin.` (self-hosted / enterprise). It is **not** deployed per project. Instead the **project is -carried in the subdomain** — `pr_{base62}.admin.norbix.ai`. A bare +carried in the host label** — `pr-<32 hex>.admin.norbix.ai` (the gateway's +`ProjectId.HostLabel`). A bare `admin.norbix.ai` with no project prefix renders a blank/placeholder page. The portal resolves the project from the subdomain, asks the **API gateway** for that project's public membership settings, and renders the correct login screen (email/password, social logins, branding). Authenticated users manage -their **auth** (password, 2FA), **profile** (contact info), **marketing -preferences**, and **compliance** (data export, account deletion, public terms -& policies). +their **auth** (password, passkeys), **profile** (contact info) and +**communication preferences**; the public terms & policies pages need no +sign-in. Compliance (data export, account deletion) is hidden until the +gateway has its endpoints. diff --git a/docs/backend.md b/docs/backend.md index 4ac3c8f..e21b7ed 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -86,7 +86,7 @@ same call: authorization** — no API key, no key lookup, no key vending. It returns structure, never a key. The server passes `projectId`, derived from the end-user host/subdomain (never a browser field). The environment is derived - from the host (e.g. `test.pr_x.admin.norbix.ai`). + from the host (e.g. `test.pr-.admin.norbix.ai`). - **Self-hosted** (forker): your backend calls the **public** Hub structure endpoint authenticated as the project's `AdminPortalManager` service-user key (in `API_KEY`), with normal IAM/tenancy clamping. OR you ship a hardcoded @@ -116,7 +116,7 @@ Runtime (read by the Node server — NOT baked into the bundle): > skip-remote config heuristic — it is NOT the Norbix `ENV` above. Browser-facing config still uses the public, non-secret values; the project id -is resolved as before (pin → `pr_` subdomain → meta → managed-host lookup). +is resolved as before (pin → `pr-` host label → meta → managed-host lookup). ## Single Docker image diff --git a/docs/devops.md b/docs/devops.md index 154224b..2c1d66f 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -7,21 +7,22 @@ image is a static SPA served by nginx, exactly like Cloud. `admin/deployments/Dockerfile` (multi-stage, same shape as Cloud): -1. **build** — `node:22-alpine`, `npm ci`, `npm run build` (`tsc && vite - build`). Build-time flags baked via Vite `define`: - - `VITE_ADMIN_RELEASE` = `ManagedService` | `SelfHosted` | `Enterprise` - - `VITE_ADMIN_API_BASE_URL`, `VITE_ADMIN_API_VERSION` - - `VITE_ADMIN_PROJECT_ID` (optional; pins the project for custom-domain - self-hosted builds) -2. **runtime** — `nginx:1.27-alpine`, serves `/dist`, runs as unprivileged - `nginx` user, listens on `8080`, SPA fallback to `index.html`, `/healthz`. +1. **build** — `node:22-alpine`, `npm ci`, `next build` (standalone output). + Browser values are inlined at build time, so they are build args: + - `NEXT_PUBLIC_ADMIN_PROJECT_ID` (optional; pins the project for + self-hosted / custom-domain builds) + - `NEXT_PUBLIC_ADMIN_CONFIG_MODE` (`dynamic` default, or `static`) +2. **runtime** — `node server.js` as an unprivileged user on `3100`. Server + env at `docker run`: `API_KEY` (service-user key, self-hosted), + `HUB_BASE_URL`, `API_BASE_URL`, `ENV`. Managed vs self-hosted comes from + `/echo` and from whether `API_KEY` is set. Build examples: ``` -docker build -t admin:managed --build-arg VITE_ADMIN_RELEASE=ManagedService . -docker build -t admin:selfhosted --build-arg VITE_ADMIN_RELEASE=SelfHosted \ - --build-arg VITE_ADMIN_PROJECT_ID=7Hk2 . +docker build -f deployments/Dockerfile -t admin:managed . +docker build -f deployments/Dockerfile -t admin:selfhosted \ + --build-arg NEXT_PUBLIC_ADMIN_PROJECT_ID=pr_5R4dlqJeXx943tOzSDEwbS . ``` `admin/deployments/nginx/default.conf` matches Cloud's: long cache on hashed @@ -38,7 +39,7 @@ devops/k8s/ deployments/managed-service/admin.yaml # Deployment (managed) deployments/self-hosted/admin.yaml # Deployment (self-hosted) services/managed-service/admin.yaml # ClusterIP Service - ingress/admin.yaml # wildcard ingress for pr_*.admin + ingress/admin.yaml # wildcard ingress for pr-.admin ``` ### ConfigMap (`norbix-admin-config`) @@ -69,12 +70,12 @@ host: "*.admin.norbix.ai" → service: admin ACME DNS-01, since HTTP-01 can't do wildcards). The bare `admin.norbix.ai` also routes to the same service (renders the blank placeholder). - **Custom domains (CNAME):** the customer CNAMEs `admin.` to - `pr_7Hk2.admin.norbix.ai`. For TLS on the custom host we either (a) ask them + `pr-<32 hex>.admin.norbix.ai` (the project's host label). For TLS on the custom host we either (a) ask them to terminate TLS at their edge, or (b) issue a per-host cert via cert-manager for the custom domain and add an ingress rule. Because the CNAME target carries the project id, the SPA still resolves the project; for the custom - host the ingress injects `X-Norbix-Project: 7Hk2` (annotation) so the SPA - can read it when the visible host has no `pr_` prefix. + host the portal asks the managed Hub (`/admin-portal-id?host=`) because the + visible host has no `pr-` label. ### Secrets diff --git a/docs/features.md b/docs/features.md index 3a9cc22..150bce5 100644 --- a/docs/features.md +++ b/docs/features.md @@ -11,12 +11,13 @@ The signed-in user manages their own credentials and account security. - **Change password** — old + new password. - **Reset password** — request a reset email; confirm with the token from the link. Also reachable unauthenticated from the login screen. -- **Two-factor (2FA)** — enroll (TOTP: show secret + QR, verify a code), - view status, disable (re-auth required). +- **Passkeys** — add, rename, revoke (behind email verification). +- **Two-factor (2FA)** — not built yet (Membership task); the portal does not + show or mention it. - **Sessions / sign-out** — sign out of the current session. (Listing/revoking other sessions is a later enhancement if the API exposes it.) -Screens: `/security` (overview), `/security/password`, `/security/2fa`. +Screens: `/security` (password + passkeys). Unauth: `/sign-in`, `/sign-in/reset`, `/sign-in/reset/confirm`. ## 2. Profile / contact info @@ -27,23 +28,39 @@ endpoints. Screen: `/profile`. -## 3. Marketing preferences +## 3. Communication preferences -The user controls what messages they receive: which marketing topics they are -subscribed to, and which transactional vs. marketing channels are enabled. -Backed by the API marketing-state endpoints (per channel / per subscription), -the same domain concept Cloud's contacts/marketing-state uses on the Hub side. +Cloud defines the communication structure for the whole project (Project +Settings → Communication Preferences): per communication channel (Marketing, +Transactional) a list of **groups**, each with **tags**; every tag says on +which delivery channels it sends (`defaultDelivery`: Email, Sms, Push, …). +Tags in no group are Cloud's "orphaned tags". The portal renders exactly that: -- List subscriptions / topics with on-off toggles. -- Per-channel marketing opt-in/opt-out (email, SMS, push, ...). -- Clear distinction between **transactional** (cannot fully opt out of - essential service messages) and **marketing** (freely opt out). +- A tab per channel (Marketing, Service messages); System is never optional. +- Each group as a card (translated title / description), each tag with one + switch per delivery channel it sends on. +- Tags in no group under **Other options**. +- The global **All marketing messages** switch (`blockAllMarketingMessages`); + when off, the marketing tag switches are disabled. + +A switch writes `blockedTags[]` (full replacement) through +`UpdateUserPreferences` — the gateway checks it per delivery channel +(`Campaign.cs`). The structure comes from `/api/bootstrap`, read server-side +with the service-user key; without a key only the global switch shows. Screen: `/preferences`. ## 4. Compliance / privacy -Self-service data rights: +**Hidden for now** (`PRIVACY_PAGE_ENABLED = false` in `src/config/structure.ts`): +the page called `/me/compliance`, `/me/compliance/export` and +`/me/compliance/delete`, which the gateway does not have. Cloud defines the +privacy flows first (Project Settings → Access / legal documents, Compliance +settings); the portal then renders what Cloud defines. The public +`/legal/terms` and `/legal/privacy` pages stay, and the sign-in screen links +them when the project published the document. + +Planned self-service data rights: - **Data export** — "what data do you hold about me" → request an export; show request status / download when ready. diff --git a/docs/login-resolution.md b/docs/login-resolution.md index 54d271b..9d0d8f3 100644 --- a/docs/login-resolution.md +++ b/docs/login-resolution.md @@ -8,19 +8,25 @@ render the unauthenticated login screen. On boot, the portal reads the project id from the **host**: ``` -pr_7Hk2.admin.norbix.ai → projectId = "7Hk2" -admin.laimingaspilvukas.lt (CNAME → pr_7Hk2.admin.norbix.ai) → projectId = "7Hk2" +pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai → projectId = "pr_5R4dlqJeXx943tOzSDEwbS" +admin.laimingaspilvukas.lt (CNAME → pr-4c0e….admin.norbix.ai) → host lookup → projectId admin.norbix.ai → no projectId → blank placeholder ``` Rules: -- The host's leftmost label must match `^pr_([0-9A-Za-z]+)$`. The captured - group is the base62 project id. +- The host's leftmost label must match `^pr-([0-9a-f]{32})$`, any case — the + gateway's `ProjectId.HostLabel` (`ProjectId.TryParseFromHost` reads it the + same way). The 32 hex digits are the project Guid; the portal converts them + to the `pr_{base62}` view id with the gateway's own steps + (`Guid.ToByteArray` order → little-endian number → base62), tested against + .NET output in `src/config/project.test.ts`. +- The old `pr_{base62}` host form is not read: `_` is not a legal host + character and browsers lower-case the host, which breaks base62. - A CNAME does not change the browser's visible host, so for custom domains the portal needs the id another way. Two supported mechanisms: - 1. **Build-time pin** (self-hosted/enterprise): `VITE_ADMIN_PROJECT_ID` is - baked into the image. The operator builds one image per project. + 1. **Build-time pin** (self-hosted/enterprise): `NEXT_PUBLIC_ADMIN_PROJECT_ID` + is baked into the image (`--build-arg`). One image per project. 2. **Edge header** (managed service custom domains): the ingress injects an `X-Norbix-Project` header / rewrites a known path that the runtime config shim reads. (See `devops.md`.) diff --git a/docs/tasks/portal-structure-hostlabel.md b/docs/tasks/portal-structure-hostlabel.md new file mode 100644 index 0000000..fa1bb4d --- /dev/null +++ b/docs/tasks/portal-structure-hostlabel.md @@ -0,0 +1,165 @@ +# Admin portal — communication structure, privacy page, managed host label, F37–F40 +This file: /Users/djovaisas/Projects/norbix/admin/docs/tasks/portal-structure-hostlabel.md (branch main, after the merge; worked on in branch fix/portal-structure-hostlabel) + +## Goal +Make the end-user portal render what Cloud defines (communication structure, legal docs), stop calling endpoints that do not exist, and resolve the managed `pr-` host. +Not in scope: gateway changes (the canonical admin URL builder, a compliance API, 2FA, end-user `membership:update`) and the public docs repo — listed under Findings / Needs you. + +## Plan +1. [done] feat(admin:preferences): render the project's channels → groups → tags (Cloud's Communication Preferences) with per-delivery switches, not one marketing switch +2. [done] fix(admin:privacy): hide the Privacy & data page and nav until /me/compliance* exist; drop "two-factor" from the Home Security card +3. [done] fix(admin:project): read the managed host label pr-<32 hex> (ProjectId.HostLabel) and turn it into the pr_ id the gateway expects +4. [done] fix(admin:auth): use the usr_ login id (JWT claim cm_auth_id) for Profile, Preferences and Passkeys ref: F37 +5. [done] fix(admin:auth): no empty "or continue with"; Terms / Privacy links filled; reset form says "sent" only on success ref: F38 +6. [done] chore(admin:docker): ARG NEXT_PUBLIC_ADMIN_PROJECT_ID (and CONFIG_MODE) in the build stage, checked with a real docker build ref: F39 +7. [done] fix(admin:structure): self-hosted backend calls /v3/account/projects/{projectId}/admin-portal/structure with the service-user key ref: F40 +8. [done] docs(admin): README + docs/README, login-resolution, features, devops, backend updated for the host label, hidden privacy page, preferences +9. [doing] release(admin): pull request merged with nbx-ship + +## Changes +| file (absolute, branch main after merge) | what changed | step | +|------|--------------|------| +| /Users/djovaisas/Projects/norbix/admin/src/features/preferences/communication.ts | new: Cloud structure → view model; blockedTags helpers | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/features/preferences/communication.test.ts | new: 7 tests | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/features/preferences/preferences.tsx | tabs, groups, tags, per-delivery switches, Other options, global switch | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/services/portalApi.ts | getBootstrap endpoint (/api/bootstrap) | 1 | +| /Users/djovaisas/Projects/norbix/admin/src/config/structure.ts | PRIVACY_PAGE_ENABLED=false; Security card "Password and passkeys." | 2 | +| /Users/djovaisas/Projects/norbix/admin/src/config/structure.test.ts | new: 3 tests | 2 | +| /Users/djovaisas/Projects/norbix/admin/src/components/layouts.tsx | Privacy nav item behind the flag | 2 | +| /Users/djovaisas/Projects/norbix/admin/src/App.tsx | /privacy route behind the flag; comment | 2, 3 | +| /Users/djovaisas/Projects/norbix/admin/src/config/project.ts | pr- host label → pr_ (viewIdFromHex) | 3 | +| /Users/djovaisas/Projects/norbix/admin/src/config/project.test.ts | .NET reference pairs + host cases (10 tests) | 3 | +| /Users/djovaisas/Projects/norbix/admin/src/features/dashboard/dashboard.tsx | placeholder text shows pr- | 3 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/loginId.ts | new: cm_auth_id claim → usr_ id | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/loginId.test.ts | new: 6 tests | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/slice.ts | signedIn / selectUserId use resolveLoginId | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/profile/profile.tsx | skip the user query until a usr_ id exists | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/passkeys.tsx | same | 4 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/login.tsx | showPasskey needs WebAuthn; useLegalLinks | 5 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/passwordReset.tsx | "sent" only on success, error alert | 5 | +| /Users/djovaisas/Projects/norbix/admin/src/features/auth/signInScreens.test.tsx | new: 5 component tests | 5 | +| /Users/djovaisas/Projects/norbix/admin/src/services/publicApi.ts | project id from the store (custom domains) | 5 | +| /Users/djovaisas/Projects/norbix/admin/deployments/Dockerfile | build args NEXT_PUBLIC_ADMIN_PROJECT_ID / CONFIG_MODE | 6 | +| /Users/djovaisas/Projects/norbix/admin/app/api/structure/route.ts | self-hosted path via SDK getAdminPortalStructure | 7 | +| /Users/djovaisas/Projects/norbix/admin/app/api/structure/route.test.ts | new: local Hub server checks path + bearer | 7 | +| /Users/djovaisas/Projects/norbix/admin/README.md, docs/{README,login-resolution,features,devops,backend}.md | host label, preferences, hidden privacy, Next build args | 8 | + +## Findings + +fix(admin:project): the portal read `pr_` from the host; browsers lower-case it, so the id was wrong and the portal showed "Portal unavailable" — done + where: /Users/djovaisas/Projects/norbix/admin/src/config/project.ts (branch main after merge) +```ts +// before — admin/src/config/project.ts:19,44-48 (main before) +const PR_PREFIX = /^pr_([0-9A-Za-z]+)$/; // <-- here: '_' + mixed case, not a DNS label +export function parseProjectIdFromHost(host: string): string | null { + const firstLabel = host.split('.')[0] ?? ''; + const match = PR_PREFIX.exec(firstLabel); + return match ? match[1] : null; +} +``` +```csharp +// the gateway contract — gateway/src/Isidos.CodeMash.Domain/BoundContext/Project/ValueObjects/ProjectId.cs:76-77 (refactoringV2) +public string HostLabel => + $"{HostLabelPrefix}-{Value.ToString("N").ToLowerInvariant()}"; // pr-<32 hex> +``` +```ts +// after — admin/src/config/project.ts (fix/portal-structure-hostlabel) +const HOST_LABEL = /^pr-([0-9a-f]{32})$/i; +export function parseProjectIdFromHost(host: string): string | null { + const firstLabel = host.split(':')[0].split('.')[0] ?? ''; + const match = HOST_LABEL.exec(firstLabel); + return match ? viewIdFromHex(match[1]) : null; // <-- same steps as Base62Converter; tested vs .NET output +} +``` + +fix(admin:auth): Profile, Preferences and Passkeys used the numeric /auth userId ("2"); /membership/auth/{id} needs the usr_ login id — done ref: F37 + where: /Users/djovaisas/Projects/norbix/admin/src/features/auth/loginId.ts (branch main after merge) +```ts +// before — admin/src/features/auth/login.tsx:76-79 (main before) +if (res.bearerToken) { + dispatch( + signedIn({ token: res.bearerToken, userId: res.userId ?? '' }), // <-- here: ServiceStack UserAuthId "2" + ); +} +``` +```csharp +// where the real id is — gateway/src/Isidos.CodeMash.Services.Api/Configure/Plugins/Auth/Gateway/GatewayAuth.cs:85-90 (refactoringV2) +CreatePayloadFilter = (payload, session) => +{ + if (session is CodeMashUserSession { CodeMashUserAuth: { } userAuth }) + { + if (!string.IsNullOrEmpty(userAuth.AuthId)) + payload["cm_auth_id"] = userAuth.AuthId; // <-- usr_… login id in the JWT +``` + +fix(admin:auth): the reset form said "a reset link is on its way" even when the call failed — done ref: F38 + where: /Users/djovaisas/Projects/norbix/admin/src/features/auth/passwordReset.tsx (branch main after merge) +```tsx +// before — admin/src/features/auth/passwordReset.tsx:25-31 (main before) +const onSubmit = async (values: { email: string }) => { + try { + await request({ email: values.email }).unwrap(); + } finally { + setSent(true); // <-- here: runs on failure too + } +}; +``` + +fix(admin:structure): the self-hosted backend called a route that does not exist — done ref: F40 + where: /Users/djovaisas/Projects/norbix/admin/app/api/structure/route.ts (branch main after merge) +```ts +// before — admin/app/api/structure/route.ts:80-84 (main before) +if (isSelfHosted) { + headers.authorization = `Bearer ${API_KEY}`; + headers['x-cm-projectid'] = projectId; + url = `${HUB_BASE}/${HUB_VERSION}/admin-portal/structure`; // <-- here: no such route +``` +```csharp +// gateway/src/Isidos.CodeMash.Gateway.Hub.Account/Project/Settings/AdminPortalStructure.cs:27-29 (refactoringV2) +[Authenticate] +[Route("/{version}/account/projects/{projectId}/admin-portal/structure", "GET", + Summary = "Admin Portal layout/structure for the project's service user")] +``` + +fix(admin:legal): publicApi read the project with resolveProjectId(), which misses custom domains (their id comes from the async host lookup) — done + where: /Users/djovaisas/Projects/norbix/admin/src/services/publicApi.ts (branch main after merge) +```ts +// before — admin/src/services/publicApi.ts:41-44 (main before) +getLegalDocument: builder.query({ + query: (kind) => ({ + url: `/public/projects/${resolveProjectId()}/legal/${kind}`, // <-- here: null on a custom domain +``` + +blocked(gateway:project:admin-url): the gateway still builds the canonical admin URL with the pr_ ViewId, so Cloud, emails (@Model.Project.AdminUrl) and the CORS seed hand out an address a browser cannot use — needs you + where: /Users/djovaisas/Projects/norbix/gateway/src/Isidos.CodeMash.Domain/ValueObjects/DomainUrl.cs:204 (branch refactoringV2) action: use projectId.HostLabel there (and fix the comment in Hub.Account.Persistent/Project.cs:46) +```csharp +// gateway/src/Isidos.CodeMash.Domain/ValueObjects/DomainUrl.cs:203-204 (refactoringV2) +var safeScheme = string.IsNullOrWhiteSpace(scheme) ? Uri.UriSchemeHttps : scheme; +return Create($"{safeScheme}://{projectId.ViewId}.{suffix}"); // <-- here: pr_, should be HostLabel +``` + +blocked(admin:preferences): on the managed service the backend has no API_KEY, so /api/bootstrap returns no structure and the page falls back to the global marketing switch — needs you + where: /Users/djovaisas/Projects/norbix/admin/app/api/bootstrap/route.ts:91 (branch main) action: gateway — carry NotificationSettingsDto in AdminPortalStructureDto / the internal structure endpoint +```ts +// admin/app/api/bootstrap/route.ts:91-97 (main) +try { + // The marketing-preference STRUCTURE rides on the project read model + // (NotificationSettings). We return only the catalog, never any user's + // values. + const projectResp = await client.hub.account.getProject({ projectId }); // <-- only with the service key + out.marketingPreferences = projectResp.item?.notificationSettings ?? null; +``` + +## Rejected / moved out +- decision(admin:privacy): hide the page behind PRIVACY_PAGE_ENABLED instead of deleting compliance.tsx / complianceService.ts — rejected (delete) — reason: the screen comes back once Cloud + gateway define the flows — new ticket: none +- decision(admin:docs): docs/architecture.md and docs/agreements.md still describe the canonical `pr_{id}.admin.{host}` — moved out — reason: they describe the gateway's current URL builder (blocked line above); change them with that gateway fix +- not mine, untouched: /Users/djovaisas/Projects/norbix/admin/node_modules points @norbix.ai/ts at ../sdks/norbix-js and has no @norbix.ai/react-redux, so `npm run typecheck` fails in the main checkout; the worktree used a clean `npm ci` + +## Needs you +- [ ] blocked(gateway:project:admin-url): canonical admin URL must use ProjectId.HostLabel — needs you · action: gateway task (DomainUrl.cs:204) +- [ ] blocked(gateway:admin-portal:structure): add the communication structure to the managed structure endpoint — needs you · action: gateway task +- [ ] blocked(gateway:compliance): /me/compliance, /export, /delete do not exist; Cloud must define the privacy flows first — needs you · action: product decision + gateway task, then set PRIVACY_PAGE_ENABLED = true +- [ ] docs(codemash-docs:admin): the public admin/* pages describe the old behaviour — needs you · action: see the list in the final report + +## Open questions +- none diff --git a/package-lock.json b/package-lock.json index 420ac7a..ee3438d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3099,16 +3099,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -3213,15 +3213,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -3230,13 +3230,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -3257,9 +3257,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -3270,13 +3270,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -3285,13 +3285,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -3300,9 +3300,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3313,13 +3313,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -3515,9 +3515,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3541,9 +3541,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3565,9 +3565,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -3585,11 +3585,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -3629,9 +3629,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "funding": [ { "type": "opencollective", @@ -3949,9 +3949,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.449", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.449.tgz", + "integrity": "sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==", "dev": true, "license": "ISC" }, @@ -4705,9 +4705,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5061,9 +5061,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -6017,9 +6017,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6301,9 +6301,9 @@ } }, "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", "dev": true, "license": "MIT", "engines": { @@ -6454,9 +6454,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -6640,20 +6640,20 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -6683,8 +6683,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, diff --git a/src/App.tsx b/src/App.tsx index 6617db3..9f1289a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { Preferences } from '@/features/preferences/preferences'; import { Privacy } from '@/features/compliance/compliance'; import { LegalDocumentPage } from '@/features/compliance/legal'; import { Dashboard, Placeholder } from '@/features/dashboard/dashboard'; +import { PRIVACY_PAGE_ENABLED } from '@/config/structure'; type BootState = | { status: 'loading' } @@ -56,7 +57,7 @@ export default function App() { let cancelled = false; const resolve = async () => { - // Resolve the project: pin → pr_ subdomain → meta → custom-domain host + // Resolve the project: pin → pr- host label → meta → custom-domain host // lookup against the managed service. Async because the custom-domain // path calls hub.norbix.ai/admin-portal-id. const projectId = await resolveProjectIdAsync(); @@ -232,16 +233,19 @@ export default function App() { } /> - - - - - - } - /> + {/* Hidden until /me/compliance* exist (PRIVACY_PAGE_ENABLED). */} + {PRIVACY_PAGE_ENABLED && ( + + + + + + } + /> + )} } /> diff --git a/src/components/layouts.tsx b/src/components/layouts.tsx index 3c50948..231f0ff 100644 --- a/src/components/layouts.tsx +++ b/src/components/layouts.tsx @@ -13,6 +13,7 @@ import { } from './icons'; import { DropdownMenu } from './ui'; import { ROUTES } from '@/routes'; +import { PRIVACY_PAGE_ENABLED } from '@/config/structure'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { selectProjectBranding } from '@/features/project/slice'; import { projectNameOf } from '@/config/projectConfig'; @@ -52,7 +53,10 @@ const NAV = [ { to: ROUTES.PROFILE, label: 'Profile', icon: UserIcon }, { to: ROUTES.SECURITY, label: 'Security', icon: ShieldIcon }, { to: ROUTES.PREFERENCES, label: 'Preferences', icon: BellIcon }, - { to: ROUTES.PRIVACY, label: 'Privacy', icon: DocCheckIcon }, + // Hidden until the compliance endpoints exist (see PRIVACY_PAGE_ENABLED). + ...(PRIVACY_PAGE_ENABLED + ? [{ to: ROUTES.PRIVACY, label: 'Privacy', icon: DocCheckIcon }] + : []), ]; export function AppLayout({ children }: { children: ReactNode }) { diff --git a/src/config/project.test.ts b/src/config/project.test.ts index 2a141a2..7c209e7 100644 --- a/src/config/project.test.ts +++ b/src/config/project.test.ts @@ -1,17 +1,54 @@ import { describe, it, expect } from 'vitest'; -import { parseProjectIdFromHost } from './project'; +import { parseProjectIdFromHost, viewIdFromHex } from './project'; + +// Reference pairs produced by .NET with the gateway's own steps +// (Guid.ParseExact(hex, "N") → Base62Converter.ToBase62String), so the TS +// conversion is checked against the real encoder, not against itself. +const DOTNET_PAIRS: [string, string][] = [ + ['4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2', 'pr_5R4dlqJeXx943tOzSDEwbS'], + ['0a1b2c3d4e5f40718293a4b5c6d7e8f9', 'pr_7bZUvoXYZY0zZaCbVwzqcX'], + ['00000000000040008000000000000001', 'pr_1szWVIyZFBV8Hfv8V3Y3c'], + ['ffffffffffff4fffbfffffffffffffff', 'pr_7n42DGM5TfOoPIflrkH0vv'], +]; + +describe('viewIdFromHex', () => { + it.each(DOTNET_PAIRS)('%s → %s (same as the gateway)', (hex, viewId) => { + expect(viewIdFromHex(hex)).toBe(viewId); + }); +}); describe('parseProjectIdFromHost', () => { - it('extracts the base62 id from a pr_ subdomain', () => { - expect(parseProjectIdFromHost('pr_7Hk2.admin.norbix.ai')).toBe('7Hk2'); + it('reads the pr- host label (ProjectId.HostLabel)', () => { + expect( + parseProjectIdFromHost( + 'pr-4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai', + ), + ).toBe('pr_5R4dlqJeXx943tOzSDEwbS'); }); - it('returns null for a bare admin host (no project)', () => { - expect(parseProjectIdFromHost('admin.norbix.ai')).toBeNull(); + it('accepts any case and a port, like ProjectId.TryParseFromHost', () => { + expect( + parseProjectIdFromHost( + 'PR-4C0E7B1A8F9D4E2AA1B3C5D7E9F0A1B2.admin.localhost:3100', + ), + ).toBe('pr_5R4dlqJeXx943tOzSDEwbS'); }); - it('returns null when the first label is not a pr_ prefix', () => { - expect(parseProjectIdFromHost('cloud.norbix.ai')).toBeNull(); + it('no longer reads the pr_ form (browsers lower-case it)', () => { + expect(parseProjectIdFromHost('pr_7hk2.admin.norbix.ai')).toBeNull(); + }); + + it('rejects a label with the wrong length or non-hex digits', () => { + expect(parseProjectIdFromHost('pr-4c0e7b1a.admin.norbix.ai')).toBeNull(); + expect( + parseProjectIdFromHost( + 'pr-zz0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2.admin.norbix.ai', + ), + ).toBeNull(); + }); + + it('returns null for a bare admin host (no project)', () => { + expect(parseProjectIdFromHost('admin.norbix.ai')).toBeNull(); }); it('handles single-label hosts (localhost) as no project', () => { diff --git a/src/config/project.ts b/src/config/project.ts index 5b2be28..c3f219f 100644 --- a/src/config/project.ts +++ b/src/config/project.ts @@ -3,11 +3,12 @@ // Resolution order (first hit wins): // 1. Build-time pin (VITE_ADMIN_PROJECT_ID) — self-hosted / custom-domain // builds. No network call: you set it, the portal knows its project. -// 2. The pr_{base62} subdomain prefix — managed multi-tenant -// (pr_x.admin.norbix.ai). Extracted from the hostname. +// 2. The managed host label — pr-{32 lower-case hex}.admin.norbix.ai, the +// gateway's ProjectId.HostLabel (DNS-safe: no '_', one case). It is turned +// back into the pr_{base62} id (ProjectId.ViewId) the gateway reads. // 3. A tag, if an edge/host injected one. // 4. Custom domain (e.g. admin.laimingaspilvukas.lt): the host carries no -// pr_ id, so ASK the managed service — GET hub.norbix.ai/{v}/admin-portal-id +// pr- label, so ASK the managed service — GET hub.norbix.ai/{v}/admin-portal-id // ?host= → { projectId } or 404. Only the managed-service Hub answers // (it owns the host→project map); self-hosted Hubs do not. This is async, // so it lives in resolveProjectIdAsync. @@ -16,7 +17,11 @@ import { NORBIX_HUB_URL } from '@norbix.ai/ts'; import { PINNED_PROJECT_ID } from './env'; -const PR_PREFIX = /^pr_([0-9A-Za-z]+)$/; +// ProjectId.HostLabel: "pr-" + the Guid as 32 hex digits ("N" format). The +// gateway parses it case-insensitively (ProjectId.TryParseHostLabel); browsers +// lower-case the host anyway. The older pr_{base62} host form cannot work in a +// browser: '_' is not a legal host character and lower-casing breaks base62. +const HOST_LABEL = /^pr-([0-9a-f]{32})$/i; /** * The request headers that carry the project to the gateway. The API host's @@ -42,10 +47,41 @@ export function setProjectHeaders( // /admin-portal-id. Resolution by custom domain is a managed-service feature. const MANAGED_SERVICE_HUB_ROOT = `${NORBIX_HUB_URL}/v3`; +const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +/** + * Turn the 32 hex digits of a Guid into the gateway's `pr_{base62}` view id — + * the same steps as IdUtility.GenerateId / Base62Converter.ToBase62String: + * .NET Guid.ToByteArray() order (the first three groups little-endian), read + * as one unsigned little-endian number, written in base62. + */ +export function viewIdFromHex(hex: string): string { + const h = hex.toLowerCase(); + const byteAt = (i: number) => h.slice(i * 2, i * 2 + 2); + // Display order → ToByteArray order: reverse bytes 0-3, 4-5 and 6-7. + const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15]; + // Little-endian: the last byte of the array is the most significant. + let value = 0n; + for (let i = order.length - 1; i >= 0; i--) { + value = (value << 8n) | BigInt(parseInt(byteAt(order[i]), 16)); + } + let out = ''; + while (value > 0n) { + out = BASE62[Number(value % 62n)] + out; + value /= 62n; + } + return `pr_${out}`; +} + +/** + * Read the project from a managed host (`pr-.admin.norbix.ai`, any case, + * optional port). Returns the `pr_{base62}` view id, or null when the first + * label is not a host label. + */ export function parseProjectIdFromHost(host: string): string | null { - const firstLabel = host.split('.')[0] ?? ''; - const match = PR_PREFIX.exec(firstLabel); - return match ? match[1] : null; + const firstLabel = host.split(':')[0].split('.')[0] ?? ''; + const match = HOST_LABEL.exec(firstLabel); + return match ? viewIdFromHex(match[1]) : null; } function projectFromMetaTag(): string | null { @@ -55,7 +91,7 @@ function projectFromMetaTag(): string | null { return content && content.length > 0 ? content : null; } -/** Synchronous resolution: pin → subdomain → meta tag. No network. */ +/** Synchronous resolution: pin → host label → meta tag. No network. */ export function resolveProjectId(host?: string): string | null { if (PINNED_PROJECT_ID) return PINNED_PROJECT_ID; diff --git a/src/config/structure.test.ts b/src/config/structure.test.ts new file mode 100644 index 0000000..3709dce --- /dev/null +++ b/src/config/structure.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_NAV_CARDS, navCardsFromStructure } from './structure'; +import { ROUTES } from '@/routes'; +import type { AdminPortalStructure } from '@/services/portalApi'; + +const structure = (keys: string[]): AdminPortalStructure => + ({ + projectId: 'pr_x', + adminPortalEnabled: true, + displayName: '', + modules: keys.map((key) => ({ key, displayName: key, enabled: true })), + }) as AdminPortalStructure; + +describe('navCardsFromStructure', () => { + it('hides the Privacy & data card while the compliance API is missing', () => { + const cards = navCardsFromStructure( + structure(['profile', 'security', 'preferences', 'legal']), + ); + expect(cards.map((c) => c.to)).toEqual([ + ROUTES.PROFILE, + ROUTES.SECURITY, + ROUTES.PREFERENCES, + ]); + }); + + it('has no Privacy & data card in the default layout either', () => { + expect(DEFAULT_NAV_CARDS.map((c) => c.to)).not.toContain(ROUTES.PRIVACY); + }); + + it('does not promise two-factor on the Security card (not built yet)', () => { + const security = DEFAULT_NAV_CARDS.find((c) => c.to === ROUTES.SECURITY); + expect(security?.desc).toBe('Password and passkeys.'); + expect(security?.desc.toLowerCase()).not.toContain('two-factor'); + }); +}); diff --git a/src/config/structure.ts b/src/config/structure.ts index 0589ff0..56cd689 100644 --- a/src/config/structure.ts +++ b/src/config/structure.ts @@ -5,6 +5,16 @@ import { ROUTES } from '@/routes'; import type { AdminPortalStructure } from '@/services/portalApi'; +/** + * The Privacy & data page (export / delete my data) calls /me/compliance, + * /me/compliance/export and /me/compliance/delete, which the gateway does not + * have (404). Cloud must define the privacy flows first (Project Settings → + * Access / legal documents, Compliance settings); the portal then renders what + * Cloud defines. Until then the page, its nav entry and its Home card are + * hidden. The public /legal/terms and /legal/privacy pages are not affected. + */ +export const PRIVACY_PAGE_ENABLED = false; + export interface NavCard { to: string; title: string; @@ -17,13 +27,16 @@ const MODULE_ROUTES: Record = { profile: { to: ROUTES.PROFILE, desc: 'Update your contact information.' }, security: { to: ROUTES.SECURITY, - desc: 'Password and two-factor authentication.', + // Two-factor sign-in is not built yet (Membership task) — do not promise it. + desc: 'Password and passkeys.', }, preferences: { to: ROUTES.PREFERENCES, desc: 'Choose which messages you receive.', }, - legal: { to: ROUTES.PRIVACY, desc: 'Export or delete your data.' }, + ...(PRIVACY_PAGE_ENABLED + ? { legal: { to: ROUTES.PRIVACY, desc: 'Export or delete your data.' } } + : {}), }; // The layout the portal falls back to when structure can't be loaded — the @@ -36,11 +49,15 @@ export const DEFAULT_NAV_CARDS: NavCard[] = [ title: 'Preferences', desc: MODULE_ROUTES.preferences.desc, }, - { - to: ROUTES.PRIVACY, - title: 'Privacy & data', - desc: MODULE_ROUTES.legal.desc, - }, + ...(PRIVACY_PAGE_ENABLED + ? [ + { + to: ROUTES.PRIVACY, + title: 'Privacy & data', + desc: 'Export or delete your data.', + }, + ] + : []), ]; /** Turn a structure into the nav cards the dashboard renders. */ diff --git a/src/features/auth/login.tsx b/src/features/auth/login.tsx index 3bea68c..271c2b4 100644 --- a/src/features/auth/login.tsx +++ b/src/features/auth/login.tsx @@ -13,6 +13,8 @@ import { useLoginMutation } from '@/services/norbix'; import { useAppDispatch, useAppSelector } from '@/app/hooks'; import { signedIn, selectIsAuthenticated } from './slice'; import { PasskeySignInButton } from './passkeySignIn'; +import { isWebAuthnAvailable } from './webauthn'; +import { useGetLegalDocumentQuery } from '@/services/publicApi'; import { ROUTES } from '@/routes'; import type { ProjectConfig, @@ -60,6 +62,10 @@ export function Login({ config }: { config: ProjectConfig }) { const isAuthenticated = useAppSelector(selectIsAuthenticated); const { socialProviders, passkey, methods, passwordPolicy } = config.auth; + // The passkey button renders nothing without WebAuthn, so only count it when + // the browser can use it — otherwise "or continue with" would stand alone. + const showPasskey = passkey && isWebAuthnAvailable(); + const { termsUrl, privacyUrl } = useLegalLinks(config); useEffect(() => { if (isAuthenticated) navigate(ROUTES.HOME, { replace: true }); @@ -149,13 +155,13 @@ export function Login({ config }: { config: ProjectConfig }) { )} - {(socialProviders.length > 0 || passkey) && ( + {(socialProviders.length > 0 || showPasskey) && (
or continue with
- {passkey && } + {showPasskey && } {socialProviders.map((p) => (
- {(config.links.termsUrl || config.links.privacyUrl) && ( + {(termsUrl || privacyUrl) && (
- {config.links.termsUrl && ( - + {termsUrl && ( + Terms )} - {config.links.termsUrl && config.links.privacyUrl && ' · '} - {config.links.privacyUrl && ( - + {termsUrl && privacyUrl && ' · '} + {privacyUrl && ( + Privacy )} @@ -193,3 +199,28 @@ export function Login({ config }: { config: ProjectConfig }) { ); } + +/** + * Terms / Privacy links under the sign-in form. A link set in the project + * config wins; otherwise the portal's own /legal/* page is linked when Cloud + * has published that document (Project Settings → Access, "expose legal"). + */ +function useLegalLinks(config: ProjectConfig): { + termsUrl?: string; + privacyUrl?: string; +} { + const terms = useGetLegalDocumentQuery('terms', { + skip: Boolean(config.links.termsUrl), + }); + const privacy = useGetLegalDocumentQuery('privacy', { + skip: Boolean(config.links.privacyUrl), + }); + return { + termsUrl: + config.links.termsUrl ?? + (terms.data?.available ? ROUTES.LEGAL_TERMS : undefined), + privacyUrl: + config.links.privacyUrl ?? + (privacy.data?.available ? ROUTES.LEGAL_PRIVACY : undefined), + }; +} diff --git a/src/features/auth/loginId.test.ts b/src/features/auth/loginId.test.ts new file mode 100644 index 0000000..872403e --- /dev/null +++ b/src/features/auth/loginId.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { loginIdFromToken, resolveLoginId } from './loginId'; + +// A JWT with the given payload (header and signature do not matter here). +function jwt(payload: Record): string { + const b64url = (o: unknown) => + btoa(JSON.stringify(o)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `${b64url({ alg: 'HS256', typ: 'JWT' })}.${b64url(payload)}.sig`; +} + +describe('loginIdFromToken', () => { + it('reads the usr_ id from the cm_auth_id claim, not the numeric sub', () => { + const token = jwt({ sub: '2', cm_auth_id: 'usr_5R4dlqJeXx943tOzSDEwbS' }); + expect(loginIdFromToken(token)).toBe('usr_5R4dlqJeXx943tOzSDEwbS'); + }); + + it('returns null when the claim is missing or not a usr_ id', () => { + expect(loginIdFromToken(jwt({ sub: '2' }))).toBeNull(); + expect(loginIdFromToken(jwt({ cm_auth_id: '2' }))).toBeNull(); + }); + + it('returns null for a token that is not a JWT', () => { + expect(loginIdFromToken('not-a-jwt')).toBeNull(); + expect(loginIdFromToken('a.%%%.c')).toBeNull(); + expect(loginIdFromToken(null)).toBeNull(); + }); +}); + +describe('resolveLoginId', () => { + it('prefers the token claim over the /auth response userId', () => { + const token = jwt({ cm_auth_id: 'usr_abc' }); + expect(resolveLoginId(token, '2')).toBe('usr_abc'); + }); + + it('never returns the numeric ServiceStack id', () => { + expect(resolveLoginId(jwt({ sub: '2' }), '2')).toBeNull(); + }); + + it('falls back to a usr_ id the sign-in flow passed', () => { + expect(resolveLoginId('opaque', 'usr_xyz')).toBe('usr_xyz'); + }); +}); diff --git a/src/features/auth/loginId.ts b/src/features/auth/loginId.ts new file mode 100644 index 0000000..0aa71c3 --- /dev/null +++ b/src/features/auth/loginId.ts @@ -0,0 +1,52 @@ +// The signed-in user's LOGIN id — the `usr_…` Auth id the membership routes take +// (/membership/auth/{id}, /membership/auth/{id}/preferences). +// +// The /auth response's `userId` is ServiceStack's numeric UserAuthId (e.g. "2"), +// which those routes do not accept. The gateway writes the real id into the JWT +// as the `cm_auth_id` claim (GatewayAuth.cs CreatePayloadFilter), so we read it +// from the token. The token is only decoded, never trusted for anything else — +// the gateway still checks every call. + +const LOGIN_ID_PREFIX = 'usr_'; + +export function isLoginId(id: string | null | undefined): id is string { + return typeof id === 'string' && id.startsWith(LOGIN_ID_PREFIX); +} + +function decodeJwtPayload(token: string): Record | null { + const part = token.split('.')[1]; + if (!part) return null; + try { + const b64 = part.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const json = new TextDecoder().decode( + Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)), + ); + const payload: unknown = JSON.parse(json); + return payload && typeof payload === 'object' + ? (payload as Record) + : null; + } catch { + return null; + } +} + +/** The `cm_auth_id` claim of a gateway JWT, when it is a `usr_` id. */ +export function loginIdFromToken( + token: string | null | undefined, +): string | null { + if (!token) return null; + const claim = decodeJwtPayload(token)?.cm_auth_id; + return typeof claim === 'string' && isLoginId(claim) ? claim : null; +} + +/** + * The login id for a session: the token claim first, then a `usr_` id the + * sign-in flow passed along. A numeric id ("2") is never returned. + */ +export function resolveLoginId( + token: string | null | undefined, + fallback?: string | null, +): string | null { + return loginIdFromToken(token) ?? (isLoginId(fallback) ? fallback : null); +} diff --git a/src/features/auth/passkeys.tsx b/src/features/auth/passkeys.tsx index 10131c7..1b31d50 100644 --- a/src/features/auth/passkeys.tsx +++ b/src/features/auth/passkeys.tsx @@ -1,3 +1,4 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { useState } from 'react'; import { Form } from 'react-final-form'; import { Card, Button, Alert, Spinner, ConfirmDialog } from '@/components/ui'; @@ -27,7 +28,9 @@ import { createPasskey, isWebAuthnAvailable } from './webauthn'; */ export function PasskeysCard() { const userId = useAppSelector(selectUserId) ?? ''; - const { data: userData } = useGetUserQuery({ id: userId }); + // Wait for the usr_ login id; never query with an empty or numeric id. + const userArg = userId ? { id: userId } : skipToken; + const { data: userData } = useGetUserQuery(userArg); const email = userData?.user?.email ?? userData?.user?.generalInfo?.primaryEmail ?? ''; diff --git a/src/features/auth/passwordReset.tsx b/src/features/auth/passwordReset.tsx index b975f4b..75fb40e 100644 --- a/src/features/auth/passwordReset.tsx +++ b/src/features/auth/passwordReset.tsx @@ -18,15 +18,17 @@ import { import { ROUTES } from '@/routes'; export function PasswordResetRequest() { - const [request, { isLoading }] = useRequestPasswordResetMutation(); + const [request, { isLoading, isError }] = useRequestPasswordResetMutation(); const [sent, setSent] = useState(false); const branding = useAppSelector(selectProjectBranding); const onSubmit = async (values: { email: string }) => { try { await request({ email: values.email }).unwrap(); - } finally { + // Only a successful call says "sent"; a failure keeps the form open. setSent(true); + } catch { + /* surfaced via isError */ } }; @@ -45,6 +47,11 @@ export function PasswordResetRequest() { className="flex flex-col gap-4" noValidate > + {isError && ( + + Could not send the reset link. Please try again. + + )} ({ + available: { terms: false, privacy: false } as Record, +})); +const reset = vi.hoisted(() => ({ fails: false })); + +vi.mock('@/services/publicApi', () => ({ + useGetLegalDocumentQuery: (kind: 'terms' | 'privacy') => ({ + data: { kind, body: '', available: legal.available[kind] }, + }), +})); + +vi.mock('@/services/norbix', async () => { + const { useState } = await import('react'); + return { + useLoginMutation: () => [vi.fn(), { isLoading: false, isError: false }], + usePasskeyAuthenticationOptionsMutation: () => [vi.fn()], + useVerifyPasskeyAuthenticationMutation: () => [vi.fn()], + useConfirmPasswordResetMutation: () => [vi.fn(), {}], + useRequestPasswordResetMutation: () => { + const [isError, setError] = useState(false); + const request = () => ({ + unwrap: async () => { + if (reset.fails) { + setError(true); + throw new Error('500'); + } + }, + }); + return [request, { isLoading: false, isError }]; + }, + }; +}); + +const { Login } = await import('./login'); +const { PasswordResetRequest } = await import('./passwordReset'); + +const CONFIG = { + projectId: 'pr_1', + branding: { displayName: 'Shop' }, + auth: { + socialProviders: [], + passkey: true, + methods: ['email'], + passwordPolicy: { minLength: 3 }, + exposed: false, + }, + links: {}, +} as ProjectConfig; + +const renderWith = (ui: React.ReactElement) => + render( + + {ui} + , + ); + +afterEach(() => { + cleanup(); + legal.available = { terms: false, privacy: false }; + reset.fails = false; +}); + +describe('Login', () => { + it('shows no "or continue with" when passkeys are on but WebAuthn is missing', () => { + renderWith(); + expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy(); + expect(screen.queryByText('or continue with')).toBeNull(); + }); + + it('links the published Terms page and leaves out an unpublished Privacy page', () => { + legal.available = { terms: true, privacy: false }; + renderWith(); + const terms = screen.getByRole('link', { name: 'Terms' }); + expect(terms.getAttribute('href')).toBe('/legal/terms'); + expect(screen.queryByRole('link', { name: 'Privacy' })).toBeNull(); + }); + + it('a link set in the project config wins', () => { + legal.available = { terms: true, privacy: true }; + renderWith( + , + ); + expect( + screen.getByRole('link', { name: 'Privacy' }).getAttribute('href'), + ).toBe('https://x.test/p'); + }); +}); + +describe('PasswordResetRequest', () => { + const submit = () => { + fireEvent.change(screen.getByLabelText('Email'), { + target: { value: 'a@b.co' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Send reset link' })); + }; + + it('says the link is on its way only after the call succeeded', async () => { + renderWith(); + submit(); + expect(await screen.findByText(/a reset link is on its way/)).toBeTruthy(); + }); + + it('keeps the form and shows an error when the call fails', async () => { + reset.fails = true; + renderWith(); + submit(); + expect( + await screen.findByText(/Could not send the reset link/), + ).toBeTruthy(); + expect(screen.queryByText(/a reset link is on its way/)).toBeNull(); + expect(screen.getByLabelText('Email')).toBeTruthy(); + }); +}); diff --git a/src/features/auth/slice.ts b/src/features/auth/slice.ts index e9796cc..1b05e80 100644 --- a/src/features/auth/slice.ts +++ b/src/features/auth/slice.ts @@ -1,8 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '@/app/store'; +import { resolveLoginId } from './loginId'; interface AuthState { token: string | null; + /** The `usr_` login id (see loginId.ts) — never the numeric /auth userId. */ userId: string | null; } @@ -16,7 +18,10 @@ const slice = createSlice({ reducers: { signedIn(state, action: PayloadAction<{ token: string; userId: string }>) { state.token = action.payload.token; - state.userId = action.payload.userId; + state.userId = resolveLoginId( + action.payload.token, + action.payload.userId, + ); }, signedOut(state) { state.token = null; @@ -34,7 +39,9 @@ export const { signedIn, signedOut, reset } = slice.actions; export const selectIsAuthenticated = (state: RootState): boolean => Boolean(state.auth.token); +// Re-derived from the token so a session persisted before this fix (userId "2") +// also gets the usr_ id without signing in again. export const selectUserId = (state: RootState): string | null => - state.auth.userId; + resolveLoginId(state.auth.token, state.auth.userId); export default slice.reducer; diff --git a/src/features/dashboard/dashboard.tsx b/src/features/dashboard/dashboard.tsx index 356e34d..f39e72b 100644 --- a/src/features/dashboard/dashboard.tsx +++ b/src/features/dashboard/dashboard.tsx @@ -42,7 +42,7 @@ export function Placeholder() {

Norbix Admin

No project selected. Open this portal at your project address (for - example pr_xxxx.admin.norbix.ai) to continue. + example pr-<id>.admin.norbix.ai) to continue.

); diff --git a/src/features/preferences/communication.test.ts b/src/features/preferences/communication.test.ts new file mode 100644 index 0000000..8cb2ce2 --- /dev/null +++ b/src/features/preferences/communication.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCommunicationView, + isSubscribed, + setSubscribed, + textOf, + type NotificationSettings, +} from './communication'; + +const tr = (language: string, title: string, description?: string) => ({ + language, + content: { title, description }, +}); + +// What Cloud saves for a project: Marketing has a "News" group with two tags, +// Transactional an "Orders" group; "surveys" is in no group (orphaned) and +// "internal" sends nowhere. +const settings = { + channels: [ + { + channel: 'Marketing', + groups: [{ tag: 'news', tags: ['weekly', 'offers', 'internal'] }], + }, + { + channel: 'Transactional', + groups: [{ tag: 'orders', tags: ['shipping'] }], + }, + { channel: 'System', groups: [{ tag: 'sys', tags: ['security'] }] }, + ], + allGroups: [ + { + tag: 'news', + translations: [tr('en', 'News', 'What is new'), tr('lt', 'Naujienos')], + }, + { tag: 'orders', translations: [tr('en', 'Orders')] }, + ], + allTags: [ + { + tag: 'weekly', + translations: [tr('en', 'Weekly digest')], + defaultDelivery: { Email: true, Sms: false }, + }, + { + tag: 'offers', + translations: [], + defaultDelivery: { Email: true, Push: true }, + }, + { tag: 'internal', translations: [], defaultDelivery: { Email: false } }, + { + tag: 'shipping', + translations: [tr('en', 'Shipping updates')], + defaultDelivery: { Sms: true }, + }, + { tag: 'security', translations: [], defaultDelivery: { Email: true } }, + { + tag: 'surveys', + translations: [tr('en', 'Surveys')], + defaultDelivery: { Email: true }, + }, + ], +} as unknown as NotificationSettings; + +describe('buildCommunicationView', () => { + const view = buildCommunicationView(settings, 'en'); + + it('renders Marketing and Transactional, never System', () => { + expect(view.channels.map((c) => c.channel)).toEqual([ + 'Marketing', + 'Transactional', + ]); + }); + + it('keeps Cloud group order and titles, drops tags that send nowhere', () => { + const news = view.channels[0].groups[0]; + expect(news).toMatchObject({ + tag: 'news', + title: 'News', + description: 'What is new', + }); + expect(news.tags.map((t) => [t.tag, t.title, t.deliveries])).toEqual([ + ['weekly', 'Weekly digest', ['Email']], + ['offers', 'offers', ['Email', 'Push']], + ]); + }); + + it('lists tags in no group as "Other options" (Cloud\'s orphaned tags)', () => { + expect(view.otherOptions.map((t) => t.tag)).toEqual(['surveys']); + }); + + it('is empty when Cloud defined no structure', () => { + expect(buildCommunicationView(null, 'en')).toEqual({ + channels: [], + otherOptions: [], + }); + }); +}); + +describe('textOf', () => { + it('uses the user language, then English, then the tag', () => { + const def = settings.allGroups[0]; + expect(textOf(def, 'news', 'lt-LT').title).toBe('Naujienos'); + expect(textOf(def, 'news', 'de').title).toBe('News'); + expect(textOf(undefined, 'news', 'en').title).toBe('news'); + }); +}); + +describe('blockedTags', () => { + it('turning a tag off adds it under that delivery channel only', () => { + const next = setSubscribed({}, 'Email', 'offers', false); + expect(next).toEqual({ Email: ['offers'] }); + expect(isSubscribed(next, 'Email', 'offers')).toBe(false); + expect(isSubscribed(next, 'Push', 'offers')).toBe(true); + }); + + it('turning it back on removes it and drops the empty entry', () => { + const before = { Email: ['offers'], Sms: ['shipping'] }; + expect(setSubscribed(before, 'Email', 'offers', true)).toEqual({ + Sms: ['shipping'], + }); + expect(before.Email).toEqual(['offers']); // input not mutated + }); +}); diff --git a/src/features/preferences/communication.ts b/src/features/preferences/communication.ts new file mode 100644 index 0000000..788a188 --- /dev/null +++ b/src/features/preferences/communication.ts @@ -0,0 +1,172 @@ +// The project's communication structure, as Cloud defines it +// (Cloud → Project Settings → Communication Preferences), turned into what the +// end user switches on and off. +// +// Cloud's shape (NotificationSettingsDto, carried on the project read model): +// channels[] — one per CommunicationChannel (Marketing, Transactional, …) +// groups[] — { tag: group tag, tags: tag names in that group } +// allGroups[] — group definitions (translations = title / description) +// allTags[] — tag definitions (translations + defaultDelivery per delivery +// channel: Email, Sms, Push, …) +// Tags that are in no group are "orphaned"; Cloud shows them to end users as +// "Other options". +// +// The user's choice is UserMarketingPreferencesDto.blockedTags: per DELIVERY +// channel (Email, Sms, …), the tags they turned off. A tag only sends on a +// delivery channel where its defaultDelivery is on (gateway Campaign.cs), so a +// switch is shown only for those channels. Marketing also has the global +// blockAllMarketingMessages switch. + +import { CodeMashHub2 } from '@norbix.ai/ts/types/hub'; + +export type NotificationSettings = CodeMashHub2.NotificationSettingsDto; +type TagDefinitionBase = CodeMashHub2.TagDefinitionBaseDto; + +/** blockedTags as the API takes it: delivery channel → blocked tag names. */ +export type BlockedTags = Record; + +export interface TagRow { + tag: string; + title: string; + description?: string; + /** Delivery channels this tag sends on (defaultDelivery = true). */ + deliveries: string[]; +} + +export interface GroupView { + tag: string; + title: string; + description?: string; + tags: TagRow[]; +} + +export interface ChannelView { + /** CommunicationChannel: Marketing | Transactional. */ + channel: string; + groups: GroupView[]; +} + +export interface CommunicationView { + channels: ChannelView[]; + /** Tags in no group — Cloud's "Other options". */ + otherOptions: TagRow[]; +} + +// End users switch Marketing and Transactional messages — the two channels +// Cloud lets the developer structure. System messages are never optional. +const USER_CHANNELS = ['Marketing', 'Transactional']; + +/** Display text for a tag/group in `language` (then English, then the first). */ +export function textOf( + def: TagDefinitionBase | undefined, + fallbackTag: string, + language: string, +): { title: string; description?: string } { + const list = def?.translations ?? []; + const lang = language.toLowerCase().split('-')[0]; + const pick = + list.find((t) => t.language?.toLowerCase().split('-')[0] === lang) ?? + list.find((t) => t.language?.toLowerCase().startsWith('en')) ?? + list[0]; + const title = pick?.content?.title?.trim() || fallbackTag; + const description = pick?.content?.description?.trim() || undefined; + return { title, description }; +} + +function tagRow( + tag: string, + defs: Map, + language: string, +): TagRow { + const def = defs.get(tag); + const deliveries = Object.entries(def?.defaultDelivery ?? {}) + .filter(([, on]) => on) + .map(([channel]) => channel); + return { tag, ...textOf(def, tag, language), deliveries }; +} + +/** Build the view the page renders. Tags that send nowhere are left out. */ +export function buildCommunicationView( + settings: NotificationSettings | null | undefined, + language: string, +): CommunicationView { + const tagDefs = new Map((settings?.allTags ?? []).map((t) => [t.tag, t])); + const groupDefs = new Map((settings?.allGroups ?? []).map((g) => [g.tag, g])); + const channels: ChannelView[] = []; + for (const name of USER_CHANNELS) { + const ch = settings?.channels?.find((c) => c.channel === name); + if (!ch) continue; + const groups: GroupView[] = []; + for (const g of ch.groups ?? []) { + const tags = (g.tags ?? []) + .map((t) => tagRow(t, tagDefs, language)) + .filter((r) => r.deliveries.length > 0); + if (tags.length === 0) continue; + groups.push({ + tag: g.tag, + ...textOf(groupDefs.get(g.tag), g.tag, language), + tags, + }); + } + if (groups.length > 0) channels.push({ channel: name, groups }); + } + + // Tags in a group of ANY channel (System included) are not "other options". + const grouped = new Set(); + for (const ch of settings?.channels ?? []) { + for (const g of ch.groups ?? []) + (g.tags ?? []).forEach((t) => grouped.add(t)); + } + const otherOptions = (settings?.allTags ?? []) + .filter((t) => !grouped.has(t.tag)) + .map((t) => tagRow(t.tag, tagDefs, language)) + .filter((r) => r.deliveries.length > 0); + + return { channels, otherOptions }; +} + +/** True when the user receives `tag` on `delivery` (it is not blocked). */ +export function isSubscribed( + blocked: BlockedTags | undefined, + delivery: string, + tag: string, +): boolean { + return !(blocked?.[delivery] ?? []).includes(tag); +} + +/** + * The new blockedTags after switching `tag` on `delivery` (full replacement, + * as UpdateUserPreferences expects). Empty delivery entries are dropped. + */ +export function setSubscribed( + blocked: BlockedTags | undefined, + delivery: string, + tag: string, + subscribed: boolean, +): BlockedTags { + const next: BlockedTags = {}; + for (const [k, v] of Object.entries(blocked ?? {})) next[k] = [...v]; + const set = new Set(next[delivery] ?? []); + if (subscribed) set.delete(tag); + else set.add(tag); + if (set.size > 0) next[delivery] = [...set]; + else delete next[delivery]; + return next; +} + +/** Readable name for a delivery channel key. */ +export const DELIVERY_LABEL: Record = { + Email: 'Email', + Sms: 'SMS', + Push: 'Push', + WebPush: 'Browser', + InApp: 'In-app', + ChatBot: 'Chat bot', + ChatPlatform: 'Chat', +}; + +/** Readable name for a communication channel tab. */ +export const CHANNEL_LABEL: Record = { + Marketing: 'Marketing', + Transactional: 'Service messages', +}; diff --git a/src/features/preferences/preferences.tsx b/src/features/preferences/preferences.tsx index 3cbb5fc..f29f1d4 100644 --- a/src/features/preferences/preferences.tsx +++ b/src/features/preferences/preferences.tsx @@ -1,58 +1,179 @@ +import { useMemo, useState } from 'react'; +import { skipToken } from '@reduxjs/toolkit/query'; import { PageHeader, Card, Toggle, Spinner, Alert } from '@/components/ui'; import { useGetUserPreferencesQuery, useUpdateUserPreferencesMutation, } from '@/services/norbix'; +import { useGetBootstrapQuery } from '@/services/portalApi'; import { useAppSelector } from '@/app/hooks'; import { selectUserId } from '@/features/auth/slice'; +import { selectSelectedProjectId } from '@/features/project/slice'; +import { + buildCommunicationView, + isSubscribed, + setSubscribed, + CHANNEL_LABEL, + DELIVERY_LABEL, + type BlockedTags, + type TagRow, +} from './communication'; + +const userLanguage = (): string => + typeof navigator !== 'undefined' ? navigator.language : 'en'; +/** + * Communication preferences. Cloud defines the structure for the whole project + * (channels → groups → tags, Project Settings → Communication Preferences); + * this page renders it so the user turns topics on or off per delivery channel. + * With no structure (none defined, or the backend has no service key) only the + * global marketing switch is shown. + */ export function Preferences() { - const userId = useAppSelector(selectUserId) ?? ''; - const { data, isLoading } = useGetUserPreferencesQuery({ id: userId }); + const userId = useAppSelector(selectUserId); + const projectId = useAppSelector(selectSelectedProjectId); + const { data, isLoading } = useGetUserPreferencesQuery( + userId ? { id: userId } : skipToken, + ); + const { data: bootstrap, isLoading: structureLoading } = useGetBootstrapQuery( + projectId ? { projectId } : skipToken, + ); const [update, { isError }] = useUpdateUserPreferencesMutation(); + const view = useMemo( + () => + buildCommunicationView(bootstrap?.marketingPreferences, userLanguage()), + [bootstrap], + ); + const [selected, setSelected] = useState(undefined); + const channel = + view.channels.find((c) => c.channel === selected) ?? view.channels[0]; + const prefs = data?.preferences; const blockAll = prefs?.blockAllMarketingMessages ?? false; + const blocked = (prefs?.blockedTags ?? {}) as BlockedTags; + + const save = (next: { blockAll?: boolean; blocked?: BlockedTags }) => { + if (!userId) return; + void update({ + id: userId, + blockAllMarketingMessages: next.blockAll ?? blockAll, + blockedTags: next.blocked ?? blocked, + }); + }; + + const tagRows = (rows: TagRow[], disabled: boolean) => + rows.map((row) => ( +
+
+
{row.title}
+ {row.description && ( +
{row.description}
+ )} +
+
+ {row.deliveries.map((d) => ( + + save({ blocked: setSubscribed(blocked, d, row.tag, on) }) + } + /> + ))} +
+
+ )); + + const loading = isLoading || structureLoading || !data; + const isMarketing = channel?.channel === 'Marketing'; return ( <> - - {isLoading || !data ? ( + {loading ? ( + - ) : ( -
- {isError && ( - Could not update preferences. - )} -
-
-
- Marketing messages -
-
- Receive promotional emails and announcements. Transactional - service messages are always sent. + + ) : ( +
+ {isError && Could not update preferences.} + + {view.channels.length > 1 && ( +
+ {view.channels.map((c) => ( + + ))} +
+ )} + + {(isMarketing || view.channels.length === 0) && ( + +
+
+
+ All marketing messages +
+
+ Turn off to stop every promotional message. Service messages + about your account are still sent. +
+ {/* "subscribed" = NOT blocked. */} + save({ blockAll: !subscribed })} + />
- {/* Toggle is "subscribed" = NOT blocked. */} - - update({ - id: userId, - blockAllMarketingMessages: !subscribed, - blockedTags: prefs?.blockedTags, - }) - } - /> -
-
- )} - + + )} + + {channel?.groups.map((group) => ( + +

{group.title}

+ {group.description && ( +

+ {group.description} +

+ )} +
+ {tagRows(group.tags, isMarketing && blockAll)} +
+
+ ))} + + {view.otherOptions.length > 0 && ( + +

Other options

+
+ {tagRows(view.otherOptions, false)} +
+
+ )} +
+ )} ); } diff --git a/src/features/profile/profile.tsx b/src/features/profile/profile.tsx index 6919d35..96f492c 100644 --- a/src/features/profile/profile.tsx +++ b/src/features/profile/profile.tsx @@ -1,3 +1,4 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { Form, FormSpy } from 'react-final-form'; import type { FormApi } from 'final-form'; import { PageHeader, Card, Button, Alert, Spinner } from '@/components/ui'; @@ -16,7 +17,9 @@ interface Editable { export function Profile() { const userId = useAppSelector(selectUserId) ?? ''; - const { data, isLoading, isError } = useGetUserQuery({ id: userId }); + // Wait for the usr_ login id; never query with an empty or numeric id. + const userArg = userId ? { id: userId } : skipToken; + const { data, isLoading, isError } = useGetUserQuery(userArg); const generalInfo = data?.user?.generalInfo; return ( diff --git a/src/services/portalApi.ts b/src/services/portalApi.ts index 0229eb2..80f737d 100644 --- a/src/services/portalApi.ts +++ b/src/services/portalApi.ts @@ -14,10 +14,20 @@ import { CodeMashHub2 } from '@norbix.ai/ts/types/hub'; export type AdminPortalModule = CodeMashHub2.AdminPortalModuleDto; export type AdminPortalStructure = CodeMashHub2.AdminPortalStructureDto; +/** + * The part of GET /api/bootstrap the screens use: the communication structure + * Cloud defines (project NotificationSettings), read server-side with the + * service-user key. Null when the backend has no key or the read failed. + */ +export interface PortalBootstrap { + marketingPreferences: CodeMashHub2.NotificationSettingsDto | null; + warnings: string[]; +} + export const portalApi = createApi({ reducerPath: 'portalApi', baseQuery: fetchBaseQuery({ baseUrl: '/api' }), - tagTypes: ['Structure'], + tagTypes: ['Structure', 'Bootstrap'], endpoints: (builder) => ({ // GET /api/structure?projectId=… — the backend reads the real structure // server-side and always returns a usable layout. The backend itself decides @@ -32,7 +42,18 @@ export const portalApi = createApi({ { type: 'Structure', id: arg.projectId }, ], }), + // GET /api/bootstrap?projectId=… — privileged catalogs (communication + // structure) fetched by the backend with the service key, never the browser. + getBootstrap: builder.query({ + query: ({ projectId }) => ({ + url: `/bootstrap?projectId=${encodeURIComponent(projectId)}`, + method: 'GET', + }), + providesTags: (_result, _error, arg) => [ + { type: 'Bootstrap', id: arg.projectId }, + ], + }), }), }); -export const { useGetStructureQuery } = portalApi; +export const { useGetStructureQuery, useGetBootstrapQuery } = portalApi; diff --git a/src/services/publicApi.ts b/src/services/publicApi.ts index f9cb830..dcf8f7e 100644 --- a/src/services/publicApi.ts +++ b/src/services/publicApi.ts @@ -14,6 +14,7 @@ import { } from '@reduxjs/toolkit/query/react'; import { resolveProjectId, setProjectHeaders } from '@/config/project'; import { selectApiRoot } from '@/config/slice'; +import { selectSelectedProjectId } from '@/features/project/slice'; import type { RootState } from '@/app/store'; import type { LegalDocument } from '@/types/user'; @@ -23,7 +24,13 @@ const dynamicBaseQuery: BaseQueryFn< FetchBaseQueryError > = (args, apiArg, extra) => { const state = apiArg.getState() as RootState; - const projectId = resolveProjectId(); + // The project resolved at boot (custom domains included — their id comes + // from the async host lookup, which resolveProjectId() alone cannot see). + const projectId = selectSelectedProjectId(state) ?? resolveProjectId(); + // Endpoints write `{projectId}` in their url; fill it in here. + const fill = (u: string) => u.replace('{projectId}', projectId ?? ''); + const filled = + typeof args === 'string' ? fill(args) : { ...args, url: fill(args.url) }; const rawBaseQuery = fetchBaseQuery({ baseUrl: selectApiRoot(state), prepareHeaders: (headers) => { @@ -31,7 +38,7 @@ const dynamicBaseQuery: BaseQueryFn< return headers; }, }); - return rawBaseQuery(args, apiArg, extra); + return rawBaseQuery(filled, apiArg, extra); }; export const publicApi = createApi({ @@ -40,7 +47,7 @@ export const publicApi = createApi({ endpoints: (builder) => ({ getLegalDocument: builder.query({ query: (kind) => ({ - url: `/public/projects/${resolveProjectId()}/legal/${kind}`, + url: `/public/projects/{projectId}/legal/${kind}`, method: 'GET', }), // Normalize: the endpoint returns { kind, title?, body, available }. diff --git a/tests/e2e/branding.spec.ts-snapshots/branding-shell-chromium-darwin.png b/tests/e2e/branding.spec.ts-snapshots/branding-shell-chromium-darwin.png index 7e443c0..26f1750 100644 Binary files a/tests/e2e/branding.spec.ts-snapshots/branding-shell-chromium-darwin.png and b/tests/e2e/branding.spec.ts-snapshots/branding-shell-chromium-darwin.png differ