Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
Danger zone and restore import, SMTP port fix, PWA splash screen, larger API key field on phones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9eb26bc563
19 files changed
+560
-27
No files matched your search
@@ -35,6 +35,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||||
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
||||||
| POST | `/import/mindsift`, `/import/obsidian` | Import (Multipart-Feld `file`, max. 100 MB): MindSift-JSON/-Archiv bzw. Obsidian-Vault-ZIP; `?preview=1` liefert nur die Zusammenfassung (`folders`, `notes`, `taskLists`, `tasks`, `lists`, `listItems`, `inbox`, `skipped`) |
|
| POST | `/import/mindsift`, `/import/obsidian` | Import (Multipart-Feld `file`, max. 100 MB): MindSift-JSON/-Archiv bzw. Obsidian-Vault-ZIP; `?preview=1` liefert nur die Zusammenfassung (`folders`, `notes`, `taskLists`, `tasks`, `lists`, `listItems`, `inbox`, `skipped`) |
|
||||||
|
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
|
||||||
|
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
|
||||||
|
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
||||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||||
|
|
||||||
### Globale Suche (`GET /api/v1/search?q=`)
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mi
|
|||||||
| Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. |
|
| Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. |
|
||||||
|
|
||||||
- **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an.
|
- **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an.
|
||||||
- **Nie überschreiben:** Ein Import legt nur neu an; doppelte Titel sind möglich, Bestehendes bleibt unverändert.
|
- **Hinzufügen oder Wiederherstellen** (nur MindSift-Daten): „Hinzufügen“ legt nur neu an, Bestehendes bleibt unverändert. „Wiederherstellen“ (`?mode=restore`, in der Oberfläche nur nach bestätigtem Warnhinweis) löscht vorher alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen – erst nachdem die Datei vollständig gelesen und geprüft ist, eine kaputte Datei löscht also nichts. Mit dem Nutzer geteilte Inhalte anderer bleiben. Ein Obsidian-Import fügt immer hinzu.
|
||||||
- **Markdown → Notiz:** Listen behalten ihre Verschachtelung (Tabs oder Leerzeichen), Inhalte unter einer Überschrift werden darunter eingerückt (Gegenstück zum Export), Absätze werden zu Punkten, Codeblöcke zu Codeblöcken.
|
- **Markdown → Notiz:** Listen behalten ihre Verschachtelung (Tabs oder Leerzeichen), Inhalte unter einer Überschrift werden darunter eingerückt (Gegenstück zum Export), Absätze werden zu Punkten, Codeblöcke zu Codeblöcken.
|
||||||
- **Grenzen:** Upload max. 100 MB, eine Notiz max. 10 MB, entpackt zusammen max. 500 MB, höchstens 20 000 Dateien. Pfade mit `..` oder absolute Pfade werden entschärft und bleiben im Zielordner.
|
- **Grenzen:** Upload max. 100 MB, eine Notiz max. 10 MB, entpackt zusammen max. 500 MB, höchstens 20 000 Dateien. Pfade mit `..` oder absolute Pfade werden entschärft und bleiben im Zielordner.
|
||||||
- Bricht ein Import wegen eines Fehlers ab, bleibt das bis dahin Angelegte erhalten; die Meldung nennt das Element, an dem es scheiterte.
|
- Bricht ein Import wegen eines Fehlers ab, bleibt das bis dahin Angelegte erhalten; die Meldung nennt das Element, an dem es scheiterte.
|
||||||
|
|||||||
@@ -61,3 +61,10 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
|||||||
- `inline`: im Textfluss.
|
- `inline`: im Textfluss.
|
||||||
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
||||||
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||||
|
|
||||||
|
## Ladebildschirm & Statusleiste
|
||||||
|
|
||||||
|
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
|
||||||
|
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
|
||||||
|
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
|
||||||
|
|
||||||
+9
-2
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0b0f19" />
|
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background -->
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||||
@@ -49,7 +50,13 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||||
<div id="root"></div>
|
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
|
||||||
|
<div id="root">
|
||||||
|
<div style="position:fixed;inset:0;background:#2563eb;display:flex;align-items:center;justify-content:center">
|
||||||
|
<!-- the icon's own blue square disappears on blue, so the visible brain is ~55% of this size -->
|
||||||
|
<img src="/icon.svg" width="128" height="128" alt="" style="display:block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script>
|
<script>
|
||||||
if ('caches' in window) {
|
if ('caches' in window) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"id": "/",
|
"id": "/",
|
||||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
"name": "MindSift",
|
||||||
"short_name": "MindSift",
|
"short_name": "MindSift",
|
||||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
"description": "MindSift — Der Kopf frei in drei Sekunden. Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0b0f19",
|
"background_color": "#2563eb",
|
||||||
"theme_color": "#0b0f19",
|
"theme_color": "#2563eb",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,13 +9,25 @@ interface FormSheetProps {
|
|||||||
onSubmit: (e: React.FormEvent) => void;
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
children: React.ReactNode; // the fields
|
children: React.ReactNode; // the fields
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
|
danger?: boolean; // red submit button for destructive actions
|
||||||
|
busy?: boolean;
|
||||||
// Desktop only: open next to the click instead of centred
|
// Desktop only: open next to the click instead of centred
|
||||||
anchor?: { x: number; y: number } | null;
|
anchor?: { x: number; y: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
// 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.
|
// 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();
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
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">
|
<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
|
Abbrechen
|
||||||
</button>
|
</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}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
allowRegistration: false,
|
allowRegistration: false,
|
||||||
instanceName: 'MindSift',
|
instanceName: 'MindSift',
|
||||||
smtpHost: '',
|
smtpHost: '',
|
||||||
smtpPort: 587,
|
smtpPort: '587',
|
||||||
smtpUser: '',
|
smtpUser: '',
|
||||||
smtpPass: '',
|
smtpPass: '',
|
||||||
smtpFrom: '',
|
smtpFrom: '',
|
||||||
@@ -126,7 +126,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
allowRegistration: Boolean(sys.allowRegistration),
|
allowRegistration: Boolean(sys.allowRegistration),
|
||||||
instanceName: sys.instanceName || 'MindSift',
|
instanceName: sys.instanceName || 'MindSift',
|
||||||
smtpHost: sys.smtpHost || '',
|
smtpHost: sys.smtpHost || '',
|
||||||
smtpPort: sys.smtpPort || 587,
|
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
||||||
smtpUser: sys.smtpUser || '',
|
smtpUser: sys.smtpUser || '',
|
||||||
smtpPass: sys.smtpPass || '',
|
smtpPass: sys.smtpPass || '',
|
||||||
smtpFrom: sys.smtpFrom || '',
|
smtpFrom: sys.smtpFrom || '',
|
||||||
@@ -1150,10 +1150,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
SMTP Port (`SMTP_PORT`)
|
SMTP Port (`SMTP_PORT`)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
value={settings.smtpPort || 587}
|
inputMode="numeric"
|
||||||
|
value={settings.smtpPort ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((prev) => ({ ...prev, smtpPort: Number(e.target.value) }))
|
// Digits only; an empty field stays empty while typing (saved as 587)
|
||||||
|
setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) }))
|
||||||
}
|
}
|
||||||
placeholder="587"
|
placeholder="587"
|
||||||
className="w-full h-9 px-3 input-field font-mono"
|
className="w-full h-9 px-3 input-field font-mono"
|
||||||
|
|||||||
@@ -115,11 +115,11 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
|||||||
value={newKeyName}
|
value={newKeyName}
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
|
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
|
<button
|
||||||
type="submit"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>API-Key erzeugen</span>
|
<span>API-Key erzeugen</span>
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
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 = () => {
|
const reset = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setConfirmRestore(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pick = async (f?: File) => {
|
const pick = async (f?: File) => {
|
||||||
@@ -57,9 +62,10 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setResult(await api.importData(source, file, false));
|
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge'));
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
|
setConfirmRestore(false);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Import fehlgeschlagen');
|
setError(err.message || 'Import fehlgeschlagen');
|
||||||
} finally {
|
} 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>
|
<p className="text-muted text-[11px] mt-0.5">{description}</p>
|
||||||
</div>
|
</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
|
<div
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -147,10 +185,30 @@ export const ImportCard: React.FC<ImportCardProps> = ({ source, title, descripti
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</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 && (
|
{!result && (
|
||||||
<div className="flex flex-col sm:flex-row gap-2 pt-1">
|
<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">
|
<button
|
||||||
Jetzt importieren
|
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>
|
||||||
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
|
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
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 { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||||
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||||
import { LegalLinks } from '../../components/LegalLinks';
|
import { LegalLinks } from '../../components/LegalLinks';
|
||||||
import { AboutModal } from '../../components/AboutModal';
|
import { AboutModal } from '../../components/AboutModal';
|
||||||
import { ImportCard } from './ImportCard';
|
import { ImportCard } from './ImportCard';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
@@ -54,6 +55,45 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||||
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
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);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
|
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
|
||||||
@@ -697,6 +737,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1143,11 +1226,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
value={newKeyName}
|
value={newKeyName}
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
|
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
|
<button
|
||||||
type="submit"
|
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" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<span>Neuen API-Key erzeugen</span>
|
<span>Neuen API-Key erzeugen</span>
|
||||||
@@ -1327,6 +1410,35 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</LegalLinks>
|
</LegalLinks>
|
||||||
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+22
-4
@@ -209,8 +209,8 @@ export const api = {
|
|||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMe(): Promise<{ user: User; stats: DashboardStats }> {
|
async getMe(): Promise<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }> {
|
||||||
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me');
|
return request<{ user: User; stats: DashboardStats; isLastAdmin?: boolean }>('/api/v1/auth/me');
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateSettings(patch: {
|
async updateSettings(patch: {
|
||||||
@@ -1087,11 +1087,20 @@ export const api = {
|
|||||||
|
|
||||||
// Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP).
|
// Import of MindSift data (JSON backup or Markdown archive) or an Obsidian vault (ZIP).
|
||||||
// preview = true only reports what would be created.
|
// 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 token = getToken();
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
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',
|
method: 'POST',
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
body: form,
|
body: form,
|
||||||
@@ -1101,6 +1110,15 @@ export const api = {
|
|||||||
return data as ImportSummary;
|
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', …)
|
// Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …)
|
||||||
async exportBackupZip(): Promise<Blob> {
|
async exportBackupZip(): Promise<Blob> {
|
||||||
return downloadBlob('/api/v1/export?format=zip');
|
return downloadBlob('/api/v1/export?format=zip');
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ function applyToDocument(themeId: ThemeId, customCss: string | null): void {
|
|||||||
root.setAttribute('data-theme', attr);
|
root.setAttribute('data-theme', attr);
|
||||||
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
|
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
|
||||||
root.classList.toggle('dark', !light);
|
root.classList.toggle('dark', !light);
|
||||||
|
syncThemeColorMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status bar (theme-color) starts blue for the splash screen; once the app runs it follows the
|
||||||
|
// active theme's background so it blends with the header.
|
||||||
|
function syncThemeColorMeta(): void {
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (!meta || typeof requestAnimationFrame === 'undefined') return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-app').trim();
|
||||||
|
if (bg) meta.setAttribute('content', bg);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function store(key: string, value: string | null): void {
|
function store(key: string, value: string | null): void {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export interface SystemSettings {
|
|||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
instanceName: string;
|
instanceName: string;
|
||||||
smtpHost?: string;
|
smtpHost?: string;
|
||||||
smtpPort?: number;
|
smtpPort?: string; // sent as text, like the server stores it
|
||||||
smtpUser?: string;
|
smtpUser?: string;
|
||||||
smtpPass?: string;
|
smtpPass?: string;
|
||||||
smtpFrom?: string;
|
smtpFrom?: string;
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/export", s.handleExport)
|
r.Get("/api/v1/export", s.handleExport)
|
||||||
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
||||||
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
|
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
|
||||||
|
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
|
||||||
|
r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount)
|
||||||
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
|
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
|
||||||
r.Get("/api/v1/themes", s.handleGetThemes)
|
r.Get("/api/v1/themes", s.handleGetThemes)
|
||||||
r.Post("/api/v1/themes", s.handleSaveTheme)
|
r.Post("/api/v1/themes", s.handleSaveTheme)
|
||||||
@@ -710,6 +712,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"user": user,
|
"user": user,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
|
// The settings disable "Konto löschen" for the last administrator
|
||||||
|
"isLastAdmin": s.store.IsLastAdmin(userID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2117,6 +2121,56 @@ func (s *Server) handleDeleteTheme(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// confirmPassword checks the current password sent as {"password": "…"} before destructive actions.
|
||||||
|
func (s *Server) confirmPassword(w http.ResponseWriter, r *http.Request) (*domain.User, bool) {
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
user, err := s.store.GetUserByID(auth.GetUserID(r.Context()))
|
||||||
|
if err != nil || user == nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if req.Password == "" || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||||
|
writeError(w, http.StatusForbidden, "Passwort ist falsch")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePurgeOwnData deletes all notes, tasks, thoughts and lists of the caller; the account stays.
|
||||||
|
func (s *Server) handlePurgeOwnData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.confirmPassword(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.PurgeUserData(user.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("Benutzer hat alle eigenen Daten gelöscht", "user_id", user.ID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteOwnAccount deletes the caller's account with all data; never the last administrator.
|
||||||
|
func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.confirmPassword(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteOwnAccount(user.ID); err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
if errors.Is(err, storage.ErrLastAdmin) {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
writeError(w, status, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
||||||
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
||||||
@@ -2143,6 +2197,14 @@ func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
|
// mode=restore (MindSift data only): replace everything instead of adding next to it. The plan is
|
||||||
|
// fully read and validated at this point, so a broken file never wipes anything.
|
||||||
|
if r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift") {
|
||||||
|
if err := s.store.PurgeUserData(userID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
sum, err := s.store.ApplyImport(userID, plan)
|
sum, err := s.store.ApplyImport(userID, plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Import abgebrochen", "user_id", userID, "error", err)
|
slog.Error("Import abgebrochen", "user_id", userID, "error", err)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package domain
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -289,6 +290,36 @@ type SystemSettings struct {
|
|||||||
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
|
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole
|
||||||
|
// settings request fail with "Ungültiges JSON".
|
||||||
|
func (s *SystemSettings) UnmarshalJSON(data []byte) error {
|
||||||
|
type plain SystemSettings
|
||||||
|
aux := struct {
|
||||||
|
*plain
|
||||||
|
SMTPPort json.RawMessage `json:"smtpPort"`
|
||||||
|
}{plain: (*plain)(s)}
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
port := strings.TrimSpace(string(aux.SMTPPort))
|
||||||
|
switch {
|
||||||
|
case port == "" || port == "null":
|
||||||
|
case strings.HasPrefix(port, `"`):
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(aux.SMTPPort, &str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.SMTPPort = strings.TrimSpace(str)
|
||||||
|
default:
|
||||||
|
var n json.Number
|
||||||
|
if err := json.Unmarshal(aux.SMTPPort, &n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.SMTPPort = n.String()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type DocumentBlock struct {
|
type DocumentBlock struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DocumentID string `json:"documentId,omitempty"`
|
DocumentID string `json:"documentId,omitempty"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -32,3 +33,19 @@ func TestStaleAnchorTags(t *testing.T) {
|
|||||||
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
|
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSystemSettingsAcceptsPortAsNumberOrText(t *testing.T) {
|
||||||
|
for _, body := range []string{`{"smtpPort": 465, "smtpHost": "mail"}`, `{"smtpPort": "465", "smtpHost": "mail"}`} {
|
||||||
|
var s SystemSettings
|
||||||
|
if err := json.Unmarshal([]byte(body), &s); err != nil {
|
||||||
|
t.Fatalf("%s: %v", body, err)
|
||||||
|
}
|
||||||
|
if s.SMTPPort != "465" || s.SMTPHost != "mail" {
|
||||||
|
t.Errorf("%s: got port %q host %q", body, s.SMTPPort, s.SMTPHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var empty SystemSettings
|
||||||
|
if err := json.Unmarshal([]byte(`{"smtpPort": ""}`), &empty); err != nil || empty.SMTPPort != "" {
|
||||||
|
t.Errorf("empty port: %q (%v)", empty.SMTPPort, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrLastAdmin protects the server from losing its last administrator.
|
||||||
|
var ErrLastAdmin = errors.New("Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.")
|
||||||
|
|
||||||
|
// PurgeUserData deletes everything the user owns – thoughts and tasks, notes (with blocks, history and
|
||||||
|
// shares), folders, shared folders, task lists and lists – in one transaction. The account, its
|
||||||
|
// settings, themes, API keys and group memberships stay. Other people's tasks in the user's task lists
|
||||||
|
// become their own personal tasks, as when a single list is deleted.
|
||||||
|
func (s *Store) PurgeUserData(userID string) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
ownDocs := `(SELECT id FROM documents WHERE user_id = ?)`
|
||||||
|
ownTaskLists := `(SELECT id FROM task_lists WHERE user_id = ?)`
|
||||||
|
ownFolders := `(SELECT id FROM workspace_shared_folders WHERE owner_id = ?)`
|
||||||
|
stmts := []struct {
|
||||||
|
q string
|
||||||
|
args int // how many times userID is passed
|
||||||
|
}{
|
||||||
|
// Task lists: keep the tasks of others, drop shares and the lists
|
||||||
|
{`UPDATE thoughts SET task_list_id = '' WHERE task_list_id IN ` + ownTaskLists + ` AND user_id != ?`, 2},
|
||||||
|
{`DELETE FROM task_list_shares WHERE task_list_id IN ` + ownTaskLists, 1},
|
||||||
|
{`DELETE FROM task_lists WHERE user_id = ?`, 1},
|
||||||
|
// Tasks of others linked to blocks of the user's notes lose the link
|
||||||
|
{`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block'
|
||||||
|
AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) IN ` + ownDocs, 1},
|
||||||
|
{`DELETE FROM thoughts WHERE user_id = ?`, 1},
|
||||||
|
// Shared folders owned by the user
|
||||||
|
{`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id IN ` + ownFolders, 1},
|
||||||
|
{`DELETE FROM workspace_folder_mounts WHERE folder_id IN ` + ownFolders, 1},
|
||||||
|
{`DELETE FROM workspace_folder_shares WHERE folder_id IN ` + ownFolders, 1},
|
||||||
|
{`DELETE FROM workspace_folders WHERE shared_folder_id IN ` + ownFolders, 1},
|
||||||
|
{`DELETE FROM workspace_shared_folders WHERE owner_id = ?`, 1},
|
||||||
|
// Notes (history tables cascade with the document)
|
||||||
|
{`DELETE FROM blocks WHERE document_id IN ` + ownDocs, 1},
|
||||||
|
{`DELETE FROM block_tags WHERE document_id IN ` + ownDocs, 1},
|
||||||
|
{`DELETE FROM doc_user_folders WHERE doc_id IN ` + ownDocs, 1},
|
||||||
|
{`DELETE FROM doc_team_shares WHERE doc_id IN ` + ownDocs, 1},
|
||||||
|
{`DELETE FROM doc_shares WHERE doc_id IN ` + ownDocs, 1},
|
||||||
|
{`DELETE FROM documents WHERE user_id = ?`, 1},
|
||||||
|
// The user's own placement of notes, folders, pins and tag pages
|
||||||
|
{`DELETE FROM doc_user_folders WHERE user_id = ?`, 1},
|
||||||
|
{`DELETE FROM doc_pins WHERE user_id = ?`, 1},
|
||||||
|
{`DELETE FROM workspace_folders WHERE user_id = ?`, 1},
|
||||||
|
{`DELETE FROM tag_pages WHERE user_id = ?`, 1},
|
||||||
|
// Lists (items cascade)
|
||||||
|
{`DELETE FROM list_shares WHERE list_id IN (SELECT id FROM lists WHERE user_id = ?)`, 1},
|
||||||
|
{`DELETE FROM lists WHERE user_id = ?`, 1},
|
||||||
|
}
|
||||||
|
for _, st := range stmts {
|
||||||
|
args := make([]interface{}, st.args)
|
||||||
|
for i := range args {
|
||||||
|
args[i] = userID
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(st.q, args...); err != nil {
|
||||||
|
return fmt.Errorf("Daten löschen: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.PruneTaskAssignees()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLastAdmin reports whether userID is an administrator and no other one exists.
|
||||||
|
func (s *Store) IsLastAdmin(userID string) bool {
|
||||||
|
var role string
|
||||||
|
if err := s.db.QueryRow(`SELECT role FROM users WHERE id = ?`, userID).Scan(&role); err != nil || role != "admin" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var others int
|
||||||
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin' AND id != ?`, userID).Scan(&others)
|
||||||
|
return others == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOwnAccount removes the user with all data; refused for the last administrator.
|
||||||
|
func (s *Store) DeleteOwnAccount(userID string) error {
|
||||||
|
if s.IsLastAdmin(userID) {
|
||||||
|
return ErrLastAdmin
|
||||||
|
}
|
||||||
|
return s.DeleteUser(userID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPurgeUserDataKeepsAccountAndOthersData(t *testing.T) {
|
||||||
|
store, err := NewStore(filepath.Join(t.TempDir(), "purge.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
openSharing(t, store)
|
||||||
|
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||||
|
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||||
|
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "x"}}
|
||||||
|
|
||||||
|
annaDoc, _ := store.CreateDocument(anna.ID, "", "Annas Notiz", "", "Ordner", blocks, nil, false)
|
||||||
|
_, _ = store.CreateDocShare(anna.ID, annaDoc.ID, ben.ID, "write")
|
||||||
|
benDoc, _ := store.CreateDocument(ben.ID, "", "Bens Notiz", "", "", blocks, nil, false)
|
||||||
|
_, _ = store.CreateDocShare(ben.ID, benDoc.ID, anna.ID, "write")
|
||||||
|
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "")
|
||||||
|
_, _ = store.AddTaskListShare(tl.ID, "user", ben.ID, "editor", anna.ID)
|
||||||
|
bensTask, _ := store.CreateThoughtWithDetails(ben.ID, "Bens Aufgabe", "normal", "manual", "", tl.ID, "todo", "")
|
||||||
|
_, _ = store.UpdateThoughtTriage(ben.ID, bensTask.ID, "keep", "task", "", "", tl.ID)
|
||||||
|
_, _ = store.CreateThought(anna.ID, "Annas Gedanke", "normal", "manual", "")
|
||||||
|
list, _ := store.CreateList(anna.ID, "Einkauf", "", "shopping")
|
||||||
|
_, _ = store.CreateListItem(list.ID, "Milch", "", "", 0, anna.ID)
|
||||||
|
_, _ = store.SaveUserTheme(anna.ID, "", "Wald", ":root{--accent-primary:#16a34a}")
|
||||||
|
|
||||||
|
if err := store.PurgeUserData(anna.ID); err != nil {
|
||||||
|
t.Fatalf("PurgeUserData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if docs, _ := store.GetDocuments(anna.ID); len(docs) != 1 || docs[0].ID != benDoc.ID {
|
||||||
|
t.Errorf("Anna should only still see Ben's shared note, got %+v", docs)
|
||||||
|
}
|
||||||
|
if th, _ := store.GetThoughts(anna.ID, "all", 100); len(th) != 0 {
|
||||||
|
t.Errorf("Anna's thoughts must be gone, got %d", len(th))
|
||||||
|
}
|
||||||
|
if tls, _ := store.GetTaskLists(anna.ID); len(tls) != 0 {
|
||||||
|
t.Errorf("Anna's task lists must be gone")
|
||||||
|
}
|
||||||
|
if l, _ := store.GetAllListsForUser(anna.ID); len(l) != 0 {
|
||||||
|
t.Errorf("Anna's lists must be gone")
|
||||||
|
}
|
||||||
|
if u, err := store.GetUserByID(anna.ID); err != nil || u == nil {
|
||||||
|
t.Errorf("the account must stay")
|
||||||
|
}
|
||||||
|
if th, _ := store.GetUserThemes(anna.ID); len(th) != 1 {
|
||||||
|
t.Errorf("settings such as themes must stay")
|
||||||
|
}
|
||||||
|
// Ben keeps his task (now without list) and no longer sees Anna's note
|
||||||
|
task, err := store.GetThoughtByID(ben.ID, bensTask.ID)
|
||||||
|
if err != nil || task.TaskListID != "" {
|
||||||
|
t.Errorf("Ben's task must survive as personal task, got %+v (%v)", task, err)
|
||||||
|
}
|
||||||
|
if docs, _ := store.GetDocuments(ben.ID); len(docs) != 1 {
|
||||||
|
t.Errorf("Ben should only see his own note, got %d", len(docs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteOwnAccountProtectsLastAdmin(t *testing.T) {
|
||||||
|
store, err := NewStore(filepath.Join(t.TempDir(), "account.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
admin, _ := store.CreateUser("admin@example.com", "hash", "Admin", "admin")
|
||||||
|
member, _ := store.CreateUser("member@example.com", "hash", "Mitglied", "member")
|
||||||
|
|
||||||
|
if err := store.DeleteOwnAccount(admin.ID); !errors.Is(err, ErrLastAdmin) {
|
||||||
|
t.Fatalf("last admin must not delete the account, got %v", err)
|
||||||
|
}
|
||||||
|
if err := store.DeleteOwnAccount(member.ID); err != nil {
|
||||||
|
t.Fatalf("member deletes own account: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.GetUserByID(member.ID); err == nil {
|
||||||
|
t.Errorf("member account should be gone")
|
||||||
|
}
|
||||||
|
second, _ := store.CreateUser("admin2@example.com", "hash", "Admin 2", "admin")
|
||||||
|
if err := store.DeleteOwnAccount(admin.ID); err != nil {
|
||||||
|
t.Fatalf("with a second admin the first may leave: %v", err)
|
||||||
|
}
|
||||||
|
if !store.IsLastAdmin(second.ID) {
|
||||||
|
t.Errorf("the remaining admin is now the last one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -809,6 +809,10 @@ func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteUser(userID string) error {
|
func (s *Store) DeleteUser(userID string) error {
|
||||||
|
// Remove owned data properly first (other people's tasks in the user's lists stay theirs)
|
||||||
|
if err := s.PurgeUserData(userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
|
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID)
|
||||||
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
|
_, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID)
|
||||||
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
_, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||||
|
|||||||
Reference in new issue
Block a user