feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).
Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.
Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ca715ba64
commit
b883315914
36 files changed
+1119
-563
No files matched your search
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { AgentMark } from '../lib/types';
|
||||
|
||||
interface AgentBadgeProps {
|
||||
mark: AgentMark | undefined;
|
||||
// sm: task rows and the task detail; xs: Kanban cards and list items
|
||||
size?: 'sm' | 'xs';
|
||||
}
|
||||
|
||||
const TITLES: Record<AgentMark, string> = {
|
||||
created: 'Von einem KI-Agenten per MCP angelegt. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
|
||||
edited: 'Zuletzt von einem KI-Agenten per MCP geändert. Das Zeichen verschwindet, sobald du den Eintrag selbst änderst.',
|
||||
};
|
||||
|
||||
// Marks entries an AI agent created or changed via MCP that the user has not touched since
|
||||
// (Doku/ui-patterns.md)
|
||||
export const AgentBadge: React.FC<AgentBadgeProps> = ({ mark, size = 'sm' }) => {
|
||||
if (mark !== 'created' && mark !== 'edited') return null;
|
||||
const label = mark === 'created' ? 'KI' : 'KI geändert';
|
||||
return (
|
||||
<span
|
||||
className={`px-1.5 border border-accent/40 bg-accent/10 text-accent rounded-btn font-medium inline-flex items-center gap-1 flex-shrink-0 ${
|
||||
size === 'xs' ? 'py-0.2 text-[9px]' : 'py-0.5 text-[10px]'
|
||||
}`}
|
||||
title={TITLES[mark]}
|
||||
aria-label={TITLES[mark]}
|
||||
>
|
||||
<Bot className="w-2.5 h-2.5" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Bot, Webhook } from 'lucide-react';
|
||||
import { Origin } from '../lib/types';
|
||||
|
||||
interface OriginBadgeProps {
|
||||
origin: Origin | undefined;
|
||||
// When the entry was created, for the details
|
||||
createdAt?: string;
|
||||
// xs: Kanban cards and list items; sm: task rows, inbox, detail view; md: triage cards
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
}
|
||||
|
||||
const VIA_LABEL = { api: 'API', mcp: 'MCP' } as const;
|
||||
|
||||
const formatWhen = (iso?: string) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// originDetails: who created and who last changed the entry, as one line for tooltip and detail view
|
||||
export const originDetails = (origin: Origin | undefined, createdAt?: string): string => {
|
||||
if (!origin || (!origin.createdVia && !origin.changedVia)) return '';
|
||||
const parts: string[] = [];
|
||||
const created = formatWhen(createdAt);
|
||||
if (origin.createdVia) {
|
||||
const key = origin.createdKey ? ` (${origin.createdKey})` : '';
|
||||
parts.push(`Angelegt per ${VIA_LABEL[origin.createdVia]}${key}${created ? ` am ${created}` : ''}`);
|
||||
} else {
|
||||
parts.push(`In der App angelegt${created ? ` am ${created}` : ''}`);
|
||||
}
|
||||
if (origin.changedVia) {
|
||||
const key = origin.changedKey ? ` (${origin.changedKey})` : '';
|
||||
const when = formatWhen(origin.changedAt);
|
||||
parts.push(`zuletzt geändert per ${VIA_LABEL[origin.changedVia]}${key}${when ? ` am ${when}` : ''}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: 'px-1.5 py-0.2 text-[9px]',
|
||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||
md: 'px-2 py-0.5 text-[11px]',
|
||||
};
|
||||
|
||||
// One badge for entries an API key created or changed (Doku/ui-patterns.md): icon for the access
|
||||
// (webhook = REST, robot = MCP) and the key's name. Hover shows the details; a tap opens them, so it
|
||||
// works on phones too. It stays for good.
|
||||
export const OriginBadge: React.FC<OriginBadgeProps> = ({ origin, createdAt, size = 'sm' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: Event) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', close);
|
||||
return () => document.removeEventListener('pointerdown', close);
|
||||
}, [open]);
|
||||
|
||||
const via = origin?.createdVia || origin?.changedVia;
|
||||
if (!origin || !via) return null;
|
||||
const key = origin.createdVia ? origin.createdKey : origin.changedKey;
|
||||
const label = key || VIA_LABEL[via];
|
||||
const details = originDetails(origin, createdAt);
|
||||
const Icon = via === 'mcp' ? Bot : Webhook;
|
||||
|
||||
return (
|
||||
<span ref={ref} className="relative inline-flex flex-shrink-0 max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
title={details}
|
||||
aria-label={details}
|
||||
aria-expanded={open}
|
||||
className={`relative border border-border bg-canvas text-muted rounded-btn font-medium inline-flex items-center gap-1 max-w-[12rem] cursor-pointer hover:text-main before:absolute before:content-[''] before:inset-x-0 before:-inset-y-3 sm:before:hidden ${SIZE_CLASS[size]}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className="absolute left-0 top-full mt-1 z-40 w-64 max-w-[80vw] p-2 bg-panel border border-border rounded-card shadow-panel text-[11px] leading-snug text-main whitespace-normal"
|
||||
>
|
||||
{details}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AgentBadge } from '../AgentBadge';
|
||||
|
||||
describe('AgentBadge', () => {
|
||||
it('shows nothing for entries of the user', () => {
|
||||
expect(render(<AgentBadge mark={undefined} />).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('tells created and edited entries apart', () => {
|
||||
const created = render(<AgentBadge mark="created" />).container.firstChild as HTMLElement;
|
||||
expect(created.textContent).toBe('KI');
|
||||
expect(created.getAttribute('title')).toContain('angelegt');
|
||||
const edited = render(<AgentBadge mark="edited" />).container.firstChild as HTMLElement;
|
||||
expect(edited.textContent).toBe('KI geändert');
|
||||
expect(edited.getAttribute('title')).toContain('geändert');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { OriginBadge, originDetails } from '../OriginBadge';
|
||||
|
||||
describe('OriginBadge', () => {
|
||||
it('shows nothing for entries no key touched', () => {
|
||||
expect(render(<OriginBadge origin={undefined} />).container.firstChild).toBeNull();
|
||||
expect(render(<OriginBadge origin={{}} />).container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('names the key that created the entry, else the one that changed it', () => {
|
||||
const created = render(<OriginBadge origin={{ createdVia: 'api', createdKey: 'Home Assistant', changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||
expect(created.container.textContent).toBe('Home Assistant');
|
||||
const changed = render(<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||
expect(changed.container.textContent).toBe('Claude Code');
|
||||
const old = render(<OriginBadge origin={{ createdVia: 'mcp' }} />);
|
||||
expect(old.container.textContent).toBe('MCP');
|
||||
});
|
||||
|
||||
it('opens the details on tap', () => {
|
||||
const { getByRole, queryByRole } = render(
|
||||
<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code', changedAt: '2026-11-01T09:40:00Z' }} createdAt="2026-10-31T08:00:00Z" />
|
||||
);
|
||||
expect(queryByRole('tooltip')).toBeNull();
|
||||
fireEvent.click(getByRole('button'));
|
||||
const tip = getByRole('tooltip').textContent || '';
|
||||
expect(tip).toContain('In der App angelegt');
|
||||
expect(tip).toContain('zuletzt geändert per MCP (Claude Code)');
|
||||
});
|
||||
|
||||
it('describes both created and changed', () => {
|
||||
const text = originDetails({ createdVia: 'api', createdKey: 'HA', changedVia: 'mcp', changedKey: 'Claude Code' }, '2026-11-01T08:12:00Z');
|
||||
expect(text).toMatch(/^Angelegt per API \(HA\) am .* · zuletzt geändert per MCP \(Claude Code\)$/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user