import React, { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react'; import { setupAPI } from '../services/api'; // ── constants ───────────────────────────────────────────────────────────────── const TOTAL_STEPS = 4; const CELL_NAME_RE = /^[a-z][a-z0-9-]{1,30}$/; const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; const DOMAIN_OPTIONS = [ { value: 'pic_ngo', label: 'PIC.NGO subdomain', description: 'Free yourname.pic.ngo address — HTTPS and DDNS fully automatic.', }, { value: 'cloudflare', label: 'Cloudflare DNS-01', description: 'Your own domain via Cloudflare API. Domain must use Cloudflare nameservers.', }, { value: 'duckdns', label: 'DuckDNS', description: 'Free *.duckdns.org subdomain with automatic DNS-01 challenge.', }, { value: 'http01', label: 'Any domain (HTTP-01)', description: 'Any domain via standard ACME. Port 80 must be publicly reachable.', }, { value: 'lan', label: 'Local only', description: 'No public domain. Accessible only on your local network and via VPN.', }, ]; // ── helpers ─────────────────────────────────────────────────────────────────── function getAllTimezones() { try { return Intl.supportedValuesOf('timeZone'); } catch { 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%', score: 0 }; let score = 0; if (pw.length >= 12) score++; if (pw.length >= 16) score++; if (/[A-Z]/.test(pw)) score++; if (/[a-z]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++; if (/[^A-Za-z0-9]/.test(pw)) score++; if (score <= 2) return { label: 'Weak', color: 'bg-red-500', width: '20%', score }; if (score === 3) return { label: 'Fair', color: 'bg-yellow-500', width: '45%', score }; if (score === 4) return { label: 'Good', color: 'bg-blue-500', width: '70%', score }; return { label: 'Strong', color: 'bg-green-500', width: '100%', score }; } function meetsApiRequirements(pw) { return pw.length >= 12 && /[A-Z]/.test(pw) && /[a-z]/.test(pw) && /[0-9]/.test(pw); } function deriveCellName(domainType, picName, duckSub, customDomain) { if (domainType === 'pic_ngo') return picName.toLowerCase(); if (domainType === 'duckdns') return duckSub.toLowerCase(); if (domainType === 'lan') return 'cell'; const seg = (customDomain || '').split('.')[0].toLowerCase().replace(/[^a-z0-9-]/g, ''); return CELL_NAME_RE.test(seg) ? seg : 'cell'; } function getDomainName(domainType, picName, duckSub, customDomain) { if (domainType === 'pic_ngo') return `${picName}.pic.ngo`; if (domainType === 'duckdns') return `${duckSub}.duckdns.org`; if (domainType === 'lan') return ''; return customDomain; } // ── shared UI ───────────────────────────────────────────────────────────────── 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 ? ( ) :
}
); } function TokenVerifyButton({ onVerify, status }) { const label = status === 'checking' ? 'Verifying…' : status === 'valid' ? '✓ Valid' : status === 'invalid' ? 'Invalid — retry' : 'Verify token'; const cls = status === 'valid' ? 'text-green-400 border-green-600' : status === 'invalid' ? 'text-red-400 border-red-600' : 'text-blue-400 border-blue-600 hover:bg-blue-900/20'; return ( ); } // ── step 1: password ────────────────────────────────────────────────────────── function Step1Password({ password, confirm, onChangePassword, onChangeConfirm, onNext }) { const [showPw, setShowPw] = useState(false); const [showCf, setShowCf] = useState(false); const [errors, setErrors] = useState({}); const strength = passwordStrength(password); const ready = meetsApiRequirements(password) && password === confirm; const handleNext = async () => { const e = {}; if (!meetsApiRequirements(password)) e.password = 'Minimum 12 characters with uppercase, lowercase, and a digit.'; if (password !== confirm) e.confirm = 'Passwords do not match.'; setErrors(e); if (Object.keys(e).length > 0) return; try { await setupAPI.validate('password', { password }); onNext(); } catch (ex) { setErrors({ password: ex?.response?.data?.errors?.[0] || 'Password validation failed.' }); } }; return (
{ onChangePassword(e.target.value); setErrors(p => ({...p, password: ''})); }} onKeyDown={e => e.key === 'Enter' && handleNext()} className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 pr-10 text-white text-sm focus:outline-none focus:border-blue-500" />
{password && (

{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-10 text-white text-sm focus:outline-none focus:border-blue-500" />

Min 12 characters, one uppercase letter, one lowercase letter, one digit.

); } // ── step 2: domain ──────────────────────────────────────────────────────────── function Step2Domain({ domainType, onDomainType, picName, onPicName, customDomain, onCustomDomain, duckSub, onDuckSub, cloudflareToken, onCloudflareToken, duckdnsToken, onDuckdnsToken, onNext, onBack, }) { const [errors, setErrors] = useState({}); const [picAvail, setPicAvail] = useState(null); // null|checking|available|taken|unknown const [cfStatus, setCfStatus] = useState(null); const [dnsStatus, setDnsStatus] = useState(null); const [nextLoading, setNextLoading] = useState(false); const checkPicAvail = async name => { if (!CELL_NAME_RE.test(name)) return null; setPicAvail('checking'); try { const res = await setupAPI.validate('pic_ngo_available', { cell_name: name }); const result = res.data?.available ? 'available' : 'taken'; setPicAvail(result); return result; } catch { setPicAvail('unknown'); return 'unknown'; } }; const verifyCf = async () => { setCfStatus('checking'); try { const res = await setupAPI.validate('cloudflare_token', { token: cloudflareToken }); const result = res.data?.valid ? 'valid' : 'invalid'; setCfStatus(result); return result; } catch { setCfStatus('invalid'); return 'invalid'; } }; const verifyDns = async () => { setDnsStatus('checking'); try { const res = await setupAPI.validate('duckdns_token', { subdomain: duckSub, token: duckdnsToken }); const result = res.data?.valid ? 'valid' : 'invalid'; setDnsStatus(result); return result; } catch { setDnsStatus('invalid'); return 'invalid'; } }; const handleNext = async () => { const e = {}; if (domainType === 'pic_ngo') { if (!picName) { e.name = 'Subdomain is required.'; } else if (!CELL_NAME_RE.test(picName)) { e.name = 'Lowercase letters, digits, hyphens. 2–31 chars, must start with a letter.'; } else if (picAvail !== 'available') { setNextLoading(true); const result = await checkPicAvail(picName); setNextLoading(false); if (result === 'taken') e.name = 'This subdomain is already taken. Choose another.'; else if (result !== 'available') e.name = 'Could not reach the DDNS service. Check your connection or choose a different domain option.'; } } else if (domainType === 'cloudflare') { if (!customDomain || !DOMAIN_RE.test(customDomain)) e.domain = 'Enter a valid domain (e.g. home.example.com).'; if (!cloudflareToken.trim()) { e.token = 'Cloudflare API token is required.'; } else if (cfStatus !== 'valid') { setNextLoading(true); const result = await verifyCf(); setNextLoading(false); if (result !== 'valid') e.token = 'Cloudflare token is invalid or could not be verified.'; } } else if (domainType === 'duckdns') { if (!duckSub) e.name = 'DuckDNS subdomain is required.'; if (!duckdnsToken.trim()) { e.token = 'DuckDNS token is required.'; } else if (dnsStatus !== 'valid') { setNextLoading(true); const result = await verifyDns(); setNextLoading(false); if (result !== 'valid') e.token = 'DuckDNS token is invalid or could not be verified.'; } } else if (domainType === 'http01') { if (!customDomain || !DOMAIN_RE.test(customDomain)) e.domain = 'Enter a valid domain (e.g. home.example.com).'; } setErrors(e); if (Object.keys(e).length === 0) onNext(); }; return (
{DOMAIN_OPTIONS.map(opt => ( { onDomainType(v); setErrors({}); setPicAvail(null); setCfStatus(null); setDnsStatus(null); }} /> ))}
{/* pic.ngo subdomain name */} {domainType === 'pic_ngo' && (
{ onPicName(e.target.value.toLowerCase()); setErrors(p => ({...p, name: ''})); setPicAvail(null); }} onBlur={() => picName && checkPicAvail(picName)} placeholder="e.g. myhome" 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" /> {CELL_NAME_RE.test(picName) && (

{picName}.pic.ngo {picAvail === 'checking' && checking…} {picAvail === 'available' && — available} {picAvail === 'taken' && — may already be taken}

)}
)} {/* Cloudflare */} {domainType === 'cloudflare' && (
{ onCustomDomain(e.target.value.toLowerCase().trim()); setErrors(p => ({...p, domain: ''})); }} placeholder="e.g. home.example.com" 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" />
{ onCloudflareToken(e.target.value); setErrors(p => ({...p, token: ''})); setCfStatus(null); }} placeholder="Token with Zone / DNS / Edit permission" 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" />

