import { useCallback, useEffect, useState } from 'react' import { Loading, ErrorState } from '../../../components/PageState.jsx' import ProviderIcon from '../../../components/ProviderIcon.jsx' import { api } from '../../../api/client.js' // Admin config for authentication providers. Local password + TOTP is always on // (informational tab). Google/Discord are built-ins with a fixed config surface // (Enabled + Client ID + Client Secret). Custom providers use the full OIDC editor. const TABS = [ { id: 'local', label: 'Local Accounts' }, { id: 'google', label: 'Google' }, { id: 'discord', label: 'Discord' }, { id: 'custom', label: 'Custom Providers' }, ] // The redirect/callback URL to register with the provider. Mirrors the server's // redirect_uri (APP_BASE_URL + this path); shown so admins can copy it exactly. function callbackUrl(id) { return `${window.location.origin}/api/v1/auth/sso/${id}/callback` } function HealthWarning({ provider }) { if (!provider || !provider.enabled || provider.health.valid) return null return (

Enabled but incomplete (missing: {provider.health.missing.join(', ')}). Hidden from the login page until fully configured.

) } function CallbackHint({ id }) { return (
Redirect / callback URL (register this with the provider) {callbackUrl(id)}
) } // Live = enabled and healthy; Incomplete = enabled but missing/invalid config; // Disabled otherwise. function ProviderStatus({ provider: p }) { if (p.enabled && p.health.valid) return Live if (p.enabled) return Incomplete return Disabled } function Toggle({ checked, onChange, label }) { return ( ) } // ── Built-in (Google / Discord) config form ──────────────────────────────── function BuiltinForm({ provider, onSaved }) { const [enabled, setEnabled] = useState(provider.enabled) const [clientId, setClientId] = useState(provider.clientId || '') const [secret, setSecret] = useState('') const [busy, setBusy] = useState(false) const [msg, setMsg] = useState('') const [error, setError] = useState('') // Re-sync when switching between provider tabs. useEffect(() => { setEnabled(provider.enabled) setClientId(provider.clientId || '') setSecret('') setMsg('') setError('') }, [provider.id]) // eslint-disable-line react-hooks/exhaustive-deps async function save() { setBusy(true) setMsg('') setError('') try { const body = { enabled, clientId } if (secret) body.secret = secret // only send a new secret when entered await api.admin.updateAuthProvider(provider.id, body) setSecret('') setMsg('Saved.') await onSaved() } catch (err) { setError(err.message || 'Could not save.') } finally { setBusy(false) } } return (

{provider.name}

{msg && {msg}} {error && {error}}
) } // ── Local accounts (informational) ───────────────────────────────────────── function LocalInfo() { return (

Local accounts

Username & password sign-in (with optional TOTP two-factor) is always enabled and cannot be turned off — it is how you manage accounts and link SSO identities. Manage users under Users, and your own two-factor under Account.

) } // ── Custom OIDC/OAuth2 providers ──────────────────────────────────────────── const EMPTY_CUSTOM = { id: '', name: '', kind: 'oidc', enabled: false, clientId: '', secret: '', authorizeUrl: '', tokenUrl: '', userinfoUrl: '', scopes: 'openid email profile', priority: 100, } function CustomEditor({ initial, onDone, onCancel }) { const isNew = !initial.id const [f, setF] = useState(isNew ? EMPTY_CUSTOM : { ...initial, secret: '' }) const [busy, setBusy] = useState(false) const [error, setError] = useState('') const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value })) async function save() { setBusy(true) setError('') try { const body = { name: f.name, kind: f.kind, enabled: f.enabled, clientId: f.clientId, authorizeUrl: f.authorizeUrl, tokenUrl: f.tokenUrl, userinfoUrl: f.userinfoUrl, scopes: f.scopes, priority: Number(f.priority) || 100, } if (f.secret) body.secret = f.secret if (isNew) await api.admin.createAuthProvider({ id: f.id, ...body }) else await api.admin.updateAuthProvider(initial.id, body) await onDone() } catch (err) { setError(err.message || 'Could not save provider.') } finally { setBusy(false) } } return (

{isNew ? 'Add custom provider' : `Edit ${initial.name}`}

{isNew && (
)}
setF((p) => ({ ...p, enabled: v }))} label="Enabled" /> {!isNew && }
{error && {error}}
) } function CustomProviders({ items, onChanged }) { const [editing, setEditing] = useState(null) // null | 'new' | provider async function del(p) { if (!window.confirm(`Delete provider "${p.name}"? This cannot be undone.`)) return await api.admin.deleteAuthProvider(p.id) await onChanged() } return (

OAuth2 / OIDC providers (Authentik, Keycloak, Okta, Azure AD, Zitadel, …)

{!editing && ( )}
{items.length === 0 && !editing && (

No custom providers yet.

)} {items.length > 0 && (
{items.map((p) => ( ))}
Name Type Status
{p.name} {p.kind} setEditing(p)}>Edit del(p)} style={{ marginLeft: 14, color: '#d98b84' }}>Delete
)} {editing && ( setEditing(null)} onDone={async () => { setEditing(null) await onChanged() }} /> )}
) } export default function AuthProvidersAdmin() { const [providers, setProviders] = useState(null) const [error, setError] = useState('') const [tab, setTab] = useState('local') const load = useCallback(async () => { try { setProviders(await api.admin.listAuthProviders()) } catch { setError('Could not load authentication providers.') } }, []) useEffect(() => { load() }, [load]) if (error) return if (!providers) return const byId = (id) => providers.find((p) => p.id === id) const customs = providers.filter((p) => !p.builtin) return (
{TABS.map((t) => ( ))}
{tab === 'local' && } {tab === 'google' && } {tab === 'discord' && } {tab === 'custom' && }
) }