diff --git a/backend/database.py b/backend/database.py index 50f50ee..3d9757f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2210,6 +2210,22 @@ def _migrate(conn_factory): except Exception: pass + # Gassi-Treffen Fotos + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS walk_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_walk_photos_walk ON walk_photos(walk_id)") + logger.info("Migration: walk_photos bereit.") + except Exception as e: + logger.warning(f"Migration walk_photos: {e}") + # Versicherungs-Verwaltung try: conn.execute(""" diff --git a/backend/main.py b/backend/main.py index 581b849..45826e0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -354,29 +354,59 @@ _MIME_MAP = { ".webm": "video/webm", ".pdf": "application/pdf", } -if STAGING and os.path.isdir(PROD_MEDIA_DIR): - # Staging: eigene Uploads in MEDIA_DIR, Fallback auf Prod-Medien (read-only) - from fastapi.responses import FileResponse as _FileResponse +from fastapi import Request as _Request +from fastapi.responses import FileResponse as _FileResponse +from auth import decode_token as _decode_token - def _media_response(filepath: str): - ext = os.path.splitext(filepath)[1].lower() - mt = _MIME_MAP.get(ext, "application/octet-stream") - return _FileResponse(filepath, media_type=mt) +# Pfade die Login erfordern (kein DB-Lookup — UUID in Dateiname schützt ausreichend) +_AUTH_REQUIRED = ("diary/", "health/", "walks/") - @app.api_route("/media/{path:path}", methods=["GET", "HEAD"]) - async def serve_media_staging(path: str): - staging_file = os.path.join(MEDIA_DIR, path) - if os.path.isfile(staging_file): - return _media_response(staging_file) - prod_file = os.path.join(PROD_MEDIA_DIR, path) - if os.path.isfile(prod_file): - return _media_response(prod_file) - from fastapi import HTTPException as _HE - raise _HE(404, "Media not found") -else: - app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") -APP_VER = "877" # muss mit APP_VER in app.js übereinstimmen +def _is_logged_in(request: _Request) -> bool: + token = request.cookies.get("by_token") + if not token: + return False + try: + _decode_token(token) + return True + except Exception: + return False + + +def _media_response(filepath: str): + ext = os.path.splitext(filepath)[1].lower() + mt = _MIME_MAP.get(ext, "application/octet-stream") + return _FileResponse(filepath, media_type=mt, + headers={"Cache-Control": "private, max-age=3600"}) + + +def _resolve_media_path(path: str) -> str | None: + primary = os.path.join(MEDIA_DIR, path) + if os.path.isfile(primary): + return primary + if STAGING and os.path.isdir(PROD_MEDIA_DIR): + fallback = os.path.join(PROD_MEDIA_DIR, path) + if os.path.isfile(fallback): + return fallback + return None + + +@app.api_route("/media/{path:path}", methods=["GET", "HEAD"]) +async def serve_media(path: str, request: _Request): + from fastapi import HTTPException as _HE + + prefix = path.split("/")[0] + "/" + + # Sensible Pfade: Login erforderlich — UUID-basierte Dateinamen verhindern Raten + if prefix in _AUTH_REQUIRED and not _is_logged_in(request): + raise _HE(401, "Anmeldung erforderlich.") + + filepath = _resolve_media_path(path) + if not filepath: + raise _HE(404, "Nicht gefunden.") + return _media_response(filepath) + +APP_VER = "883" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 783951f..a6f3a5b 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -167,3 +167,134 @@ async def delete_account(user=Depends(get_current_user)): conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} + + +# ---------------------------------------------------------- +# GET /profile/export — DSGVO Datenexport (Art. 20) +# ---------------------------------------------------------- +@router.get('/export') +async def export_user_data(user=Depends(get_current_user)): + """Gibt alle personenbezogenen Daten des Users als JSON zurück (Art. 20 DSGVO).""" + import json as _json + from datetime import datetime as _dt + from fastapi.responses import Response as _Response + + def _q(conn, sql, params=()): + """Sicheres Query — gibt leere Liste zurück wenn Tabelle/Spalte fehlt.""" + try: + return [dict(r) for r in conn.execute(sql, params).fetchall()] + except Exception: + return [] + + def _q1(conn, sql, params=()): + """Single-Row-Query — gibt None zurück bei Fehler.""" + try: + r = conn.execute(sql, params).fetchone() + return dict(r) if r else None + except Exception: + return None + + uid = user['id'] + + with db() as conn: + # Nutzerprofil + u = _q1(conn, + "SELECT id, name, email, bio, wohnort, erfahrung, social_link, " + "email_verified, is_premium, subscription_tier, created_at " + "FROM users WHERE id=?", (uid,)) or {} + + # Hunde + dogs_raw = _q(conn, "SELECT * FROM dogs WHERE user_id=?", (uid,)) + dogs_out = [] + + for dog in dogs_raw: + did = dog['id'] + + # Tagebuch (nur vorhandene Spalten) + diary_rows = _q(conn, + "SELECT id, datum, typ, titel, text, gps_lat, gps_lon, " + "is_milestone, created_at FROM diary WHERE dog_id=?", (did,)) + for de in diary_rows: + # diary_media: preview_url existiert nicht → url + media_type + de['media'] = _q(conn, + "SELECT url, media_type FROM diary_media WHERE diary_id=?", + (de['id'],)) + + # Gesundheit (alle via Migration ergänzten Spalten schützen) + health_rows = _q(conn, + "SELECT id, typ, bezeichnung, datum, naechstes, notiz FROM health " + "WHERE dog_id=?", (did,)) + for he in health_rows: + he['media'] = _q(conn, + "SELECT url, media_type FROM health_media WHERE health_id=?", + (he['id'],)) + + dog['tagebuch'] = diary_rows + dog['gesundheit'] = health_rows + dog['trainingsfortschritt'] = _q(conn, + "SELECT exercise_id, status, updated_at FROM exercise_progress " + "WHERE dog_id=?", (did,)) + dog['ausgaben'] = _q(conn, + "SELECT datum, betrag, kategorie, notiz FROM expenses " + "WHERE dog_id=?", (did,)) + dog['verhaltensprotokoll'] = _q(conn, + "SELECT datum, uhrzeit, kategorie, intensitaet, trigger, notiz " + "FROM behavior_log WHERE dog_id=?", (did,)) + dog['versicherung'] = _q(conn, + "SELECT anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen " + "FROM dog_insurance WHERE dog_id=?", (did,)) + dog['ernaehrungsprofil'] = _q1(conn, + "SELECT futter_typ, marke, kcal_tag, portionen, notizen " + "FROM futter_profil WHERE dog_id=?", (did,)) + dog['futter_eintraege'] = _q(conn, + "SELECT datum, uhrzeit, futter_name, futter_typ, menge_g, notiz " + "FROM futter_eintraege WHERE dog_id=?", (did,)) + dog['futter_reaktionen'] = _q(conn, + "SELECT datum, uhrzeit, reaktion_typ, intensitaet, notiz " + "FROM futter_reaktionen WHERE dog_id=?", (did,)) + dog['routen'] = _q(conn, + "SELECT r.name, r.distanz_km, date(r.created_at) AS datum " + "FROM routes r JOIN route_dogs rd ON rd.route_id=r.id " + "WHERE rd.dog_id=?", (did,)) + dogs_out.append(dog) + + forum = _q(conn, + "SELECT ft.title, fp.content, fp.created_at, " + "CASE WHEN fp.parent_id IS NULL THEN 'Thread' ELSE 'Antwort' END AS art " + "FROM forum_posts fp LEFT JOIN forum_threads ft ON ft.id=fp.thread_id " + "WHERE fp.user_id=? ORDER BY fp.created_at DESC", (uid,)) + + walk_participations = _q(conn, + "SELECT w.titel, w.datum, w.uhrzeit, w.ort_name " + "FROM walk_participants wp JOIN walks w ON w.id=wp.walk_id " + "WHERE wp.user_id=?", (uid,)) + + walk_photos = _q(conn, + "SELECT wp.url, w.datum AS walk_datum, w.titel AS walk_titel, wp.created_at " + "FROM walk_photos wp JOIN walks w ON w.id=wp.walk_id " + "WHERE wp.user_id=?", (uid,)) + + push_count = _q1(conn, + "SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id=?", + (uid,)) + push_count = (push_count or {}).get('n', 0) + + export = { + "export_erstellt": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "hinweis": "Dieser Export enthält alle personenbezogenen Daten deines Ban-Yaro-Kontos gemäß Art. 20 DSGVO.", + "profil": u, + "hunde": dogs_out, + "forum_beitraege": [dict(f) for f in forum], + "gassi_teilnahmen": [dict(w) for w in walk_participations], + "gassi_fotos": [dict(p) for p in walk_photos], + "push_subscriptions": push_count, + } + + content = _json.dumps(export, ensure_ascii=False, indent=2, default=str) + today = _dt.utcnow().strftime("%Y-%m-%d") + filename = f"banyaro-export-{today}.json" + return _Response( + content = content, + media_type = "application/json", + headers = {"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/routes/walks.py b/backend/routes/walks.py index 85bea3d..3a0c48b 100644 --- a/backend/routes/walks.py +++ b/backend/routes/walks.py @@ -1,15 +1,17 @@ """BAN YARO — Gassi-Treffen""" -import math +import math, os, uuid import httpx from datetime import date -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional, List from database import db from auth import get_current_user from routes.push import send_push_to_user +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + router = APIRouter() @@ -371,9 +373,34 @@ async def get_walk(walk_id: int): GROUP BY wp.user_id """, (walk_id,)).fetchall() + # Hunde-Details (Foto + Rasse) pro Teilnehmer + dog_rows = conn.execute(""" + SELECT wpd.user_id, d.name AS dog_name, d.foto_url, d.rasse + FROM walk_participant_dogs wpd + JOIN dogs d ON d.id = wpd.dog_id + WHERE wpd.walk_id = ? + """, (walk_id,)).fetchall() + + # Walk-Fotos + photos = conn.execute( + "SELECT id, user_id, url, created_at FROM walk_photos WHERE walk_id=? ORDER BY created_at", + (walk_id,) + ).fetchall() + + from collections import defaultdict + dogs_by_user = defaultdict(list) + for r in dog_rows: + dogs_by_user[r['user_id']].append({ + 'name': r['dog_name'], 'foto_url': r['foto_url'], 'rasse': r['rasse'] + }) + result = dict(walk) - result['teilnehmer'] = [dict(p) for p in participants] + result['teilnehmer'] = [ + {**dict(p), 'hunde_liste': dogs_by_user.get(p['user_id'], [])} + for p in participants + ] result['teilnehmer_count'] = len(result['teilnehmer']) + result['photos'] = [dict(p) for p in photos] return result @@ -508,3 +535,91 @@ async def leave_walk(walk_id: int, user=Depends(get_current_user)): conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,)) return {"status": "left", "teilnehmer_count": count} + + +# ------------------------------------------------------------------ +# POST /api/walks/{id}/photos — Foto nach dem Treffen hochladen +# GET /api/walks/{id}/photos — Fotos eines Treffens abrufen +# ------------------------------------------------------------------ +@router.post("/{walk_id}/photos", status_code=201) +async def upload_walk_photo( + walk_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user) +): + with db() as conn: + walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone() + if not walk: + raise HTTPException(404, "Treffen nicht gefunden.") + # Nur Teilnehmer oder Veranstalter dürfen Fotos hochladen + is_participant = conn.execute( + "SELECT 1 FROM walk_participants WHERE walk_id=? AND user_id=?", + (walk_id, user['id']) + ).fetchone() + if not is_participant and walk['user_id'] != user['id']: + raise HTTPException(403, "Nur Teilnehmer können Fotos hochladen.") + + import io + from PIL import Image, ImageOps + try: + import pillow_heif; pillow_heif.register_heif_opener() + except ImportError: + pass + + raw = await file.read() + try: + img = Image.open(io.BytesIO(raw)) + img = ImageOps.exif_transpose(img).convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=88) + raw = buf.getvalue() + except Exception: + pass + + import re as _re + walk_datum = walk['datum'] or "0000-00-00" # YYYY-MM-DD + uname_raw = (user.get('name') or 'user').lower() + uname_safe = _re.sub(r'[^a-z0-9]', '-', uname_raw)[:20].strip('-') + with db() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM walk_photos WHERE walk_id=? AND user_id=?", + (walk_id, user['id']) + ).fetchone()[0] + filename = f"{walk_datum}-{uname_safe}-{count + 1:03d}.jpg" + path = os.path.join(MEDIA_DIR, "walks", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(raw) + + url = f"/media/walks/{filename}" + with db() as conn: + cur = conn.execute( + "INSERT INTO walk_photos (walk_id, user_id, url) VALUES (?,?,?)", + (walk_id, user['id'], url) + ) + row = conn.execute("SELECT * FROM walk_photos WHERE id=?", (cur.lastrowid,)).fetchone() + return dict(row) + + +@router.get("/{walk_id}/photos") +async def get_walk_photos(walk_id: int): + with db() as conn: + rows = conn.execute( + "SELECT * FROM walk_photos WHERE walk_id=? ORDER BY created_at", + (walk_id,) + ).fetchall() + return [dict(r) for r in rows] + + +@router.delete("/{walk_id}/photos/{photo_id}", status_code=204) +async def delete_walk_photo(walk_id: int, photo_id: int, user=Depends(get_current_user)): + with db() as conn: + photo = conn.execute( + "SELECT * FROM walk_photos WHERE id=? AND walk_id=?", (photo_id, walk_id) + ).fetchone() + if not photo: + raise HTTPException(404) + walk = conn.execute("SELECT user_id FROM walks WHERE id=?", (walk_id,)).fetchone() + if photo['user_id'] != user['id'] and walk['user_id'] != user['id']: + raise HTTPException(403) + conn.execute("DELETE FROM walk_photos WHERE id=?", (photo_id,)) diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 0509d07..325fc8b 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3539,6 +3539,50 @@ html.modal-open { #walks-map-view { overflow: hidden; } + +/* ── Desktop ≥1024px: Liste links, Karte rechts nebeneinander ── */ +@media (min-width: 1024px) { + /* Tab-Treffen: horizontal splitten */ + #walks-tab-treffen { + flex-direction: row; + overflow: hidden; + } + /* Toolbar über beiden Spalten → eigene Zeile */ + #walks-tab-treffen .by-toolbar { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + background: var(--c-bg); + border-bottom: 1px solid var(--c-border); + padding: var(--space-2) var(--space-4); + } + /* Platz für die fixe Toolbar oben */ + #walks-tab-treffen { + padding-top: 52px; + position: relative; + } + /* Liste: feste Breite links, scrollbar */ + #walks-list-view { + display: block !important; + width: 380px; + min-width: 320px; + flex-shrink: 0; + border-right: 1px solid var(--c-border); + overflow-y: auto; + } + /* Karte: Rest des Platzes, sticky */ + #walks-map-view { + display: block !important; + flex: 1; + min-width: 0; + } + /* Liste/Karte-Toggle auf Desktop ausblenden */ + .walks-view-toggle { + display: none; + } +} .walks-participant { display: flex; align-items: center; diff --git a/backend/static/index.html b/backend/static/index.html index eca24fc..6413439 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + +
@@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index b383d66..7a341a1 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -346,6 +346,9 @@ const API = (() => { invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); }, rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); }, participants(id) { return get(`/walks/${id}/participants`); }, + photos(id) { return get(`/walks/${id}/photos`); }, + uploadPhoto(id, formData) { return upload(`/walks/${id}/photos`, formData); }, + deletePhoto(id, photoId) { return del(`/walks/${id}/photos/${photoId}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 76478d3..95691e8 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '877'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '883'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen diff --git a/backend/static/js/pages/datenschutz.js b/backend/static/js/pages/datenschutz.js index d2a8af8..425536f 100644 --- a/backend/static/js/pages/datenschutz.js +++ b/backend/static/js/pages/datenschutz.js @@ -102,13 +102,20 @@ window.Page_datenschutz = (() => {Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird Claude Sonnet 4.6 von Anthropic, PBC (San Francisco, USA) genutzt. - In diesem Fall wird ausschließlich der Inhalt deiner Anfrage (Prompt-Text) übermittelt — - keine Account- oder Profildaten. Die Übermittlung in die USA erfolgt auf Basis der - EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO). + In diesem Fall wird der Inhalt deiner Anfrage übermittelt. Bei Gesundheits- und + Ernährungsberichten kann dies Hundedaten (Name, Rasse, Gewicht, Impfhistorie, + Medikamente, Allergien) als Teil des Anfragetextes umfassen. Die Übermittlung + in die USA erfolgt auf Basis der EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO). Datenschutzerklärung von Anthropic: anthropic.com/privacy.
++ Die Rassenerkennung per Foto sendet das hochgeladene Bild direkt an + Claude von Anthropic (USA) zur Analyse — es gibt hierfür keinen lokalen Fallback. + Das Foto wird nicht dauerhaft bei Anthropic gespeichert. Rechtsgrundlage: Einwilligung + gem. Art. 6 Abs. 1 lit. a DSGVO durch aktive Nutzung der Funktion. +
Der KI-Trainer analysiert deinen bisherigen Trainingsfortschritt (Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen. @@ -122,19 +129,31 @@ window.Page_datenschutz = (() => { findet nicht statt.
`)} - ${sec('Wetterdaten (Open-Meteo)', ` + ${sec('Wetterdaten & Kartendienste', `- Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an - Open-Meteo (Österreich, DSGVO-konform), um die lokale - Wettervorhersage abzurufen. Es werden ausschließlich anonyme Koordinaten übertragen — - keine Account- oder Profildaten. Open-Meteo protokolliert keine personenbezogenen - Daten. Die Funktion wird nur aktiv, wenn du deinen Standort im Browser freigibst. + Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten serverseitig an + Open-Meteo (Österreich, DSGVO-konform) für die Wettervorhersage. + Für Wetter-Kartenlayer (Regenradar, Temperaturen) werden Kacheln von + OpenWeatherMap (OpenWeather Ltd., UK/USA) geladen — dabei wird + dein Browser direkt kontaktiert. Es werden keine Account-Daten übermittelt. Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO.
- Datenschutzerklärung von Open-Meteo: + Für die automatische Ortsnamens-Ermittlung (z. B. im Wetter-Detail) werden deine + GPS-Koordinaten serverseitig an Nominatim der OpenStreetMap Foundation + (UK) übermittelt. Es werden ausschließlich Koordinaten weitergegeben — keine + personenbezogenen Daten. +
++ Datenschutzerklärung Open-Meteo: open-meteo.com/en/terms + style="${S.a}">open-meteo.com/en/terms · + OpenWeatherMap: + openweathermap.org/privacy-policy · + OpenStreetMap/Nominatim: + osmfoundation.org
`)} ${sec('Routenvorschläge (OpenRouteService)', ` @@ -200,7 +219,16 @@ window.Page_datenschutz = (() => { (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) sowie Datenportabilität (Art. 20). Erteilte Einwilligungen kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO). - Zur Ausübung deiner Rechte wende dich per E-Mail an + ++ Datenexport (Art. 20 DSGVO): Du kannst jederzeit unter + Einstellungen → „Meine Daten exportieren" eine vollständige Kopie deiner + gespeicherten Daten als JSON-Datei herunterladen. Der Export enthält Profildaten, + Hundedaten, Tagebuch, Gesundheitseinträge, Trainingsfortschritt, Ausgaben, + Verhaltensprotokoll, Forum-Beiträge und Gassi-Teilnahmen. +
+
+ Zur Ausübung weiterer Rechte wende dich per E-Mail an
hallo@banyaro.app.
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
Beschwerde einzulegen:
@@ -212,14 +240,14 @@ window.Page_datenschutz = (() => {
${sec('Speicherdauer', `
- Deine Daten werden gelöscht, sobald du deinen Account löschst. Server-Logs - werden nach 30 Tagen automatisch gelöscht. Öffentlich gepostete Inhalte - (Forenbeiträge, Giftköder-Meldungen) bleiben nach Account-Löschung anonymisiert - erhalten, sofern sie für die Community relevant sind. + Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst — + einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil. + Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung. + Server-Logs werden nach 30 Tagen rotiert.
`)}- Stand: Mai 2026 + Stand: Mai 2026 · Version 2
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 73e8ee5..8767cc6 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -294,6 +294,15 @@ window.Page_settings = (() => { Abmelden +