diff --git a/.dockerignore b/.dockerignore
index b6c9487..57f87fa 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,7 +1,67 @@
+# Build context for deployments/Dockerfile (`COPY . .` in the build stage).
+# The image is PUBLIC (community-admin), so keep secrets, local state, test
+# artefacts and anything the `next build` does not need out of the context.
+# Needed by the build: package*.json, next.config.mjs, tsconfig*.json,
+# next-env.d.ts, postcss/tailwind config, app/, src/, public/, config/.
+
+# Dependencies / build output (rebuilt inside the image)
node_modules
dist
+.next
+out
+build
+*.tsbuildinfo
+
+# VCS / CI / editor / OS
.git
+.gitignore
+.github
+.vscode
+.idea
+*.swp
+.DS_Store
+Thumbs.db
+
+# Env files and local state — every variant (the build reads none of them;
+# runtime config is passed with `docker run -e` / k8s env)
.env
+.env.*
*.local
-.DS_Store
+.local
+.claude
+.cursor
+
+# Keys and certificates
+*.pem
+*.key
+*.p8
+*.p12
+*.pfx
+*.crt
+*.cer
+id_rsa*
+*.keystore
+
+# Tests and test artefacts (the image runs no tests)
+tests
+test-results
+playwright-report
+blob-report
+coverage
+.nyc_output
+**/*.test.ts
+**/*.test.tsx
+vitest.config.ts
+
+# Vite-era tooling not used by `next build`
+vite.config.ts
+index.html
+tsconfig.node.json
+
+# Docs, logs and Docker files themselves
docs
+*.md
+*.log
+npm-debug.log*
+deployments
+.dockerignore
diff --git a/.env.example b/.env.example
index 4c3a605..5f7949d 100644
--- a/.env.example
+++ b/.env.example
@@ -33,6 +33,12 @@ API_BASE_URL=https://api.norbix.ai
HUB_VERSION=v3
API_VERSION=v3
+# Self-hosted: the project this portal serves (pr_{base62} or pr-{32 hex}).
+# Read at RUNTIME by the server and emitted as ,
+# so a published image needs no rebuild per customer. Unset = managed (the
+# project comes from the pr- host label).
+PROJECT_ID=
+
# Optional Norbix environment (self-hosted only). Unset = PROD.
# Set to "staging" or "test" to target that project environment; the backend
# sends it as the norbix-env header on every request. Managed deployments
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index a511546..f40ed20 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -18,9 +18,9 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- # Build the managed-service flavor. Multi-arch push happens on tags only;
- # PRs just build to validate the Dockerfile.
- - name: Build image (managed)
+ # One build serves every edition (managed, enterprise, community): the
+ # edition is read at runtime from the Hub's /echo, not at build time.
+ - name: Build image
uses: docker/build-push-action@v6
with:
context: .
@@ -28,8 +28,6 @@ jobs:
push: false
load: true
tags: norbix/admin:ci
- build-args: |
- VITE_ADMIN_RELEASE=ManagedService
- name: Trivy image scan
uses: aquasecurity/trivy-action@0.28.0
diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml
index 81a35ed..8269e11 100644
--- a/.github/workflows/staging.yml
+++ b/.github/workflows/staging.yml
@@ -2,8 +2,10 @@ name: Staging · Build & Deploy
# Continuous deployment of the ADMIN portal to the staging box (Hostinger, k3s).
#
-# Admin ships only for managed + enterprise (there is no community-admin
-# image), so the triggers are:
+# This workflow stages managed + enterprise only. The self-hosted
+# community-admin image is the SAME build (the edition is decided at runtime
+# from the Hub's /echo `release`, never at build time) and is not staged here,
+# so the triggers are:
#
# push a tag staging-ms-v2.1.0 → build `admin` (ManagedService) → deploy to norbix-ms
# push a tag staging-ent-v2.1.0 → build `enterprise-admin` (Enterprise) → deploy to norbix-enterprise
@@ -30,7 +32,7 @@ on:
workflow_dispatch:
inputs:
release:
- description: 'Release to build & deploy (admin has no self-hosted image)'
+ description: 'Release to build & deploy (community-admin is not staged here)'
required: true
default: 'managed'
type: choice
@@ -65,7 +67,7 @@ jobs:
staging-managed-*) RELEASE=managed ; VERSION="${TAG#staging-managed-}" ;;
staging-ms-*) RELEASE=managed ; VERSION="${TAG#staging-ms-}" ;;
staging-self-hosted-*|staging-sh-*)
- echo "::notice::Admin has no self-hosted image — nothing to build for '$TAG'."
+ echo "::notice::Admin self-hosted (community-admin) is not staged by this workflow — nothing to build for '$TAG'."
echo "release=skip" >> "$GITHUB_OUTPUT"; exit 0 ;;
*) echo "::error::Tag '$TAG' must look like staging--v"; exit 1 ;;
esac
@@ -125,8 +127,9 @@ jobs:
platforms: linux/amd64
push: true
tags: ${{ steps.ref.outputs.image_ref }}
- build-args: |
- VITE_ADMIN_RELEASE=${{ needs.plan.outputs.flavor }}
+ # No edition build arg: the Dockerfile has none and the portal reads
+ # the edition at runtime (/echo `release`), so every flavour is the
+ # same build. `flavor` only scopes the cache.
cache-from: type=gha,scope=staging-${{ needs.plan.outputs.flavor }}
cache-to: type=gha,mode=max,scope=staging-${{ needs.plan.outputs.flavor }}
diff --git a/app/layout.tsx b/app/layout.tsx
index b811b12..6cbbb75 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,12 +3,23 @@
// screens keep the same theming. During the phased migration the actual UI is
// still served by the Vite app; this layout is the shell the ported screens
// will mount into.
+import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import '../src/styles.css';
+import { runtimeProjectId } from './lib/runtimeProject';
-export const metadata = {
- title: 'Admin Portal',
-};
+// Per request (the catch-all page is force-dynamic), so PROJECT_ID is read
+// from the RUNTIME server env, not baked in at build: one public image works
+// for any self-hosted customer. Emitted as , which
+// the client resolver reads (src/config/project.ts). Unset → no tag, and the
+// project comes from the host label as before (managed).
+export function generateMetadata(): Metadata {
+ const projectId = runtimeProjectId(process.env.PROJECT_ID);
+ return {
+ title: 'Admin Portal',
+ ...(projectId ? { other: { 'norbix-project': projectId } } : {}),
+ };
+}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
diff --git a/app/lib/runtimeProject.test.ts b/app/lib/runtimeProject.test.ts
new file mode 100644
index 0000000..5d015eb
--- /dev/null
+++ b/app/lib/runtimeProject.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { runtimeProjectId } from './runtimeProject';
+import { viewIdFromHex } from '@/config/project';
+
+describe('runtimeProjectId', () => {
+ it('returns null when unset or blank', () => {
+ expect(runtimeProjectId(undefined)).toBeNull();
+ expect(runtimeProjectId('')).toBeNull();
+ expect(runtimeProjectId(' ')).toBeNull();
+ });
+
+ it('keeps a pr_{base62} view id (trimmed)', () => {
+ expect(runtimeProjectId(' pr_5R4dlqJeXx943tOzSDEwbS ')).toBe(
+ 'pr_5R4dlqJeXx943tOzSDEwbS',
+ );
+ });
+
+ it('converts a pr-{hex} host label to the view id', () => {
+ const hex = '4c0e7b1a8f9d4e2aa1b3c5d7e9f0a1b2';
+ expect(runtimeProjectId(`pr-${hex}`)).toBe(viewIdFromHex(hex));
+ });
+
+ it('rejects anything else', () => {
+ expect(runtimeProjectId('my-project')).toBeNull();
+ expect(runtimeProjectId('pr_">