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

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