feat(system-reset): add system reset and factory reset feature

- Add FACTORY_RESET_ON_STARTUP and ENABLE_FACTORY_RESET config settings
- Create app/utils/system_reset.py with core reset logic (wipe DB + files, reimport)
- Create app/api/system_reset.py with admin-only API endpoints
- Create app/views/system_reset.py with admin-only UI view
- Create frontend/templates/system_reset.html with confirmation dialogs
- Auto-reset on startup when FACTORY_RESET_ON_STARTUP=true
- Re-import uses watch folder mechanism for re-ingestion
- Register routers in API and views init files
- Add i18n keys and SETTING_METADATA entries
- Add nav links in base.html (desktop + mobile)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-16 22:26:17 +00:00
parent 4d302b495c
commit a88d790445
13 changed files with 812 additions and 0 deletions
+10
View File
@@ -177,6 +177,11 @@
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
</a>
{% if enable_factory_reset %}
<a href="/admin/system-reset" role="menuitem" class="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50">
<i class="fas fa-skull-crossbones w-4 mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
</a>
{% endif %}
<a href="/admin/audit-logs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-shield-halved w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Audit Logs
</a>
@@ -445,6 +450,11 @@
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
</a>
{% if enable_factory_reset %}
<a href="/admin/system-reset" class="block px-3 py-3 rounded-md text-base font-medium text-red-600 hover:text-red-800 hover:bg-red-50">
<i class="fas fa-skull-crossbones mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
</a>
{% endif %}
<a href="/status" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
<i class="fas fa-circle-dot mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.status") }}
+261
View File
@@ -0,0 +1,261 @@
{% extends "base.html" %}
{% block title %}{{ _("system_reset.page_title") }} - DocuElevate{% endblock %}
{% block head_extra %}
<style>
.reset-card { transition: box-shadow 0.2s ease; }
.reset-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
.confirmation-input { font-family: 'Courier New', monospace; letter-spacing: 0.1em; text-transform: uppercase; }
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="systemResetApp()">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-skull-crossbones mr-2 text-red-600" aria-hidden="true"></i>
{{ _("system_reset.heading") }}
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ _("system_reset.subtitle") }}
</p>
</div>
<!-- Factory Reset on Startup banner -->
{% if factory_reset_on_startup %}
<div class="mb-6 rounded-lg border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-700 p-4" role="alert">
<div class="flex items-start gap-3">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-semibold text-yellow-800 dark:text-yellow-200">{{ _("system_reset.startup_reset_active") }}</p>
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">{{ _("system_reset.startup_reset_desc") }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Warning banner -->
<div class="mb-8 rounded-lg border-2 border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-700 p-6" role="alert">
<div class="flex items-start gap-3">
<i class="fas fa-radiation text-red-600 text-2xl mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-bold text-red-800 dark:text-red-200 text-lg">{{ _("system_reset.danger_zone") }}</p>
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{{ _("system_reset.danger_desc") }}</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Full Reset Card -->
<div class="reset-card rounded-xl border-2 border-red-200 dark:border-red-800 bg-white dark:bg-gray-800 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center">
<i class="fas fa-trash-alt text-red-600" aria-hidden="true"></i>
</div>
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.full_reset_title") }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.full_reset_subtitle") }}</p>
</div>
</div>
<div class="space-y-3 mb-6">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.full_reset_desc") }}</p>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-disc">
<li>{{ _("system_reset.full_reset_item_db") }}</li>
<li>{{ _("system_reset.full_reset_item_files") }}</li>
<li>{{ _("system_reset.full_reset_item_cache") }}</li>
<li>{{ _("system_reset.full_reset_item_settings_kept") }}</li>
</ul>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label for="fullResetConfirm" class="block text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{{ _("system_reset.type_delete") }}
</label>
<input id="fullResetConfirm"
type="text"
x-model="fullResetInput"
class="confirmation-input w-full px-3 py-2 border-2 border-red-300 dark:border-red-700 rounded-lg
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
placeholder="DELETE"
autocomplete="off"
aria-describedby="fullResetHelp" />
<p id="fullResetHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_delete_help") }}</p>
<button @click="executeFullReset()"
:disabled="fullResetInput !== 'DELETE' || loading"
type="button"
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
text-base font-bold rounded-lg text-white
bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
aria-label="{{ _('system_reset.full_reset_button') }}">
<template x-if="loading && activeAction === 'full'">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<i x-show="!(loading && activeAction === 'full')" class="fas fa-trash-alt mr-2" aria-hidden="true"></i>
{{ _("system_reset.full_reset_button") }}
</button>
</div>
</div>
<!-- Reset & Re-import Card -->
<div class="reset-card rounded-xl border-2 border-orange-200 dark:border-orange-800 bg-white dark:bg-gray-800 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="h-10 w-10 rounded-full bg-orange-100 dark:bg-orange-900 flex items-center justify-center">
<i class="fas fa-recycle text-orange-600" aria-hidden="true"></i>
</div>
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.reimport_title") }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.reimport_subtitle") }}</p>
</div>
</div>
<div class="space-y-3 mb-6">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.reimport_desc") }}</p>
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-decimal">
<li>{{ _("system_reset.reimport_step_1") }}</li>
<li>{{ _("system_reset.reimport_step_2") }}</li>
<li>{{ _("system_reset.reimport_step_3") }}</li>
</ol>
<p class="text-xs text-gray-500 dark:text-gray-400 italic">{{ _("system_reset.reimport_note") }}</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label for="reimportConfirm" class="block text-sm font-semibold text-orange-700 dark:text-orange-400 mb-2">
{{ _("system_reset.type_reimport") }}
</label>
<input id="reimportConfirm"
type="text"
x-model="reimportInput"
class="confirmation-input w-full px-3 py-2 border-2 border-orange-300 dark:border-orange-700 rounded-lg
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
placeholder="REIMPORT"
autocomplete="off"
aria-describedby="reimportHelp" />
<p id="reimportHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_reimport_help") }}</p>
<button @click="executeReimport()"
:disabled="reimportInput !== 'REIMPORT' || loading"
type="button"
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
text-base font-bold rounded-lg text-white
bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
aria-label="{{ _('system_reset.reimport_button') }}">
<template x-if="loading && activeAction === 'reimport'">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<i x-show="!(loading && activeAction === 'reimport')" class="fas fa-recycle mr-2" aria-hidden="true"></i>
{{ _("system_reset.reimport_button") }}
</button>
</div>
</div>
</div>
<!-- Result banner (shown after a reset completes) -->
<div x-show="resultMessage" x-cloak
class="mt-8 rounded-lg p-4"
:class="resultSuccess ? 'bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700' :
'bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700'"
role="status" aria-live="polite">
<div class="flex items-start gap-3">
<i :class="resultSuccess ? 'fas fa-check-circle text-green-600' : 'fas fa-times-circle text-red-600'" aria-hidden="true"></i>
<div>
<p class="font-semibold" :class="resultSuccess ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'"
x-text="resultMessage"></p>
<pre x-show="resultDetail" x-text="resultDetail"
class="mt-2 text-xs overflow-x-auto whitespace-pre-wrap"
:class="resultSuccess ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'"></pre>
</div>
</div>
</div>
</div>
<script>
function systemResetApp() {
const __i18n = {
successFull: {{ _("system_reset.js_success_full") | tojson }},
successReimport: {{ _("system_reset.js_success_reimport") | tojson }},
errorGeneric: {{ _("system_reset.js_error_generic") | tojson }},
};
return {
fullResetInput: '',
reimportInput: '',
loading: false,
activeAction: null,
resultMessage: null,
resultDetail: null,
resultSuccess: false,
async executeFullReset() {
if (this.fullResetInput !== 'DELETE') return;
this.loading = true;
this.activeAction = 'full';
this.resultMessage = null;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const resp = await fetch('/api/admin/system-reset/full', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ confirmation: 'DELETE' }),
});
const data = await resp.json();
if (resp.ok) {
this.resultSuccess = true;
this.resultMessage = __i18n.successFull;
this.resultDetail = JSON.stringify(data.result, null, 2);
} else {
this.resultSuccess = false;
this.resultMessage = data.detail || __i18n.errorGeneric;
}
} catch (err) {
this.resultSuccess = false;
this.resultMessage = __i18n.errorGeneric;
} finally {
this.loading = false;
this.fullResetInput = '';
}
},
async executeReimport() {
if (this.reimportInput !== 'REIMPORT') return;
this.loading = true;
this.activeAction = 'reimport';
this.resultMessage = null;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const resp = await fetch('/api/admin/system-reset/reimport', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ confirmation: 'REIMPORT' }),
});
const data = await resp.json();
if (resp.ok) {
this.resultSuccess = true;
this.resultMessage = __i18n.successReimport;
this.resultDetail = JSON.stringify(data.result, null, 2);
} else {
this.resultSuccess = false;
this.resultMessage = data.detail || __i18n.errorGeneric;
}
} catch (err) {
this.resultSuccess = false;
this.resultMessage = __i18n.errorGeneric;
} finally {
this.loading = false;
this.reimportInput = '';
}
},
};
}
</script>
{% endblock %}
+31
View File
@@ -1184,6 +1184,7 @@
"nav.skip_to_content": "Skip to main content",
"nav.status": "Status",
"nav.subscription": "Subscription",
"nav.system_reset": "System Reset",
"nav.toggle_dark_mode": "Toggle dark mode",
"nav.toggle_nav": "Toggle navigation menu",
"nav.upload": "Upload",
@@ -1723,6 +1724,36 @@
"subscription.upgrade_info": "Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.",
"subscription.upgrade_to_prefix": "Upgrade to",
"subscription.usage_heading": "Usage",
"system_reset.danger_desc": "The actions below will permanently destroy data. They cannot be undone. Application settings and configuration are preserved, but all documents, files, processing history, and audit logs will be deleted.",
"system_reset.danger_zone": "Danger Zone — Irreversible Actions",
"system_reset.full_reset_button": "Wipe All Data",
"system_reset.full_reset_desc": "Permanently deletes all user data from the database and removes all work files from disk. The application will be in its initial state after this operation.",
"system_reset.full_reset_item_cache": "All watch-folder caches and ingestion state",
"system_reset.full_reset_item_db": "All document records, processing logs, and audit history",
"system_reset.full_reset_item_files": "All original, processed, and temporary files on disk",
"system_reset.full_reset_item_settings_kept": "Application settings and configuration are preserved",
"system_reset.full_reset_subtitle": "Wipe everything and start fresh",
"system_reset.full_reset_title": "Full System Reset",
"system_reset.heading": "System Reset",
"system_reset.js_error_generic": "An error occurred. Please check the server logs for details.",
"system_reset.js_success_full": "System reset complete. All user data has been wiped.",
"system_reset.js_success_reimport": "Reset complete. Original files have been staged for re-import via the watch folder.",
"system_reset.page_title": "System Reset",
"system_reset.reimport_button": "Reset & Re-import",
"system_reset.reimport_desc": "Copies your original files to a special reimport folder, wipes everything, then lets the watch-folder mechanism re-process them as if they were freshly uploaded.",
"system_reset.reimport_note": "Re-imported files will go through the full processing pipeline with the same rate limits and backoff strategy as regular uploads.",
"system_reset.reimport_step_1": "Original files are copied to a dedicated reimport folder",
"system_reset.reimport_step_2": "All data (database + work files) is wiped clean",
"system_reset.reimport_step_3": "The reimport folder is configured as a watch folder for automatic re-ingestion",
"system_reset.reimport_subtitle": "Wipe and re-process all original files",
"system_reset.reimport_title": "Reset & Re-import",
"system_reset.startup_reset_active": "Factory Reset on Startup is ACTIVE",
"system_reset.startup_reset_desc": "FACTORY_RESET_ON_STARTUP is enabled. All user data is wiped every time the application starts.",
"system_reset.subtitle": "Reset DocuElevate to a clean, fresh state. All user data will be permanently deleted.",
"system_reset.type_delete": "Type DELETE to confirm",
"system_reset.type_delete_help": "You must type the word DELETE in capital letters to enable the reset button.",
"system_reset.type_reimport": "Type REIMPORT to confirm",
"system_reset.type_reimport_help": "You must type the word REIMPORT in capital letters to enable the button.",
"terms.cookie_link": "Cookie Policy",
"terms.heading": "Terms of Service",
"terms.last_updated": "Last Updated:",