From 8f8d6d5465cd72dacdc1750af73f1e015824fb23 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 17 May 2026 20:22:14 +0100 Subject: [PATCH] feat(auth): OIDC login UI + binding management + OIDC-only mode - useAuthConfig fetches public /auth/config; LoginPage hides the password form when oidc_only and shows an SSO button when enabled. - /oidc/callback route applies the returned JWT (sign-in) or shows the link result; oidc_error surfaced on LoginPage. - UsersPage: hides password fields in OIDC-only mode; admin OIDC bind/unbind UI per user. Sidebar self-service "Link OIDC account" (non-OIDC_ONLY). - Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 5 ++ src/App.tsx | 5 +- src/components/Sidebar.tsx | 7 +++ src/hooks/useAuth.ts | 8 ++- src/hooks/useAuthConfig.ts | 76 +++++++++++++++++++++++ src/pages/LoginPage.tsx | 82 ++++++++++++++++++------- src/pages/OidcCallbackPage.tsx | 55 +++++++++++++++++ src/pages/UsersPage.tsx | 106 +++++++++++++++++++++++++++++---- src/types/index.ts | 2 + 9 files changed, 312 insertions(+), 34 deletions(-) create mode 100644 src/hooks/useAuthConfig.ts create mode 100644 src/pages/OidcCallbackPage.tsx diff --git a/Dockerfile b/Dockerfile index b9bd4a2..71cc119 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,5 +12,10 @@ 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. +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"] diff --git a/src/App.tsx b/src/App.tsx index 7b5b05f..6c3ff0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import UsersPage from '@/pages/UsersPage' import CalendarPage from '@/pages/CalendarPage' import SupportDetailPage from '@/pages/SupportDetailPage' import MeetingDetailPage from '@/pages/MeetingDetailPage' +import OidcCallbackPage from '@/pages/OidcCallbackPage' import axios from 'axios' const getStoredWizardPort = (): number | null => { @@ -35,7 +36,7 @@ type AppState = 'checking' | 'setup' | 'ready' export default function App() { const [appState, setAppState] = useState('checking') - const { user, loading, login, logout } = useAuth() + const { user, loading, login, loginWithToken, logout } = useAuth() useEffect(() => { checkInitialized() @@ -100,6 +101,7 @@ export default function App() { } /> } /> } /> + } /> } /> @@ -133,6 +135,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7227acf..1246c3f 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import api from '@/services/api' +import { useAuthConfig, oidcLinkHref } from '@/hooks/useAuthConfig' import type { User } from '@/types' interface Props { @@ -11,6 +12,7 @@ interface Props { export default function Sidebar({ user, onLogout }: Props) { const { pathname } = useLocation() const navigate = useNavigate() + const { config: authCfg } = useAuthConfig() const [unreadCount, setUnreadCount] = useState(0) useEffect(() => { @@ -64,6 +66,11 @@ export default function Sidebar({ user, onLogout }: Props) { )} + {user && authCfg.oidcEnabled && !authCfg.oidcOnly && ( + + )} ) } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 4954072..78288ce 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -44,10 +44,16 @@ export function useAuth() { await fetchUser() } + const loginWithToken = async (token: string) => { + localStorage.setItem('token', token) + setState((s) => ({ ...s, token })) + await fetchUser() + } + const logout = () => { localStorage.removeItem('token') setState({ user: null, token: null, loading: false }) } - return { ...state, login, logout } + return { ...state, login, loginWithToken, logout } } diff --git a/src/hooks/useAuthConfig.ts b/src/hooks/useAuthConfig.ts new file mode 100644 index 0000000..af0f0d2 --- /dev/null +++ b/src/hooks/useAuthConfig.ts @@ -0,0 +1,76 @@ +import { useState, useEffect } from 'react' +import api from '@/services/api' + +export interface AuthConfig { + oidcEnabled: boolean + oidcOnly: boolean + passwordLogin: boolean + oidcLoginUrl: string +} + +const DEFAULT: AuthConfig = { + oidcEnabled: false, + oidcOnly: false, + passwordLogin: true, + oidcLoginUrl: '/auth/oidc/login', +} + +let cache: AuthConfig | null = null +let inflight: Promise | null = null + +async function load(): Promise { + if (cache) return cache + if (inflight) return inflight + inflight = api + .get('/auth/config') + .then(({ data }) => { + cache = { + oidcEnabled: !!data.oidc_enabled, + oidcOnly: !!data.oidc_only, + passwordLogin: data.password_login !== false, + oidcLoginUrl: data.oidc_login_url || '/auth/oidc/login', + } + return cache + }) + .catch(() => { + // Backend unreachable / old backend without /auth/config: + // fall back to password-only so login is never fully blocked. + cache = { ...DEFAULT } + return cache + }) + .finally(() => { + inflight = null + }) + return inflight +} + +/** Absolute backend URL for full-page OIDC redirects. */ +export function oidcLoginHref(cfg: AuthConfig): string { + const base = localStorage.getItem('HF_BACKEND_BASE_URL') ?? '' + return `${base}${cfg.oidcLoginUrl}` +} + +export function oidcLinkHref(): string { + const base = localStorage.getItem('HF_BACKEND_BASE_URL') ?? '' + return `${base}/auth/oidc/link` +} + +export function useAuthConfig() { + const [config, setConfig] = useState(cache) + const [loading, setLoading] = useState(!cache) + + useEffect(() => { + let alive = true + load().then((c) => { + if (alive) { + setConfig(c) + setLoading(false) + } + }) + return () => { + alive = false + } + }, []) + + return { config: config ?? DEFAULT, loading } +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 646fec3..71fba7d 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,15 +1,30 @@ import React, { useState } from 'react' +import { useAuthConfig, oidcLoginHref } from '@/hooks/useAuthConfig' interface Props { onLogin: (username: string, password: string) => Promise } +const OIDC_ERRORS: Record = { + not_linked: 'This OIDC account is not linked to any HarborForge user. Ask an administrator to bind it first.', + exchange_failed: 'OIDC sign-in failed during token exchange. Please try again.', + no_subject: 'The identity provider did not return a subject. Sign-in aborted.', + token_rejected: 'The issued session token was rejected. Please try again.', + missing_token: 'No session token was returned. Please try again.', + link_not_allowed: 'Account linking is not allowed in this mode.', + already_bound: 'That OIDC identity is already bound to another user.', +} + export default function LoginPage({ onLogin }: Props) { + const { config, loading: cfgLoading } = useAuthConfig() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const urlError = new URLSearchParams(window.location.search).get('oidc_error') + const oidcError = urlError ? (OIDC_ERRORS[urlError] || `OIDC error: ${urlError}`) : '' + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') @@ -23,31 +38,58 @@ export default function LoginPage({ onLogin }: Props) { } } + const showPassword = !cfgLoading && config.passwordLogin && !config.oidcOnly + const showOidc = !cfgLoading && config.oidcEnabled + return (

