Files
pic/webui/src/pages/Setup.jsx
T
2026-05-09 08:05:38 -04:00

708 lines
25 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 } 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>
);
}