345 lines
15 KiB
React
345 lines
15 KiB
React
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
import { api } from '@/api';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||
|
|
import { FieldRow, Toggle } from './adminShared';
|
||
|
|
|
||
|
|
const AUTHENTIK_ICON_URL = '/img/auth.png';
|
||
|
|
|
||
|
|
function defaultOidcRedirectUri() {
|
||
|
|
if (typeof window === 'undefined') return '';
|
||
|
|
return `${window.location.origin}/api/auth/oidc/callback`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function looksLikeOidcEndpoint(url) {
|
||
|
|
const value = String(url || '').toLowerCase();
|
||
|
|
return /\/(?:authorize|token|userinfo|jwks|certs)\/?$/.test(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function AuthMethodsCard() {
|
||
|
|
const [data, setData] = useState(null);
|
||
|
|
const [form, setForm] = useState(null);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
const [testingOidc, setTestingOidc] = useState(false);
|
||
|
|
const [oidcTest, setOidcTest] = useState(null);
|
||
|
|
|
||
|
|
const load = useCallback(async () => {
|
||
|
|
try {
|
||
|
|
const d = await api.authModeConfig();
|
||
|
|
setData(d);
|
||
|
|
setForm({
|
||
|
|
local_login_enabled: d.local_login_enabled !== false,
|
||
|
|
oidc_login_enabled: !!d.oidc_login_enabled,
|
||
|
|
oidc_provider_name: d.oidc_provider_name || 'authentik',
|
||
|
|
oidc_issuer_url: d.oidc_issuer_url || '',
|
||
|
|
oidc_client_id: d.oidc_client_id || '',
|
||
|
|
oidc_client_secret: '',
|
||
|
|
oidc_client_secret_clear: false,
|
||
|
|
oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic',
|
||
|
|
oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(),
|
||
|
|
oidc_scopes: d.oidc_scopes || 'openid email profile groups',
|
||
|
|
oidc_auto_provision: d.oidc_auto_provision !== false,
|
||
|
|
oidc_admin_group: d.oidc_admin_group || '',
|
||
|
|
oidc_default_role: d.oidc_default_role || 'user',
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
toast.error(err.message || 'Failed to load auth settings.');
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => { load(); }, [load]);
|
||
|
|
|
||
|
|
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
|
||
|
|
|
||
|
|
async function handleSave() {
|
||
|
|
setSaving(true);
|
||
|
|
try {
|
||
|
|
const d = await api.setAuthMode(form);
|
||
|
|
setData(d);
|
||
|
|
setForm({
|
||
|
|
local_login_enabled: d.local_login_enabled !== false,
|
||
|
|
oidc_login_enabled: !!d.oidc_login_enabled,
|
||
|
|
oidc_provider_name: d.oidc_provider_name || 'authentik',
|
||
|
|
oidc_issuer_url: d.oidc_issuer_url || '',
|
||
|
|
oidc_client_id: d.oidc_client_id || '',
|
||
|
|
oidc_client_secret: '',
|
||
|
|
oidc_client_secret_clear: false,
|
||
|
|
oidc_token_auth_method: d.oidc_token_auth_method || 'client_secret_basic',
|
||
|
|
oidc_redirect_uri: d.oidc_redirect_uri || defaultOidcRedirectUri(),
|
||
|
|
oidc_scopes: d.oidc_scopes || 'openid email profile groups',
|
||
|
|
oidc_auto_provision: d.oidc_auto_provision !== false,
|
||
|
|
oidc_admin_group: d.oidc_admin_group || '',
|
||
|
|
oidc_default_role: d.oidc_default_role || 'user',
|
||
|
|
});
|
||
|
|
toast.success('Auth method settings saved.');
|
||
|
|
} catch (err) {
|
||
|
|
toast.error(err.message || 'Failed to save auth method settings.');
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleTestOidc() {
|
||
|
|
setTestingOidc(true);
|
||
|
|
setOidcTest(null);
|
||
|
|
try {
|
||
|
|
const result = await api.testOidcConfig(form);
|
||
|
|
setOidcTest(result);
|
||
|
|
toast.success('authentik configuration test passed.');
|
||
|
|
} catch (err) {
|
||
|
|
const result = err.data || { ok: false, error: err.message || 'OIDC configuration test failed.' };
|
||
|
|
setOidcTest(result);
|
||
|
|
toast.error(result.error || 'OIDC configuration test failed.');
|
||
|
|
} finally {
|
||
|
|
setTestingOidc(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (loading || !form) {
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardContent className="py-8 text-center text-muted-foreground text-sm">
|
||
|
|
Loading auth settings…
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const secretAvailable = form.oidc_client_secret.trim()
|
||
|
|
? true
|
||
|
|
: form.oidc_client_secret_clear
|
||
|
|
? false
|
||
|
|
: !!data?.oidc_client_secret_set;
|
||
|
|
const oidcConfigured = !!(
|
||
|
|
form.oidc_issuer_url.trim() &&
|
||
|
|
form.oidc_client_id.trim() &&
|
||
|
|
secretAvailable &&
|
||
|
|
form.oidc_redirect_uri.trim()
|
||
|
|
);
|
||
|
|
const adminGroupConfigured = !!form.oidc_admin_group.trim();
|
||
|
|
const wouldLockOut = !form.local_login_enabled && !form.oidc_login_enabled;
|
||
|
|
const cantDisableLocal = !form.local_login_enabled && (!oidcConfigured || !form.oidc_login_enabled || !adminGroupConfigured);
|
||
|
|
const oidcEnabledButIncomplete = form.oidc_login_enabled && !oidcConfigured;
|
||
|
|
const canSave = !wouldLockOut && !cantDisableLocal && !oidcEnabledButIncomplete && !saving;
|
||
|
|
const canTestOidc = oidcConfigured && !testingOidc;
|
||
|
|
const missingFields = [
|
||
|
|
!form.oidc_issuer_url.trim() && 'Issuer URL',
|
||
|
|
!form.oidc_client_id.trim() && 'Client ID',
|
||
|
|
!secretAvailable && 'Client Secret',
|
||
|
|
!form.oidc_redirect_uri.trim() && 'Redirect URI',
|
||
|
|
].filter(Boolean);
|
||
|
|
const issuerEndpointWarning = looksLikeOidcEndpoint(form.oidc_issuer_url);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-4">
|
||
|
|
<div className="flex items-center justify-between gap-3">
|
||
|
|
<div>
|
||
|
|
<CardTitle>Authentication Methods</CardTitle>
|
||
|
|
<p className="text-sm text-muted-foreground mt-1">
|
||
|
|
Control local login and authentik/OIDC. Settings are saved in the database;
|
||
|
|
environment variables only fill blank fields as bootstrap defaults.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
|
||
|
|
<CardContent className="space-y-5">
|
||
|
|
|
||
|
|
{(data?.warnings?.length > 0 || wouldLockOut || cantDisableLocal) && (
|
||
|
|
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-1">
|
||
|
|
{wouldLockOut && (
|
||
|
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||
|
|
Cannot disable all login methods; at least one must remain enabled.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{cantDisableLocal && !wouldLockOut && (
|
||
|
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||
|
|
Cannot disable local login without authentik/OIDC configured, enabled, and mapped to an admin group.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{oidcEnabledButIncomplete && (
|
||
|
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||
|
|
authentik/OIDC needs {missingFields.join(', ')} before it can be enabled.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{data?.warnings?.map((w, i) => (
|
||
|
|
<p key={i} className="text-sm text-amber-600 dark:text-amber-400">{w}</p>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<FieldRow label="Local username/password login">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<Toggle checked={form.local_login_enabled} onChange={v => set('local_login_enabled', v)} label="Enable local login" />
|
||
|
|
<span className="text-xs text-muted-foreground">{form.local_login_enabled ? 'Enabled' : 'Disabled'}</span>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="authentik / OIDC login">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<Toggle checked={form.oidc_login_enabled} onChange={v => set('oidc_login_enabled', v)} label="Enable OIDC login" />
|
||
|
|
<span className={`text-xs ${oidcConfigured ? 'text-muted-foreground' : 'text-amber-500'}`}>
|
||
|
|
{!oidcConfigured ? 'Not fully configured' : form.oidc_login_enabled ? 'Enabled' : 'Disabled'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<div className="space-y-4 pt-2 border-t border-border">
|
||
|
|
<div className="flex items-center gap-2 pt-1 text-sm font-medium text-muted-foreground">
|
||
|
|
<img src={AUTHENTIK_ICON_URL} alt="" aria-hidden="true" className="h-5 w-5 shrink-0 object-contain" />
|
||
|
|
<span>authentik / OIDC configuration</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<FieldRow label="Provider name">
|
||
|
|
<Input value={form.oidc_provider_name} onChange={e => set('oidc_provider_name', e.target.value)} placeholder="authentik" className="max-w-xs h-8 text-sm" />
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Issuer / discovery URL">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Input
|
||
|
|
value={form.oidc_issuer_url}
|
||
|
|
onChange={e => set('oidc_issuer_url', e.target.value)}
|
||
|
|
placeholder="https://yourURL.com/application/o/bills/.well-known/openid-configuration"
|
||
|
|
className="max-w-xl h-8 text-sm"
|
||
|
|
/>
|
||
|
|
<p className={issuerEndpointWarning ? 'text-xs text-amber-500' : 'text-xs text-muted-foreground'}>
|
||
|
|
Use the authentik provider issuer URL or full discovery URL, for example https://yourURL.com/application/o/bills/.well-known/openid-configuration.
|
||
|
|
</p>
|
||
|
|
{issuerEndpointWarning && (
|
||
|
|
<p className="text-xs text-amber-500">
|
||
|
|
This looks like an authorization endpoint. In authentik, copy the provider issuer or OpenID Configuration URL.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Client ID">
|
||
|
|
<Input value={form.oidc_client_id} onChange={e => set('oidc_client_id', e.target.value)} placeholder="authentik client ID" className="max-w-xl h-8 text-sm" />
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Client Secret">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="flex max-w-xl items-center gap-3">
|
||
|
|
<Input
|
||
|
|
type="password"
|
||
|
|
value={form.oidc_client_secret}
|
||
|
|
onChange={e => setForm(prev => ({
|
||
|
|
...prev,
|
||
|
|
oidc_client_secret: e.target.value,
|
||
|
|
oidc_client_secret_clear: e.target.value ? false : prev.oidc_client_secret_clear,
|
||
|
|
}))}
|
||
|
|
placeholder="Leave blank to keep existing secret"
|
||
|
|
className="h-8 text-sm"
|
||
|
|
/>
|
||
|
|
<span className={`shrink-0 text-xs ${data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'text-emerald-500' : 'text-muted-foreground'}`}>
|
||
|
|
{data?.oidc_client_secret_set && !form.oidc_client_secret_clear ? 'Secret is set' : 'No secret saved'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
|
|
<input type="checkbox" checked={form.oidc_client_secret_clear} onChange={e => set('oidc_client_secret_clear', e.target.checked)} />
|
||
|
|
Clear saved secret on save
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Client auth method">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<select
|
||
|
|
value={form.oidc_token_auth_method}
|
||
|
|
onChange={e => set('oidc_token_auth_method', e.target.value)}
|
||
|
|
className="h-8 rounded-md border border-input bg-background px-3 text-sm"
|
||
|
|
>
|
||
|
|
<option value="client_secret_basic">client_secret_basic</option>
|
||
|
|
<option value="client_secret_post">client_secret_post</option>
|
||
|
|
</select>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
Advanced. Keep client_secret_basic unless your authentik provider explicitly requires client_secret_post.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Redirect URI">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<div className="flex max-w-xl gap-2">
|
||
|
|
<Input value={form.oidc_redirect_uri} onChange={e => set('oidc_redirect_uri', e.target.value)} placeholder={defaultOidcRedirectUri()} className="h-8 text-sm" />
|
||
|
|
<Button type="button" variant="outline" size="sm" onClick={() => set('oidc_redirect_uri', defaultOidcRedirectUri())}>Use Current</Button>
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground">Add this exact URL to the Redirect URIs allowed by authentik.</p>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Scopes">
|
||
|
|
<Input value={form.oidc_scopes} onChange={e => set('oidc_scopes', e.target.value)} placeholder="openid email profile groups" className="max-w-xl h-8 text-sm" />
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Admin group">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<Input value={form.oidc_admin_group} onChange={e => set('oidc_admin_group', e.target.value)} placeholder="e.g. bill-tracker-admins" className="max-w-sm h-8 text-sm" />
|
||
|
|
<p className="text-xs text-muted-foreground">Only users in this authentik group become app admins. Admin is never granted by default.</p>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Auto-provision users">
|
||
|
|
<div className="space-y-1">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<Toggle checked={form.oidc_auto_provision} onChange={v => set('oidc_auto_provision', v)} label="Auto-provision users" />
|
||
|
|
<span className="text-xs text-muted-foreground">{form.oidc_auto_provision ? 'Enabled' : 'Disabled'}</span>
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground">When enabled, valid authentik users are created in this app on first login.</p>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
<FieldRow label="Default role">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Input value="user" readOnly className="max-w-[120px] h-8 text-sm" />
|
||
|
|
<span className="text-xs text-muted-foreground">Admin role only via admin group.</span>
|
||
|
|
</div>
|
||
|
|
</FieldRow>
|
||
|
|
|
||
|
|
{data?.oidc_env_fallback_used && (
|
||
|
|
<div className="rounded-lg border border-sky-500/25 bg-sky-500/10 px-4 py-3 text-xs text-sky-700 dark:text-sky-400">
|
||
|
|
One or more blank database fields are currently using environment fallback values. Saving values here takes precedence.
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{oidcTest && (
|
||
|
|
<div className={`rounded-lg border px-4 py-3 text-xs ${
|
||
|
|
oidcTest.ok
|
||
|
|
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||
|
|
: 'border-destructive/25 bg-destructive/10 text-destructive'
|
||
|
|
}`}>
|
||
|
|
{oidcTest.ok
|
||
|
|
? `Configuration test passed for ${oidcTest.issuer || form.oidc_issuer_url}.`
|
||
|
|
: oidcTest.error || 'Configuration test failed.'}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-2">
|
||
|
|
<Button variant="outline" onClick={handleTestOidc} disabled={!canTestOidc}>
|
||
|
|
{testingOidc ? 'Testing…' : 'Test Configuration'}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
disabled={!data?.oidc_login_enabled || !data?.oidc_configured}
|
||
|
|
onClick={() => { window.location.href = '/api/auth/oidc/login?redirect_to=/admin'; }}
|
||
|
|
>
|
||
|
|
<img src={AUTHENTIK_ICON_URL} alt="" aria-hidden="true" className="mr-2 h-4 w-4 shrink-0 object-contain" />
|
||
|
|
Test authentik Login
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSave} disabled={!canSave}>
|
||
|
|
{saving ? 'Saving…' : 'Save Auth Settings'}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|