âš“ HarborForge

Agent/Human collaborative task management platform

-
- setUsername(e.target.value)} - required - /> - setPassword(e.target.value)} - required - /> - {error &&

{error}

} - -
+ + {oidcError &&

{oidcError}

} + + {showPassword && ( +
+ setUsername(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + {error &&

{error}

} + +
+ )} + + {showPassword && showOidc && ( +

— or —

+ )} + + {showOidc && ( + + Sign in with SSO + + )} + + {cfgLoading &&

Loading sign-in options…

} + {!cfgLoading && !showPassword && !showOidc && ( +

No sign-in method is available. Check server configuration.

+ )}
) diff --git a/src/pages/OidcCallbackPage.tsx b/src/pages/OidcCallbackPage.tsx new file mode 100644 index 0000000..63e339b --- /dev/null +++ b/src/pages/OidcCallbackPage.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +interface Props { + onToken: (token: string) => Promise +} + +/** + * Lands here after the backend OIDC callback redirect. + * - sign-in: URL fragment `#token=` → apply token, go to dashboard + * - self-link: query `?oidc_linked=1` → success notice, go to /users + * - failure: query `?oidc_error=` → back to /login with the code + */ +export default function OidcCallbackPage({ onToken }: Props) { + const navigate = useNavigate() + const [msg, setMsg] = useState('Completing sign-in…') + const ran = useRef(false) + + useEffect(() => { + if (ran.current) return + ran.current = true + + const hash = new URLSearchParams(window.location.hash.replace(/^#/, '')) + const query = new URLSearchParams(window.location.search) + const token = hash.get('token') + const oidcError = query.get('oidc_error') + const linked = query.get('oidc_linked') + + if (oidcError) { + navigate(`/login?oidc_error=${encodeURIComponent(oidcError)}`, { replace: true }) + return + } + if (linked) { + setMsg('OIDC account linked. Redirecting…') + const t = setTimeout(() => navigate('/users', { replace: true }), 1200) + return () => clearTimeout(t) + } + if (token) { + onToken(token) + .then(() => navigate('/', { replace: true })) + .catch(() => navigate('/login?oidc_error=token_rejected', { replace: true })) + return + } + navigate('/login?oidc_error=missing_token', { replace: true }) + }, [navigate, onToken]) + + return ( +
+
+

âš“ HarborForge

+

{msg}

+
+
+ ) +} diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index 251a78d..c7ec9c5 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import api from '@/services/api' import { useAuth } from '@/hooks/useAuth' +import { useAuthConfig } from '@/hooks/useAuthConfig' import type { User } from '@/types' interface RoleOption { @@ -16,8 +17,13 @@ interface ApiKeyPerms { export default function UsersPage() { const { user } = useAuth() + const { config: authCfg } = useAuthConfig() + const oidcOnly = authCfg.oidcOnly + const oidcEnabled = authCfg.oidcEnabled const isAdmin = user?.is_admin === true + const [bindForm, setBindForm] = useState({ issuer: '', subject: '' }) + const [users, setUsers] = useState([]) const [roles, setRoles] = useState([]) const [loading, setLoading] = useState(true) @@ -105,17 +111,20 @@ export default function UsersPage() { } const handleCreateUser = async () => { - if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) return + if (!createForm.username.trim() || !createForm.email.trim()) return + if (!oidcOnly && !createForm.password.trim()) return setSaving(true) setMessage('') try { - const payload = { + const payload: Record = { username: createForm.username.trim(), email: createForm.email.trim(), full_name: createForm.full_name.trim() || null, - password: createForm.password, role_id: createForm.role_id ? Number(createForm.role_id) : undefined, } + if (!oidcOnly) { + payload.password = createForm.password + } const { data } = await api.post('/users', payload) const guestRole = roles.find((r) => r.name === 'guest') ?? roles[0] setCreateForm({ @@ -201,6 +210,42 @@ export default function UsersPage() { } } + const handleBindOidc = async () => { + if (!selectedUser) return + if (!bindForm.issuer.trim() || !bindForm.subject.trim()) return + setSaving(true) + setMessage('') + try { + await api.put(`/users/${selectedUser.id}/oidc-binding`, { + issuer: bindForm.issuer.trim(), + subject: bindForm.subject.trim(), + }) + setBindForm({ issuer: '', subject: '' }) + setMessage('OIDC identity bound successfully') + await fetchData() + } catch (err: any) { + setMessage(err.response?.data?.detail || 'Failed to bind OIDC identity') + } finally { + setSaving(false) + } + } + + const handleUnbindOidc = async () => { + if (!selectedUser) return + if (!confirm(`Remove the OIDC binding for ${selectedUser.username}?`)) return + setSaving(true) + setMessage('') + try { + await api.delete(`/users/${selectedUser.id}/oidc-binding`) + setMessage('OIDC binding removed') + await fetchData() + } catch (err: any) { + setMessage(err.response?.data?.detail || 'Failed to remove OIDC binding') + } finally { + setSaving(false) + } + } + if (loading) return
Loading users...
if (!isAdmin) { @@ -251,10 +296,15 @@ export default function UsersPage() { Full Name setCreateForm({ ...createForm, full_name: e.target.value })} /> - + {!oidcOnly && ( + + )} + {oidcOnly && ( +

OIDC-only mode: users are created without a password and sign in via a bound OIDC identity.

+ )} - @@ -326,10 +376,12 @@ export default function UsersPage() { Full Name setEditForm({ ...editForm, full_name: e.target.value })} /> - + {!oidcOnly && ( + + )}
Role
@@ -381,6 +433,36 @@ export default function UsersPage() { )}
)} + + {oidcEnabled && ( +
+
OIDC Binding
+ {selectedUser.oidc_subject ? ( +
+
+ issuer: {selectedUser.oidc_issuer || '—'}
+ subject: {selectedUser.oidc_subject} +
+ +
+ ) : ( +
No OIDC identity bound.
+ )} + + + +
+ )} ) : ( diff --git a/src/types/index.ts b/src/types/index.ts index 5b088b2..c392391 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,8 @@ export interface User { is_active: boolean role_id: number | null role_name: string | null + oidc_issuer?: string | null + oidc_subject?: string | null created_at: string }