From 604c4ea9c20906530369a766a65d9afae71786ab Mon Sep 17 00:00:00 2001 From: Erik Thiele Date: Fri, 31 Jul 2026 11:16:01 +0200 Subject: [PATCH] Add invite-code registration and admin user management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open registration let anyone with the URL create an account. Two changes address that: - REGISTRATION_CODE (.env, optional): when set, registration requires entering it correctly. Empty/unset keeps registration open, so existing installs are unaffected until configured. - is_admin flag on User: the first account ever created on an install becomes admin automatically (existing installs get their oldest account promoted via the startup migration, so nobody is locked out of user management after upgrading). Admins get a new "Benutzerverwaltung" panel in Einstellungen listing every account (email, link/prompt counts, join date) with a delete button per account — deleting cascades to that user's links and prompts via the existing relationship cascade. Deleting your own account through this page is blocked (redirects with an error) to avoid accidental admin lockout. Non-admins get a 403 on the /settings/users routes. Also fixes several pre-existing German pluralization bugs found while writing the new counts ("2 Linke" -> "2 Links", "Kontoen"/"Konton" -> "Konten") — irregular plurals need a full word swap, not a suffix. Co-Authored-By: Claude Sonnet 5 --- .env.example | 5 ++ README.md | 5 +- app/config.py | 4 ++ app/main.py | 100 ++++++++++++++++++++++++++++-- app/models.py | 1 + app/templates/register.html | 6 ++ app/templates/settings.html | 15 ++++- app/templates/settings_users.html | 54 ++++++++++++++++ 8 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 app/templates/settings_users.html diff --git a/.env.example b/.env.example index 009db5e..bd1e813 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,8 @@ DATABASE_URL=sqlite:///./linkvault.db # Versionsangabe für die Fußzeile (Standard: 2.1.0) APP_VERSION=2.1.0 + +# Wenn gesetzt, muss dieser Code bei der Registrierung eingegeben werden +# (verhindert, dass Fremde sich einfach so ein Konto anlegen). Leer lassen, +# um die Registrierung offen zu lassen. +REGISTRATION_CODE= diff --git a/README.md b/README.md index 8cdf85c..a985fcf 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ Datenbank, per Menü umschaltbar. exportieren, SQL-Dump auch wieder importieren. - 🌗 **Dark/Light-Modus** über den Umschalter im Kopfbereich. - 👥 **Mehrbenutzer** – Registrierung/Login, jeder Nutzer sieht nur seine - eigenen Links und Prompts. + eigenen Links und Prompts. Registrierung optional per Einladungscode + schützbar (`REGISTRATION_CODE` in der `.env`); das zuerst angelegte Konto + wird automatisch Admin und kann unter Einstellungen → Benutzerverwaltung + weitere Konten einsehen und löschen. Die Fußzeile zeigt Autor, Version und den Hostnamen des Servers. Die Version lässt sich über `APP_VERSION` in der `.env` setzen. diff --git a/app/config.py b/app/config.py index d5c42e4..619b673 100644 --- a/app/config.py +++ b/app/config.py @@ -18,3 +18,7 @@ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./linkvault.db") # KI-Funktionen (Kategorisierung, Zusammenfassung, semantische Suche) # sind nur aktiv, wenn ein API-Key hinterlegt ist. AI_ENABLED = bool(OPENAI_API_KEY) + +# Ist ein Code gesetzt, muss er bei der Registrierung korrekt eingegeben +# werden. Leer = Registrierung bleibt für alle offen (Standardverhalten). +REGISTRATION_CODE = os.getenv("REGISTRATION_CODE", "").strip() diff --git a/app/main.py b/app/main.py index d13d2aa..d6fff02 100644 --- a/app/main.py +++ b/app/main.py @@ -22,12 +22,27 @@ Base.metadata.create_all(bind=engine) # im Projekt): create_all legt nur neue Tabellen an, keine neuen Spalten. if config.DATABASE_URL.startswith("sqlite"): with engine.begin() as _conn: - _cols = {row[1] for row in _conn.execute(text("PRAGMA table_info(links)"))} - if "needs_review" not in _cols: + _link_cols = {row[1] for row in _conn.execute(text("PRAGMA table_info(links)"))} + if "needs_review" not in _link_cols: _conn.execute( text("ALTER TABLE links ADD COLUMN needs_review BOOLEAN DEFAULT 0") ) + _user_cols = {row[1] for row in _conn.execute(text("PRAGMA table_info(users)"))} + if "is_admin" not in _user_cols: + _conn.execute( + text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0") + ) + # Bestehende Installationen hatten noch keinen Admin – ältestes + # Konto übernimmt die Rolle, damit die Benutzerverwaltung + # nutzbar bleibt, ohne die Datenbank von Hand anfassen zu müssen. + _conn.execute( + text( + "UPDATE users SET is_admin = 1 WHERE id = " + "(SELECT id FROM users ORDER BY created_at LIMIT 1)" + ) + ) + app = FastAPI(title="LinkVault") app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14) @@ -255,7 +270,12 @@ def login( @app.get("/register", response_class=HTMLResponse) def register_form(request: Request): return templates.TemplateResponse( - "register.html", {"request": request, "error": None} + "register.html", + { + "request": request, + "error": None, + "registration_code_required": bool(config.REGISTRATION_CODE), + }, ) @@ -264,25 +284,37 @@ def register( request: Request, email: str = Form(...), password: str = Form(...), + registration_code: str = Form(""), db: Session = Depends(get_db), ): + ctx = { + "request": request, + "registration_code_required": bool(config.REGISTRATION_CODE), + } email = email.strip().lower() if not email or len(password) < 6: return templates.TemplateResponse( "register.html", { - "request": request, + **ctx, "error": "Bitte gültige E-Mail und Passwort (min. 6 Zeichen) angeben.", }, status_code=400, ) + if config.REGISTRATION_CODE and registration_code.strip() != config.REGISTRATION_CODE: + return templates.TemplateResponse( + "register.html", + {**ctx, "error": "Ungültiger Einladungscode."}, + status_code=400, + ) if db.scalar(select(User).where(User.email == email)): return templates.TemplateResponse( "register.html", - {"request": request, "error": "Diese E-Mail ist bereits registriert."}, + {**ctx, "error": "Diese E-Mail ist bereits registriert."}, status_code=400, ) - user = User(email=email, password_hash=hash_password(password)) + is_first_user = db.scalar(select(func.count()).select_from(User)) == 0 + user = User(email=email, password_hash=hash_password(password), is_admin=is_first_user) db.add(user) db.commit() request.session["user_id"] = user.id @@ -812,12 +844,14 @@ def settings_page(request: Request, db: Session = Depends(get_db)): link_count = db.scalar( select(func.count()).select_from(Link).where(Link.user_id == user.id) ) + user_count = db.scalar(select(func.count()).select_from(User)) if user.is_admin else None return templates.TemplateResponse( "settings.html", { "request": request, "user": user, "link_count": link_count or 0, + "user_count": user_count, "ai_enabled": config.AI_ENABLED, "imported": request.query_params.get("imported"), "import_error": request.query_params.get("import_error"), @@ -825,6 +859,60 @@ def settings_page(request: Request, db: Session = Depends(get_db)): ) +# --------------------------------------------------------------------------- +# Einstellungen: Benutzerverwaltung (nur Admin) +# --------------------------------------------------------------------------- +@app.get("/settings/users", response_class=HTMLResponse) +def list_users(request: Request, db: Session = Depends(get_db)): + user = current_user(request, db) + if not user: + return RedirectResponse("/login", status_code=303) + if not user.is_admin: + return Response(status_code=403) + + rows = [] + for u in db.scalars(select(User).order_by(User.created_at)).all(): + link_count = db.scalar( + select(func.count()).select_from(Link).where(Link.user_id == u.id) + ) + prompt_count = db.scalar( + select(func.count()).select_from(Prompt).where(Prompt.user_id == u.id) + ) + rows.append({"user": u, "link_count": link_count or 0, "prompt_count": prompt_count or 0}) + + return templates.TemplateResponse( + "settings_users.html", + { + "request": request, + "user": user, + "ai_enabled": config.AI_ENABLED, + "rows": rows, + "delete_error": request.query_params.get("delete_error"), + }, + ) + + +@app.post("/settings/users/{user_id}/delete") +def delete_user(user_id: int, request: Request, db: Session = Depends(get_db)): + user = current_user(request, db) + if not user: + return RedirectResponse("/login", status_code=303) + if not user.is_admin: + return Response(status_code=403) + + if user_id == user.id: + return RedirectResponse( + f"/settings/users?delete_error={quote('Das eigene Konto kann hier nicht gelöscht werden.')}", + status_code=303, + ) + + target = db.get(User, user_id) + if target: + db.delete(target) + db.commit() + return RedirectResponse("/settings/users", status_code=303) + + @app.get("/settings/export.csv") def export_csv(request: Request, db: Session = Depends(get_db)): user = current_user(request, db) diff --git a/app/models.py b/app/models.py index 7e93a11..8ea2494 100644 --- a/app/models.py +++ b/app/models.py @@ -17,6 +17,7 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(255), unique=True, index=True) password_hash: Mapped[str] = mapped_column(String(255)) + is_admin: Mapped[bool] = mapped_column(default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) links: Mapped[list["Link"]] = relationship( diff --git a/app/templates/register.html b/app/templates/register.html index f80b50e..81696cd 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -14,6 +14,12 @@ + {% if registration_code_required %} +
+ + +
+ {% endif %}

