feat: Selfservice-UI fuer Organisation + Installationsmedium (Phase 5)
Zwei neue Menuepunkte fuer eingeloggte Kunden (nicht der organisationsuebergreifende /admin/-Bereich): - "Organisation": Name bearbeiten (ruft Phase-3-PATCH auf provisioning-server auf). - "Installationsmedium": WLAN-SSID/-Passwort, Vorlagenwahl, Kontingent und Gueltigkeitsdauer speichern, personalisierte Kunden-ISO per Klick erstellen (Phase-4-Endpoints), Fortschritt/Status-Anzeige (Meta-Refresh waehrend pending/running), Download + dd/Rufus- Anleitung. Getrennte "aufladen"-Aktion erhoeht Kontingent/ Gueltigkeit der bestehenden Enrollment Session (neuer PATCH- Endpoint), ohne eine neue ISO zu erzeugen - die bereits ausgeteilte bleibt gueltig. Neue Tabelle installationsmedium_konfiguration (Migration 0004, eigene DB, kein Foreign Key auf provisioning-server-Ressourcen, siehe ADR-0011) haelt SSID/verschluesselten PSK/Vorlage/Kontingent-Defaults sowie die IDs der aktuell gueltigen Enrollment Session und des zugehoerigen ISO-Baus. WLAN-PSK wird Fernet-verschluesselt gespeichert (encryption.py, neuer KUNDENPLATTFORM_ENCRYPTION_KEY) - echte Zugangsdaten, kein Passwort-Hash-Fall. Download laeuft ueber einen Streaming-Proxy (anode_stream() in anode_client.py) statt eines direkten Links, weil der Browser kein KUNDENPLATTFORM_TOKEN besitzt. Lokal end-to-end verifiziert (Wegwerf-Postgres fuer beide Services, echter build_customer_iso.sh-Lauf ueber die Kundenplattform-UI): Formular -> Enrollment Session + ISO-Bau -> Status-Polling -> Download-Proxy liefert byte-identische, korrekt personalisierte Datei (SSID/PSK/Aktivierungscode per xorriso-Extraktion gegengeprueft) -> Aufladen erhoeht Kontingent/Gueltigkeit der bestehenden Session ohne neuen Bau -> "neues Installationsmedium erstellen" erzeugt neue Session + ISO, alte Session bleibt unangetastet nutzbar (alter ISO-Bau wird laut Phase-4-Cleanup automatisch entfernt), leeres PSK-Feld uebernimmt das zuvor gespeicherte PSK korrekt. Organisation-Name-Aenderung ueber PATCH verifiziert. Unauthentifizierter Zugriff auf beide neuen Routen leitet korrekt zu /login um. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
576613ed0f
commit
19b39af0ce
@ -1,4 +1,5 @@
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
import httpx
|
||||
|
||||
@ -18,3 +19,22 @@ def anode_request(method: str, path: str, **kwargs) -> dict:
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def anode_stream(method: str, path: str):
|
||||
"""
|
||||
Fuer den ISO-Download (Phase 5): der Browser hat kein
|
||||
KUNDENPLATTFORM_TOKEN, daher muss Kundenplattform als Proxy streamen
|
||||
statt einen direkten Link auf anode auszugeben. Kein Read-Timeout - eine
|
||||
mehrere GB grosse Datei ueber die WireGuard-Verbindung zu anode kann
|
||||
laenger dauern als der sonst genutzte 15s-Timeout erlaubt.
|
||||
"""
|
||||
|
||||
with httpx.stream(
|
||||
method,
|
||||
ANODE_URL + path,
|
||||
headers={"Authorization": f"Bearer {KUNDENPLATTFORM_TOKEN}"},
|
||||
timeout=httpx.Timeout(15, read=None),
|
||||
) as response:
|
||||
yield response
|
||||
|
||||
12
app.py
12
app.py
@ -2,7 +2,15 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
import auth
|
||||
from routers import admin_flows, admin_geraete, admin_kunden, admin_technik, geraete
|
||||
from routers import (
|
||||
admin_flows,
|
||||
admin_geraete,
|
||||
admin_kunden,
|
||||
admin_technik,
|
||||
geraete,
|
||||
installationsmedium,
|
||||
organisation,
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(title="Tuxflotte Kundenplattform")
|
||||
@ -10,6 +18,8 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(geraete.router)
|
||||
app.include_router(organisation.router)
|
||||
app.include_router(installationsmedium.router)
|
||||
app.include_router(admin_technik.router)
|
||||
app.include_router(admin_geraete.router)
|
||||
app.include_router(admin_flows.router)
|
||||
|
||||
34
encryption.py
Normal file
34
encryption.py
Normal file
@ -0,0 +1,34 @@
|
||||
import os
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
|
||||
# Phase 5: verschluesselt den WLAN-PSK at-rest (echte Zugangsdaten, kein
|
||||
# Passwort-Hash-Fall - muss beim ISO-Bau wieder im Klartext verfuegbar
|
||||
# sein). Eigener Schluessel statt Ableitung aus SESSION_SECRET, um
|
||||
# Cookie/JWT-Signing und At-rest-Verschluesselung nicht zu vermischen.
|
||||
# Erzeugen: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
ENCRYPTION_KEY = os.environ.get("KUNDENPLATTFORM_ENCRYPTION_KEY", "")
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
if not ENCRYPTION_KEY:
|
||||
raise RuntimeError("KUNDENPLATTFORM_ENCRYPTION_KEY ist nicht gesetzt.")
|
||||
|
||||
return Fernet(ENCRYPTION_KEY.encode("ascii"))
|
||||
|
||||
|
||||
def encrypt_psk(psk: str) -> bytes:
|
||||
return _fernet().encrypt(psk.encode("utf-8"))
|
||||
|
||||
|
||||
def decrypt_psk(psk_encrypted) -> str | None:
|
||||
if psk_encrypted is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return _fernet().decrypt(bytes(psk_encrypted)).decode("utf-8")
|
||||
except InvalidToken:
|
||||
# Sollte nur bei Schluesselwechsel/Datenkorruption passieren - dann
|
||||
# lieber "kein PSK gespeichert" anzeigen als mit 500 abzubrechen.
|
||||
return None
|
||||
32
migrations/0004_installationsmedium.sql
Normal file
32
migrations/0004_installationsmedium.sql
Normal file
@ -0,0 +1,32 @@
|
||||
BEGIN;
|
||||
|
||||
-- Phase 5 des Self-Service-ISO-Plans: gespeicherte Installationsmedium-
|
||||
-- Konfiguration je Organisation (WLAN-Zugangsdaten, gewaehlte
|
||||
-- Bereitstellungsvorlage, Enrollment-Session-Parameter). Wie
|
||||
-- benutzer.organization_id bewusst ohne Foreign Key - Kundenplattform hat
|
||||
-- eine eigene, von provisioning-server getrennte Datenbank (siehe
|
||||
-- ADR-0011). Ein Datensatz je Organisation reicht (organization_id ist
|
||||
-- Primary Key statt eigener id-Spalte) - es gibt genau eine "aktuelle"
|
||||
-- Konfiguration, keine Historie.
|
||||
--
|
||||
-- wifi_psk_encrypted: echte WLAN-Zugangsdaten, kein Passwort-Hash-Fall -
|
||||
-- muss entschluesselbar bleiben (wird beim ISO-Bau im Klartext an
|
||||
-- build_customer_iso.sh durchgereicht, siehe encryption.py), daher
|
||||
-- Fernet-verschluesselt statt gehasht.
|
||||
--
|
||||
-- enrollment_session_id/iso_build_id verweisen auf provisioning-server-
|
||||
-- Ressourcen (ebenfalls ohne Foreign Key, andere Datenbank). Beide sind
|
||||
-- nullable, bis das erste Installationsmedium erstellt wurde.
|
||||
CREATE TABLE installationsmedium_konfiguration (
|
||||
organization_id UUID PRIMARY KEY,
|
||||
wifi_ssid TEXT,
|
||||
wifi_psk_encrypted BYTEA,
|
||||
bereitstellungsvorlage_id UUID,
|
||||
max_devices INTEGER NOT NULL DEFAULT 20,
|
||||
validity_days INTEGER NOT NULL DEFAULT 30,
|
||||
enrollment_session_id UUID,
|
||||
iso_build_id UUID,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
281
routers/installationsmedium.py
Normal file
281
routers/installationsmedium.py
Normal file
@ -0,0 +1,281 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
|
||||
from anode_client import anode_request, anode_stream
|
||||
from auth import get_current_user
|
||||
from db import get_database_connection
|
||||
from encryption import decrypt_psk, encrypt_psk
|
||||
from templating import templates
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def fetch_konfiguration(organization_id: str) -> dict | None:
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT wifi_ssid, wifi_psk_encrypted, bereitstellungsvorlage_id,
|
||||
max_devices, validity_days, enrollment_session_id, iso_build_id
|
||||
FROM installationsmedium_konfiguration
|
||||
WHERE organization_id = %s
|
||||
""",
|
||||
(organization_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
(wifi_ssid, wifi_psk_encrypted, bereitstellungsvorlage_id,
|
||||
max_devices, validity_days, enrollment_session_id, iso_build_id) = row
|
||||
|
||||
return {
|
||||
"wifi_ssid": wifi_ssid,
|
||||
"hat_wifi_psk": wifi_psk_encrypted is not None,
|
||||
"bereitstellungsvorlage_id": str(bereitstellungsvorlage_id) if bereitstellungsvorlage_id else None,
|
||||
"max_devices": max_devices,
|
||||
"validity_days": validity_days,
|
||||
"enrollment_session_id": str(enrollment_session_id) if enrollment_session_id else None,
|
||||
"iso_build_id": str(iso_build_id) if iso_build_id else None,
|
||||
}
|
||||
|
||||
|
||||
def speichere_konfiguration_basis(
|
||||
organization_id: str,
|
||||
wifi_ssid: str,
|
||||
wifi_psk: str,
|
||||
bereitstellungsvorlage_id: str,
|
||||
max_devices: int,
|
||||
validity_days: int,
|
||||
) -> None:
|
||||
# Leeres PSK-Feld laesst ein zuvor gespeichertes PSK unangetastet (siehe
|
||||
# installationsmedium.html - das Feld zeigt nie den Klartext an, nur
|
||||
# explizites Neueintippen ueberschreibt es).
|
||||
psk_encrypted = encrypt_psk(wifi_psk) if wifi_psk else None
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if wifi_psk:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO installationsmedium_konfiguration
|
||||
(organization_id, wifi_ssid, wifi_psk_encrypted, bereitstellungsvorlage_id,
|
||||
max_devices, validity_days, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (organization_id) DO UPDATE SET
|
||||
wifi_ssid = EXCLUDED.wifi_ssid,
|
||||
wifi_psk_encrypted = EXCLUDED.wifi_psk_encrypted,
|
||||
bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id,
|
||||
max_devices = EXCLUDED.max_devices,
|
||||
validity_days = EXCLUDED.validity_days,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(organization_id, wifi_ssid, psk_encrypted, bereitstellungsvorlage_id,
|
||||
max_devices, validity_days),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO installationsmedium_konfiguration
|
||||
(organization_id, wifi_ssid, bereitstellungsvorlage_id,
|
||||
max_devices, validity_days, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (organization_id) DO UPDATE SET
|
||||
wifi_ssid = EXCLUDED.wifi_ssid,
|
||||
bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id,
|
||||
max_devices = EXCLUDED.max_devices,
|
||||
validity_days = EXCLUDED.validity_days,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(organization_id, wifi_ssid, bereitstellungsvorlage_id,
|
||||
max_devices, validity_days),
|
||||
)
|
||||
|
||||
|
||||
def setze_aktuellen_build(organization_id: str, enrollment_session_id: str, iso_build_id: str) -> None:
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE installationsmedium_konfiguration
|
||||
SET enrollment_session_id = %s, iso_build_id = %s
|
||||
WHERE organization_id = %s
|
||||
""",
|
||||
(enrollment_session_id, iso_build_id, organization_id),
|
||||
)
|
||||
|
||||
|
||||
def lade_gespeichertes_psk(organization_id: str) -> str | None:
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT wifi_psk_encrypted FROM installationsmedium_konfiguration WHERE organization_id = %s",
|
||||
(organization_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
return decrypt_psk(row[0]) if row else None
|
||||
|
||||
|
||||
def finde_enrollment_session(organization_id: str, session_id: str) -> dict | None:
|
||||
result = anode_request("GET", f"/api/v1/organizations/{organization_id}/enrollment-sessions")
|
||||
sessions = result.get("enrollment_sessions", []) if result.get("success") else []
|
||||
|
||||
return next((s for s in sessions if s["id"] == session_id), None)
|
||||
|
||||
|
||||
@router.get("/installationsmedium")
|
||||
def installationsmedium_ansicht(request: Request, user=Depends(get_current_user)):
|
||||
organization_id = str(user.organization_id)
|
||||
konfiguration = fetch_konfiguration(organization_id)
|
||||
|
||||
vorlagen_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/bereitstellungsvorlagen")
|
||||
vorlagen = vorlagen_result.get("bereitstellungsvorlagen", []) if vorlagen_result.get("success") else []
|
||||
|
||||
enrollment_session = None
|
||||
iso_build = None
|
||||
|
||||
if konfiguration and konfiguration["enrollment_session_id"]:
|
||||
enrollment_session = finde_enrollment_session(organization_id, konfiguration["enrollment_session_id"])
|
||||
|
||||
if konfiguration and konfiguration["iso_build_id"]:
|
||||
build_result = anode_request("GET", f"/api/v1/iso-builds/{konfiguration['iso_build_id']}")
|
||||
iso_build = build_result.get("iso_build") if build_result.get("success") else None
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"installationsmedium.html",
|
||||
{
|
||||
"user": user,
|
||||
"konfiguration": konfiguration,
|
||||
"vorlagen": vorlagen,
|
||||
"enrollment_session": enrollment_session,
|
||||
"iso_build": iso_build,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/installationsmedium")
|
||||
def installationsmedium_erstellen(
|
||||
wifi_ssid: str = Form(default=""),
|
||||
wifi_psk: str = Form(default=""),
|
||||
bereitstellungsvorlage_id: str = Form(...),
|
||||
max_devices: int = Form(...),
|
||||
validity_days: int = Form(...),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
organization_id = str(user.organization_id)
|
||||
|
||||
speichere_konfiguration_basis(
|
||||
organization_id, wifi_ssid, wifi_psk, bereitstellungsvorlage_id, max_devices, validity_days
|
||||
)
|
||||
|
||||
# Nur fuer den ISO-Bau gebraucht, nie ueber diesen Aufruf hinaus
|
||||
# gespeichert (siehe Phase-4-Design von provisioning-server) - bei
|
||||
# leerem Feld wird das zuvor gespeicherte PSK weiterverwendet.
|
||||
psk_fuer_build = wifi_psk or (lade_gespeichertes_psk(organization_id) or "")
|
||||
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=validity_days)).isoformat()
|
||||
|
||||
session_result = anode_request(
|
||||
"POST",
|
||||
f"/api/v1/organizations/{organization_id}/enrollment-sessions",
|
||||
json={
|
||||
"bereitstellungsvorlage_id": bereitstellungsvorlage_id,
|
||||
"max_devices": max_devices,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
if not session_result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=session_result.get("message", "Enrollment Session konnte nicht erstellt werden."))
|
||||
|
||||
session_id = session_result["enrollment_session"]["id"]
|
||||
|
||||
build_result = anode_request(
|
||||
"POST",
|
||||
f"/api/v1/organizations/{organization_id}/iso-builds",
|
||||
json={"activation_code": session_id, "wifi_ssid": wifi_ssid, "wifi_psk": psk_fuer_build},
|
||||
)
|
||||
|
||||
if not build_result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=build_result.get("message", "ISO-Bau konnte nicht gestartet werden."))
|
||||
|
||||
setze_aktuellen_build(organization_id, session_id, build_result["iso_build"]["id"])
|
||||
|
||||
return RedirectResponse("/installationsmedium", status_code=303)
|
||||
|
||||
|
||||
@router.post("/installationsmedium/aufladen")
|
||||
def installationsmedium_aufladen(
|
||||
additional_devices: int = Form(default=0),
|
||||
additional_days: int = Form(default=0),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
organization_id = str(user.organization_id)
|
||||
konfiguration = fetch_konfiguration(organization_id)
|
||||
|
||||
if not konfiguration or not konfiguration["enrollment_session_id"]:
|
||||
raise HTTPException(status_code=404, detail="Es wurde noch kein Installationsmedium erstellt.")
|
||||
|
||||
session_id = konfiguration["enrollment_session_id"]
|
||||
neues_expires_at = None
|
||||
|
||||
if additional_days > 0:
|
||||
aktuelle_session = finde_enrollment_session(organization_id, session_id)
|
||||
|
||||
if aktuelle_session is None:
|
||||
raise HTTPException(status_code=404, detail="Enrollment Session wurde nicht gefunden.")
|
||||
|
||||
aktuelles_ablaufdatum = datetime.fromisoformat(aktuelle_session["expires_at"])
|
||||
neues_expires_at = (aktuelles_ablaufdatum + timedelta(days=additional_days)).isoformat()
|
||||
|
||||
result = anode_request(
|
||||
"PATCH",
|
||||
f"/api/v1/enrollment-sessions/{session_id}",
|
||||
json={"additional_devices": additional_devices, "expires_at": neues_expires_at},
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("message", "Aufladen fehlgeschlagen."))
|
||||
|
||||
return RedirectResponse("/installationsmedium", status_code=303)
|
||||
|
||||
|
||||
@router.get("/installationsmedium/download")
|
||||
def installationsmedium_download(user=Depends(get_current_user)):
|
||||
organization_id = str(user.organization_id)
|
||||
konfiguration = fetch_konfiguration(organization_id)
|
||||
|
||||
if not konfiguration or not konfiguration["iso_build_id"]:
|
||||
raise HTTPException(status_code=404, detail="Es wurde noch kein Installationsmedium erstellt.")
|
||||
|
||||
build_id = konfiguration["iso_build_id"]
|
||||
status_result = anode_request("GET", f"/api/v1/iso-builds/{build_id}")
|
||||
iso_build = status_result.get("iso_build") if status_result.get("success") else None
|
||||
|
||||
# Besitz-Validierung wie bei Geraeten (ADR-0011): iso_build_id kommt
|
||||
# zwar aus der eigenen Konfigurationszeile, aber sicherheitshalber noch
|
||||
# einmal explizit gegenpruefen statt der gespeicherten Kennung blind zu
|
||||
# vertrauen.
|
||||
if iso_build is None or iso_build["organization_id"] != organization_id:
|
||||
raise HTTPException(status_code=404, detail="Installationsmedium wurde nicht gefunden.")
|
||||
|
||||
if iso_build["status"] != "completed":
|
||||
raise HTTPException(status_code=409, detail="Das Installationsmedium ist noch nicht fertig gebaut.")
|
||||
|
||||
def stream_datei():
|
||||
with anode_stream("GET", f"/api/v1/iso-builds/{build_id}/download") as response:
|
||||
response.raise_for_status()
|
||||
for chunk in response.iter_bytes(chunk_size=1024 * 1024):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
stream_datei(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": 'attachment; filename="tuxflotte-installationsmedium.iso"'},
|
||||
)
|
||||
28
routers/organisation.py
Normal file
28
routers/organisation.py
Normal file
@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from anode_client import anode_request
|
||||
from auth import get_current_user
|
||||
from templating import templates
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/organisation")
|
||||
def organisation_ansicht(request: Request, user=Depends(get_current_user)):
|
||||
result = anode_request("GET", f"/api/v1/organizations/{user.organization_id}")
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=404, detail="Organisation wurde nicht gefunden.")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, "organisation.html", {"user": user, "organization": result["organization"]}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/organisation")
|
||||
def organisation_speichern(name: str = Form(...), user=Depends(get_current_user)):
|
||||
anode_request("PATCH", f"/api/v1/organizations/{user.organization_id}", json={"name": name})
|
||||
|
||||
return RedirectResponse("/organisation", status_code=303)
|
||||
@ -18,6 +18,9 @@
|
||||
<ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul>
|
||||
{% if user %}
|
||||
<ul>
|
||||
<li><a href="/geraete">Geräte</a></li>
|
||||
<li><a href="/installationsmedium">Installationsmedium</a></li>
|
||||
<li><a href="/organisation">Organisation</a></li>
|
||||
{% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
|
||||
<li>{{ user.email }}</li>
|
||||
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
|
||||
|
||||
112
templates/installationsmedium.html
Normal file
112
templates/installationsmedium.html
Normal file
@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Installationsmedium — Tuxflotte{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Installationsmedium</h2>
|
||||
|
||||
{% if iso_build and iso_build.status in ("pending", "running") %}
|
||||
<meta http-equiv="refresh" content="5">
|
||||
{% endif %}
|
||||
|
||||
{% if konfiguration %}
|
||||
<section>
|
||||
<h3>Aktuelles Installationsmedium</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>WLAN-SSID</th><td>{{ konfiguration.wifi_ssid or "—" }}</td></tr>
|
||||
<tr><th>Vorlage</th><td>
|
||||
{% set vorlage = vorlagen | selectattr("id", "equalto", konfiguration.bereitstellungsvorlage_id) | first %}
|
||||
{{ vorlage.label if vorlage else "—" }}
|
||||
</td></tr>
|
||||
{% if enrollment_session %}
|
||||
<tr><th>Kontingent</th><td>{{ enrollment_session.devices_used }} / {{ enrollment_session.max_devices }} Geräte</td></tr>
|
||||
<tr><th>Gültig bis</th><td>{{ enrollment_session.expires_at }}</td></tr>
|
||||
<tr><th>Widerrufen</th><td>{{ "ja" if enrollment_session.revoked_at else "nein" }}</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if iso_build %}
|
||||
{% if iso_build.status in ("pending", "running") %}
|
||||
<p>⏳ Wird gerade erstellt (Status: {{ iso_build.status }}) — diese Seite aktualisiert sich automatisch alle 5 Sekunden.</p>
|
||||
{% elif iso_build.status == "completed" %}
|
||||
<p><a href="/installationsmedium/download" role="button">Installationsmedium herunterladen</a></p>
|
||||
<details>
|
||||
<summary role="button" class="secondary outline">Anleitung: auf USB-Stick übertragen</summary>
|
||||
<p>
|
||||
Unter Windows: <a href="https://rufus.ie/" target="_blank" rel="noopener">Rufus</a> verwenden —
|
||||
heruntergeladene .iso-Datei auswählen, USB-Stick auswählen, "Start" klicken.
|
||||
</p>
|
||||
<p>
|
||||
Unter Linux/macOS:
|
||||
<code>sudo dd if=tuxflotte-installationsmedium.iso of=/dev/sdX bs=4M status=progress conv=fsync</code>
|
||||
(<code>/dev/sdX</code> durch den tatsächlichen USB-Stick ersetzen — <strong>alle Daten darauf werden überschrieben</strong>).
|
||||
</p>
|
||||
</details>
|
||||
{% elif iso_build.status == "failed" %}
|
||||
<p><mark>Der Bau ist fehlgeschlagen. Bitte weiter unten erneut versuchen.</mark></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if enrollment_session and not enrollment_session.revoked_at %}
|
||||
<section>
|
||||
<h3>Kontingent/Gültigkeit aufladen</h3>
|
||||
<p>
|
||||
Erhöht Kontingent und/oder Gültigkeit der bestehenden Sitzung — das bereits heruntergeladene
|
||||
Installationsmedium bleibt gültig, es muss <strong>kein</strong> neues erstellt werden.
|
||||
</p>
|
||||
<form method="post" action="/installationsmedium/aufladen">
|
||||
<div class="grid">
|
||||
<label>Zusätzliche Geräte
|
||||
<input type="number" name="additional_devices" min="0" value="0">
|
||||
</label>
|
||||
<label>Zusätzliche Tage Gültigkeit
|
||||
<input type="number" name="additional_days" min="0" value="0">
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Aufladen</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h3>{{ "Neues Installationsmedium erstellen" if konfiguration else "Installationsmedium erstellen" }}</h3>
|
||||
{% if konfiguration %}
|
||||
<p>
|
||||
Erstellt ein komplett neues Installationsmedium mit eigener Sitzung — nötig, wenn sich die
|
||||
WLAN-Zugangsdaten geändert haben. Das bisherige Medium bleibt bis zu seinem Ablauf weiter nutzbar.
|
||||
</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/installationsmedium">
|
||||
<label for="wifi_ssid">WLAN-SSID</label>
|
||||
<input type="text" id="wifi_ssid" name="wifi_ssid" value="{{ konfiguration.wifi_ssid if konfiguration else '' }}">
|
||||
|
||||
<label for="wifi_psk">WLAN-Passwort</label>
|
||||
<input type="password" id="wifi_psk" name="wifi_psk"
|
||||
placeholder="{{ '•••••••• (gespeichert – zum Ändern neu eingeben)' if konfiguration and konfiguration.hat_wifi_psk else '' }}">
|
||||
|
||||
<label for="bereitstellungsvorlage_id">Vorlage</label>
|
||||
<select id="bereitstellungsvorlage_id" name="bereitstellungsvorlage_id" required>
|
||||
{% for v in vorlagen %}
|
||||
<option value="{{ v.id }}"
|
||||
{% if konfiguration and konfiguration.bereitstellungsvorlage_id == v.id %}selected
|
||||
{% elif not konfiguration and v.is_default %}selected{% endif %}>
|
||||
{{ v.label }} ({{ v.backend.name }} {{ v.backend.version }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<div class="grid">
|
||||
<label>Kontingent (Anzahl Geräte)
|
||||
<input type="number" name="max_devices" min="1" value="{{ konfiguration.max_devices if konfiguration else 20 }}" required>
|
||||
</label>
|
||||
<label>Gültigkeit (Tage)
|
||||
<input type="number" name="validity_days" min="1" value="{{ konfiguration.validity_days if konfiguration else 30 }}" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">{{ "Neues Installationsmedium erstellen" if konfiguration else "Installationsmedium erstellen" }}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
11
templates/organisation.html
Normal file
11
templates/organisation.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Organisation — Tuxflotte{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Organisation</h2>
|
||||
|
||||
<form method="post" action="/organisation">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ organization.name }}" required>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Loading…
x
Reference in New Issue
Block a user