Phase 1: first-run setup wizard, bash installer, Docker profiles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+28
-2
@@ -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
|
||||
|
||||
@@ -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,6 +463,17 @@ 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()
|
||||
|
||||
+3
-1
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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': '<step_name>', '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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Executable
+331
@@ -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:-<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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onSetupPage = location.pathname === '/setup';
|
||||
|
||||
// Setup incomplete and not already on /setup → redirect there
|
||||
if (status === false && !onSetupPage) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
// Setup complete but user navigated to /setup → send to login
|
||||
if (status === true && onSetupPage) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium text-blue-400 uppercase tracking-wider mb-1">
|
||||
Step {step} of {TOTAL_STEPS}
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-gray-400">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ step }) {
|
||||
const pct = Math.round((step / TOTAL_STEPS) * 100);
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Setup progress</span>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={step}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={TOTAL_STEPS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p className="mt-1.5 flex items-center gap-1 text-xs text-red-400" role="alert">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioOption({ value, selected, label, description, onChange }) {
|
||||
return (
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-950/40'
|
||||
: 'border-gray-700 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5 accent-blue-500"
|
||||
value={value}
|
||||
checked={selected}
|
||||
onChange={() => onChange(value)}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{label}</div>
|
||||
{description && <div className="text-xs text-gray-400 mt-0.5">{description}</div>}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function NavButtons({ onBack, onNext, nextLabel = 'Next', nextDisabled = false, loading = false }) {
|
||||
return (
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-700">
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={nextDisabled || loading}
|
||||
className="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-md transition-colors flex items-center gap-2"
|
||||
>
|
||||
{loading && (
|
||||
<span className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-white" />
|
||||
)}
|
||||
{nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── step screens ──────────────────────────────────────────────────────────────
|
||||
|
||||
function Step1CellName({ value, onChange, onNext }) {
|
||||
const [error, setError] = useState('');
|
||||
const [serverError, setServerError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validate = () => {
|
||||
if (!value.trim()) return 'Cell name is required.';
|
||||
if (!CELL_NAME_RE.test(value))
|
||||
return 'Use lowercase letters, numbers, and hyphens only. Must start with a letter. 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 (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={1}
|
||||
title="Name your cell"
|
||||
description="This is the internal identifier for your Personal Internet Cell. It appears in hostnames and logs."
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
|
||||
Cell name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="cell-name"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={e => {
|
||||
onChange(e.target.value.toLowerCase());
|
||||
setError('');
|
||||
setServerError('');
|
||||
}}
|
||||
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
||||
placeholder="e.g. homelab"
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
|
||||
aria-describedby={error || serverError ? 'cell-name-error' : undefined}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Lowercase letters, numbers, hyphens. Must start with a letter. 2–31 characters.
|
||||
</p>
|
||||
<div id="cell-name-error">
|
||||
<FieldError message={error || serverError} />
|
||||
</div>
|
||||
</div>
|
||||
<NavButtons onNext={handleNext} loading={loading} nextDisabled={!value.trim()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, onNext, onBack }) {
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const strength = passwordStrength(password);
|
||||
|
||||
const validate = () => {
|
||||
const e = {};
|
||||
if (!password) e.password = 'Password is required.';
|
||||
else if (password.length < 12) e.password = 'Password must be at least 12 characters.';
|
||||
if (!confirm) e.confirm = 'Please confirm your password.';
|
||||
else if (password !== confirm) e.confirm = 'Passwords do not match.';
|
||||
return e;
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const e = validate();
|
||||
setErrors(e);
|
||||
if (Object.keys(e).length === 0) onNext();
|
||||
};
|
||||
|
||||
const isReady = password.length >= 12 && password === confirm;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={2}
|
||||
title="Set admin password"
|
||||
description="This password protects access to your cell. Choose something strong and store it safely."
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="pw">
|
||||
Password <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="pw"
|
||||
type={showPw ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={e => { onChangePassword(e.target.value); setErrors(p => ({ ...p, password: '' })); }}
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 pr-9 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
aria-describedby={errors.password ? 'pw-error' : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw(v => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-200"
|
||||
tabIndex={-1}
|
||||
aria-label={showPw ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Strength bar */}
|
||||
{password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-gray-700 rounded-full h-1">
|
||||
<div
|
||||
className={`h-1 rounded-full transition-all duration-300 ${strength.color}`}
|
||||
style={{ width: strength.width }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Strength: {strength.label}</p>
|
||||
</div>
|
||||
)}
|
||||
<div id="pw-error"><FieldError message={errors.password} /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="pw-confirm">
|
||||
Confirm password <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="pw-confirm"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={e => { onChangeConfirm(e.target.value); setErrors(p => ({ ...p, confirm: '' })); }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleNext()}
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 pr-9 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
aria-describedby={errors.confirm ? 'pw-confirm-error' : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(v => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-200"
|
||||
tabIndex={-1}
|
||||
aria-label={showConfirm ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step3Domain({ value, onChange, onNext, onBack }) {
|
||||
return (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={3}
|
||||
title="Choose your domain"
|
||||
description="How will you and your peers reach this cell over the internet?"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{DOMAIN_OPTIONS.map(opt => (
|
||||
<RadioOption
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
selected={value === opt.value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step4DDNS({ value, onChange, onNext, onBack }) {
|
||||
return (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={4}
|
||||
title="DDNS provider"
|
||||
description="Which provider will keep your dynamic IP address up to date? Credentials are configured separately after setup."
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{DDNS_OPTIONS.map(opt => (
|
||||
<RadioOption
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
selected={value === opt.value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step5Services({ selected, onChange, onNext, onBack }) {
|
||||
const toggle = key => {
|
||||
onChange(
|
||||
selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={5}
|
||||
title="Optional services"
|
||||
description="Choose which services to enable. You can change this later in Settings."
|
||||
/>
|
||||
|
||||
{/* Optional services */}
|
||||
<div className="space-y-2 mb-6">
|
||||
{OPTIONAL_SERVICES.map(svc => {
|
||||
const checked = selected.includes(svc.key);
|
||||
return (
|
||||
<label
|
||||
key={svc.key}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
checked ? 'border-blue-500 bg-blue-950/40' : 'border-gray-700 hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 accent-blue-500"
|
||||
checked={checked}
|
||||
onChange={() => toggle(svc.key)}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{svc.label}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{svc.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Always-on services */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
||||
Always enabled
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{ALWAYS_ON_SERVICES.map(svc => (
|
||||
<div
|
||||
key={svc.key}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-gray-800 bg-gray-900/40 opacity-60"
|
||||
>
|
||||
<input type="checkbox" checked readOnly disabled className="mt-0 accent-blue-500" aria-label={`${svc.label} is always enabled`} />
|
||||
<span className="text-sm text-gray-400">{svc.label}</span>
|
||||
<span className="ml-auto text-xs text-gray-600">always enabled</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavButtons onBack={onBack} onNext={onNext} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step6Timezone({ value, onChange, onNext, onBack }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const allZones = useMemo(() => getAllTimezones(), []);
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return q ? allZones.filter(z => z.toLowerCase().includes(q)) : allZones;
|
||||
}, [query, allZones]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={6}
|
||||
title="Timezone"
|
||||
description="Used for log timestamps, cron jobs, and email headers."
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="tz-search">
|
||||
Search timezone
|
||||
</label>
|
||||
<input
|
||||
id="tz-search"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="e.g. New York, Berlin, Tokyo"
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600 mb-2"
|
||||
/>
|
||||
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="tz-select">
|
||||
Select timezone <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="tz-select"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
size={8}
|
||||
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{filtered.map(z => (
|
||||
<option key={z} value={z}>{z}</option>
|
||||
))}
|
||||
</select>
|
||||
{value && (
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
Selected: <span className="text-white">{value}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2.5 border-b border-gray-800 last:border-0">
|
||||
<span className="text-sm text-gray-400">{label}</span>
|
||||
<span className="text-sm text-white font-medium text-right max-w-[60%] break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
|
||||
const domainLabel = DOMAIN_OPTIONS.find(o => o.value === fields.domain_type)?.label || fields.domain_type;
|
||||
const ddnsLabel = DDNS_OPTIONS.find(o => o.value === fields.ddns_provider)?.label || fields.ddns_provider;
|
||||
const serviceLabels = fields.services.length
|
||||
? fields.services.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
|
||||
: 'None selected';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StepHeader
|
||||
step={7}
|
||||
title="Review and finish"
|
||||
description="Check your choices below. You can go back to change anything before completing setup."
|
||||
/>
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
|
||||
<ReviewRow label="Cell name" value={fields.cell_name} />
|
||||
<ReviewRow label="Admin password" value="••••••••••••" />
|
||||
<ReviewRow label="Domain type" value={domainLabel} />
|
||||
{fields.domain_type !== 'lan_only' && (
|
||||
<ReviewRow label="DDNS provider" value={ddnsLabel} />
|
||||
)}
|
||||
<ReviewRow label="Optional services" value={serviceLabels} />
|
||||
<ReviewRow label="Timezone" value={fields.timezone} />
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="mt-4 p-3 bg-red-950/50 border border-red-700 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-300">{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NavButtons
|
||||
onBack={onBack}
|
||||
onNext={onSubmit}
|
||||
nextLabel="Complete setup"
|
||||
loading={submitting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Setup() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState(1);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [cellName, setCellName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [domainType, setDomainType] = useState('pic_ngo');
|
||||
const [ddnsProvider, setDdnsProvider] = useState('pic_ngo');
|
||||
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
|
||||
const [timezone, setTimezone] = useState(
|
||||
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
|
||||
);
|
||||
|
||||
// Submit state
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
const skipDdns = domainType === 'lan_only';
|
||||
|
||||
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
|
||||
const goBack = () => setStep(s => Math.max(s - 1, 1));
|
||||
|
||||
// Skip step 4 when LAN only
|
||||
const handleStep3Next = () => {
|
||||
if (skipDdns) setStep(5);
|
||||
else setStep(4);
|
||||
};
|
||||
const handleStep4Back = () => setStep(3);
|
||||
const handleStep5Back = () => {
|
||||
if (skipDdns) setStep(3);
|
||||
else setStep(4);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitError('');
|
||||
setSubmitting(true);
|
||||
const payload = {
|
||||
cell_name: cellName,
|
||||
password,
|
||||
domain_type: domainType,
|
||||
...(skipDdns ? {} : { ddns_provider: ddnsProvider }),
|
||||
services,
|
||||
timezone,
|
||||
};
|
||||
try {
|
||||
await setupAPI.complete(payload);
|
||||
setDone(true);
|
||||
setTimeout(() => navigate('/login', { replace: true }), 2000);
|
||||
} catch (e) {
|
||||
setSubmitError(
|
||||
e?.response?.data?.error ||
|
||||
'Setup could not be completed. Please check your entries and try again.'
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const allFields = { cell_name: cellName, domain_type: domainType, ddns_provider: ddnsProvider, services, timezone };
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-950">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Setup complete!</h2>
|
||||
<p className="text-sm text-gray-400">Redirecting to login...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10">
|
||||
<div className="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl">
|
||||
{/* Page title */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1>
|
||||
<p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
|
||||
</div>
|
||||
|
||||
<ProgressBar step={step} />
|
||||
|
||||
{step === 1 && (
|
||||
<Step1CellName value={cellName} onChange={setCellName} onNext={goNext} />
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Step2Password
|
||||
password={password}
|
||||
confirm={passwordConfirm}
|
||||
onChangePassword={setPassword}
|
||||
onChangeConfirm={setPasswordConfirm}
|
||||
onNext={goNext}
|
||||
onBack={goBack}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Step4DDNS value={ddnsProvider} onChange={setDdnsProvider} onNext={goNext} onBack={handleStep4Back} />
|
||||
)}
|
||||
{step === 5 && (
|
||||
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
|
||||
)}
|
||||
{step === 6 && (
|
||||
<Step6Timezone value={timezone} onChange={setTimezone} onNext={goNext} onBack={goBack} />
|
||||
)}
|
||||
{step === 7 && (
|
||||
<Step7Review
|
||||
fields={allFields}
|
||||
onBack={goBack}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
submitError={submitError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user