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:
@@ -40,6 +40,7 @@ from db import (
|
||||
from security import (
|
||||
IMPORT_LIMIT,
|
||||
VIEWER_CREATE_LIMIT,
|
||||
VIEWER_DELETE_LIMIT,
|
||||
configure_app,
|
||||
external_base_url,
|
||||
limiter,
|
||||
@@ -48,6 +49,7 @@ from security import (
|
||||
from viewers import (
|
||||
LOCAL_VIEWER_ID,
|
||||
create_viewer,
|
||||
delete_viewer,
|
||||
ensure_local_viewer,
|
||||
get_or_create_cli_viewer,
|
||||
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")
|
||||
def api_goal_groups(viewer_id: str):
|
||||
db_path = _resolve_viewer_db(viewer_id)
|
||||
|
||||
@@ -15,6 +15,7 @@ limiter = Limiter(key_func=get_remote_address, storage_uri="memory://")
|
||||
|
||||
DEFAULT_MAX_UPLOAD_MB = 10
|
||||
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")
|
||||
|
||||
|
||||
|
||||
+44
-1
@@ -71,6 +71,7 @@ async function init() {
|
||||
setupUpload();
|
||||
setupExport();
|
||||
setupViewerDbImport();
|
||||
setupViewerDelete();
|
||||
setupGlobalSearch();
|
||||
setupGoalModal();
|
||||
await loadData();
|
||||
@@ -145,7 +146,7 @@ function activateTab(tab) {
|
||||
document.getElementById(`tab-${tab}`).classList.add("active");
|
||||
if (tab === "history") loadHistoryTab();
|
||||
if (tab === "goals") trackEvent("Goals Tab");
|
||||
if (tab === "backup") trackEvent("Backup Tab");
|
||||
if (tab === "backup") trackEvent("Data Tab");
|
||||
}
|
||||
|
||||
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() {
|
||||
const input = document.getElementById("global-search");
|
||||
if (!input) return;
|
||||
|
||||
+11
-2
@@ -13,6 +13,7 @@
|
||||
"quests": "Quests",
|
||||
"combat": "Kampf",
|
||||
"history": "Verlauf",
|
||||
"data": "Daten",
|
||||
"backup": "Backup"
|
||||
},
|
||||
"settings": {
|
||||
@@ -266,7 +267,7 @@
|
||||
"installed": "App installiert – öffne sie über Startbildschirm oder App-Liste."
|
||||
},
|
||||
"viewerDb": {
|
||||
"title": "Viewer-Backup",
|
||||
"title": "Viewer-Daten",
|
||||
"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.",
|
||||
"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?",
|
||||
"importSuccess": "Viewer wiederhergestellt: {snapshots} Snapshot(s), {goals} Ziel(e).",
|
||||
"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": {
|
||||
"filterAll": "Alle",
|
||||
|
||||
+11
-2
@@ -13,6 +13,7 @@
|
||||
"quests": "Quests",
|
||||
"combat": "Combat",
|
||||
"history": "History",
|
||||
"data": "Data",
|
||||
"backup": "Backup"
|
||||
},
|
||||
"settings": {
|
||||
@@ -266,7 +267,7 @@
|
||||
"installed": "App installed – open it from your home screen or app list."
|
||||
},
|
||||
"viewerDb": {
|
||||
"title": "Viewer backup",
|
||||
"title": "Viewer data",
|
||||
"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).",
|
||||
"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?",
|
||||
"importSuccess": "Viewer restored: {snapshots} snapshot(s), {goals} goal(s).",
|
||||
"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": {
|
||||
"filterAll": "All",
|
||||
|
||||
@@ -1261,6 +1261,51 @@ tr.has-goal td:first-child { font-weight: 600; }
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<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="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>
|
||||
<div class="sidebar-footer">
|
||||
{% include '_pwa_hint.html' %}
|
||||
@@ -89,7 +89,7 @@
|
||||
<section class="tab-panel" id="tab-history"></section>
|
||||
<section class="tab-panel" id="tab-backup">
|
||||
<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-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">
|
||||
@@ -100,6 +100,13 @@
|
||||
</label>
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+13
@@ -135,3 +135,16 @@ def restore_viewer_db(source: Path, target: Path) -> dict[str, int]:
|
||||
staging.unlink(missing_ok=True)
|
||||
raise
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user