Add invite-code registration and admin user management

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 <noreply@anthropic.com>
This commit is contained in:
Erik Thiele
2026-07-31 11:16:01 +02:00
parent 517652708a
commit 604c4ea9c2
8 changed files with 181 additions and 9 deletions

View File

@@ -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=

View File

@@ -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.

View File

@@ -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()

View File

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

View File

@@ -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(

View File

@@ -14,6 +14,12 @@
<label>Passwort (min. 6 Zeichen)</label>
<input type="password" name="password" required minlength="6">
</div>
{% if registration_code_required %}
<div class="field">
<label>Einladungscode</label>
<input type="text" name="registration_code" required autocomplete="off">
</div>
{% endif %}
<button type="submit" style="width:100%">Registrieren</button>
</form>
<p class="muted" style="margin-top:16px">

View File

@@ -8,18 +8,29 @@
<main class="main">
<h1 style="margin-top:0;">Einstellungen</h1>
<p class="muted" style="margin-top:-8px;">
Angemeldet als {{ user.email }} &middot; {{ link_count }} Link{{ '' if link_count == 1 else 'e' }}
Angemeldet als {{ user.email }} &middot; {{ link_count }} Link{{ '' if link_count == 1 else 's' }}
</p>
{% if imported is not none %}
<div class="panel" style="border-color:#16a34a;">
{{ 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.
</div>
{% endif %}
{% if import_error %}
<div class="error">{{ import_error }}</div>
{% endif %}
{% if user.is_admin %}
<div class="panel">
<h2 style="margin-top:0; font-size:1.05rem;">Benutzerverwaltung</h2>
<p class="muted">
{{ user_count }} {{ 'registriertes Konto' if user_count == 1 else 'registrierte Konten' }}.
Konten ansehen oder entfernen.
</p>
<a href="/settings/users"><button type="button">{{ icons.user(size=14) }} Benutzer verwalten</button></a>
</div>
{% endif %}
<div class="panel">
<h2 style="margin-top:0; font-size:1.05rem;">CSV-Export</h2>
<p class="muted">

View File

@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% import "_icons.html" as icons %}
{% block title %}Benutzerverwaltung · LinkVault{% endblock %}
{% block body %}
{% include "_topbar.html" %}
<div class="layout" style="max-width:820px; margin-left:auto; margin-right:auto;">
<main class="main">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;">
<a href="/settings" class="ghost icon-btn" title="Zurück zu den Einstellungen">{{ icons.grid(size=14) }}</a>
<h1 style="margin:0;">Benutzerverwaltung</h1>
</div>
<p class="muted" style="margin-top:0;">
{{ rows | length }} {{ 'Konto' if rows | length == 1 else 'Konten' }} insgesamt.
</p>
{% if delete_error %}
<div class="error">{{ delete_error }}</div>
{% endif %}
<div class="panel" style="padding:0; overflow:hidden;">
{% for row in rows %}
<div style="display:flex; align-items:center; gap:12px; padding:14px 16px; {% if not loop.last %}border-bottom:1px solid var(--border);{% endif %}">
<span class="card-avatar" style="--h: {{ row.user.email | texthue }};">
{{ row.user.email[:1] | upper }}
</span>
<div style="flex:1; min-width:0;">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<strong style="overflow:hidden; text-overflow:ellipsis;">{{ row.user.email }}</strong>
{% if row.user.is_admin %}
<span class="badge badge-cat">Admin</span>
{% endif %}
{% if row.user.id == user.id %}
<span class="badge badge-tag">Du</span>
{% endif %}
</div>
<div class="muted" style="font-size:.8rem; margin-top:2px;">
{{ row.link_count }} Link{{ '' if row.link_count == 1 else 's' }} &middot;
{{ row.prompt_count }} Prompt{{ '' if row.prompt_count == 1 else 's' }} &middot;
seit {{ row.user.created_at | datum }}
</div>
</div>
{% if row.user.id != user.id %}
<form method="post" action="/settings/users/{{ row.user.id }}/delete" style="margin:0;"
onsubmit="return confirm('Konto {{ row.user.email }} inklusive aller Links und Prompts wirklich löschen?');">
<button type="submit" class="danger icon-btn" title="Konto löschen">{{ icons.trash() }}</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
</main>
</div>
{% endblock %}