- {/* Domain name */}
-
-
- {
- 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"
- />
-
-
+
- {/* TLS method */}
-
-
-
- {CUSTOM_METHOD_OPTIONS.map(opt => (
- {
- onCustomMethod(v);
- setErrors(p => ({ ...p, method: '', token: '' }));
- }}
- />
- ))}
-
-
-
+
+ {DOMAIN_OPTIONS.map(opt => (
+ { onDomainType(v); setErrors({}); setPicAvail(null); setCfStatus(null); setDnsStatus(null); }} />
+ ))}
+
- {/* Cloudflare token */}
- {customMethod === 'cloudflare' && (
+ {/* 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="Cloudflare API token with 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"
- />
-
- Create at Cloudflare Dashboard → My Profile → API Tokens. Needs Zone / DNS / Edit.
-
- {cloudflareToken.trim() && (
-
- )}
+ 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" />
+
Cloudflare Dashboard → My Profile → API Tokens → Create Token
+ {cloudflareToken.trim() &&
}
- )}
+
+ )}
- {/* DuckDNS token */}
- {customMethod === 'duckdns' && (
+ {/* DuckDNS */}
+ {domainType === 'duckdns' && (
+
-
-
DuckDNS subdomain
*
+
+ { 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="Your DuckDNS account token"
- 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"
- />
-
- Found at duckdns.org after login. The subdomain must already exist in your account.
-
- {duckdnsToken.trim() && (
-
- )}
+ 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" />
+
The subdomain must already exist in your DuckDNS account.
+ {duckdnsToken.trim() &&
}
- )}
+
+ )}
- {/* HTTP-01 info */}
- {customMethod === 'http01' && (
+ {/* 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 from the
- internet for Let's Encrypt HTTP-01 validation. Ensure your router forwards port 80
- to this machine before completing setup.
+ Port 80 must be publicly reachable for Let's Encrypt HTTP-01.
+ Ensure your router forwards port 80 to this machine before completing setup.
- )}
-
-
-
-
- );
-}
-
-function Step5Services({ selected, onChange, onNext, onBack }) {
- const toggle = key => {
- onChange(
- selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key]
- );
- };
-
- return (
-
);
}
-function Step6Timezone({ value, onChange, onNext, onBack }) {
+// ── step 3: timezone ──────────────────────────────────────────────────────────
+
+function Step3Timezone({ value, onChange, onNext, onBack }) {
const [query, setQuery] = useState('');
const allZones = useMemo(() => getAllTimezones(), []);
const filtered = useMemo(() => {
@@ -705,48 +458,69 @@ function Step6Timezone({ value, onChange, onNext, onBack }) {
return (
);
}
+// ── step 4: services ──────────────────────────────────────────────────────────
+
+function Step4Services({ selected, onChange, onNext, onBack }) {
+ const toggle = key => onChange(selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key]);
+
+ return (
+
+ );
+}
+
+// ── step 5: review ────────────────────────────────────────────────────────────
+
function ReviewRow({ label, value }) {
return (
@@ -756,52 +530,31 @@ function ReviewRow({ label, value }) {
);
}
-function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
- const domainDisplay =
- fields.domain_type === 'pic_ngo' ? `${fields.cell_name}.pic.ngo` :
- fields.domain_type === 'lan' ? 'LAN only (no public domain)' :
- fields.custom_domain || '(not set)';
-
- const tlsDisplay =
- fields.domain_type === 'pic_ngo' ? 'Automatic (pic.ngo)' :
- fields.domain_type === 'lan' ? '—' :
- CUSTOM_METHOD_OPTIONS.find(o => o.value === fields.custom_method)?.label || fields.custom_method;
-
- const serviceLabels = (fields.services_enabled || []).length
- ? fields.services_enabled.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
+function Step5Review({ domainType, domainName, services, timezone, onBack, onSubmit, submitting, submitError }) {
+ const domainDisplay = domainName || 'LAN only (no public domain)';
+ const providerLabel = DOMAIN_OPTIONS.find(o => o.value === domainType)?.label || domainType;
+ const serviceLabels = services.length
+ ? services.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
: 'None selected';
return (
-
+
-
- {fields.domain_type !== 'lan' && (
-
- )}
+
+
-
-
{submitError && (
)}
-
-
+
);
}
@@ -810,77 +563,65 @@ function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
export default function Setup() {
const navigate = useNavigate();
- const [step, setStep] = useState(1);
- const [done, setDone] = useState(false);
+ const [step, setStep] = useState(1);
+ const [done, setDone] = useState(false);
- // Form state
- const [cellName, setCellName] = useState('');
- const [password, setPassword] = useState('');
+ const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
- const [domainType, setDomainType] = useState('pic_ngo');
- const [customDomain, setCustomDomain] = useState('');
- const [customMethod, setCustomMethod] = 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 [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
+ const [duckdnsToken, setDuckdnsToken] = useState('');
+
const [timezone, setTimezone] = useState(
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
);
+ const [services, setServices] = useState([]);
- // Submit state
- const [submitting, setSubmitting] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
- // Pre-fill from any existing config (e.g. from a previous partial run)
+ // Pre-fill from any previously saved config
useEffect(() => {
setupAPI.getStatus()
.then(res => {
const pre = res.data?.preconfigured;
if (!pre) return;
- if (pre.cell_name) setCellName(pre.cell_name);
- if (pre.domain_mode) {
- if (pre.domain_mode === 'pic_ngo') setDomainType('pic_ngo');
- else if (pre.domain_mode === 'lan') setDomainType('lan');
- else { setDomainType('custom'); setCustomMethod(pre.domain_mode); }
- }
- if (pre.domain_name) setCustomDomain(pre.domain_name);
+ 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 skipStep4 = domainType === 'lan';
-
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
const goBack = () => setStep(s => Math.max(s - 1, 1));
- const handleStep3Next = () => skipStep4 ? setStep(5) : setStep(4);
- const handleStep4Back = () => setStep(3);
- const handleStep5Back = () => skipStep4 ? setStep(3) : setStep(4);
-
const handleSubmit = async () => {
setSubmitError('');
setSubmitting(true);
- const domainMode = getDomainMode(domainType, customMethod);
- const domainName =
- domainType === 'pic_ngo' ? `${cellName}.pic.ngo` :
- domainType === 'lan' ? '' :
- customDomain;
+ 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,
+ cell_name: cellName,
password,
- domain_mode: domainMode,
- domain_name: domainName,
+ domain_mode: domainType,
+ domain_name: domainName,
timezone,
services_enabled: services,
- ...(domainType !== 'lan' && {
- ddns_provider: domainType === 'pic_ngo' ? 'pic_ngo' : customMethod === 'http01' ? 'none' : customMethod,
- }),
- ...(customMethod === 'cloudflare' && { cloudflare_api_token: cloudflareToken }),
- ...(customMethod === 'duckdns' && { duckdns_token: duckdnsToken }),
+ ddns_provider: ddnsProvider,
+ ...(domainType === 'cloudflare' && { cloudflare_api_token: cloudflareToken }),
+ ...(domainType === 'duckdns' && { duckdns_token: duckdnsToken }),
};
try {
@@ -898,15 +639,6 @@ export default function Setup() {
}
};
- const reviewFields = {
- cell_name: cellName,
- domain_type: domainType,
- custom_domain: customDomain,
- custom_method: customMethod,
- services_enabled: services,
- timezone,
- };
-
if (done) {
return (
@@ -919,6 +651,8 @@ export default function Setup() {
);
}
+ const domainName = getDomainName(domainType, picName, duckSub, customDomain);
+
return (
@@ -930,50 +664,35 @@ export default function Setup() {
{step === 1 && (
-
+
)}
{step === 2 && (
-
)}
{step === 3 && (
-
+
)}
{step === 4 && (
-
+
)}
{step === 5 && (
-
- )}
- {step === 6 && (
-
- )}
- {step === 7 && (
-
)}