'use client' import { useState, useEffect, useCallback } from 'react' import { useSession } from 'next-auth/react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { User, Mail, Server, Cloud, Shield, Check, X, AlertTriangle } from 'lucide-react' import { toast } from 'sonner' import { invalidateReference } from '@/lib/reference-data' import type { OidcStatus } from '@/lib/oidc-config' export function SettingsClient({ oidc, callbackUrl }: { oidc: OidcStatus; callbackUrl: string }) { const { data: session } = useSession() const [imapConfig, setImapConfig] = useState({ host: '', port: 993, tls: true, username: '', password: '', folder: 'INBOX' }) const [ncConfig, setNcConfig] = useState({ nextcloudUrl: '', cardDavPath: '', username: '', password: '' }) const [savingImap, setSavingImap] = useState(false) const [testingImap, setTestingImap] = useState(false) const [syncingContacts, setSyncingContacts] = useState(false) useEffect(() => { fetch('/api/settings/imap').then(r => r.json()).then(data => { if (data) setImapConfig(prev => ({ ...prev, host: data.host ?? '', port: data.port ?? 993, tls: data.tls ?? true, username: data.username ?? '', folder: data.folder ?? 'INBOX' })) }).catch(() => {}) }, []) // Every action clears its spinner in finally and tolerates non-JSON or // failed network responses, so no state can stick after an outage. const saveImap = async () => { setSavingImap(true) try { const res = await fetch('/api/settings/imap', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // tls is forced on: legacy stored configs with tls=false migrate to // verified TLS on the next save (review NET-02). body: JSON.stringify({ ...imapConfig, tls: true }), }) if (res.ok) toast.success('IMAP settings saved') else toast.error((await res.json().catch(() => null))?.error ?? 'Failed to save') } catch { toast.error('Failed to save') } finally { setSavingImap(false) } } const testImap = async () => { setTestingImap(true) try { const res = await fetch('/api/imap/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...imapConfig, tls: true }), }) const data = await res.json().catch(() => null) if (res.ok && data?.ok) toast.success('Connection successful!') else toast.error(data?.error ?? 'Connection failed') } catch { toast.error('Connection failed') } finally { setTestingImap(false) } } const syncContacts = async () => { if (!ncConfig.nextcloudUrl || !ncConfig.username || !ncConfig.password) { toast.error('Fill in Nextcloud URL, username, and password') return } setSyncingContacts(true) try { const res = await fetch('/api/contacts/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ncConfig), }) const data = await res.json().catch(() => null) if (res.ok && data?.synced !== undefined) { // Review CR-07: the sync mirrors the address book — remotely deleted // contacts are removed too (scoped to this source, never from partial // responses). Surface both counts so the policy is visible. const removed = typeof data.removed === 'number' && data.removed > 0 ? `, removed ${data.removed} deleted on the server` : '' toast.success(`Synced ${data.synced} contacts${removed}`) invalidateReference('contacts') } else toast.error(data?.error ?? 'Sync failed') } catch { toast.error('Sync failed') } finally { setSyncingContacts(false) } } return (

Settings

Configure your integrations and preferences

Profile IMAP Nextcloud OIDC
{(session?.user?.name ?? session?.user?.email ?? '?')[0]?.toUpperCase()}

{session?.user?.name ?? 'User'}

{session?.user?.email}

IMAP Configuration

) => setImapConfig(p => ({ ...p, host: e.target.value }))} placeholder="imap.example.com" />
) => setImapConfig(p => ({ ...p, port: parseInt(e.target.value) || 993 }))} />
) => setImapConfig(p => ({ ...p, username: e.target.value }))} />
) => setImapConfig(p => ({ ...p, password: e.target.value }))} />
) => setImapConfig(p => ({ ...p, folder: e.target.value }))} />
{/* Review NET-02: mailbox logins always run over verified TLS; the backend rejects insecure settings, so there is no toggle. */}

TLS is required (implicit TLS, port 993 by default)

Nextcloud / CardDAV

) => setNcConfig(p => ({ ...p, nextcloudUrl: e.target.value }))} placeholder="https://nextcloud.example.com" />
) => setNcConfig(p => ({ ...p, cardDavPath: e.target.value }))} placeholder="/remote.php/dav/addressbooks/users/USER/contacts/" />
) => setNcConfig(p => ({ ...p, username: e.target.value }))} />
) => setNcConfig(p => ({ ...p, password: e.target.value }))} />

OIDC Configuration

Authelia single sign-on, configured via environment variables (read-only)

{oidc.enabled ? : } {oidc.enabled ? <>Configured for {oidc.issuer}. Client integration is still pending. : <>Not connected. Authelia sign-in is disabled until all three variables below hold real values. Users can sign in with email and password meanwhile. Setup guide: docs/AUTHELIA.md}
{([['OIDC_ISSUER', oidc.issuerSet], ['OIDC_CLIENT_ID', oidc.clientIdSet], ['OIDC_CLIENT_SECRET', oidc.clientSecretSet]] as const).map(([name, set]) => (
{name} {set ? 'Set' : 'Not set'} {set ? : }
))}

Redirect URI to register in Authelia

{callbackUrl}

) }