Phase 1: first-run setup wizard, bash installer, Docker profiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 08:05:38 -04:00
parent 6dbd0dff46
commit cf1b9672f4
12 changed files with 1754 additions and 16 deletions
+707
View File
@@ -0,0 +1,707 @@
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 (
<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>
);
}
// ── 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. 231 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 (
<div>
<StepHeader
step={1}
title="Name your cell"
description="This is the internal identifier for your Personal Internet Cell. It appears in hostnames and logs."
/>
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
Cell name <span className="text-red-400">*</span>
</label>
<input
id="cell-name"
type="text"
autoComplete="off"
spellCheck={false}
value={value}
onChange={e => {
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}
/>
<p className="mt-1 text-xs text-gray-500">
Lowercase letters, numbers, hyphens. Must start with a letter. 231 characters.
</p>
<div id="cell-name-error">
<FieldError message={error || serverError} />
</div>
</div>
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
</div>
);
}
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 (
<div>
<StepHeader
step={2}
title="Set admin password"
description="This password protects access to your cell. Choose something strong and store it safely."
/>
<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: '' })); }}
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}
/>
<button
type="button"
onClick={() => setShowPw(v => !v)}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-200"
tabIndex={-1}
aria-label={showPw ? 'Hide password' : 'Show password'}
>
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* Strength bar */}
{password.length > 0 && (
<div className="mt-2">
<div className="w-full bg-gray-700 rounded-full h-1">
<div
className={`h-1 rounded-full transition-all duration-300 ${strength.color}`}
style={{ width: strength.width }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">Strength: {strength.label}</p>
</div>
)}
<div id="pw-error"><FieldError message={errors.password} /></div>
</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={showConfirm ? '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-9 text-white text-sm focus:outline-none focus:border-blue-500"
aria-describedby={errors.confirm ? 'pw-confirm-error' : undefined}
/>
<button
type="button"
onClick={() => setShowConfirm(v => !v)}
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-200"
tabIndex={-1}
aria-label={showConfirm ? 'Hide password' : 'Show password'}
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
</div>
</div>
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
</div>
);
}
function Step3Domain({ value, onChange, onNext, onBack }) {
return (
<div>
<StepHeader
step={3}
title="Choose your domain"
description="How will you and your peers reach this cell over the internet?"
/>
<div className="space-y-2">
{DOMAIN_OPTIONS.map(opt => (
<RadioOption
key={opt.value}
value={opt.value}
label={opt.label}
description={opt.description}
selected={value === opt.value}
onChange={onChange}
/>
))}
</div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
</div>
);
}
function Step4DDNS({ value, onChange, onNext, onBack }) {
return (
<div>
<StepHeader
step={4}
title="DDNS provider"
description="Which provider will keep your dynamic IP address up to date? Credentials are configured separately after setup."
/>
<div className="space-y-2">
{DDNS_OPTIONS.map(opt => (
<RadioOption
key={opt.value}
value={opt.value}
label={opt.label}
description={opt.description}
selected={value === opt.value}
onChange={onChange}
/>
))}
</div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
</div>
);
}
function Step5Services({ selected, onChange, onNext, onBack }) {
const toggle = key => {
onChange(
selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key]
);
};
return (
<div>
<StepHeader
step={5}
title="Optional services"
description="Choose which services to enable. You can change this later in Settings."
/>
{/* Optional services */}
<div className="space-y-2 mb-6">
{OPTIONAL_SERVICES.map(svc => {
const checked = selected.includes(svc.key);
return (
<label
key={svc.key}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
checked ? 'border-blue-500 bg-blue-950/40' : 'border-gray-700 hover:border-gray-500'
}`}
>
<input
type="checkbox"
className="mt-0.5 accent-blue-500"
checked={checked}
onChange={() => toggle(svc.key)}
/>
<div>
<div className="text-sm font-medium text-white">{svc.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{svc.description}</div>
</div>
</label>
);
})}
</div>
{/* Always-on services */}
<div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Always enabled
</p>
<div className="space-y-1.5">
{ALWAYS_ON_SERVICES.map(svc => (
<div
key={svc.key}
className="flex items-center gap-3 p-3 rounded-lg border border-gray-800 bg-gray-900/40 opacity-60"
>
<input type="checkbox" checked readOnly disabled className="mt-0 accent-blue-500" aria-label={`${svc.label} is always enabled`} />
<span className="text-sm text-gray-400">{svc.label}</span>
<span className="ml-auto text-xs text-gray-600">always enabled</span>
</div>
))}
</div>
</div>
<NavButtons onBack={onBack} onNext={onNext} />
</div>
);
}
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 (
<div>
<StepHeader
step={6}
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>
);
}
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 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 (
<div>
<StepHeader
step={7}
title="Review and finish"
description="Check your choices below. You can go back to change anything before completing setup."
/>
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
<ReviewRow label="Cell name" value={fields.cell_name} />
<ReviewRow label="Admin password" value="••••••••••••" />
<ReviewRow label="Domain type" value={domainLabel} />
{fields.domain_type !== 'lan_only' && (
<ReviewRow label="DDNS provider" value={ddnsLabel} />
)}
<ReviewRow label="Optional services" value={serviceLabels} />
<ReviewRow label="Timezone" value={fields.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);
// 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 (
<div className="flex items-center justify-center min-h-screen bg-gray-950">
<div className="text-center">
<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>
<p className="text-sm text-gray-400">Redirecting to login...</p>
</div>
</div>
);
}
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">
{/* Page title */}
<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 && (
<Step1CellName value={cellName} onChange={setCellName} onNext={goNext} />
)}
{step === 2 && (
<Step2Password
password={password}
confirm={passwordConfirm}
onChangePassword={setPassword}
onChangeConfirm={setPasswordConfirm}
onNext={goNext}
onBack={goBack}
/>
)}
{step === 3 && (
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
)}
{step === 4 && (
<Step4DDNS value={ddnsProvider} onChange={setDdnsProvider} onNext={goNext} onBack={handleStep4Back} />
)}
{step === 5 && (
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
)}
{step === 6 && (
<Step6Timezone value={timezone} onChange={setTimezone} onNext={goNext} onBack={goBack} />
)}
{step === 7 && (
<Step7Review
fields={allFields}
onBack={goBack}
onSubmit={handleSubmit}
submitting={submitting}
submitError={submitError}
/>
)}
</div>
</div>
);
}