Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,31 @@
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
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,
Expand Down Expand Up @@ -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-<hex>` 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

Expand Down
58 changes: 58 additions & 0 deletions app/api/structure/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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 }],
});
});
});
52 changes: 28 additions & 24 deletions app/api/structure/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=…
Expand All @@ -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).
Expand All @@ -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;
Expand Down Expand Up @@ -73,29 +74,32 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
// per-project key.
const isSelfHosted = Boolean(API_KEY);

const headers: Record<string, string> = { 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<AdminPortalStructure>;
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<string, string> = { 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<AdminPortalStructure>;
}
const structure = (await res.json()) as Partial<AdminPortalStructure>;
// Backfill a usable shape if the gateway returned a sparse/empty body.
return NextResponse.json(
{
Expand Down
18 changes: 15 additions & 3 deletions deployments/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 9 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hex>` 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-<hex>`. |

## One-paragraph summary

The Admin Portal is a single React SPA, open-source, served from
`admin.norbix.ai` (managed service) or `admin.<customer-domain>` (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.
4 changes: 2 additions & 2 deletions docs/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hex>.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
Expand Down Expand Up @@ -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-<hex>` host label → meta → managed-host lookup).

## Single Docker image

Expand Down
31 changes: 16 additions & 15 deletions docs/devops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<hex>.admin
```

### ConfigMap (`norbix-admin-config`)
Expand Down Expand Up @@ -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.<their-domain>` 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

Expand Down
Loading
Loading