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_">