Files
MindSift/frontend/src/features/settings/ApiKeysPanel.tsx
T
sandrodachandClaude Opus 5.5 b883315914
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).

Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.

Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:26:44 +01:00

532 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState } from 'react';
import {
Key,
Plus,
Trash2,
Copy,
Check,
Webhook,
Zap,
Terminal,
Bot,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface ApiKeysPanelProps {
onIngested?: () => void;
}
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
];
const SCOPE_GROUPS: [ScopeGroup, string][] = [
['tasks', '📋 Aufgaben'],
['lists', '🛒 Listen'],
['inbox', '📥 Inbox'],
];
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'claude' | 'codex' | 'json';
type Access = 'rest' | 'mcp';
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
// delivery simulator) or AI agents over MCP (settings → API)
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);
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
const [access, setAccess] = useState<Access>('rest');
// 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"]
}`;
const mcpUrl = `${baseUrl}/api/v1/mcp`;
const mcpSnippet =
mcpClient === 'claude'
? `claude mcp add --transport http mindsift ${mcpUrl} \\
--header "X-API-Key: ${exampleToken}"`
: mcpClient === 'codex'
? `# ~/.codex/config.toml
[mcp_servers.mindsift]
url = "${mcpUrl}"
http_headers = { "X-API-Key" = "${exampleToken}" }`
: `{
"mcpServers": {
"mindsift": {
"type": "http",
"url": "${mcpUrl}",
"headers": { "X-API-Key": "${exampleToken}" }
}
}
}`;
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">API-Schlüssel</h3>
<p className="text-xs text-muted">
Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich. Notizen und
Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel anlegt oder ändert, trägt seinen Namen als
Badge.
</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="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label="Berechtigungen des Schlüssels">
{SCOPE_GROUPS.map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<div className="min-w-0">
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.chip} />
</div>
);
})}
</div>
))}
</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-muted text-[10px]">
{scopeChip(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>
{/* How to connect: one API, two accesses */}
<div className="pt-4 border-t border-border space-y-4">
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
{(
[
['rest', 'curl / Smart Home', Terminal],
['mcp', 'KI-Agenten (MCP)', Bot],
] as const
).map(([value, label, Icon]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={access === value}
onClick={() => setAccess(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
<Icon className="w-3.5 h-3.5 text-accent" />
{label}
</button>
))}
</div>
{access === 'rest' && (
<div className="space-y-5">
{/* Reading: e.g. a smart home sensor for what is due today */}
<div>
<div className="flex items-center gap-2 text-xs font-medium text-muted mb-2">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Lesen, z. B. was heute fällig ist (Recht „Aufgaben lesen“):</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`curl "${baseUrl}/api/v1/tasks?due=today&status=open" \\
-H "X-API-Key: ${exampleToken}"`}
</pre>
<p className="mt-2 text-[11px] text-faint">
Filter: <code className="font-mono">due=today</code>, <code className="font-mono">due=overdue</code> oder ein
Datum (<code className="font-mono">JJJJ-MM-TT</code>, fällig bis einschließlich), <code className="font-mono">status=open|done</code>,{' '}
<code className="font-mono">taskListId</code>. Listen: <code className="font-mono">GET /api/v1/lists</code> und{' '}
<code className="font-mono">/lists/ID</code>. Mit „Ändern“ auch <code className="font-mono">PATCH /api/v1/thoughts/ID</code>{' '}
(z. B. <code className="font-mono">{'{"isCompleted": true}'}</code>).
</p>
<div className="flex items-center gap-2 text-xs font-medium text-muted mt-3 mb-2">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Home Assistant: Sensor „Heute fällig“ (configuration.yaml)</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`rest:
- resource: "${baseUrl}/api/v1/tasks?due=today&status=open"
headers:
X-API-Key: "${exampleToken}"
scan_interval: 900
sensor:
- name: "MindSift heute fällig"
value_template: "{{ value_json | length }}"`}
</pre>
</div>
<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">Einliefern testen (Webhook)</h3>
<p className="text-xs text-muted">
Teste das Einliefern per <code className="font-mono">POST /api/v1/webhooks/ingest</code> 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>
)}
{access === 'mcp' && (
<div>
<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">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{(
[
['claude', 'Claude Code'],
['codex', 'OpenAI Codex'],
['json', 'Claude Desktop / Cursor'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mcpClient === value}
onClick={() => setMcpClient(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{label}
</button>
))}
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
<code className="font-mono">complete_task</code>, <code className="font-mono">delete_task</code>,{' '}
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
werden noch nicht unterstützt.
</p>
</div>
)}
</div>
</div>
);
};