feature/oidc-login #12

Merged
hzhang merged 5 commits from feature/oidc-login into main 2026-05-17 21:27:55 +00:00
4 changed files with 125 additions and 12 deletions
Showing only changes of commit 782e42ac64 - Show all commits

View File

@@ -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"]

View File

@@ -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>

View File

@@ -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
View 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 {}