12 Commits

Author SHA1 Message Date
roof 925ab1f696 Overhaul setup wizard: domain config, password strength, field alignment
Unit Tests / test (push) Successful in 8m48s
Password:
- Add lowercase to strength scoring; "Good" now requires all API criteria
  (12 chars, upper, lower, digit) — no more submitting passwords the API rejects
- isReady gates the Next button on meeting API requirements, not just length

Domain steps 3 + 4:
- Step 3: choose pic_ngo / custom / lan (sends valid API domain_modes)
- Step 4 (pic.ngo): shows derived [cellName].pic.ngo domain preview
- Step 4 (custom): domain name field + TLS method selector
  (Cloudflare DNS-01 + API token, DuckDNS + token, HTTP-01 + port-80 warning)
- Step 4 skipped entirely for LAN-only
- Review step shows actual domain string and TLS method instead of opaque codes

Cell name:
- Description and preview hint make clear it becomes the pic.ngo subdomain
- Step 1 shows live "name.pic.ngo" preview as you type

Backend:
- setup_manager now accepts and stores domain_name, cloudflare_api_token,
  duckdns_token for Phase 3 DDNS registration use

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 07:27:59 -04:00
roof 439886624e Fix config/data ownership — chown to invoking user after make install
Unit Tests / test (push) Successful in 8m46s
make install runs as root so all generated files (config/, data/) land
as root:root. Added a chown pass in install.sh after make install
completes, re-applying REPO_OWNER ownership. Also fixed the make setup
chown to use SUDO_USER when invoked via sudo rather than always id -u
(which is 0 when running as root).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 06:46:12 -04:00
roof 24877df976 Fix setup wizard and installer for fresh-install flow
Unit Tests / test (push) Successful in 8m53s
- setup_manager: fall back to update_password if admin already exists
  (installer bootstrap creates admin; wizard now updates rather than fails)
- install.sh: chown repo to SUDO_USER instead of pic user so the
  invoking operator can run make update without git safe.directory errors
