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:
@@ -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. 2–31 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. 2–31 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user