feature/oidc-login #12
13
Dockerfile
13
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"]
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<string, any> = {
|
||||
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
|
||||
<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>
|
||||
</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">
|
||||
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
|
||||
<button className="btn-primary" onClick={() => {
|
||||
@@ -161,8 +207,51 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Backend */}
|
||||
{/* Step 2: OIDC */}
|
||||
{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">
|
||||
<h2>Backend URL</h2>
|
||||
<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>
|
||||
</div>
|
||||
<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}>
|
||||
{saving ? 'Saving...' : 'Finish setup'}
|
||||
</button>
|
||||
@@ -178,8 +267,8 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Done */}
|
||||
{step === 3 && (
|
||||
{/* Step 4: Done */}
|
||||
{step === 4 && (
|
||||
<div className="setup-step-content">
|
||||
<div className="setup-done">
|
||||
<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