Cloudflare Dashboard → My Profile → API Tokens → Create Token

{cloudflareToken.trim() && }
)} {/* DuckDNS */} {domainType === 'duckdns' && (
{ onDuckSub(e.target.value.toLowerCase().trim()); setErrors(p => ({...p, name: ''})); setDnsStatus(null); }} placeholder="e.g. myhome" className="flex-1 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" /> .duckdns.org
{ onDuckdnsToken(e.target.value); setErrors(p => ({...p, token: ''})); setDnsStatus(null); }} placeholder="Token from duckdns.org account page" 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" />

The subdomain must already exist in your DuckDNS account.

{duckdnsToken.trim() && }
)} {/* HTTP-01 */} {domainType === 'http01' && (
{ onCustomDomain(e.target.value.toLowerCase().trim()); setErrors(p => ({...p, domain: ''})); }} placeholder="e.g. home.example.com" 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" />

Port 80 must be publicly reachable for Let's Encrypt HTTP-01. Ensure your router forwards port 80 to this machine before completing setup.

)}
); } // ── step 3: timezone ────────────────────────────────────────────────────────── function Step3Timezone({ 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}

}
); } // ── step 4: review ──────────────────────────────────────────────────────────── function ReviewRow({ label, value }) { return (
{label} {value}
); } function Step4Review({ domainType, domainName, timezone, onBack, onSubmit, submitting, submitError }) { const domainDisplay = domainName || 'LAN only (no public domain)'; const providerLabel = DOMAIN_OPTIONS.find(o => o.value === domainType)?.label || domainType; return (
{submitError && (

{submitError}

)}
); } // ── main component ──────────────────────────────────────────────────────────── export default function Setup() { const navigate = useNavigate(); const [step, setStep] = useState(1); const [done, setDone] = useState(false); const [setupWarnings, setSetupWarnings] = useState([]); const [password, setPassword] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState(''); const [domainType, setDomainType] = useState('pic_ngo'); const [picName, setPicName] = useState(''); const [customDomain, setCustomDomain] = useState(''); const [duckSub, setDuckSub] = useState(''); const [cloudflareToken, setCloudflareToken] = useState(''); const [duckdnsToken, setDuckdnsToken] = useState(''); const [timezone, setTimezone] = useState( (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })() ); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(''); // Pre-fill from any previously saved config useEffect(() => { setupAPI.getStatus() .then(res => { const pre = res.data?.preconfigured; if (!pre) return; if (pre.domain_mode === 'pic_ngo' && pre.cell_name) setPicName(pre.cell_name); if (pre.domain_mode === 'duckdns' && pre.cell_name) setDuckSub(pre.cell_name); if (pre.domain_mode && pre.domain_mode !== 'pic_ngo' && pre.domain_mode !== 'duckdns' && pre.domain_name) setCustomDomain(pre.domain_name); if (pre.domain_mode) setDomainType(pre.domain_mode); if (pre.cloudflare_api_token) setCloudflareToken(pre.cloudflare_api_token); if (pre.duckdns_token) setDuckdnsToken(pre.duckdns_token); }) .catch(() => {}); }, []); const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS)); const goBack = () => setStep(s => Math.max(s - 1, 1)); const handleSubmit = async () => { setSubmitError(''); setSubmitting(true); const cellName = deriveCellName(domainType, picName, duckSub, customDomain); const domainName = getDomainName(domainType, picName, duckSub, customDomain); const ddnsProvider = domainType === 'lan' ? 'none' : domainType === 'http01' ? 'none' : domainType; const payload = { cell_name: cellName, password, domain_mode: domainType, domain_name: domainName, timezone, ddns_provider: ddnsProvider, ...(domainType === 'cloudflare' && { cloudflare_api_token: cloudflareToken }), ...(domainType === 'duckdns' && { duckdns_token: duckdnsToken }), }; try { const res = await setupAPI.complete(payload); const warnings = res?.data?.warnings || []; setSetupWarnings(warnings); setDone(true); if (warnings.length === 0) { setTimeout(() => navigate('/login', { replace: true }), 2000); } } catch (e) { setSubmitError( e?.response?.data?.errors?.join(' ') || e?.response?.data?.error || 'Setup could not be completed. Please check your entries and try again.' ); } finally { setSubmitting(false); } }; if (done) { return (

Setup complete!

{setupWarnings.length > 0 ? (
{setupWarnings.map((w, i) => (

{w}

))}
) : (

Redirecting to login...

)}
); } const domainName = getDomainName(domainType, picName, duckSub, customDomain); return (

Personal Internet Cell

First-time setup

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