feat: edit API keys (name, rights); quick dump text above its buttons; inline note badges
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m33s
- PATCH /keys/{id} renames a key or changes its scopes; the secret stays. Settings → API gets an
edit button per key with the same switches as for a new key.
- Quick dump: a multiline text gets the full width, the buttons move to their own row below; the
bar only goes back to one line once the text is clearly shorter (no flip-flopping).
- Notes: the "Erweitert"/"Unteraufgabe" badges sit right after the checkbox text.
v0.38.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f2eb08489
commit
1e3ac9786f
14 files changed
+260
-70
No files matched your search
+11
-1
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.38.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- API-Schlüssel lassen sich bearbeiten: Name und Rechte ändern (Stift-Symbol in Einstellungen → API). Der Schlüssel selbst bleibt gleich, Geräte und Agenten müssen nichts anpassen; die Herkunfts-Badges zeigen sofort den neuen Namen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Quick Dump: Wird der Text mehrzeilig, rutschen die Knöpfe in eine eigene Zeile darunter, und der Text bekommt die volle Breite.
|
||||||
|
- Notizen: Die Badges „Erweitert“ und „Unteraufgabe“ stehen jetzt direkt hinter dem Text in derselben Zeile statt in einer eigenen Zeile darunter.
|
||||||
|
|
||||||
## [0.37.2] - 2026-11-01
|
## [0.37.2] - 2026-11-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -630,7 +639,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.0...dev
|
||||||
|
[0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0
|
||||||
[0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2
|
[0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2
|
||||||
[0.37.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...v0.37.1
|
[0.37.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...v0.37.1
|
||||||
[0.37.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...v0.37.0
|
[0.37.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...v0.37.0
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
||||||
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`; Standard `["webhook:triage"]`) |
|
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`; Standard `["webhook:triage"]`) |
|
||||||
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
|
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
|
||||||
|
| PATCH | `/keys/{id}` | Name (`name`) und/oder Rechte (`scopes`) ändern; der Schlüssel selbst bleibt gleich |
|
||||||
| DELETE | `/keys/{id}` | API-Key löschen |
|
| DELETE | `/keys/{id}` | API-Key löschen |
|
||||||
|
|
||||||
### Globale Suche (`GET /api/v1/search?q=`)
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.37.2",
|
"version": "0.38.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.37.2",
|
"version": "0.38.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.37.2",
|
"version": "0.38.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
||||||
const [isMultiline, setIsMultiline] = useState(false);
|
const [isMultiline, setIsMultiline] = useState(false);
|
||||||
|
// Text length at which the bar became multiline: there the buttons move below the text and the field
|
||||||
|
// gets wider, so it only goes back to one line once the text is clearly shorter (no flip-flopping)
|
||||||
|
const multilineAtRef = useRef(0);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLFormElement>(null);
|
const containerRef = useRef<HTMLFormElement>(null);
|
||||||
@@ -88,7 +91,16 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
if (targetEl.value) {
|
if (targetEl.value) {
|
||||||
const scrollH = targetEl.scrollHeight;
|
const scrollH = targetEl.scrollHeight;
|
||||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
const len = targetEl.value.length;
|
||||||
|
const wraps = targetEl.value.includes('\n') || scrollH > 28;
|
||||||
|
setIsMultiline((was) => {
|
||||||
|
if (!was && wraps) {
|
||||||
|
multilineAtRef.current = len;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (was && !wraps) return len >= multilineAtRef.current - 8;
|
||||||
|
return wraps;
|
||||||
|
});
|
||||||
setIsOverMaxHeight(scrollH > 140);
|
setIsOverMaxHeight(scrollH > 140);
|
||||||
} else {
|
} else {
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
@@ -256,7 +268,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
|
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
|
||||||
<div
|
<div
|
||||||
className={`flex ${
|
className={`flex ${
|
||||||
isMultiline ? 'items-end py-1.5 min-h-[36px]' : 'items-center h-9'
|
isMultiline ? 'flex-wrap items-start py-1.5 min-h-[36px]' : 'items-center h-9'
|
||||||
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
|
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
|
||||||
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
|
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
|
||||||
} transition-all duration-150 ${
|
} transition-all duration-150 ${
|
||||||
@@ -304,7 +316,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
? 'Notiz erfassen…'
|
? 'Notiz erfassen…'
|
||||||
: 'Quick Dump…'
|
: 'Quick Dump…'
|
||||||
}
|
}
|
||||||
className={`w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
|
className={`flex-1 min-w-0 w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
|
||||||
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
|
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '22px', maxHeight: '140px' }}
|
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||||
@@ -312,8 +324,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
|
className={`flex items-center gap-1 flex-shrink-0 ${
|
||||||
isMultiline ? 'self-end mb-0.5' : ''
|
// Multiline: the buttons get their own row under the text, so the text keeps the full width
|
||||||
|
isMultiline ? 'basis-full justify-end mt-1 animate-in fade-in slide-in-from-top-1 duration-150' : 'ml-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Pencil,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Webhook,
|
Webhook,
|
||||||
@@ -73,6 +74,58 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
api.getTaskLists().then(setTaskLists).catch(() => {});
|
api.getTaskLists().then(setTaskLists).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Editing an existing key: name and rights (the secret stays the same)
|
||||||
|
const [editKeyId, setEditKeyId] = useState<string | null>(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editScopes, setEditScopes] = useState<ApiKeyScope[]>([]);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const startEdit = (k: APIKey) => {
|
||||||
|
setEditKeyId(k.id);
|
||||||
|
setEditName(k.name);
|
||||||
|
setEditScopes(k.scopes.split(',').filter(Boolean) as ApiKeyScope[]);
|
||||||
|
setEditError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEditScope = (id: ApiKeyScope) => {
|
||||||
|
setEditScopes((prev) => {
|
||||||
|
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
||||||
|
return next.length > 0 ? next : prev; // at least one
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editKeyId) return;
|
||||||
|
try {
|
||||||
|
await api.updateAPIKey(editKeyId, { name: editName.trim(), scopes: editScopes });
|
||||||
|
triggerHaptic(15);
|
||||||
|
setEditKeyId(null);
|
||||||
|
await loadKeys();
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditError(err?.message || 'Speichern fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switches grouped by tasks, lists and inbox (same layout for a new and an existing key)
|
||||||
|
const renderScopeSwitches = (selected: ApiKeyScope[], toggle: (id: ApiKeyScope) => void, label: string) => (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label={label}>
|
||||||
|
{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) => (
|
||||||
|
<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={selected.includes(sc.id)} onChange={() => toggle(sc.id)} title={sc.chip} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const toggleScope = (id: ApiKeyScope) => {
|
const toggleScope = (id: ApiKeyScope) => {
|
||||||
setNewKeyScopes((prev) => {
|
setNewKeyScopes((prev) => {
|
||||||
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
||||||
@@ -211,25 +264,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
<span>API-Key erzeugen</span>
|
<span>API-Key erzeugen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label="Berechtigungen des Schlüssels">
|
{renderScopeSwitches(newKeyScopes, toggleScope, '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>
|
</form>
|
||||||
|
|
||||||
{newlyCreatedKey && (
|
{newlyCreatedKey && (
|
||||||
@@ -250,7 +285,39 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
|
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
|
||||||
) : (
|
) : (
|
||||||
keys.map((k) => (
|
keys.map((k) =>
|
||||||
|
editKeyId === k.id ? (
|
||||||
|
<div key={k.id} className="px-3 py-3 bg-canvas space-y-2">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-label="Name des Schlüssels"
|
||||||
|
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setEditKeyId(null)} className="btn-action h-11 sm:h-9 px-3 text-xs flex-1 sm:flex-none">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={!editName.trim()}
|
||||||
|
className="btn-primary h-11 sm:h-9 px-4 text-xs flex-1 sm:flex-none disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderScopeSwitches(editScopes, toggleEditScope, `Berechtigungen von ${k.name}`)}
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen
|
||||||
|
Rechte gelten ab sofort.
|
||||||
|
</p>
|
||||||
|
{editError && <p className="text-[11px] text-status-err">{editError}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
|
<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">
|
<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-sm font-medium text-main">{k.name}</span>
|
||||||
@@ -269,15 +336,26 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(k)}
|
||||||
|
className="btn-icon w-7 h-7"
|
||||||
|
aria-label={`API-Key ${k.name} bearbeiten`}
|
||||||
|
title="Name und Rechte ändern"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteKey(k.id)}
|
onClick={() => handleDeleteKey(k.id)}
|
||||||
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
|
className="btn-icon btn-icon-danger w-7 h-7"
|
||||||
aria-label={`API-Key ${k.name} löschen`}
|
aria-label={`API-Key ${k.name} löschen`}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
import { render, screen, fireEvent, act, within } from '@testing-library/react';
|
||||||
|
|
||||||
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
|
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
|
||||||
|
const updateAPIKey = vi.fn(async () => ({}));
|
||||||
vi.mock('../../../lib/api', () => ({
|
vi.mock('../../../lib/api', () => ({
|
||||||
api: {
|
api: {
|
||||||
getAPIKeys: vi.fn(async () => [
|
getAPIKeys: vi.fn(async () => [
|
||||||
@@ -11,6 +12,7 @@ vi.mock('../../../lib/api', () => ({
|
|||||||
getTaskLists: vi.fn(async () => []),
|
getTaskLists: vi.fn(async () => []),
|
||||||
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
|
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
|
||||||
deleteAPIKey: vi.fn(),
|
deleteAPIKey: vi.fn(),
|
||||||
|
updateAPIKey: (...args: unknown[]) => updateAPIKey(...(args as [])),
|
||||||
testWebhookIngest: vi.fn(),
|
testWebhookIngest: vi.fn(),
|
||||||
},
|
},
|
||||||
triggerHaptic: vi.fn(),
|
triggerHaptic: vi.fn(),
|
||||||
@@ -51,4 +53,19 @@ describe('ApiKeysPanel', () => {
|
|||||||
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
||||||
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renames a key and changes its rights', async () => {
|
||||||
|
render(<ApiKeysPanel />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'API-Key Rauchmelder bearbeiten' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Name des Schlüssels'), { target: { value: 'Rauchmelder Flur' } });
|
||||||
|
const group = screen.getByRole('group', { name: 'Berechtigungen von Rauchmelder' });
|
||||||
|
fireEvent.click(within(group).getByRole('switch', { name: 'Aufgaben lesen' }));
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
});
|
||||||
|
expect(updateAPIKey).toHaveBeenCalledWith('k0', {
|
||||||
|
name: 'Rauchmelder Flur',
|
||||||
|
scopes: ['webhook:triage', 'webhook:task', 'tasks:read'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1406,6 +1406,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
'\u200b'
|
'\u200b'
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Linked task / subtask: a short badge right after the text (inline-block, so a ticked
|
||||||
|
checkbox does not strike it through) */}
|
||||||
|
{block.type === 'todo' && (block.taskId || block.subtaskOf) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenTask?.((block.taskId || block.subtaskOf) as string);
|
||||||
|
}}
|
||||||
|
className={`${block.taskId ? 'badge-accent' : 'badge-neutral'} inline-block align-middle ml-1.5 text-[9px] leading-none px-1.5 py-0.5 rounded font-medium select-none cursor-pointer hover:opacity-80 ${
|
||||||
|
activeFocus ? 'pointer-events-none' : 'pointer-events-auto'
|
||||||
|
}`}
|
||||||
|
title={block.taskId ? 'Erweiterte Aufgabe öffnen' : 'Unteraufgabe einer verknüpften Aufgabe – Aufgabe öffnen'}
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-2.5 h-2.5 inline-block align-[-1px] mr-0.5" />
|
||||||
|
{block.taskId ? 'Erweitert' : 'Unteraufgabe'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1470,43 +1489,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* If To-Do is an enhanced task, render badge stably outside text layer */}
|
|
||||||
{block.type === 'todo' && block.taskId && (
|
|
||||||
<div className="flex items-center gap-1 mt-0.5 select-none">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenTask?.(block.taskId as string);
|
|
||||||
}}
|
|
||||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
|
|
||||||
title="Erweiterte Aufgabe öffnen"
|
|
||||||
>
|
|
||||||
<CheckSquare className="w-2.5 h-2.5" />
|
|
||||||
<span>Erweitert</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Checkbox tied to a subtask of a linked task: tick and text follow the subtask */}
|
|
||||||
{block.type === 'todo' && !block.taskId && block.subtaskOf && (
|
|
||||||
<div className="flex items-center gap-1 mt-0.5 select-none">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenTask?.(block.subtaskOf as string);
|
|
||||||
}}
|
|
||||||
className="badge-neutral text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
|
|
||||||
title="Unteraufgabe einer verknüpften Aufgabe – Aufgabe öffnen"
|
|
||||||
>
|
|
||||||
<CheckSquare className="w-2.5 h-2.5" />
|
|
||||||
<span>Unteraufgabe</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Conflict copy: text that lost against someone else's change */}
|
{/* Conflict copy: text that lost against someone else's change */}
|
||||||
{block.conflictOf && (
|
{block.conflictOf && (
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
||||||
|
|||||||
@@ -1290,6 +1290,10 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateAPIKey(id: string, patch: { name?: string; scopes?: ApiKeyScope[] }): Promise<APIKey> {
|
||||||
|
return request<APIKey>(`/api/v1/keys/${id}`, { method: 'PATCH', body: JSON.stringify(patch) });
|
||||||
|
},
|
||||||
|
|
||||||
async deleteAPIKey(id: string): Promise<void> {
|
async deleteAPIKey(id: string): Promise<void> {
|
||||||
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -126,3 +126,28 @@ func TestFilterTasks(t *testing.T) {
|
|||||||
t.Error("unknown status should fail")
|
t.Error("unknown status should fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A key can be renamed and get other rights; the secret stays, other users' keys are out of reach.
|
||||||
|
func TestUpdateAPIKey(t *testing.T) {
|
||||||
|
srv, store, token := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||||
|
key, _ := store.CreateAPIKey(claims.UserID, "Home Assistant", []string{"webhook:triage"})
|
||||||
|
|
||||||
|
rec := sessionCall(router, http.MethodPatch, "/api/v1/keys/"+key.ID, token, `{"name":"Claude Code","scopes":["tasks:read","tasks:write"]}`, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("update: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if code, _ := keyRequest(t, router, key.RawKey, http.MethodGet, "/api/v1/tasks", ""); code != http.StatusOK {
|
||||||
|
t.Fatalf("same secret should now read tasks: %d", code)
|
||||||
|
}
|
||||||
|
keys, _ := store.GetAPIKeys(claims.UserID)
|
||||||
|
if keys[0].Name != "Claude Code" {
|
||||||
|
t.Fatalf("name not changed: %+v", keys[0])
|
||||||
|
}
|
||||||
|
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||||
|
foreign, _ := store.CreateAPIKey(other.ID, "Fremd", nil)
|
||||||
|
if rec := sessionCall(router, http.MethodPatch, "/api/v1/keys/"+foreign.ID, token, `{"name":"x"}`, ""); rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("foreign key must not be changed: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,29 @@ func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, key)
|
writeJSON(w, http.StatusCreated, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleUpdateAPIKey renames a key or changes its rights; the key itself stays the same.
|
||||||
|
func (s *Server) handleUpdateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Scopes *[]string `json:"scopes"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := s.store.UpdateAPIKey(userID, chi.URLParam(r, "id"), req.Name, req.Scopes)
|
||||||
|
if errors.Is(err, storage.ErrAPIKeyNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, key)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
keyID := chi.URLParam(r, "id")
|
keyID := chi.URLParam(r, "id")
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
// API Keys Management
|
// API Keys Management
|
||||||
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
r.Get("/api/v1/keys", s.handleGetAPIKeys)
|
||||||
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
r.Post("/api/v1/keys", s.handleCreateAPIKey)
|
||||||
|
r.Patch("/api/v1/keys/{id}", s.handleUpdateAPIKey)
|
||||||
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey)
|
||||||
|
|
||||||
// Staff Routes (Server-Admin)
|
// Staff Routes (Server-Admin)
|
||||||
|
|||||||
@@ -93,6 +93,42 @@ func (s *Store) GetAPIKeys(userID string) ([]domain.APIKey, error) {
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAPIKey changes name and/or rights of an own key; the secret itself never changes. nil keeps
|
||||||
|
// a value. Returns ErrAPIKeyNotFound for keys of other users.
|
||||||
|
func (s *Store) UpdateAPIKey(userID, keyID string, name *string, scopes *[]string) (*domain.APIKey, error) {
|
||||||
|
if name != nil {
|
||||||
|
n := strings.TrimSpace(*name)
|
||||||
|
if n == "" {
|
||||||
|
return nil, errors.New("Der Name darf nicht leer sein")
|
||||||
|
}
|
||||||
|
if res, err := s.db.Exec(`UPDATE api_keys SET name = ? WHERE id = ? AND user_id = ?`, n, keyID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if c, _ := res.RowsAffected(); c == 0 {
|
||||||
|
return nil, ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scopes != nil {
|
||||||
|
joined := strings.Join(NormalizeScopes(*scopes), ",")
|
||||||
|
if res, err := s.db.Exec(`UPDATE api_keys SET scopes = ? WHERE id = ? AND user_id = ?`, joined, keyID, userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if c, _ := res.RowsAffected(); c == 0 {
|
||||||
|
return nil, ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys, err := s.GetAPIKeys(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range keys {
|
||||||
|
if keys[i].ID == keyID {
|
||||||
|
return &keys[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrAPIKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrAPIKeyNotFound = errors.New("API-Schlüssel nicht gefunden")
|
||||||
|
|
||||||
func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
func (s *Store) DeleteAPIKey(userID, keyID string) error {
|
||||||
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
_, err := s.db.Exec(`DELETE FROM api_keys WHERE id = ? AND user_id = ?`, keyID, userID)
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in new issue
Block a user