feat(api-keys): scopes for triage, tasks and list items (FLAW-07)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m33s

Keys get scopes when created (webhook:triage, webhook:task,
webhook:list; default triage, old keys become triage). The webhook
ingest learns target task (optional task list) and list_item (needs a
list the owner may write to); /inbox checks the scope of its target as
well; a missing scope answers 403. Keys can no longer open live streams,
which carried all of the owner's events.

The visible key UI lived in the account settings while EcosystemView
was never rendered; it is replaced by ApiKeysPanel in the settings with
scope chips, scope badges per key and a simulator for all three
targets. Examples send the key as X-API-Key header, not in the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 14:36:17 +01:00
1 parent 3900c6104e
commit 5c711c2cf7
16 files changed
+644 -518

No files matched your search

+4 -1
View File
@@ -488,7 +488,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
content: (
<div className="space-y-2 text-muted leading-relaxed">
<p>
Unter <em className="text-main">Benutzereinstellungen → Webhooks & API</em> kannst du persönliche API-Schlüssel generieren.
Unter <em className="text-main">Kontoeinstellungen → Private Webhooks &amp; Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie.
</p>
<p>
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
</p>
<p>
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
@@ -0,0 +1,380 @@
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Zap,
Terminal,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may deliver; keys never read anything
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
];
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
type SimTarget = 'triage' | 'task' | 'list_item';
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
const [simTarget, setSimTarget] = useState<SimTarget>('triage');
const [simListId, setSimListId] = useState('');
const [simTaskListId, setSimTaskListId] = useState('');
const [simStatus, setSimStatus] = useState<string | null>(null);
const [lists, setLists] = useState<SharedList[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const loadKeys = async () => {
const k = await api.getAPIKeys();
setKeys(k);
};
useEffect(() => {
loadKeys();
api.getAllLists().then(setLists).catch(() => {});
api.getTaskLists().then(setTaskLists).catch(() => {});
}, []);
const toggleScope = (id: ApiKeyScope) => {
setNewKeyScopes((prev) => {
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
return next.length > 0 ? next : prev; // at least one
});
};
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes);
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
setNewKeyScopes(['webhook:triage']);
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
triggerHaptic(20);
await api.deleteAPIKey(id);
await loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleSimulateWebhook = async () => {
triggerHaptic(20);
if (simTarget === 'list_item' && !simListId) {
setSimStatus('❌ Bitte eine Liste wählen.');
return;
}
try {
// With a freshly created key the simulator tests exactly that key (and its scopes)
await api.testWebhookIngest(newlyCreatedKey, {
title: simTitle,
priority: simPriority,
tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'],
target: simTarget,
taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined,
listId: simTarget === 'list_item' ? simListId : undefined,
});
setSimStatus(
simTarget === 'task'
? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.'
: simTarget === 'list_item'
? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.'
: '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'
);
onIngested?.();
setTimeout(() => setSimStatus(null), 4000);
} catch (err: any) {
setSimStatus(`❌ Fehler: ${err.message}`);
}
};
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
const baseUrl = window.location.origin;
const examplePayload =
simTarget === 'task'
? `{
"target": "task",
"title": "Filter der Lüftungsanlage wechseln",
"priority": "high",
"dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''}
}`
: simTarget === 'list_item'
? `{
"target": "list_item",
"listId": "${simListId || 'ID_DER_LISTE'}",
"content": "Hafermilch",
"quantity": "2"
}`
: `{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}`;
return (
<div className="space-y-6">
{/* API keys */}
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
<p className="text-xs text-muted">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
einliefern, nie lesen; was genau, legst du pro Key fest.
</p>
</div>
</div>
<form onSubmit={handleCreateKey} className="mt-3 space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
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-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>
</button>
</div>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
<span className="text-xs text-muted">Darf einliefern:</span>
{SCOPES.map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<button
key={sc.id}
type="button"
role="checkbox"
aria-checked={on}
title={sc.hint}
onClick={() => toggleScope(sc.id)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3.5 h-3.5 text-accent" />}
{sc.label}
</button>
);
})}
</div>
</form>
{newlyCreatedKey && (
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-accent mb-1">Neuer API-Schlüssel erstellt (bitte jetzt kopieren):</p>
<code className="text-xs sm:text-sm font-mono text-main break-all">{newlyCreatedKey}</code>
</div>
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-8 px-3 text-xs">
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
</button>
</div>
)}
{/* Active Keys List */}
<div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? (
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
) : (
keys.map((k) => (
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-main">{k.name}</span>
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.scopes
.split(',')
.filter(Boolean)
.map((sc) => (
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
{scopeLabel(sc)}
</span>
))}
{k.lastUsedAt && (
<span className="text-xs text-faint">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<button
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
aria-label={`API-Key ${k.name} löschen`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Simulator */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
<p className="text-xs text-muted">
Teste den Webhook-Endpunkt direkt hier
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
{(
[
['triage', '📥 Inbox'],
['task', '📋 Aufgabe'],
['list_item', '📝 Listeneintrag'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={simTarget === value}
onClick={() => setSimTarget(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{label}
</button>
))}
{simTarget === 'list_item' && (
<select
value={simListId}
onChange={(e) => setSimListId(e.target.value)}
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
aria-label="Liste"
>
<option value="">Liste wählen…</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{l.icon} {l.title}
</option>
))}
</select>
)}
{simTarget === 'task' && (
<select
value={simTaskListId}
onChange={(e) => setSimTaskListId(e.target.value)}
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
aria-label="Aufgabenliste"
>
<option value="">Ohne Aufgabenliste</option>
{taskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{tl.icon} {tl.title}
</option>
))}
</select>
)}
</div>
{simTarget === 'triage' && (
<div className="flex flex-wrap gap-1.5 mb-3">
{[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setSimTitle(preset.text);
setSimPriority(preset.prio);
}}
className="btn-action text-xs"
>
{preset.label}
</button>
))}
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={simTitle}
onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<button
type="button"
onClick={handleSimulateWebhook}
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
>
<Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span>
</button>
</div>
{simStatus && (
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
)}
{/* Code Example Snippets */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
-H "X-API-Key: ${exampleToken}" \\
-H "Content-Type: application/json" \\
-d '${examplePayload}'`}
</pre>
<p className="text-[11px] text-faint">
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
</p>
</div>
</div>
</div>
);
};
@@ -1,283 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Download,
ShieldCheck,
Zap,
Terminal,
} from 'lucide-react';
import { APIKey, PriorityLevel } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
interface EcosystemViewProps {
onIngested: () => void;
}
export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
const [simStatus, setSimStatus] = useState<string | null>(null);
const loadKeys = async () => {
const k = await api.getAPIKeys();
setKeys(k);
};
useEffect(() => {
loadKeys();
}, []);
const handleCreateKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
await loadKeys();
};
const handleDeleteKey = async (id: string) => {
triggerHaptic(20);
await api.deleteAPIKey(id);
await loadKeys();
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
const handleSimulateWebhook = async () => {
triggerHaptic(20);
const tokenToUse = newlyCreatedKey || getToken() || '';
try {
await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']);
setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.');
onIngested();
setTimeout(() => setSimStatus(null), 4000);
} catch (err: any) {
setSimStatus(`❌ Fehler: ${err.message}`);
}
};
const handleFullExport = async () => {
triggerHaptic(15);
const data = await api.exportAll();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
const baseUrl = window.location.origin;
return (
<div className="w-full panel-surface divide-y divide-border">
{/* Section 1: API Keys for Smarthome & Automation */}
<div className="p-4">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
Webhooks & Ingestion API-Schlüssel
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
</p>
</div>
</div>
<form onSubmit={handleCreateKey} className="mt-3 flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
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-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>
</button>
</form>
{newlyCreatedKey && (
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-accent mb-1">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
</p>
<code className="text-xs sm:text-sm font-mono text-white break-all">
{newlyCreatedKey}
</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey)}
className="btn-primary h-8 px-3 text-xs"
>
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
</button>
</div>
)}
{/* Active Keys List */}
<div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? (
<p className="text-xs text-zinc-500 px-3 py-2">
Noch keine dedizierten API-Schlüssel hinterlegt.
</p>
) : (
keys.map((k) => (
<div
key={k.id}
className="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
>
<div>
<span className="text-sm font-medium text-white">{k.name}</span>
<span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.lastUsedAt && (
<span className="ml-3 text-xs text-zinc-500">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<button
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
</div>
{/* Section 2: Interactive Smarthome Webhook Simulator */}
<div className="p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
Live Smarthome Webhook-Simulator
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
].map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
setSimTitle(preset.text);
setSimPriority(preset.prio);
}}
className="btn-action text-xs"
>
{preset.label}
</button>
))}
</div>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={simTitle}
onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-9 px-3 input-field text-sm"
/>
<button
type="button"
onClick={handleSimulateWebhook}
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-1.5"
>
<Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span>
</button>
</div>
{simStatus && (
<p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
)}
{/* Code Example Snippets */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-zinc-300 overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
-H "Content-Type: application/json" \\
-d '{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}'`}
</pre>
</div>
</div>
{/* Section 3: Data Sovereignty & Export */}
<div className="p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<ShieldCheck className="w-4 h-4" />
</div>
<div>
<h3 className="text-base font-bold text-white">
100 % Datensouveränität & Komplett-Export
</h3>
<p className="text-xs sm:text-sm text-zinc-400">
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
</p>
</div>
</div>
<button
onClick={handleFullExport}
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Komplett-Backup (.json)</span>
</button>
</div>
</div>
);
};
@@ -3,9 +3,7 @@ import {
User as UserIcon,
Bell,
Key,
Plus,
Trash2,
Terminal,
Download,
ArrowLeft,
Check,
@@ -20,7 +18,8 @@ import {
Pencil,
Copy,
} from 'lucide-react';
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
import { AuthStatus, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { api, clearToken, triggerHaptic } from '../../lib/api';
import { passwordProblem } from '../../lib/password';
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
@@ -208,12 +207,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// --- Webhooks & API Keys State ---
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
@@ -225,19 +218,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]);
useEffect(() => {
loadKeys();
}, []);
const loadKeys = async () => {
try {
const keys = await api.getAPIKeys();
setApiKeys(keys);
} catch {
// ignore
}
};
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) =>
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
@@ -382,34 +362,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
// --- Handlers: Webhooks & Export ---
const handleCreateAPIKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
try {
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
loadKeys();
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAPIKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
await api.deleteAPIKey(id);
loadKeys();
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopiedLabel(label);
setTimeout(() => setCopiedLabel(null), 2000);
};
// --- Handlers: Export ---
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
const saveBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
@@ -448,20 +401,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
const baseUrl = window.location.origin;
const activeKeyPlaceholder =
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-Admin'
@@ -1226,108 +1165,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</div>
{/* Create API Key */}
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
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-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>
</button>
</form>
{newlyCreatedKey && (
<div className="p-3 bg-accent-dim border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] text-accent font-bold">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im
Klartext angezeigt):
</p>
<code className="text-xs font-mono text-main break-all">{newlyCreatedKey}</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey, 'key')}
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
>
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
</button>
</div>
)}
{/* Existing Keys List */}
<div className="border border-border divide-y divide-border bg-canvas">
{apiKeys.length === 0 ? (
<div className="p-4 text-center text-faint">
Noch keine persönlichen API-Schlüssel vorhanden.
</div>
) : (
apiKeys.map((k) => (
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-main">{k.name}</p>
<p className="text-[11px] text-faint font-mono">
Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '}
{new Date(k.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteAPIKey(k.id)}
className="btn-icon btn-icon-danger"
title="Schlüssel widerrufen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Curl Examples */}
<div className="space-y-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
</span>
<button
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
{curlTriageExample}
</pre>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl: Direkt als Aufgabe anlegen</span>
</span>
<button
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
{curlTaskExample}
</pre>
</div>
</div>
<ApiKeysPanel />
{/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
+19 -14
View File
@@ -4,6 +4,7 @@ import {
DashboardStats,
Document,
TrashedDocument,
ApiKeyScope,
Backlink,
DocumentBlock,
DocShare,
@@ -1108,10 +1109,10 @@ export const api = {
return ensureArray(res);
},
async createAPIKey(name: string): Promise<APIKey> {
async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise<APIKey> {
return request<APIKey>('/api/v1/keys', {
method: 'POST',
body: JSON.stringify({ name }),
body: JSON.stringify({ name, scopes }),
});
},
@@ -1119,19 +1120,23 @@ export const api = {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},
/** Simulator: sends like an external system would, with the given key or else the own session. */
async testWebhookIngest(
token: string,
title: string,
priority: PriorityLevel,
tags: string[]
): Promise<{ status: string; thought: Thought }> {
return request<{ status: string; thought: Thought }>(
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
{
method: 'POST',
body: JSON.stringify({ title, priority, tags }),
}
);
apiKey: string | null,
payload: {
title: string;
priority?: PriorityLevel;
tags?: string[];
target?: 'triage' | 'task' | 'list_item';
taskListId?: string;
listId?: string;
}
): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/webhooks/ingest', {
method: 'POST',
headers: apiKey ? { 'X-API-Key': apiKey } : {},
body: JSON.stringify(payload),
});
},
async searchAll(q: string): Promise<SearchResults> {
+4 -1
View File
@@ -477,13 +477,16 @@ export interface UserTheme {
updatedAt: string;
}
/** What an API key may deliver; keys never read data. */
export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list';
export interface APIKey {
id: string;
userId: string;
name: string;
keyPrefix: string;
rawKey?: string;
scopes: string;
scopes: string; // comma separated ApiKeyScope values
lastUsedAt?: string;
createdAt: string;
}