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
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:
1 parent
9bc8716f41
commit
0e9fd1b3ad
11 files changed
+472
-15
No files matched your search
@@ -9,13 +9,25 @@ interface FormSheetProps {
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
children: React.ReactNode; // the fields
|
||||
submitLabel?: string;
|
||||
danger?: boolean; // red submit button for destructive actions
|
||||
busy?: boolean;
|
||||
// Desktop only: open next to the click instead of centred
|
||||
anchor?: { x: number; y: number } | null;
|
||||
}
|
||||
|
||||
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
||||
// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
|
||||
export const FormSheet: React.FC<FormSheetProps> = ({ title, icon, onClose, onSubmit, children, submitLabel = 'Erstellen', anchor }) => {
|
||||
export const FormSheet: React.FC<FormSheetProps> = ({
|
||||
title,
|
||||
icon,
|
||||
onClose,
|
||||
onSubmit,
|
||||
children,
|
||||
submitLabel = 'Erstellen',
|
||||
anchor,
|
||||
danger = false,
|
||||
busy = false,
|
||||
}) => {
|
||||
const { inset, visibleHeight } = useKeyboardInset();
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
@@ -61,7 +73,11 @@ export const FormSheet: React.FC<FormSheetProps> = ({ title, icon, onClose, onSu
|
||||
<button type="button" onClick={onClose} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className={`${danger ? 'btn-danger' : 'btn-primary'} flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold disabled:opacity-50`}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+22
-4
@@ -209,8 +209,8 @@ export const api = {
|
||||
return res;
|
||||
},
|
||||
|
||||
async getMe(): Promise<{ user: User; stats: DashboardStats }> {
|
||||
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me');
|
||||
async getMe(): Promise<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }> {
|
||||
return request<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }>('/api/v1/auth/me');
|
||||
},
|
||||
|
||||
async updateSettings(patch: {
|
||||
@@ -1087,11 +1087,20 @@ export const api = {
|
||||
|
||||
// Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP).
|
||||
// preview = true only reports what would be created.
|
||||
async importData(source: 'mindsift' | 'obsidian', file: File, preview: boolean): Promise<ImportSummary> {
|
||||
// mode 'restore' (MindSift data only) deletes the user's notes, tasks and lists before importing
|
||||
async importData(
|
||||
source: 'mindsift' | 'obsidian',
|
||||
file: File,
|
||||
preview: boolean,
|
||||
mode: 'merge' | 'restore' = 'merge'
|
||||
): Promise<ImportSummary> {
|
||||
const token = getToken();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`/api/v1/import/${source}${preview ? '?preview=1' : ''}`, {
|
||||
const params = new URLSearchParams();
|
||||
if (preview) params.set('preview', '1');
|
||||
if (mode === 'restore') params.set('mode', 'restore');
|
||||
const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
@@ -1101,6 +1110,15 @@ export const api = {
|
||||
return data as ImportSummary;
|
||||
},
|
||||
|
||||
// Danger zone: both need the current password
|
||||
async purgeOwnData(password: string): Promise<void> {
|
||||
await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) });
|
||||
},
|
||||
|
||||
async deleteOwnAccount(password: string): Promise<void> {
|
||||
await request('/api/v1/user/account', { method: 'DELETE', body: JSON.stringify({ password }) });
|
||||
},
|
||||
|
||||
// Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …)
|
||||
async exportBackupZip(): Promise<Blob> {
|
||||
return downloadBlob('/api/v1/export?format=zip');
|
||||
|
||||
Reference in new issue
Block a user