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