diff --git a/Dockerfile b/Dockerfile index 71cc119..eb3e7aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,15 @@ RUN npm install -g serve@14 WORKDIR /app COPY --from=build /app ./ ENV FRONTEND_DEV_MODE=0 -# OIDC-only mode flag. The SPA's effective behavior is driven at runtime by -# the backend's public GET /auth/config (single source of truth); this -# build/runtime arg is declared so the frontend image carries the same knob. +# OIDC-only mode flag. Injected into the SPA at container start as +# /runtime-config.js so the setup wizard knows it before the backend +# exists; /auth/config remains authoritative once the backend is up. ARG HARBORFORGE_OIDC_ONLY=false ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY} EXPOSE 3000 -CMD ["sh", "-c", "if [ \"$FRONTEND_DEV_MODE\" = \"1\" ]; then npm run dev -- --host 0.0.0.0 --port 3000 --strictPort; else serve -s dist -l 3000; fi"] +CMD ["sh", "-c", "\ + if [ \"$HARBORFORGE_OIDC_ONLY\" = \"true\" ]; then OO=true; else OO=false; fi; \ + CFG=\"window.__HF_RUNTIME__={\\\"oidc_only\\\":$OO};\"; \ + mkdir -p public; printf '%s' \"$CFG\" > public/runtime-config.js; \ + [ -d dist ] && printf '%s' \"$CFG\" > dist/runtime-config.js; \ + if [ \"$FRONTEND_DEV_MODE\" = \"1\" ]; then npm run dev -- --host 0.0.0.0 --port 3000 --strictPort; else serve -s dist -l 3000; fi"] diff --git a/index.html b/index.html index c140927..de538cb 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,9 @@
+ + diff --git a/src/pages/SetupWizardPage.tsx b/src/pages/SetupWizardPage.tsx index 1cf9542..5ffd8b5 100644 --- a/src/pages/SetupWizardPage.tsx +++ b/src/pages/SetupWizardPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import axios from 'axios' +import { getRuntimeOidcOnly } from '@/runtime' interface Props { initialWizardPort: number | null @@ -14,9 +15,18 @@ interface SetupForm { backend_base_url: string project_name: string project_description: string + oidc_enabled: boolean + oidc_issuer: string + oidc_client_id: string + oidc_client_secret: string + oidc_redirect_uri: string + oidc_scopes: string + oidc_post_login_redirect: string + oidc_admin_subject: string } -const STEPS = ['Wizard', 'Admin', 'Backend', 'Finish'] +const oidcOnly = getRuntimeOidcOnly() === true +const STEPS = ['Wizard', 'Admin', 'OIDC', 'Backend', 'Finish'] export default function SetupWizardPage({ initialWizardPort, onComplete }: Props) { const [step, setStep] = useState(0) @@ -35,9 +45,17 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props backend_base_url: '', project_name: '', project_description: '', + oidc_enabled: oidcOnly, + oidc_issuer: '', + oidc_client_id: '', + oidc_client_secret: '', + oidc_redirect_uri: '', + oidc_scopes: 'openid email profile', + oidc_post_login_redirect: '', + oidc_admin_subject: '', }) - const set = (key: keyof SetupForm, value: string | number) => + const set = (key: keyof SetupForm, value: string | number | boolean) => setForm((f) => ({ ...f, [key]: value })) const checkWizard = async () => { @@ -61,11 +79,24 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props } } + const validateOidc = (): string => { + if (!oidcOnly && !form.oidc_enabled) return '' + if (!form.oidc_issuer.trim()) return 'OIDC issuer is required' + if (!form.oidc_client_id.trim()) return 'OIDC client ID is required' + if (!form.oidc_client_secret.trim()) return 'OIDC client secret is required' + if (!form.oidc_redirect_uri.trim()) return 'OIDC redirect/callback URL is required' + if (oidcOnly && !form.oidc_admin_subject.trim()) { + return "In OIDC-only mode the admin's OIDC subject is required so the admin can sign in" + } + return '' + } + const saveConfig = async () => { setError('') setSaving(true) try { - const config = { + const includeOidc = oidcOnly || form.oidc_enabled + const config: Record = { initialized: true, admin: { username: form.admin_username, @@ -75,6 +106,18 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props }, backend_url: form.backend_base_url || undefined, } + if (includeOidc) { + config.oidc = { + enabled: true, + issuer: form.oidc_issuer.trim(), + client_id: form.oidc_client_id.trim(), + client_secret: form.oidc_client_secret, + redirect_uri: form.oidc_redirect_uri.trim(), + scopes: form.oidc_scopes.trim() || 'openid email profile', + post_login_redirect: form.oidc_post_login_redirect.trim() || undefined, + admin_subject: form.oidc_admin_subject.trim() || undefined, + } + } await axios.put(`${wizardBase}/api/v1/config/harborforge.json`, config, { headers: { 'Content-Type': 'application/json' }, @@ -85,7 +128,7 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props localStorage.setItem('HF_BACKEND_BASE_URL', form.backend_base_url) } - setStep(3) + setStep(4) } catch (err: any) { setError(`Failed to save configuration: ${err.message}`) } finally { @@ -150,6 +193,9 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props + {oidcOnly && ( +

OIDC-only deployment: this admin will sign in via OIDC; the password is kept only as a fallback identity record.

+ )}
)} - {/* Step 2: Backend */} + {/* Step 2: OIDC */} {step === 2 && ( +
+

OIDC {oidcOnly ? '(required)' : '(optional)'}

+

+ {oidcOnly + ? 'This deployment runs in OIDC-only mode — configure the identity provider now (the admin cannot reach this page again without it).' + : 'Optionally configure single sign-on. You can also do this later from the admin OIDC settings page.'} +

+ {!oidcOnly && ( + + )} + {(oidcOnly || form.oidc_enabled) && ( +
+ + + + + + + {oidcOnly && ( + + )} +

Register the Redirect / Callback URL above at your identity provider.

+
+ )} +
+ + +
+
+ )} + + {/* Step 3: Backend */} + {step === 3 && (

Backend URL

Configure the HarborForge backend API URL (leave blank to use the frontend default).

@@ -170,7 +259,7 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
- + @@ -178,8 +267,8 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
)} - {/* Step 3: Done */} - {step === 3 && ( + {/* Step 4: Done */} + {step === 4 && (

✅ Setup complete!

diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..761afa4 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,16 @@ +// Runtime config injected by the container entrypoint into +// /runtime-config.js (from the deploy-time HARBORFORGE_OIDC_ONLY env). +// Available before the backend exists — used by the setup wizard. +declare global { + interface Window { + __HF_RUNTIME__?: { oidc_only?: boolean } + } +} + +/** true/false from the injected runtime config, or null when unknown. */ +export function getRuntimeOidcOnly(): boolean | null { + const v = typeof window !== 'undefined' ? window.__HF_RUNTIME__?.oidc_only : undefined + return typeof v === 'boolean' ? v : null +} + +export {}