Overhaul setup wizard: domain config, password strength, field alignment
Unit Tests / test (push) Successful in 8m48s
Unit Tests / test (push) Successful in 8m48s
Password: - Add lowercase to strength scoring; "Good" now requires all API criteria (12 chars, upper, lower, digit) — no more submitting passwords the API rejects - isReady gates the Next button on meeting API requirements, not just length Domain steps 3 + 4: - Step 3: choose pic_ngo / custom / lan (sends valid API domain_modes) - Step 4 (pic.ngo): shows derived [cellName].pic.ngo domain preview - Step 4 (custom): domain name field + TLS method selector (Cloudflare DNS-01 + API token, DuckDNS + token, HTTP-01 + port-80 warning) - Step 4 skipped entirely for LAN-only - Review step shows actual domain string and TLS method instead of opaque codes Cell name: - Description and preview hint make clear it becomes the pic.ngo subdomain - Step 1 shows live "name.pic.ngo" preview as you type Backend: - setup_manager now accepts and stores domain_name, cloudflare_api_token, duckdns_token for Phase 3 DDNS registration use Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-5
@@ -128,9 +128,12 @@ class SetupManager:
|
|||||||
cell_name = payload.get('cell_name', '')
|
cell_name = payload.get('cell_name', '')
|
||||||
password = payload.get('password', '')
|
password = payload.get('password', '')
|
||||||
domain_mode = payload.get('domain_mode', '')
|
domain_mode = payload.get('domain_mode', '')
|
||||||
|
domain_name = payload.get('domain_name', '')
|
||||||
timezone = payload.get('timezone', '')
|
timezone = payload.get('timezone', '')
|
||||||
services_enabled = payload.get('services_enabled', [])
|
services_enabled = payload.get('services_enabled', [])
|
||||||
ddns_provider = payload.get('ddns_provider', 'none')
|
ddns_provider = payload.get('ddns_provider', 'none')
|
||||||
|
cloudflare_api_token = payload.get('cloudflare_api_token', '')
|
||||||
|
duckdns_token = payload.get('duckdns_token', '')
|
||||||
|
|
||||||
errors.extend(self.validate_cell_name(cell_name))
|
errors.extend(self.validate_cell_name(cell_name))
|
||||||
errors.extend(self.validate_password(password))
|
errors.extend(self.validate_password(password))
|
||||||
@@ -185,16 +188,19 @@ class SetupManager:
|
|||||||
# ── persist identity fields ────────────────────────────────────
|
# ── persist identity fields ────────────────────────────────────
|
||||||
self.config_manager.set_identity_field('cell_name', cell_name)
|
self.config_manager.set_identity_field('cell_name', cell_name)
|
||||||
self.config_manager.set_identity_field('domain_mode', domain_mode)
|
self.config_manager.set_identity_field('domain_mode', domain_mode)
|
||||||
|
if domain_name:
|
||||||
|
self.config_manager.set_identity_field('domain_name', domain_name)
|
||||||
self.config_manager.set_identity_field('timezone', timezone)
|
self.config_manager.set_identity_field('timezone', timezone)
|
||||||
self.config_manager.set_identity_field('services_enabled', services_enabled)
|
self.config_manager.set_identity_field('services_enabled', services_enabled)
|
||||||
self.config_manager.set_identity_field('ddns_provider', ddns_provider)
|
self.config_manager.set_identity_field('ddns_provider', ddns_provider)
|
||||||
|
if cloudflare_api_token:
|
||||||
|
self.config_manager.set_identity_field('cloudflare_api_token', cloudflare_api_token)
|
||||||
|
if duckdns_token:
|
||||||
|
self.config_manager.set_identity_field('duckdns_token', duckdns_token)
|
||||||
|
|
||||||
# NOTE: DDNS registration is deferred to Phase 3.
|
|
||||||
# For now we just store ddns_provider in config.
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'DDNS registration skipped (Phase 1). '
|
'DDNS registration deferred to Phase 3. '
|
||||||
'DDNS registration will happen in Phase 3. '
|
f'ddns_provider={ddns_provider!r} domain_name={domain_name!r}'
|
||||||
f'ddns_provider={ddns_provider!r} stored in identity config.'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── mark setup complete (must be last) ─────────────────────────
|
# ── mark setup complete (must be last) ─────────────────────────
|
||||||
|
|||||||
+283
-69
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
|
import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react';
|
||||||
import { setupAPI } from '../services/api';
|
import { setupAPI } from '../services/api';
|
||||||
|
|
||||||
// ── constants ─────────────────────────────────────────────────────────────────
|
// ── constants ─────────────────────────────────────────────────────────────────
|
||||||
@@ -13,27 +13,36 @@ const DOMAIN_OPTIONS = [
|
|||||||
{
|
{
|
||||||
value: 'pic_ngo',
|
value: 'pic_ngo',
|
||||||
label: 'PIC.NGO subdomain',
|
label: 'PIC.NGO subdomain',
|
||||||
description: 'Get a free yourname.pic.ngo domain — managed automatically.',
|
description: 'Get a free yourname.pic.ngo address — HTTPS and DDNS managed automatically.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'custom',
|
value: 'custom',
|
||||||
label: 'Custom domain',
|
label: 'Custom domain',
|
||||||
description: 'Bring your own domain. You will configure DNS records manually.',
|
description: 'Use your own domain with Cloudflare, DuckDNS, or standard HTTP challenge.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'lan_only',
|
value: 'lan',
|
||||||
label: 'LAN only',
|
label: 'LAN only',
|
||||||
description: 'No public domain. Accessible only on your local network and via VPN.',
|
description: 'No public domain. Accessible only on your local network and via VPN.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DDNS_OPTIONS = [
|
const CUSTOM_METHOD_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: 'cloudflare',
|
||||||
{ value: 'duckdns', label: 'DuckDNS', description: 'Free dynamic DNS via duckdns.org.' },
|
label: 'Cloudflare DNS',
|
||||||
{ value: 'noip', label: 'No-IP', description: 'Free dynamic DNS via noip.com.' },
|
description: 'DNS-01 via Cloudflare API. Your domain must use Cloudflare nameservers.',
|
||||||
{ value: 'freedns', label: 'FreeDNS', description: 'Free DNS via freedns.afraid.org.' },
|
},
|
||||||
{ value: 'manual', label: 'Manual / None', description: 'You will handle DNS updates yourself.' },
|
{
|
||||||
|
value: 'duckdns',
|
||||||
|
label: 'DuckDNS',
|
||||||
|
description: 'Free subdomain via duckdns.org with automatic DNS-01 challenge.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'http01',
|
||||||
|
label: 'HTTP-01 (any registrar)',
|
||||||
|
description: 'Standard ACME challenge. Port 80 must be publicly reachable.',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const OPTIONAL_SERVICES = [
|
const OPTIONAL_SERVICES = [
|
||||||
@@ -55,35 +64,37 @@ function getAllTimezones() {
|
|||||||
try {
|
try {
|
||||||
return Intl.supportedValuesOf('timeZone');
|
return Intl.supportedValuesOf('timeZone');
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback list for older browsers
|
|
||||||
return [
|
return [
|
||||||
'UTC',
|
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
|
||||||
'America/New_York',
|
'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin',
|
||||||
'America/Chicago',
|
'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney',
|
||||||
'America/Denver',
|
|
||||||
'America/Los_Angeles',
|
|
||||||
'Europe/London',
|
|
||||||
'Europe/Paris',
|
|
||||||
'Europe/Berlin',
|
|
||||||
'Asia/Tokyo',
|
|
||||||
'Asia/Shanghai',
|
|
||||||
'Australia/Sydney',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function passwordStrength(pw) {
|
function passwordStrength(pw) {
|
||||||
if (!pw) return { label: '', color: '', width: '0%' };
|
if (!pw) return { label: '', color: '', width: '0%', score: 0 };
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (pw.length >= 12) score++;
|
if (pw.length >= 12) score++;
|
||||||
if (pw.length >= 16) score++;
|
if (pw.length >= 16) score++;
|
||||||
if (/[A-Z]/.test(pw)) score++;
|
if (/[A-Z]/.test(pw)) score++;
|
||||||
|
if (/[a-z]/.test(pw)) score++;
|
||||||
if (/[0-9]/.test(pw)) score++;
|
if (/[0-9]/.test(pw)) score++;
|
||||||
if (/[^A-Za-z0-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: 'Weak', color: 'bg-red-500', width: '20%', score };
|
||||||
if (score === 2) return { label: 'Fair', color: 'bg-yellow-500', width: '40%' };
|
if (score === 3) return { label: 'Fair', color: 'bg-yellow-500', width: '45%', score };
|
||||||
if (score === 3) return { label: 'Good', color: 'bg-blue-500', width: '65%' };
|
if (score === 4) return { label: 'Good', color: 'bg-blue-500', width: '70%', score };
|
||||||
return { label: 'Strong', color: 'bg-green-500', width: '100%' };
|
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 getDomainMode(domainType, customMethod) {
|
||||||
|
if (domainType === 'pic_ngo') return 'pic_ngo';
|
||||||
|
if (domainType === 'lan') return 'lan';
|
||||||
|
return customMethod || 'http01';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── sub-components ────────────────────────────────────────────────────────────
|
// ── sub-components ────────────────────────────────────────────────────────────
|
||||||
@@ -218,12 +229,14 @@ function Step1CellName({ value, onChange, onNext }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isValid = CELL_NAME_RE.test(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
step={1}
|
step={1}
|
||||||
title="Name your cell"
|
title="Name your cell"
|
||||||
description="This is the internal identifier for your Personal Internet Cell. It appears in hostnames and logs."
|
description="This becomes your cell's identity and subdomain. If you choose pic.ngo it will be reachable at name.pic.ngo."
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
|
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
|
||||||
@@ -241,13 +254,20 @@ function Step1CellName({ value, onChange, onNext }) {
|
|||||||
setServerError('');
|
setServerError('');
|
||||||
}}
|
}}
|
||||||
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
||||||
placeholder="e.g. homelab"
|
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"
|
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}
|
aria-describedby={error || serverError ? 'cell-name-error' : undefined}
|
||||||
/>
|
/>
|
||||||
|
{isValid ? (
|
||||||
|
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
pic.ngo preview: <span className="font-mono font-medium ml-1">{value}.pic.ngo</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.
|
Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
<div id="cell-name-error">
|
<div id="cell-name-error">
|
||||||
<FieldError message={error || serverError} />
|
<FieldError message={error || serverError} />
|
||||||
</div>
|
</div>
|
||||||
@@ -263,11 +283,18 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const strength = passwordStrength(password);
|
const strength = passwordStrength(password);
|
||||||
|
const ready = meetsApiRequirements(password) && password === confirm;
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const e = {};
|
const e = {};
|
||||||
if (!password) e.password = 'Password is required.';
|
if (!password) {
|
||||||
else if (password.length < 12) e.password = 'Password must be at least 12 characters.';
|
e.password = 'Password is required.';
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
if (password.length < 12) e.password = 'Must be at least 12 characters.';
|
||||||
|
else if (!/[A-Z]/.test(password)) e.password = 'Must contain at least one uppercase letter.';
|
||||||
|
else if (!/[a-z]/.test(password)) e.password = 'Must contain at least one lowercase letter.';
|
||||||
|
else if (!/[0-9]/.test(password)) e.password = 'Must contain at least one digit.';
|
||||||
if (!confirm) e.confirm = 'Please confirm your password.';
|
if (!confirm) e.confirm = 'Please confirm your password.';
|
||||||
else if (password !== confirm) e.confirm = 'Passwords do not match.';
|
else if (password !== confirm) e.confirm = 'Passwords do not match.';
|
||||||
return e;
|
return e;
|
||||||
@@ -279,14 +306,12 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
if (Object.keys(e).length === 0) onNext();
|
if (Object.keys(e).length === 0) onNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
const isReady = password.length >= 12 && password === confirm;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
step={2}
|
step={2}
|
||||||
title="Set admin password"
|
title="Set admin password"
|
||||||
description="This password protects access to your cell. Choose something strong and store it safely."
|
description="This password protects access to your cell. At least 12 characters with uppercase, lowercase, and a digit."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -313,7 +338,6 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Strength bar */}
|
|
||||||
{password.length > 0 && (
|
{password.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="w-full bg-gray-700 rounded-full h-1">
|
<div className="w-full bg-gray-700 rounded-full h-1">
|
||||||
@@ -356,7 +380,7 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
|
|||||||
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
|
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
|
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!ready} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -386,27 +410,182 @@ function Step3Domain({ value, onChange, onNext, onBack }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step4DDNS({ value, onChange, onNext, onBack }) {
|
function Step4DomainConfig({
|
||||||
|
domainType, cellName,
|
||||||
|
customDomain, onCustomDomain,
|
||||||
|
customMethod, onCustomMethod,
|
||||||
|
cloudflareToken, onCloudflareToken,
|
||||||
|
duckdnsToken, onDuckdnsToken,
|
||||||
|
onNext, onBack,
|
||||||
|
}) {
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
// ── pic_ngo: just show the derived domain ────────────────────────────────
|
||||||
|
if (domainType === 'pic_ngo') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
step={4}
|
step={4}
|
||||||
title="DDNS provider"
|
title="Your pic.ngo domain"
|
||||||
description="Which provider will keep your dynamic IP address up to date? Credentials are configured separately after setup."
|
description="Your cell will be reachable at the address below. HTTPS and DDNS are managed automatically."
|
||||||
/>
|
/>
|
||||||
|
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-5 text-center mb-4">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Your public address</p>
|
||||||
|
<p className="text-2xl font-mono font-semibold text-white tracking-tight">
|
||||||
|
{cellName || '…'}.pic.ngo
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-3">
|
||||||
|
DNS and TLS certificates are provisioned automatically via the pic.ngo API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Not the right name? Go back to step 1 to change your cell name.
|
||||||
|
</p>
|
||||||
|
<NavButtons onBack={onBack} onNext={onNext} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── custom domain ─────────────────────────────────────────────────────────
|
||||||
|
const validateCustom = () => {
|
||||||
|
const e = {};
|
||||||
|
const dom = customDomain.trim();
|
||||||
|
if (!dom) {
|
||||||
|
e.domain = 'Domain name is required.';
|
||||||
|
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(dom)) {
|
||||||
|
e.domain = 'Enter a valid domain name (e.g. home.example.com).';
|
||||||
|
}
|
||||||
|
if (!customMethod) e.method = 'Select a TLS method.';
|
||||||
|
if (customMethod === 'cloudflare' && !cloudflareToken.trim())
|
||||||
|
e.token = 'Cloudflare API token is required.';
|
||||||
|
if (customMethod === 'duckdns' && !duckdnsToken.trim())
|
||||||
|
e.token = 'DuckDNS token is required.';
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const e = validateCustom();
|
||||||
|
setErrors(e);
|
||||||
|
if (Object.keys(e).length === 0) onNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReady =
|
||||||
|
customDomain.trim() &&
|
||||||
|
customMethod &&
|
||||||
|
(customMethod === 'http01' ||
|
||||||
|
(customMethod === 'cloudflare' && cloudflareToken.trim()) ||
|
||||||
|
(customMethod === 'duckdns' && duckdnsToken.trim()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StepHeader
|
||||||
|
step={4}
|
||||||
|
title="Domain configuration"
|
||||||
|
description="Enter your domain and choose how TLS certificates will be obtained."
|
||||||
|
/>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Domain name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
Domain name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* TLS method */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1.5">
|
||||||
|
TLS method <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{DDNS_OPTIONS.map(opt => (
|
{CUSTOM_METHOD_OPTIONS.map(opt => (
|
||||||
<RadioOption
|
<RadioOption
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
description={opt.description}
|
description={opt.description}
|
||||||
selected={value === opt.value}
|
selected={customMethod === opt.value}
|
||||||
onChange={onChange}
|
onChange={v => {
|
||||||
|
onCustomMethod(v);
|
||||||
|
setErrors(p => ({ ...p, method: '', token: '' }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
|
<FieldError message={errors.method} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cloudflare token */}
|
||||||
|
{customMethod === 'cloudflare' && (
|
||||||
|
<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: '' }));
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Create at Cloudflare Dashboard → My Profile → API Tokens. Needs Zone / DNS / Edit.
|
||||||
|
</p>
|
||||||
|
<FieldError message={errors.token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DuckDNS token */}
|
||||||
|
{customMethod === 'duckdns' && (
|
||||||
|
<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: '' }));
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Found at duckdns.org after login. The subdomain must already exist in your account.
|
||||||
|
</p>
|
||||||
|
<FieldError message={errors.token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HTTP-01 info */}
|
||||||
|
{customMethod === 'http01' && (
|
||||||
|
<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> from the
|
||||||
|
internet for Let's Encrypt HTTP-01 validation. Ensure your router forwards port 80
|
||||||
|
to this machine before completing setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -426,7 +605,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
|
|||||||
description="Choose which services to enable. You can change this later in Settings."
|
description="Choose which services to enable. You can change this later in Settings."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Optional services */}
|
|
||||||
<div className="space-y-2 mb-6">
|
<div className="space-y-2 mb-6">
|
||||||
{OPTIONAL_SERVICES.map(svc => {
|
{OPTIONAL_SERVICES.map(svc => {
|
||||||
const checked = selected.includes(svc.key);
|
const checked = selected.includes(svc.key);
|
||||||
@@ -452,7 +630,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Always-on services */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||||
Always enabled
|
Always enabled
|
||||||
@@ -538,8 +715,16 @@ function ReviewRow({ label, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
|
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
|
||||||
const domainLabel = DOMAIN_OPTIONS.find(o => o.value === fields.domain_mode)?.label || fields.domain_mode;
|
const domainDisplay =
|
||||||
const ddnsLabel = DDNS_OPTIONS.find(o => o.value === fields.ddns_provider)?.label || fields.ddns_provider;
|
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
|
const serviceLabels = (fields.services_enabled || []).length
|
||||||
? fields.services_enabled.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
|
? fields.services_enabled.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
|
||||||
: 'None selected';
|
: 'None selected';
|
||||||
@@ -554,9 +739,9 @@ function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
|
|||||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
|
<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="Cell name" value={fields.cell_name} />
|
||||||
<ReviewRow label="Admin password" value="••••••••••••" />
|
<ReviewRow label="Admin password" value="••••••••••••" />
|
||||||
<ReviewRow label="Domain type" value={domainLabel} />
|
<ReviewRow label="Domain" value={domainDisplay} />
|
||||||
{fields.domain_mode !== 'lan_only' && (
|
{fields.domain_type !== 'lan' && (
|
||||||
<ReviewRow label="DDNS provider" value={ddnsLabel} />
|
<ReviewRow label="TLS / DNS" value={tlsDisplay} />
|
||||||
)}
|
)}
|
||||||
<ReviewRow label="Optional services" value={serviceLabels} />
|
<ReviewRow label="Optional services" value={serviceLabels} />
|
||||||
<ReviewRow label="Timezone" value={fields.timezone} />
|
<ReviewRow label="Timezone" value={fields.timezone} />
|
||||||
@@ -591,7 +776,10 @@ export default function Setup() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||||
const [domainType, setDomainType] = useState('pic_ngo');
|
const [domainType, setDomainType] = useState('pic_ngo');
|
||||||
const [ddnsProvider, setDdnsProvider] = useState('pic_ngo');
|
const [customDomain, setCustomDomain] = useState('');
|
||||||
|
const [customMethod, setCustomMethod] = useState('');
|
||||||
|
const [cloudflareToken, setCloudflareToken] = useState('');
|
||||||
|
const [duckdnsToken, setDuckdnsToken] = useState('');
|
||||||
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
|
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
|
||||||
const [timezone, setTimezone] = useState(
|
const [timezone, setTimezone] = useState(
|
||||||
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
|
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
|
||||||
@@ -601,39 +789,46 @@ export default function Setup() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
|
||||||
const skipDdns = domainType === 'lan_only';
|
const skipStep4 = domainType === 'lan';
|
||||||
|
|
||||||
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
|
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
|
||||||
const goBack = () => setStep(s => Math.max(s - 1, 1));
|
const goBack = () => setStep(s => Math.max(s - 1, 1));
|
||||||
|
|
||||||
// Skip step 4 when LAN only
|
const handleStep3Next = () => skipStep4 ? setStep(5) : setStep(4);
|
||||||
const handleStep3Next = () => {
|
|
||||||
if (skipDdns) setStep(5);
|
|
||||||
else setStep(4);
|
|
||||||
};
|
|
||||||
const handleStep4Back = () => setStep(3);
|
const handleStep4Back = () => setStep(3);
|
||||||
const handleStep5Back = () => {
|
const handleStep5Back = () => skipStep4 ? setStep(3) : setStep(4);
|
||||||
if (skipDdns) setStep(3);
|
|
||||||
else setStep(4);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSubmitError('');
|
setSubmitError('');
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
|
const domainMode = getDomainMode(domainType, customMethod);
|
||||||
|
const domainName =
|
||||||
|
domainType === 'pic_ngo' ? `${cellName}.pic.ngo` :
|
||||||
|
domainType === 'lan' ? '' :
|
||||||
|
customDomain;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
cell_name: cellName,
|
cell_name: cellName,
|
||||||
password,
|
password,
|
||||||
domain_mode: domainType,
|
domain_mode: domainMode,
|
||||||
...(skipDdns ? {} : { ddns_provider: ddnsProvider }),
|
domain_name: domainName,
|
||||||
services_enabled: services,
|
|
||||||
timezone,
|
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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setupAPI.complete(payload);
|
await setupAPI.complete(payload);
|
||||||
setDone(true);
|
setDone(true);
|
||||||
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSubmitError(
|
setSubmitError(
|
||||||
|
e?.response?.data?.errors?.join(' ') ||
|
||||||
e?.response?.data?.error ||
|
e?.response?.data?.error ||
|
||||||
'Setup could not be completed. Please check your entries and try again.'
|
'Setup could not be completed. Please check your entries and try again.'
|
||||||
);
|
);
|
||||||
@@ -642,7 +837,14 @@ export default function Setup() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const allFields = { cell_name: cellName, domain_mode: domainType, ddns_provider: ddnsProvider, services_enabled: services, timezone };
|
const reviewFields = {
|
||||||
|
cell_name: cellName,
|
||||||
|
domain_type: domainType,
|
||||||
|
custom_domain: customDomain,
|
||||||
|
custom_method: customMethod,
|
||||||
|
services_enabled: services,
|
||||||
|
timezone,
|
||||||
|
};
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
return (
|
return (
|
||||||
@@ -659,7 +861,6 @@ export default function Setup() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10">
|
<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="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl">
|
||||||
{/* Page title */}
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1>
|
<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>
|
<p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
|
||||||
@@ -684,7 +885,20 @@ export default function Setup() {
|
|||||||
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
|
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
|
||||||
)}
|
)}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<Step4DDNS value={ddnsProvider} onChange={setDdnsProvider} onNext={goNext} onBack={handleStep4Back} />
|
<Step4DomainConfig
|
||||||
|
domainType={domainType}
|
||||||
|
cellName={cellName}
|
||||||
|
customDomain={customDomain}
|
||||||
|
onCustomDomain={setCustomDomain}
|
||||||
|
customMethod={customMethod}
|
||||||
|
onCustomMethod={setCustomMethod}
|
||||||
|
cloudflareToken={cloudflareToken}
|
||||||
|
onCloudflareToken={setCloudflareToken}
|
||||||
|
duckdnsToken={duckdnsToken}
|
||||||
|
onDuckdnsToken={setDuckdnsToken}
|
||||||
|
onNext={goNext}
|
||||||
|
onBack={handleStep4Back}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 5 && (
|
{step === 5 && (
|
||||||
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
|
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
|
||||||
@@ -694,7 +908,7 @@ export default function Setup() {
|
|||||||
)}
|
)}
|
||||||
{step === 7 && (
|
{step === 7 && (
|
||||||
<Step7Review
|
<Step7Review
|
||||||
fields={allFields}
|
fields={reviewFields}
|
||||||
onBack={goBack}
|
onBack={goBack}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
|
|||||||
Reference in New Issue
Block a user