Add session abstraction, mobile bearer auth, and pluggable SSO
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).
Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
(create/validate/partial-TOTP/revoke), session.middleware.js
(attachSession/requireAuth/requireRole). utils/auth.js is now a thin
compat facade so existing imports are unchanged.
Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
long-lived refresh token, stored hashed and rotated on use, in a new
mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
TOTP. token.signToken gains a backward-compatible expiresIn option.
Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
identities are never auto-provisioned. Client secrets encrypted at rest
(AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
account linking (/admin/account/identities). New auth_providers +
user_identities tables.
Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
graceful with zero providers). New Authentication admin view
(Local/Google/Discord/Custom). Account page linked-accounts section.
Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import HeroEditor from './routes/admin/views/HeroEditor.jsx'
|
||||
import SettingsAdmin from './routes/admin/views/SettingsAdmin.jsx'
|
||||
import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||
|
||||
@@ -73,6 +74,7 @@ export default function App() {
|
||||
<Route path="settings" element={<SettingsAdmin />} />
|
||||
<Route path="activity" element={<ActivityAdmin />} />
|
||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||
<Route path="users" element={<UsersAdmin />} />
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
|
||||
@@ -47,6 +47,8 @@ export const api = {
|
||||
loginTotp: (challenge, code) =>
|
||||
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
|
||||
logout: () => req('/auth/logout', { method: 'POST' }),
|
||||
// Public SSO provider discovery — drives the login-page provider buttons.
|
||||
authProviders: () => req('/auth/providers'),
|
||||
|
||||
// ----- public -----
|
||||
publicSettings: () => req('/public/settings'),
|
||||
@@ -120,6 +122,16 @@ export const api = {
|
||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
|
||||
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
|
||||
|
||||
// ----- linked SSO identities (self-service) -----
|
||||
linkedIdentities: () => req('/admin/account/identities'),
|
||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||
|
||||
// ----- auth providers / SSO config (admin only) -----
|
||||
listAuthProviders: () => req('/admin/auth/providers'),
|
||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
|
||||
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
37
client/src/components/ProviderIcon.jsx
Normal file
37
client/src/components/ProviderIcon.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// Inline SVG brand icons for SSO providers. No binary assets — these scale
|
||||
// crisply at any size and keep their own brand colors. `icon` matches the
|
||||
// provider `kind` from the discovery endpoint ('google' | 'discord' | oidc/oauth2).
|
||||
// Anything unknown falls back to a neutral key glyph in the current text color.
|
||||
|
||||
function GoogleMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" aria-hidden="true" focusable="false">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscordMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="#5865F2" aria-hidden="true" focusable="false">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericMark({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProviderIcon({ icon, size = 18 }) {
|
||||
if (icon === 'google') return <GoogleMark size={size} />
|
||||
if (icon === 'discord') return <DiscordMark size={size} />
|
||||
return <GenericMark size={size} />
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const NAV = [
|
||||
{ to: '/admin/settings', label: 'Settings' },
|
||||
{ to: '/admin/activity', label: 'Activity' },
|
||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||
{ to: '/admin/auth-providers', label: 'Authentication' },
|
||||
{ to: '/admin/users', label: 'Users' },
|
||||
{ to: '/admin/account', label: 'Account' },
|
||||
]
|
||||
@@ -24,6 +25,7 @@ const TITLES = {
|
||||
'/admin/settings': 'Site Settings',
|
||||
'/admin/activity': 'Activity Log',
|
||||
'/admin/bot-activity': 'Bot Activity',
|
||||
'/admin/auth-providers': 'Authentication',
|
||||
'/admin/users': 'Users',
|
||||
'/admin/account': 'Account Security',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// Friendly copy for the ?sso_error codes the SSO callback can redirect back with.
|
||||
const SSO_ERRORS = {
|
||||
not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.',
|
||||
denied: 'Sign-in was cancelled.',
|
||||
unavailable: 'That sign-in method is not available right now.',
|
||||
bad_state: 'Your sign-in session expired. Please try again.',
|
||||
error: 'Could not complete sign-in. Please try again.',
|
||||
}
|
||||
|
||||
const BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
@@ -36,11 +47,36 @@ export default function AdminLogin() {
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
|
||||
// SSO providers to offer (empty if none configured) + any error the callback
|
||||
// bounced us back with (?sso_error=...).
|
||||
const [providers, setProviders] = useState([])
|
||||
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||
|
||||
// Already signed in → go straight to the panel.
|
||||
useEffect(() => {
|
||||
if (user) navigate(dest, { replace: true })
|
||||
}, [user, dest, navigate])
|
||||
|
||||
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
||||
// still works with password login and simply shows no provider buttons.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api
|
||||
.authProviders()
|
||||
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||
.catch(() => active && setProviders([]))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Full-page redirect into the provider's OAuth flow, preserving the intended
|
||||
// destination so the callback can return the user there.
|
||||
function startSso(provider) {
|
||||
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
|
||||
window.location.assign(provider.loginUrl + q)
|
||||
}
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
@@ -174,9 +210,9 @@ export default function AdminLogin() {
|
||||
</label>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
{error}
|
||||
{(error || (stage === 'creds' && ssoError)) && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
{error || ssoError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -188,6 +224,45 @@ export default function AdminLogin() {
|
||||
>
|
||||
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
||||
{stage === 'creds' && providers.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => startSso(p)}
|
||||
className="btn"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
padding: 11,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Continue with {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Protected area — not indexed. Sessions expire after 1 day.
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
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'
|
||||
|
||||
// Link/unlink external SSO identities to this account. Linking redirects through
|
||||
// the provider's OAuth flow (/auth/sso/:id/link) and returns here with ?linked
|
||||
// or ?link_error. Only providers that are enabled + valid can be linked.
|
||||
function LinkedAccounts() {
|
||||
const [linked, setLinked] = useState(null)
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.admin.linkedIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
setAvailable(Array.isArray(avail) ? avail : [])
|
||||
} catch {
|
||||
setError('Could not load linked accounts.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.admin.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Linked accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other SSO account so you can sign in with it. SSO can only sign in
|
||||
to an account it is linked to — linking here is what grants that access.
|
||||
</p>
|
||||
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>
|
||||
{banner.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linkable.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||
{linkable.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link`)}
|
||||
className="btn"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Link {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linked.length === 0 && linkable.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem' }}>
|
||||
No SSO providers are enabled. Configure them under <strong>Authentication</strong>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Self-service account security: enable / disable optional TOTP two-factor.
|
||||
export default function AccountAdmin() {
|
||||
const [account, setAccount] = useState(null)
|
||||
@@ -187,6 +301,8 @@ export default function AccountAdmin() {
|
||||
|
||||
{msg && <p className="sans" style={{ marginTop: 16, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
|
||||
{error && <p className="sans" style={{ marginTop: 16, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
|
||||
|
||||
<LinkedAccounts />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
380
client/src/routes/admin/views/AuthProvidersAdmin.jsx
Normal file
380
client/src/routes/admin/views/AuthProvidersAdmin.jsx
Normal file
@@ -0,0 +1,380 @@
|
||||
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 (
|
||||
<p className="sans" style={{ margin: '4px 0 0', color: '#e0b070', fontSize: '0.82rem', lineHeight: 1.5 }}>
|
||||
Enabled but incomplete (missing: {provider.health.missing.join(', ')}). Hidden from the login
|
||||
page until fully configured.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function CallbackHint({ id }) {
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span className="field-label">Redirect / callback URL (register this with the provider)</span>
|
||||
<code
|
||||
className="sans"
|
||||
style={{ display: 'block', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--line)', background: 'var(--bg-deep)', color: 'var(--muted)', fontSize: '0.82rem', wordBreak: 'break-all' }}
|
||||
>
|
||||
{callbackUrl(id)}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, label }) {
|
||||
return (
|
||||
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
{label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ maxWidth: 560, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ display: 'inline-flex', width: 26, height: 26 }}>
|
||||
<ProviderIcon icon={provider.kind} size={26} />
|
||||
</span>
|
||||
<h2 className="display" style={{ margin: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
{provider.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Toggle checked={enabled} onChange={setEnabled} label="Enable this sign-in method" />
|
||||
<HealthWarning provider={provider} />
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Client ID</span>
|
||||
<input type="text" value={clientId} onChange={(e) => setClientId(e.target.value)} className="input" autoComplete="off" />
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'block' }}>
|
||||
<span className="field-label">Client Secret</span>
|
||||
<input
|
||||
type="password"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
placeholder={provider.hasSecret ? '•••••••• configured — leave blank to keep' : 'Client secret'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<CallbackHint id={provider.id} />
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 4 }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Local accounts (informational) ─────────────────────────────────────────
|
||||
function LocalInfo() {
|
||||
return (
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
|
||||
Local accounts
|
||||
</h2>
|
||||
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||||
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
|
||||
<strong> Users</strong>, and your own two-factor under <strong>Account</strong>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 20, marginTop: 16, display: 'flex', flexDirection: 'column', gap: 14, maxWidth: 640 }}>
|
||||
<h3 className="display" style={{ margin: 0, fontSize: '1.05rem', color: 'var(--head)' }}>
|
||||
{isNew ? 'Add custom provider' : `Edit ${initial.name}`}
|
||||
</h3>
|
||||
{isNew && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">ID (slug)</span>
|
||||
<input className="input" value={f.id} onChange={set('id')} placeholder="authentik" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Type</span>
|
||||
<select className="input" value={f.kind} onChange={set('kind')}>
|
||||
<option value="oidc">OIDC</option>
|
||||
<option value="oauth2">OAuth2</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label>
|
||||
<span className="field-label">Display name</span>
|
||||
<input className="input" value={f.name} onChange={set('name')} placeholder="Authentik" />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Client ID</span>
|
||||
<input className="input" value={f.clientId} onChange={set('clientId')} autoComplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Client Secret</span>
|
||||
<input className="input" type="password" value={f.secret} onChange={set('secret')} autoComplete="new-password" placeholder={!isNew && initial.hasSecret ? '•••• leave blank to keep' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span className="field-label">Authorization URL</span>
|
||||
<input className="input" value={f.authorizeUrl} onChange={set('authorizeUrl')} placeholder="https://idp.example/application/o/authorize/" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Token URL</span>
|
||||
<input className="input" value={f.tokenUrl} onChange={set('tokenUrl')} placeholder="https://idp.example/application/o/token/" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">UserInfo URL</span>
|
||||
<input className="input" value={f.userinfoUrl} onChange={set('userinfoUrl')} placeholder="https://idp.example/application/o/userinfo/" />
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||||
<label>
|
||||
<span className="field-label">Scopes</span>
|
||||
<input className="input" value={f.scopes} onChange={set('scopes')} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Priority</span>
|
||||
<input className="input" type="number" value={f.priority} onChange={set('priority')} />
|
||||
</label>
|
||||
</div>
|
||||
<Toggle checked={f.enabled} onChange={(v) => setF((p) => ({ ...p, enabled: v }))} label="Enabled" />
|
||||
{!isNew && <CallbackHint id={initial.id} />}
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={save} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Save provider'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="pill">Cancel</button>
|
||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 12, flexWrap: 'wrap' }}>
|
||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
OAuth2 / OIDC providers (Authentik, Keycloak, Okta, Azure AD, Zitadel, …)
|
||||
</p>
|
||||
{!editing && (
|
||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">+ Add provider</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !editing && (
|
||||
<p className="sans dim" style={{ fontSize: '0.88rem' }}>No custom providers yet.</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="panel-flat">
|
||||
<table className="adm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="adm-th">Name</th>
|
||||
<th className="adm-th">Type</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="adm-td" style={{ color: 'var(--head)' }}>{p.name}</td>
|
||||
<td className="adm-td dim">{p.kind}</td>
|
||||
<td className="adm-td">
|
||||
{p.enabled && p.health.valid ? (
|
||||
<span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
|
||||
) : p.enabled ? (
|
||||
<span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
|
||||
) : (
|
||||
<span className="sans dim">Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<span className="link-accent" onClick={() => setEditing(p)}>Edit</span>
|
||||
<span className="link-accent" onClick={() => del(p)} style={{ marginLeft: 14, color: '#d98b84' }}>Delete</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<CustomEditor
|
||||
initial={editing === 'new' ? {} : editing}
|
||||
onCancel={() => setEditing(null)}
|
||||
onDone={async () => {
|
||||
setEditing(null)
|
||||
await onChanged()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <ErrorState message={error} />
|
||||
if (!providers) return <Loading />
|
||||
|
||||
const byId = (id) => providers.find((p) => p.id === id)
|
||||
const customs = providers.filter((p) => !p.builtin)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div style={{ display: 'flex', gap: 6, borderBottom: '1px solid var(--line-soft)', marginBottom: 24, flexWrap: 'wrap' }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className="sans"
|
||||
style={{
|
||||
padding: '9px 16px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
color: tab === t.id ? 'var(--head)' : 'var(--muted)',
|
||||
borderBottom: `2px solid ${tab === t.id ? 'var(--accent)' : 'transparent'}`,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'local' && <LocalInfo />}
|
||||
{tab === 'google' && <BuiltinForm provider={byId('google')} onSaved={load} />}
|
||||
{tab === 'discord' && <BuiltinForm provider={byId('discord')} onSaved={load} />}
|
||||
{tab === 'custom' && <CustomProviders items={customs} onChanged={load} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user