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 (
);
}
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 && (
)}
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' && (
)}
{/* DuckDNS */}
{domainType === 'duckdns' && (
{ 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' && (
)}
);
}
// ── 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 (
);
}
// ── 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) => (
))}
) : (
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 && (
)}
);
}