Files
pic/webui/src/pages/Setup.jsx
T
roof 6bd5f02b03
Unit Tests / test (push) Successful in 7m34s
fix: surface DDNS registration failure during setup wizard
Two problems on fresh install with pic_ngo mode:

1. Caddy crashed at startup because ddns.token was empty (registration
   hadn't completed yet), producing a bare `token` keyword in the
   Caddyfile that Caddy rejects with "wrong argument count".
   Fix: fall back to lan mode in _caddyfile_pic_ngo when the token is
   empty so Caddy always starts cleanly. The Caddyfile is regenerated
   once registration completes and the token is persisted.

2. DDNS registration failures were silently swallowed — the wizard
   showed "Setup complete!" with no indication that HTTPS wouldn't work.
   This made it look like everything was fine when the subdomain was
   never registered (e.g. name already taken from a previous install,
   or transient network error).
   Fix: capture the exception, classify it (name_taken vs transient),
   and return it as a `warnings` list in the setup response. The wizard
   done screen now shows amber warning cards with actionable text instead
   of auto-redirecting, giving the user a "Continue to login" button and
   a clear explanation of what went wrong.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:52:00 -04:00

696 lines
31 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="mb-6">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wider mb-1">
Step {step} of {TOTAL_STEPS}
</p>
<h2 className="text-lg font-semibold text-white">{title}</h2>
{description && <p className="mt-1 text-sm text-gray-400">{description}</p>}
</div>
);
}
function ProgressBar({ step }) {
const pct = Math.round((step / TOTAL_STEPS) * 100);
return (
<div className="mb-8">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>Setup progress</span><span>{pct}%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${pct}%` }}
role="progressbar" aria-valuenow={step} aria-valuemin={1} aria-valuemax={TOTAL_STEPS}
/>
</div>
</div>
);
}
function FieldError({ message }) {
if (!message) return null;
return (
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-400" role="alert">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />{message}
</p>
);
}
function RadioOption({ value, selected, label, description, onChange }) {
return (
<label className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selected ? 'border-blue-500 bg-blue-950/40' : 'border-gray-700 hover:border-gray-500'
}`}>
<input type="radio" className="mt-0.5 accent-blue-500" value={value} checked={selected} onChange={() => onChange(value)} />
<div>
<div className="text-sm font-medium text-white">{label}</div>
{description && <div className="text-xs text-gray-400 mt-0.5">{description}</div>}
</div>
</label>
);
}
function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false, loading = false }) {
return (
<div className="flex justify-between mt-8 pt-6 border-t border-gray-700">
{onBack ? (
<button type="button" onClick={onBack}
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-md transition-colors">
Back
</button>
) : <div />}
<button type="button" onClick={onNext} disabled={nextDisabled || loading}
className="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors flex items-center gap-2">
{loading && <span className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-white" />}
{nextLabel}
</button>
</div>
);
}
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 (
<button type="button" onClick={onVerify} disabled={status === 'checking'}
className={`mt-2 px-3 py-1 text-xs border rounded transition-colors ${cls}`}>
{label}
</button>
);
}
// ── 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 (
<div>
<StepHeader step={1} title="Set admin password"
description="This is the password for the admin account. Store it securely." />
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="pw">
Password <span className="text-red-400">*</span>
</label>
<div className="relative">
<input id="pw" type={showPw ? 'text' : 'password'} autoComplete="new-password"
value={password} onChange={e => { 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" />
<button type="button" onClick={() => setShowPw(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200">
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{password && (
<div className="mt-2">
<div className="w-full bg-gray-700 rounded-full h-1">
<div className={`h-1 rounded-full transition-all ${strength.color}`} style={{ width: strength.width }} />
</div>
<p className="text-xs text-gray-400 mt-1">{strength.label}</p>
</div>
)}
<FieldError message={errors.password} />
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="pw-confirm">
Confirm password <span className="text-red-400">*</span>
</label>
<div className="relative">
<input id="pw-confirm" type={showCf ? 'text' : 'password'} autoComplete="new-password"
value={confirm} onChange={e => { 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" />
<button type="button" onClick={() => setShowCf(v => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200">
{showCf ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<FieldError message={errors.confirm} />
</div>
<p className="text-xs text-gray-500">
Min 12 characters, one uppercase letter, one lowercase letter, one digit.
</p>
</div>
<NavButtons onNext={handleNext} nextDisabled={!ready} />
</div>
);
}
// ── 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. 231 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 (
<div>
<StepHeader step={2} title="How will your cell be reached?"
description="Choose how your cell will be accessible from the internet." />
<div className="space-y-2 mb-5">
{DOMAIN_OPTIONS.map(opt => (
<RadioOption key={opt.value} value={opt.value} label={opt.label} description={opt.description}
selected={domainType === opt.value}
onChange={v => { onDomainType(v); setErrors({}); setPicAvail(null); setCfStatus(null); setDnsStatus(null); }} />
))}
</div>
{/* pic.ngo subdomain name */}
{domainType === 'pic_ngo' && (
<div className="mt-4 space-y-3">
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Subdomain <span className="text-red-400">*</span>
</label>
<input type="text" autoComplete="off" spellCheck={false}
value={picName}
onChange={e => { 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) && (
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
<Globe className="h-3.5 w-3.5" />
<span className="font-mono">{picName}.pic.ngo</span>
{picAvail === 'checking' && <span className="text-gray-400 ml-1">checking</span>}
{picAvail === 'available' && <span className="text-green-400 ml-1"> available</span>}
{picAvail === 'taken' && <span className="text-yellow-400 ml-1"> may already be taken</span>}
</p>
)}
<FieldError message={errors.name} />
</div>
</div>
)}
{/* Cloudflare */}
{domainType === 'cloudflare' && (
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">Domain <span className="text-red-400">*</span></label>
<input type="text" autoComplete="off"
value={customDomain}
onChange={e => { 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" />
<FieldError message={errors.domain} />
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">Cloudflare API token <span className="text-red-400">*</span></label>
<input type="password" autoComplete="off"
value={cloudflareToken}
onChange={e => { 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" />
<p className="mt-1 text-xs text-gray-500">Cloudflare Dashboard My Profile API Tokens Create Token</p>
{cloudflareToken.trim() && <TokenVerifyButton onVerify={verifyCf} status={cfStatus} />}
<FieldError message={errors.token} />
</div>
</div>
)}
{/* DuckDNS */}
{domainType === 'duckdns' && (
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">DuckDNS subdomain <span className="text-red-400">*</span></label>
<div className="flex items-center gap-2">
<input type="text" autoComplete="off"
value={duckSub}
onChange={e => { 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" />
<span className="text-gray-500 text-sm">.duckdns.org</span>
</div>
<FieldError message={errors.name} />
</div>
<div>
<label className="block text-sm text-gray-400 mb-1.5">DuckDNS token <span className="text-red-400">*</span></label>
<input type="password" autoComplete="off"
value={duckdnsToken}
onChange={e => { 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" />
<p className="mt-1 text-xs text-gray-500">The subdomain must already exist in your DuckDNS account.</p>
{duckdnsToken.trim() && <TokenVerifyButton onVerify={verifyDns} status={dnsStatus} />}
<FieldError message={errors.token} />
</div>
</div>
)}
{/* HTTP-01 */}
{domainType === 'http01' && (
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">Domain <span className="text-red-400">*</span></label>
<input type="text" autoComplete="off"
value={customDomain}
onChange={e => { 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" />
<FieldError message={errors.domain} />
</div>
<div className="p-3 bg-yellow-950/40 border border-yellow-700/50 rounded-lg">
<p className="text-xs text-yellow-300">
<span className="font-semibold">Port 80 must be publicly reachable</span> for Let's Encrypt HTTP-01.
Ensure your router forwards port 80 to this machine before completing setup.
</p>
</div>
</div>
)}
<NavButtons onBack={onBack} onNext={handleNext} loading={nextLoading} />
</div>
);
}
// ── 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 (
<div>
<StepHeader step={3} title="Timezone" description="Used for log timestamps, cron jobs, and email headers." />
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="tz-search">Search timezone</label>
<input id="tz-search" type="text" value={query} onChange={e => 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" />
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="tz-select">
Select timezone <span className="text-red-400">*</span>
</label>
<select id="tz-select" value={value} onChange={e => onChange(e.target.value)} size={8}
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">
{filtered.map(z => <option key={z} value={z}>{z}</option>)}
</select>
{value && <p className="mt-2 text-xs text-gray-400">Selected: <span className="text-white">{value}</span></p>}
</div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
</div>
);
}
// ── step 4: review ────────────────────────────────────────────────────────────
function ReviewRow({ label, value }) {
return (
<div className="flex justify-between py-2.5 border-b border-gray-800 last:border-0">
<span className="text-sm text-gray-400">{label}</span>
<span className="text-sm text-white font-medium text-right max-w-[60%] break-words">{value}</span>
</div>
);
}
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 (
<div>
<StepHeader step={4} title="Review and finish"
description="Check your choices. You can go back to change anything." />
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
<ReviewRow label="Admin password" value="••••••••••••" />
<ReviewRow label="Domain" value={domainDisplay} />
<ReviewRow label="Provider" value={providerLabel} />
<ReviewRow label="Timezone" value={timezone} />
</div>
{submitError && (
<div className="mt-4 p-3 bg-red-950/50 border border-red-700 rounded-lg flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-300">{submitError}</p>
</div>
)}
<NavButtons onBack={onBack} onNext={onSubmit} nextLabel="Complete setup" loading={submitting} />
</div>
);
}
// ── 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 (
<div className="flex items-center justify-center min-h-screen bg-gray-950">
<div className="text-center max-w-md px-4">
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" />
<h2 className="text-lg font-semibold text-white mb-2">Setup complete!</h2>
{setupWarnings.length > 0 ? (
<div className="mt-4 text-left space-y-3">
{setupWarnings.map((w, i) => (
<div key={i} className="flex gap-2 bg-amber-900/40 border border-amber-700 rounded-lg p-3">
<AlertCircle className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-200">{w}</p>
</div>
))}
<button
onClick={() => navigate('/login', { replace: true })}
className="mt-4 w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium"
>
Continue to login
</button>
</div>
) : (
<p className="text-sm text-gray-400">Redirecting to login...</p>
)}
</div>
</div>
);
}
const domainName = getDomainName(domainType, picName, duckSub, customDomain);
return (
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10">
<div className="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl">
<div className="mb-6">
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1>
<p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
</div>
<ProgressBar step={step} />
{step === 1 && (
<Step1Password
password={password} confirm={passwordConfirm}
onChangePassword={setPassword} onChangeConfirm={setPasswordConfirm}
onNext={goNext}
/>
)}
{step === 2 && (
<Step2Domain
domainType={domainType} onDomainType={setDomainType}
picName={picName} onPicName={setPicName}
customDomain={customDomain} onCustomDomain={setCustomDomain}
duckSub={duckSub} onDuckSub={setDuckSub}
cloudflareToken={cloudflareToken} onCloudflareToken={setCloudflareToken}
duckdnsToken={duckdnsToken} onDuckdnsToken={setDuckdnsToken}
onNext={goNext} onBack={goBack}
/>
)}
{step === 3 && (
<Step3Timezone value={timezone} onChange={setTimezone} onNext={goNext} onBack={goBack} />
)}
{step === 4 && (
<Step4Review
domainType={domainType} domainName={domainName}
timezone={timezone}
onBack={goBack} onSubmit={handleSubmit}
submitting={submitting} submitError={submitError}
/>
)}
</div>
</div>
);
}