diff --git a/CHANGELOG.md b/CHANGELOG.md index 033d9bb..53a3a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [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 ### 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. - 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.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 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index eadb3bd..168c36b 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -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` | | 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 | +| PATCH | `/keys/{id}` | Name (`name`) und/oder Rechte (`scopes`) ändern; der Schlüssel selbst bleibt gleich | | DELETE | `/keys/{id}` | API-Key löschen | ### Globale Suche (`GET /api/v1/search?q=`) diff --git a/README.md b/README.md index 763284d..473879b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.37.2-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.38.0-16a34a?style=flat-square)](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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3513d9a..3f00e94 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.37.2", + "version": "0.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.37.2", + "version": "0.38.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 50dfc82..284c46f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.37.2", + "version": "0.38.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index f59aaf9..a133917 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -59,6 +59,9 @@ export const InstantCaptureBar: React.FC = ({ const [isSubmitting, setIsSubmitting] = useState(false); const [justSavedLabel, setJustSavedLabel] = useState(null); 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(null); const containerRef = useRef(null); @@ -88,7 +91,16 @@ export const InstantCaptureBar: React.FC = ({ if (targetEl.value) { const scrollH = targetEl.scrollHeight; 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); } else { setIsMultiline(false); @@ -256,7 +268,7 @@ export const InstantCaptureBar: React.FC = ({ {/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
= ({ ? 'Notiz erfassen…' : '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' }`} style={{ minHeight: '22px', maxHeight: '140px' }} @@ -312,8 +324,9 @@ export const InstantCaptureBar: React.FC = ({ />
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */} diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx index ca08c42..59702a2 100644 --- a/frontend/src/features/settings/ApiKeysPanel.tsx +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -3,6 +3,7 @@ import { Key, Plus, Trash2, + Pencil, Copy, Check, Webhook, @@ -73,6 +74,58 @@ export const ApiKeysPanel: React.FC = ({ onIngested }) => { api.getTaskLists().then(setTaskLists).catch(() => {}); }, []); + // Editing an existing key: name and rights (the secret stays the same) + const [editKeyId, setEditKeyId] = useState(null); + const [editName, setEditName] = useState(''); + const [editScopes, setEditScopes] = useState([]); + const [editError, setEditError] = useState(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) => ( +
+ {SCOPE_GROUPS.map(([group, heading]) => ( +
+

{heading}

+ {SCOPES.filter((sc) => sc.group === group).map((sc) => ( +
+
+ {sc.label} + {sc.hint} +
+ toggle(sc.id)} title={sc.chip} /> +
+ ))} +
+ ))} +
+ ); + const toggleScope = (id: ApiKeyScope) => { setNewKeyScopes((prev) => { const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]; @@ -211,25 +264,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` API-Key erzeugen
-
- {SCOPE_GROUPS.map(([group, heading]) => ( -
-

{heading}

- {SCOPES.filter((sc) => sc.group === group).map((sc) => { - const on = newKeyScopes.includes(sc.id); - return ( -
-
- {sc.label} - {sc.hint} -
- toggleScope(sc.id)} title={sc.chip} /> -
- ); - })} -
- ))} -
+ {renderScopeSwitches(newKeyScopes, toggleScope, 'Berechtigungen des Schlüssels')} {newlyCreatedKey && ( @@ -250,7 +285,39 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` {keys.length === 0 ? (

Noch keine dedizierten API-Schlüssel hinterlegt.

) : ( - keys.map((k) => ( + keys.map((k) => + editKeyId === k.id ? ( +
+
+ 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" + /> +
+ + +
+
+ {renderScopeSwitches(editScopes, toggleEditScope, `Berechtigungen von ${k.name}`)} +

+ Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen + Rechte gelten ab sofort. +

+ {editError &&

{editError}

} +
+ ) : (
{k.name} @@ -269,15 +336,26 @@ http_headers = { "X-API-Key" = "${exampleToken}" }` )}
- +
+ + +
- )) + ) + ) )}
diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx index 3d130c3..c57bf11 100644 --- a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -1,7 +1,8 @@ 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 updateAPIKey = vi.fn(async () => ({})); vi.mock('../../../lib/api', () => ({ api: { getAPIKeys: vi.fn(async () => [ @@ -11,6 +12,7 @@ vi.mock('../../../lib/api', () => ({ getTaskLists: vi.fn(async () => []), createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])), deleteAPIKey: vi.fn(), + updateAPIKey: (...args: unknown[]) => updateAPIKey(...(args as [])), testWebhookIngest: vi.fn(), }, triggerHaptic: vi.fn(), @@ -51,4 +53,19 @@ describe('ApiKeysPanel', () => { fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' })); expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy(); }); + + it('renames a key and changes its rights', async () => { + render(); + 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'], + }); + }); }); diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 412e2d1..068afa5 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -1406,6 +1406,25 @@ export const OutlinerBlock: React.FC = ({ '\u200b' )} + {/* 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) && ( + + )}