wizard: move all config to /setup; install.sh is infrastructure-only
Unit Tests / test (push) Successful in 15m41s

install.sh no longer prompts for anything. It installs packages (with sudo),
creates the system user, clones the repo, and runs 'make install' — all as
the invoking user. Only package installs and system-level ops use sudo.
All folder creation happens under the user's own account, no chown needed.

/setup wizard gains the missing validation that was previously in install.sh:
- Step 1: checks pic.ngo name availability via backend (non-blocking)
- Step 4: 'Verify token' button for Cloudflare and DuckDNS tokens,
  validated server-side through new /api/setup/validate steps

API changes (routes/setup.py):
- validate step 'pic_ngo_available': proxy check to ddns.pic.ngo
- validate step 'cloudflare_token': verify via Cloudflare tokens API
- validate step 'duckdns_token': verify via DuckDNS update endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:07:56 -04:00
parent 2d842abe5b
commit 4a42ff5dcc
3 changed files with 175 additions and 307 deletions
+70 -28
View File
@@ -200,43 +200,42 @@ function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false,
function Step1CellName({ value, onChange, onNext }) {
const [error, setError] = useState('');
const [serverError, setServerError] = useState('');
const [loading, setLoading] = useState(false);
const [availability, setAvailability] = useState(null); // null | 'available' | 'taken' | 'unknown'
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 isValid = CELL_NAME_RE.test(value);
const handleChange = v => {
onChange(v.toLowerCase());
setError('');
setAvailability(null);
};
const handleNext = async () => {
const err = validate();
setError(err);
setServerError('');
if (err) return;
if (!value.trim()) { setError('Cell name is required.'); return; }
if (!isValid) { setError('Use lowercase letters, numbers, and hyphens only. Must start with a letter. 231 characters.'); return; }
setLoading(true);
setError('');
try {
await setupAPI.validate('cell_name', { cell_name: value });
const res = await setupAPI.validate('pic_ngo_available', { cell_name: value });
const avail = res.data?.available;
setAvailability(avail ? 'available' : 'taken');
onNext();
} catch (e) {
setServerError(
e?.response?.data?.error || 'Validation failed. Please try a different name.'
);
setAvailability('unknown');
onNext(); // don't block — availability check is informational
} finally {
setLoading(false);
}
};
const isValid = CELL_NAME_RE.test(value);
return (
<div>
<StepHeader
step={1}
title="Name your cell"
description="This becomes your cell's identity and subdomain. If you choose pic.ngo it will be reachable at name.pic.ngo."
description="This becomes your cell's identity. If you choose pic.ngo it will be reachable at name.pic.ngo."
/>
<div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
@@ -248,29 +247,25 @@ function Step1CellName({ value, onChange, onNext }) {
autoComplete="off"
spellCheck={false}
value={value}
onChange={e => {
onChange(e.target.value.toLowerCase());
setError('');
setServerError('');
}}
onChange={e => handleChange(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleNext()}
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"
aria-describedby={error || serverError ? 'cell-name-error' : undefined}
/>
{isValid ? (
{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>
{availability === 'available' && <span className="text-green-400 ml-1"> available</span>}
{availability === 'taken' && <span className="text-yellow-400 ml-1"> may already be taken</span>}
</p>
) : (
)}
{!isValid && value && (
<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>
<FieldError message={error} />
</div>
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
</div>
@@ -410,6 +405,26 @@ function Step3Domain({ value, onChange, onNext, onBack }) {
);
}
function TokenVerifyButton({ onVerify, status }) {
const label = status === 'checking' ? 'Verifying…'
: status === 'valid' ? '✓ Valid'
: status === 'invalid' ? 'Invalid — retry'
: 'Verify token';
const cls = status === 'valid' ? 'text-green-400 border-green-600'
: status === 'invalid' ? 'text-red-400 border-red-600'
: 'text-blue-400 border-blue-600 hover:bg-blue-900/20';
return (
<button
type="button"
onClick={onVerify}
disabled={status === 'checking'}
className={`mt-2 px-3 py-1 text-xs border rounded transition-colors ${cls}`}
>
{label}
</button>
);
}
function Step4DomainConfig({
domainType, cellName,
customDomain, onCustomDomain,
@@ -419,6 +434,25 @@ function Step4DomainConfig({
onNext, onBack,
}) {
const [errors, setErrors] = useState({});
const [cfStatus, setCfStatus] = useState(null); // null|checking|valid|invalid
const [dnsStatus, setDnsStatus] = useState(null);
const verifyCf = async () => {
setCfStatus('checking');
try {
const res = await setupAPI.validate('cloudflare_token', { token: cloudflareToken });
setCfStatus(res.data?.valid ? 'valid' : 'invalid');
} catch { setCfStatus('invalid'); }
};
const verifyDns = async () => {
const sub = customDomain.replace(/\.duckdns\.org$/i, '') || customDomain.split('.')[0];
setDnsStatus('checking');
try {
const res = await setupAPI.validate('duckdns_token', { subdomain: sub, token: duckdnsToken });
setDnsStatus(res.data?.valid ? 'valid' : 'invalid');
} catch { setDnsStatus('invalid'); }
};
// ── pic_ngo: just show the derived domain ────────────────────────────────
if (domainType === 'pic_ngo') {
@@ -538,6 +572,7 @@ function Step4DomainConfig({
onChange={e => {
onCloudflareToken(e.target.value);
setErrors(p => ({ ...p, token: '' }));
setCfStatus(null);
}}
placeholder="Cloudflare API token with DNS:Edit permission"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
@@ -545,6 +580,9 @@ function Step4DomainConfig({
<p className="mt-1 text-xs text-gray-500">
Create at Cloudflare Dashboard My Profile API Tokens. Needs Zone / DNS / Edit.
</p>
{cloudflareToken.trim() && (
<TokenVerifyButton onVerify={verifyCf} status={cfStatus} />
)}
<FieldError message={errors.token} />
</div>
)}
@@ -562,6 +600,7 @@ function Step4DomainConfig({
onChange={e => {
onDuckdnsToken(e.target.value);
setErrors(p => ({ ...p, token: '' }));
setDnsStatus(null);
}}
placeholder="Your DuckDNS account token"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
@@ -569,6 +608,9 @@ function Step4DomainConfig({
<p className="mt-1 text-xs text-gray-500">
Found at duckdns.org after login. The subdomain must already exist in your account.
</p>
{duckdnsToken.trim() && (
<TokenVerifyButton onVerify={verifyDns} status={dnsStatus} />
)}
<FieldError message={errors.token} />
</div>
)}