import React, { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react'; import { setupAPI } from '../services/api'; // ── constants ───────────────────────────────────────────────────────────────── const TOTAL_STEPS = 7; const CELL_NAME_RE = /^[a-z][a-z0-9-]{1,30}$/; const DOMAIN_OPTIONS = [ { value: 'pic_ngo', label: 'PIC.NGO subdomain', description: 'Get a free yourname.pic.ngo domain — managed automatically.', }, { value: 'custom', label: 'Custom domain', description: 'Bring your own domain. You will configure DNS records manually.', }, { value: 'lan_only', label: 'LAN only', description: 'No public domain. Accessible only on your local network and via VPN.', }, ]; const DDNS_OPTIONS = [ { value: 'pic_ngo', label: 'pic.ngo (managed)', description: 'Automatic — no setup required.' }, { value: 'cloudflare', label: 'Cloudflare', description: 'Use Cloudflare DNS with API token.' }, { value: 'duckdns', label: 'DuckDNS', description: 'Free dynamic DNS via duckdns.org.' }, { value: 'noip', label: 'No-IP', description: 'Free dynamic DNS via noip.com.' }, { value: 'freedns', label: 'FreeDNS', description: 'Free DNS via freedns.afraid.org.' }, { value: 'manual', label: 'Manual / None', description: 'You will handle DNS updates yourself.' }, ]; const OPTIONAL_SERVICES = [ { key: 'email', label: 'Email', description: 'Postfix + Dovecot IMAP/SMTP server.' }, { key: 'calendar', label: 'Calendar & Contacts', description: 'CalDAV/CardDAV via Radicale.' }, { key: 'files', label: 'Files (WebDAV)', description: 'WebDAV file storage accessible from any device.' }, { key: 'webmail', label: 'Webmail UI', description: 'Browser-based email client (Roundcube).' }, ]; const ALWAYS_ON_SERVICES = [ { key: 'vpn', label: 'VPN (WireGuard)' }, { key: 'dns', label: 'DNS (CoreDNS)' }, { key: 'api', label: 'API (cell-api)' }, ]; // ── helpers ─────────────────────────────────────────────────────────────────── function getAllTimezones() { try { return Intl.supportedValuesOf('timeZone'); } catch { // Fallback list for older browsers return [ 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney', ]; } } function passwordStrength(pw) { if (!pw) return { label: '', color: '', width: '0%' }; let score = 0; if (pw.length >= 12) score++; if (pw.length >= 16) score++; if (/[A-Z]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++; if (/[^A-Za-z0-9]/.test(pw)) score++; if (score <= 1) return { label: 'Weak', color: 'bg-red-500', width: '20%' }; if (score === 2) return { label: 'Fair', color: 'bg-yellow-500', width: '40%' }; if (score === 3) return { label: 'Good', color: 'bg-blue-500', width: '65%' }; return { label: 'Strong', color: 'bg-green-500', width: '100%' }; } // ── sub-components ──────────────────────────────────────────────────────────── function StepHeader({ step, title, description }) { return (

Step {step} of {TOTAL_STEPS}

{title}

{description &&

{description}

}
); } function ProgressBar({ step }) { const pct = Math.round((step / TOTAL_STEPS) * 100); return (
Setup progress {pct}%
); } function FieldError({ message }) { if (!message) return null; return (

{message}

); } function RadioOption({ value, selected, label, description, onChange }) { return ( ); } function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false, loading = false }) { return (
{onBack ? ( ) : (
)}
); } // ── step screens ────────────────────────────────────────────────────────────── function Step1CellName({ value, onChange, onNext }) { const [error, setError] = useState(''); const [serverError, setServerError] = useState(''); const [loading, setLoading] = useState(false); const validate = () => { if (!value.trim()) return 'Cell name is required.'; if (!CELL_NAME_RE.test(value)) return 'Use lowercase letters, numbers, and hyphens only. Must start with a letter. 2–31 characters.'; return ''; }; const handleNext = async () => { const err = validate(); setError(err); setServerError(''); if (err) return; setLoading(true); try { await setupAPI.validate('cell_name', { cell_name: value }); onNext(); } catch (e) { setServerError( e?.response?.data?.error || 'Validation failed. Please try a different name.' ); } finally { setLoading(false); } }; return (
{ onChange(e.target.value.toLowerCase()); setError(''); setServerError(''); }} onKeyDown={e => e.key === 'Enter' && handleNext()} placeholder="e.g. homelab" className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600" aria-describedby={error || serverError ? 'cell-name-error' : undefined} />

Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.

); } function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, onNext, onBack }) { const [showPw, setShowPw] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [errors, setErrors] = useState({}); const strength = passwordStrength(password); const validate = () => { const e = {}; if (!password) e.password = 'Password is required.'; else if (password.length < 12) e.password = 'Password must be at least 12 characters.'; if (!confirm) e.confirm = 'Please confirm your password.'; else if (password !== confirm) e.confirm = 'Passwords do not match.'; return e; }; const handleNext = () => { const e = validate(); setErrors(e); if (Object.keys(e).length === 0) onNext(); }; const isReady = password.length >= 12 && password === confirm; return (
{ onChangePassword(e.target.value); setErrors(p => ({ ...p, password: '' })); }} className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 pr-9 text-white text-sm focus:outline-none focus:border-blue-500" aria-describedby={errors.password ? 'pw-error' : undefined} />
{/* Strength bar */} {password.length > 0 && (

Strength: {strength.label}

)}
{ onChangeConfirm(e.target.value); setErrors(p => ({ ...p, confirm: '' })); }} onKeyDown={e => e.key === 'Enter' && handleNext()} className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 pr-9 text-white text-sm focus:outline-none focus:border-blue-500" aria-describedby={errors.confirm ? 'pw-confirm-error' : undefined} />
); } function Step3Domain({ value, onChange, onNext, onBack }) { return (
{DOMAIN_OPTIONS.map(opt => ( ))}
); } function Step4DDNS({ value, onChange, onNext, onBack }) { return (
{DDNS_OPTIONS.map(opt => ( ))}
); } function Step5Services({ selected, onChange, onNext, onBack }) { const toggle = key => { onChange( selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key] ); }; return (
{/* Optional services */}
{OPTIONAL_SERVICES.map(svc => { const checked = selected.includes(svc.key); return ( ); })}
{/* Always-on services */}

Always enabled

{ALWAYS_ON_SERVICES.map(svc => (
{svc.label} always enabled
))}
); } function Step6Timezone({ value, onChange, onNext, onBack }) { const [query, setQuery] = useState(''); const allZones = useMemo(() => getAllTimezones(), []); const filtered = useMemo(() => { const q = query.toLowerCase(); return q ? allZones.filter(z => z.toLowerCase().includes(q)) : allZones; }, [query, allZones]); return (
setQuery(e.target.value)} placeholder="e.g. New York, Berlin, Tokyo" className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600 mb-2" /> {value && (

Selected: {value}

)}
); } function ReviewRow({ label, value }) { return (
{label} {value}
); } function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) { const domainLabel = DOMAIN_OPTIONS.find(o => o.value === fields.domain_type)?.label || fields.domain_type; const ddnsLabel = DDNS_OPTIONS.find(o => o.value === fields.ddns_provider)?.label || fields.ddns_provider; const serviceLabels = fields.services.length ? fields.services.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ') : 'None selected'; return (
{fields.domain_type !== 'lan_only' && ( )}
{submitError && (

{submitError}

)}
); } // ── main component ──────────────────────────────────────────────────────────── export default function Setup() { const navigate = useNavigate(); const [step, setStep] = useState(1); const [done, setDone] = useState(false); // Form state const [cellName, setCellName] = useState(''); const [password, setPassword] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState(''); const [domainType, setDomainType] = useState('pic_ngo'); const [ddnsProvider, setDdnsProvider] = useState('pic_ngo'); const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']); const [timezone, setTimezone] = useState( (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })() ); // Submit state const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(''); const skipDdns = domainType === 'lan_only'; const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS)); const goBack = () => setStep(s => Math.max(s - 1, 1)); // Skip step 4 when LAN only const handleStep3Next = () => { if (skipDdns) setStep(5); else setStep(4); }; const handleStep4Back = () => setStep(3); const handleStep5Back = () => { if (skipDdns) setStep(3); else setStep(4); }; const handleSubmit = async () => { setSubmitError(''); setSubmitting(true); const payload = { cell_name: cellName, password, domain_type: domainType, ...(skipDdns ? {} : { ddns_provider: ddnsProvider }), services, timezone, }; try { await setupAPI.complete(payload); setDone(true); setTimeout(() => navigate('/login', { replace: true }), 2000); } catch (e) { setSubmitError( e?.response?.data?.error || 'Setup could not be completed. Please check your entries and try again.' ); } finally { setSubmitting(false); } }; const allFields = { cell_name: cellName, domain_type: domainType, ddns_provider: ddnsProvider, services, timezone }; if (done) { return (

Setup complete!

Redirecting to login...

); } return (
{/* Page title */}

Personal Internet Cell

First-time setup

{step === 1 && ( )} {step === 2 && ( )} {step === 3 && ( )} {step === 4 && ( )} {step === 5 && ( )} {step === 6 && ( )} {step === 7 && ( )}
); }