Files
Mitgliederverwaltung/src/views/Backup.vue
T
shahondin1624 d48e2b7d9d feat: v0.2.0 — backup system, self-update with Ed25519 signing, column pickers, import fixes
Major features:
- Full backup & restore system (JSON snapshots of all 20 tables + settings)
  - Web UI, REST API, OCC CLI commands, scheduled background job
- Self-update from Gitea releases with Ed25519 signature verification
- Configurable column visibility on all data tables (persisted via localStorage)

Fixes:
- NC admin group fallback for PermissionService (IGroupManager)
- Bundle import inline error correction (editable error rows)

New files: BackupService, BackupSettingsService, BackupController, BackupJob,
SelfUpdateService, 4 OCC commands, ColumnPicker component, Backup.vue,
Ed25519 signing scripts, signature verification tests (18 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:11:51 +02:00

312 lines
11 KiB
Vue

<template>
<div class="backup-page">
<h2>Backup & Wiederherstellung</h2>
<!-- Messages -->
<div v-if="store.error" class="backup-page__message backup-page__message--error">
{{ store.error }}
<NcButton @click="store.clearError()">Schliessen</NcButton>
</div>
<div v-if="store.success" class="backup-page__message backup-page__message--success">
{{ store.success }}
<NcButton @click="store.clearSuccess()">Schliessen</NcButton>
</div>
<!-- App Update -->
<div class="backup-page__section">
<h3>App-Update</h3>
<div class="backup-page__update">
<div class="backup-page__update-status">
<span>Installierte Version: <strong>v{{ store.updateInfo?.currentVersion || '...' }}</strong></span>
<template v-if="store.updateInfo">
<template v-if="store.updateInfo.available">
<span class="backup-page__badge backup-page__badge--update">
v{{ store.updateInfo.latestVersion }} verfuegbar
</span>
</template>
<template v-else-if="store.updateInfo.latestVersion">
<span class="backup-page__badge">Aktuell</span>
</template>
<template v-else-if="store.updateInfo.error">
<span class="backup-page__badge backup-page__badge--none">
Pruefung fehlgeschlagen
</span>
</template>
</template>
</div>
<div v-if="store.updateInfo?.releaseBody" class="backup-page__update-notes">
{{ store.updateInfo.releaseBody }}
</div>
<div class="backup-page__update-actions">
<NcButton @click="store.checkForUpdate()">
Nach Updates suchen
</NcButton>
<NcButton v-if="store.updateInfo?.available"
type="primary"
:disabled="store.updating"
@click="confirmUpdate">
<NcLoadingIcon v-if="store.updating" :size="20" />
<template v-else>Update installieren</template>
</NcButton>
</div>
</div>
</div>
<!-- Schedule settings -->
<div class="backup-page__section">
<h3>Automatische Backups</h3>
<div class="backup-page__settings">
<div class="backup-page__setting">
<label for="backup-schedule">Zeitplan</label>
<select id="backup-schedule"
:value="store.settings.schedule"
class="backup-page__select"
@change="updateSchedule($event.target.value)">
<option value="disabled">Deaktiviert</option>
<option value="daily">Taeglich</option>
<option value="weekly">Woechentlich</option>
</select>
</div>
<div class="backup-page__setting">
<label for="backup-retention">Aufbewahrung (Anzahl)</label>
<input id="backup-retention"
type="number"
min="1"
max="100"
:value="store.settings.retentionCount"
class="backup-page__input"
@change="updateRetention(parseInt($event.target.value))">
</div>
<div class="backup-page__setting">
<label>Automatisches Passwort</label>
<span v-if="store.settings.hasPassword" class="backup-page__badge">Gesetzt</span>
<span v-else class="backup-page__badge backup-page__badge--none">Nicht gesetzt</span>
</div>
</div>
</div>
<!-- Manual backup -->
<div class="backup-page__section">
<h3>Manuelles Backup</h3>
<div class="backup-page__create">
<div class="backup-page__setting">
<label for="backup-password">Passwort (optional)</label>
<input id="backup-password"
v-model="createPassword"
type="password"
placeholder="Ohne Passwort"
class="backup-page__input">
</div>
<NcButton type="primary"
:disabled="store.creating"
@click="doCreate">
<NcLoadingIcon v-if="store.creating" :size="20" />
<template v-else>Backup erstellen</template>
</NcButton>
</div>
</div>
<!-- Backup list -->
<div class="backup-page__section">
<h3>Vorhandene Backups</h3>
<NcLoadingIcon v-if="store.loading && store.backups.length === 0"
:size="48"
class="backup-page__loading" />
<div v-else-if="store.backups.length === 0" class="backup-page__empty">
Keine Backups vorhanden.
</div>
<table v-else class="backup-page__table">
<thead>
<tr>
<th>Dateiname</th>
<th>Erstellt</th>
<th>Groesse</th>
<th>Tabellen</th>
<th>Zeilen</th>
<th>Ausloser</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="backup in store.backups" :key="backup.filename">
<td>{{ backup.filename }}</td>
<td>{{ formatDateTime(backup.createdAt) }}</td>
<td>{{ formatBytes(backup.size) }}</td>
<td>{{ Object.keys(backup.tables || {}).length }}</td>
<td>{{ totalRows(backup) }}</td>
<td>{{ formatTrigger(backup.triggeredBy) }}</td>
<td class="backup-page__actions">
<NcButton @click="store.downloadBackup(backup.filename)">
Download
</NcButton>
<NcButton type="warning"
:disabled="store.restoring"
@click="confirmRestore(backup)">
Wiederherstellen
</NcButton>
<NcButton type="error"
@click="confirmDelete(backup)">
Loeschen
</NcButton>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Restore confirmation dialog -->
<div v-if="restoreTarget" class="backup-page__dialog-backdrop">
<div class="backup-page__dialog">
<h3>Backup wiederherstellen</h3>
<p>
<strong>WARNUNG:</strong> Alle bestehenden Daten werden geloescht und durch
das Backup <strong>{{ restoreTarget.filename }}</strong> ersetzt.
</p>
<p>Erstellt: {{ formatDateTime(restoreTarget.createdAt) }}</p>
<div class="backup-page__setting">
<label for="restore-password">Passwort (falls verschluesselt)</label>
<input id="restore-password"
v-model="restorePassword"
type="password"
placeholder="Ohne Passwort"
class="backup-page__input">
</div>
<div class="backup-page__dialog-actions">
<NcButton @click="restoreTarget = null">Abbrechen</NcButton>
<NcButton type="error"
:disabled="store.restoring"
@click="doRestore">
<NcLoadingIcon v-if="store.restoring" :size="20" />
<template v-else>Jetzt wiederherstellen</template>
</NcButton>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
import { useBackupStore } from '../stores/backup.js'
const store = useBackupStore()
const createPassword = ref('')
const restoreTarget = ref(null)
const restorePassword = ref('')
onMounted(() => {
store.fetchBackups()
store.fetchSettings()
store.checkForUpdate()
})
async function doCreate() {
await store.createBackup(createPassword.value || null)
createPassword.value = ''
}
function confirmRestore(backup) {
restoreTarget.value = backup
restorePassword.value = ''
}
async function doRestore() {
const filename = restoreTarget.value.filename
restoreTarget.value = null
await store.restoreBackup(filename, restorePassword.value || null)
}
async function confirmUpdate() {
if (confirm('Update installieren? Die App-Dateien werden ersetzt. Danach muss ggf. "occ upgrade" ausgefuehrt werden.')) {
await store.installUpdate(false)
}
}
function confirmDelete(backup) {
if (confirm(`Backup "${backup.filename}" wirklich loeschen?`)) {
store.deleteBackup(backup.filename)
}
}
function updateSchedule(value) {
store.updateSettings({ schedule: value })
}
function updateRetention(value) {
if (value >= 1) {
store.updateSettings({ retentionCount: value })
}
}
function formatDateTime(isoString) {
if (!isoString) return '-'
const d = new Date(isoString)
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function formatBytes(bytes) {
if (!bytes) return '-'
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB'
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
return bytes + ' B'
}
function totalRows(backup) {
if (!backup.tables) return 0
return Object.values(backup.tables).reduce((sum, n) => sum + n, 0)
}
function formatTrigger(trigger) {
const map = { manual: 'Manuell', scheduled: 'Geplant', cli: 'CLI' }
return map[trigger] || trigger || '-'
}
</script>
<style scoped>
.backup-page { padding: 20px; max-width: 1200px; }
.backup-page h2 { margin: 0 0 20px 0; }
.backup-page__section { margin-bottom: 32px; }
.backup-page__section h3 { margin: 0 0 12px 0; }
.backup-page__message { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: var(--border-radius); margin-bottom: 16px; color: white; }
.backup-page__message--error { background: var(--color-error); }
.backup-page__message--success { background: var(--color-success); }
.backup-page__settings { display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-end; padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
.backup-page__setting { display: flex; flex-direction: column; gap: 4px; }
.backup-page__setting label { font-size: 0.8em; color: var(--color-text-lighter); font-weight: 500; }
.backup-page__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); }
.backup-page__input { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); }
.backup-page__badge { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; background: var(--color-success); color: white; }
.backup-page__badge--none { background: var(--color-text-lighter); }
.backup-page__badge--update { background: var(--color-primary); }
.backup-page__update { padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
.backup-page__update-status { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.backup-page__update-notes { font-size: 0.9em; color: var(--color-text-lighter); margin-bottom: 12px; white-space: pre-line; }
.backup-page__update-actions { display: flex; gap: 8px; }
.backup-page__create { display: flex; gap: 16px; align-items: flex-end; }
.backup-page__loading { display: flex; justify-content: center; margin-top: 40px; }
.backup-page__empty { color: var(--color-text-lighter); padding: 24px; text-align: center; }
.backup-page__table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
.backup-page__table th { text-align: left; padding: 10px 12px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
.backup-page__table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
.backup-page__table tr:hover { background: var(--color-background-hover); }
.backup-page__actions { white-space: nowrap; }
.backup-page__actions :deep(.button-vue) { display: inline-flex; margin: 2px; }
.backup-page__dialog-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.backup-page__dialog { background: var(--color-main-background); padding: 24px; border-radius: var(--border-radius-large); max-width: 520px; width: 90%; box-shadow: 0 4px 24px rgba(0,0,0,0.2); }
.backup-page__dialog h3 { margin: 0 0 12px 0; }
.backup-page__dialog p { margin: 0 0 12px 0; }
.backup-page__dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
</style>