'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 (
Configure your integrations and preferences
{session?.user?.name ?? 'User'}
{session?.user?.email}
TLS is required (implicit TLS, port 993 by default)
Authelia single sign-on, configured via environment variables (read-only)
Redirect URI to register in Authelia
{callbackUrl}