- test: update mock to also stub update_password when testing total auth failure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 06:08:55 -04:00
roof bfa0d99dd1 Fix git safe.directory error for non-root users after install
Unit Tests / test (push) Successful in 8m55s
The installer runs as root and chowns /opt/pic to the pic user.
Any other user (roof, operator) running make update then hits
"detected dubious ownership". Fix: add /opt/pic to system-wide
git safe.directory after clone, and add same guard in make update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:46:40 -04:00
roof 1e2cf5580f Fix setup wizard: align field names with API (domain_type→domain_mode, services→services_enabled)
Unit Tests / test (push) Successful in 8m52s
The wizard was sending domain_type and services but the API expected
domain_mode and services_enabled, causing a validation error on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:36:18 -04:00
roof 1989dfa0a3 Fix: exempt /api/setup/* from enforce_auth so setup wizard works on fresh install
Unit Tests / test (push) Successful in 8m49s
The setup wizard runs before any account exists, but the installer's
setup_cell.py creates auth_users.json with an admin account first.
This meant enforce_auth was active by the time the browser hit /setup,
blocking all /api/setup/* calls with 401. The CSRF hook already exempted
/api/setup/* — auth enforcement now matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 05:03:44 -04:00
roof 5dab6377bc Restore https:// now that git.pic.ngo has a TLS certificate
Unit Tests / test (push) Failing after 15m59s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 04:33:51 -04:00
roof 0a24d20bbc Update QUICKSTART: use http for install.pic.ngo and git.pic.ngo (no HTTPS yet)
Unit Tests / test (push) Successful in 8m50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 02:58:48 -04:00
roof 46599bd37e Fix installer: use http://git.pic.ngo without port (nginx forwards)
Unit Tests / test (push) Successful in 8m55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 02:57:13 -04:00
roof dde4d9a53f Rewrite CLAUDE.md following article best practices
Unit Tests / test (push) Successful in 8m54s
Adds: tech stack, coding conventions, file placement rules, safety rules,
infrastructure topology table, and expands architecture with key-file table
and before-request hook documentation. Removes vague guidance, replaces
with actionable rules Claude can follow automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:25:53 -04:00
roof 674a66f7a0 Revert registry port: git.pic.ngo uses standard port (DNS fix pending)
Unit Tests / test (push) Successful in 8m55s
2026-05-10 06:59:13 -04:00
roof 9df3bf6a17 Fix release workflow: registry is git.pic.ngo:3000 not port 80
Unit Tests / test (push) Successful in 8m55s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 06:52:42 -04:00
8 changed files with 587 additions and 154 deletions
+252 -57
View File
@@ -1,87 +1,282 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file is the primary context source for Claude Code in this repository. Read it fully before touching any code.
## What This Project Is ---
**Personal Internet Cell (PIC)** — a self-hosted digital infrastructure platform. It manages DNS, DHCP, NTP, WireGuard VPN, email, calendar/contacts (CalDAV), file storage (WebDAV), reverse proxy (Caddy), a certificate authority, and container orchestration, all from a single API + React UI. ## Project Overview
## Common Commands **Personal Internet Cell (PIC)** is a self-hosted digital infrastructure platform for individuals who want full ownership of their core internet services without relying on cloud providers.
```bash A PIC instance runs DNS, DHCP, NTP, WireGuard VPN, email (SMTP/IMAP), calendar/contacts (CalDAV/CardDAV), file storage (WebDAV), HTTPS reverse proxy (Caddy), an internal certificate authority, and optional third-party services — all managed from a single REST API and a React web UI. No manual config-file editing is required for normal operations.
# Full stack
make start # docker-compose up -d
make stop # docker-compose down
make restart # docker-compose restart
make status # docker status + API health
make logs # docker-compose logs -f
make build # rebuild api image
# Tests **Primary users:** technically capable individuals, homelab operators, small families or teams.
make test # pytest tests/ api/tests/
make test-coverage # pytest with coverage HTML report
make test-api # pytest tests/test_api_endpoints.py
pytest tests/test_<module>.py # single test file
# Local dev (no Docker) **What the product optimizes for:**
pip install -r api/requirements.txt - One-command install, browser-based first-run wizard, no manual `.env` editing for identity
python api/app.py # Flask API on :3000 - Everything managed through the API and UI — the user should never need to `ssh` for day-to-day operations
- Security by default: session auth, CSRF protection, WireGuard isolation, internal CA, no open API port
- Reliability and observability: structured logs, health monitoring, automated config backups
cd webui && npm install && npm run dev # React UI on :5173 (proxies API to :3000) **Key constraints:**
- Runs on a single Linux host with Docker; no Kubernetes, no swarm
- Must work on Debian, Ubuntu, Fedora, RHEL, and Alpine
- The Flask API must never be exposed directly; Caddy always proxies it
- All secrets live in `data/` (git-ignored), never in the repo
# WireGuard ---
make show-routes
make add-peer PEER_NAME=foo PEER_IP=10.0.0.5 PEER_KEY=<pubkey> ## Tech Stack
make list-peers
``` ### Backend
- **Python 3.11** — Flask REST API (`api/app.py`)
- **Flask** — routing, sessions, before-request hooks (enforce_setup, enforce_auth, check_csrf)
- **bcrypt** — password hashing in `AuthManager`
- **Docker SDK for Python** — container lifecycle in `ContainerManager`
- **PyNaCl / Age** — encryption in `VaultManager`
- **pyotp** — TOTP for DDNS registration
### Frontend
- **React 18** — SPA
- **Vite** — dev server and build (proxies `/api``:3000`)
- **Tailwind CSS** — all styling; no custom CSS files
- **Axios** — all API calls go through `src/services/api.js`
### Infrastructure
- **Docker Compose** — all 12+ service containers
- **Caddy** — reverse proxy, TLS termination (Let's Encrypt DNS-01 or HTTP-01 or internal CA)
- **CoreDNS** — `.cell` TLD authoritative DNS
- **dnsmasq** — DHCP
- **chrony** — NTP
- **WireGuard** — VPN (kernel module, not userspace)
- **Postfix + Dovecot** — email via `docker-mailserver`
- **Radicale** — CalDAV/CardDAV
- **PowerDNS** — authoritative DNS on the DDNS VPS (separate repo: `pic-ddns`)
### CI/CD
- **Gitea Actions** — unit tests on every push, image builds on tag
- **act_runner** — self-hosted runner on pic0 (192.168.31.51)
- **Gitea Container Registry** — images pushed to `git.pic.ngo`
Do not introduce: Redux, styled-components, SQLAlchemy, Celery, or any async framework (asyncio/FastAPI) into the main API unless explicitly requested.
---
## Architecture ## Architecture
### Backend (`api/`) ```
Browser / WireGuard peer
└── Caddy (:80/:443) TLS termination, reverse proxy
└── React SPA (:8081) Vite + Tailwind (Nginx in container)
└── Flask API (:3000) REST API, bound to 127.0.0.1 only
├── NetworkManager CoreDNS, dnsmasq, chrony
├── WireGuardManager WireGuard peer lifecycle
├── PeerRegistry peer registration and trust
├── EmailManager Postfix + Dovecot
├── CalendarManager Radicale CalDAV/CardDAV
├── FileManager WebDAV + Filegator
├── RoutingManager iptables NAT and routing
├── FirewallManager iptables INPUT/FORWARD rules
├── VaultManager internal CA, TLS certs, Age encryption
├── ContainerManager Docker SDK
├── CellLinkManager site-to-site WireGuard links
├── ConnectivityManager per-peer exit routing (WG ext, OpenVPN, Tor)
├── DDNSManager dynamic DNS heartbeat
├── ServiceStoreManager optional service install/remove
├── CaddyManager Caddyfile generation and reload
├── AuthManager bcrypt passwords, session auth, RBAC
└── SetupManager first-run wizard state
```
All service managers inherit `BaseServiceManager` (`api/base_service_manager.py`). This enforces a consistent interface: `get_status()`, `get_config()`, `update_config()`, `validate_config()`, `test_connectivity()`, `get_logs()`, `restart_service()`. When adding or modifying a service manager, follow this pattern. ### Key files
The `ServiceBus` (`api/service_bus.py`) is a pub/sub event system used for inter-service communication. Services publish events (e.g., `SERVICE_STARTED`, `CONFIG_CHANGED`, `PEER_CONNECTED`) and subscribe to events from dependencies. Dependency graph is declared in the bus — e.g., `wireguard` depends on `network`; `email` depends on `network` and `vault`. | File | Role |
|---|---|
| `api/app.py` | Flask app, all REST endpoints, before-request hooks, health monitor thread |
| `api/managers.py` | Singleton instantiation of all service managers |
| `api/base_service_manager.py` | Abstract base class: `get_status`, `get_config`, `update_config`, `validate_config`, `test_connectivity`, `get_logs`, `restart_service` |
| `api/config_manager.py` | Single source of truth for `cell_config.json` — all read/write goes through here |
| `api/service_bus.py` | Pub/sub event system between managers |
| `webui/src/services/api.js` | Axios API client — all UI→API calls |
| `docker-compose.yml` | Container definitions and network topology |
| `Makefile` | All operational commands |
| `install.sh` | Bash installer served via `https://install.pic.ngo` |
`ConfigManager` (`api/config_manager.py`) is the single source of truth. Config lives in `/app/config/cell_config.json` (mapped from `config/api/`). All managers read/write through ConfigManager, which validates against per-service schemas and maintains automatic backups. ### Directory layout
`LogManager` (`api/log_manager.py`) provides structured JSON logging with rotation (5 MB / 5 backups per service). Use it instead of `print()` or raw `logging`. ```
api/ Flask API and all service managers
webui/ React SPA (Vite + Tailwind)
tests/ pytest unit tests (no running services required)
tests/integration/ require a running PIC stack
tests/e2e/ Playwright UI and WireGuard e2e tests
config/ Runtime config per service (mostly git-ignored)
data/ Runtime secrets and state (fully git-ignored)
scripts/ Setup and maintenance scripts
install.sh One-line installer entry point
Makefile All make targets
docker-compose.yml
```
`app.py` (2000+ lines) contains all Flask REST endpoints, organized by service. It runs a background health-monitoring thread. ### Config and secrets
Service managers: - Runtime config: `config/api/cell_config.json` — managed by `ConfigManager`, never edit directly
- `network_manager.py` — DNS (CoreDNS), DHCP (dnsmasq), NTP (chrony) - Secrets and user data: `data/` — git-ignored, contains `auth_users.json`, WireGuard keys, DDNS token, CA key
- `wireguard_manager.py` — VPN peer lifecycle, QR codes - DDNS config lives under the top-level `ddns` key in `cell_config.json`, accessed via `config_manager.configs.get('ddns', {})`
- `peer_registry.py` — peer registration/lookup - Do not read `_identity.domain` expecting a dict — it is a plain string (the domain mode, e.g. `"pic_ngo"`)
- `routing_manager.py` — NAT, firewall rules, VPN gateway
- `vault_manager.py` — internal certificate authority
- `email_manager.py` — Postfix + Dovecot
- `calendar_manager.py` — Radicale CalDAV/CardDAV
- `file_manager.py` — WebDAV storage
- `container_manager.py` — Docker SDK wrappers
- `cell_manager.py` — top-level orchestration
### Frontend (`webui/`) ### Before-request hooks (app.py)
React 18 + Vite + Tailwind CSS. All API calls go through `src/services/api.js` (Axios). Vite dev server proxies `/api` to `localhost:3000`. Pages in `src/pages/`, shared components in `src/components/`. Three hooks run on every request in this order:
1. `enforce_setup` — returns 428 for all `/api/*` except `/api/setup/*` and `/health` until setup is complete. Skipped when `app.config['TESTING']` is True.
2. `enforce_auth` — returns 401 if no session; returns 503 if users file exists but is empty (misconfiguration). Skipped when `app.config['TESTING']` is True.
3. `check_csrf` — requires `X-CSRF-Token` header on all mutating requests except `/api/auth/*` and `/api/setup/*`.
### Infrastructure ---
`docker-compose.yml` defines 13 services on a custom bridge network `cell-network` (172.20.0.0/16). Cell IPs default to 10.0.0.0/24. Key ports: 53 (DNS), 80/443 (Caddy), 3000 (API), 5173/8081 (WebUI), 51820/udp (WireGuard), 25/587/993 (mail), 5232 (CalDAV), 8080 (WebDAV). ## Coding Conventions
Config files for each service live under `config/<service>/`. Persistent data is under `data/` (git-ignored). WireGuard configs are also git-ignored. ### Python (API)
## Testing - All managers inherit `BaseServiceManager` — always implement all abstract methods
- Use `self.logger` (from `BaseServiceManager`) — never `print()` or raw `logging`
- Config reads go through `self.config_manager` — never open `cell_config.json` directly
- Use `threading.RLock` for shared state; managers run in a multi-threaded Flask app
- Do not use `any` typing; be explicit
- Keep Flask route handlers thin — business logic belongs in the manager, not in `app.py`
- Error responses must be JSON: `jsonify({'error': '...'}), <status_code>`
- Do not catch bare `Exception` and silently swallow it — log at minimum
Tests live in `tests/` (28 files). Use mocking (`pytest-mock`) for external system calls. Integration tests in `test_integration.py` require Docker services running. ### JavaScript (webui)
## AI Collaboration Rules (Claude Code) - All API calls go through `src/services/api.js` — never use `fetch` or a new Axios instance directly
- Use functional components; no class components
- Tailwind utilities only — no inline styles, no custom CSS files
- Keep page components in `src/pages/`, reusable UI in `src/components/`
- State: local `useState`/`useEffect` is fine; no Redux or global state library
### General
- No comments that describe *what* the code does — only *why* if non-obvious
- No dead code, no commented-out blocks
- No backwards-compat shims for things being removed
- Prefer editing existing files over creating new ones
- Tests that write to disk: mock `builtins.open` with `OSError` rather than relying on `/nonexistent/path` (CI runs as root and can create any path)
---
## Testing and Quality
Before considering any task complete:
1. Run `make test` — all 1500+ unit tests must pass
2. Fix failures before committing — the pre-commit hook will block the commit anyway
### Rules
- Use `unittest.mock` / `pytest-mock` for all Docker, filesystem, and subprocess calls
- Tests must pass in CI (rootless environment where filesystem assumptions don't hold)
- When testing write-failure paths, mock `builtins.open` with `side_effect=OSError` — do not rely on unwritable paths
- Integration tests (`tests/integration/`) require a running stack — exclude from CI with `--ignore=tests/integration`
- E2e tests (`tests/e2e/`) require Playwright — exclude from CI with `--ignore=tests/e2e`
- Add tests for any new API endpoint, manager method, or utility function
- Do not add tests for Flask routing boilerplate or trivial getters — test behaviour, not structure
---
## File Placement Rules
| New thing | Where it goes |
|---|---|
| New service manager | `api/<name>_manager.py`, registered in `api/managers.py` and wired into `app.py` |
| New API endpoints | `app.py` — grouped with the relevant manager's existing endpoints |
| New React page | `webui/src/pages/` |
| Reusable UI component | `webui/src/components/` |
| New pytest test file | `tests/test_<module>.py` |
| Operational script | `scripts/` |
| Documentation | Update `README.md`, `QUICKSTART.md`, or `Personal Internet Cell Project Wiki.md` as appropriate |
Do not create a new abstraction for a single use case. Do not create near-duplicate files — edit the existing one.
---
## Safety Rules
- **Never expose the Flask API port (3000) directly** — it must always be behind Caddy
- **Never commit secrets** — `data/`, `.env`, `*.key`, `*.pem` are all git-ignored; keep it that way
- **Do not modify `enforce_setup` or `enforce_auth` hooks** without understanding the full auth flow — these are the security boundary
- **Do not change the `cell_config.json` schema** without updating `ConfigManager` validation and all manager reads
- **Do not rename API route paths** without checking the webui `api.js` client and any external callers
- **Do not modify WireGuard key generation** — losing the server private key means all peers must be re-provisioned
- Flag any change to auth flow, CSRF logic, or session management as security-sensitive before implementing
---
## Commands
```bash
# Stack lifecycle (always use make — never call docker/docker-compose directly)
make start # build and start all containers
make stop # stop all containers
make restart # restart containers
make status # container status + API health check
make logs # follow all container logs
make logs-api # follow API logs only
make logs-caddy # follow Caddy logs
make shell-api # shell inside the API container
make build-api # rebuild API image after code change
make build-webui # rebuild webui image after code change
# Tests
make test # pytest tests/ --ignore=tests/e2e --ignore=tests/integration
make test-coverage # coverage report in htmlcov/
pytest tests/test_<module>.py -v # single test file
# Local dev (no Docker)
pip install -r api/requirements.txt
python3 api/app.py # Flask API on :3000
cd webui && npm install && npm run dev # React UI on :5173 (proxies /api → :3000)
# Peer / WireGuard
make list-peers
make show-routes
# Admin password
make show-admin-password
make reset-admin-password
# Backup / restore
make backup
make restore
# Maintenance
make update # git pull + rebuild + restart
make uninstall # stop containers; prompt to delete config/ and data/
```
---
## Infrastructure Topology
| Machine | IP | Role |
|---|---|---|
| pic0 | 192.168.31.51 | Dev machine — you are here. Run all commands directly. |
| pic1 | 192.168.31.52 | Test/staging PIC instance |
| Gitea | 192.168.31.50 | Self-hosted git server (`gitea@192.168.31.50:roof/pic.git`) |
| DDNS VPS | 192.168.31.101 (LAN) / 178.168.15.65 (public) | PowerDNS + FastAPI for `*.pic.ngo` DDNS |
The `roof` user on pic0 has passwordless sudo and is in the `docker` group — use both freely.
---
## AI Collaboration Rules
These rules apply to every Claude Code session in this repo: These rules apply to every Claude Code session in this repo:
- **Read memory first** — load `/home/roof/.claude/projects/-home-roof/memory/MEMORY.md` and referenced files at session start. - **Read memory first** — load `/home/roof/.claude/projects/-home-roof/memory/MEMORY.md` at session start; follow referenced memory files for relevant context.
- **Dev machine context** — you are already on pic0 (192.168.31.51), the dev machine. Execute commands here directly; do not ask the user to run them. - **You are on pic0** — execute commands directly here; do not ask the user to run them.
- **Use all available agents** — spawn specialized sub-agents (pic-remote, pic-qa, pic-architect, etc.) for tasks that match their description. - **`make` is the only container interface** — never call `docker` or `docker-compose` directly. All container lifecycle goes through `make start`, `make stop`, `make build`, `make logs`, etc.
- **make is the only interface** — never call docker/docker-compose directly. All container lifecycle operations go through `make start`, `make stop`, `make build`, `make logs`, etc. - **Use specialized agents** — spawn `pic-remote` for VPS/pic1 SSH tasks, `pic-qa` for test writing, `pic-architect` for design decisions, `pic-designer` for UI review, `pic-devops` for docker-compose/Makefile changes, `pic-writer` for documentation.
- **Test every new feature** — after implementing any change, run `make test` before considering the task done. - **Test before commit** — run `make test` and fix all failures before staging. The pre-commit hook enforces this, but run it manually first.
- **Test before commit** — the pre-commit hook enforces this, but run `make test` manually first and fix all failures before staging files. - **No skipping hooks** — never use `--no-verify` unless the only change is documentation or a workflow file with no Python/JS.
- **Commits need context** — write commit messages that explain *why*, not just *what*. Always add the Co-Authored-By trailer.
+2 -1
View File
@@ -79,7 +79,7 @@ check-deps:
setup: check-deps setup: check-deps
@echo "Setting up Personal Internet Cell..." @echo "Setting up Personal Internet Cell..."
@sudo chown -R $$(id -u):$$(id -g) config/ data/ 2>/dev/null || true @sudo chown -R $${SUDO_USER:-$$(id -un)}:$${SUDO_USER:-$$(id -un)} config/ data/ 2>/dev/null || true
CELL_NAME=$(or $(CELL_NAME),mycell) \ CELL_NAME=$(or $(CELL_NAME),mycell) \
CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \ CELL_DOMAIN=$(or $(CELL_DOMAIN),cell) \
VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \ VPN_ADDRESS=$(or $(VPN_ADDRESS),10.0.0.1/24) \
@@ -131,6 +131,7 @@ shell-%:
update: update:
@echo "Pulling latest code..." @echo "Pulling latest code..."
@git config --global --add safe.directory $$(pwd) 2>/dev/null || true
@git stash --include-untracked --quiet 2>/dev/null || true @git stash --include-untracked --quiet 2>/dev/null || true
git pull git pull
@git stash pop --quiet 2>/dev/null || true @git stash pop --quiet 2>/dev/null || true
+2 -2
View File
@@ -199,8 +199,8 @@ def enforce_auth():
backward-compatibility with pre-auth test suites. backward-compatibility with pre-auth test suites.
""" """
path = request.path path = request.path
# Always allow non-API paths and auth namespace # Always allow non-API paths, auth namespace, and setup namespace
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 return None
# Cell peer-sync endpoints authenticate via source IP + WG pubkey — not session # Cell peer-sync endpoints authenticate via source IP + WG pubkey — not session
if path.startswith('/api/cells/peer-sync/'): if path.startswith('/api/cells/peer-sync/'):
+18 -7
View File
@@ -128,9 +128,12 @@ class SetupManager:
cell_name = payload.get('cell_name', '') cell_name = payload.get('cell_name', '')
password = payload.get('password', '') password = payload.get('password', '')
domain_mode = payload.get('domain_mode', '') domain_mode = payload.get('domain_mode', '')
domain_name = payload.get('domain_name', '')
timezone = payload.get('timezone', '') timezone = payload.get('timezone', '')
services_enabled = payload.get('services_enabled', []) services_enabled = payload.get('services_enabled', [])
ddns_provider = payload.get('ddns_provider', 'none') ddns_provider = payload.get('ddns_provider', 'none')
cloudflare_api_token = payload.get('cloudflare_api_token', '')
duckdns_token = payload.get('duckdns_token', '')
errors.extend(self.validate_cell_name(cell_name)) errors.extend(self.validate_cell_name(cell_name))
errors.extend(self.validate_password(password)) errors.extend(self.validate_password(password))
@@ -168,28 +171,36 @@ class SetupManager:
if self.is_setup_complete(): if self.is_setup_complete():
return {'success': False, 'errors': ['Setup has already been completed.']} return {'success': False, 'errors': ['Setup has already been completed.']}
# ── create admin user ────────────────────────────────────────── # ── create or update admin user ────────────────────────────────
# The installer may have bootstrapped an admin account from a
# generated password. The wizard's job is to set the real password,
# so update it if the account already exists.
ok = self.auth_manager.create_user( ok = self.auth_manager.create_user(
username='admin', username='admin',
password=password, password=password,
role='admin', role='admin',
) )
if not ok: if not ok:
return {'success': False, 'errors': ['Failed to create admin user. The username may already exist.']} ok = self.auth_manager.update_password('admin', password)
if not ok:
return {'success': False, 'errors': ['Failed to set admin password.']}
# ── persist identity fields ──────────────────────────────────── # ── persist identity fields ────────────────────────────────────
self.config_manager.set_identity_field('cell_name', cell_name) self.config_manager.set_identity_field('cell_name', cell_name)
self.config_manager.set_identity_field('domain_mode', domain_mode) self.config_manager.set_identity_field('domain_mode', domain_mode)
if domain_name:
self.config_manager.set_identity_field('domain_name', domain_name)
self.config_manager.set_identity_field('timezone', timezone) self.config_manager.set_identity_field('timezone', timezone)
self.config_manager.set_identity_field('services_enabled', services_enabled) self.config_manager.set_identity_field('services_enabled', services_enabled)
self.config_manager.set_identity_field('ddns_provider', ddns_provider) self.config_manager.set_identity_field('ddns_provider', ddns_provider)
if cloudflare_api_token:
self.config_manager.set_identity_field('cloudflare_api_token', cloudflare_api_token)
if duckdns_token:
self.config_manager.set_identity_field('duckdns_token', duckdns_token)
# NOTE: DDNS registration is deferred to Phase 3.
# For now we just store ddns_provider in config.
logger.info( logger.info(
'DDNS registration skipped (Phase 1). ' 'DDNS registration deferred to Phase 3. '
'DDNS registration will happen in Phase 3. ' f'ddns_provider={ddns_provider!r} domain_name={domain_name!r}'
f'ddns_provider={ddns_provider!r} stored in identity config.'
) )
# ── mark setup complete (must be last) ───────────────────────── # ── mark setup complete (must be last) ─────────────────────────
+10 -5
View File
@@ -245,23 +245,28 @@ else
log_ok "Repository cloned to ${PIC_DIR}" log_ok "Repository cloned to ${PIC_DIR}"
fi fi
# Ensure the pic user owns the directory # Give the invoking user (or pic if run directly as root) ownership of the repo
chown -R "${PIC_USER}:${PIC_USER}" "$PIC_DIR" # so they can run `make update` and other git commands without sudo.
REPO_OWNER="${SUDO_USER:-${PIC_USER}}"
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
# Allow all users to run git commands here regardless of who owns the files
git config --system --add safe.directory "$PIC_DIR" 2>/dev/null || true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Step 5 — Run make install # Step 5 — Run make install
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log_step 5 "Running '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" cd "$PIC_DIR"
if ! make install 2>&1 | sed 's/^/ /'; then if ! make install 2>&1 | sed 's/^/ /'; then
die "'make install' failed. Check the output above." die "'make install' failed. Check the output above."
fi fi
# make install runs as root so config/ and data/ get created root-owned.
# Re-apply ownership to the invoking user so they can manage files without sudo.
chown -R "${REPO_OWNER}:${REPO_OWNER}" "$PIC_DIR"
log_ok "'make install' complete" log_ok "'make install' complete"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+6
View File
@@ -128,6 +128,12 @@ def test_anon_blocked_from_peer_routes(anon_client):
assert r.status_code == 401 assert r.status_code == 401
def test_setup_routes_bypass_auth(anon_client):
"""/api/setup/* must be reachable without a session — setup runs before any account exists."""
r = anon_client.get('/api/setup/status')
assert r.status_code != 401
def test_anon_blocked_from_peer_dashboard(anon_client): def test_anon_blocked_from_peer_dashboard(anon_client):
r = anon_client.get('/api/peer/dashboard') r = anon_client.get('/api/peer/dashboard')
assert r.status_code == 401 assert r.status_code == 401
+2 -1
View File
@@ -252,10 +252,11 @@ def test_complete_setup_returns_error_when_create_user_fails(
setup_manager, mock_config_manager, mock_auth_manager, tmp_path): setup_manager, mock_config_manager, mock_auth_manager, tmp_path):
mock_config_manager.get_identity.return_value = {} mock_config_manager.get_identity.return_value = {}
mock_auth_manager.create_user.return_value = False mock_auth_manager.create_user.return_value = False
mock_auth_manager.update_password.return_value = False
with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}): with patch.dict(os.environ, {'DATA_DIR': str(tmp_path)}):
result = setup_manager.complete_setup(_valid_payload()) result = setup_manager.complete_setup(_valid_payload())
assert result['success'] is False assert result['success'] is False
assert any('admin' in e.lower() or 'user' in e.lower() for e in result['errors']) assert any('admin' in e.lower() or 'password' in e.lower() for e in result['errors'])
# ── get_setup_status ────────────────────────────────────────────────────────── # ── get_setup_status ──────────────────────────────────────────────────────────
+285 -71
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react'; import { Eye, EyeOff, CheckCircle, AlertCircle, Globe } from 'lucide-react';
import { setupAPI } from '../services/api'; import { setupAPI } from '../services/api';
// ── constants ───────────────────────────────────────────────────────────────── // ── constants ─────────────────────────────────────────────────────────────────
@@ -13,27 +13,36 @@ const DOMAIN_OPTIONS = [
{ {
value: 'pic_ngo', value: 'pic_ngo',
label: 'PIC.NGO subdomain', label: 'PIC.NGO subdomain',
description: 'Get a free yourname.pic.ngo domain — managed automatically.', description: 'Get a free yourname.pic.ngo address — HTTPS and DDNS managed automatically.',
}, },
{ {
value: 'custom', value: 'custom',
label: 'Custom domain', label: 'Custom domain',
description: 'Bring your own domain. You will configure DNS records manually.', description: 'Use your own domain with Cloudflare, DuckDNS, or standard HTTP challenge.',
}, },
{ {
value: 'lan_only', value: 'lan',
label: 'LAN only', label: 'LAN only',
description: 'No public domain. Accessible only on your local network and via VPN.', description: 'No public domain. Accessible only on your local network and via VPN.',
}, },
]; ];
const DDNS_OPTIONS = [ const CUSTOM_METHOD_OPTIONS = [
{ value: 'pic_ngo', label: 'pic.ngo (managed)', description: 'Automatic — no setup required.' }, {
{ value: 'cloudflare', label: 'Cloudflare', description: 'Use Cloudflare DNS with API token.' }, value: 'cloudflare',
{ value: 'duckdns', label: 'DuckDNS', description: 'Free dynamic DNS via duckdns.org.' }, label: 'Cloudflare DNS',
{ value: 'noip', label: 'No-IP', description: 'Free dynamic DNS via noip.com.' }, description: 'DNS-01 via Cloudflare API. Your domain must use Cloudflare nameservers.',
{ value: 'freedns', label: 'FreeDNS', description: 'Free DNS via freedns.afraid.org.' }, },
{ value: 'manual', label: 'Manual / None', description: 'You will handle DNS updates yourself.' }, {
value: 'duckdns',
label: 'DuckDNS',
description: 'Free subdomain via duckdns.org with automatic DNS-01 challenge.',
},
{
value: 'http01',
label: 'HTTP-01 (any registrar)',
description: 'Standard ACME challenge. Port 80 must be publicly reachable.',
},
]; ];
const OPTIONAL_SERVICES = [ const OPTIONAL_SERVICES = [
@@ -55,35 +64,37 @@ function getAllTimezones() {
try { try {
return Intl.supportedValuesOf('timeZone'); return Intl.supportedValuesOf('timeZone');
} catch { } catch {
// Fallback list for older browsers
return [ return [
'UTC', 'UTC', 'America/New_York', 'America/Chicago', 'America/Denver',
'America/New_York', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Europe/Berlin',
'America/Chicago', 'Asia/Tokyo', 'Asia/Shanghai', 'Australia/Sydney',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Asia/Tokyo',
'Asia/Shanghai',
'Australia/Sydney',
]; ];
} }
} }
function passwordStrength(pw) { function passwordStrength(pw) {
if (!pw) return { label: '', color: '', width: '0%' }; if (!pw) return { label: '', color: '', width: '0%', score: 0 };
let score = 0; let score = 0;
if (pw.length >= 12) score++; if (pw.length >= 12) score++;
if (pw.length >= 16) score++; if (pw.length >= 16) score++;
if (/[A-Z]/.test(pw)) score++; if (/[A-Z]/.test(pw)) score++;
if (/[a-z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++; if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++; if (/[^A-Za-z0-9]/.test(pw)) score++;
if (score <= 1) return { label: 'Weak', color: 'bg-red-500', width: '20%' }; if (score <= 2) return { label: 'Weak', color: 'bg-red-500', width: '20%', score };
if (score === 2) return { label: 'Fair', color: 'bg-yellow-500', width: '40%' }; if (score === 3) return { label: 'Fair', color: 'bg-yellow-500', width: '45%', score };
if (score === 3) return { label: 'Good', color: 'bg-blue-500', width: '65%' }; if (score === 4) return { label: 'Good', color: 'bg-blue-500', width: '70%', score };
return { label: 'Strong', color: 'bg-green-500', width: '100%' }; return { label: 'Strong', color: 'bg-green-500', width: '100%', score };
}
function meetsApiRequirements(pw) {
return pw.length >= 12 && /[A-Z]/.test(pw) && /[a-z]/.test(pw) && /[0-9]/.test(pw);
}
function getDomainMode(domainType, customMethod) {
if (domainType === 'pic_ngo') return 'pic_ngo';
if (domainType === 'lan') return 'lan';
return customMethod || 'http01';
} }
// ── sub-components ──────────────────────────────────────────────────────────── // ── sub-components ────────────────────────────────────────────────────────────
@@ -218,12 +229,14 @@ function Step1CellName({ value, onChange, onNext }) {
} }
}; };
const isValid = CELL_NAME_RE.test(value);
return ( return (
<div> <div>
<StepHeader <StepHeader
step={1} step={1}
title="Name your cell" title="Name your cell"
description="This is the internal identifier for your Personal Internet Cell. It appears in hostnames and logs." description="This becomes your cell's identity and subdomain. If you choose pic.ngo it will be reachable at name.pic.ngo."
/> />
<div> <div>
<label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name"> <label className="block text-sm text-gray-400 mb-1.5" htmlFor="cell-name">
@@ -241,13 +254,20 @@ function Step1CellName({ value, onChange, onNext }) {
setServerError(''); setServerError('');
}} }}
onKeyDown={e => e.key === 'Enter' && handleNext()} onKeyDown={e => e.key === 'Enter' && handleNext()}
placeholder="e.g. homelab" placeholder="e.g. myhome"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600" className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
aria-describedby={error || serverError ? 'cell-name-error' : undefined} aria-describedby={error || serverError ? 'cell-name-error' : undefined}
/> />
{isValid ? (
<p className="mt-1.5 text-xs text-blue-400 flex items-center gap-1">
<Globe className="h-3.5 w-3.5" />
pic.ngo preview: <span className="font-mono font-medium ml-1">{value}.pic.ngo</span>
</p>
) : (
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
Lowercase letters, numbers, hyphens. Must start with a letter. 231 characters. Lowercase letters, numbers, hyphens. Must start with a letter. 231 characters.
</p> </p>
)}
<div id="cell-name-error"> <div id="cell-name-error">
<FieldError message={error || serverError} /> <FieldError message={error || serverError} />
</div> </div>
@@ -263,11 +283,18 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const strength = passwordStrength(password); const strength = passwordStrength(password);
const ready = meetsApiRequirements(password) && password === confirm;
const validate = () => { const validate = () => {
const e = {}; const e = {};
if (!password) e.password = 'Password is required.'; if (!password) {
else if (password.length < 12) e.password = 'Password must be at least 12 characters.'; e.password = 'Password is required.';
return e;
}
if (password.length < 12) e.password = 'Must be at least 12 characters.';
else if (!/[A-Z]/.test(password)) e.password = 'Must contain at least one uppercase letter.';
else if (!/[a-z]/.test(password)) e.password = 'Must contain at least one lowercase letter.';
else if (!/[0-9]/.test(password)) e.password = 'Must contain at least one digit.';
if (!confirm) e.confirm = 'Please confirm your password.'; if (!confirm) e.confirm = 'Please confirm your password.';
else if (password !== confirm) e.confirm = 'Passwords do not match.'; else if (password !== confirm) e.confirm = 'Passwords do not match.';
return e; return e;
@@ -279,14 +306,12 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
if (Object.keys(e).length === 0) onNext(); if (Object.keys(e).length === 0) onNext();
}; };
const isReady = password.length >= 12 && password === confirm;
return ( return (
<div> <div>
<StepHeader <StepHeader
step={2} step={2}
title="Set admin password" title="Set admin password"
description="This password protects access to your cell. Choose something strong and store it safely." description="This password protects access to your cell. At least 12 characters with uppercase, lowercase, and a digit."
/> />
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -313,7 +338,6 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
{showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPw ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
</div> </div>
{/* Strength bar */}
{password.length > 0 && ( {password.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<div className="w-full bg-gray-700 rounded-full h-1"> <div className="w-full bg-gray-700 rounded-full h-1">
@@ -356,7 +380,7 @@ function Step2Password({ password, confirm, onChangePassword, onChangeConfirm, o
<div id="pw-confirm-error"><FieldError message={errors.confirm} /></div> <div id="pw-confirm-error"><FieldError message={errors.confirm} /></div>
</div> </div>
</div> </div>
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} /> <NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!ready} />
</div> </div>
); );
} }
@@ -386,27 +410,182 @@ function Step3Domain({ value, onChange, onNext, onBack }) {
); );
} }
function Step4DDNS({ value, onChange, onNext, onBack }) { function Step4DomainConfig({
domainType, cellName,
customDomain, onCustomDomain,
customMethod, onCustomMethod,
cloudflareToken, onCloudflareToken,
duckdnsToken, onDuckdnsToken,
onNext, onBack,
}) {
const [errors, setErrors] = useState({});
// ── pic_ngo: just show the derived domain ────────────────────────────────
if (domainType === 'pic_ngo') {
return ( return (
<div> <div>
<StepHeader <StepHeader
step={4} step={4}
title="DDNS provider" title="Your pic.ngo domain"
description="Which provider will keep your dynamic IP address up to date? Credentials are configured separately after setup." description="Your cell will be reachable at the address below. HTTPS and DDNS are managed automatically."
/> />
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-5 text-center mb-4">
<p className="text-xs text-gray-500 mb-2">Your public address</p>
<p className="text-2xl font-mono font-semibold text-white tracking-tight">
{cellName || '…'}.pic.ngo
</p>
<p className="text-xs text-gray-400 mt-3">
DNS and TLS certificates are provisioned automatically via the pic.ngo API.
</p>
</div>
<p className="text-xs text-gray-500">
Not the right name? Go back to step 1 to change your cell name.
</p>
<NavButtons onBack={onBack} onNext={onNext} />
</div>
);
}
// ── custom domain ─────────────────────────────────────────────────────────
const validateCustom = () => {
const e = {};
const dom = customDomain.trim();
if (!dom) {
e.domain = 'Domain name is required.';
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(dom)) {
e.domain = 'Enter a valid domain name (e.g. home.example.com).';
}
if (!customMethod) e.method = 'Select a TLS method.';
if (customMethod === 'cloudflare' && !cloudflareToken.trim())
e.token = 'Cloudflare API token is required.';
if (customMethod === 'duckdns' && !duckdnsToken.trim())
e.token = 'DuckDNS token is required.';
return e;
};
const handleNext = () => {
const e = validateCustom();
setErrors(e);
if (Object.keys(e).length === 0) onNext();
};
const isReady =
customDomain.trim() &&
customMethod &&
(customMethod === 'http01' ||
(customMethod === 'cloudflare' && cloudflareToken.trim()) ||
(customMethod === 'duckdns' && duckdnsToken.trim()));
return (
<div>
<StepHeader
step={4}
title="Domain configuration"
description="Enter your domain and choose how TLS certificates will be obtained."
/>
<div className="space-y-5">
{/* Domain name */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Domain name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={customDomain}
onChange={e => {
onCustomDomain(e.target.value.toLowerCase().trim());
setErrors(p => ({ ...p, domain: '' }));
}}
placeholder="e.g. home.example.com"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
/>
<FieldError message={errors.domain} />
</div>
{/* TLS method */}
<div>
<label className="block text-sm text-gray-400 mb-1.5">
TLS method <span className="text-red-400">*</span>
</label>
<div className="space-y-2"> <div className="space-y-2">
{DDNS_OPTIONS.map(opt => ( {CUSTOM_METHOD_OPTIONS.map(opt => (
<RadioOption <RadioOption
key={opt.value} key={opt.value}
value={opt.value} value={opt.value}
label={opt.label} label={opt.label}
description={opt.description} description={opt.description}
selected={value === opt.value} selected={customMethod === opt.value}
onChange={onChange} onChange={v => {
onCustomMethod(v);
setErrors(p => ({ ...p, method: '', token: '' }));
}}
/> />
))} ))}
</div> </div>
<NavButtons onBack={onBack} onNext={onNext} nextDisabled={!value} /> <FieldError message={errors.method} />
</div>
{/* Cloudflare token */}
{customMethod === 'cloudflare' && (
<div>
<label className="block text-sm text-gray-400 mb-1.5">
Cloudflare API token <span className="text-red-400">*</span>
</label>
<input
type="password"
autoComplete="off"
value={cloudflareToken}
onChange={e => {
onCloudflareToken(e.target.value);
setErrors(p => ({ ...p, token: '' }));
}}
placeholder="Cloudflare API token with DNS:Edit permission"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
/>
<p className="mt-1 text-xs text-gray-500">
Create at Cloudflare Dashboard My Profile API Tokens. Needs Zone / DNS / Edit.
</p>
<FieldError message={errors.token} />
</div>
)}
{/* DuckDNS token */}
{customMethod === 'duckdns' && (
<div>
<label className="block text-sm text-gray-400 mb-1.5">
DuckDNS token <span className="text-red-400">*</span>
</label>
<input
type="password"
autoComplete="off"
value={duckdnsToken}
onChange={e => {
onDuckdnsToken(e.target.value);
setErrors(p => ({ ...p, token: '' }));
}}
placeholder="Your DuckDNS account token"
className="w-full bg-gray-800 border border-gray-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 placeholder-gray-600"
/>
<p className="mt-1 text-xs text-gray-500">
Found at duckdns.org after login. The subdomain must already exist in your account.
</p>
<FieldError message={errors.token} />
</div>
)}
{/* HTTP-01 info */}
{customMethod === 'http01' && (
<div className="p-3 bg-yellow-950/40 border border-yellow-700/50 rounded-lg">
<p className="text-xs text-yellow-300">
<span className="font-semibold">Port 80 must be publicly reachable</span> from the
internet for Let's Encrypt HTTP-01 validation. Ensure your router forwards port 80
to this machine before completing setup.
</p>
</div>
)}
</div>
<NavButtons onBack={onBack} onNext={handleNext} nextDisabled={!isReady} />
</div> </div>
); );
} }
@@ -426,7 +605,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
description="Choose which services to enable. You can change this later in Settings." description="Choose which services to enable. You can change this later in Settings."
/> />
{/* Optional services */}
<div className="space-y-2 mb-6"> <div className="space-y-2 mb-6">
{OPTIONAL_SERVICES.map(svc => { {OPTIONAL_SERVICES.map(svc => {
const checked = selected.includes(svc.key); const checked = selected.includes(svc.key);
@@ -452,7 +630,6 @@ function Step5Services({ selected, onChange, onNext, onBack }) {
})} })}
</div> </div>
{/* Always-on services */}
<div> <div>
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2"> <p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Always enabled Always enabled
@@ -538,10 +715,18 @@ function ReviewRow({ label, value }) {
} }
function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) { function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
const domainLabel = DOMAIN_OPTIONS.find(o => o.value === fields.domain_type)?.label || fields.domain_type; const domainDisplay =
const ddnsLabel = DDNS_OPTIONS.find(o => o.value === fields.ddns_provider)?.label || fields.ddns_provider; fields.domain_type === 'pic_ngo' ? `${fields.cell_name}.pic.ngo` :
const serviceLabels = fields.services.length fields.domain_type === 'lan' ? 'LAN only (no public domain)' :
? fields.services.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ') fields.custom_domain || '(not set)';
const tlsDisplay =
fields.domain_type === 'pic_ngo' ? 'Automatic (pic.ngo)' :
fields.domain_type === 'lan' ? '' :
CUSTOM_METHOD_OPTIONS.find(o => o.value === fields.custom_method)?.label || fields.custom_method;
const serviceLabels = (fields.services_enabled || []).length
? fields.services_enabled.map(k => OPTIONAL_SERVICES.find(s => s.key === k)?.label || k).join(', ')
: 'None selected'; : 'None selected';
return ( return (
@@ -554,9 +739,9 @@ function Step7Review({ fields, onBack, onSubmit, submitting, submitError }) {
<div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2"> <div className="bg-gray-800/50 border border-gray-700 rounded-lg px-4 py-1 mb-2">
<ReviewRow label="Cell name" value={fields.cell_name} /> <ReviewRow label="Cell name" value={fields.cell_name} />
<ReviewRow label="Admin password" value="••••••••••••" /> <ReviewRow label="Admin password" value="••••••••••••" />
<ReviewRow label="Domain type" value={domainLabel} /> <ReviewRow label="Domain" value={domainDisplay} />
{fields.domain_type !== 'lan_only' && ( {fields.domain_type !== 'lan' && (
<ReviewRow label="DDNS provider" value={ddnsLabel} /> <ReviewRow label="TLS / DNS" value={tlsDisplay} />
)} )}
<ReviewRow label="Optional services" value={serviceLabels} /> <ReviewRow label="Optional services" value={serviceLabels} />
<ReviewRow label="Timezone" value={fields.timezone} /> <ReviewRow label="Timezone" value={fields.timezone} />
@@ -591,7 +776,10 @@ export default function Setup() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState('');
const [domainType, setDomainType] = useState('pic_ngo'); const [domainType, setDomainType] = useState('pic_ngo');
const [ddnsProvider, setDdnsProvider] = useState('pic_ngo'); const [customDomain, setCustomDomain] = useState('');
const [customMethod, setCustomMethod] = useState('');
const [cloudflareToken, setCloudflareToken] = useState('');
const [duckdnsToken, setDuckdnsToken] = useState('');
const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']); const [services, setServices] = useState(['email', 'calendar', 'files', 'webmail']);
const [timezone, setTimezone] = useState( const [timezone, setTimezone] = useState(
(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })() (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch { return 'UTC'; } })()
@@ -601,39 +789,46 @@ export default function Setup() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const skipDdns = domainType === 'lan_only'; const skipStep4 = domainType === 'lan';
const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS)); const goNext = () => setStep(s => Math.min(s + 1, TOTAL_STEPS));
const goBack = () => setStep(s => Math.max(s - 1, 1)); const goBack = () => setStep(s => Math.max(s - 1, 1));
// Skip step 4 when LAN only const handleStep3Next = () => skipStep4 ? setStep(5) : setStep(4);
const handleStep3Next = () => {
if (skipDdns) setStep(5);
else setStep(4);
};
const handleStep4Back = () => setStep(3); const handleStep4Back = () => setStep(3);
const handleStep5Back = () => { const handleStep5Back = () => skipStep4 ? setStep(3) : setStep(4);
if (skipDdns) setStep(3);
else setStep(4);
};
const handleSubmit = async () => { const handleSubmit = async () => {
setSubmitError(''); setSubmitError('');
setSubmitting(true); setSubmitting(true);
const domainMode = getDomainMode(domainType, customMethod);
const domainName =
domainType === 'pic_ngo' ? `${cellName}.pic.ngo` :
domainType === 'lan' ? '' :
customDomain;
const payload = { const payload = {
cell_name: cellName, cell_name: cellName,
password, password,
domain_type: domainType, domain_mode: domainMode,
...(skipDdns ? {} : { ddns_provider: ddnsProvider }), domain_name: domainName,
services,
timezone, timezone,
services_enabled: services,
...(domainType !== 'lan' && {
ddns_provider: domainType === 'pic_ngo' ? 'pic_ngo' : customMethod === 'http01' ? 'none' : customMethod,
}),
...(customMethod === 'cloudflare' && { cloudflare_api_token: cloudflareToken }),
...(customMethod === 'duckdns' && { duckdns_token: duckdnsToken }),
}; };
try { try {
await setupAPI.complete(payload); await setupAPI.complete(payload);
setDone(true); setDone(true);
setTimeout(() => navigate('/login', { replace: true }), 2000); setTimeout(() => navigate('/login', { replace: true }), 2000);
} catch (e) { } catch (e) {
setSubmitError( setSubmitError(
e?.response?.data?.errors?.join(' ') ||
e?.response?.data?.error || e?.response?.data?.error ||
'Setup could not be completed. Please check your entries and try again.' 'Setup could not be completed. Please check your entries and try again.'
); );
@@ -642,7 +837,14 @@ export default function Setup() {
} }
}; };
const allFields = { cell_name: cellName, domain_type: domainType, ddns_provider: ddnsProvider, services, timezone }; const reviewFields = {
cell_name: cellName,
domain_type: domainType,
custom_domain: customDomain,
custom_method: customMethod,
services_enabled: services,
timezone,
};
if (done) { if (done) {
return ( return (
@@ -659,7 +861,6 @@ export default function Setup() {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10"> <div className="flex items-center justify-center min-h-screen bg-gray-950 px-4 py-10">
<div className="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl"> <div className="w-full max-w-lg bg-gray-900 border border-gray-700 rounded-xl p-8 shadow-2xl">
{/* Page title */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold text-white">Personal Internet Cell</h1> <h1 className="text-xl font-bold text-white">Personal Internet Cell</h1>
<p className="text-sm text-gray-400 mt-0.5">First-time setup</p> <p className="text-sm text-gray-400 mt-0.5">First-time setup</p>
@@ -684,7 +885,20 @@ export default function Setup() {
<Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} /> <Step3Domain value={domainType} onChange={setDomainType} onNext={handleStep3Next} onBack={goBack} />
)} )}
{step === 4 && ( {step === 4 && (
<Step4DDNS value={ddnsProvider} onChange={setDdnsProvider} onNext={goNext} onBack={handleStep4Back} /> <Step4DomainConfig
domainType={domainType}
cellName={cellName}
customDomain={customDomain}
onCustomDomain={setCustomDomain}
customMethod={customMethod}
onCustomMethod={setCustomMethod}
cloudflareToken={cloudflareToken}
onCloudflareToken={setCloudflareToken}
duckdnsToken={duckdnsToken}
onDuckdnsToken={setDuckdnsToken}
onNext={goNext}
onBack={handleStep4Back}
/>
)} )}
{step === 5 && ( {step === 5 && (
<Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} /> <Step5Services selected={services} onChange={setServices} onNext={goNext} onBack={handleStep5Back} />
@@ -694,7 +908,7 @@ export default function Setup() {
)} )}
{step === 7 && ( {step === 7 && (
<Step7Review <Step7Review
fields={allFields} fields={reviewFields}
onBack={goBack} onBack={goBack}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitting={submitting} submitting={submitting}