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
+71 -7
View File
@@ -1,10 +1,16 @@
import logging import logging
import re
import urllib.request
import urllib.error
import json as _json
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
logger = logging.getLogger('picell') logger = logging.getLogger('picell')
setup_bp = Blueprint('setup', __name__, url_prefix='/api/setup') setup_bp = Blueprint('setup', __name__, url_prefix='/api/setup')
_DOMAIN_RE = re.compile(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$', re.I)
def _get_setup_manager(): def _get_setup_manager():
from app import setup_manager from app import setup_manager
@@ -24,8 +30,8 @@ def get_setup_status():
def validate_setup_step(): def validate_setup_step():
"""Validate a single wizard step. """Validate a single wizard step.
Expects JSON body: ``{'step': '<step_name>', 'data': {...}}``. Supported steps: ``cell_name``, ``password``,
Supported steps: ``cell_name``, ``password``. ``pic_ngo_available``, ``cloudflare_token``, ``duckdns_token``.
""" """
sm = _get_setup_manager() sm = _get_setup_manager()
if sm.is_setup_complete(): if sm.is_setup_complete():
@@ -37,12 +43,36 @@ def validate_setup_step():
if step == 'cell_name': if step == 'cell_name':
errors = sm.validate_cell_name(data.get('cell_name', '')) errors = sm.validate_cell_name(data.get('cell_name', ''))
elif step == 'password': return jsonify({'valid': len(errors) == 0, 'errors': errors})
errors = sm.validate_password(data.get('password', ''))
else:
return jsonify({'valid': False, 'errors': [f"Unknown step: {step!r}"]}), 400
return jsonify({'valid': len(errors) == 0, 'errors': errors}) if step == 'password':
errors = sm.validate_password(data.get('password', ''))
return jsonify({'valid': len(errors) == 0, 'errors': errors})
if step == 'pic_ngo_available':
name = data.get('cell_name', '').strip()
errors = sm.validate_cell_name(name)
if errors:
return jsonify({'available': False, 'errors': errors})
available = _check_pic_ngo_available(name)
return jsonify({'available': available})
if step == 'cloudflare_token':
token = data.get('token', '').strip()
if not token:
return jsonify({'valid': False, 'error': 'Token is required.'})
valid = _verify_cloudflare_token(token)
return jsonify({'valid': valid})
if step == 'duckdns_token':
subdomain = data.get('subdomain', '').strip()
token = data.get('token', '').strip()
if not token or not subdomain:
return jsonify({'valid': False, 'error': 'Subdomain and token are required.'})
valid = _verify_duckdns_token(subdomain, token)
return jsonify({'valid': valid})
return jsonify({'valid': False, 'errors': [f"Unknown step: {step!r}"]}), 400
@setup_bp.route('/complete', methods=['POST']) @setup_bp.route('/complete', methods=['POST'])
@@ -56,3 +86,37 @@ def complete_setup():
result = sm.complete_setup(payload) result = sm.complete_setup(payload)
status_code = 200 if result.get('success') else 400 status_code = 200 if result.get('success') else 400
return jsonify(result), status_code return jsonify(result), status_code
# ── external validation helpers ───────────────────────────────────────────────
def _check_pic_ngo_available(name: str) -> bool:
try:
url = f'https://ddns.pic.ngo/api/v1/check/{name}'
with urllib.request.urlopen(url, timeout=8) as resp:
body = _json.loads(resp.read())
return bool(body.get('available'))
except Exception:
return True # assume available if check fails — don't block the wizard
def _verify_cloudflare_token(token: str) -> bool:
try:
req = urllib.request.Request(
'https://api.cloudflare.com/client/v4/user/tokens/verify',
headers={'Authorization': f'Bearer {token}'},
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = _json.loads(resp.read())
return bool(body.get('success'))
except Exception:
return False
def _verify_duckdns_token(subdomain: str, token: str) -> bool:
try:
url = f'https://www.duckdns.org/update?domains={subdomain}&token={token}&ip='
with urllib.request.urlopen(url, timeout=8) as resp:
return resp.read().strip() == b'OK'
except Exception:
return False
+34 -272
View File
@@ -22,9 +22,9 @@
# ============================================================================= # =============================================================================
# #
# Usage: # Usage:
# sudo bash install.sh # Standard install # bash install.sh # Standard install (uses sudo internally for packages)
# sudo bash install.sh --force # Bypass idempotency check # bash install.sh --force # Bypass idempotency check
# sudo PIC_DIR=/srv/pic bash install.sh # Custom install directory # PIC_DIR=/srv/pic bash install.sh # Custom install directory
# #
# Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk) # Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk)
# #
@@ -79,62 +79,13 @@ log_error() { printf "\n${RED}${BOLD}ERROR:${RESET}${RED} %s${RESET}\n" "$1" >
die() { log_error "$1"; exit 1; } die() { log_error "$1"; exit 1; }
# --------------------------------------------------------------------------- TOTAL_STEPS=7
# Interactive prompt helpers (use /dev/tty so they work even with piped stdin)
# ---------------------------------------------------------------------------
prompt() {
# prompt <label> <default> <var>
local _label="$1" _default="$2" _var="$3" _inp=''
if [ -n "$_default" ]; then
printf " %s [%s]: " "$_label" "$_default" >/dev/tty
else
printf " %s: " "$_label" >/dev/tty
fi
read -r _inp </dev/tty || true
[ -z "$_inp" ] && _inp="$_default"
eval "${_var}=\${_inp}"
}
prompt_secret() {
# prompt_secret <label> <var>
local _label="$1" _var="$2" _inp=''
printf " %s: " "$_label" >/dev/tty
stty -echo </dev/tty 2>/dev/null || true
read -r _inp </dev/tty || true
stty echo </dev/tty 2>/dev/null || true
printf "\n" >/dev/tty
eval "${_var}=\${_inp}"
}
verify_cf_token() {
local _token="$1" _result=''
_result=$(curl -fsSm 10 \
-H "Authorization: Bearer ${_token}" \
"https://api.cloudflare.com/client/v4/user/tokens/verify" 2>/dev/null) || true
echo "$_result" | grep -q '"success":true'
}
verify_duckdns() {
local _sub="$1" _token="$2" _result=''
_result=$(curl -fsSm 10 \
"https://www.duckdns.org/update?domains=${_sub}&token=${_token}&ip=" 2>/dev/null) || true
[ "$_result" = "OK" ]
}
check_pic_ngo_available() {
local _name="$1" _result=''
_result=$(curl -fsSm 10 \
"https://ddns.pic.ngo/api/v1/check/${_name}" 2>/dev/null) || true
echo "$_result" | grep -q '"available":true'
}
TOTAL_STEPS=8
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Must run as root # Sudo check — we need it for package installs and system user creation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then if ! command -v sudo >/dev/null 2>&1; then
die "This installer must be run as root (use sudo)." die "sudo is required. Install it and ensure your user has sudo access."
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -198,37 +149,32 @@ case "$PKG_MANAGER" in
apt) apt)
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
apt-get update -qq sudo apt-get update -qq
apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \ sudo apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \
| grep -v "^$" | sed 's/^/ /' || true | grep -v "^$" | sed 's/^/ /' || true
# Verify docker compose plugin installed
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
log_warn "docker-compose-plugin not available; falling back to standalone docker-compose" log_warn "docker-compose-plugin not available; falling back to standalone docker-compose"
apt-get install -y -qq docker-compose 2>&1 | grep -v "^$" | sed 's/^/ /' || true sudo apt-get install -y -qq docker-compose 2>&1 | grep -v "^$" | sed 's/^/ /' || true
fi fi
;; ;;
dnf) dnf)
dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true sudo dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true
# Enable and start Docker (dnf installs but doesn't enable it) sudo systemctl enable --now docker >/dev/null 2>&1 || true
systemctl enable --now docker >/dev/null 2>&1 || true
# Docker Compose plugin comes bundled with the Docker CE package on Fedora/RHEL.
# If not present, install via the docker-compose-plugin package (Docker CE repo).
if ! docker compose version >/dev/null 2>&1; then if ! docker compose version >/dev/null 2>&1; then
log_warn "docker compose plugin not found; installing docker-compose-plugin..." log_warn "docker compose plugin not found; installing docker-compose-plugin..."
dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true sudo dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true
fi fi
;; ;;
apk) apk)
apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true sudo apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true
# Enable Docker on Alpine (OpenRC) sudo rc-update add docker default >/dev/null 2>&1 || true
rc-update add docker default >/dev/null 2>&1 || true sudo service docker start >/dev/null 2>&1 || true
service docker start >/dev/null 2>&1 || true
;; ;;
esac esac
@@ -253,10 +199,10 @@ log_step 3 "Configuring system user..."
if ! id "$PIC_USER" >/dev/null 2>&1; then if ! id "$PIC_USER" >/dev/null 2>&1; then
case "$PKG_MANAGER" in case "$PKG_MANAGER" in
apk) apk)
adduser -S -D -H -s /sbin/nologin "$PIC_USER" sudo adduser -S -D -H -s /sbin/nologin "$PIC_USER"
;; ;;
*) *)
useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER" sudo useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER"
;; ;;
esac esac
log_ok "Created system user: ${PIC_USER}" log_ok "Created system user: ${PIC_USER}"
@@ -264,17 +210,18 @@ else
log_ok "System user already exists: ${PIC_USER}" log_ok "System user already exists: ${PIC_USER}"
fi fi
# Ensure docker group exists and user is in it # Ensure docker group exists and invoking user is in it
if ! getent group docker >/dev/null 2>&1; then if ! getent group docker >/dev/null 2>&1; then
groupadd docker sudo groupadd docker
log_ok "Created docker group" log_ok "Created docker group"
fi fi
if ! id -nG "$PIC_USER" | grep -qw docker; then CURRENT_USER="${USER:-$(id -un)}"
usermod -aG docker "$PIC_USER" if ! id -nG "$CURRENT_USER" | grep -qw docker; then
log_ok "Added ${PIC_USER} to docker group" sudo usermod -aG docker "$CURRENT_USER"
log_ok "Added ${CURRENT_USER} to docker group (re-login or newgrp docker to apply)"
else else
log_ok "${PIC_USER} is already in docker group" log_ok "${CURRENT_USER} is already in docker group"
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -294,203 +241,25 @@ else
log_ok "Repository cloned to ${PIC_DIR}" log_ok "Repository cloned to ${PIC_DIR}"
fi fi
# Give the invoking user (or pic if run directly as root) ownership of the repo sudo git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
# so they can run `make update` and other git commands without sudo.
REPO_OWNER="${SUDO_USER:-${PIC_USER}}"
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
# Allow all users to run git commands here regardless of who owns the files
git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 5 — Configure cell identity # Step 5 — Run make install
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 5 "Configuring cell identity..." log_step 5 "Running 'make install'..."
if [ ! -c /dev/tty ]; then
die "No interactive terminal available. Re-run with a real terminal (not piped)."
fi
printf "\n" >/dev/tty
# ── Cell name ──────────────────────────────────────────────────────────────
PIC_CELL_NAME=''
while true; do
prompt "Cell name (e.g. myhome, alice, lab)" "" PIC_CELL_NAME
if echo "$PIC_CELL_NAME" | grep -qE '^[a-z][a-z0-9-]{1,30}$'; then
break
fi
log_warn "Must start with a letter, use only lowercase letters/digits/hyphens, 231 chars."
done
# ── Domain / DDNS choice ───────────────────────────────────────────────────
printf "\n" >/dev/tty
printf " How will your cell be publicly reachable?\n" >/dev/tty
printf " 1) pic.ngo subdomain — free, %s.pic.ngo, fully automatic HTTPS\n" "$PIC_CELL_NAME" >/dev/tty
printf " 2) Cloudflare DNS-01 — your own domain (must use Cloudflare nameservers)\n" >/dev/tty
printf " 3) DuckDNS — free *.duckdns.org subdomain\n" >/dev/tty
printf " 4) HTTP-01 (any) — any domain, port 80 must be publicly reachable\n" >/dev/tty
printf " 5) Local only — no public domain, LAN/VPN access only\n" >/dev/tty
printf "\n" >/dev/tty
PIC_DOMAIN_MODE=''
_choice=''
while true; do
prompt "Choice" "1" _choice
case "$_choice" in
1) PIC_DOMAIN_MODE="pic_ngo"; break ;;
2) PIC_DOMAIN_MODE="cloudflare"; break ;;
3) PIC_DOMAIN_MODE="duckdns"; break ;;
4) PIC_DOMAIN_MODE="http01"; break ;;
5) PIC_DOMAIN_MODE="lan"; break ;;
*) log_warn "Enter a number from 1 to 5." ;;
esac
done
PIC_DOMAIN_NAME=''
PIC_CF_TOKEN=''
PIC_DDK_TOKEN=''
PIC_DDK_SUB=''
# ── pic.ngo ────────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "pic_ngo" ]; then
PIC_DOMAIN_NAME="${PIC_CELL_NAME}.pic.ngo"
printf " Checking name availability at pic.ngo..." >/dev/tty
if check_pic_ngo_available "$PIC_CELL_NAME"; then
printf " available\n" >/dev/tty
log_ok "Will register: ${PIC_DOMAIN_NAME}"
else
printf "\n" >/dev/tty
log_warn "${PIC_DOMAIN_NAME} may already be taken or the server is unreachable."
log_warn "Registration will be retried at first boot."
fi
fi
# ── Cloudflare ─────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "cloudflare" ]; then
printf "\n" >/dev/tty
while true; do
prompt "Your domain name (e.g. home.example.com)" "" PIC_DOMAIN_NAME
if echo "$PIC_DOMAIN_NAME" | grep -qiE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$'; then
break
fi
log_warn "Enter a valid fully-qualified domain name (e.g. home.example.com)."
done
printf "\n" >/dev/tty
printf " Create an API token at: Cloudflare Dashboard → My Profile → API Tokens\n" >/dev/tty
printf " Required permission: Zone / DNS / Edit (set to all zones or your specific zone)\n" >/dev/tty
printf "\n" >/dev/tty
_attempts=0
while true; do
prompt_secret "Cloudflare API token" PIC_CF_TOKEN
if [ -z "$PIC_CF_TOKEN" ]; then
log_warn "Token cannot be empty."
continue
fi
printf " Verifying token with Cloudflare..." >/dev/tty
if verify_cf_token "$PIC_CF_TOKEN"; then
printf " valid\n" >/dev/tty
log_ok "Cloudflare token verified"
break
fi
printf " invalid\n" >/dev/tty
_attempts=$((_attempts + 1))
log_warn "Verification failed — check the token has Zone / DNS / Edit permission."
if [ "$_attempts" -ge 2 ]; then
log_warn "Token failed twice. You can still continue — it will be tested again at first boot."
prompt "Press Enter to continue with this token, or Ctrl-C to abort and re-run" "" _dummy
break
fi
done
fi
# ── DuckDNS ────────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "duckdns" ]; then
printf "\n" >/dev/tty
printf " First create a subdomain at duckdns.org, then enter the details below.\n" >/dev/tty
printf "\n" >/dev/tty
while true; do
prompt "DuckDNS subdomain (e.g. myhome → myhome.duckdns.org)" "" PIC_DDK_SUB
if echo "$PIC_DDK_SUB" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then
break
fi
log_warn "Subdomain must be lowercase letters, digits, and hyphens only."
done
PIC_DOMAIN_NAME="${PIC_DDK_SUB}.duckdns.org"
printf "\n" >/dev/tty
_attempts=0
while true; do
prompt_secret "DuckDNS token (from duckdns.org account page)" PIC_DDK_TOKEN
if [ -z "$PIC_DDK_TOKEN" ]; then
log_warn "Token cannot be empty."
continue
fi
printf " Verifying token with DuckDNS..." >/dev/tty
if verify_duckdns "$PIC_DDK_SUB" "$PIC_DDK_TOKEN"; then
printf " valid\n" >/dev/tty
log_ok "DuckDNS token verified (${PIC_DOMAIN_NAME})"
break
fi
printf " invalid\n" >/dev/tty
_attempts=$((_attempts + 1))
log_warn "Verification failed — make sure the subdomain exists at duckdns.org and the token is correct."
if [ "$_attempts" -ge 2 ]; then
log_warn "Token failed twice. You can still continue — it will be tested again at first boot."
prompt "Press Enter to continue with this token, or Ctrl-C to abort and re-run" "" _dummy
break
fi
done
fi
# ── HTTP-01 ────────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "http01" ]; then
printf "\n" >/dev/tty
while true; do
prompt "Your domain name (e.g. home.example.com)" "" PIC_DOMAIN_NAME
if echo "$PIC_DOMAIN_NAME" | grep -qiE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+$'; then
break
fi
log_warn "Enter a valid fully-qualified domain name."
done
printf "\n" >/dev/tty
log_warn "HTTP-01 requires port 80 to be publicly reachable from the internet."
log_warn "Make sure your router forwards port 80 to this machine before completing setup."
fi
# ── Local only ─────────────────────────────────────────────────────────────
if [ "$PIC_DOMAIN_MODE" = "lan" ]; then
log_ok "Local-only mode — no public domain or DDNS will be configured."
fi
printf "\n" >/dev/tty
log_ok "Identity configured: cell=${PIC_CELL_NAME} mode=${PIC_DOMAIN_MODE}${PIC_DOMAIN_NAME:+ domain=${PIC_DOMAIN_NAME}}"
# ---------------------------------------------------------------------------
# Step 6 — Run make install
# ---------------------------------------------------------------------------
log_step 6 "Running 'make install'..."
cd "$PIC_DIR" cd "$PIC_DIR"
if ! CELL_NAME="$PIC_CELL_NAME" \ if ! make install 2>&1 | sed 's/^/ /'; then
DOMAIN_MODE="$PIC_DOMAIN_MODE" \
CELL_DOMAIN_NAME="${PIC_DOMAIN_NAME:-}" \
CLOUDFLARE_API_TOKEN="${PIC_CF_TOKEN:-}" \
DUCKDNS_TOKEN="${PIC_DDK_TOKEN:-}" \
DUCKDNS_SUBDOMAIN="${PIC_DDK_SUB:-}" \
make install 2>&1 | sed 's/^/ /'; then
die "'make install' failed. Check the output above." die "'make install' failed. Check the output above."
fi fi
# make install runs as root so config/ and data/ get created root-owned.
# Re-apply ownership to the invoking user so they can manage files without sudo.
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
log_ok "'make install' complete" log_ok "'make install' complete"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 7 — Start core services # Step 6 — Start core services
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 7 "Starting core services..." log_step 6 "Starting core services..."
cd "$PIC_DIR" cd "$PIC_DIR"
@@ -501,9 +270,9 @@ fi
log_ok "Core services started" log_ok "Core services started"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 8 — Health check + print wizard URL # Step 7 — Health check + print wizard URL
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 8 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..." log_step 7 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..."
ELAPSED=0 ELAPSED=0
HEALTHY=0 HEALTHY=0
@@ -541,14 +310,7 @@ printf "\n${GREEN}${BOLD}=======================================================
printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n" printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n"
printf "${GREEN}${BOLD}============================================================${RESET}\n" printf "${GREEN}${BOLD}============================================================${RESET}\n"
printf "\n" printf "\n"
printf " Cell: ${BOLD}%s${RESET}\n" "$PIC_CELL_NAME" printf " Open the setup wizard to configure your cell:\n"
if [ -n "$PIC_DOMAIN_NAME" ]; then
printf " Domain: ${BOLD}%s${RESET} (%s)\n" "$PIC_DOMAIN_NAME" "$PIC_DOMAIN_MODE"
else
printf " Domain: %s\n" "local only (LAN/VPN)"
fi
printf "\n"
printf " Open the setup wizard to set your admin password and choose services:\n"
printf "\n" printf "\n"
printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n" printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n"
printf "\n" printf "\n"
+70 -28
View File
@@ -200,43 +200,42 @@ function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false,
function Step1CellName({ value, onChange, onNext }) { function Step1CellName({ value, onChange, onNext }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [serverError, setServerError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [availability, setAvailability] = useState(null); // null | 'available' | 'taken' | 'unknown'
const validate = () => { const isValid = CELL_NAME_RE.test(value);
if (!value.trim()) return 'Cell name is required.';
if (!CELL_NAME_RE.test(value)) const handleChange = v => {
return 'Use lowercase letters, numbers, and hyphens only. Must start with a letter. 231 characters.'; onChange(v.toLowerCase());
return ''; setError('');
setAvailability(null);
}; };
const handleNext = async () => { const handleNext = async () => {
const err = validate(); if (!value.trim()) { setError('Cell name is required.'); return; }
setError(err); if (!isValid) { setError('Use lowercase letters, numbers, and hyphens only. Must start with a letter. 231 characters.'); return; }
setServerError('');
if (err) return;
setLoading(true); setLoading(true);
setError('');
try { 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(); onNext();
} catch (e) { } catch (e) {
setServerError( setAvailability('unknown');
e?.response?.data?.error || 'Validation failed. Please try a different name.' onNext(); // don't block — availability check is informational
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
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 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> <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">
@@ -248,29 +247,25 @@ function Step1CellName({ value, onChange, onNext }) {
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
value={value} value={value}
onChange={e => { onChange={e => handleChange(e.target.value)}
onChange(e.target.value.toLowerCase());
setError('');
setServerError('');
}}
onKeyDown={e => e.key === 'Enter' && handleNext()} onKeyDown={e => e.key === 'Enter' && handleNext()}
placeholder="e.g. myhome" 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}
/> />
{isValid ? ( {isValid && (
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1"> <p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
<Globe className="h-3.5 w-3.5" /> <Globe className="h-3.5 w-3.5" />
pic.ngo preview: <span className="font-mono font-medium ml-1">{value}.pic.ngo</span> 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> </p>
) : ( )}
{!isValid && value && (
<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. 231 characters. Lowercase letters, numbers, hyphens. Must start with a letter. 231 characters.
</p> </p>
)} )}
<div id="cell-name-error"> <FieldError message={error} />
<FieldError message={error || serverError} />
</div>
</div> </div>
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} /> <NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
</div> </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({ function Step4DomainConfig({
domainType, cellName, domainType, cellName,
customDomain, onCustomDomain, customDomain, onCustomDomain,
@@ -419,6 +434,25 @@ function Step4DomainConfig({
onNext, onBack, onNext, onBack,
}) { }) {
const [errors, setErrors] = useState({}); 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 ──────────────────────────────── // ── pic_ngo: just show the derived domain ────────────────────────────────
if (domainType === 'pic_ngo') { if (domainType === 'pic_ngo') {
@@ -538,6 +572,7 @@ function Step4DomainConfig({
onChange={e => { onChange={e => {
onCloudflareToken(e.target.value); onCloudflareToken(e.target.value);
setErrors(p => ({ ...p, token: '' })); setErrors(p => ({ ...p, token: '' }));
setCfStatus(null);
}} }}
placeholder="Cloudflare API token with DNS:Edit permission" 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" 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"> <p className="mt-1 text-xs text-gray-500">
Create at Cloudflare Dashboard My Profile API Tokens. Needs Zone / DNS / Edit. Create at Cloudflare Dashboard My Profile API Tokens. Needs Zone / DNS / Edit.
</p> </p>
{cloudflareToken.trim() && (
<TokenVerifyButton onVerify={verifyCf} status={cfStatus} />
)}
<FieldError message={errors.token} /> <FieldError message={errors.token} />
</div> </div>
)} )}
@@ -562,6 +600,7 @@ function Step4DomainConfig({
onChange={e => { onChange={e => {
onDuckdnsToken(e.target.value); onDuckdnsToken(e.target.value);
setErrors(p => ({ ...p, token: '' })); setErrors(p => ({ ...p, token: '' }));
setDnsStatus(null);
}} }}
placeholder="Your DuckDNS account 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" 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"> <p className="mt-1 text-xs text-gray-500">
Found at duckdns.org after login. The subdomain must already exist in your account. Found at duckdns.org after login. The subdomain must already exist in your account.
</p> </p>
{duckdnsToken.trim() && (
<TokenVerifyButton onVerify={verifyDns} status={dnsStatus} />
)}
<FieldError message={errors.token} /> <FieldError message={errors.token} />
</div> </div>
)} )}