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 %}
+
+ {% 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" %}
+
+
+
+
+
+ {{ 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 %}