'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import AutoTextarea from '@/components/AutoTextarea'; import { StorageSessionPrompt } from '@/components/StorageSessionPrompt'; import { useToast } from '@/lib/contexts/ToastContext'; import { useAccentColor } from '@/lib/contexts/AccentColorContext'; import { refreshStorageSession } from '@/lib/storage/client'; export default function AdminPage() { const { showToast } = useToast(); const { refreshAccentColor } = useAccentColor(); const [isAdmin, setIsAdmin] = useState(null); const [loading, setLoading] = useState(false); const [nodeSettings, setNodeSettings] = useState({ name: '', description: '', longDescription: '', rules: '', bannerUrl: '', logoUrl: '', faviconUrl: '', accentColor: '#FFFFFF', isNsfw: false, turnstileSiteKey: '', turnstileSecretKey: '', }); const [savingSettings, setSavingSettings] = useState(false); const [isUploadingBanner, setIsUploadingBanner] = useState(false); const [bannerUploadError, setBannerUploadError] = useState(null); const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false); const [pendingBannerFile, setPendingBannerFile] = useState(null); const [bannerPassword, setBannerPassword] = useState(''); const [bannerPromptError, setBannerPromptError] = useState(''); const [isRefreshingBannerSession, setIsRefreshingBannerSession] = useState(false); const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [logoUploadError, setLogoUploadError] = useState(null); const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); const [faviconUploadError, setFaviconUploadError] = useState(null); const [updateStatus, setUpdateStatus] = useState<{ current: { version: string; commit: string | null; buildDate: string | null }; latest: { version: string; commit: string | null; buildDate: string | null } | null; updateAvailable: boolean; updater: { available: boolean; status: string; message?: string; lastStartedAt?: string | null; lastFinishedAt?: string | null; lastExitCode?: number | null; lastError?: string | null; trigger?: 'manual' | 'auto' | null; config?: { autoUpdateEnabled: boolean; intervalMinutes: number; }; }; } | null>(null); const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false); const [triggeringUpdate, setTriggeringUpdate] = useState(false); const [savingAutoUpdate, setSavingAutoUpdate] = useState(false); useEffect(() => { fetch('/api/admin/me') .then((res) => res.json()) .then((data) => setIsAdmin(!!data.isAdmin)) .catch(() => setIsAdmin(false)); }, []); const loadNodeSettings = async () => { setLoading(true); try { const res = await fetch('/api/node'); const data = await res.json(); setNodeSettings({ name: data.name || '', description: data.description || '', longDescription: data.longDescription || '', rules: data.rules || '', bannerUrl: data.bannerUrl || '', logoUrl: data.logoUrl || '', faviconUrl: data.faviconUrl || '', accentColor: data.accentColor || '#FFFFFF', isNsfw: data.isNsfw || false, turnstileSiteKey: data.turnstileSiteKey || '', turnstileSecretKey: data.turnstileSecretKey || '', }); } catch { // error } finally { setLoading(false); } }; const loadUpdateStatus = async () => { setLoadingUpdateStatus(true); try { const res = await fetch('/api/admin/update', { cache: 'no-store' }); if (!res.ok) { throw new Error('Failed to load update status'); } const data = await res.json(); setUpdateStatus(data); } catch { setUpdateStatus(null); } finally { setLoadingUpdateStatus(false); } }; useEffect(() => { if (isAdmin) { loadNodeSettings(); loadUpdateStatus(); } }, [isAdmin]); useEffect(() => { if (!isAdmin) { return; } const interval = window.setInterval(() => { loadUpdateStatus(); }, 30000); return () => window.clearInterval(interval); }, [isAdmin]); const handleSaveSettings = async (override?: typeof nodeSettings) => { const payload = override ?? nodeSettings; setSavingSettings(true); try { const res = await fetch('/api/admin/node', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (res.ok) { showToast('Settings saved!', 'success'); refreshAccentColor(); } else { showToast('Failed to save settings.', 'error'); } } catch { showToast('Failed to save settings.', 'error'); } finally { setSavingSettings(false); } }; const uploadBannerFile = async (file: File, allowPrompt = true) => { setBannerUploadError(null); setIsUploadingBanner(true); try { const formData = new FormData(); formData.append('file', file); const res = await fetch('/api/media/upload', { method: 'POST', body: formData, }); const data = await res.json(); if (!res.ok || !data.url) { if (res.status === 401 && allowPrompt) { setPendingBannerFile(file); setBannerPromptError(''); setShowBannerSessionPrompt(true); return; } throw new Error(data.error || 'Upload failed'); } const nextSettings = { ...nodeSettings, bannerUrl: data.media?.url || data.url, }; setNodeSettings(nextSettings); await handleSaveSettings(nextSettings); setPendingBannerFile(null); setShowBannerSessionPrompt(false); setBannerPassword(''); setBannerPromptError(''); } catch (error) { console.error('Banner upload failed', error); setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); } finally { setIsUploadingBanner(false); } }; const handleBannerUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; await uploadBannerFile(file); }; const handleBannerSessionSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!pendingBannerFile) { setShowBannerSessionPrompt(false); return; } if (!bannerPassword.trim()) { setBannerPromptError('Please enter your password'); return; } setIsRefreshingBannerSession(true); setBannerPromptError(''); try { await refreshStorageSession(bannerPassword.trim()); await uploadBannerFile(pendingBannerFile, false); } catch (error) { setBannerPromptError(error instanceof Error ? error.message : 'Failed to confirm password'); } finally { setIsRefreshingBannerSession(false); } }; const handleBannerSessionCancel = () => { setShowBannerSessionPrompt(false); setPendingBannerFile(null); setBannerPassword(''); setBannerPromptError(''); }; const handleTriggerUpdate = async () => { setTriggeringUpdate(true); try { const res = await fetch('/api/admin/update', { method: 'POST', keepalive: true }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || 'Failed to start update'); } showToast(data.message || 'Update started. Synapsis will restart shortly.', 'success'); await loadUpdateStatus(); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to start update'; if (message.toLowerCase().includes('fetch') || message.toLowerCase().includes('network')) { showToast('Update likely started. The node is restarting, so this page may disconnect briefly.', 'success'); window.setTimeout(() => { window.location.reload(); }, 5000); } else { showToast(message, 'error'); } } finally { setTriggeringUpdate(false); } }; const handleLogoUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; setLogoUploadError(null); setIsUploadingLogo(true); try { const formData = new FormData(); formData.append('file', file); formData.append('type', 'logo'); const res = await fetch('/api/admin/node/upload', { method: 'POST', body: formData, }); const data = await res.json(); if (!res.ok || !data.url) { throw new Error(data.error || 'Upload failed'); } const nextSettings = { ...nodeSettings, logoUrl: data.url, }; setNodeSettings(nextSettings); await handleSaveSettings(nextSettings); } catch (error) { console.error('Logo upload failed', error); setLogoUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); } finally { setIsUploadingLogo(false); } }; const handleToggleAutoUpdate = async () => { if (!updateStatus?.updater.available || !updateStatus.updater.config) return; const nextValue = !updateStatus.updater.config.autoUpdateEnabled; setSavingAutoUpdate(true); try { const res = await fetch('/api/admin/update', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ autoUpdateEnabled: nextValue }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || 'Failed to update auto-update setting'); } setUpdateStatus((prev) => prev ? { ...prev, updater: { ...prev.updater, config: data.config, }, } : prev); showToast(nextValue ? 'Automatic updates enabled' : 'Automatic updates disabled', 'success'); } catch (error) { showToast(error instanceof Error ? error.message : 'Failed to update auto-update setting', 'error'); } finally { setSavingAutoUpdate(false); } }; const handleFaviconUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; setFaviconUploadError(null); setIsUploadingFavicon(true); try { const formData = new FormData(); formData.append('file', file); formData.append('type', 'favicon'); const res = await fetch('/api/admin/node/upload', { method: 'POST', body: formData, }); const data = await res.json(); if (!res.ok || !data.url) { throw new Error(data.error || 'Upload failed'); } const nextSettings = { ...nodeSettings, faviconUrl: data.url, }; setNodeSettings(nextSettings); await handleSaveSettings(nextSettings); } catch (error) { console.error('Favicon upload failed', error); setFaviconUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); } finally { setIsUploadingFavicon(false); } }; if (isAdmin === null) { return (
Checking permissions...
); } if (!isAdmin) { return (

Admin Settings

You do not have access to this page.

Back to home
); } return ( <>

Admin Settings

{loading ? (
Loading settings...
) : (
setNodeSettings({ ...nodeSettings, name: e.target.value })} placeholder="My Synapsis Node" />

Replaces the default logo in the sidebar. Max width: 200px.

{nodeSettings.logoUrl && ( )} {logoUploadError && ( {logoUploadError} )}
{nodeSettings.logoUrl && (
Custom logo
)}

The icon shown in browser tabs. Recommended: 32x32 or 64x64 PNG.

{nodeSettings.faviconUrl && ( )} {faviconUploadError && ( {faviconUploadError} )}
{nodeSettings.faviconUrl && (
Custom favicon
)}
setNodeSettings({ ...nodeSettings, description: e.target.value })} placeholder="A brief tagline for your node." rows={2} />
setNodeSettings({ ...nodeSettings, accentColor: e.target.value })} style={{ width: '44px', height: '36px', padding: 0, border: '1px solid var(--border)', background: 'transparent', borderRadius: '8px' }} /> setNodeSettings({ ...nodeSettings, accentColor: e.target.value })} placeholder="#FFFFFF" />
{bannerUploadError && ( {bannerUploadError} )}
{nodeSettings.bannerUrl && (
Banner preview
)}
setNodeSettings({ ...nodeSettings, longDescription: e.target.value })} placeholder="Detailed information about your node/community." rows={5} />
setNodeSettings({ ...nodeSettings, rules: e.target.value })} placeholder="Community rules and guidelines." rows={5} />

{nodeSettings.isNsfw ? 'This node is marked as NSFW. All content will be hidden from users who haven\'t enabled NSFW viewing.' : 'Enable this if your node primarily hosts adult or sensitive content. All posts from this node will be treated as NSFW across the swarm.'}

Add Cloudflare Turnstile to protect registration and login from bots. Get your keys from the{' '} Cloudflare Dashboard .

{nodeSettings.turnstileSiteKey && nodeSettings.turnstileSecretKey && (
✓ Turnstile is enabled and will be shown on login/registration
)}
setNodeSettings({ ...nodeSettings, turnstileSiteKey: e.target.value })} placeholder="0x4AAAAAAA..." style={{ fontFamily: 'monospace', fontSize: '13px' }} />

Public key shown to users

setNodeSettings({ ...nodeSettings, turnstileSecretKey: e.target.value })} placeholder={nodeSettings.turnstileSecretKey ? '••••••••••••••••' : '0x4AAAAAAA...'} style={{ fontFamily: 'monospace', fontSize: '13px' }} />

{nodeSettings.turnstileSecretKey ? 'Secret key is configured (hidden for security)' : 'Secret key for server-side verification'}

System Update

Keep this node on the latest published Synapsis build.

{updateStatus?.updater.available && updateStatus?.updater.config && (
Automatic updates
Enabled by default. This node checks for updates every {updateStatus.updater.config.intervalMinutes} minutes and installs them automatically.
)} {!updateStatus?.updater.available && (
One-click updates are unavailable on this host. Use:
curl -fsSL https://synapsis.social/update.sh | bash
)}
)} { setBannerPassword(nextPassword); setBannerPromptError(''); }} onSubmit={handleBannerSessionSubmit} onCancel={handleBannerSessionCancel} /> ); }