BillTracker/client/components/admin/AuthMethodsCard.jsx

345 lines
15 KiB
React
Raw Normal View History

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>
);
}