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 %}