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:
@@ -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()
|
||||
|
||||
100
app/main.py
100
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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }} · {{ link_count }} Link{{ '' if link_count == 1 else 'e' }}
|
||||
Angemeldet als {{ user.email }} · {{ 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">
|
||||
|
||||
54
app/templates/settings_users.html
Normal file
54
app/templates/settings_users.html
Normal 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' }} ·
|
||||
{{ row.prompt_count }} Prompt{{ '' if row.prompt_count == 1 else 's' }} ·
|
||||
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 %}
|
||||
Reference in New Issue
Block a user