feat(setup): OIDC step in setup wizard + runtime OIDC_ONLY flag
Solves the OIDC-only bootstrap lockout (admin can't reach the in-app OIDC settings page when password login is disabled and OIDC is unset). - Frontend image entrypoint injects /runtime-config.js from the deploy-time HARBORFORGE_OIDC_ONLY env so the SPA knows the mode before the backend exists. - Setup wizard gains an "OIDC" step (between Admin and Backend): required when OIDC-only (incl. admin's OIDC subject so the bootstrap admin can sign in), optional otherwise; written into harborforge.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
Dockerfile
13
Dockerfile
@@ -12,10 +12,15 @@ RUN npm install -g serve@14
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app ./
|
COPY --from=build /app ./
|
||||||
ENV FRONTEND_DEV_MODE=0
|
ENV FRONTEND_DEV_MODE=0
|
||||||
# OIDC-only mode flag. The SPA's effective behavior is driven at runtime by
|
# OIDC-only mode flag. Injected into the SPA at container start as
|
||||||
# the backend's public GET /auth/config (single source of truth); this
|
# /runtime-config.js so the setup wizard knows it before the backend
|
||||||
# build/runtime arg is declared so the frontend image carries the same knob.
|
# exists; /auth/config remains authoritative once the backend is up.
|
||||||
ARG HARBORFORGE_OIDC_ONLY=false
|
ARG HARBORFORGE_OIDC_ONLY=false
|
||||||
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
|
ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY}
|
||||||
EXPOSE 3000
|
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"]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<!-- Runtime config injected by the container entrypoint (deploy-time
|
||||||
|
HARBORFORGE_OIDC_ONLY). Absent in dev → app falls back to /auth/config. -->
|
||||||
|
<script src="/runtime-config.js"></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import { getRuntimeOidcOnly } from '@/runtime'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialWizardPort: number | null
|
initialWizardPort: number | null
|
||||||
@@ -14,9 +15,18 @@ interface SetupForm {
|
|||||||
backend_base_url: string
|
backend_base_url: string
|
||||||
project_name: string
|
project_name: string
|
||||||
project_description: 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) {
|
export default function SetupWizardPage({ initialWizardPort, onComplete }: Props) {
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
@@ -35,9 +45,17 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
|||||||
backend_base_url: '',
|
backend_base_url: '',
|
||||||
project_name: '',
|
project_name: '',
|
||||||
project_description: '',
|
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 }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
const checkWizard = async () => {
|
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 () => {
|
const saveConfig = async () => {
|
||||||
setError('')
|
setError('')
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const config = {
|
const includeOidc = oidcOnly || form.oidc_enabled
|
||||||
|
const config: Record<string, any> = {
|
||||||
initialized: true,
|
initialized: true,
|
||||||
admin: {
|
admin: {
|
||||||
username: form.admin_username,
|
username: form.admin_username,
|
||||||
@@ -75,6 +106,18 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
|||||||
},
|
},
|
||||||
backend_url: form.backend_base_url || undefined,
|
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, {
|
await axios.put(`${wizardBase}/api/v1/config/harborforge.json`, config, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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)
|
localStorage.setItem('HF_BACKEND_BASE_URL', form.backend_base_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
setStep(3)
|
setStep(4)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(`Failed to save configuration: ${err.message}`)
|
setError(`Failed to save configuration: ${err.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -150,6 +193,9 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
|||||||
<label>Email <input type="email" value={form.admin_email} onChange={(e) => set('admin_email', e.target.value)} placeholder="admin@example.com" /></label>
|
<label>Email <input type="email" value={form.admin_email} onChange={(e) => set('admin_email', e.target.value)} placeholder="admin@example.com" /></label>
|
||||||
<label>Full name <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label>
|
<label>Full name <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label>
|
||||||
</div>
|
</div>
|
||||||
|
{oidcOnly && (
|
||||||
|
<p className="setup-hint">OIDC-only deployment: this admin will sign in via OIDC; the password is kept only as a fallback identity record.</p>
|
||||||
|
)}
|
||||||
<div className="setup-nav">
|
<div className="setup-nav">
|
||||||
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
|
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
|
||||||
<button className="btn-primary" onClick={() => {
|
<button className="btn-primary" onClick={() => {
|
||||||
@@ -161,8 +207,51 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Backend */}
|
{/* Step 2: OIDC */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
|
<div className="setup-step-content">
|
||||||
|
<h2>OIDC {oidcOnly ? '(required)' : '(optional)'}</h2>
|
||||||
|
<p className="text-dim">
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
{!oidcOnly && (
|
||||||
|
<label className="filter-check" style={{ marginBottom: 12 }}>
|
||||||
|
<input type="checkbox" checked={form.oidc_enabled} onChange={(e) => set('oidc_enabled', e.target.checked)} />
|
||||||
|
Enable OIDC sign-in
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{(oidcOnly || form.oidc_enabled) && (
|
||||||
|
<div className="setup-form">
|
||||||
|
<label>Issuer (OIDC source) <input value={form.oidc_issuer} onChange={(e) => set('oidc_issuer', e.target.value)} placeholder="https://idp.example.com/realms/hf" /></label>
|
||||||
|
<label>Client ID <input value={form.oidc_client_id} onChange={(e) => set('oidc_client_id', e.target.value)} /></label>
|
||||||
|
<label>Client Secret <input type="password" value={form.oidc_client_secret} onChange={(e) => set('oidc_client_secret', e.target.value)} /></label>
|
||||||
|
<label>Redirect / Callback URL <input value={form.oidc_redirect_uri} onChange={(e) => set('oidc_redirect_uri', e.target.value)} placeholder="https://hf-api.example.com/auth/oidc/callback" /></label>
|
||||||
|
<label>Scopes <input value={form.oidc_scopes} onChange={(e) => set('oidc_scopes', e.target.value)} /></label>
|
||||||
|
<label>Post-login redirect (frontend) <input value={form.oidc_post_login_redirect} onChange={(e) => set('oidc_post_login_redirect', e.target.value)} placeholder="https://hf.example.com/oidc/callback" /></label>
|
||||||
|
{oidcOnly && (
|
||||||
|
<label>Admin OIDC subject (sub)
|
||||||
|
<input value={form.oidc_admin_subject} onChange={(e) => set('oidc_admin_subject', e.target.value)} placeholder="the admin's `sub` claim at the IdP" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<p className="setup-hint">Register the Redirect / Callback URL above at your identity provider.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="setup-nav">
|
||||||
|
<button className="btn-back" onClick={() => setStep(1)}>Back</button>
|
||||||
|
<button className="btn-primary" onClick={() => {
|
||||||
|
const e = validateOidc()
|
||||||
|
if (e) { setError(e); return }
|
||||||
|
setError('')
|
||||||
|
setStep(3)
|
||||||
|
}}>Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Backend */}
|
||||||
|
{step === 3 && (
|
||||||
<div className="setup-step-content">
|
<div className="setup-step-content">
|
||||||
<h2>Backend URL</h2>
|
<h2>Backend URL</h2>
|
||||||
<p className="text-dim">Configure the HarborForge backend API URL (leave blank to use the frontend default).</p>
|
<p className="text-dim">Configure the HarborForge backend API URL (leave blank to use the frontend default).</p>
|
||||||
@@ -170,7 +259,7 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
|||||||
<label>Backend Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://backend:8000" /></label>
|
<label>Backend Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://backend:8000" /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="setup-nav">
|
<div className="setup-nav">
|
||||||
<button className="btn-back" onClick={() => setStep(1)}>Back</button>
|
<button className="btn-back" onClick={() => setStep(2)}>Back</button>
|
||||||
<button className="btn-primary" onClick={saveConfig} disabled={saving}>
|
<button className="btn-primary" onClick={saveConfig} disabled={saving}>
|
||||||
{saving ? 'Saving...' : 'Finish setup'}
|
{saving ? 'Saving...' : 'Finish setup'}
|
||||||
</button>
|
</button>
|
||||||
@@ -178,8 +267,8 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Done */}
|
{/* Step 4: Done */}
|
||||||
{step === 3 && (
|
{step === 4 && (
|
||||||
<div className="setup-step-content">
|
<div className="setup-step-content">
|
||||||
<div className="setup-done">
|
<div className="setup-done">
|
||||||
<h2>✅ Setup complete!</h2>
|
<h2>✅ Setup complete!</h2>
|
||||||
|
|||||||
16
src/runtime.ts
Normal file
16
src/runtime.ts
Normal file
@@ -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 {}
|
||||||
Reference in New Issue
Block a user