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:
Thomas Stallinger 2026-08-07 01:36:46 +02:00
parent 576613ed0f
commit 19b39af0ce
9 changed files with 532 additions and 1 deletions

View File

@ -1,4 +1,5 @@
import os import os
from contextlib import contextmanager
import httpx import httpx
@ -18,3 +19,22 @@ def anode_request(method: str, path: str, **kwargs) -> dict:
response.raise_for_status() response.raise_for_status()
return response.json() 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
View File

@ -2,7 +2,15 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import auth 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") 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(auth.router)
app.include_router(geraete.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_technik.router)
app.include_router(admin_geraete.router) app.include_router(admin_geraete.router)
app.include_router(admin_flows.router) app.include_router(admin_flows.router)

34
encryption.py Normal file
View 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

View 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;

View 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
View 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)

View File

@ -18,6 +18,9 @@
<ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul> <ul><li><strong><a href="/geraete">Tuxflotte Kundenplattform</a></strong></li></ul>
{% if user %} {% if user %}
<ul> <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 %} {% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
<li>{{ user.email }}</li> <li>{{ user.email }}</li>
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li> <li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>

View 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 %}

View 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 %}