Compare commits

..
1 Commits
Author SHA1 Message Date
Julian Boldt cf326d2d17 Release v1.16.0
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:

- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
  Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
  verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
  Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
  Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
  überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
  oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
  (Migration `0005`).
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
  (`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
  Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
  vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
  `python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
2026-10-10 14:15:55 +02:00
22 changed files with 981 additions and 29 deletions

No files matched your search

+19
View File
@@ -31,6 +31,25 @@ den Einstellungen.
Nächste Codenamen (der Reihe nach, wenn die jeweils nächste MINOR-Version
ansteht): Merlin …
## [1.16.0] "Hobby" — 2026-10-10
Erste Schritte nach der Installation. Ende-zu-Ende gegen einen isolierten Stack getestet, siehe
Handbuch „Einrichtungsassistent und Passwort-Reset“:
- **Einrichtungsassistent** (`/setup`, System › Einrichtung): vier Schritte mit Erklärungen —
Willkommen und Sprache, Konto (eigenes Passwort, Zwei-Faktor-Anmeldung), erste Firewall
verbinden (inklusive Anleitung für den API-Key in OPNsense, Zertifikat festsetzen,
Verbindungsstatus) und eine Übersicht, was als Nächstes sinnvoll ist. Auf einer frischen
Installation ohne Hosts öffnet er sich nach der ersten Anmeldung von selbst; „Assistent
überspringen“ merkt sich das pro Benutzer, erneut öffnen geht jederzeit über System › Einrichtung
oder die Einstellungen. Nur für Admins. Neue Spalte `user_preferences.setup_done`
(Migration `0005`).
- **`opnexus password [NAME]`** setzt ein neues Zufallspasswort für den Admin und zeigt es an
(`--yes` ohne Rückfrage; Rückfrage nur, wenn sich schon jemand angemeldet hat). Ersetzt das
Suchen im Backend-Log nach dem beim ersten Start erzeugten Passwort und taugt zugleich für ein
vergessenes Admin-Passwort. Alle Sitzungen des Kontos enden. Dahinter steht
`python -m app.reset_password`. Die Meldung beim ersten Start verweist darauf.
## [1.15.0] "Gyrfalcon" — 2026-10-10
Updates aus dem Webinterface. Auf Klick, mit Fortschritt und automatischem Rollback; ein eigener,
+7 -4
View File
@@ -7,7 +7,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
![Zentrale Firewall-Regeln](docs/screenshots/readme-rules-de.webp)
> **Stand:** `1.15.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
> **Stand:** `1.16.0` ist die aktuelle stabile Version. Entwickelt und getestet wurde gegen echte OPNsense-Instanzen; eine frische Installation aus dem Release-Bundle sowie Updates und Rollbacks sind durchgängig geprüft. Ecken und Kanten bitte melden.
## Funktionen
@@ -20,6 +20,7 @@ Zentrale Verwaltung mehrerer [OPNsense](https://opnsense.org)-Firewalls: Regeln,
- **Zertifikate** — Trust-Store für alle Firewalls und Ausstellung per ACME / Let's Encrypt.
- **Logs & Audit** — zentrale Log-Suche mit Korrelation, Dashboards und lückenloser Deploy-Verlauf.
- **Update-Center** — Firmware-Updates für die Flotte, mit Blick auf HA-Reihenfolge und Wartungsmodus.
- **Einrichtungsassistent** — geführter Erststart nach der Installation: eigenes Passwort, Zwei-Faktor und die erste Firewall verbinden.
- **Selbst-Update** — OPNexus aktualisiert sich auf Wunsch aus der eigenen Oberfläche, mit Fortschritt und automatischem Rollback (optional).
- **Zugang** — HTTPS mit gepinnten Firewall-Zertifikaten, optionale Zwei-Faktor-Anmeldung, Rollen (Admin / Nur-Lesen), Tastaturbedienung, Dark Mode, Oberfläche auf Deutsch und Englisch.
@@ -43,19 +44,21 @@ cp .env.example .env # POSTGRES_PASSWORD (und ADMIN_PASSWORD) setzen
docker compose up -d --build
```
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben:
Die Oberfläche läuft dann unter `http://localhost:3100`. Ohne `ADMIN_PASSWORD` wird beim ersten Start ein zufälliges Admin-Passwort erzeugt und **einmalig** im Backend-Log ausgegeben. Verloren oder nie gesehen? Dieses Kommando setzt ein neues Zufallspasswort und zeigt es an (im Repo-Checkout `scripts/opnexus`, im Release-Bundle `./opnexus`):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
Nach der ersten Anmeldung öffnet sich von selbst ein kurzer **Einrichtungsassistent**: eigenes Passwort setzen, optional Zwei-Faktor-Anmeldung einschalten, die erste Firewall verbinden (samt Anleitung für den API-Key in OPNsense). Er lässt sich überspringen und jederzeit unter System › Einrichtung wieder öffnen.
Für den Zugriff von anderen Rechnern und den Produktivbetrieb mit TLS (mitgelieferter Caddy-Proxy) siehe das [Handbuch](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls).
**Fertige Images** (amd64) liegen in der Registry dieses Gitea. In der `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.15.0
OPNEXUS_VERSION=1.16.0
```
```bash
+7 -4
View File
@@ -7,7 +7,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
![Central firewall rules](docs/screenshots/readme-rules-en.webp)
> **Status:** `1.15.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
> **Status:** `1.16.0` is the current stable release. It has been developed and tested against real OPNsense instances; a clean install from the release bundle, updates and rollbacks have been verified end to end. Please report rough edges.
## What it does
@@ -20,6 +20,7 @@ Central management for multiple [OPNsense](https://opnsense.org) firewalls: rule
- **Certificates** — trust store for all firewalls and ACME / Let's Encrypt issuance.
- **Logs & audit** — central log search with correlation, dashboards and a complete deploy audit trail.
- **Update center** — firmware updates for the fleet, aware of HA order and maintenance mode.
- **Setup assistant** — a guided first run after installation: your own password, two-factor, and connecting the first firewall.
- **Self-update** — OPNexus can update itself from its web interface, with progress and automatic rollback (opt-in).
- **Access** — HTTPS with pinned firewall certificates, optional two-factor sign-in, roles (admin / read-only), keyboard operation, dark mode, German and English UI.
@@ -45,19 +46,21 @@ cp .env.example .env # set POSTGRES_PASSWORD (and ADMIN_PASSWORD)
docker compose up -d --build
```
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log:
The UI is then available at `http://localhost:3100`. Without `ADMIN_PASSWORD` a random admin password is generated on first start and printed **once** in the backend log. Lost it, or never saw it? This sets a new random one and prints it (use `scripts/opnexus` in a repo checkout, `./opnexus` in the release bundle):
```bash
docker compose logs backend | grep -A5 "generated password"
scripts/opnexus password
```
After the first sign-in a short **setup assistant** opens on its own: set your own password, optionally enable two-factor sign-in, connect your first firewall (including how to create the API key in OPNsense). You can skip it and reopen it any time under System › Setup.
For access from other machines and for production use with TLS (bundled Caddy proxy), see the [handbook](docs/HANDBUCH.md#zugriff-von-außerhalb-dieser-vm--produktivbetrieb-mit-tls) (German).
**Prebuilt images** (amd64) are published to this Gitea's registry. In `.env`:
```bash
OPNEXUS_IMAGE_REPO=git.opnexus.dev/opnexus
OPNEXUS_VERSION=1.15.0
OPNEXUS_VERSION=1.16.0
```
```bash
+4 -1
View File
@@ -1,5 +1,5 @@
import uuid
from sqlalchemy import ForeignKey, String
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -9,3 +9,6 @@ class UserPreferences(Base):
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), ForeignKey('admin_users.id',ondelete='CASCADE'), primary_key=True)
theme: Mapped[str] = mapped_column(String(10), default='system')
language: Mapped[str] = mapped_column(String(5), nullable=False, default='de', server_default='de')
# Set once the user finished or dismissed the first-run setup assistant
# (frontend /setup); it is then no longer offered automatically.
setup_done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default='false')
+66
View File
@@ -0,0 +1,66 @@
"""Recovery: give an account a new random password and print it once.
For a fresh install whose generated first-start password scrolled out of the
log, and for an admin who forgot theirs. Needs shell access to the server,
which is the trust boundary for this tool anyway (same as app.reset_2fa).
All sessions of the account are ended. Normally called through the host
CLI:
./opnexus password [username]
or directly:
docker compose exec backend python -m app.reset_password [username]
Without a username the configured ADMIN_USERNAME is used; if that account does
not exist and there is exactly one admin, that one.
"""
import asyncio
import secrets
import sys
from sqlalchemy import select
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser
from app.services import auth_service
async def main(username: str | None) -> int:
async with SessionLocal() as db:
if username is None:
username = settings.admin_username
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
admins = (await db.execute(select(AdminUser).where(AdminUser.role == "admin"))).scalars().all()
if len(admins) != 1:
names = ", ".join(sorted(a.username for a in admins)) or "-"
print(f"No account named '{username}'. Pass the username (admins: {names}).", file=sys.stderr)
return 1
user = admins[0]
else:
user = (await db.execute(select(AdminUser).where(AdminUser.username == username))).scalar_one_or_none()
if user is None:
print(f"No such user: {username}", file=sys.stderr)
return 1
password = secrets.token_urlsafe(18)
user.password_hash = await auth_service.hash_password_async(password)
await auth_service.revoke_sessions(db, user.id, commit=False)
await db.commit()
name = user.username
has_totp = user.totp_enabled
print(f"Username: {name}")
print(f"Password: {password}")
if has_totp:
print("Two-factor sign-in is still on (lost the code? python -m app.reset_2fa).", file=sys.stderr)
return 0
if __name__ == "__main__":
if len(sys.argv) > 2:
print("usage: python -m app.reset_password [username]", file=sys.stderr)
sys.exit(2)
sys.exit(asyncio.run(main(sys.argv[1] if len(sys.argv) == 2 else None)))
+5 -2
View File
@@ -14,12 +14,14 @@ router=APIRouter(prefix='/settings',tags=['settings'])
class Preferences(BaseModel):
theme: Literal['light','dark','system'] | None = None
language: Literal['de','en'] | None = None
setup_done: bool | None = None
@router.get('')
async def read(user: AdminUser=Depends(current_user),db:AsyncSession=Depends(get_db)):
preferences=await db.get(UserPreferences,user.id)
return {'username':user.username,'theme':preferences.theme if preferences else 'system',
'language':preferences.language if preferences else 'de',
'setup_done':preferences.setup_done if preferences else False,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
@@ -29,8 +31,9 @@ async def update(payload:Preferences,user:AdminUser=Depends(current_user),db:Asy
current = await db.get(UserPreferences, user.id)
theme = payload.theme or (current.theme if current else 'system')
language = payload.language or (current.language if current else 'de')
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language}))
setup_done = payload.setup_done if payload.setup_done is not None else bool(current.setup_done if current else False)
await db.execute(insert(UserPreferences).values(user_id=user.id,theme=theme,language=language,setup_done=setup_done).on_conflict_do_update(index_elements=['user_id'],set_={'theme':theme,'language':language,'setup_done':setup_done}))
await db.commit()
return {'username':username,'theme':theme,'language':language,
return {'username':username,'theme':theme,'language':language,'setup_done':setup_done,
'deploy_confirm_window_seconds':settings.deploy_confirm_window_seconds,
'session_lifetime_seconds':settings.session_lifetime}
+2 -1
View File
@@ -367,7 +367,8 @@ async def bootstrap_admin(db: AsyncSession) -> None:
logger.warning(
"\n%s\n No ADMIN_PASSWORD set — created admin user '%s' with a\n"
" generated password:\n\n %s\n\n"
" Log in and change it now. This is shown only once.\n%s",
" Log in and change it now. This is shown only once.\n"
" Lost it? ./opnexus password (sets and prints a new one)\n%s",
"=" * 64, settings.admin_username, password, "=" * 64,
)
else:
+2 -2
View File
@@ -6,5 +6,5 @@ convention — but both are bumped together whenever a release ships).
Versioning scheme: see CHANGELOG.md ("Versionierung").
"""
APP_VERSION = "1.15.0"
APP_CODENAME = "Gyrfalcon"
APP_VERSION = "1.16.0"
APP_CODENAME = "Hobby"
@@ -0,0 +1,20 @@
"""Einrichtungsassistent: Merker, dass ein Nutzer ihn abgeschlossen oder übersprungen hat.
Revision ID: 0005
Revises: 0004
Create Date: 2026-10-10
"""
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS setup_done boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE user_preferences DROP COLUMN IF EXISTS setup_done")
+66
View File
@@ -0,0 +1,66 @@
"""First-run setup assistant: per-user "done" flag and the password reset tool."""
import pytest
from sqlalchemy import select
from app import reset_password
from app.config import settings
from app.database import SessionLocal
from app.models.user import AdminUser, Session
from app.services import auth_service
@pytest.mark.asyncio
async def test_setup_done_defaults_to_false_and_persists(client):
assert (await client.get("/api/settings")).json()["setup_done"] is False
resp = await client.put("/api/settings", json={"setup_done": True})
assert resp.status_code == 200
assert resp.json()["setup_done"] is True
assert (await client.get("/api/settings")).json()["setup_done"] is True
@pytest.mark.asyncio
async def test_other_preference_changes_keep_setup_done(client):
await client.put("/api/settings", json={"setup_done": True})
resp = await client.put("/api/settings", json={"theme": "dark", "language": "en"})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", True)
# ...and the other direction: saving setup_done leaves theme/language alone.
resp = await client.put("/api/settings", json={"setup_done": False})
body = resp.json()
assert (body["theme"], body["language"], body["setup_done"]) == ("dark", "en", False)
@pytest.mark.asyncio
async def test_setup_done_rejects_non_boolean(client):
assert (await client.put("/api/settings", json={"setup_done": "maybe"})).status_code == 422
@pytest.mark.asyncio
async def test_reset_password_prints_new_password_and_ends_sessions(client, capsys):
async with SessionLocal() as db:
before = (await db.execute(select(Session))).scalars().all()
assert before, "the client fixture logged in, so a session must exist"
assert await reset_password.main(None) == 0
out = capsys.readouterr().out
lines = dict(line.split(": ", 1) for line in out.strip().splitlines())
assert lines["Username"] == settings.admin_username
new_password = lines["Password"]
assert len(new_password) >= 20 and new_password != settings.admin_password
async with SessionLocal() as db:
assert (await db.execute(select(Session))).scalars().all() == []
user = (await db.execute(select(AdminUser).where(AdminUser.username == settings.admin_username))).scalar_one()
assert await auth_service.verify_password_async(user.password_hash, new_password)
assert not await auth_service.verify_password_async(user.password_hash, settings.admin_password)
# Put the shared bootstrap admin back for the tests that follow.
user.password_hash = await auth_service.hash_password_async(settings.admin_password)
await db.commit()
@pytest.mark.asyncio
async def test_reset_password_unknown_user_fails(capsys):
assert await reset_password.main("does-not-exist") == 1
assert "No such user" in capsys.readouterr().err
+56 -3
View File
@@ -157,6 +157,56 @@ Verkehrsunterbrechung auf dem falschen Knoten.
---
## Einrichtungsassistent und Passwort-Reset
**Einrichtungsassistent** (`/setup`, System › Einrichtung). Vier Schritte mit
kurzen Erklärungen, damit eine frische Installation ohne Handbuch funktioniert:
1. **Willkommen:** was der Assistent tut, Sprache (Deutsch/Englisch). Er liest
nur von der Firewall; geändert wird dort erst, wenn du selbst etwas ausrollst.
2. **Konto:** eigenes Passwort setzen (optional, empfohlen, wenn OPNexus beim
Start eines erzeugt hat) und Zwei-Faktor-Anmeldung einrichten (derselbe
Baustein wie in den Einstellungen).
3. **Firewall:** Anleitung für den API-Key in OPNsense (System › Access › Users,
Benutzer, „API keys“, `apikey.txt`), dann Name, Adresse, Key und Secret
eintragen, Zertifikat abrufen und den Fingerabdruck bestätigen (dasselbe
Festsetzen wie beim Hinzufügen eines Hosts). Nach dem Anlegen zeigt der
Schritt sofort, ob die Firewall antwortet, und bei „offline“ was man prüft.
Weitere Firewalls, auch die Knoten eines HA-Paars, lassen sich im selben
Schritt nacheinander eintragen.
4. **Fertig:** Zusammenfassung (Firewalls erreichbar, Zwei-Faktor an/aus) und
Verweise auf das, was danach sinnvoll ist: Regeln, Benachrichtigungen,
Updates, Datenbank-Sicherung.
*Wann er sich öffnet:* nach der Anmeldung auf dem Dashboard, solange es **keinen
Host** gibt und dieser Benutzer den Assistenten weder abgeschlossen noch
übersprungen hat. Der Merker liegt pro Benutzer in `user_preferences.setup_done`
(Migration `0005`, `PUT /api/settings {"setup_done": true}`). `read_only`-Benutzer
bekommen ihn nicht angeboten. Wieder öffnen geht über System › Einrichtung, die
Einstellungen und die Schnellsuche (Strg/⌘+K).
**Passwort-Reset** (`opnexus password [BENUTZER] [--yes]`). Setzt für das
Admin-Konto (Standard: `ADMIN_USERNAME`; gibt es das nicht und genau einen Admin,
dieser) ein neues Zufallspasswort, gibt es einmal aus und beendet alle Sitzungen
des Kontos. Eine Rückfrage kommt nur, wenn sich schon jemand angemeldet hat;
ohne Terminal ist `--yes` nötig. Dahinter steht
`docker compose exec backend python -m app.reset_password [BENUTZER]`, wie
`app.reset_2fa` ein Werkzeug mit Shell-Zugriff auf den Server als
Vertrauensgrenze. Eine eingeschaltete Zwei-Faktor-Anmeldung bleibt bestehen
(verloren: `app.reset_2fa`). Es gibt bewusst keine Anzeige des alten Passworts:
Gespeichert wird nur der Argon2id-Hash.
*Verifikation 2026-10-10:* Backend-Tests (`test_setup_assistant.py`: Merker
persistiert und bleibt bei anderen Einstellungen erhalten, Reset gibt Passwort
aus, beendet Sitzungen und tauscht den Hash, unbekannter Benutzer scheitert;
vollständige Suite 583 bestanden). Kommandozeile gegen einen Wegwerf-Stack:
frische Installation ohne Rückfrage, nach einer Anmeldung Rückfrage bzw.
`--yes`, ungültige Option/Benutzer mit Fehlercode. Browser (Chromium, 1440 und
375 px, Wegwerf-Stack mit Mock-Firewall): Anmeldung landet auf `/setup`,
Sprachwechsel, Passwortwechsel inklusive Fehlerfall, Host online und offline,
Zusammenfassung, Abschließen merkt sich den Zustand, keine Konsolenfehler, kein
horizontaler Überlauf.
## Barrierefreiheit (2026-10-04)
Version 1.8.0 „Harris“. Ziel: OPNexus per Tastatur und mit Screenreader
@@ -2404,9 +2454,12 @@ in die Backend-Logs geschrieben:
docker compose logs backend | grep -A5 "generated password"
```
Danach in `.env` ein eigenes `ADMIN_PASSWORD` setzen (empfohlen) oder das
Passwort über die künftige "Passwort ändern"-Funktion (`POST
/api/auth/change-password`, aktuell nur per API, kein UI-Formular) ändern.
Ist das Passwort verloren oder nie gesehen worden, setzt `opnexus password`
(im Repo-Checkout `scripts/opnexus password`) ein **neues** Zufallspasswort und
zeigt es an; siehe "Einrichtungsassistent und Passwort-Reset" unten. Danach
das Passwort im Einrichtungsassistenten oder unter Einstellungen › Konto
ändern, oder in `.env` ein eigenes `ADMIN_PASSWORD` setzen (gilt nur beim
Anlegen des ersten Kontos).
Login-Versuche sind pro (IP, Benutzername) und pro IP rate-limitiert
(Standard: 5 Fehlversuche / 5 Minuten Sperre).
+13 -2
View File
@@ -18,6 +18,7 @@ import {
ShieldAlert,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useLanguage } from "@/components/LanguageProvider";
import { labelFor, type Language } from "@/lib/i18n";
@@ -110,6 +111,7 @@ function certProblemLabel(context: Record<string, unknown>, t: (key: string, par
export default function DashboardPage() {
const { language, t, errorMessage } = useLanguage();
const router = useRouter();
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [problems, setProblems] = useState<NotificationProblem[] | null>(null);
const [firmwareWarnings, setFirmwareWarnings] = useState<FirmwareWarning[] | null>(null);
@@ -133,6 +135,15 @@ export default function DashboardPage() {
.then(async (data) => {
if (!active) return;
setSummary(data);
// Fresh install: nothing to manage yet and the setup assistant was
// never finished or skipped, so open it instead of an empty dashboard.
if (data.hosts.length === 0) {
Promise.all([api.me(), api.settings()])
.then(([me, prefs]) => {
if (active && me.role !== "read_only" && !prefs.setup_done) router.replace("/setup");
})
.catch(() => {});
}
const reachableHosts = data.hosts.filter((h) => h.status !== "offline" && h.status !== "disabled");
const results = await Promise.all(
reachableHosts.map(async (host) => {
@@ -164,7 +175,7 @@ export default function DashboardPage() {
return () => {
active = false;
};
}, [errorMessage]);
}, [errorMessage, router]);
const healthProblems = (problems ?? []).filter((p) => ["host_offline", "cert_expiry", "rule_drift"].includes(p.problem_type));
const securityWarningsLoading = problems === null || firmwareWarnings === null;
@@ -212,7 +223,7 @@ export default function DashboardPage() {
{summary === null ? (
<div className="space-y-2">{[0, 1].map((i) => <Skeleton key={i} className="h-10" />)}</div>
) : summary.hosts.length === 0 ? (
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link>} />
<EmptyState icon={Network} title={t("dashboard.noHosts")} action={<span className="flex flex-wrap justify-center gap-x-4 gap-y-1"><Link href="/setup" className="text-sm font-medium text-sky-700 hover:underline">{t("dashboard.startSetup")}</Link><Link href="/hosts" className="text-sm text-sky-700 hover:underline">{t("dashboard.toHosts")}</Link></span>} />
) : (
<ul className="divide-y divide-slate-100">
{summary.hosts.map((host) => (
+17 -2
View File
@@ -12,6 +12,21 @@ import { useReadOnly } from "@/lib/useReadOnly";
import { APP_CODENAME, APP_MAINTAINER, APP_VERSION } from "@/lib/version";
import { translate } from "@/lib/i18n";
function SetupAssistantSection() {
const { t } = useLanguage();
const readOnly = useReadOnly();
if (readOnly) return null;
return (
<section className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-5">
<div>
<h2 className="text-lg font-medium">{t("settings.setup.title")}</h2>
<p className="mt-1 text-sm text-slate-600">{t("settings.setup.intro")}</p>
</div>
<Button href="/setup" variant="outline">{t("settings.setup.open")}</Button>
</section>
);
}
function NotificationsSection() {
const { t, errorMessage } = useLanguage();
const readOnly = useReadOnly();
@@ -133,7 +148,7 @@ function NotificationsSection() {
}
return (
<section className="space-y-5 rounded-xl border border-slate-200 bg-white p-5">
<section id="notifications" className="scroll-mt-4 space-y-5 rounded-xl border border-slate-200 bg-white p-5">
<h2 className="flex items-center gap-2 text-lg font-medium">{t("settings.notify.title")}<InfoTip text={t("settings.notify.intro", { seconds: pollInterval })} /></h2>
<form onSubmit={save} className="space-y-5">
@@ -377,5 +392,5 @@ export default function SettingsPage(){
async function saveTheme(value:Theme){setSaving(true);setError('');setNotice('');const old=theme;setTheme(value);try{setSettings(await api.saveSettings({theme:value,language}));setNotice(t('settings.appearanceSaved'));}catch(e){setTheme(old);setError(errorMessage(e));}finally{setSaving(false);}}
async function saveLanguage(value:"de"|"en"){setSaving(true);setError('');setNotice('');const old=language;setLanguage(value);try{setSettings(await api.saveSettings({theme,language:value}));setNotice(translate(value,'settings.language.saved'));}catch(e){setLanguage(old);setError(errorMessage(e));}finally{setSaving(false);}}
async function changePassword(e:React.FormEvent){e.preventDefault();setError('');setNotice('');if(password!==repeat){setError(t('settings.passwordMismatch'));return;}setSaving(true);try{await api.changePassword(current,password);setCurrent('');setPassword('');setRepeat('');setNotice(t('settings.passwordChanged'));}catch(e){setError(errorMessage(e,'settings.passwordFailed'));}finally{setSaving(false);}}
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
return <div className="mx-auto max-w-3xl space-y-6"><h1 className="text-2xl font-semibold">{t("settings.title")}</h1>{error && <p role="alert" className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}{notice && <p role="status" className="text-sm text-emerald-700">{notice}</p>}<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.appearance')}<InfoTip text={t('settings.appearanceHint')} /></h2><div role="radiogroup" aria-label={t('settings.colorScheme')} className="flex flex-wrap gap-3">{(['light','dark','system'] as const).map((v)=><label key={v} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${theme===v?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="theme" checked={theme===v} disabled={saving || !settings} onChange={()=>saveTheme(v)}/>{t(`settings.theme.${v}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.language.label')}</h2><div role="radiogroup" aria-label={t('settings.language.label')} className="flex flex-wrap gap-3">{(['de','en'] as const).map(value=><label key={value} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-3 text-sm ${language===value?'border-sky-500 bg-sky-50 text-sky-700':'border-slate-200'}`}><input type="radio" name="language" checked={language===value} disabled={saving || !settings} onChange={()=>saveLanguage(value)}/>{t(`settings.language.${value}`)}</label>)}</div></section><section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5"><h2 className="text-lg font-medium">{t('settings.account')} {settings?.username ? `· ${settings.username}`:''}</h2><form onSubmit={changePassword} className="space-y-4"><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.currentPassword')}</span><Input aria-label={t('settings.currentPassword')} type="password" autoComplete="current-password" required value={current} onChange={e=>setCurrent(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.newPassword')}</span><Input aria-label={t('settings.newPassword')} type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={e=>setPassword(e.target.value)}/></label><label className="block text-sm"><span className="mb-1 block font-medium text-slate-700">{t('settings.repeatPassword')}</span><Input aria-label={t('settings.repeatPassword')} type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={e=>setRepeat(e.target.value)}/></label><Button type="submit" disabled={saving}>{t('settings.changePassword')}</Button></form></section><section className="space-y-3 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.operational')}<InfoTip text={t('settings.serverManaged')} /></h2>{settings?<dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.deployWindow')}</dt><dd>{t('settings.seconds',{n:settings.deploy_confirm_window_seconds})}</dd><dt>{t('settings.sessionLifetime')}</dt><dd>{t('settings.days',{n:Math.round(settings.session_lifetime_seconds/86400)})}</dd></dl>:<p className="text-sm text-slate-500">{t('common.loading')}</p>}</section><SetupAssistantSection/><TwoFactorSection/><NotificationsSection/><LogIngestSection/><OpnexusUpdateSection/><section className="space-y-2 rounded-xl border border-slate-200 bg-white p-5"><h2 className="flex items-center gap-2 text-lg font-medium">{t('settings.about')}<InfoTip text={t('settings.changelogNote',{file:'CHANGELOG.md'})} /></h2><dl className="grid grid-cols-2 gap-3 text-sm"><dt>{t('settings.version')}</dt><dd>{APP_VERSION} {language==='en'?'“':'„'}{APP_CODENAME}{language==='en'?'”':'“'}</dd><dt>{t('settings.development')}</dt><dd>{APP_MAINTAINER}</dd></dl></section></div>;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SetupWizard } from "@/components/SetupWizard";
export default function SetupPage() {
return <SetupWizard />;
}
+7
View File
@@ -18,6 +18,7 @@ import {
KeyRound,
LayoutDashboard,
Layers,
ListChecks,
LogOut,
Network,
RefreshCw,
@@ -162,6 +163,12 @@ export const navGroups: NavGroup[] = [
icon: Users,
keywords: ["User", "Accounts", "Rollen", "Rechte", "Read-Only", "Nur-Lesen", "Admin-Rolle", "Passwort zurücksetzen"],
},
{
href: "/setup",
label: "Einrichtung",
icon: ListChecks,
keywords: ["Assistent", "Wizard", "Setup", "Erste Schritte", "Onboarding", "Firewall verbinden", "API-Key", "Einführung"],
},
],
},
];
+462
View File
@@ -0,0 +1,462 @@
"use client";
import { useLanguage } from "@/components/LanguageProvider";
import { TlsPinControl, TlsPinValue } from "@/components/TlsPinControl";
import { TwoFactorSection } from "@/components/TwoFactorSection";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { FieldGroup, Input } from "@/components/ui/Field";
import { StatusDot } from "@/components/ui/StatusDot";
import { useToast } from "@/components/ui/Toast";
import { InfoTip } from "@/components/ui/Tooltip";
import { api, AppSettings, Host } from "@/lib/api";
import clsx from "clsx";
import { ArrowLeft, ArrowRight, Bell, Check, DownloadCloud, ExternalLink, Shield, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
const STEPS = ["welcome", "account", "firewall", "done"] as const;
type Step = (typeof STEPS)[number];
const emptyHostForm = { name: "", base_url: "", api_key: "", api_secret: "", cluster_name: "" };
const noPin: TlsPinValue = { pem: null, insecure: false, clear: false };
function Stepper({ step, onGo }: { step: number; onGo: (index: number) => void }) {
const { t } = useLanguage();
return (
<nav aria-label={t("setup.stepsLabel")}>
<ol className="flex flex-wrap gap-2">
{STEPS.map((key, index) => {
const current = index === step;
const done = index < step;
return (
<li key={key}>
<button
type="button"
aria-current={current ? "step" : undefined}
onClick={() => onGo(index)}
className={clsx(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500",
current ? "border-brand-500 bg-brand-500 text-white" : done ? "border-slate-300 text-slate-700 hover:bg-slate-100" : "border-slate-200 text-slate-500 hover:bg-slate-100"
)}
>
<span
aria-hidden="true"
className={clsx("flex h-5 w-5 items-center justify-center rounded-full text-xs", current ? "bg-white/25" : done ? "bg-emerald-100 text-emerald-700" : "bg-slate-100")}
>
{done ? <Check size={12} /> : index + 1}
</span>
{t(`setup.steps.${key}`)}
</button>
</li>
);
})}
</ol>
</nav>
);
}
function WelcomeStep({ settings, onSettings }: { settings: AppSettings | null; onSettings: (s: AppSettings) => void }) {
const { t, errorMessage, language, setLanguage } = useLanguage();
const [error, setError] = useState("");
async function pick(value: "de" | "en") {
const previous = language;
setLanguage(value);
setError("");
try {
onSettings(await api.saveSettings({ language: value }));
} catch (e) {
setLanguage(previous);
setError(errorMessage(e, "common.saveFailed"));
}
}
return (
<div className="space-y-5">
<p className="text-sm text-slate-600">{t("setup.welcome.intro")}</p>
<ul className="space-y-2 text-sm text-slate-700">
{(["account", "firewall", "done"] as const).map((key) => (
<li key={key} className="flex items-start gap-2">
<Check size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
<span>{t(`setup.welcome.points.${key}`)}</span>
</li>
))}
</ul>
<p className="flex items-start gap-2 rounded-xl bg-slate-50 p-3 text-sm text-slate-600">
<ShieldCheck size={16} className="mt-0.5 shrink-0 text-emerald-600" aria-hidden="true" />
{t("setup.welcome.readOnlyNote")}
</p>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-slate-700">{t("settings.language.label")}</legend>
<div role="radiogroup" aria-label={t("settings.language.label")} className="flex flex-wrap gap-3">
{(["de", "en"] as const).map((value) => (
<label
key={value}
className={clsx("flex cursor-pointer items-center gap-2 rounded-lg border px-4 py-2.5 text-sm", language === value ? "border-sky-500 bg-sky-50 text-sky-700" : "border-slate-200")}
>
<input type="radio" name="setup-language" checked={language === value} disabled={!settings} onChange={() => void pick(value)} />
{t(`settings.language.${value}`)}
</label>
))}
</div>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
</fieldset>
</div>
);
}
function AccountStep() {
const { t, errorMessage } = useLanguage();
const toast = useToast();
const [current, setCurrent] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [busy, setBusy] = useState(false);
const [changed, setChanged] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (password !== repeat) {
setError(t("settings.passwordMismatch"));
return;
}
setBusy(true);
try {
await api.changePassword(current, password);
setCurrent("");
setPassword("");
setRepeat("");
setChanged(true);
toast.show(t("settings.passwordChanged"), "success");
} catch (err) {
setError(errorMessage(err, "settings.passwordFailed"));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
<section aria-labelledby="setup-password" className="space-y-3">
<h3 id="setup-password" className="flex items-center gap-2 text-base font-semibold text-slate-900">
{t("setup.account.passwordTitle")}
{changed && <Badge tone="success"><Check size={12} />{t("setup.account.passwordDone")}</Badge>}
</h3>
<p className="text-sm text-slate-600">{t("setup.account.passwordIntro")}</p>
<form onSubmit={submit} className="max-w-md space-y-3">
<FieldGroup label={t("settings.currentPassword")} hint={t("setup.account.currentHint")}>
<Input type="password" autoComplete="current-password" required value={current} onChange={(e) => setCurrent(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("settings.newPassword")} hint={t("setup.account.newHint")}>
<Input type="password" autoComplete="new-password" minLength={10} maxLength={1024} required value={password} onChange={(e) => setPassword(e.target.value)} />
</FieldGroup>
<FieldGroup label={t("settings.repeatPassword")}>
<Input type="password" autoComplete="new-password" minLength={10} required value={repeat} onChange={(e) => setRepeat(e.target.value)} />
</FieldGroup>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<Button type="submit" variant="secondary" loading={busy}>{t("settings.changePassword")}</Button>
</form>
</section>
<section aria-labelledby="setup-2fa" className="space-y-3">
<h3 id="setup-2fa" className="text-base font-semibold text-slate-900">{t("setup.account.twofaTitle")}</h3>
<p className="text-sm text-slate-600">{t("setup.account.twofaIntro")}</p>
<TwoFactorSection />
</section>
</div>
);
}
function FirewallStep({ hosts, onHostsChanged }: { hosts: Host[] | null; onHostsChanged: () => Promise<void> }) {
const { t, ts, errorMessage } = useLanguage();
const toast = useToast();
const [form, setForm] = useState(emptyHostForm);
const [pin, setPin] = useState<TlsPinValue>(noPin);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [checking, setChecking] = useState<string | null>(null);
const firstHost = !hosts || hosts.length === 0;
async function create(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError("");
try {
const created = await api.createHost({
name: form.name.trim(),
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
api_secret: form.api_secret.trim(),
verify_tls: !pin.insecure,
tls_ca_pem: pin.pem,
enabled: true,
cluster_name: form.cluster_name.trim() || null,
policy_hierarchy_group_id: null,
});
toast.show(t("hosts.created", { name: created.name }), "success");
setForm(emptyHostForm);
setPin(noPin);
await onHostsChanged();
} catch (err) {
setError(errorMessage(err, "hosts.createFailed"));
} finally {
setSaving(false);
}
}
async function recheck(id: string) {
setChecking(id);
try {
await onHostsChanged();
} finally {
setChecking(null);
}
}
return (
<div className="space-y-6">
<p className="text-sm text-slate-600">{t("setup.firewall.intro")}</p>
{hosts && hosts.length > 0 && (
<section aria-labelledby="setup-hosts" className="space-y-2">
<h3 id="setup-hosts" className="text-base font-semibold text-slate-900">{t("setup.firewall.connected")}</h3>
<ul className="divide-y divide-slate-100 rounded-xl border border-slate-200">
{hosts.map((host) => (
<li key={host.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
<Link href={`/hosts/${host.id}`} className="font-medium text-sky-700 hover:underline">{host.name}</Link>
<span className="text-slate-500">{host.base_url}</span>
<span className="ml-auto flex items-center gap-3">
<StatusDot status={host.status} />
{host.status === "offline" && (
<Button type="button" variant="outline" loading={checking === host.id} onClick={() => void recheck(host.id)}>
{t("setup.firewall.recheck")}
</Button>
)}
</span>
{host.status === "offline" && (
<div className="basis-full space-y-0.5 text-xs">
{host.status_detail && <p className="text-slate-500">{ts(host.status_detail).slice(0, 160)}</p>}
<p className="text-flame-700">{t("setup.firewall.offlineHelp")}</p>
</div>
)}
</li>
))}
</ul>
</section>
)}
<section aria-labelledby="setup-key" className="space-y-3 rounded-xl border border-slate-200 p-4">
<h3 id="setup-key" className="text-base font-semibold text-slate-900">{t("setup.firewall.keyTitle")}</h3>
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-slate-700">
{(["menu", "user", "key", "rights"] as const).map((key) => (
<li key={key}>{t(`setup.firewall.keySteps.${key}`)}</li>
))}
</ol>
<p className="flex items-start gap-1.5 text-xs text-slate-500">
<DownloadCloud size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
{t("setup.firewall.keyFileNote")}
</p>
</section>
<form onSubmit={create} className="space-y-4" aria-labelledby="setup-add">
<h3 id="setup-add" className="text-base font-semibold text-slate-900">
{firstHost ? t("setup.firewall.addFirst") : t("setup.firewall.addAnother")}
</h3>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup label="Name" hint={t("setup.firewall.nameHint")}>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="fw-primary" />
</FieldGroup>
<FieldGroup label={t("hosts.baseUrl")} hint={t("setup.firewall.urlHint")}>
<Input required value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} placeholder="https://192.168.1.1" />
</FieldGroup>
<FieldGroup label="API-Key">
<Input required autoComplete="off" value={form.api_key} onChange={(e) => setForm({ ...form, api_key: e.target.value })} />
</FieldGroup>
<FieldGroup label="API-Secret">
<Input required type="password" autoComplete="off" value={form.api_secret} onChange={(e) => setForm({ ...form, api_secret: e.target.value })} />
</FieldGroup>
<FieldGroup label={t("hosts.clusterOptional")} hint={t("setup.firewall.clusterHint")}>
<Input value={form.cluster_name} onChange={(e) => setForm({ ...form, cluster_name: e.target.value })} placeholder={t("hosts.clusterPlaceholder")} />
</FieldGroup>
</div>
<TlsPinControl baseUrl={form.base_url} value={pin} onChange={setPin} />
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" loading={saving}>{saving ? t("hosts.saving") : t("setup.firewall.connect")}</Button>
<span className="text-xs text-slate-500">{t("setup.firewall.connectNote")}</span>
</div>
</form>
</div>
);
}
function DoneStep({ hosts, settings }: { hosts: Host[] | null; settings: AppSettings | null }) {
const { t } = useLanguage();
const [twofa, setTwofa] = useState<boolean | null>(null);
useEffect(() => {
api.twoFactorStatus().then((s) => setTwofa(s.enabled)).catch(() => setTwofa(null));
}, []);
const online = hosts?.filter((h) => h.status === "online").length ?? 0;
const total = hosts?.length ?? 0;
const seconds = settings?.deploy_confirm_window_seconds ?? 0;
const next: { href: string; icon: typeof Shield; key: string; params?: Record<string, string | number> }[] = [
{ href: "/rules", icon: Shield, key: "rules", params: { seconds } },
{ href: "/settings#notifications", icon: Bell, key: "notify" },
{ href: "/settings#opnexus-update", icon: DownloadCloud, key: "update" },
];
return (
<div className="space-y-6">
<ul className="space-y-2 text-sm">
<li className="flex items-center gap-2">
<Badge tone={total > 0 && online === total ? "success" : total > 0 ? "warning" : "neutral"}>
{total > 0 ? t("setup.done.hostsOnline", { online, total }) : t("setup.done.noHosts")}
</Badge>
</li>
<li className="flex items-center gap-2">
<Badge tone={twofa ? "success" : "neutral"}>{twofa ? t("setup.done.twofaOn") : t("setup.done.twofaOff")}</Badge>
</li>
</ul>
<section aria-labelledby="setup-next" className="space-y-3">
<h3 id="setup-next" className="text-base font-semibold text-slate-900">{t("setup.done.nextTitle")}</h3>
<ul className="grid gap-3 sm:grid-cols-3">
{next.map(({ href, icon: Icon, key, params }) => (
<li key={key}>
<Link href={href} className="flex h-full flex-col gap-1 rounded-xl border border-slate-200 p-4 text-sm transition-colors hover:border-brand-500 hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand-500">
<span className="flex items-center gap-2 font-medium text-slate-900"><Icon size={16} aria-hidden="true" />{t(`setup.done.next.${key}.title`)}</span>
<span className="text-slate-600">{t(`setup.done.next.${key}.text`, params)}</span>
</Link>
</li>
))}
</ul>
<p className="text-sm text-slate-600">
{t("setup.done.backupBefore")} <code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs">./opnexus backup</code>
<InfoTip text={t("setup.done.backupHelp")} size={13} className="ml-1 align-middle" />
</p>
<p className="text-sm text-slate-500">
<a href="https://opnexus.dev/docs/cli/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-sky-700 hover:underline">
{t("setup.done.docs")} <ExternalLink size={13} aria-hidden="true" />
</a>
</p>
</section>
</div>
);
}
export function SetupWizard() {
const { t } = useLanguage();
const router = useRouter();
const [index, setIndex] = useState(0);
const [role, setRole] = useState<string | null>(null);
const [settings, setSettings] = useState<AppSettings | null>(null);
const [hosts, setHosts] = useState<Host[] | null>(null);
const [finishing, setFinishing] = useState(false);
const [error, setError] = useState("");
const headingRef = useRef<HTMLHeadingElement>(null);
const mounted = useRef(false);
async function loadHosts() {
try {
setHosts(await api.listHosts());
} catch {
setHosts([]);
}
}
useEffect(() => {
api.me().then((s) => setRole(s.role ?? "admin")).catch(() => setRole("admin"));
api.settings().then(setSettings).catch(() => {});
void loadHosts();
}, []);
// Move focus to the step heading on navigation so screen-reader users hear
// where they are; skipped on the very first render.
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
headingRef.current?.focus();
}, [index]);
async function leave(markDone: boolean) {
setFinishing(true);
setError("");
try {
if (markDone && !settings?.setup_done) await api.saveSettings({ setup_done: true });
router.push("/dashboard");
} catch {
setError(t("common.saveFailed"));
setFinishing(false);
}
}
const step: Step = STEPS[index];
const last = index === STEPS.length - 1;
if (role === "read_only") {
return (
<div className="mx-auto max-w-3xl space-y-3">
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
<p className="text-sm text-slate-600">{t("setup.readOnly")}</p>
<Button href="/dashboard" variant="outline">{t("setup.toDashboard")}</Button>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-slate-900">{t("setup.title")}</h1>
<p className="mt-1 text-sm text-slate-500">{t("setup.stepOf", { current: index + 1, total: STEPS.length })}</p>
</div>
<Button type="button" variant="ghost" loading={finishing} onClick={() => void leave(true)}>
{t("setup.skip")}
</Button>
</div>
<Stepper step={index} onGo={setIndex} />
<Card className="space-y-5 p-5 sm:p-6">
<div>
<h2 ref={headingRef} tabIndex={-1} className="text-xl font-semibold text-slate-900 outline-none">
{t(`setup.${step}.title`)}
</h2>
</div>
{step === "welcome" && <WelcomeStep settings={settings} onSettings={setSettings} />}
{step === "account" && <AccountStep />}
{step === "firewall" && <FirewallStep hosts={hosts} onHostsChanged={loadHosts} />}
{step === "done" && <DoneStep hosts={hosts} settings={settings} />}
</Card>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<div className="flex items-center justify-between gap-3">
<Button type="button" variant="outline" disabled={index === 0} onClick={() => setIndex(index - 1)}>
<ArrowLeft size={16} aria-hidden="true" />
{t("setup.back")}
</Button>
{last ? (
<Button type="button" loading={finishing} onClick={() => void leave(true)}>
{t("setup.finish")}
</Button>
) : (
<Button type="button" onClick={() => setIndex(index + 1)}>
{t("setup.next")}
<ArrowRight size={16} aria-hidden="true" />
</Button>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -1098,7 +1098,7 @@ export interface UpdaterStatus {
export const api = {
search: (q: string, options?: RequestInit) => request<SearchHit[]>(`/search?${new URLSearchParams({ q })}`, options),
settings: () => request<AppSettings>("/settings"),
saveSettings: (partial:{theme?:Theme;language?:Language}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
saveSettings: (partial:{theme?:Theme;language?:Language;setup_done?:boolean}) => request<AppSettings>("/settings",{method:"PUT",body:JSON.stringify(partial)}),
notificationSettings: () => request<NotificationSettings>("/notifications/settings"),
saveNotificationSettings: (data: NotificationSettingsUpdate) => request<NotificationSettings>("/notifications/settings", { method: "PUT", body: JSON.stringify(data) }),
logSettings: () => request<LogSettings>("/logs/settings"),
@@ -1527,7 +1527,7 @@ export interface CarpSwitchState {
export interface CarpSwitchPreview {preview_id:string;expires_at:string;action:"carp_enter"|"carp_leave";state:CarpSwitchState;}
export type Theme="light"|"dark"|"system";
export interface AppSettings {username:string;theme:Theme;language:Language;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
export interface AppSettings {username:string;theme:Theme;language:Language;setup_done:boolean;deploy_confirm_window_seconds:number;session_lifetime_seconds:number;}
export type SmtpSecurity = "none" | "starttls" | "ssl";
+92 -1
View File
@@ -164,6 +164,11 @@
},
"badge": "Update verfügbar",
"resultTime": "Ergebnis vom {time}"
},
"setup": {
"title": "Einrichtungsassistent",
"intro": "Führt Schritt für Schritt durch Passwort, Zwei-Faktor-Anmeldung und das Verbinden einer Firewall.",
"open": "Assistent öffnen"
}
},
"nav": {
@@ -215,7 +220,8 @@
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnose"
"hostsystem-diagnostics": "Diagnose",
"setup": "Einrichtung"
}
},
"dashboard": {
@@ -227,6 +233,7 @@
"fleetHealth": "Fleet-/CARP-Gesundheit",
"noHosts": "Noch keine Hosts angelegt",
"toHosts": "Zu den Hosts",
"startSetup": "Einrichtungsassistent starten",
"nothingPending": "Nichts offen",
"nothingPendingDescription": "Keine ausstehenden Bestätigungen, Fehler oder unklaren Wartungsvorgänge.",
"securityAndExpiryWarnings": "Sicherheits- & Ablauf-Warnungen",
@@ -2195,5 +2202,89 @@
"closed": "Port {port} nicht erreichbar",
"noRecords": "Keine Antwort für „{name}“."
}
},
"setup": {
"title": "Einrichtungsassistent",
"skip": "Assistent überspringen",
"stepsLabel": "Einrichtungsschritte",
"stepOf": "Schritt {current} von {total}",
"back": "Zurück",
"next": "Weiter",
"finish": "Einrichtung abschließen",
"readOnly": "Der Assistent richtet Konto und Firewalls ein und ist deshalb Administratoren vorbehalten.",
"toDashboard": "Zum Dashboard",
"steps": {
"welcome": "Willkommen",
"account": "Konto",
"firewall": "Firewall",
"done": "Fertig"
},
"welcome": {
"title": "Willkommen bei OPNexus",
"intro": "OPNexus verwaltet deine OPNsense-Firewalls zentral: Regeln, NAT, VPN, Zertifikate, Updates und HA-Cluster an einem Ort. Dieser Assistent richtet das Nötigste in wenigen Minuten ein. Du kannst ihn jederzeit überspringen und später unter System › Einrichtung wieder öffnen.",
"points": {
"account": "Konto absichern: eigenes Passwort und Zwei-Faktor-Anmeldung.",
"firewall": "Erste Firewall verbinden: API-Key anlegen, Zertifikat prüfen, fertig.",
"done": "Überblick, was als Nächstes sinnvoll ist."
},
"readOnlyNote": "Der Assistent liest nur von deiner Firewall. Geändert wird dort erst, wenn du später selbst etwas ausrollst."
},
"account": {
"title": "Konto absichern",
"passwordTitle": "Eigenes Passwort",
"passwordDone": "Geändert",
"passwordIntro": "Hast du beim Start kein ADMIN_PASSWORD gesetzt, hat OPNexus ein Zufallspasswort erzeugt. Setze jetzt ein eigenes. Dieser Schritt ist optional.",
"currentHint": "Das Passwort, mit dem du dich gerade angemeldet hast. Verloren? Auf dem Server: ./opnexus password",
"newHint": "Mindestens 10 Zeichen. Ein Passwortmanager ist am bequemsten.",
"twofaTitle": "Zwei-Faktor-Anmeldung (empfohlen)",
"twofaIntro": "OPNexus kann Firewalls verändern. Ein zweiter Faktor aus einer Authenticator-App schützt das Konto auch dann, wenn das Passwort bekannt wird."
},
"firewall": {
"title": "Erste Firewall verbinden",
"intro": "OPNexus spricht über die REST-API mit deiner OPNsense. Dafür braucht es einen API-Key. Das Anlegen dauert eine Minute.",
"connected": "Verbundene Firewalls",
"recheck": "Erneut prüfen",
"offlineHelp": "Prüfe Adresse und API-Key und ob die Firewall den Zugriff auf die Weboberfläche von diesem Server aus erlaubt.",
"keyTitle": "So legst du den API-Key in OPNsense an",
"keySteps": {
"menu": "In OPNsense System › Access › Users öffnen.",
"user": "Einen eigenen Benutzer (z. B. „opnexus“) anlegen oder einen vorhandenen bearbeiten.",
"key": "Beim Benutzer unter „API keys“ auf das Plus klicken. Der Browser lädt eine Datei apikey.txt herunter.",
"rights": "Dem Benutzer ausreichende Rechte geben, am einfachsten die Gruppe „admin“. Mit weniger Rechten schlagen einzelne Funktionen fehl."
},
"keyFileNote": "In apikey.txt steht der Key hinter key=, das Secret hinter secret=. Das Secret gibt es nur in dieser Datei.",
"addFirst": "Firewall eintragen",
"addAnother": "Weitere Firewall eintragen",
"nameHint": "Frei wählbar, nur zur Anzeige. Bei einem HA-Paar z. B. fw-a und fw-b.",
"urlHint": "Adresse der OPNsense-Weboberfläche, mit https:// und gegebenenfalls Port, z. B. https://192.168.1.1:8443.",
"clusterHint": "Besteht die Firewall aus einem HA-Paar? Trage beide Knoten als eigene Firewall ein und gib bei beiden denselben Cluster-Namen an.",
"connect": "Verbinden",
"connectNote": "OPNexus prüft sofort, ob die Firewall antwortet."
},
"done": {
"title": "Fertig",
"hostsOnline": "{online} von {total} Firewalls erreichbar",
"noHosts": "Noch keine Firewall verbunden",
"twofaOn": "Zwei-Faktor aktiv",
"twofaOff": "Zwei-Faktor nicht aktiv",
"nextTitle": "Das kannst du als Nächstes tun",
"next": {
"rules": {
"title": "Regeln anlegen",
"text": "Neue Regeln rollst du mit Bestätigung aus: Bestätigst du nicht innerhalb von {seconds} Sekunden, rollt OPNexus automatisch zurück."
},
"notify": {
"title": "Benachrichtigungen",
"text": "Per Webhook oder E-Mail melden lassen, wenn eine Firewall ausfällt oder ein Zertifikat abläuft."
},
"update": {
"title": "Updates",
"text": "Hinweis auf neue OPNexus-Versionen und Updates per Klick einrichten."
}
},
"backupBefore": "Eine Sicherung der OPNexus-Datenbank erstellst du auf dem Server mit",
"backupHelp": "Sichert die Datenbank (Regeln, Hosts, Verlauf). API-Secrets und Schlüssel liegen in eigenen Docker-Volumes und sind nicht im Dump. Details im Handbuch.",
"docs": "Dokumentation zum Kommandozeilen-Werkzeug"
}
}
}
+92 -1
View File
@@ -164,6 +164,11 @@
},
"badge": "Update available",
"resultTime": "Result from {time}"
},
"setup": {
"title": "Setup assistant",
"intro": "Walks you step by step through password, two-factor sign-in and connecting a firewall.",
"open": "Open assistant"
}
},
"nav": {
@@ -215,7 +220,8 @@
"hostsystem-cron": "Cron",
"hostsystem-syslog": "Syslog",
"hostsystem-snapshots": "Snapshots",
"hostsystem-diagnostics": "Diagnostics"
"hostsystem-diagnostics": "Diagnostics",
"setup": "Setup"
}
},
"dashboard": {
@@ -227,6 +233,7 @@
"fleetHealth": "Fleet/CARP health",
"noHosts": "No hosts yet",
"toHosts": "Go to hosts",
"startSetup": "Start the setup assistant",
"nothingPending": "Nothing pending",
"nothingPendingDescription": "No pending confirmations, errors, or unclear maintenance operations.",
"securityAndExpiryWarnings": "Security & expiry warnings",
@@ -2195,5 +2202,89 @@
"closed": "Port {port} not reachable",
"noRecords": "No answer for “{name}”."
}
},
"setup": {
"title": "Setup assistant",
"skip": "Skip assistant",
"stepsLabel": "Setup steps",
"stepOf": "Step {current} of {total}",
"back": "Back",
"next": "Next",
"finish": "Finish setup",
"readOnly": "The assistant sets up the account and firewalls, so it is reserved for administrators.",
"toDashboard": "Go to dashboard",
"steps": {
"welcome": "Welcome",
"account": "Account",
"firewall": "Firewall",
"done": "Done"
},
"welcome": {
"title": "Welcome to OPNexus",
"intro": "OPNexus manages your OPNsense firewalls in one place: rules, NAT, VPN, certificates, updates and HA clusters. This assistant sets up the essentials in a few minutes. You can skip it at any time and reopen it later under System › Setup.",
"points": {
"account": "Secure the account: your own password and two-factor sign-in.",
"firewall": "Connect your first firewall: create an API key, check the certificate, done.",
"done": "An overview of what makes sense next."
},
"readOnlyNote": "The assistant only reads from your firewall. Nothing there changes until you roll something out yourself later."
},
"account": {
"title": "Secure the account",
"passwordTitle": "Your own password",
"passwordDone": "Changed",
"passwordIntro": "If you did not set ADMIN_PASSWORD at startup, OPNexus generated a random password. Set your own now. This step is optional.",
"currentHint": "The password you just signed in with. Lost it? On the server: ./opnexus password",
"newHint": "At least 10 characters. A password manager is the most convenient way.",
"twofaTitle": "Two-factor sign-in (recommended)",
"twofaIntro": "OPNexus can change firewalls. A second factor from an authenticator app protects the account even if the password becomes known."
},
"firewall": {
"title": "Connect your first firewall",
"intro": "OPNexus talks to your OPNsense through its REST API, which needs an API key. Creating one takes a minute.",
"connected": "Connected firewalls",
"recheck": "Check again",
"offlineHelp": "Check the address and API key, and that the firewall allows web interface access from this server.",
"keyTitle": "How to create the API key in OPNsense",
"keySteps": {
"menu": "In OPNsense open System › Access › Users.",
"user": "Create a dedicated user (e.g. “opnexus”) or edit an existing one.",
"key": "Under “API keys” on that user click the plus. Your browser downloads a file called apikey.txt.",
"rights": "Give the user sufficient privileges, most simply the “admin” group. With fewer privileges individual features fail."
},
"keyFileNote": "In apikey.txt the key follows key= and the secret follows secret=. The secret exists only in this file.",
"addFirst": "Add a firewall",
"addAnother": "Add another firewall",
"nameHint": "Free choice, display only. For an HA pair e.g. fw-a and fw-b.",
"urlHint": "Address of the OPNsense web interface, with https:// and the port if needed, e.g. https://192.168.1.1:8443.",
"clusterHint": "Is the firewall an HA pair? Add both nodes as separate firewalls and give both the same cluster name.",
"connect": "Connect",
"connectNote": "OPNexus checks right away whether the firewall answers."
},
"done": {
"title": "Done",
"hostsOnline": "{online} of {total} firewalls reachable",
"noHosts": "No firewall connected yet",
"twofaOn": "Two-factor on",
"twofaOff": "Two-factor off",
"nextTitle": "What you can do next",
"next": {
"rules": {
"title": "Create rules",
"text": "New rules are rolled out with a confirmation: if you do not confirm within {seconds} seconds, OPNexus rolls back automatically."
},
"notify": {
"title": "Notifications",
"text": "Get told by webhook or email when a firewall goes down or a certificate expires."
},
"update": {
"title": "Updates",
"text": "Set up the notice about new OPNexus versions and one-click updates."
}
},
"backupBefore": "You back up the OPNexus database on the server with",
"backupHelp": "Covers the database (rules, hosts, history). API secrets and keys live in their own Docker volumes and are not in the dump. See the manual for details.",
"docs": "Documentation for the command-line tool"
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
// Manually bumped whenever a notable change ships — mirrors the dated
// entries already kept in the project README/CHANGELOG.md, no build
// tooling involved. Versioning scheme: see CHANGELOG.md ("Versionierung").
export const APP_VERSION = "1.15.0";
export const APP_CODENAME = "Gyrfalcon";
export const APP_VERSION = "1.16.0";
export const APP_CODENAME = "Hobby";
export const APP_MAINTAINER = "Julian Boldt";
+3 -1
View File
@@ -38,7 +38,9 @@ OPNexus $version
1. cp .env.example .env und POSTGRES_PASSWORD (und was sonst nötig ist) eintragen
2. docker compose pull && docker compose up -d --no-build
3. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
3. Anmelden: ./opnexus password (zeigt das Admin-Passwort, wenn ADMIN_PASSWORD leer war)
dann im Browser öffnen — der Einrichtungsassistent führt durch den Rest
4. Updates: ./opnexus update (neueste Version der in .env gewählten Spur)
./opnexus update 1.10.1 (feste Version)
Zurück: ./opnexus rollback
Sichern: ./opnexus backup
+30 -1
View File
@@ -7,6 +7,8 @@
# bei Fehler automatisch zurückrollen
# opnexus rollback Letztes Update rückgängig machen
# opnexus backup Sofort-Backup der OPNexus-Datenbank
# opnexus password [NAME] Neues Zufallspasswort für den Admin setzen und
# anzeigen (--yes: ohne Rückfrage)
# opnexus status --porcelain Zustand als key=value-Zeilen (für den Updater)
#
# Läuft im Verzeichnis mit docker-compose.yml und .env (oder OPNEXUS_DIR=...).
@@ -269,10 +271,37 @@ cmd_rollback() {
phase done
}
# Setzt ein neues Zufallspasswort und gibt es einmalig aus. Gedacht für die
# frische Installation (das beim ersten Start erzeugte Passwort steht nur
# einmal im Log) und für ein vergessenes Admin-Passwort. Alle Sitzungen des
# Kontos enden. Rückfrage nur, wenn sich schon jemand angemeldet hat.
cmd_password() {
local user="" yes=0 arg
for arg in "$@"; do
case "$arg" in
--yes|-y) yes=1 ;;
-*) die "Unbekannte Option: $arg" ;;
*) [ -z "$user" ] || die "Nur ein Benutzername erlaubt."; user="$arg" ;;
esac
done
c ps --status running --services 2>/dev/null | grep -qx backend \
|| die "Das Backend läuft nicht. Erst starten: docker compose up -d"
if [ "$yes" != 1 ]; then
local logins; logins="$(psql_db -c 'SELECT count(*) FROM admin_users WHERE last_login_at IS NOT NULL' 2>/dev/null || echo 1)"
if [ "$logins" != 0 ]; then
[ -t 0 ] || die "Ohne Terminal nur mit --yes."
read -r -p "Passwort von ${user:-dem Admin} neu setzen? Alle Sitzungen enden. [j/N] " answer
case "$answer" in j|J|y|Y) ;; *) die "Abgebrochen." ;; esac
fi
fi
c exec -T backend python -m app.reset_password ${user:+"$user"}
}
case "${1:-}" in
password) shift; cmd_password "$@" ;;
status) if [ "${2:-}" = "--porcelain" ]; then cmd_status_porcelain; else cmd_status; fi ;;
update) shift; cmd_update "${1:-}" ;;
rollback) cmd_rollback ;;
backup) cmd_backup ;;
*) sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
*) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 1 ;;
esac