Add viewer delete danger zone and rename backup tab to Data.

Let users permanently delete their viewer from the data tab with confirmation, while keeping the local dev viewer protected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-05 11:37:58 +02:00
co-authored by Cursor
parent bf9108c76d
commit fd364218ec
8 changed files with 154 additions and 7 deletions
+20
View File
@@ -40,6 +40,7 @@ from db import (
from security import ( from security import (
IMPORT_LIMIT, IMPORT_LIMIT,
VIEWER_CREATE_LIMIT, VIEWER_CREATE_LIMIT,
VIEWER_DELETE_LIMIT,
configure_app, configure_app,
external_base_url, external_base_url,
limiter, limiter,
@@ -48,6 +49,7 @@ from security import (
from viewers import ( from viewers import (
LOCAL_VIEWER_ID, LOCAL_VIEWER_ID,
create_viewer, create_viewer,
delete_viewer,
ensure_local_viewer, ensure_local_viewer,
get_or_create_cli_viewer, get_or_create_cli_viewer,
is_valid_viewer_id, is_valid_viewer_id,
@@ -237,6 +239,24 @@ def api_export_viewer(viewer_id: str):
) )
@viewer_bp.route("/api/viewer", methods=["DELETE"])
@limiter.limit(VIEWER_DELETE_LIMIT)
def api_delete_viewer(viewer_id: str):
if viewer_id == LOCAL_VIEWER_ID:
return jsonify({"error": "Local viewer cannot be deleted"}), 403
if not is_valid_viewer_id(viewer_id):
abort(404)
db_path = viewer_db_path(viewer_id, get_data_dir())
if not db_path.exists():
return jsonify({"error": "Viewer not found"}), 404
try:
if not delete_viewer(viewer_id, get_data_dir()):
return jsonify({"error": "Viewer not found"}), 404
except ValueError as exc:
return jsonify({"error": str(exc)}), 403
return jsonify({"deleted": True})
@viewer_bp.route("/api/goal-groups") @viewer_bp.route("/api/goal-groups")
def api_goal_groups(viewer_id: str): def api_goal_groups(viewer_id: str):
db_path = _resolve_viewer_db(viewer_id) db_path = _resolve_viewer_db(viewer_id)
+1
View File
@@ -15,6 +15,7 @@ limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
DEFAULT_MAX_UPLOAD_MB = 10 DEFAULT_MAX_UPLOAD_MB = 10
VIEWER_CREATE_LIMIT = os.environ.get("RATE_LIMIT_VIEWER_CREATE", "5 per minute") VIEWER_CREATE_LIMIT = os.environ.get("RATE_LIMIT_VIEWER_CREATE", "5 per minute")
VIEWER_DELETE_LIMIT = os.environ.get("RATE_LIMIT_VIEWER_DELETE", "5 per hour")
IMPORT_LIMIT = os.environ.get("RATE_LIMIT_IMPORT", "20 per hour") IMPORT_LIMIT = os.environ.get("RATE_LIMIT_IMPORT", "20 per hour")
+44 -1
View File
@@ -71,6 +71,7 @@ async function init() {
setupUpload(); setupUpload();
setupExport(); setupExport();
setupViewerDbImport(); setupViewerDbImport();
setupViewerDelete();
setupGlobalSearch(); setupGlobalSearch();
setupGoalModal(); setupGoalModal();
await loadData(); await loadData();
@@ -145,7 +146,7 @@ function activateTab(tab) {
document.getElementById(`tab-${tab}`).classList.add("active"); document.getElementById(`tab-${tab}`).classList.add("active");
if (tab === "history") loadHistoryTab(); if (tab === "history") loadHistoryTab();
if (tab === "goals") trackEvent("Goals Tab"); if (tab === "goals") trackEvent("Goals Tab");
if (tab === "backup") trackEvent("Backup Tab"); if (tab === "backup") trackEvent("Data Tab");
} }
function setupNav() { function setupNav() {
@@ -206,6 +207,48 @@ function setupViewerDbImport() {
}); });
} }
function setupViewerDelete() {
const zone = document.getElementById("viewer-danger-zone");
const btn = document.getElementById("viewer-delete-btn");
const errEl = document.getElementById("viewer-delete-error");
if (!zone || !btn) return;
const vid = getViewerId();
if (!vid || vid === "local") {
zone.hidden = true;
return;
}
zone.hidden = false;
btn.addEventListener("click", async () => {
if (errEl) errEl.hidden = true;
if (!confirm(t("viewerDb.deleteConfirm"))) return;
const expected = t("viewerDb.deleteConfirmWord");
const typed = prompt(t("viewerDb.deleteTypePrompt", { word: expected }));
if (typed !== expected) return;
btn.disabled = true;
try {
const res = await fetch(`${apiBase()}/viewer`, { method: "DELETE" });
const result = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(result.error || t("viewerDb.deleteFailed"));
}
trackEvent("Viewer Delete", { status: "deleted" });
window.location.href = "/";
} catch (err) {
if (errEl) {
errEl.textContent = err.message || t("viewerDb.deleteFailed");
errEl.hidden = false;
} else {
alert(err.message || t("viewerDb.deleteFailed"));
}
btn.disabled = false;
}
});
}
function setupGlobalSearch() { function setupGlobalSearch() {
const input = document.getElementById("global-search"); const input = document.getElementById("global-search");
if (!input) return; if (!input) return;
+11 -2
View File
@@ -13,6 +13,7 @@
"quests": "Quests", "quests": "Quests",
"combat": "Kampf", "combat": "Kampf",
"history": "Verlauf", "history": "Verlauf",
"data": "Daten",
"backup": "Backup" "backup": "Backup"
}, },
"settings": { "settings": {
@@ -266,7 +267,7 @@
"installed": "App installiert öffne sie über Startbildschirm oder App-Liste." "installed": "App installiert öffne sie über Startbildschirm oder App-Liste."
}, },
"viewerDb": { "viewerDb": {
"title": "Viewer-Backup", "title": "Viewer-Daten",
"helpTitle": "Was ist das?", "helpTitle": "Was ist das?",
"helpBody": "Der Viewer speichert importierte Spiel-Backups, Verlaufsdiagramme und Ziele in einer SQLite-Datenbank auf dem Server. Export lädt diese Datenbank als Backup herunter. Import stellt sie wieder her und ersetzt alle aktuellen Viewer-Daten. Das ist nicht dasselbe wie ein Spiel-Backup (.json) importieren.", "helpBody": "Der Viewer speichert importierte Spiel-Backups, Verlaufsdiagramme und Ziele in einer SQLite-Datenbank auf dem Server. Export lädt diese Datenbank als Backup herunter. Import stellt sie wieder her und ersetzt alle aktuellen Viewer-Daten. Das ist nicht dasselbe wie ein Spiel-Backup (.json) importieren.",
"gameBackupNote": "Um einen neuen Spielstand hinzuzufügen, nutze in der Sidebar „Spiel-Backup importieren“.", "gameBackupNote": "Um einen neuen Spielstand hinzuzufügen, nutze in der Sidebar „Spiel-Backup importieren“.",
@@ -274,7 +275,15 @@
"importConfirm": "Alle aktuellen Viewer-Daten (Snapshots, Verlauf, Ziele) werden durch die importierte .db-Datei ersetzt. Fortfahren?", "importConfirm": "Alle aktuellen Viewer-Daten (Snapshots, Verlauf, Ziele) werden durch die importierte .db-Datei ersetzt. Fortfahren?",
"importSuccess": "Viewer wiederhergestellt: {snapshots} Snapshot(s), {goals} Ziel(e).", "importSuccess": "Viewer wiederhergestellt: {snapshots} Snapshot(s), {goals} Ziel(e).",
"importFailed": "Viewer konnte nicht importiert werden", "importFailed": "Viewer konnte nicht importiert werden",
"invalidFile": "Bitte eine .db-Datei auswählen (Viewer-Export)." "invalidFile": "Bitte eine .db-Datei auswählen (Viewer-Export).",
"dangerTitle": "Gefahrenzone",
"dangerLead": "Diesen Viewer und alle gespeicherten Daten dauerhaft löschen. Dein persönlicher Link funktioniert danach nicht mehr. Das kann nicht rückgängig gemacht werden.",
"exportBeforeDelete": "Exportiere den Viewer zuerst, wenn du eine Kopie deiner Daten behalten möchtest.",
"deleteViewer": "Viewer und alle Daten löschen",
"deleteConfirm": "Diesen Viewer wirklich dauerhaft löschen? Dein Link wird ungültig und alle Daten gehen verloren.",
"deleteTypePrompt": "Zur Bestätigung „{word}“ eingeben:",
"deleteConfirmWord": "LÖSCHEN",
"deleteFailed": "Viewer konnte nicht gelöscht werden"
}, },
"goals": { "goals": {
"filterAll": "Alle", "filterAll": "Alle",
+11 -2
View File
@@ -13,6 +13,7 @@
"quests": "Quests", "quests": "Quests",
"combat": "Combat", "combat": "Combat",
"history": "History", "history": "History",
"data": "Data",
"backup": "Backup" "backup": "Backup"
}, },
"settings": { "settings": {
@@ -266,7 +267,7 @@
"installed": "App installed open it from your home screen or app list." "installed": "App installed open it from your home screen or app list."
}, },
"viewerDb": { "viewerDb": {
"title": "Viewer backup", "title": "Viewer data",
"helpTitle": "What is this?", "helpTitle": "What is this?",
"helpBody": "The viewer stores imported game backups, history charts, and goals in a SQLite database on the server. Export downloads that database as a backup. Import restores it and replaces all current viewer data. This is not the same as importing a game save (.json).", "helpBody": "The viewer stores imported game backups, history charts, and goals in a SQLite database on the server. Export downloads that database as a backup. Import restores it and replaces all current viewer data. This is not the same as importing a game save (.json).",
"gameBackupNote": "To add a new game save snapshot, use Import game backup in the sidebar.", "gameBackupNote": "To add a new game save snapshot, use Import game backup in the sidebar.",
@@ -274,7 +275,15 @@
"importConfirm": "All current viewer data (snapshots, history, goals) will be replaced by the imported .db file. Continue?", "importConfirm": "All current viewer data (snapshots, history, goals) will be replaced by the imported .db file. Continue?",
"importSuccess": "Viewer restored: {snapshots} snapshot(s), {goals} goal(s).", "importSuccess": "Viewer restored: {snapshots} snapshot(s), {goals} goal(s).",
"importFailed": "Could not import viewer database", "importFailed": "Could not import viewer database",
"invalidFile": "Please select a .db file (viewer export)." "invalidFile": "Please select a .db file (viewer export).",
"dangerTitle": "Danger zone",
"dangerLead": "Permanently delete this viewer and all stored data. Your personal link will stop working. This cannot be undone.",
"exportBeforeDelete": "Export your viewer first if you want to keep a copy of your data.",
"deleteViewer": "Delete viewer and all data",
"deleteConfirm": "Really delete this viewer permanently? Your link will become invalid and all data will be lost.",
"deleteTypePrompt": "Type \"{word}\" to confirm:",
"deleteConfirmWord": "DELETE",
"deleteFailed": "Could not delete viewer"
}, },
"goals": { "goals": {
"filterAll": "All", "filterAll": "All",
+45
View File
@@ -1261,6 +1261,51 @@ tr.has-goal td:first-child { font-weight: 600; }
width: 100%; width: 100%;
} }
.danger-zone-card {
max-width: 560px;
margin-top: 20px;
border-color: rgba(248, 113, 113, 0.45);
background: rgba(248, 113, 113, 0.04);
}
.danger-zone-title {
margin: 0 0 10px;
font-size: 1.05rem;
color: var(--danger);
}
.danger-zone-lead,
.danger-zone-note {
margin: 0 0 12px;
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.5;
}
.danger-zone-note {
padding: 10px 12px;
border-radius: 8px;
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--text);
}
.danger-zone-btn {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--danger);
background: rgba(248, 113, 113, 0.12);
color: var(--danger);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
}
.danger-zone-btn:hover:not(:disabled) {
background: var(--danger);
color: #fff;
}
.danger-zone-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.goal-modal { .goal-modal {
position: fixed; position: fixed;
inset: 0; inset: 0;
+9 -2
View File
@@ -33,7 +33,7 @@
<button class="nav-btn" data-tab="quests" data-i18n="nav.quests">Quests</button> <button class="nav-btn" data-tab="quests" data-i18n="nav.quests">Quests</button>
<button class="nav-btn" data-tab="combat" data-i18n="nav.combat">Combat</button> <button class="nav-btn" data-tab="combat" data-i18n="nav.combat">Combat</button>
<button class="nav-btn" data-tab="history" data-i18n="nav.history">History</button> <button class="nav-btn" data-tab="history" data-i18n="nav.history">History</button>
<button class="nav-btn" data-tab="backup" data-i18n="nav.backup">Backup</button> <button class="nav-btn" data-tab="backup" data-i18n="nav.data">Data</button>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
{% include '_pwa_hint.html' %} {% include '_pwa_hint.html' %}
@@ -89,7 +89,7 @@
<section class="tab-panel" id="tab-history"></section> <section class="tab-panel" id="tab-history"></section>
<section class="tab-panel" id="tab-backup"> <section class="tab-panel" id="tab-backup">
<div class="card backup-tab-card"> <div class="card backup-tab-card">
<h3 data-i18n="viewerDb.title">Viewer backup</h3> <h3 data-i18n="viewerDb.title">Viewer data</h3>
<p class="backup-tab-lead" data-i18n="viewerDb.helpBody">The viewer stores imported game backups, history charts, and goals in a SQLite database on the server. Export downloads that database as a backup. Import restores it and replaces all current viewer data. This is not the same as importing a game save (.json).</p> <p class="backup-tab-lead" data-i18n="viewerDb.helpBody">The viewer stores imported game backups, history charts, and goals in a SQLite database on the server. Export downloads that database as a backup. Import restores it and replaces all current viewer data. This is not the same as importing a game save (.json).</p>
<p class="backup-tab-note" data-i18n="viewerDb.gameBackupNote">To add a new game save snapshot, use Import game backup in the sidebar.</p> <p class="backup-tab-note" data-i18n="viewerDb.gameBackupNote">To add a new game save snapshot, use Import game backup in the sidebar.</p>
<div class="backup-tab-actions"> <div class="backup-tab-actions">
@@ -100,6 +100,13 @@
</label> </label>
</div> </div>
</div> </div>
<div class="card danger-zone-card" id="viewer-danger-zone" hidden>
<h3 class="danger-zone-title" data-i18n="viewerDb.dangerTitle">Danger zone</h3>
<p class="danger-zone-lead" data-i18n="viewerDb.dangerLead">Permanently delete this viewer and all stored data. Your personal link will stop working. This cannot be undone.</p>
<p class="danger-zone-note" data-i18n="viewerDb.exportBeforeDelete">Export your viewer first if you want to keep a copy of your data.</p>
<button type="button" class="danger-zone-btn" id="viewer-delete-btn" data-i18n="viewerDb.deleteViewer">Delete viewer and all data</button>
<p class="landing-hint landing-hint-error" id="viewer-delete-error" hidden></p>
</div>
</section> </section>
</main> </main>
</div> </div>
+13
View File
@@ -135,3 +135,16 @@ def restore_viewer_db(source: Path, target: Path) -> dict[str, int]:
staging.unlink(missing_ok=True) staging.unlink(missing_ok=True)
raise raise
return stats return stats
def delete_viewer(viewer_id: str, data_dir: Path) -> bool:
"""Permanently delete a viewer database."""
if viewer_id == LOCAL_VIEWER_ID:
raise ValueError("Local viewer cannot be deleted")
if not is_valid_viewer_id(viewer_id):
raise ValueError("Invalid viewer id")
db_path = viewer_db_path(viewer_id, data_dir)
if not db_path.exists():
return False
db_path.unlink()
return True