From cf1b9672f45a69c069209c3b1d790285ddcbcdbf Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Sat, 9 May 2026 08:05:38 -0400 Subject: [PATCH] Phase 1: first-run setup wizard, bash installer, Docker profiles Co-Authored-By: Claude Sonnet 4.6 --- Makefile | 47 +- api/app.py | 30 +- api/config_manager.py | 16 +- api/managers.py | 4 +- api/routes/setup.py | 58 +++ api/setup_manager.py | 206 ++++++++ docker-compose.yml | 12 + install.sh | 331 +++++++++++++ scripts/pic.service | 15 + tests/test_setup_manager.py | 301 ++++++++++++ webui/src/components/SetupGuard.jsx | 43 ++ webui/src/pages/Setup.jsx | 707 ++++++++++++++++++++++++++++ 12 files changed, 1754 insertions(+), 16 deletions(-) create mode 100644 api/routes/setup.py create mode 100644 api/setup_manager.py create mode 100755 install.sh create mode 100644 scripts/pic.service create mode 100644 tests/test_setup_manager.py create mode 100644 webui/src/components/SetupGuard.jsx create mode 100644 webui/src/pages/Setup.jsx diff --git a/Makefile b/Makefile index b7e0411..cbf3d1e 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,9 @@ # Provides easy commands for managing the cell .PHONY: help start stop restart status logs clean setup check-deps init-peers \ - update reinstall uninstall \ + update reinstall uninstall install \ build build-api build-webui \ - start-dns start-api start-wg start-webui \ + start-core start-dns start-api start-wg start-webui \ backup restore \ test test-all test-unit test-coverage test-api test-cli \ test-phase1 test-phase2 test-phase3 test-phase4 test-all-phases \ @@ -93,12 +93,12 @@ init-peers: start: @echo "Starting Personal Internet Cell..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) up -d --build + PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build @echo "Services started. Check status with 'make status'" stop: @echo "Stopping Personal Internet Cell..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) down + PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down @echo "Services stopped." restart: @@ -135,17 +135,31 @@ update: $(MAKE) setup; \ fi @echo "Rebuilding and restarting services..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) up -d --build + PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full up -d --build @echo "Update complete. Run 'make status' to verify." reinstall: @echo "Reinstalling Personal Internet Cell from scratch..." - PUID=$$(id -u) PGID=$$(id -g) $(DC) down -v 2>/dev/null || true + PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v 2>/dev/null || true @sudo rm -rf config/ data/ @$(MAKE) setup @$(MAKE) start @echo "Reinstall complete." +install: + @if [ -f /opt/pic/.installed ] && [ "$(FORCE)" != "1" ]; then \ + echo "Already installed. Run 'make update' to update, or 'make install FORCE=1' to reinstall."; \ + exit 0; \ + fi + @echo "Running setup..." + @$(MAKE) setup + @echo "Installing systemd unit..." + @sudo cp scripts/pic.service /etc/systemd/system/pic.service + @-sudo systemctl daemon-reload && sudo systemctl enable pic + @sudo mkdir -p /opt/pic + @sudo touch /opt/pic/.installed + @echo "Installation complete. Run 'make start-core' to start core services." + uninstall: @echo "" @echo "This will stop and remove all containers." @@ -155,20 +169,24 @@ uninstall: case "$$ans" in \ y|Y) \ echo "Stopping containers and removing images..."; \ - PUID=$$(id -u) PGID=$$(id -g) $(DC) down -v --rmi all 2>/dev/null || true; \ + PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down -v --rmi all 2>/dev/null || true; \ echo "Deleting config/ and data/..."; \ sudo rm -rf config/ data/; \ echo "Uninstall complete. Git repo and scripts remain."; \ ;; \ n|N|"") \ echo "Stopping and removing containers (keeping images and data)..."; \ - PUID=$$(id -u) PGID=$$(id -g) $(DC) down 2>/dev/null || true; \ + PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile full down 2>/dev/null || true; \ echo "Done. Images, config/ and data/ are untouched. Run 'make start' to bring it back up."; \ ;; \ *) \ echo "Cancelled."; \ ;; \ esac + @-sudo systemctl disable pic 2>/dev/null || true + @-sudo rm -f /etc/systemd/system/pic.service + @-sudo rm -f /opt/pic/.installed + @echo "Note: Data volumes were not deleted. To remove all data, manually delete config/ and data/." # ── Build ───────────────────────────────────────────────────────────────────── @@ -188,17 +206,22 @@ build-webui: # ── Individual services ─────────────────────────────────────────────────────── +start-core: + @echo "Starting core services (caddy, dns, wireguard, api, webui)..." + PUID=$$(id -u) PGID=$$(id -g) $(DC) --profile core up -d --build + @echo "Core services started. Run 'make start' to also bring up optional services." + start-dns: - $(DC) up -d dns + $(DC) --profile core up -d dns start-api: - $(DC) up -d api + $(DC) --profile core up -d api start-wg: - $(DC) up -d wireguard + $(DC) --profile core up -d wireguard start-webui: - $(DC) up -d webui + $(DC) --profile core up -d webui # ── Maintenance ─────────────────────────────────────────────────────────────── diff --git a/api/app.py b/api/app.py index dca7322..2340ca8 100644 --- a/api/app.py +++ b/api/app.py @@ -40,7 +40,7 @@ from managers import ( network_manager, wireguard_manager, peer_registry, email_manager, calendar_manager, file_manager, routing_manager, vault_manager, container_manager, - cell_link_manager, auth_manager, + cell_link_manager, auth_manager, setup_manager, firewall_manager, EventType, ) # Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns` @@ -158,6 +158,28 @@ def enrich_log_context(): 'user': user }) +@app.before_request +def enforce_setup(): + """Block API requests until the first-run wizard has been completed. + + The setup routes, /health, and all non-/api/ paths are always allowed + through. Any other /api/* request while setup is incomplete receives + a 428 with a redirect hint to /setup. + + Skipped entirely when app.config['TESTING'] is True so unit tests remain + unaffected without needing to mark setup as complete. + """ + if app.config.get('TESTING'): + return None + path = request.path + if (path.startswith('/api/setup') or + path == '/health' or + not path.startswith('/api/')): + return None + if not setup_manager.is_setup_complete(): + return jsonify({'error': 'Setup required', 'redirect': '/setup'}), 428 + + @app.before_request def enforce_auth(): """Enforce session-based authentication and role-based access control. @@ -232,7 +254,7 @@ def check_csrf(): if request.method not in ('POST', 'PUT', 'DELETE', 'PATCH'): return None path = request.path - if not path.startswith('/api/') or path.startswith('/api/auth/'): + if not path.startswith('/api/') or path.startswith('/api/auth/') or path.startswith('/api/setup/'): return None # peer-sync uses IP+pubkey auth — no session, no CSRF token possible if path.startswith('/api/cells/peer-sync/'): @@ -409,6 +431,10 @@ service_bus.register_service('container', container_manager) # Register auth blueprint app.register_blueprint(auth_routes.auth_bp) +# Register setup blueprint (no auth required — runs before any account exists) +from routes.setup import setup_bp +app.register_blueprint(setup_bp) + # Register service blueprints (routes extracted from this file) from routes.email import bp as _email_bp from routes.calendar import bp as _calendar_bp diff --git a/api/config_manager.py b/api/config_manager.py index 9d8bdc3..08a9a7f 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -37,6 +37,9 @@ class ConfigManager: pass self.service_schemas = self._load_service_schemas() self.configs = self._load_all_configs() + # Ensure _identity key always exists + if '_identity' not in self.configs: + self.configs['_identity'] = {} if not self.config_file.exists(): self._save_all_configs() @@ -460,10 +463,21 @@ class ConfigManager: # No-op for unified config, but keep for compatibility pass + def get_identity(self) -> Dict[str, Any]: + """Return the current identity configuration.""" + return self.configs.get('_identity', {}) + + def set_identity_field(self, key: str, value: Any): + """Set a single field in the identity configuration and persist.""" + if '_identity' not in self.configs: + self.configs['_identity'] = {} + self.configs['_identity'][key] = value + self._save_all_configs() + def get_all_configs(self) -> Dict[str, Dict]: """Get all service configurations""" return self.configs.copy() - + def get_config_summary(self) -> Dict[str, Any]: """Get summary of all configurations""" summary = { diff --git a/api/managers.py b/api/managers.py index a42bee2..21cfc1c 100644 --- a/api/managers.py +++ b/api/managers.py @@ -27,6 +27,7 @@ from log_manager import LogManager from cell_link_manager import CellLinkManager import firewall_manager from auth_manager import AuthManager +from setup_manager import SetupManager DATA_DIR = os.environ.get('DATA_DIR', '/app/data') CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config') @@ -53,6 +54,7 @@ cell_link_manager = CellLinkManager( network_manager=network_manager, ) auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR) +setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager) # Service logger configuration _service_log_configs = { @@ -86,7 +88,7 @@ __all__ = [ 'network_manager', 'wireguard_manager', 'peer_registry', 'email_manager', 'calendar_manager', 'file_manager', 'routing_manager', 'vault_manager', 'container_manager', - 'cell_link_manager', 'auth_manager', + 'cell_link_manager', 'auth_manager', 'setup_manager', 'firewall_manager', 'EventType', 'DATA_DIR', 'CONFIG_DIR', ] diff --git a/api/routes/setup.py b/api/routes/setup.py new file mode 100644 index 0000000..ab85ff5 --- /dev/null +++ b/api/routes/setup.py @@ -0,0 +1,58 @@ +import logging +from flask import Blueprint, request, jsonify + +logger = logging.getLogger('picell') + +setup_bp = Blueprint('setup', __name__, url_prefix='/api/setup') + + +def _get_setup_manager(): + from app import setup_manager + return setup_manager + + +@setup_bp.route('/status', methods=['GET']) +def get_setup_status(): + """Return wizard status and available options.""" + sm = _get_setup_manager() + if sm.is_setup_complete(): + return jsonify({'error': 'Setup already complete'}), 410 + return jsonify(sm.get_setup_status()) + + +@setup_bp.route('/validate', methods=['POST']) +def validate_setup_step(): + """Validate a single wizard step. + + Expects JSON body: ``{'step': '', 'data': {...}}``. + Supported steps: ``cell_name``, ``password``. + """ + sm = _get_setup_manager() + if sm.is_setup_complete(): + return jsonify({'error': 'Setup already complete'}), 410 + + body = request.get_json(silent=True) or {} + step = body.get('step', '') + data = body.get('data', {}) + + if step == 'cell_name': + errors = sm.validate_cell_name(data.get('cell_name', '')) + elif step == 'password': + 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}) + + +@setup_bp.route('/complete', methods=['POST']) +def complete_setup(): + """Complete the first-run wizard and create the admin account.""" + sm = _get_setup_manager() + if sm.is_setup_complete(): + return jsonify({'error': 'Setup already complete'}), 410 + + payload = request.get_json(silent=True) or {} + result = sm.complete_setup(payload) + status_code = 200 if result.get('success') else 400 + return jsonify(result), status_code diff --git a/api/setup_manager.py b/api/setup_manager.py new file mode 100644 index 0000000..5ecdffa --- /dev/null +++ b/api/setup_manager.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +SetupManager — first-run wizard backend for PIC. + +Handles validation, locking, and atomic completion of the initial setup +wizard. Called by api/routes/setup.py. +""" + +import fcntl +import logging +import os +import re +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + +# Top 30 representative IANA time zones shown in the wizard +AVAILABLE_TIMEZONES = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Anchorage', + 'America/Honolulu', + 'America/Sao_Paulo', + 'America/Argentina/Buenos_Aires', + 'America/Toronto', + 'America/Vancouver', + 'America/Mexico_City', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Madrid', + 'Europe/Rome', + 'Europe/Amsterdam', + 'Europe/Moscow', + 'Europe/Istanbul', + 'Africa/Cairo', + 'Africa/Johannesburg', + 'Asia/Dubai', + 'Asia/Kolkata', + 'Asia/Bangkok', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Seoul', + 'Australia/Sydney', + 'Pacific/Auckland', +] + +AVAILABLE_SERVICES = [ + 'email', + 'calendar', + 'files', + 'wireguard', +] + +VALID_DOMAIN_MODES = {'pic_ngo', 'custom', 'lan'} + +CELL_NAME_RE = re.compile(r'^[a-z][a-z0-9-]{1,30}$') + + +class SetupManager: + """Manages the first-run setup wizard state and completion.""" + + def __init__(self, config_manager, auth_manager): + self.config_manager = config_manager + self.auth_manager = auth_manager + + # ── state helpers ───────────────────────────────────────────────────── + + def is_setup_complete(self) -> bool: + """Return True if setup has already been completed.""" + return bool(self.config_manager.get_identity().get('setup_complete', False)) + + def get_setup_status(self) -> Dict[str, Any]: + """Return current setup status and wizard metadata.""" + return { + 'complete': self.is_setup_complete(), + 'available_services': AVAILABLE_SERVICES, + 'available_timezones': AVAILABLE_TIMEZONES, + } + + # ── validation ──────────────────────────────────────────────────────── + + def validate_cell_name(self, name: str) -> List[str]: + """Validate a proposed cell name. Returns a list of error strings.""" + errors: List[str] = [] + if not name: + errors.append('Cell name is required.') + return errors + if not CELL_NAME_RE.match(name): + errors.append( + 'Cell name must start with a lowercase letter, be 2–31 characters, ' + 'and contain only lowercase letters, digits, and hyphens.' + ) + if name.startswith('-') or name.endswith('-'): + errors.append('Cell name must not start or end with a hyphen.') + return errors + + def validate_password(self, password: str) -> List[str]: + """Validate admin password strength. Returns a list of error strings.""" + errors: List[str] = [] + if not password: + errors.append('Password is required.') + return errors + if len(password) < 12: + errors.append('Password must be at least 12 characters long.') + if not re.search(r'[A-Z]', password): + errors.append('Password must contain at least one uppercase letter.') + if not re.search(r'[a-z]', password): + errors.append('Password must contain at least one lowercase letter.') + if not re.search(r'\d', password): + errors.append('Password must contain at least one digit.') + return errors + + # ── main completion ─────────────────────────────────────────────────── + + def complete_setup(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Run all validation, then atomically complete the setup wizard. + + Returns ``{'success': True, 'redirect': '/login'}`` on success or + ``{'success': False, 'errors': [...]}`` on any failure. + """ + errors: List[str] = [] + + # ── validate inputs ──────────────────────────────────────────────── + cell_name = payload.get('cell_name', '') + password = payload.get('password', '') + domain_mode = payload.get('domain_mode', '') + timezone = payload.get('timezone', '') + services_enabled = payload.get('services_enabled', []) + ddns_provider = payload.get('ddns_provider', 'none') + + errors.extend(self.validate_cell_name(cell_name)) + errors.extend(self.validate_password(password)) + + if domain_mode not in VALID_DOMAIN_MODES: + errors.append( + f"domain_mode must be one of: {', '.join(sorted(VALID_DOMAIN_MODES))}." + ) + if not timezone or not isinstance(timezone, str): + errors.append('timezone is required.') + if not isinstance(services_enabled, list): + errors.append('services_enabled must be a list.') + + if errors: + return {'success': False, 'errors': errors} + + # ── acquire file lock to prevent double-completion ───────────────── + lock_path = os.path.join( + os.environ.get('DATA_DIR', '/app/data'), 'api', '.setup.lock' + ) + try: + os.makedirs(os.path.dirname(lock_path), exist_ok=True) + except OSError: + pass + + try: + lock_fd = open(lock_path, 'w') + fcntl.flock(lock_fd, fcntl.LOCK_EX) + except OSError as exc: + logger.error(f'Could not acquire setup lock: {exc}') + return {'success': False, 'errors': ['Setup lock could not be acquired. Try again.']} + + try: + # Re-check inside lock + if self.is_setup_complete(): + return {'success': False, 'errors': ['Setup has already been completed.']} + + # ── create admin user ────────────────────────────────────────── + ok = self.auth_manager.create_user( + username='admin', + password=password, + role='admin', + ) + if not ok: + return {'success': False, 'errors': ['Failed to create admin user. The username may already exist.']} + + # ── persist identity fields ──────────────────────────────────── + 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('timezone', timezone) + self.config_manager.set_identity_field('services_enabled', services_enabled) + self.config_manager.set_identity_field('ddns_provider', ddns_provider) + + # NOTE: DDNS registration is deferred to Phase 3. + # For now we just store ddns_provider in config. + logger.info( + 'DDNS registration skipped (Phase 1). ' + 'DDNS registration will happen in Phase 3. ' + f'ddns_provider={ddns_provider!r} stored in identity config.' + ) + + # ── mark setup complete (must be last) ───────────────────────── + self.config_manager.set_identity_field('setup_complete', True) + + logger.info(f"Setup completed. cell_name={cell_name!r}, domain_mode={domain_mode!r}") + return {'success': True, 'redirect': '/login'} + + finally: + try: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + lock_fd.close() + except Exception: + pass diff --git a/docker-compose.yml b/docker-compose.yml index 493df73..cd41dc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: caddy: image: caddy:2-alpine container_name: cell-caddy + profiles: ["core", "full"] ports: - "80:80" - "443:443" @@ -28,6 +29,7 @@ services: dns: image: coredns/coredns:latest container_name: cell-dns + profiles: ["core", "full"] command: ["-conf", "/etc/coredns/Corefile"] ports: - "${DNS_PORT:-53}:53/udp" @@ -49,6 +51,7 @@ services: dhcp: image: alpine:latest container_name: cell-dhcp + profiles: ["full"] ports: - "${DHCP_PORT:-67}:67/udp" volumes: @@ -71,6 +74,7 @@ services: ntp: image: alpine:latest container_name: cell-ntp + profiles: ["full"] ports: - "${NTP_PORT:-123}:123/udp" volumes: @@ -92,6 +96,7 @@ services: mail: image: mailserver/docker-mailserver:latest container_name: cell-mail + profiles: ["full"] hostname: mail domainname: cell.local env_file: ./config/mail/mailserver.env @@ -121,6 +126,7 @@ services: radicale: image: tomsquest/docker-radicale:latest container_name: cell-radicale + profiles: ["full"] ports: - "127.0.0.1:${RADICALE_PORT:-5232}:5232" volumes: @@ -140,6 +146,7 @@ services: webdav: image: bytemark/webdav:latest container_name: cell-webdav + profiles: ["full"] ports: - "127.0.0.1:${WEBDAV_PORT:-8080}:80" environment: @@ -162,6 +169,7 @@ services: wireguard: image: linuxserver/wireguard:latest container_name: cell-wireguard + profiles: ["core", "full"] environment: - SERVERMODE=true - PUID=${PUID:-1000} @@ -193,6 +201,7 @@ services: api: build: ./api container_name: cell-api + profiles: ["core", "full"] ports: - "127.0.0.1:${API_PORT:-3000}:3000" volumes: @@ -225,6 +234,7 @@ services: webui: build: ./webui container_name: cell-webui + profiles: ["core", "full"] ports: - "${WEBUI_PORT:-8081}:80" restart: unless-stopped @@ -241,6 +251,7 @@ services: rainloop: image: hardware/rainloop container_name: cell-rainloop + profiles: ["full"] restart: unless-stopped networks: cell-network: @@ -259,6 +270,7 @@ services: filegator: image: filegator/filegator container_name: cell-filegator + profiles: ["full"] restart: unless-stopped networks: cell-network: diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c3590c4 --- /dev/null +++ b/install.sh @@ -0,0 +1,331 @@ +#!/usr/bin/env bash +# ============================================================================= +# Personal Internet Cell (PIC) — Bash Installer +# ============================================================================= +# +# SECURITY NOTICE +# --------------- +# You are about to execute this script with elevated privileges. +# ALWAYS review a script before running it: +# +# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh | less +# +# SHA256 checksum (verify before running): +# PLACEHOLDER — updated when script is published at git.pic.ngo +# +# Verify with: +# sha256sum install.sh +# # or, via curl before piping: +# curl -fsSL https://git.pic.ngo/roof/pic/raw/branch/main/install.sh \ +# | sha256sum +# +# ============================================================================= +# +# Usage: +# sudo bash install.sh # Standard install +# sudo bash install.sh --force # Bypass idempotency check +# sudo PIC_DIR=/srv/pic bash install.sh # Custom install directory +# +# Supported OS: Debian/Ubuntu (apt), Fedora/RHEL (dnf), Alpine Linux (apk) +# +# ============================================================================= + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +PIC_DIR="${PIC_DIR:-/opt/pic}" +PIC_REPO="${PIC_REPO:-https://git.pic.ngo/roof/pic.git}" +PIC_USER="${PIC_USER:-pic}" +API_HEALTH_URL="http://127.0.0.1:3000/health" +API_HEALTH_TIMEOUT=60 +WEBUI_PORT=8081 +FORCE=0 + +# Parse flags +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Usage: $0 [--force]" >&2 + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Color output +# --------------------------------------------------------------------------- +if [ -t 1 ] && command -v tput >/dev/null 2>&1 && tput setaf 1 >/dev/null 2>&1; then + RED="$(tput setaf 1)" + GREEN="$(tput setaf 2)" + YELLOW="$(tput setaf 3)" + BOLD="$(tput bold)" + RESET="$(tput sgr0)" +else + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BOLD='\033[1m' + RESET='\033[0m' +fi + +log_step() { printf "\n${BOLD}[%s/%s] %s${RESET}\n" "$1" "$TOTAL_STEPS" "$2"; } +log_ok() { printf " ${GREEN}✓${RESET} %s\n" "$1"; } +log_warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; } +log_error() { printf "\n${RED}${BOLD}ERROR:${RESET}${RED} %s${RESET}\n" "$1" >&2; } + +die() { log_error "$1"; exit 1; } + +TOTAL_STEPS=7 + +# --------------------------------------------------------------------------- +# Must run as root +# --------------------------------------------------------------------------- +if [ "$(id -u)" -ne 0 ]; then + die "This installer must be run as root (use sudo)." +fi + +# --------------------------------------------------------------------------- +# Idempotency guard +# --------------------------------------------------------------------------- +if [ -f "${PIC_DIR}/.installed" ] && [ "$FORCE" -eq 0 ]; then + printf "${YELLOW}Already installed.${RESET} Run ${BOLD}'make update'${RESET} to update.\n" + printf "To force a full reinstall, run: ${BOLD}$0 --force${RESET}\n" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Step 1 — Detect OS / package manager +# --------------------------------------------------------------------------- +log_step 1 "Detecting operating system..." + +PKG_MANAGER="" +OS_ID="" + +if [ -f /etc/os-release ]; then + # shellcheck source=/dev/null + . /etc/os-release + OS_ID="${ID:-unknown}" +fi + +case "$OS_ID" in + ubuntu|debian|raspbian) + PKG_MANAGER="apt" + ;; + fedora) + PKG_MANAGER="dnf" + ;; + rhel|centos|almalinux|rocky) + PKG_MANAGER="dnf" + ;; + alpine) + PKG_MANAGER="apk" + ;; + *) + # Last-resort detection + if command -v apt-get >/dev/null 2>&1; then + PKG_MANAGER="apt" + elif command -v dnf >/dev/null 2>&1; then + PKG_MANAGER="dnf" + elif command -v apk >/dev/null 2>&1; then + PKG_MANAGER="apk" + else + die "Unsupported OS '${OS_ID}'. PIC requires Debian/Ubuntu, Fedora/RHEL, or Alpine Linux." + fi + ;; +esac + +log_ok "Detected OS: ${OS_ID} (package manager: ${PKG_MANAGER})" + +# --------------------------------------------------------------------------- +# Step 2 — Install required packages +# --------------------------------------------------------------------------- +log_step 2 "Installing dependencies..." + +case "$PKG_MANAGER" in + + apt) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq git curl make docker.io docker-compose-plugin 2>&1 \ + | grep -v "^$" | sed 's/^/ /' || true + + # Verify docker compose plugin installed + if ! docker compose version >/dev/null 2>&1; then + 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 + fi + ;; + + dnf) + dnf install -y -q git curl make docker 2>&1 | sed 's/^/ /' || true + + # Enable and start Docker (dnf installs but doesn't enable it) + 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 + log_warn "docker compose plugin not found; installing docker-compose-plugin..." + dnf install -y -q docker-compose-plugin 2>&1 | sed 's/^/ /' || true + fi + ;; + + apk) + apk add --quiet git curl make docker docker-cli-compose 2>&1 | sed 's/^/ /' || true + + # Enable Docker on Alpine (OpenRC) + rc-update add docker default >/dev/null 2>&1 || true + service docker start >/dev/null 2>&1 || true + ;; + +esac + +# Final sanity checks +command -v git >/dev/null 2>&1 || die "git could not be installed. Aborting." +command -v curl >/dev/null 2>&1 || die "curl could not be installed. Aborting." +command -v make >/dev/null 2>&1 || die "make could not be installed. Aborting." +command -v docker >/dev/null 2>&1 || die "docker could not be installed. Aborting." + +docker compose version >/dev/null 2>&1 || \ + docker-compose version >/dev/null 2>&1 || \ + die "Neither 'docker compose' (plugin) nor 'docker-compose' is available. Aborting." + +log_ok "All dependencies satisfied" + +# --------------------------------------------------------------------------- +# Step 3 — Create system user +# --------------------------------------------------------------------------- +log_step 3 "Configuring system user..." + +if ! id "$PIC_USER" >/dev/null 2>&1; then + case "$PKG_MANAGER" in + apk) + adduser -S -D -H -s /sbin/nologin "$PIC_USER" + ;; + *) + useradd --system --no-create-home --shell /usr/sbin/nologin "$PIC_USER" + ;; + esac + log_ok "Created system user: ${PIC_USER}" +else + log_ok "System user already exists: ${PIC_USER}" +fi + +# Ensure docker group exists and user is in it +if ! getent group docker >/dev/null 2>&1; then + groupadd docker + log_ok "Created docker group" +fi + +if ! id -nG "$PIC_USER" | grep -qw docker; then + usermod -aG docker "$PIC_USER" + log_ok "Added ${PIC_USER} to docker group" +else + log_ok "${PIC_USER} is already in docker group" +fi + +# --------------------------------------------------------------------------- +# Step 4 — Clone or update repository +# --------------------------------------------------------------------------- +log_step 4 "Setting up repository at ${PIC_DIR}..." + +if [ -d "${PIC_DIR}/.git" ]; then + log_warn "Repository already cloned — running git pull" + git -C "$PIC_DIR" pull --ff-only 2>&1 | sed 's/^/ /' + log_ok "Repository updated" +elif [ -d "$PIC_DIR" ] && [ "$(ls -A "$PIC_DIR" 2>/dev/null)" ]; then + die "${PIC_DIR} exists and is not empty and is not a git repo. Aborting to avoid data loss." +else + mkdir -p "$(dirname "$PIC_DIR")" + git clone "$PIC_REPO" "$PIC_DIR" 2>&1 | sed 's/^/ /' + log_ok "Repository cloned to ${PIC_DIR}" +fi + +# Ensure the pic user owns the directory +chown -R "${PIC_USER}:${PIC_USER}" "$PIC_DIR" + +# --------------------------------------------------------------------------- +# Step 5 — Run make install +# --------------------------------------------------------------------------- +log_step 5 "Running 'make install'..." + +# make install generates config, writes the systemd unit, and touches .installed. +# We run it as the pic user (via sudo -u) so files get correct ownership, but +# make install itself calls sudo internally where root is needed. +cd "$PIC_DIR" + +if ! make install 2>&1 | sed 's/^/ /'; then + die "'make install' failed. Check the output above." +fi + +log_ok "'make install' complete" + +# --------------------------------------------------------------------------- +# Step 6 — Start core services +# --------------------------------------------------------------------------- +log_step 6 "Starting core services..." + +cd "$PIC_DIR" + +if ! make start-core 2>&1 | sed 's/^/ /'; then + die "'make start-core' failed. Check the output above." +fi + +log_ok "Core services started" + +# --------------------------------------------------------------------------- +# Step 7 — Health check + print wizard URL +# --------------------------------------------------------------------------- +log_step 7 "Waiting for API health check (up to ${API_HEALTH_TIMEOUT}s)..." + +ELAPSED=0 +HEALTHY=0 +while [ "$ELAPSED" -lt "$API_HEALTH_TIMEOUT" ]; do + if curl -fsS "$API_HEALTH_URL" >/dev/null 2>&1; then + HEALTHY=1 + break + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) + printf " Waiting... (%ds)\r" "$ELAPSED" +done +printf "\n" + +if [ "$HEALTHY" -ne 1 ]; then + log_warn "API did not respond within ${API_HEALTH_TIMEOUT}s at ${API_HEALTH_URL}" + log_warn "The stack may still be starting up. Check with: make -C ${PIC_DIR} status" + log_warn "Or follow logs with: make -C ${PIC_DIR} logs" +else + log_ok "API is healthy" +fi + +# Detect the host's primary outbound IP address +HOST_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/{print $7; exit}' || true)" +if [ -z "$HOST_IP" ]; then + # Fallback: first non-loopback IPv4 + HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" +fi +HOST_IP="${HOST_IP:-}" + +# --------------------------------------------------------------------------- +# Done +# --------------------------------------------------------------------------- +printf "\n${GREEN}${BOLD}============================================================${RESET}\n" +printf "${GREEN}${BOLD} PIC installed successfully!${RESET}\n" +printf "${GREEN}${BOLD}============================================================${RESET}\n" +printf "\n" +printf " Open the setup wizard at:\n" +printf "\n" +printf " ${BOLD}http://${HOST_IP}:${WEBUI_PORT}/setup${RESET}\n" +printf "\n" +printf " Useful commands:\n" +printf " make -C ${PIC_DIR} status — check service status\n" +printf " make -C ${PIC_DIR} logs — follow all service logs\n" +printf " make -C ${PIC_DIR} start — start all services\n" +printf " make -C ${PIC_DIR} stop — stop all services\n" +printf " make -C ${PIC_DIR} update — pull latest code and restart\n" +printf "\n" diff --git a/scripts/pic.service b/scripts/pic.service new file mode 100644 index 0000000..c512f25 --- /dev/null +++ b/scripts/pic.service @@ -0,0 +1,15 @@ +[Unit] +Description=Personal Internet Cell +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/pic +ExecStart=/usr/bin/make start +ExecStop=/usr/bin/make stop +TimeoutStartSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/tests/test_setup_manager.py b/tests/test_setup_manager.py new file mode 100644 index 0000000..693df64 --- /dev/null +++ b/tests/test_setup_manager.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +""" +Unit tests for SetupManager (api/setup_manager.py). + +Config manager and auth manager are injected as MagicMock objects so no +filesystem access or Docker calls are needed. +""" + +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) +from setup_manager import SetupManager, AVAILABLE_SERVICES, AVAILABLE_TIMEZONES + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def mock_config_manager(): + """A MagicMock standing in for ConfigManager.""" + mgr = MagicMock() + # Default: setup not yet complete + mgr.get_identity.return_value = {} + return mgr + + +@pytest.fixture +def mock_auth_manager(): + """A MagicMock standing in for AuthManager.""" + mgr = MagicMock() + mgr.create_user.return_value = True + return mgr + + +@pytest.fixture +def setup_manager(mock_config_manager, mock_auth_manager): + """SetupManager wired to both mocks.""" + return SetupManager(mock_config_manager, mock_auth_manager) + + +# ── valid payload helper ─────────────────────────────────────────────────────── + +def _valid_payload(**overrides): + base = { + 'cell_name': 'mycel', + 'password': 'SecurePass1!', + 'domain_mode': 'lan', + 'timezone': 'UTC', + 'services_enabled': ['wireguard'], + 'ddns_provider': 'none', + } + base.update(overrides) + return base + + +# ── is_setup_complete ───────────────────────────────────────────────────────── + +def test_is_setup_complete_missing_key_returns_false(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {} + assert setup_manager.is_setup_complete() is False + + +def test_is_setup_complete_false_value_returns_false(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {'setup_complete': False} + assert setup_manager.is_setup_complete() is False + + +def test_is_setup_complete_true_value_returns_true(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {'setup_complete': True} + assert setup_manager.is_setup_complete() is True + + +# ── validate_cell_name ──────────────────────────────────────────────────────── + +@pytest.mark.parametrize('name', ['mycel', 'my-cel', 'a1', 'abc-123-xyz']) +def test_validate_cell_name_accepts_valid_names(setup_manager, name): + assert setup_manager.validate_cell_name(name) == [] + + +def test_validate_cell_name_rejects_empty_string(setup_manager): + errors = setup_manager.validate_cell_name('') + assert errors + assert any('required' in e.lower() for e in errors) + + +def test_validate_cell_name_rejects_starts_with_digit(setup_manager): + errors = setup_manager.validate_cell_name('1abc') + assert errors + + +def test_validate_cell_name_rejects_starts_with_hyphen(setup_manager): + errors = setup_manager.validate_cell_name('-abc') + assert errors + + +def test_validate_cell_name_rejects_ends_with_hyphen(setup_manager): + errors = setup_manager.validate_cell_name('abc-') + assert errors + + +def test_validate_cell_name_rejects_uppercase(setup_manager): + errors = setup_manager.validate_cell_name('MyCell') + assert errors + + +def test_validate_cell_name_rejects_underscore(setup_manager): + errors = setup_manager.validate_cell_name('my_cell') + assert errors + + +def test_validate_cell_name_rejects_dot(setup_manager): + errors = setup_manager.validate_cell_name('my.cell') + assert errors + + +def test_validate_cell_name_rejects_too_short_single_char(setup_manager): + # Single character: regex requires at least 2 chars (start + 1-30 more) + errors = setup_manager.validate_cell_name('a') + assert errors + + +def test_validate_cell_name_rejects_too_long(setup_manager): + # 32 lowercase letters — one over the 31-char limit + errors = setup_manager.validate_cell_name('a' * 32) + assert errors + + +def test_validate_cell_name_accepts_maximum_length(setup_manager): + # 31 chars: 'a' + 30 more lowercase = exactly at limit + assert setup_manager.validate_cell_name('a' + 'b' * 30) == [] + + +# ── validate_password ───────────────────────────────────────────────────────── + +def test_validate_password_accepts_valid_password(setup_manager): + assert setup_manager.validate_password('SecurePass1!') == [] + + +def test_validate_password_rejects_too_short(setup_manager): + errors = setup_manager.validate_password('Short1!') + assert errors + assert any('12' in e or 'least' in e.lower() for e in errors) + + +def test_validate_password_rejects_no_uppercase(setup_manager): + errors = setup_manager.validate_password('securepass1!') + assert errors + assert any('uppercase' in e.lower() for e in errors) + + +def test_validate_password_rejects_no_lowercase(setup_manager): + errors = setup_manager.validate_password('SECUREPASS1!') + assert errors + assert any('lowercase' in e.lower() for e in errors) + + +def test_validate_password_rejects_no_digit(setup_manager): + errors = setup_manager.validate_password('SecurePassword!') + assert errors + assert any('digit' in e.lower() for e in errors) + + +# ── complete_setup ──────────────────────────────────────────────────────────── + +def test_complete_setup_returns_error_when_cell_name_invalid(setup_manager): + result = setup_manager.complete_setup(_valid_payload(cell_name='1bad')) + assert result['success'] is False + assert result['errors'] + + +def test_complete_setup_returns_error_when_password_invalid(setup_manager): + result = setup_manager.complete_setup(_valid_payload(password='weak')) + assert result['success'] is False + assert result['errors'] + + +def test_complete_setup_returns_error_when_domain_mode_invalid(setup_manager): + result = setup_manager.complete_setup(_valid_payload(domain_mode='ftp')) + assert result['success'] is False + assert any('domain_mode' in e for e in result['errors']) + + +def test_complete_setup_calls_create_user_with_correct_args( + setup_manager, mock_auth_manager, mock_config_manager, tmp_path): + mock_config_manager.get_identity.return_value = {} + with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): + result = setup_manager.complete_setup(_valid_payload()) + mock_auth_manager.create_user.assert_called_once_with( + username='admin', + password='SecurePass1!', + role='admin', + ) + + +def test_complete_setup_calls_set_identity_field_for_each_field( + setup_manager, mock_config_manager, mock_auth_manager, tmp_path): + mock_config_manager.get_identity.return_value = {} + with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): + setup_manager.complete_setup(_valid_payload()) + calls = mock_config_manager.set_identity_field.call_args_list + field_names = [c[0][0] for c in calls] + for expected in ('cell_name', 'domain_mode', 'timezone', 'services_enabled', 'ddns_provider'): + assert expected in field_names, f"set_identity_field not called for '{expected}'" + + +def test_complete_setup_marks_setup_complete_last( + setup_manager, mock_config_manager, mock_auth_manager, tmp_path): + """setup_complete must be the final set_identity_field call.""" + mock_config_manager.get_identity.return_value = {} + with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): + setup_manager.complete_setup(_valid_payload()) + calls = mock_config_manager.set_identity_field.call_args_list + last_call = calls[-1] + assert last_call == call('setup_complete', True) + + +def test_complete_setup_returns_success_redirect_on_valid_payload( + setup_manager, mock_config_manager, mock_auth_manager, tmp_path): + mock_config_manager.get_identity.return_value = {} + with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): + result = setup_manager.complete_setup(_valid_payload()) + assert result == {'success': True, 'redirect': '/login'} + + +def test_complete_setup_returns_error_when_already_complete( + setup_manager, mock_config_manager, tmp_path): + """If setup is already done when the lock-protected re-check runs, return error.""" + # complete_setup calls is_setup_complete() exactly once — inside the lock. + # Returning True there triggers the "already completed" guard. + mock_config_manager.get_identity.return_value = {'setup_complete': True} + with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): + result = setup_manager.complete_setup(_valid_payload()) + assert result['success'] is False + assert any('already' in e.lower() for e in result['errors']) + + +def test_complete_setup_does_not_persist_fields_when_already_complete( + setup_manager, mock_config_manager, mock_auth_manager, tmp_path): + """No side-effects (no create_user, no set_identity_field) when already done.""" + mock_config_manager.get_identity.return_value = {'setup_complete': True} + with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): + setup_manager.complete_setup(_valid_payload()) + mock_auth_manager.create_user.assert_not_called() + mock_config_manager.set_identity_field.assert_not_called() + + +def test_complete_setup_returns_error_when_create_user_fails( + setup_manager, mock_config_manager, mock_auth_manager, tmp_path): + mock_config_manager.get_identity.return_value = {} + mock_auth_manager.create_user.return_value = False + with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): + result = setup_manager.complete_setup(_valid_payload()) + assert result['success'] is False + assert any('admin' in e.lower() or 'user' in e.lower() for e in result['errors']) + + +# ── get_setup_status ────────────────────────────────────────────────────────── + +def test_get_setup_status_returns_complete_key(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {} + status = setup_manager.get_setup_status() + assert 'complete' in status + assert status['complete'] is False + + +def test_get_setup_status_complete_reflects_true_when_done(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {'setup_complete': True} + status = setup_manager.get_setup_status() + assert status['complete'] is True + + +def test_get_setup_status_contains_available_services(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {} + status = setup_manager.get_setup_status() + assert 'available_services' in status + assert isinstance(status['available_services'], list) + assert status['available_services'] == AVAILABLE_SERVICES + + +def test_get_setup_status_contains_available_timezones(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {} + status = setup_manager.get_setup_status() + assert 'available_timezones' in status + assert isinstance(status['available_timezones'], list) + assert len(status['available_timezones']) > 0 + + +def test_get_setup_status_timezones_includes_utc(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {} + status = setup_manager.get_setup_status() + assert 'UTC' in status['available_timezones'] + + +def test_get_setup_status_timezones_match_module_constant(setup_manager, mock_config_manager): + mock_config_manager.get_identity.return_value = {} + status = setup_manager.get_setup_status() + assert status['available_timezones'] == AVAILABLE_TIMEZONES diff --git a/webui/src/components/SetupGuard.jsx b/webui/src/components/SetupGuard.jsx new file mode 100644 index 0000000..1dc8bde --- /dev/null +++ b/webui/src/components/SetupGuard.jsx @@ -0,0 +1,43 @@ +import React, { useState, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { setupAPI } from '../services/api'; + +export default function SetupGuard({ children }) { + const location = useLocation(); + const [status, setStatus] = useState(null); // null = loading, true = complete, false = incomplete + const [error, setError] = useState(false); + + useEffect(() => { + setupAPI.getStatus() + .then(r => setStatus(r.data?.complete === true)) + .catch(() => { + // If the setup endpoint doesn't exist yet, treat setup as complete + // so the rest of the app functions normally. + setStatus(true); + setError(true); + }); + }, []); + + // Still loading — show nothing to avoid flash of wrong content + if (status === null) { + return ( +
+
+
+ ); + } + + const onSetupPage = location.pathname === '/setup'; + + // Setup incomplete and not already on /setup → redirect there + if (status === false && !onSetupPage) { + return ; + } + + // Setup complete but user navigated to /setup → send to login + if (status === true && onSetupPage) { + return ; + } + + return children; +} diff --git a/webui/src/pages/Setup.jsx b/webui/src/pages/Setup.jsx new file mode 100644 index 0000000..09f6b8b --- /dev/null +++ b/webui/src/pages/Setup.jsx @@ -0,0 +1,707 @@ +import React, { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react'; +import { setupAPI } from '../services/api'; + +// ── constants ───────────────────────────────────────────────────────────────── + +const TOTAL_STEPS = 7; + +const CELL_NAME_RE = /^[a-z][a-z0-9-]{1,30}$/; + +const DOMAIN_OPTIONS = [ + { + value: 'pic_ngo', + label: 'PIC.NGO subdomain', + description: 'Get a free yourname.pic.ngo domain — managed automatically.', + }, + { + value: 'custom', + label: 'Custom domain', + description: 'Bring your own domain. You will configure DNS records manually.', + }, + { + value: 'lan_only', + label: 'LAN only', + description: 'No public domain. Accessible only on your local network and via VPN.', + }, +]; + +const DDNS_OPTIONS = [ + { value: 'pic_ngo', label: 'pic.ngo (managed)', description: 'Automatic — no setup required.' }, + { value: 'cloudflare', label: 'Cloudflare', description: 'Use Cloudflare DNS with API token.' }, + { value: 'duckdns', label: 'DuckDNS', description: 'Free dynamic DNS via duckdns.org.' }, + { value: 'noip', label: 'No-IP', description: 'Free dynamic DNS via noip.com.' }, + { value: 'freedns', label: 'FreeDNS', description: 'Free DNS via freedns.afraid.org.' }, + { value: 'manual', label: 'Manual / None', description: 'You will handle DNS updates yourself.' }, +]; + +const OPTIONAL_SERVICES = [ + { key: 'email', label: 'Email', description: 'Postfix + Dovecot IMAP/SMTP server.' }, + { key: 'calendar', label: 'Calendar & Contacts', description: 'CalDAV/CardDAV via Radicale.' }, + { key: 'files', label: 'Files (WebDAV)', description: 'WebDAV file storage accessible from any device.' }, + { key: 'webmail', label: 'Webmail UI', description: 'Browser-based email client (Roundcube).' }, +]; + +const ALWAYS_ON_SERVICES = [ + { key: 'vpn', label: 'VPN (WireGuard)' }, + { key: 'dns', label: 'DNS (CoreDNS)' }, + { key: 'api', label: 'API (cell-api)' }, +]; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function getAllTimezones() { + try { + return Intl.supportedValuesOf('timeZone'); + } catch { + // Fallback list for older browsers + return [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Asia/Tokyo', + 'Asia/Shanghai', + 'Australia/Sydney', + ]; + } +} + +function passwordStrength(pw) { + if (!pw) return { label: '', color: '', width: '0%' }; + let score = 0; + if (pw.length >= 12) score++; + if (pw.length >= 16) score++; + if (/[A-Z]/.test(pw)) score++; + if (/[0-9]/.test(pw)) score++; + if (/[^A-Za-z0-9]/.test(pw)) score++; + if (score <= 1) return { label: 'Weak', color: 'bg-red-500', width: '20%' }; + if (score === 2) return { label: 'Fair', color: 'bg-yellow-500', width: '40%' }; + if (score === 3) return { label: 'Good', color: 'bg-blue-500', width: '65%' }; + return { label: 'Strong', color: 'bg-green-500', width: '100%' }; +} + +// ── sub-components ──────────────────────────────────────────────────────────── + +function StepHeader({ step, title, description }) { + return ( +
+

