initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
'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 (
<div className="max-w-2xl space-y-6">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">Configure your integrations and preferences</p>
</div>
<Tabs defaultValue="profile" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="profile" className="gap-1.5"><User className="h-3.5 w-3.5" /> Profile</TabsTrigger>
<TabsTrigger value="imap" className="gap-1.5"><Mail className="h-3.5 w-3.5" /> IMAP</TabsTrigger>
<TabsTrigger value="nextcloud" className="gap-1.5"><Cloud className="h-3.5 w-3.5" /> Nextcloud</TabsTrigger>
<TabsTrigger value="oidc" className="gap-1.5"><Shield className="h-3.5 w-3.5" /> OIDC</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4 mt-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-primary/20 flex items-center justify-center text-2xl font-bold text-primary">
{(session?.user?.name ?? session?.user?.email ?? '?')[0]?.toUpperCase()}
</div>
<div>
<p className="font-medium">{session?.user?.name ?? 'User'}</p>
<p className="text-sm text-muted-foreground" suppressHydrationWarning>{session?.user?.email}</p>
</div>
</div>
</TabsContent>
<TabsContent value="imap" className="space-y-4 mt-4">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><Server className="h-4 w-4" /> IMAP Configuration</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Host</label>
<Input value={imapConfig.host} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, host: e.target.value }))} placeholder="imap.example.com" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Port</label>
<Input type="number" value={imapConfig.port} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, port: parseInt(e.target.value) || 993 }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Username</label>
<Input value={imapConfig.username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, username: e.target.value }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Password</label>
<Input type="password" value={imapConfig.password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, password: e.target.value }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Folder</label>
<Input value={imapConfig.folder} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapConfig(p => ({ ...p, folder: e.target.value }))} />
</div>
<div className="flex items-end">
{/* Review NET-02: mailbox logins always run over verified TLS;
the backend rejects insecure settings, so there is no toggle. */}
<p className="text-sm text-muted-foreground">TLS is required (implicit TLS, port 993 by default)</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={testImap} variant="outline" loading={testingImap}>Test Connection</Button>
<Button onClick={saveImap} loading={savingImap}>Save IMAP Settings</Button>
</div>
</div>
</TabsContent>
<TabsContent value="nextcloud" className="space-y-4 mt-4">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><Cloud className="h-4 w-4" /> Nextcloud / CardDAV</h3>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="text-xs font-medium text-muted-foreground">Nextcloud URL</label>
<Input value={ncConfig.nextcloudUrl} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, nextcloudUrl: e.target.value }))} placeholder="https://nextcloud.example.com" />
</div>
<div className="col-span-2">
<label className="text-xs font-medium text-muted-foreground">CardDAV Path (optional)</label>
<Input value={ncConfig.cardDavPath} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, cardDavPath: e.target.value }))} placeholder="/remote.php/dav/addressbooks/users/USER/contacts/" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Username</label>
<Input value={ncConfig.username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, username: e.target.value }))} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">App Password</label>
<Input type="password" value={ncConfig.password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNcConfig(p => ({ ...p, password: e.target.value }))} />
</div>
</div>
<Button onClick={syncContacts} loading={syncingContacts}>Sync Contacts Now</Button>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-4 mt-4">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-medium flex items-center gap-2"><Shield className="h-4 w-4" /> OIDC Configuration</h3>
<p className="text-sm text-muted-foreground">Authelia single sign-on, configured via environment variables (read-only)</p>
<div className={`flex items-start gap-2 rounded-md p-3 text-sm ${oidc.enabled ? 'bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-amber-500/10 text-amber-800 dark:text-amber-300'}`}>
{oidc.enabled ? <Check className="h-4 w-4 mt-0.5 shrink-0" /> : <AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />}
<span>
{oidc.enabled
? <>Configured for <span className="font-mono break-all">{oidc.issuer}</span>. 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: <span className="font-mono">docs/AUTHELIA.md</span></>}
</span>
</div>
<div className="space-y-2">
{([['OIDC_ISSUER', oidc.issuerSet], ['OIDC_CLIENT_ID', oidc.clientIdSet], ['OIDC_CLIENT_SECRET', oidc.clientSecretSet]] as const).map(([name, set]) => (
<div key={name} className="flex items-center gap-2">
<span className="text-xs font-mono bg-muted px-2 py-1 rounded">{name}</span>
<span className="text-sm text-muted-foreground">{set ? 'Set' : 'Not set'}</span>
{set ? <Check className="h-4 w-4 text-green-500" /> : <X className="h-4 w-4 text-muted-foreground" />}
</div>
))}
</div>
<div className="space-y-1 pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground">Redirect URI to register in Authelia</p>
<p className="text-xs font-mono bg-muted px-2 py-1.5 rounded break-all" suppressHydrationWarning>{callbackUrl}</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { SettingsClient } from './client'
import { getOidcStatus } from '@/lib/oidc-config'
import { headers } from 'next/headers'
export const dynamic = 'force-dynamic'
export default async function SettingsPage() {
// The redirect URI shown for Authelia registration derives from the same
// configured origin auth.ts trusts — never from client-controllable request
// headers in production (header poisoning could hand an admin an
// attacker-hosted callback URL to register).
const envUrl = process.env.NEXTAUTH_URL || process.env.AUTH_URL || ''
let callbackUrl = '/api/auth/callback/authelia'
try {
if (envUrl) callbackUrl = `${new URL(envUrl).origin}/api/auth/callback/authelia`
} catch { /* keep the relative fallback */ }
if (!envUrl && process.env.NODE_ENV !== 'production') {
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host') ?? ''
const proto = h.get('x-forwarded-proto') ?? 'http'
if (host) callbackUrl = `${proto}://${host}/api/auth/callback/authelia`
}
return <SettingsClient oidc={getOidcStatus()} callbackUrl={callbackUrl} />
}