Feature+Security: DSGVO-Datenexport, auth-geschützte Media, Datenschutzerklärung v2 (SW by-v880)
This commit is contained in:
parent
465dc2e4d3
commit
bf1087c5e1
7 changed files with 264 additions and 27 deletions
|
|
@ -447,7 +447,7 @@ async def serve_media(path: str, request: _Request):
|
||||||
raise _HE(404, "Nicht gefunden.")
|
raise _HE(404, "Nicht gefunden.")
|
||||||
return _media_response(filepath)
|
return _media_response(filepath)
|
||||||
|
|
||||||
APP_VER = "879" # muss mit APP_VER in app.js übereinstimmen
|
APP_VER = "880" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
@app.get("/.well-known/assetlinks.json")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
|
||||||
|
|
@ -167,3 +167,177 @@ async def delete_account(user=Depends(get_current_user)):
|
||||||
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
|
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
|
||||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||||
return {"status": "deleted"}
|
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."""
|
||||||
|
import json as _json
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
from fastapi.responses import Response as _Response
|
||||||
|
uid = user['id']
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
# --- Nutzerprofil ---
|
||||||
|
u = dict(conn.execute(
|
||||||
|
"SELECT id, name, email, bio, wohnort, erfahrung, social_link, "
|
||||||
|
"email_verified, is_premium, subscription_tier, created_at "
|
||||||
|
"FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone() or {})
|
||||||
|
|
||||||
|
# --- Hunde ---
|
||||||
|
dogs_raw = conn.execute(
|
||||||
|
"SELECT * FROM dogs WHERE user_id=?", (uid,)
|
||||||
|
).fetchall()
|
||||||
|
dogs_out = []
|
||||||
|
|
||||||
|
for dog in dogs_raw:
|
||||||
|
did = dog['id']
|
||||||
|
d = dict(dog)
|
||||||
|
|
||||||
|
# Tagebuch
|
||||||
|
diary_rows = conn.execute(
|
||||||
|
"SELECT id, datum, typ, titel, text, gps_lat, gps_lon, "
|
||||||
|
"location_name, is_milestone, created_at FROM diary WHERE dog_id=?",
|
||||||
|
(did,)
|
||||||
|
).fetchall()
|
||||||
|
diary_out = []
|
||||||
|
for de in diary_rows:
|
||||||
|
de_dict = dict(de)
|
||||||
|
media = conn.execute(
|
||||||
|
"SELECT url, preview_url, media_type FROM diary_media WHERE diary_id=?",
|
||||||
|
(de['id'],)
|
||||||
|
).fetchall()
|
||||||
|
de_dict['media'] = [dict(m) for m in media]
|
||||||
|
diary_out.append(de_dict)
|
||||||
|
|
||||||
|
# Gesundheit
|
||||||
|
health_rows = conn.execute(
|
||||||
|
"SELECT id, typ, bezeichnung, datum, naechstes, notiz, "
|
||||||
|
"schweregrad, reaktion, dosierung, haeufigkeit, "
|
||||||
|
"tierarzt_name, charge_nr FROM health WHERE dog_id=?",
|
||||||
|
(did,)
|
||||||
|
).fetchall()
|
||||||
|
health_out = []
|
||||||
|
for he in health_rows:
|
||||||
|
he_dict = dict(he)
|
||||||
|
media = conn.execute(
|
||||||
|
"SELECT url, media_type FROM health_media WHERE health_id=?",
|
||||||
|
(he['id'],)
|
||||||
|
).fetchall()
|
||||||
|
he_dict['media'] = [dict(m) for m in media]
|
||||||
|
health_out.append(he_dict)
|
||||||
|
|
||||||
|
# Trainingsfortschritt
|
||||||
|
progress = conn.execute(
|
||||||
|
"SELECT exercise_id, status, updated_at FROM exercise_progress "
|
||||||
|
"WHERE dog_id=?", (did,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Ausgaben
|
||||||
|
expenses = conn.execute(
|
||||||
|
"SELECT datum, betrag, kategorie, notiz, is_recurring "
|
||||||
|
"FROM expenses WHERE dog_id=?", (did,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Verhalten
|
||||||
|
behavior = conn.execute(
|
||||||
|
"SELECT datum, uhrzeit, kategorie, intensitaet, trigger, notiz "
|
||||||
|
"FROM behavior_log WHERE dog_id=?", (did,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Versicherung
|
||||||
|
insurance = conn.execute(
|
||||||
|
"SELECT anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen "
|
||||||
|
"FROM dog_insurance WHERE dog_id=?", (did,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Ernährungs-Profil
|
||||||
|
ern = conn.execute(
|
||||||
|
"SELECT futter_typ, marke, kcal_tag, portionen, notizen "
|
||||||
|
"FROM futter_profil WHERE dog_id=?", (did,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
# Futter-Einträge
|
||||||
|
futter = conn.execute(
|
||||||
|
"SELECT datum, uhrzeit, futter_name, futter_typ, menge_g, notiz "
|
||||||
|
"FROM futter_eintraege WHERE dog_id=?", (did,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Futter-Reaktionen
|
||||||
|
reaktionen = conn.execute(
|
||||||
|
"SELECT datum, uhrzeit, reaktion_typ, intensitaet, notiz "
|
||||||
|
"FROM futter_reaktionen WHERE dog_id=?", (did,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Routen (via route_dogs)
|
||||||
|
routes = conn.execute(
|
||||||
|
"SELECT r.name, r.distanz_km, r.gps_track IS NOT NULL AS hat_track, "
|
||||||
|
"date(r.created_at) AS datum "
|
||||||
|
"FROM routes r JOIN route_dogs rd ON rd.route_id=r.id "
|
||||||
|
"WHERE rd.dog_id=?", (did,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
d['tagebuch'] = diary_out
|
||||||
|
d['gesundheit'] = [dict(h) for h in health_out]
|
||||||
|
d['trainingsfortschritt'] = [dict(p) for p in progress]
|
||||||
|
d['ausgaben'] = [dict(e) for e in expenses]
|
||||||
|
d['verhaltensprotokoll'] = [dict(b) for b in behavior]
|
||||||
|
d['versicherung'] = [dict(i) for i in insurance]
|
||||||
|
d['ernaehrungsprofil'] = dict(ern) if ern else None
|
||||||
|
d['futter_eintraege'] = [dict(f) for f in futter]
|
||||||
|
d['futter_reaktionen'] = [dict(r) for r in reaktionen]
|
||||||
|
d['routen'] = [dict(r) for r in routes]
|
||||||
|
dogs_out.append(d)
|
||||||
|
|
||||||
|
# --- Forum-Beiträge ---
|
||||||
|
forum = conn.execute(
|
||||||
|
"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,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# --- Gassi-Teilnahmen ---
|
||||||
|
walk_participations = conn.execute(
|
||||||
|
"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,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# --- Gassi-Fotos ---
|
||||||
|
walk_photos = conn.execute(
|
||||||
|
"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,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# --- Push-Subscriptions (Anzahl, kein raw endpoint) ---
|
||||||
|
push_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM push_subscriptions WHERE user_id=?", (uid,)
|
||||||
|
).fetchone()[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}"'},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=879">
|
<link rel="stylesheet" href="/css/design-system.css?v=880">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=879">
|
<link rel="stylesheet" href="/css/layout.css?v=880">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=879">
|
<link rel="stylesheet" href="/css/components.css?v=880">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -583,10 +583,10 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=879"></script>
|
<script src="/js/api.js?v=880"></script>
|
||||||
<script src="/js/ui.js?v=879"></script>
|
<script src="/js/ui.js?v=880"></script>
|
||||||
<script src="/js/app.js?v=879"></script>
|
<script src="/js/app.js?v=880"></script>
|
||||||
<script src="/js/worlds.js?v=879"></script>
|
<script src="/js/worlds.js?v=880"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '879'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '880'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||||
|
|
|
||||||
|
|
@ -102,13 +102,20 @@ window.Page_datenschutz = (() => {
|
||||||
<p style="${S.p};margin-top:var(--space-3)">
|
<p style="${S.p};margin-top:var(--space-3)">
|
||||||
Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird
|
Als Ausweichlösung bei Nichtverfügbarkeit des lokalen Modells wird
|
||||||
<strong>Claude Sonnet 4.6</strong> von Anthropic, PBC (San Francisco, USA) genutzt.
|
<strong>Claude Sonnet 4.6</strong> von Anthropic, PBC (San Francisco, USA) genutzt.
|
||||||
In diesem Fall wird ausschließlich der Inhalt deiner Anfrage (Prompt-Text) übermittelt —
|
In diesem Fall wird der Inhalt deiner Anfrage übermittelt. Bei Gesundheits- und
|
||||||
keine Account- oder Profildaten. Die Übermittlung in die USA erfolgt auf Basis der
|
Ernährungsberichten kann dies Hundedaten (Name, Rasse, Gewicht, Impfhistorie,
|
||||||
EU-Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO).
|
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:
|
Datenschutzerklärung von Anthropic:
|
||||||
<a href="https://www.anthropic.com/privacy" target="_blank" rel="noopener"
|
<a href="https://www.anthropic.com/privacy" target="_blank" rel="noopener"
|
||||||
style="${S.a}">anthropic.com/privacy</a>.
|
style="${S.a}">anthropic.com/privacy</a>.
|
||||||
</p>
|
</p>
|
||||||
|
<p style="${S.p};margin-top:var(--space-3)">
|
||||||
|
Die <strong>Rassenerkennung per Foto</strong> 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.
|
||||||
|
</p>
|
||||||
<p style="${S.p};margin-top:var(--space-3)">
|
<p style="${S.p};margin-top:var(--space-3)">
|
||||||
Der <strong>KI-Trainer</strong> analysiert deinen bisherigen Trainingsfortschritt
|
Der <strong>KI-Trainer</strong> analysiert deinen bisherigen Trainingsfortschritt
|
||||||
(Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen.
|
(Übungshistorie, Erfolgsquoten, Streaks) und gibt personalisierte Empfehlungen.
|
||||||
|
|
@ -122,19 +129,31 @@ window.Page_datenschutz = (() => {
|
||||||
findet nicht statt.
|
findet nicht statt.
|
||||||
</p>`)}
|
</p>`)}
|
||||||
|
|
||||||
${sec('Wetterdaten (Open-Meteo)', `
|
${sec('Wetterdaten & Kartendienste', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten einmalig an
|
Die Wetter-Funktion übermittelt auf Wunsch deine GPS-Koordinaten serverseitig an
|
||||||
<strong>Open-Meteo</strong> (Österreich, DSGVO-konform), um die lokale
|
<strong>Open-Meteo</strong> (Österreich, DSGVO-konform) für die Wettervorhersage.
|
||||||
Wettervorhersage abzurufen. Es werden ausschließlich anonyme Koordinaten übertragen —
|
Für Wetter-Kartenlayer (Regenradar, Temperaturen) werden Kacheln von
|
||||||
keine Account- oder Profildaten. Open-Meteo protokolliert keine personenbezogenen
|
<strong>OpenWeatherMap</strong> (OpenWeather Ltd., UK/USA) geladen — dabei wird
|
||||||
Daten. Die Funktion wird nur aktiv, wenn du deinen Standort im Browser freigibst.
|
dein Browser direkt kontaktiert. Es werden keine Account-Daten übermittelt.
|
||||||
Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO.
|
Rechtsgrundlage: Einwilligung gem. Art. 6 Abs. 1 lit. a DSGVO.
|
||||||
</p>
|
</p>
|
||||||
<p style="${S.p};margin-top:var(--space-3)">
|
<p style="${S.p};margin-top:var(--space-3)">
|
||||||
Datenschutzerklärung von Open-Meteo:
|
Für die automatische Ortsnamens-Ermittlung (z. B. im Wetter-Detail) werden deine
|
||||||
|
GPS-Koordinaten serverseitig an <strong>Nominatim</strong> der OpenStreetMap Foundation
|
||||||
|
(UK) übermittelt. Es werden ausschließlich Koordinaten weitergegeben — keine
|
||||||
|
personenbezogenen Daten.
|
||||||
|
</p>
|
||||||
|
<p style="${S.p};margin-top:var(--space-3)">
|
||||||
|
Datenschutzerklärung Open-Meteo:
|
||||||
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||||
style="${S.a}">open-meteo.com/en/terms</a>
|
style="${S.a}">open-meteo.com/en/terms</a> ·
|
||||||
|
OpenWeatherMap:
|
||||||
|
<a href="https://openweathermap.org/privacy-policy" target="_blank" rel="noopener"
|
||||||
|
style="${S.a}">openweathermap.org/privacy-policy</a> ·
|
||||||
|
OpenStreetMap/Nominatim:
|
||||||
|
<a href="https://osmfoundation.org/wiki/Privacy_Policy" target="_blank" rel="noopener"
|
||||||
|
style="${S.a}">osmfoundation.org</a>
|
||||||
</p>`)}
|
</p>`)}
|
||||||
|
|
||||||
${sec('Routenvorschläge (OpenRouteService)', `
|
${sec('Routenvorschläge (OpenRouteService)', `
|
||||||
|
|
@ -200,7 +219,16 @@ window.Page_datenschutz = (() => {
|
||||||
(Art. 16), <strong>Löschung</strong> (Art. 17), <strong>Einschränkung der Verarbeitung</strong>
|
(Art. 16), <strong>Löschung</strong> (Art. 17), <strong>Einschränkung der Verarbeitung</strong>
|
||||||
(Art. 18) sowie <strong>Datenportabilität</strong> (Art. 20). Erteilte Einwilligungen
|
(Art. 18) sowie <strong>Datenportabilität</strong> (Art. 20). Erteilte Einwilligungen
|
||||||
kannst du jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO).
|
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
|
</p>
|
||||||
|
<p style="${S.p};margin-top:var(--space-3)">
|
||||||
|
<strong>Datenexport (Art. 20 DSGVO):</strong> 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.
|
||||||
|
</p>
|
||||||
|
<p style="${S.p};margin-top:var(--space-3)">
|
||||||
|
Zur Ausübung weiterer Rechte wende dich per E-Mail an
|
||||||
<a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>.<br><br>
|
<a href="mailto:hallo@banyaro.app" style="${S.a}">hallo@banyaro.app</a>.<br><br>
|
||||||
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
|
Du hast außerdem das Recht, bei der zuständigen Datenschutz-Aufsichtsbehörde
|
||||||
Beschwerde einzulegen:<br>
|
Beschwerde einzulegen:<br>
|
||||||
|
|
@ -212,14 +240,14 @@ window.Page_datenschutz = (() => {
|
||||||
|
|
||||||
${sec('Speicherdauer', `
|
${sec('Speicherdauer', `
|
||||||
<p style="${S.p}">
|
<p style="${S.p}">
|
||||||
Deine Daten werden gelöscht, sobald du deinen Account löschst. Server-Logs
|
Deine Daten werden vollständig gelöscht, sobald du deinen Account löschst —
|
||||||
werden nach 30 Tagen automatisch gelöscht. Öffentlich gepostete Inhalte
|
einschließlich Tagebuch, Gesundheitseinträge, Fotos, Forenbeiträge und Hundeprofil.
|
||||||
(Forenbeiträge, Giftköder-Meldungen) bleiben nach Account-Löschung anonymisiert
|
Es gibt keine anonymisierte Weiterverarbeitung deiner Inhalte nach Account-Löschung.
|
||||||
erhalten, sofern sie für die Community relevant sind.
|
Server-Logs werden nach 30 Tagen rotiert.
|
||||||
</p>`)}
|
</p>`)}
|
||||||
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
||||||
Stand: Mai 2026
|
Stand: Mai 2026 · Version 2
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,15 @@ window.Page_settings = (() => {
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
|
||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
|
<button id="settings-export-btn"
|
||||||
|
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
|
||||||
|
gap:var(--space-2);padding:var(--space-2) var(--space-4);
|
||||||
|
border-radius:var(--radius-md);border:none;
|
||||||
|
background:none;color:var(--c-text-secondary);
|
||||||
|
font-size:var(--text-xs);cursor:pointer">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#download-simple"></use></svg>
|
||||||
|
Meine Daten exportieren (DSGVO Art. 20)
|
||||||
|
</button>
|
||||||
<button id="settings-delete-account-btn"
|
<button id="settings-delete-account-btn"
|
||||||
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
|
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
|
||||||
gap:var(--space-2);padding:var(--space-2) var(--space-4);
|
gap:var(--space-2);padding:var(--space-2) var(--space-4);
|
||||||
|
|
@ -892,6 +901,32 @@ window.Page_settings = (() => {
|
||||||
_render();
|
_render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-export-btn')?.addEventListener('click', async () => {
|
||||||
|
const btn = document.getElementById('settings-export-btn');
|
||||||
|
await UI.asyncButton(btn, async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/profile/export', {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Export fehlgeschlagen.');
|
||||||
|
const data = await resp.json();
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `banyaro-export-${new Date().toISOString().slice(0,10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
UI.toast.success('Export heruntergeladen.');
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error(err.message || 'Fehler beim Export.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
|
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title: 'Konto unwiderruflich löschen?',
|
title: 'Konto unwiderruflich löschen?',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v879';
|
const CACHE_VERSION = 'by-v880';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue