'use client'; import { useEffect, useState } from 'react'; import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; interface ConfigEntry { value: unknown; source: 'admin' | 'env' | 'default'; } export function AuthTab() { const [config, setConfig] = useState>({}); const [edits, setEdits] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); useEffect(() => { fetchConfig(); }, []); async function fetchConfig() { setLoading(true); const res = await apiFetch('/api/admin/config'); if (res.ok) setConfig(await res.json()); setLoading(false); } function handleChange(key: string, value: unknown) { setEdits(prev => ({ ...prev, [key]: value })); setMessage(null); } function currentValue(key: string): unknown { if (key in edits) return edits[key]; return config[key]?.value; } async function handleSave() { if (Object.keys(edits).length === 0) return; setSaving(true); setMessage(null); const res = await apiFetch('/api/admin/config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(edits), }); if (res.ok) { setMessage({ type: 'success', text: 'Authentication settings saved.' }); setEdits({}); await fetchConfig(); } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Failed to save' }); } setSaving(false); } async function handleRevert(key: string) { const res = await apiFetch('/api/admin/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), }); if (res.ok) { setEdits(prev => { const next = { ...prev }; delete next[key]; return next; }); await fetchConfig(); } } const [setupRunning, setSetupRunning] = useState(false); const [setupOpen, setSetupOpen] = useState(false); const [setupOrigin, setSetupOrigin] = useState(''); const [setupIssuer, setSetupIssuer] = useState(''); const [setupOauthOnly, setSetupOauthOnly] = useState(false); function openSetupDialog() { if (typeof window === 'undefined') return; const origin = window.location.origin; const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || ''; setSetupOrigin(origin); setSetupIssuer(jmapUrl || origin); setSetupOauthOnly(currentValue('oauthOnly') === true); setSetupOpen(true); } async function handleAutoSetup() { setSetupRunning(true); setMessage(null); try { const res = await apiFetch('/api/admin/oauth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ origin: setupOrigin.trim().replace(/\/+$/, ''), issuerUrl: setupIssuer.trim().replace(/\/+$/, ''), oauthOnly: setupOauthOnly, }), }); const data = await res.json(); if (res.ok) { setMessage({ type: 'success', text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`, }); setEdits({}); setSetupOpen(false); await fetchConfig(); } else { const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : ''; setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail }); } } catch (err) { setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' }); } finally { setSetupRunning(false); } } const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, '')); const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, '')); const hasEdits = Object.keys(edits).length > 0; if (loading) { return
Loading...
; } return (

Authentication

OAuth, SSO, and session configuration

{hasEdits && ( )}
{message && (
{message.text}
)}

Auto-configure OAuth (Stalwart)

Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. Requires your Stalwart account to have admin permissions.

{setupOpen && (
{ if (e.target === e.currentTarget && !setupRunning) setSetupOpen(false); }} >

Auto-configure OAuth

Verify the URLs below before continuing. The webmail and Stalwart can live on different domains.

setSetupOrigin(e.target.value)} disabled={setupRunning} placeholder="https://webmail.example.com" className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />

Used to register redirect URIs (one per locale: {setupOrigin.trim().replace(/\/+$/, '') || 'https://…'}/<locale>/auth/callback) on Stalwart.

{!setupOriginValid && setupOrigin.length > 0 && (

Must be like https://host with no path.

)}
setSetupIssuer(e.target.value)} disabled={setupRunning} placeholder="https://mail.example.com" className="w-full h-9 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />

Where Stalwart serves /.well-known/oauth-authorization-server. Saved as OAUTH_ISSUER_URL. Pre-filled from your JMAP server URL.

{!setupIssuerValid && setupIssuer.length > 0 && (

Must be like https://host with no path.

)}
)}
onChange(configKey, e.target.value)} placeholder={placeholder} className="h-8 w-full sm:w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> {source === 'admin' && ( )}
); } function Toggle({ label, description, configKey, value, source, onChange, onRevert }: { label: string; description?: string; configKey: string; value: boolean; source?: string; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; }) { return (
{label}
{description &&

{description}

}
{source === 'admin' && ( )}
); } function Select({ label, configKey, value, source, options, onChange, onRevert }: { label: string; configKey: string; value: string; source?: string; options: string[]; onChange: (k: string, v: unknown) => void; onRevert: (k: string) => void; }) { return (
{label}
{source === 'admin' && ( )}
); }