feat: Services UI — nested nav, per-service pages, settings migration

Rename Store → Services: ServicesIndex.jsx shows built-in core services
(Email, Calendar, Files) with Manage links, plus the existing add-on
store below.

New service sub-pages at /services/email|calendar|files serve both
admin and peer roles. Admins see connection info, service status, users
list, and an inline config form (port/data-dir). Peers see connection
info and their personal credentials fetched from peerAPI.

Navigation restructured: a Services parent item expands to show the
three sub-pages via a collapsible sidebar group (ChevronDown toggle).
Both admin and peer navigation include the Services group. Sidebar
extracted NavItem/NavList components to eliminate the duplicate mobile/
desktop rendering.

Settings.jsx drops EmailForm, CalendarForm, FilesForm and their
SERVICE_DEFS entries. Port conflict detection and per-service validation
logic extracted to utils/serviceConfig.js, shared by Settings and the
new service pages. Service form flushers are registered without cleanup
so the Apply banner saves dirty config even when the user navigates away
from a service page before clicking Apply.

Legacy routes /email, /calendar, /files, /store redirect to their new
canonical paths.

GET /api/config now includes installed_services so the nav can derive
which add-ons are installed without a separate store fetch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 06:46:17 -04:00
parent b16189d00f
commit 0afdee32da
11 changed files with 1684 additions and 309 deletions
+301
View File
@@ -0,0 +1,301 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Calendar as CalendarIcon, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react';
import { calendarAPI, cellAPI, peerAPI } from '../../services/api';
import { useConfig } from '../../contexts/ConfigContext';
import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateCalendarConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
const CAL_DEFAULTS = { port: 5232, data_dir: '' };
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-2 text-gray-400 hover:text-gray-600" title="Copy"
>
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-32 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={String(value)} />
</div>
</div>
);
}
function AdminConfigSection({ calCfg, onChange, errors, portConflicts, saving }) {
return (
<div className="card mt-6">
<div className="flex items-center gap-2 mb-4">
<SettingsIcon className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Service Configuration</h3>
{saving && <span className="ml-auto text-xs text-gray-400">Saving</span>}
</div>
<div className="space-y-3">
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy" error={errors.port || portConflicts['calendar|port']}>
<NumberInput value={calCfg.port} onChange={(v) => onChange({ ...calCfg, port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={calCfg.data_dir} onChange={(v) => onChange({ ...calCfg, data_dir: v })} placeholder="/app/data/radicale" />
</Field>
</div>
</div>
);
}
function Toast({ msg, type }) {
if (!msg) return null;
return (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
export default function CalendarPage() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig();
const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain;
const proto = domain_mode === 'lan' ? 'http' : 'https';
const cellHost = `calendar.${svcDomain}`;
const calendarIp = service_ips.vip_calendar || '172.20.0.21';
const dnsIp = service_ips.dns || '172.20.0.3';
const calendarPort = service_configs.calendar?.port ?? 5232;
const [calCfg, setCalCfg] = useState({ ...CAL_DEFAULTS });
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [toast, setToast] = useState(null);
const [peerData, setPeerData] = useState(null);
useEffect(() => {
if (service_configs.calendar) setCalCfg({ ...CAL_DEFAULTS, ...service_configs.calendar });
}, [service_configs.calendar]);
useEffect(() => {
if (!isAdmin) {
peerAPI.services().then(r => setPeerData(r.data)).catch(() => {});
return;
}
calendarAPI.getUsers().then(r => setUsers(r.data)).catch(() => {});
calendarAPI.getStatus().then(r => setStatus(r.data)).catch(() => {});
}, [isAdmin]);
const showToast = (msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const errors = useMemo(() => validateCalendarConfig(calCfg), [calCfg]);
const portConflicts = useMemo(
() => detectPortConflicts({ ...service_configs, calendar: calCfg }),
[calCfg, service_configs]
);
const hasErrors = useMemo(
() => Object.keys(errors).length > 0 || !!portConflicts['calendar|port'],
[errors, portConflicts]
);
const calCfgRef = useRef(calCfg);
useEffect(() => { calCfgRef.current = calCfg; }, [calCfg]);
const dirtyRef = useRef(dirty);
useEffect(() => { dirtyRef.current = dirty; }, [dirty]);
const hasErrorsRef = useRef(hasErrors);
useEffect(() => { hasErrorsRef.current = hasErrors; }, [hasErrors]);
const save = useCallback(async () => {
if (!dirtyRef.current || hasErrorsRef.current) return;
setSaving(true);
try {
await cellAPI.updateConfig({ calendar: calCfgRef.current });
setDirty(false);
draftConfig?.setDirty('calendar', false);
refreshConfig();
} catch (err) {
showToast(err?.response?.data?.error || 'Failed to save calendar config', 'error');
} finally {
setSaving(false);
}
}, [draftConfig, refreshConfig]);
const saveRef = useRef(save);
useEffect(() => { saveRef.current = save; }, [save]);
useEffect(() => {
if (!draftConfig) return;
draftConfig.registerFlusher('calendar', () => saveRef.current());
}, [draftConfig]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!dirty || hasErrors) return;
const t = setTimeout(() => saveRef.current(), 800);
return () => clearTimeout(t);
}, [calCfg, dirty, hasErrors]); // eslint-disable-line react-hooks/exhaustive-deps
const handleChange = (cfg) => {
setCalCfg(cfg);
setDirty(true);
draftConfig?.setDirty('calendar', true);
};
return (
<div>
<Toast {...(toast || {})} />
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection info */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Connect your device</h3>
</div>
<p className="text-xs text-gray-500 mb-3">
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
</p>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server URL" value={`${proto}://${cellHost}`} />
<InfoRow label="CalDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="CardDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="Port" value={domain_mode === 'lan' ? '80' : '443'} />
<InfoRow label="Direct IP" value={calendarIp} />
<InfoRow label="Direct port" value={String(calendarPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN. DNS must be set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* Quick setup guide */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Quick setup guide</h3>
</div>
<div className="space-y-3 text-sm text-gray-700">
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Settings Calendar Accounts)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Add Account Other Add CalDAV Account</li>
<li>Server: <span className="font-mono">{cellHost}</span></li>
<li>Enter username &amp; password</li>
<li>For contacts: Add CardDAV Account, same server</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android (DAVx⁵ app)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Install DAVx⁵ from Play Store / F-Droid</li>
<li>Login with URL: <span className="font-mono">{proto}://{cellHost}/</span></li>
<li>Select calendars &amp; address books to sync</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Thunderbird</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Calendar New Calendar On the Network</li>
<li>Format: CalDAV, Location: <span className="font-mono">{proto}://{cellHost}/</span></li>
</ol>
</div>
</div>
</div>
{/* Status — admin only */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Server className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Radicale:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CalDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CardDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
)}
{/* Peer credentials */}
{!isAdmin && peerData?.caldav && (
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Your Account</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Username" value={peerData.caldav.username || peerData.username || '—'} />
</div>
<p className="text-xs text-gray-400 mt-3">Authenticate with your dashboard username and password.</p>
</div>
)}
{/* Admin users list */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
</div>
{users.length > 0 ? (
<div className="space-y-2">
{users.map((u, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{u.username}</span>
<span className="text-sm text-gray-500">{u.calendars || 0} calendars</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm">No calendar users configured</p>
)}
</div>
)}
</div>
{isAdmin && (
<AdminConfigSection
calCfg={calCfg}
onChange={handleChange}
errors={errors}
portConflicts={portConflicts}
saving={saving}
/>
)}
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Mail, Users, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react';
import { emailAPI, cellAPI, peerAPI } from '../../services/api';
import { useConfig } from '../../contexts/ConfigContext';
import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateEmailConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
const EMAIL_DEFAULTS = { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 };
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-2 text-gray-400 hover:text-gray-600" title="Copy"
>
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={String(value)} />
</div>
</div>
);
}
function AdminConfigSection({ emailCfg, onChange, errors, portConflicts, saving }) {
const conflictFor = (f) => portConflicts[`email|${f}`];
return (
<div className="card mt-6">
<div className="flex items-center gap-2 mb-4">
<SettingsIcon className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Service Configuration</h3>
{saving && <span className="ml-auto text-xs text-gray-400">Saving</span>}
</div>
<div className="space-y-3">
<Field label="Mail Domain" error={errors.domain}>
<TextInput value={emailCfg.domain} onChange={(v) => onChange({ ...emailCfg, domain: v })} placeholder="mail.example.com" />
</Field>
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)" error={errors.smtp_port || conflictFor('smtp_port')}>
<NumberInput value={emailCfg.smtp_port ?? 25} onChange={(v) => onChange({ ...emailCfg, smtp_port: v })} min={1} max={65535} />
</Field>
<Field label="Submission Port" hint="Client mail send (default 587)" error={errors.submission_port || conflictFor('submission_port')}>
<NumberInput value={emailCfg.submission_port ?? 587} onChange={(v) => onChange({ ...emailCfg, submission_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Client mail fetch (default 993)" error={errors.imap_port || conflictFor('imap_port')}>
<NumberInput value={emailCfg.imap_port ?? 993} onChange={(v) => onChange({ ...emailCfg, imap_port: v })} min={1} max={65535} />
</Field>
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)" error={errors.webmail_port || conflictFor('webmail_port')}>
<NumberInput value={emailCfg.webmail_port ?? 8888} onChange={(v) => onChange({ ...emailCfg, webmail_port: v })} min={1} max={65535} />
</Field>
</div>
</div>
);
}
function Toast({ msg, type }) {
if (!msg) return null;
return (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
export default function EmailPage() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig();
const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain;
const proto = domain_mode === 'lan' ? 'http' : 'https';
const cellHost = `mail.${svcDomain}`;
const mailIp = service_ips.vip_mail || '172.20.0.23';
const dnsIp = service_ips.dns || '172.20.0.3';
const emailCfgServer = service_configs.email || {};
const imapPort = emailCfgServer.imap_port ?? 993;
const smtpPort = emailCfgServer.smtp_port ?? 25;
const webmailPort = emailCfgServer.webmail_port ?? 8888;
// Admin state
const [emailCfg, setEmailCfg] = useState({ ...EMAIL_DEFAULTS });
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [toast, setToast] = useState(null);
// Peer state
const [peerData, setPeerData] = useState(null);
useEffect(() => {
if (service_configs.email) setEmailCfg({ ...EMAIL_DEFAULTS, ...service_configs.email });
}, [service_configs.email]);
useEffect(() => {
if (!isAdmin) {
peerAPI.services().then(r => setPeerData(r.data)).catch(() => {});
return;
}
emailAPI.getUsers().then(r => setUsers(r.data)).catch(() => {});
emailAPI.getStatus().then(r => setStatus(r.data)).catch(() => {});
}, [isAdmin]);
const showToast = (msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const errors = useMemo(() => validateEmailConfig(emailCfg), [emailCfg]);
const portConflicts = useMemo(
() => detectPortConflicts({ ...service_configs, email: emailCfg }),
[emailCfg, service_configs]
);
const hasErrors = useMemo(
() => Object.keys(errors).length > 0 ||
PORT_CONFLICT_FIELDS.email.some(f => portConflicts[`email|${f}`]),
[errors, portConflicts]
);
// Refs so flusher closure always sees current values after navigation
const emailCfgRef = useRef(emailCfg);
useEffect(() => { emailCfgRef.current = emailCfg; }, [emailCfg]);
const dirtyRef = useRef(dirty);
useEffect(() => { dirtyRef.current = dirty; }, [dirty]);
const hasErrorsRef = useRef(hasErrors);
useEffect(() => { hasErrorsRef.current = hasErrors; }, [hasErrors]);
const save = useCallback(async () => {
if (!dirtyRef.current || hasErrorsRef.current) return;
setSaving(true);
try {
await cellAPI.updateConfig({ email: emailCfgRef.current });
setDirty(false);
draftConfig?.setDirty('email', false);
refreshConfig();
} catch (err) {
showToast(err?.response?.data?.error || 'Failed to save email config', 'error');
} finally {
setSaving(false);
}
}, [draftConfig, refreshConfig]);
const saveRef = useRef(save);
useEffect(() => { saveRef.current = save; }, [save]);
// Register flusher without cleanup so it persists when user navigates away mid-edit
useEffect(() => {
if (!draftConfig) return;
draftConfig.registerFlusher('email', () => saveRef.current());
}, [draftConfig]); // eslint-disable-line react-hooks/exhaustive-deps
// Debounced auto-save
useEffect(() => {
if (!dirty || hasErrors) return;
const t = setTimeout(() => saveRef.current(), 800);
return () => clearTimeout(t);
}, [emailCfg, dirty, hasErrors]); // eslint-disable-line react-hooks/exhaustive-deps
const handleChange = (cfg) => {
setEmailCfg(cfg);
setDirty(true);
draftConfig?.setDirty('email', true);
};
return (
<div>
<Toast {...(toast || {})} />
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IMAP */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(imapPort)} />
<InfoRow label="Security" value="SSL/TLS" />
<InfoRow label="Direct IP" value={mailIp} />
</div>
</div>
{/* SMTP */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(smtpPort)} />
<InfoRow label="Security" value="STARTTLS" />
<InfoRow label="Auth" value="Username + Password" />
</div>
</div>
{/* Webmail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Webmail</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://mail.${svcDomain}`} />
<InfoRow label="Alt URL" value={`${proto}://webmail.${svcDomain}`} />
<InfoRow label="Direct IP" value={`http://${mailIp}`} />
<InfoRow label="Direct port" value={String(webmailPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN + DNS set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* Status — admin only */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Server className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Postfix (SMTP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Dovecot (IMAP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
)}
{/* Peer credentials */}
{!isAdmin && peerData?.email && (
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Your Account</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Email address" value={peerData.email.address || '—'} />
<InfoRow label="Username" value={peerData.username || '—'} />
</div>
<p className="text-xs text-gray-400 mt-3">Authenticate with your dashboard username and password.</p>
</div>
)}
{/* Admin users list */}
{isAdmin && users.length > 0 && (
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Email Accounts</h3>
</div>
<div className="space-y-2">
{users.map((u, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{u.username}</span>
<span className="text-sm text-gray-500">{u.domain}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Admin config form */}
{isAdmin && (
<AdminConfigSection
emailCfg={emailCfg}
onChange={handleChange}
errors={errors}
portConflicts={portConflicts}
saving={saving}
/>
)}
</div>
);
}
+305
View File
@@ -0,0 +1,305 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { FolderOpen, Users, HardDrive, Wifi, Server, Copy, CheckCheck, Settings as SettingsIcon, CheckCircle, XCircle } from 'lucide-react';
import { fileAPI, cellAPI, peerAPI } from '../../services/api';
import { useConfig } from '../../contexts/ConfigContext';
import { useDraftConfig } from '../../contexts/DraftConfigContext';
import { useAuth } from '../../contexts/AuthContext';
import { Field, TextInput, NumberInput } from '../../components/FormFields';
import { detectPortConflicts, validateFilesConfig, PORT_CONFLICT_FIELDS } from '../../utils/serviceConfig';
const FILES_DEFAULTS = { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 };
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="ml-2 text-gray-400 hover:text-gray-600" title="Copy"
>
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={String(value)} />
</div>
</div>
);
}
function AdminConfigSection({ filesCfg, onChange, errors, portConflicts, saving }) {
const cf = (f) => portConflicts[`files|${f}`];
return (
<div className="card mt-6">
<div className="flex items-center gap-2 mb-4">
<SettingsIcon className="h-5 w-5 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Service Configuration</h3>
{saving && <span className="ml-auto text-xs text-gray-400">Saving</span>}
</div>
<div className="space-y-3">
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)" error={errors.port || cf('port')}>
<NumberInput value={filesCfg.port ?? 8080} onChange={(v) => onChange({ ...filesCfg, port: v })} min={1} max={65535} />
</Field>
<Field label="File Manager Port" hint="Filegator host port (default 8082)" error={errors.manager_port || cf('manager_port')}>
<NumberInput value={filesCfg.manager_port ?? 8082} onChange={(v) => onChange({ ...filesCfg, manager_port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
<TextInput value={filesCfg.data_dir} onChange={(v) => onChange({ ...filesCfg, data_dir: v })} placeholder="/app/data/webdav" />
</Field>
<Field label="Default Quota (MB)">
<NumberInput value={filesCfg.quota} onChange={(v) => onChange({ ...filesCfg, quota: v })} min={0} />
</Field>
</div>
</div>
);
}
function Toast({ msg, type }) {
if (!msg) return null;
return (
<div className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
type === 'error' ? 'bg-red-600' : 'bg-green-600'
}`}>
{type === 'error' ? <XCircle className="h-4 w-4 shrink-0" /> : <CheckCircle className="h-4 w-4 shrink-0" />}
{msg}
</div>
);
}
export default function FilesPage() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {}, refresh: refreshConfig } = useConfig();
const draftConfig = useDraftConfig();
const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain;
const proto = domain_mode === 'lan' ? 'http' : 'https';
const filesHost = `files.${svcDomain}`;
const webdavHost = `webdav.${svcDomain}`;
const filesIp = service_ips.vip_files || '172.20.0.22';
const webdavIp = service_ips.vip_webdav || '172.20.0.24';
const filesCfgServer = service_configs.files || {};
const webdavPort = filesCfgServer.port ?? 8080;
const filegatorPort = filesCfgServer.manager_port ?? 8082;
const [filesCfg, setFilesCfg] = useState({ ...FILES_DEFAULTS });
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [toast, setToast] = useState(null);
const [peerData, setPeerData] = useState(null);
useEffect(() => {
if (service_configs.files) setFilesCfg({ ...FILES_DEFAULTS, ...service_configs.files });
}, [service_configs.files]);
useEffect(() => {
if (!isAdmin) {
peerAPI.services().then(r => setPeerData(r.data)).catch(() => {});
return;
}
fileAPI.getUsers().then(r => setUsers(r.data)).catch(() => {});
fileAPI.getStatus().then(r => setStatus(r.data)).catch(() => {});
}, [isAdmin]);
const showToast = (msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const errors = useMemo(() => validateFilesConfig(filesCfg), [filesCfg]);
const portConflicts = useMemo(
() => detectPortConflicts({ ...service_configs, files: filesCfg }),
[filesCfg, service_configs]
);
const hasErrors = useMemo(
() => Object.keys(errors).length > 0 ||
PORT_CONFLICT_FIELDS.files.some(f => portConflicts[`files|${f}`]),
[errors, portConflicts]
);
const filesCfgRef = useRef(filesCfg);
useEffect(() => { filesCfgRef.current = filesCfg; }, [filesCfg]);
const dirtyRef = useRef(dirty);
useEffect(() => { dirtyRef.current = dirty; }, [dirty]);
const hasErrorsRef = useRef(hasErrors);
useEffect(() => { hasErrorsRef.current = hasErrors; }, [hasErrors]);
const save = useCallback(async () => {
if (!dirtyRef.current || hasErrorsRef.current) return;
setSaving(true);
try {
await cellAPI.updateConfig({ files: filesCfgRef.current });
setDirty(false);
draftConfig?.setDirty('files', false);
refreshConfig();
} catch (err) {
showToast(err?.response?.data?.error || 'Failed to save files config', 'error');
} finally {
setSaving(false);
}
}, [draftConfig, refreshConfig]);
const saveRef = useRef(save);
useEffect(() => { saveRef.current = save; }, [save]);
useEffect(() => {
if (!draftConfig) return;
draftConfig.registerFlusher('files', () => saveRef.current());
}, [draftConfig]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!dirty || hasErrors) return;
const t = setTimeout(() => saveRef.current(), 800);
return () => clearTimeout(t);
}, [filesCfg, dirty, hasErrors]); // eslint-disable-line react-hooks/exhaustive-deps
const handleChange = (cfg) => {
setFilesCfg(cfg);
setDirty(true);
draftConfig?.setDirty('files', true);
};
return (
<div>
<Toast {...(toast || {})} />
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File manager */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${filesHost}`} />
<InfoRow label="Direct IP" value={`http://${filesIp}`} />
<InfoRow label="Direct port" value={String(filegatorPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">Browser-based file manager. Requires VPN.</p>
</div>
{/* WebDAV */}
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${webdavHost}`} />
<InfoRow label="Direct IP" value={`http://${webdavIp}`} />
<InfoRow label="Direct port" value={String(webdavPort)} />
<InfoRow label="Auth" value="Basic (user / password)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Mount in macOS Finder, Windows Explorer, or any WebDAV client.
</p>
</div>
{/* Mount guide */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Mount as network drive</h3>
</div>
<div className="space-y-3 text-sm">
<div>
<p className="font-medium text-gray-900 mb-1">macOS (Finder)</p>
<p className="text-xs text-gray-600">Go Connect to Server <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Windows</p>
<p className="text-xs text-gray-600">Map Network Drive <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Files app)</p>
<p className="text-xs text-gray-600">Files ... Connect to Server <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android</p>
<p className="text-xs text-gray-600">Use <strong>Solid Explorer</strong> or <strong>FX File Explorer</strong> WebDAV <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
</div>
</div>
{/* Status — admin only */}
{isAdmin && (
<div className="card">
<div className="flex items-center mb-4">
<Server className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">FileGator:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">WebDAV:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
)}
{/* Peer credentials */}
{!isAdmin && peerData?.files && (
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Your Account</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Username" value={peerData.files.username || peerData.username || '—'} />
</div>
<p className="text-xs text-gray-400 mt-3">Authenticate with your dashboard username and password.</p>
</div>
)}
{/* Admin users list */}
{isAdmin && users.length > 0 && (
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
</div>
<div className="space-y-2">
{users.map((u, i) => (
<div key={i} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{u.username}</span>
<span className="text-sm text-gray-500">{u.storage_used || '0'} MB</span>
</div>
))}
</div>
</div>
)}
</div>
{isAdmin && (
<AdminConfigSection
filesCfg={filesCfg}
onChange={handleChange}
errors={errors}
portConflicts={portConflicts}
saving={saving}
/>
)}
</div>
);
}