feat(account): danger zone, restore import, larger API key field on phones
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m27s

- Danger zone in settings: delete all own data or the account, both with
  password confirmation; the last server admin cannot delete the account
- DELETE /user/data and /user/account; PurgeUserData removes everything a
  user owns in one transaction (others' tasks in shared lists stay theirs),
  also used by admin user deletion
- MindSift import: 'Hinzufügen' or 'Wiederherstellen' (mode=restore, wipes
  own data only after the file is fully validated, confirm checkbox)
- API key name field and button 44 px on phones (flex-1 collapsed them)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 07:13:29 +01:00
1 parent 9bc8716f41
commit 0e9fd1b3ad
11 files changed
+472 -15

No files matched your search

@@ -115,11 +115,11 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="flex-1 h-9 px-3 input-field text-sm"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs sm:text-sm"
className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span>API-Key erzeugen</span>
+61 -3
View File
@@ -30,11 +30,16 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
// MindSift data can replace the current state instead of being added next to it
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
const [confirmRestore, setConfirmRestore] = useState(false);
const restoring = source === 'mindsift' && mode === 'restore';
const reset = () => {
setFile(null);
setPreview(null);
setError(null);
setConfirmRestore(false);
};
const pick = async (f?: File) => {
@@ -57,9 +62,10 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
setBusy(true);
setError(null);
try {
setResult(await api.importData(source, file, false));
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
setFile(null);
setPreview(null);
setConfirmRestore(false);
} catch (err: any) {
setError(err.message || 'Import fehlgeschlagen');
} finally {
@@ -76,6 +82,38 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
<p className="text-muted text-[11px] mt-0.5">{description}</p>
</div>
{source === 'mindsift' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Import-Modus">
{(
[
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'],
] as const
).map(([value, label, text]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mode === value}
onClick={() => {
setMode(value);
setConfirmRestore(false);
}}
className={`p-2.5 min-h-[44px] text-left border rounded-card transition-colors ${
mode === value
? value === 'restore'
? 'border-status-err bg-status-err/10'
: 'border-accent bg-accent/10'
: 'border-border hover:border-strong'
}`}
>
<span className={`block text-xs font-semibold ${value === 'restore' && mode === value ? 'text-status-err' : 'text-main'}`}>{label}</span>
<span className="block text-[11px] text-muted">{text}</span>
</button>
))}
</div>
)}
<div
onDragOver={(e) => {
e.preventDefault();
@@ -147,10 +185,30 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
</ul>
</details>
)}
{!result && restoring && (
<label className="flex items-start gap-2 p-2.5 rounded-card border border-status-err/40 bg-status-err/10 text-[11px] text-main cursor-pointer">
<input
type="checkbox"
checked={confirmRestore}
onChange={(e) => setConfirmRestore(e.target.checked)}
className="mt-0.5 w-4 h-4 shrink-0"
/>
<span>
<strong className="text-status-err">Achtung:</strong> Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und
Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
bleiben erhalten.
</span>
</label>
)}
{!result && (
<div className="flex flex-col sm:flex-row gap-2 pt-1">
<button type="button" onClick={runImport} className="btn-primary h-11 sm:h-8 px-4 text-xs font-semibold">
Jetzt importieren
<button
type="button"
onClick={runImport}
disabled={restoring && !confirmRestore}
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${restoring ? 'btn-action border-status-err text-status-err' : 'btn-primary'}`}
>
{restoring ? 'Löschen und wiederherstellen' : 'Jetzt importieren'}
</button>
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
Abbrechen
@@ -21,13 +21,14 @@ import {
Copy,
} from 'lucide-react';
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { api, clearToken, triggerHaptic } from '../../lib/api';
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
import themeTemplateCss from '../../themes/theme-template.css?raw';
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
currentUser: User;
@@ -54,6 +55,45 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}) => {
const [section, setSection] = useState<UserSettingsSection>('profile');
const [legal, setLegal] = useState<AuthStatus['legal']>();
// --- Danger zone ---
const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null);
const [dangerPassword, setDangerPassword] = useState('');
const [dangerError, setDangerError] = useState<string | null>(null);
const [dangerBusy, setDangerBusy] = useState(false);
const [dangerDone, setDangerDone] = useState<string | null>(null);
const [isLastAdmin, setIsLastAdmin] = useState(false);
useEffect(() => {
api.getMe().then((me) => setIsLastAdmin(Boolean(me.isLastAdmin))).catch(() => {});
}, []);
const openDanger = (action: 'data' | 'account') => {
setDangerAction(action);
setDangerPassword('');
setDangerError(null);
setDangerDone(null);
};
const handleDangerSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setDangerBusy(true);
setDangerError(null);
try {
if (dangerAction === 'account') {
await api.deleteOwnAccount(dangerPassword);
// The account is gone: drop the session and show the login screen
clearToken();
window.location.reload();
return;
}
await api.purgeOwnData(dangerPassword);
setDangerAction(null);
setDangerDone('Alle deine Daten wurden gelöscht.');
onUserUpdated(currentUser);
} catch (err: any) {
setDangerError(err.message || 'Aktion fehlgeschlagen');
} finally {
setDangerBusy(false);
}
};
const [showAbout, setShowAbout] = useState(false);
useEffect(() => {
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
@@ -697,6 +737,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button>
</div>
</div>
{/* Danger zone: delete own data or the whole account (password required) */}
<div className="p-4 border border-status-err/40 bg-status-err/5 rounded-card space-y-3">
<h3 className="font-bold text-status-err flex items-center gap-1.5">
<AlertCircle className="w-4 h-4" />
<span>Gefahrenzone</span>
</h3>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<p className="text-main font-medium">Alle eigenen Daten löschen</p>
<p className="text-[11px] text-muted">
Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben.
Mit dir geteilte Inhalte anderer bleiben erhalten.
</p>
</div>
<button
type="button"
onClick={() => openDanger('data')}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0"
>
Daten löschen
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-3 border-t border-status-err/20">
<div>
<p className="text-main font-medium">Konto unwiderruflich löschen</p>
<p className="text-[11px] text-muted">
{isLastAdmin
? 'Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.'
: 'Löscht dein Konto mit allen Daten. Du wirst danach abgemeldet.'}
</p>
</div>
<button
type="button"
onClick={() => openDanger('account')}
disabled={isLastAdmin}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
Konto löschen
</button>
</div>
{dangerDone && <p className="text-[11px] text-status-ok">{dangerDone}</p>}
</div>
</div>
)}
@@ -1143,11 +1226,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
className="flex-1 h-9 px-3 input-field"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs"
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs font-semibold flex items-center justify-center gap-1.5"
className="btn-primary h-11 sm:h-9 px-4 text-sm sm:text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span>
@@ -1327,6 +1410,35 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</button>
</LegalLinks>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
{dangerAction && (
<FormSheet
title={dangerAction === 'account' ? 'Konto unwiderruflich löschen' : 'Alle eigenen Daten löschen'}
icon={<AlertCircle className="w-4 h-4 text-status-err" />}
onClose={() => setDangerAction(null)}
onSubmit={handleDangerSubmit}
submitLabel={dangerAction === 'account' ? 'Konto löschen' : 'Daten löschen'}
danger
busy={dangerBusy || !dangerPassword}
>
<p className="text-xs text-muted">
{dangerAction === 'account'
? 'Dein Konto und alle deine Daten werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.'
: 'Alle deine Notizen, Ordner, Aufgaben, Gedanken und Listen werden endgültig gelöscht. Lade vorher ein Backup herunter, wenn du sie behalten willst.'}
</p>
<label className="block text-xs text-muted">
Zur Bestätigung dein Passwort
<input
type="password"
autoFocus
autoComplete="current-password"
value={dangerPassword}
onChange={(e) => setDangerPassword(e.target.value)}
className="mt-1 input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
/>
</label>
{dangerError && <p className="text-[11px] text-status-err">{dangerError}</p>}
</FormSheet>
)}
</div>
);
};