feat: initiale Kundenplattform (Login + Auftragskatalog-Auswahl)
Neuer, von provisioning-server (anode) getrennter Dienst, siehe ADR-0011: FastAPI + Jinja2, eigene Postgres-DB (benutzer-Tabelle), eigenes Service-Token (TUXFLOTTE_KUNDENPLATTFORM_TOKEN) für die Kommunikation mit anode. Org-Zugehörigkeits-Prüfung passiert hier in der Anwendung vor jedem Aufruf gegen anode. Umfang v1: Login, Geräteliste, Auftragskatalog geräteweise aus-/abwählen (inkl. der standardbrowser-Option für Brave). systemd-Unit läuft unter eigenem unprivilegiertem System-User statt root. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
c4825acd61
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# kundenplattform
|
||||||
|
|
||||||
|
Kunden-Web-UI für den Tuxflotte-Auftragskatalog — von `provisioning-server`
|
||||||
|
(anode) bewusst getrennter Dienst, siehe [ADR-0011](https://git.tuxflotte.de/admin/platform-docs/raw/branch/main/adr/0011-kundenplattform-getrennter-dienst.md).
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
- FastAPI + Jinja2 (serverseitig gerendert, kein Frontend-Build).
|
||||||
|
- Eigene Postgres-Datenbank (`benutzer`-Tabelle: E-Mail/Passwort-Hash pro
|
||||||
|
Organisation) — getrennt von provisioning-servers Datenbank, keine
|
||||||
|
Cross-Database-Referenzen.
|
||||||
|
- Spricht mit anode ausschließlich über dessen HTTP-API, authentifiziert mit
|
||||||
|
einem eigenen Service-Token (`TUXFLOTTE_KUNDENPLATTFORM_TOKEN`, getrennt
|
||||||
|
vom Admin-Token). Organisationszugehörigkeits-Prüfung (darf dieser Login
|
||||||
|
auf dieses Gerät zugreifen?) passiert hier in der Anwendung, vor jedem
|
||||||
|
Aufruf gegen anode — siehe `find_device_in_organization()` in `app.py`.
|
||||||
|
|
||||||
|
## Setup (lokal)
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
KUNDENPLATTFORM_DATABASE_URL=postgresql://... .venv/bin/psql ... -f migrations/0001_initial_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Env-Variablen:
|
||||||
|
|
||||||
|
- `TUXFLOTTE_ANODE_URL` (Default `http://127.0.0.1:8080`)
|
||||||
|
- `TUXFLOTTE_KUNDENPLATTFORM_TOKEN` — muss mit dem gleichnamigen Wert in
|
||||||
|
provisioning-servers `/etc/tuxflotte/provisioning-server.env` übereinstimmen
|
||||||
|
- `KUNDENPLATTFORM_DATABASE_URL`
|
||||||
|
- `KUNDENPLATTFORM_SESSION_SECRET` — beliebiger zufälliger String zum Signieren
|
||||||
|
der Session-Cookie (z.B. `openssl rand -base64 32`)
|
||||||
|
|
||||||
|
Start: `.venv/bin/uvicorn app:app --port 8081`
|
||||||
|
|
||||||
|
## Konto anlegen
|
||||||
|
|
||||||
|
Kein Self-Service in v1 (siehe ADR-0011) — Konten werden manuell angelegt:
|
||||||
|
|
||||||
|
```
|
||||||
|
KUNDENPLATTFORM_DATABASE_URL=... .venv/bin/python scripts/create_user.py \
|
||||||
|
--organization-id <organization-uuid> \
|
||||||
|
--email admin@schule-beispiel.de
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy (anode)
|
||||||
|
|
||||||
|
Wie bei `provisioning-server`, git-basiert statt scp:
|
||||||
|
|
||||||
|
```
|
||||||
|
git pull --ff-only
|
||||||
|
systemctl restart kundenplattform
|
||||||
|
```
|
||||||
|
|
||||||
|
Läuft unter einem eigenen, unprivilegierten System-User `kundenplattform`
|
||||||
|
(nicht root, anders als provisioning-server) — siehe `kundenplattform.service`.
|
||||||
|
Port `8081` (provisioning-server belegt `8080`).
|
||||||
|
|
||||||
|
**Öffentliche Erreichbarkeit über Pangolin ist nicht Teil dieses Repos** —
|
||||||
|
das ist ein separater Ops/DNS/Reverse-Proxy-Schritt.
|
||||||
196
app.py
Normal file
196
app.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import httpx
|
||||||
|
import psycopg
|
||||||
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
ANODE_URL = os.environ.get("TUXFLOTTE_ANODE_URL", "http://127.0.0.1:8080")
|
||||||
|
KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "")
|
||||||
|
DATABASE_URL = os.environ.get("KUNDENPLATTFORM_DATABASE_URL")
|
||||||
|
SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "")
|
||||||
|
|
||||||
|
# Merkmale, für die die UI eine zusätzliche Auswahl-Option anbietet (siehe
|
||||||
|
# ADR-0010 optionen-Mechanismus). Bewusst hier als kleine, explizite Map
|
||||||
|
# statt eines generischen Options-Schemas - nur ein Fall existiert aktuell.
|
||||||
|
OPTIONEN_FELDER = {
|
||||||
|
"browser-brave": [
|
||||||
|
{"key": "standardbrowser", "label": "Als Standardbrowser festlegen"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
app = FastAPI(title="Tuxflotte Kundenplattform")
|
||||||
|
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, session_cookie="kundenplattform_session")
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_connection():
|
||||||
|
if not DATABASE_URL:
|
||||||
|
raise RuntimeError("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.")
|
||||||
|
|
||||||
|
return psycopg.connect(DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def anode_request(method: str, path: str, **kwargs) -> dict:
|
||||||
|
response = httpx.request(
|
||||||
|
method,
|
||||||
|
ANODE_URL + path,
|
||||||
|
headers={"Authorization": f"Bearer {KUNDENPLATTFORM_TOKEN}"},
|
||||||
|
timeout=15,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request) -> dict:
|
||||||
|
user = request.session.get("user")
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def find_device_in_organization(device_id: str, organization_id: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Besitz-Validierung (siehe ADR-0011): ein device_id-Wert aus der URL wird
|
||||||
|
nur akzeptiert, wenn er tatsächlich in der Geräteliste der eigenen
|
||||||
|
Organisation auftaucht - verhindert, dass ein eingeloggter Kunde per
|
||||||
|
manipulierter URL auf fremde Geräte zugreift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = anode_request("GET", f"/api/v1/organizations/{organization_id}/devices")
|
||||||
|
devices = result.get("devices", []) if result.get("success") else []
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
if device["id"] == device_id:
|
||||||
|
return device
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index(user: dict = Depends(get_current_user)):
|
||||||
|
return RedirectResponse("/geraete", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
def login_form(request: Request):
|
||||||
|
if request.session.get("user") is not None:
|
||||||
|
return RedirectResponse("/geraete", status_code=303)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(request, "login.html", {"error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
def login_submit(request: Request, email: str = Form(...), password: str = Form(...)):
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, organization_id, password_hash FROM benutzer WHERE email = %s",
|
||||||
|
(email,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if row is None or not bcrypt.checkpw(password.encode("utf-8"), row[2].encode("utf-8")):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "login.html", {"error": "E-Mail oder Passwort ist falsch."}, status_code=401
|
||||||
|
)
|
||||||
|
|
||||||
|
request.session["user"] = {
|
||||||
|
"id": str(row[0]),
|
||||||
|
"organization_id": str(row[1]),
|
||||||
|
"email": email,
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectResponse("/geraete", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
def logout(request: Request):
|
||||||
|
request.session.clear()
|
||||||
|
|
||||||
|
return RedirectResponse("/login", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/geraete")
|
||||||
|
def geraete_liste(request: Request, user: dict = Depends(get_current_user)):
|
||||||
|
result = anode_request("GET", f"/api/v1/organizations/{user['organization_id']}/devices")
|
||||||
|
devices = result.get("devices", []) if result.get("success") else []
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "geraete_liste.html", {"user": user, "devices": devices}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/geraete/{device_id}")
|
||||||
|
def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(get_current_user)):
|
||||||
|
device = find_device_in_organization(device_id, user["organization_id"])
|
||||||
|
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.")
|
||||||
|
|
||||||
|
result = anode_request("GET", f"/api/v1/devices/{device_id}/auftragskatalog")
|
||||||
|
katalog = result.get("auftragskatalog", []) if result.get("success") else []
|
||||||
|
fehler = None if result.get("success") else result.get("message")
|
||||||
|
|
||||||
|
# Bereits nach kategorie.sort_order sortiert (siehe anode-Query) - hier
|
||||||
|
# nur noch gruppieren, kein erneutes Sortieren (würde die Reihenfolge
|
||||||
|
# durcheinanderbringen).
|
||||||
|
kategorien: dict[str, list[dict]] = {}
|
||||||
|
for eintrag in katalog:
|
||||||
|
kategorie_name = eintrag["kategorie"]["name"] if eintrag["kategorie"] else "Ohne Kategorie"
|
||||||
|
kategorien.setdefault(kategorie_name, []).append(eintrag)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"auftragskatalog.html",
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"device": device,
|
||||||
|
"kategorien": kategorien,
|
||||||
|
"fehler": fehler,
|
||||||
|
"optionen_felder": OPTIONEN_FELDER,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/select")
|
||||||
|
async def auftrag_select(
|
||||||
|
request: Request, device_id: str, merkmal_key: str, user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
if find_device_in_organization(device_id, user["organization_id"]) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.")
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
optionen = {
|
||||||
|
feld["key"]: (form.get(f"optionen.{feld['key']}") == "on")
|
||||||
|
for feld in OPTIONEN_FELDER.get(merkmal_key, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
anode_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select",
|
||||||
|
json={"optionen": optionen},
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(f"/geraete/{device_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/geraete/{device_id}/auftragskatalog/{merkmal_key}/deselect")
|
||||||
|
def auftrag_deselect(device_id: str, merkmal_key: str, user: dict = Depends(get_current_user)):
|
||||||
|
if find_device_in_organization(device_id, user["organization_id"]) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.")
|
||||||
|
|
||||||
|
anode_request(
|
||||||
|
"POST", f"/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect"
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(f"/geraete/{device_id}", status_code=303)
|
||||||
25
kundenplattform.service
Normal file
25
kundenplattform.service
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Tuxflotte Kundenplattform
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=kundenplattform
|
||||||
|
Group=kundenplattform
|
||||||
|
WorkingDirectory=/opt/kundenplattform
|
||||||
|
EnvironmentFile=/etc/tuxflotte/kundenplattform.env
|
||||||
|
ExecStart=/opt/kundenplattform/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8081
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# Härtung - läuft bewusst unter einem eigenen, unprivilegierten System-User
|
||||||
|
# statt root (anders als provisioning-server, siehe ADR-0011: exponiertere,
|
||||||
|
# kundenzugängliche Komponente).
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/opt/kundenplattform
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
15
migrations/0001_initial_schema.sql
Normal file
15
migrations/0001_initial_schema.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- organization_id verweist auf provisioning-servers organizations.id, aber
|
||||||
|
-- bewusst ohne Foreign Key: Kundenplattform hat eine eigene Datenbank,
|
||||||
|
-- getrennt von provisioning-server (siehe ADR-0011), keine Cross-Database-
|
||||||
|
-- Referenzen möglich oder gewollt.
|
||||||
|
CREATE TABLE benutzer (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
organization_id UUID NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
annotated-doc==0.0.5
|
||||||
|
annotated-types==0.8.0
|
||||||
|
anyio==4.14.2
|
||||||
|
bcrypt==5.0.0
|
||||||
|
certifi==2026.7.22
|
||||||
|
click==8.4.2
|
||||||
|
fastapi==0.141.1
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.18
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
psycopg==3.3.4
|
||||||
|
psycopg-binary==3.3.4
|
||||||
|
pydantic==2.13.4
|
||||||
|
pydantic_core==2.46.4
|
||||||
|
python-multipart==0.0.32
|
||||||
|
starlette==1.3.1
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.16.0
|
||||||
|
uvicorn==0.52.1
|
||||||
53
scripts/create_user.py
Normal file
53
scripts/create_user.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
Manuelle Konto-Anlage für die Kundenplattform (kein Self-Service in v1, siehe
|
||||||
|
ADR-0011). Beispiel:
|
||||||
|
|
||||||
|
KUNDENPLATTFORM_DATABASE_URL=... .venv/bin/python scripts/create_user.py \\
|
||||||
|
--organization-id 11111111-1111-4111-8111-111111111111 \\
|
||||||
|
--email admin@schule-beispiel.de
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--organization-id", required=True)
|
||||||
|
parser.add_argument("--email", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
database_url = os.environ.get("KUNDENPLATTFORM_DATABASE_URL")
|
||||||
|
|
||||||
|
if not database_url:
|
||||||
|
sys.exit("KUNDENPLATTFORM_DATABASE_URL ist nicht gesetzt.")
|
||||||
|
|
||||||
|
password = getpass.getpass("Passwort: ")
|
||||||
|
password_confirm = getpass.getpass("Passwort (Wiederholung): ")
|
||||||
|
|
||||||
|
if password != password_confirm:
|
||||||
|
sys.exit("Passwörter stimmen nicht überein.")
|
||||||
|
|
||||||
|
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
with psycopg.connect(database_url) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO benutzer (id, organization_id, email, password_hash)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(uuid4(), args.organization_id, args.email, password_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Konto für {args.email} angelegt.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
49
templates/auftragskatalog.html
Normal file
49
templates/auftragskatalog.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Auftragskatalog {{ device.hostname or device.device_fingerprint }} — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/geraete">← Geräteliste</a></p>
|
||||||
|
<h2>Auftragskatalog: {{ device.hostname or device.device_fingerprint }}</h2>
|
||||||
|
|
||||||
|
{% if fehler %}
|
||||||
|
<p class="fehler">{{ fehler }}</p>
|
||||||
|
{% else %}
|
||||||
|
{% for kategorie_name, eintraege in kategorien.items() %}
|
||||||
|
<div class="kategorie">
|
||||||
|
<h2>{{ kategorie_name }}</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{% for eintrag in eintraege %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ eintrag.name }}</strong>
|
||||||
|
{% if eintrag.description %}<br><small>{{ eintrag.description }}</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="state-{{ eintrag.state }}">
|
||||||
|
{{ "ausgewählt" if eintrag.state == "present" else "nicht ausgewählt" }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if eintrag.state == "present" %}
|
||||||
|
<form class="inline" method="post" action="/geraete/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/deselect">
|
||||||
|
<button type="submit">Abwählen</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form class="inline" method="post" action="/geraete/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/select">
|
||||||
|
{% if optionen_felder.get(eintrag.merkmal) %}
|
||||||
|
<div class="optionen">
|
||||||
|
{% for feld in optionen_felder[eintrag.merkmal] %}
|
||||||
|
<label><input type="checkbox" name="optionen.{{ feld.key }}"> {{ feld.label }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit">Auswählen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
32
templates/base.html
Normal file
32
templates/base.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{% block title %}Tuxflotte{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
|
||||||
|
header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2rem; border-bottom: 1px solid #ddd; padding-bottom: 0.5rem; }
|
||||||
|
header a { color: inherit; text-decoration: none; }
|
||||||
|
h1 { font-size: 1.2rem; margin: 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
|
||||||
|
.kategorie { margin-top: 1.5rem; }
|
||||||
|
.kategorie h2 { font-size: 1rem; color: #555; margin-bottom: 0.3rem; }
|
||||||
|
.state-present { color: #1a7f37; font-weight: 600; }
|
||||||
|
.state-absent { color: #999; }
|
||||||
|
form.inline { display: inline; }
|
||||||
|
button { cursor: pointer; padding: 0.3rem 0.8rem; }
|
||||||
|
.fehler { background: #fee; border: 1px solid #f99; padding: 0.6rem; border-radius: 4px; margin-bottom: 1rem; }
|
||||||
|
.optionen label { font-weight: normal; font-size: 0.9rem; color: #555; display: block; margin: 0.2rem 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1><a href="/geraete">Tuxflotte Kundenplattform</a></h1>
|
||||||
|
{% if user %}
|
||||||
|
<span>{{ user.email }} — <a href="/logout">Abmelden</a></span>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
templates/geraete_liste.html
Normal file
24
templates/geraete_liste.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Geräte — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Ihre Geräte</h2>
|
||||||
|
{% if not devices %}
|
||||||
|
<p>Keine Geräte gefunden.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Hostname</th><th>Kennung</th><th>Letzter Check-in</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for device in devices %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ device.hostname or "—" }}</td>
|
||||||
|
<td><code>{{ device.device_fingerprint }}</code></td>
|
||||||
|
<td>{{ device.agent_last_checkin or "noch nie" }}</td>
|
||||||
|
<td><a href="/geraete/{{ device.id }}">Auftragskatalog →</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
11
templates/login.html
Normal file
11
templates/login.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Anmelden — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Anmelden</h2>
|
||||||
|
{% if error %}<p class="fehler">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<p><label>E-Mail<br><input type="email" name="email" required autofocus></label></p>
|
||||||
|
<p><label>Passwort<br><input type="password" name="password" required></label></p>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user