- Neue Seite „Mein Konto" (/konto, für alle Nutzer): eigenes Passwort ändern (inkl. Richtlinien-Anzeige) + Zwei-Faktor verwalten; erreichbar über den Nutzer-Chip in der Seitenleisten-Fußzeile - Einstellungen: 2FA-Karte entfernt (liegt jetzt bei „Mein Konto"), Karten in ein mehrspaltiges Raster gruppiert - Menüpunkt „Einstellungen" unter die Überschrift „System" verschoben (für alle Nutzer sichtbar; „Benutzer" bleibt Admin-only) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
5.5 KiB
React
170 lines
5.5 KiB
React
import { useRef, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import { api } from "../../api";
|
||
import { useAuth } from "../../auth";
|
||
import { useConfirm } from "../../confirm";
|
||
import { useToast } from "../../toast";
|
||
import Icon from "../../components/Icon";
|
||
|
||
/**
|
||
* Backup zurückladen – zweistufig wie der CSV-Import: erst prüfen lassen, was
|
||
* in der Datei steckt, dann bewusst bestätigen. Der Server schreibt beim Prüfen
|
||
* nichts.
|
||
*/
|
||
export default function RestoreCard() {
|
||
const { t } = useTranslation();
|
||
const toast = useToast();
|
||
const confirm = useConfirm();
|
||
const navigate = useNavigate();
|
||
const { logout } = useAuth();
|
||
const dateiFeld = useRef(null);
|
||
|
||
const [datei, setDatei] = useState(null);
|
||
const [vorschau, setVorschau] = useState(null);
|
||
const [modus, setModus] = useState("replace");
|
||
const [fehler, setFehler] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
function zuruecksetzen() {
|
||
setDatei(null);
|
||
setVorschau(null);
|
||
setFehler(null);
|
||
if (dateiFeld.current) dateiFeld.current.value = "";
|
||
}
|
||
|
||
async function pruefen(f) {
|
||
setDatei(f);
|
||
setVorschau(null);
|
||
setFehler(null);
|
||
if (!f) return;
|
||
setBusy(true);
|
||
try {
|
||
setVorschau(await api.restorePreview(f));
|
||
} catch (err) {
|
||
setFehler(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function einspielen() {
|
||
const systemweit = vorschau.scope === "system";
|
||
if (modus === "replace") {
|
||
const ok = await confirm({
|
||
title: t("restore.confirmTitle"),
|
||
message: systemweit ? t("restore.confirmSystem") : t("restore.confirmReplace"),
|
||
confirmLabel: t("restore.start"),
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
}
|
||
setBusy(true);
|
||
setFehler(null);
|
||
try {
|
||
const res = await api.restoreBackup(datei, systemweit ? "replace" : modus);
|
||
if (res.logout_required) {
|
||
// Die eigene Sitzung wurde beim Ersetzen der Benutzertabelle mit
|
||
// gelöscht – sauber abmelden statt in 401er zu laufen.
|
||
toast(t("restore.doneSystem"));
|
||
await logout();
|
||
navigate("/login");
|
||
return;
|
||
}
|
||
toast(t("restore.done"));
|
||
zuruecksetzen();
|
||
// Konten, Kategorien und Einstellungen sind komplett andere – neu laden
|
||
// ist ehrlicher als jeden Zwischenspeicher einzeln zu erneuern.
|
||
window.location.reload();
|
||
} catch (err) {
|
||
setFehler(err.message);
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="card">
|
||
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
|
||
<Icon name="upload" size={18} /> {t("restore.title")}
|
||
</h2>
|
||
<p className="muted small">{t("restore.hint")}</p>
|
||
|
||
{fehler && (
|
||
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<Icon name="alert" size={16} /><span>{fehler}</span>
|
||
</div>
|
||
)}
|
||
|
||
<label>
|
||
<span>{t("restore.file")}</span>
|
||
<input ref={dateiFeld} type="file" accept="application/json,.json"
|
||
onChange={(e) => pruefen(e.target.files?.[0] || null)} />
|
||
</label>
|
||
|
||
{busy && !vorschau && <p className="muted small">{t("common.loading")}</p>}
|
||
|
||
{vorschau && (
|
||
<>
|
||
<div className="alert" style={{ marginTop: "var(--sp-3)" }}>
|
||
<Icon name="check" size={16} />
|
||
<div>
|
||
<div>
|
||
{vorschau.scope === "system"
|
||
? t("restore.scopeSystem", { n: vorschau.user_count })
|
||
: t("restore.scopeUser", { name: vorschau.username || "?" })}
|
||
</div>
|
||
<div className="muted small" style={{ marginTop: 2 }}>
|
||
{t("restore.exportedAt", { when: vorschau.exported_at || "?" })}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<table className="restore-summary">
|
||
<tbody>
|
||
{vorschau.tables.map((z) => (
|
||
<tr key={z.key}>
|
||
<td>{z.label}</td>
|
||
<td>{z.count}</td>
|
||
</tr>
|
||
))}
|
||
{vorschau.tables.length === 0 && (
|
||
<tr><td colSpan={2} className="muted">{t("restore.empty")}</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
|
||
{vorschau.notes.map((n) => (
|
||
<div className="alert warn" key={n} style={{ marginTop: "var(--sp-3)" }}>
|
||
<Icon name="alert" size={16} /><span>{n}</span>
|
||
</div>
|
||
))}
|
||
|
||
{vorschau.scope === "user" && (
|
||
<label style={{ marginTop: "var(--sp-3)" }}>
|
||
<span>{t("restore.mode")}</span>
|
||
<select value={modus} onChange={(e) => setModus(e.target.value)}>
|
||
<option value="replace">{t("restore.modeReplace")}</option>
|
||
<option value="merge">{t("restore.modeMerge")}</option>
|
||
</select>
|
||
</label>
|
||
)}
|
||
<p className="muted small">
|
||
{vorschau.scope === "system" || modus === "replace"
|
||
? t("restore.modeReplaceHint")
|
||
: t("restore.modeMergeHint")}
|
||
</p>
|
||
|
||
<div className="btn-pair">
|
||
<button className="btn danger-solid" disabled={busy} onClick={einspielen}>
|
||
{busy ? t("common.loading") : t("restore.start")}
|
||
</button>
|
||
<button className="btn ghost" disabled={busy} onClick={zuruecksetzen}>
|
||
{t("common.cancel")}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|