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

- 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:
sandrodachandClaude Opus 5.5 committed 2026-11-01 13:34:27 +01:00
1 parent 6f2eb08489
commit 1e3ac9786f
14 files changed
+266 -76

No files matched your search

@@ -59,6 +59,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(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<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null);
@@ -88,7 +91,16 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
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<InstantCaptureBarProps> = ({
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
<div
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 ${
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
} transition-all duration-150 ${
@@ -304,7 +316,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
? '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<InstantCaptureBarProps> = ({
/>
<div
className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
isMultiline ? 'self-end mb-0.5' : ''
className={`flex items-center gap-1 flex-shrink-0 ${
// 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) */}
+106 -28
View File
@@ -3,6 +3,7 @@ import {
Key,
Plus,
Trash2,
Pencil,
Copy,
Check,
Webhook,
@@ -73,6 +74,58 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
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) => {
setNewKeyScopes((prev) => {
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>
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label="Berechtigungen des Schlüssels">
{SCOPE_GROUPS.map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<div className="min-w-0">
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={on} onChange={() => toggleScope(sc.id)} title={sc.chip} />
</div>
);
})}
</div>
))}
</div>
{renderScopeSwitches(newKeyScopes, toggleScope, 'Berechtigungen des Schlüssels')}
</form>
{newlyCreatedKey && (
@@ -250,7 +285,39 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
{keys.length === 0 ? (
<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 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>
@@ -269,15 +336,26 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</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 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
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7"
aria-label={`API-Key ${k.name} löschen`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)
)
)}
</div>
</div>
@@ -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(<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'
)}
</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>
<textarea
@@ -1470,43 +1489,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</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 */}
{block.conflictOf && (
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
+4
View File
@@ -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> {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
},