Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude <noreply@anthropic.com>
383 lines
15 KiB
JavaScript
383 lines
15 KiB
JavaScript
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>
|
|
)
|
|
}
|
|
|
|
// Live = enabled and healthy; Incomplete = enabled but missing/invalid config;
|
|
// Disabled otherwise.
|
|
function ProviderStatus({ provider: p }) {
|
|
if (p.enabled && p.health.valid) return <span className="sans" style={{ color: '#7fd0a4' }}>Live</span>
|
|
if (p.enabled) return <span className="sans" style={{ color: '#e0b070' }}>Incomplete</span>
|
|
return <span className="sans dim">Disabled</span>
|
|
}
|
|
|
|
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">
|
|
<ProviderStatus provider={p} />
|
|
</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>
|
|
)
|
|
}
|