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
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:
1 parent
3900c6104e
commit
5c711c2cf7
16 files changed
+644
-518
No files matched your search
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
# REST-API
|
# REST-API
|
||||||
|
|
||||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben vorerst genau ein Recht: Daten einliefern über `POST /inbox` (Gedanke oder per `target` direkt Aufgabe) und `POST /webhooks/ingest`; alle anderen Endpunkte antworten mit `403`. Feinere Berechtigungen (Scopes) folgen. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||||
|
|
||||||
## Öffentlich
|
## Öffentlich
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
|
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
|
||||||
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||||
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
||||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
||||||
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
|
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| — | `/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) |
|
| — | `/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/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` |
|
| 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); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
|
||||||
|
|
||||||
### Globale Suche (`GET /api/v1/search?q=`)
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
|
|
||||||
@@ -197,6 +197,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
|||||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
||||||
|
|
||||||
|
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
|
||||||
|
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||||
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
|
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
||||||
|
|
||||||
# Aufgabenliste nur lesend freigeben
|
# Aufgabenliste nur lesend freigeben
|
||||||
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
||||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
| FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** |
|
| FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** |
|
||||||
| FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** |
|
| FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** |
|
||||||
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
|
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
|
||||||
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig |
|
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ | **Behoben** |
|
||||||
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
|
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
|
||||||
| FLAW-09 | Auth | ~~Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen~~ | **Behoben** |
|
| FLAW-09 | Auth | ~~Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen~~ | **Behoben** |
|
||||||
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
||||||
@@ -79,7 +79,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
||||||
|
|
||||||
### FLAW-07: API-Key-Scopes ohne Wirkung
|
### FLAW-07: API-Key-Scopes ohne Wirkung
|
||||||
- **Teilweise behoben:** API-Keys dürfen nur noch `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed` in `server.go`), alles andere gibt `403`. Auswählbare Scopes (Triage, Aufgaben, Listen) stehen noch aus.
|
- **Status: Behoben.** API-Keys dürfen nur `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed`), und nur für die Ziele ihrer Scopes: `webhook:triage`, `webhook:task`, `webhook:list` (`requireDeliveryScope`, sonst `403`). Live-Streams (WebSocket/SSE) nehmen keine Keys an. Alte Keys bekamen `webhook:triage`. Auswahl und Badges in den Kontoeinstellungen. Test: `TestAPIKeysOnlyDeliverData`.
|
||||||
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
|
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
|
||||||
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
|
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
|
||||||
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
|
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
|
||||||
|
|||||||
@@ -488,7 +488,10 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
content: (
|
content: (
|
||||||
<div className="space-y-2 text-muted leading-relaxed">
|
<div className="space-y-2 text-muted leading-relaxed">
|
||||||
<p>
|
<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 & 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>
|
||||||
<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.
|
Ü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,
|
User as UserIcon,
|
||||||
Bell,
|
Bell,
|
||||||
Key,
|
Key,
|
||||||
Plus,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Terminal,
|
|
||||||
Download,
|
Download,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Check,
|
Check,
|
||||||
@@ -20,7 +18,8 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-react';
|
} 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 { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||||
import { passwordProblem } from '../../lib/password';
|
import { passwordProblem } from '../../lib/password';
|
||||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||||
@@ -208,12 +207,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const [savingAlarms, setSavingAlarms] = useState(false);
|
const [savingAlarms, setSavingAlarms] = useState(false);
|
||||||
const [testingChannel, setTestingChannel] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setDisplayName(currentUser.displayName || '');
|
setDisplayName(currentUser.displayName || '');
|
||||||
setEmail(currentUser.email || '');
|
setEmail(currentUser.email || '');
|
||||||
@@ -225,19 +218,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadKeys = async () => {
|
|
||||||
try {
|
|
||||||
const keys = await api.getAPIKeys();
|
|
||||||
setApiKeys(keys);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAlarmChannel = (ch: string) => {
|
const toggleAlarmChannel = (ch: string) => {
|
||||||
setAlarmChannels((prev) =>
|
setAlarmChannels((prev) =>
|
||||||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
||||||
@@ -382,34 +362,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Handlers: Webhooks & Export ---
|
// --- Handlers: 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);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
// 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 saveBlob = (blob: Blob, filename: string) => {
|
||||||
const url = URL.createObjectURL(blob);
|
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 =
|
const roleBadgeLabel =
|
||||||
currentUser.role === 'admin'
|
currentUser.role === 'admin'
|
||||||
? 'Server-Admin'
|
? 'Server-Admin'
|
||||||
@@ -1226,108 +1165,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create API Key */}
|
<ApiKeysPanel />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Full Data Backup */}
|
{/* Full Data Backup */}
|
||||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
|||||||
+19
-14
@@ -4,6 +4,7 @@ import {
|
|||||||
DashboardStats,
|
DashboardStats,
|
||||||
Document,
|
Document,
|
||||||
TrashedDocument,
|
TrashedDocument,
|
||||||
|
ApiKeyScope,
|
||||||
Backlink,
|
Backlink,
|
||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
DocShare,
|
DocShare,
|
||||||
@@ -1108,10 +1109,10 @@ export const api = {
|
|||||||
return ensureArray(res);
|
return ensureArray(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createAPIKey(name: string): Promise<APIKey> {
|
async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise<APIKey> {
|
||||||
return request<APIKey>('/api/v1/keys', {
|
return request<APIKey>('/api/v1/keys', {
|
||||||
method: 'POST',
|
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' });
|
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(
|
async testWebhookIngest(
|
||||||
token: string,
|
apiKey: string | null,
|
||||||
title: string,
|
payload: {
|
||||||
priority: PriorityLevel,
|
title: string;
|
||||||
tags: string[]
|
priority?: PriorityLevel;
|
||||||
): Promise<{ status: string; thought: Thought }> {
|
tags?: string[];
|
||||||
return request<{ status: string; thought: Thought }>(
|
target?: 'triage' | 'task' | 'list_item';
|
||||||
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
|
taskListId?: string;
|
||||||
{
|
listId?: string;
|
||||||
method: 'POST',
|
}
|
||||||
body: JSON.stringify({ title, priority, tags }),
|
): 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> {
|
async searchAll(q: string): Promise<SearchResults> {
|
||||||
|
|||||||
@@ -477,13 +477,16 @@ export interface UserTheme {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What an API key may deliver; keys never read data. */
|
||||||
|
export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list';
|
||||||
|
|
||||||
export interface APIKey {
|
export interface APIKey {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
name: string;
|
name: string;
|
||||||
keyPrefix: string;
|
keyPrefix: string;
|
||||||
rawKey?: string;
|
rawKey?: string;
|
||||||
scopes: string;
|
scopes: string; // comma separated ApiKeyScope values
|
||||||
lastUsedAt?: string;
|
lastUsedAt?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
|
"github.com/sandrodach/mindsift/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- API KEYS & WEBHOOK INGESTION ---
|
// --- API KEYS & WEBHOOK INGESTION ---
|
||||||
@@ -26,11 +29,12 @@ func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
}
|
}
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
key, err := s.store.CreateAPIKey(userID, req.Name)
|
key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -59,11 +63,14 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
// Same checks as every other request (revoked/outdated sessions, inactive accounts)
|
||||||
user, _ := s.authenticateToken(token)
|
user, keyScopes := s.authenticateToken(token)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if keyScopes != nil {
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes))
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -75,6 +82,13 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
DueDate string `json:"dueDate"`
|
DueDate string `json:"dueDate"`
|
||||||
DueDate2 string `json:"due_date"`
|
DueDate2 string `json:"due_date"`
|
||||||
|
// Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well
|
||||||
|
Target string `json:"target"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TaskListID string `json:"taskListId"`
|
||||||
|
ListID string `json:"listId"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Quantity string `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyBytes, _ := io.ReadAll(r.Body)
|
bodyBytes, _ := io.ReadAll(r.Body)
|
||||||
@@ -120,8 +134,20 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Priority == "" {
|
target := strings.ToLower(strings.TrimSpace(req.Target))
|
||||||
req.Priority = "high"
|
if target == "" {
|
||||||
|
target = strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
}
|
||||||
|
switch target {
|
||||||
|
case "", "triage", "inbox":
|
||||||
|
target = "triage"
|
||||||
|
case "task", "list_item":
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireDeliveryScope(w, r, target) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||||||
@@ -129,6 +155,18 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
source = "webhook"
|
source = "webhook"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if target == "list_item" {
|
||||||
|
s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target == "task" {
|
||||||
|
s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Priority == "" {
|
||||||
|
req.Priority = "high"
|
||||||
|
}
|
||||||
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
|
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -142,3 +180,50 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
|||||||
"thought": thought,
|
"thought": thought,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of
|
||||||
|
// the key may write to.
|
||||||
|
func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) {
|
||||||
|
if priority == "" {
|
||||||
|
priority = "normal"
|
||||||
|
}
|
||||||
|
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
|
||||||
|
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "")
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task)
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to.
|
||||||
|
func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) {
|
||||||
|
if strings.TrimSpace(listID) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil {
|
||||||
|
status := http.StatusNotFound
|
||||||
|
if errors.Is(err, storage.ErrListReadOnly) {
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
writeError(w, status, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item)
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item})
|
||||||
|
}
|
||||||
@@ -60,6 +60,10 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
|
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreError(w, http.StatusBadRequest, err)
|
writeStoreError(w, http.StatusBadRequest, err)
|
||||||
|
|||||||
@@ -16,21 +16,9 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
|||||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token != "" {
|
// Live streams carry the user's data, so API keys (which only deliver) are not accepted here
|
||||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil {
|
||||||
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
|
return user.ID
|
||||||
return user.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
|
|
||||||
if !s.store.IsTokenRevoked(token) {
|
|
||||||
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
|
|
||||||
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
|
|
||||||
return claims.UserID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-16
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -382,34 +383,64 @@ func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are
|
// authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are
|
||||||
// checked against the revocation list and token_version; viaAPIKey tells the caller which it was.
|
// checked against the revocation list and token_version; viaAPIKey tells the caller which it was.
|
||||||
func (s *Server) authenticateToken(token string) (user *domain.User, viaAPIKey bool) {
|
// authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's
|
||||||
|
// scopes for API keys.
|
||||||
|
func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) {
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, false
|
return nil, nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(token, "mindsift_sec_") {
|
if strings.HasPrefix(token, "mindsift_sec_") {
|
||||||
u, err := s.store.ValidateAPIKeyAndGetUser(token)
|
u, scopes, err := s.store.ValidateAPIKey(token)
|
||||||
if err != nil || u == nil || !u.IsActive {
|
if err != nil || u == nil || !u.IsActive {
|
||||||
return nil, true
|
return nil, nil
|
||||||
}
|
}
|
||||||
return u, true
|
return u, scopes
|
||||||
}
|
}
|
||||||
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
|
||||||
if err != nil || claims == nil || s.store.IsTokenRevoked(token) {
|
if err != nil || claims == nil || s.store.IsTokenRevoked(token) {
|
||||||
return nil, false
|
return nil, nil
|
||||||
}
|
}
|
||||||
u, err := s.store.GetUserByID(claims.UserID)
|
u, err := s.store.GetUserByID(claims.UserID)
|
||||||
if err != nil || u == nil || !u.IsActive {
|
if err != nil || u == nil || !u.IsActive {
|
||||||
return nil, false
|
return nil, nil
|
||||||
}
|
}
|
||||||
if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion {
|
if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion {
|
||||||
return nil, false
|
return nil, nil
|
||||||
}
|
}
|
||||||
return u, false
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// apiKeyAllowed: until API keys get scopes, a key has exactly one right – delivering data into MindSift
|
type apiKeyScopesKey struct{}
|
||||||
// (POST /api/v1/inbox; /api/v1/webhooks/ingest authenticates on its own). Everything else needs a login.
|
|
||||||
|
// requireDeliveryScope: API keys need the scope for the target they deliver to; sessions may do all.
|
||||||
|
// target: "inbox"/"triage", "task", "list_item".
|
||||||
|
func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool {
|
||||||
|
scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string)
|
||||||
|
if !isKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
need := storage.ScopeTriage
|
||||||
|
switch target {
|
||||||
|
case "task":
|
||||||
|
need = storage.ScopeTask
|
||||||
|
case "list_item":
|
||||||
|
need = storage.ScopeList
|
||||||
|
case "document":
|
||||||
|
need = "" // notes cannot be created with a key
|
||||||
|
}
|
||||||
|
for _, sc := range scopes {
|
||||||
|
if need != "" && sc == need {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest
|
||||||
|
// authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope).
|
||||||
|
// Everything else needs a login.
|
||||||
func apiKeyAllowed(r *http.Request) bool {
|
func apiKeyAllowed(r *http.Request) bool {
|
||||||
return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox"
|
return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox"
|
||||||
}
|
}
|
||||||
@@ -422,16 +453,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|||||||
} else {
|
} else {
|
||||||
token = r.Header.Get("X-API-Key")
|
token = r.Header.Get("X-API-Key")
|
||||||
}
|
}
|
||||||
user, viaAPIKey := s.authenticateToken(token)
|
user, keyScopes := s.authenticateToken(token)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if viaAPIKey && !apiKeyAllowed(r) {
|
ctx := auth.WithUserID(r.Context(), user.ID)
|
||||||
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
|
if keyScopes != nil {
|
||||||
return
|
if !apiKeyAllowed(r) {
|
||||||
|
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes)
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -796,7 +796,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
|||||||
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
||||||
|
|
||||||
// 2. Create API key for user
|
// 2. Create API key for user
|
||||||
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook")
|
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook", nil)
|
||||||
if err != nil || apiKey == nil || apiKey.RawKey == "" {
|
if err != nil || apiKey == nil || apiKey.RawKey == "" {
|
||||||
t.Fatalf("failed to create API key: %v", err)
|
t.Fatalf("failed to create API key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1416,13 +1416,13 @@ func TestLegalTextsArePublic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API keys have exactly one right until scopes exist: delivering data. Revoked sessions are refused
|
// API keys only deliver data, and only what their scopes allow (FLAW-07). Revoked sessions are refused
|
||||||
// everywhere, the webhook ingest included.
|
// everywhere, the webhook ingest included.
|
||||||
func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
||||||
srv, store, token := setupTestServer(t)
|
srv, store, token := setupTestServer(t)
|
||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||||
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant")
|
key, err := store.CreateAPIKey(claims.UserID, "Home Assistant", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAPIKey: %v", err)
|
t.Fatalf("CreateAPIKey: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1441,8 +1441,36 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) {
|
|||||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 {
|
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 {
|
||||||
t.Fatalf("API key must be able to post into the inbox, got %d", code)
|
t.Fatalf("API key must be able to post into the inbox, got %d", code)
|
||||||
}
|
}
|
||||||
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code >= 300 {
|
// Default key = triage only
|
||||||
t.Fatalf("API key must be able to create a task via the inbox, got %d", code)
|
if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("triage-only key must not create tasks, got %d", code)
|
||||||
|
}
|
||||||
|
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Milch","target":"list_item","listId":"x"}`); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("triage-only key must not add list items, got %d", code)
|
||||||
|
}
|
||||||
|
full, _ := store.CreateAPIKey(claims.UserID, "Alles", []string{"webhook:task", "webhook:list"})
|
||||||
|
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"title":"Filter wechseln","target":"task","priority":"high"}`); code != http.StatusCreated {
|
||||||
|
t.Fatalf("task scope must create a task, got %d", code)
|
||||||
|
}
|
||||||
|
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Rauchmelder"}`); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("key without triage scope must not fill the inbox, got %d", code)
|
||||||
|
}
|
||||||
|
list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
|
||||||
|
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Milch","target":"list_item","listId":"`+list.ID+`","quantity":"2"}`); code != http.StatusCreated {
|
||||||
|
t.Fatalf("list scope must add the item, got %d", code)
|
||||||
|
}
|
||||||
|
if items, _ := store.GetListItems(list.ID); len(items) != 1 || items[0].Quantity != "2" {
|
||||||
|
t.Fatalf("list item not stored: %+v", items)
|
||||||
|
}
|
||||||
|
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||||
|
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
|
||||||
|
if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Spam","target":"list_item","listId":"`+foreign.ID+`"}`); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("key must not write into lists its owner cannot see, got %d", code)
|
||||||
|
}
|
||||||
|
// Keys never open live streams (those carry the user's data)
|
||||||
|
streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+full.RawKey, nil)
|
||||||
|
if uid := srv.authenticateUserFromReq(streamReq); uid != "" {
|
||||||
|
t.Fatalf("API key must not authenticate a live stream")
|
||||||
}
|
}
|
||||||
if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden {
|
if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden {
|
||||||
t.Fatalf("API key must not read notes, got %d", code)
|
t.Fatalf("API key must not read notes, got %d", code)
|
||||||
|
|||||||
@@ -10,7 +10,32 @@ import (
|
|||||||
|
|
||||||
// --- API KEYS & WEBHOOKS ---
|
// --- API KEYS & WEBHOOKS ---
|
||||||
|
|
||||||
func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
// Scopes of an API key: what it may deliver. A key never reads data.
|
||||||
|
const (
|
||||||
|
ScopeTriage = "webhook:triage" // thoughts into the inbox
|
||||||
|
ScopeTask = "webhook:task" // tasks directly
|
||||||
|
ScopeList = "webhook:list" // items into lists
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only.
|
||||||
|
func NormalizeScopes(scopes []string) []string {
|
||||||
|
want := map[string]bool{}
|
||||||
|
for _, sc := range scopes {
|
||||||
|
want[strings.ToLower(strings.TrimSpace(sc))] = true
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
for _, sc := range []string{ScopeTriage, ScopeTask, ScopeList} {
|
||||||
|
if want[sc] {
|
||||||
|
out = append(out, sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = []string{ScopeTriage}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIKey, error) {
|
||||||
if strings.TrimSpace(name) == "" {
|
if strings.TrimSpace(name) == "" {
|
||||||
name = "Smarthome / Webhook Key"
|
name = "Smarthome / Webhook Key"
|
||||||
}
|
}
|
||||||
@@ -22,7 +47,7 @@ func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) {
|
|||||||
KeyPrefix: prefix,
|
KeyPrefix: prefix,
|
||||||
KeyHash: hash,
|
KeyHash: hash,
|
||||||
RawKey: rawKey,
|
RawKey: rawKey,
|
||||||
Scopes: "ingest,read",
|
Scopes: strings.Join(NormalizeScopes(scopes), ","),
|
||||||
CreatedAt: domain.NowISO(),
|
CreatedAt: domain.NowISO(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +89,19 @@ func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) {
|
||||||
|
u, _, err := s.ValidateAPIKey(rawKey)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAPIKey returns the owner of a key and the key's scopes.
|
||||||
|
func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) {
|
||||||
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
hash := auth.HashAPIKey(strings.TrimSpace(rawKey))
|
||||||
var keyID, userID string
|
var keyID, userID, scopes string
|
||||||
err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID)
|
err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("invalid API key")
|
return nil, nil, errors.New("invalid API key")
|
||||||
}
|
}
|
||||||
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID)
|
||||||
return s.GetUserByID(userID)
|
u, err := s.GetUserByID(userID)
|
||||||
|
return u, NormalizeScopes(strings.Split(scopes, ",")), err
|
||||||
}
|
}
|
||||||
@@ -497,6 +497,9 @@ func (s *Store) migrate() error {
|
|||||||
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
|
_, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`)
|
||||||
_, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`)
|
_, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`)
|
||||||
|
|
||||||
|
// Keys from before scopes existed ("ingest,read") may deliver into the inbox, as before
|
||||||
|
_, _ = s.db.Exec(`UPDATE api_keys SET scopes = 'webhook:triage' WHERE scopes NOT LIKE '%webhook:%'`)
|
||||||
|
|
||||||
// Block tree, operation log and idempotency tables for collaborative editing
|
// Block tree, operation log and idempotency tables for collaborative editing
|
||||||
s.migrateDocSync()
|
s.migrateDocSync()
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Test Webhook API Key generation & validation
|
// 4. Test Webhook API Key generation & validation
|
||||||
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
|
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAPIKey failed: %v", err)
|
t.Fatalf("CreateAPIKey failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user