diff --git a/app/templates/settings.html b/app/templates/settings.html index a26e13e..9976fad 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -8,18 +8,29 @@

Einstellungen

- Angemeldet als {{ user.email }} · {{ link_count }} Link{{ '' if link_count == 1 else 'e' }} + Angemeldet als {{ user.email }} · {{ link_count }} Link{{ '' if link_count == 1 else 's' }}

{% if imported is not none %}
- {{ icons.sparkles(size=15) }} {{ imported }} Link{{ '' if imported == '1' else 'e' }} erfolgreich importiert. + {{ icons.sparkles(size=15) }} {{ imported }} Link{{ '' if imported == '1' else 's' }} erfolgreich importiert.
{% endif %} {% if import_error %}
{{ import_error }}
{% endif %} + {% if user.is_admin %} +
+

Benutzerverwaltung

+

+ {{ user_count }} {{ 'registriertes Konto' if user_count == 1 else 'registrierte Konten' }}. + Konten ansehen oder entfernen. +

+ +
+ {% endif %} +

CSV-Export

diff --git a/app/templates/settings_users.html b/app/templates/settings_users.html new file mode 100644 index 0000000..8a4f10a --- /dev/null +++ b/app/templates/settings_users.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% import "_icons.html" as icons %} +{% block title %}Benutzerverwaltung · LinkVault{% endblock %} +{% block body %} +{% include "_topbar.html" %} + +

+
+
+ {{ icons.grid(size=14) }} +

Benutzerverwaltung

+
+

+ {{ rows | length }} {{ 'Konto' if rows | length == 1 else 'Konten' }} insgesamt. +

+ + {% if delete_error %} +
{{ delete_error }}
+ {% endif %} + +
+ {% for row in rows %} +
+ + {{ row.user.email[:1] | upper }} + +
+
+ {{ row.user.email }} + {% if row.user.is_admin %} + Admin + {% endif %} + {% if row.user.id == user.id %} + Du + {% endif %} +
+
+ {{ row.link_count }} Link{{ '' if row.link_count == 1 else 's' }} · + {{ row.prompt_count }} Prompt{{ '' if row.prompt_count == 1 else 's' }} · + seit {{ row.user.created_at | datum }} +
+
+ {% if row.user.id != user.id %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+
+
+{% endblock %}