+ Step {step} of {TOTAL_STEPS} +

+

{title}

+ {description &&

{description}

} +
+ ); +} + +function ProgressBar({ step }) { + const pct = Math.round((step / TOTAL_STEPS) * 100); + return ( +
+
+ Setup progress + {pct}% +
+
+
+
+
+ ); +} + +function FieldError({ message }) { + if (!message) return null; + return ( +

+ + {message} +

+ ); +} + +function RadioOption({ value, selected, label, description, onChange }) { + return ( + + ); +} + +function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false, loading = false }) { + return ( +
+ {onBack ? ( + + ) : ( +
+ )} + +
+ ); +} + +// ── step screens ────────────────────────────────────────────────────────────── + +function Step1CellName({ value, onChange, onNext }) { + const [error, setError] = useState(''); + const [serverError, setServerError] = useState(''); + const [loading, setLoading] = useState(false); + + const validate = () => { + if (!value.trim()) return 'Cell name is required.'; + if (!CELL_NAME_RE.test(value)) + return 'Use lowercase letters, numbers, and hyphens only. Must start with a letter. 2–31 characters.'; + return ''; + }; + + const handleNext = async () => { + const err = validate(); + setError(err); + setServerError(''); + if (err) return; + + setLoading(true); + try { + await setupAPI.validate('cell_name', { cell_name: value }); + onNext(); + } catch (e) { + setServerError( + e?.response?.data?.error || 'Validation failed. Please try a different name.' + ); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+ + { + 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} + /> +

+ Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters. +

+
+ +
+
+ +
+ ); +} + +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 ( +
+ +
+
+ +
+ { 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} + /> + +
+ {/* Strength bar */} + {password.length > 0 && ( +
+
+
+
+

Strength: {strength.label}

+
+ )} +
+
+ +
+ +
+ { 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} + /> + +
+
+
+
+ +
+ ); +} + +function Step3Domain({ value, onChange, onNext, onBack }) { + return ( +
+ +
+ {DOMAIN_OPTIONS.map(opt => ( + + ))} +
+ +
+ ); +} + +function Step4DDNS({ value, onChange, onNext, onBack }) { + return ( +
+ +
+ {DDNS_OPTIONS.map(opt => ( + + ))} +
+ +
+ ); +} + +function Step5Services({ selected, onChange, onNext, onBack }) { + const toggle = key => { + onChange( + selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key] + ); + }; + + return ( +
+ + + {/* Optional services */} +
+ {OPTIONAL_SERVICES.map(svc => { + const checked = selected.includes(svc.key); + return ( + + ); + })} +
+ + {/* Always-on services */} +
+

+ Always enabled +

+
+ {ALWAYS_ON_SERVICES.map(svc => ( +
+ + {svc.label} + always enabled +
+ ))} +
+
+ + +
+ ); +} + +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 ( +
+ +
+ + 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" + /> + + + {value && ( +

+ Selected: {value} +

+ )} +
+ +
+ ); +} + +function ReviewRow({ label, value }) { + return ( +
+ {label} + {value} +
+ ); +} + +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 ( +
+ +
+ + + + {fields.domain_type !== 'lan_only' && ( + + )} + + +
+ + {submitError && ( +
+ +

{submitError}

+
+ )} + + +
+ ); +} + +// ── 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 ( +
+
+ +

Setup complete!

+

Redirecting to login...

+
+
+ ); + } + + return ( +
+
+ {/* Page title */} +
+

Personal Internet Cell

+

First-time setup

+
+ + + + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( + + )} + {step === 5 && ( + + )} + {step === 6 && ( + + )} + {step === 7 && ( + + )} +
+
+ ); +}