feat(mcp): OAuth so claude.ai and ChatGPT can connect as connectors
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m49s
MindSift becomes the OAuth authorization server for its own MCP endpoint: discovery (RFC 9728/8414), dynamic client registration, authorization code with PKCE (S256), rotating refresh tokens with reuse detection. The consent page lives in the app at /oauth/authorize. A grant is an api_keys entry (kind 'oauth'), so scopes, origin badges, renaming and revoking work as for keys; tokens are valid for /mcp only. Unused connections expire after OAUTH_IDLE_DAYS (default 90). Requires BASE_URL; off in demo mode. Release 0.51.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
994f6dc554
commit
75aea4a7d4
31 files changed
+1581
-49
No files matched your search
@@ -58,6 +58,7 @@ import { TasksView } from './features/triage/TasksView';
|
||||
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
import { SharedListsView } from './features/lists/SharedListsView';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { OAuthConsentView } from './features/auth/OAuthConsentView';
|
||||
import { OfflineBanner } from './components/OfflineBanner';
|
||||
import { useServerOnline } from './lib/useServerOnline';
|
||||
import { SharedImportToast } from './components/SharedImportToast';
|
||||
@@ -869,6 +870,12 @@ export const App: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Consent of the OAuth flow (claude.ai, ChatGPT connect over MCP) instead of the app; after logging in
|
||||
// the address is still /oauth/authorize, so the consent follows the login
|
||||
if (window.location.pathname === '/oauth/authorize') {
|
||||
return <OAuthConsentView user={user} instanceName={authStatus.instanceName || 'MindSift'} onLogout={handleLogout} />;
|
||||
}
|
||||
|
||||
const isServerAdmin = user.role === 'admin';
|
||||
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Brain, Bot, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
import { ApiKeyScope, OAuthAuthorizeInfo, User } from '../../lib/types';
|
||||
import { SCOPES, SCOPE_GROUPS } from '../../lib/apiScopes';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
|
||||
interface OAuthConsentViewProps {
|
||||
user: User;
|
||||
instanceName: string;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
// Consent page of the OAuth flow (/oauth/authorize): an AI client such as claude.ai wants to use
|
||||
// MindSift over MCP. The person picks the rights; the server answers with the URL to return to the
|
||||
// client (with a code or access_denied). Shown instead of the app while the address is /oauth/authorize.
|
||||
export const OAuthConsentView: React.FC<OAuthConsentViewProps> = ({ user, instanceName, onLogout }) => {
|
||||
const query = window.location.search;
|
||||
const [info, setInfo] = useState<OAuthAuthorizeInfo | null>(null);
|
||||
const [scopes, setScopes] = useState<ApiKeyScope[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getOAuthAuthorizeInfo(query)
|
||||
.then((res) => {
|
||||
// Invalid request the client must hear about: straight back
|
||||
if (res.redirect) {
|
||||
window.location.replace(res.redirect);
|
||||
return;
|
||||
}
|
||||
setInfo(res);
|
||||
setScopes(res.scopes || []);
|
||||
})
|
||||
.catch((err) => setError(err?.message || 'Die Anfrage ist ungültig.'));
|
||||
}, [query]);
|
||||
|
||||
const toggle = (id: ApiKeyScope) =>
|
||||
setScopes((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
|
||||
|
||||
const answer = async (approve: boolean) => {
|
||||
triggerHaptic(15);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.answerOAuthAuthorize(query, approve, scopes);
|
||||
window.location.replace(res.redirect);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Das hat nicht geklappt.');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const offered = info?.offeredScopes || [];
|
||||
const groups = SCOPE_GROUPS.filter(([group]) => SCOPES.some((sc) => sc.group === group && offered.includes(sc.id)));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
|
||||
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
|
||||
<div className="p-4 bg-canvas border-b border-border flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<h1 className="text-sm font-bold text-main tracking-tight leading-none">{instanceName}</h1>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{!info && !error && <p className="text-xs text-muted">Anfrage wird geprüft…</p>}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-xs text-status-err">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-px" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info && (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-accent/15 text-accent rounded-btn flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-bold text-main">{info.clientName} möchte auf dein MindSift zugreifen</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Angemeldet als <span className="text-main">{user.displayName || user.email}</span>. Freigaben gelten wie
|
||||
für dich selbst; Notizen und Einstellungen bleiben gesperrt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2" role="group" aria-label="Rechte">
|
||||
{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 && offered.includes(sc.id)).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={scopes.includes(sc.id)} onChange={() => toggle(sc.id)} title={sc.chip} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-faint flex items-center gap-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
Danach geht es zurück zu <span className="font-medium text-main">{info.redirectHost}</span>. Die Verbindung
|
||||
steht unter Einstellungen → API-Schlüssel und lässt sich dort jederzeit widerrufen.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => answer(false)}
|
||||
className="btn-action h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || scopes.length === 0}
|
||||
onClick={() => answer(true)}
|
||||
className="btn-primary h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
|
||||
>
|
||||
Erlauben
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={onLogout} className="text-[11px] text-muted hover:text-main underline h-11 sm:h-auto">
|
||||
Mit einem anderen Konto anmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
|
||||
const getOAuthAuthorizeInfo = vi.fn();
|
||||
const answerOAuthAuthorize = vi.fn(async () => ({ redirect: 'https://claude.ai/cb?code=c' }));
|
||||
vi.mock('../../../lib/api', () => ({
|
||||
api: {
|
||||
getOAuthAuthorizeInfo: (...args: unknown[]) => getOAuthAuthorizeInfo(...(args as [])),
|
||||
answerOAuthAuthorize: (...args: unknown[]) => answerOAuthAuthorize(...(args as [])),
|
||||
},
|
||||
triggerHaptic: vi.fn(),
|
||||
}));
|
||||
|
||||
import { OAuthConsentView } from '../OAuthConsentView';
|
||||
|
||||
const user = { id: 'u', email: 'a@b.de', displayName: 'Anna', role: 'member', isActive: true } as never;
|
||||
const replace = vi.fn();
|
||||
|
||||
describe('OAuthConsentView', () => {
|
||||
beforeEach(() => {
|
||||
replace.mockClear();
|
||||
answerOAuthAuthorize.mockClear();
|
||||
getOAuthAuthorizeInfo.mockReset();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { search: '?client_id=mcl_1&state=s', replace },
|
||||
});
|
||||
});
|
||||
|
||||
it('preselects the requested rights and sends the chosen ones', async () => {
|
||||
getOAuthAuthorizeInfo.mockResolvedValue({
|
||||
clientName: 'Claude',
|
||||
redirectHost: 'claude.ai',
|
||||
scopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write'],
|
||||
offeredScopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write', 'webhook:triage'],
|
||||
});
|
||||
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
|
||||
expect(await screen.findByText('Claude möchte auf dein MindSift zugreifen')).toBeTruthy();
|
||||
expect(screen.getByText('claude.ai')).toBeTruthy();
|
||||
expect(screen.getByRole('switch', { name: 'Inbox' }).getAttribute('aria-checked')).toBe('false');
|
||||
// Narrow "create only" rights are not offered here
|
||||
expect(screen.queryByRole('switch', { name: 'Aufgaben anlegen' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Listen ändern' }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Erlauben' }));
|
||||
});
|
||||
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', true, ['tasks:read', 'tasks:write', 'lists:read']);
|
||||
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?code=c');
|
||||
});
|
||||
|
||||
it('refuses, and cannot allow without any right', async () => {
|
||||
getOAuthAuthorizeInfo.mockResolvedValue({ clientName: 'X', redirectHost: 'x.example', scopes: ['tasks:read'], offeredScopes: ['tasks:read'] });
|
||||
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole('switch', { name: 'Aufgaben lesen' }));
|
||||
expect((screen.getByRole('button', { name: 'Erlauben' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ablehnen' }));
|
||||
});
|
||||
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', false, []);
|
||||
});
|
||||
|
||||
it('goes straight back when the server says the request is invalid for the client', async () => {
|
||||
getOAuthAuthorizeInfo.mockResolvedValue({ redirect: 'https://claude.ai/cb?error=invalid_request' });
|
||||
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
|
||||
await act(async () => {});
|
||||
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?error=invalid_request');
|
||||
});
|
||||
|
||||
it('shows the error when there is nowhere safe to return to', async () => {
|
||||
getOAuthAuthorizeInfo.mockRejectedValue(new Error('Die Anwendung ist MindSift nicht bekannt.'));
|
||||
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
|
||||
expect(await screen.findByText('Die Anwendung ist MindSift nicht bekannt.')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Erlauben' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
|
||||
import { SCOPES, SCOPE_GROUPS, scopeChip } from '../../lib/apiScopes';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
|
||||
@@ -19,27 +20,8 @@ interface ApiKeysPanelProps {
|
||||
onIngested?: () => void;
|
||||
}
|
||||
|
||||
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
|
||||
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
|
||||
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
|
||||
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
|
||||
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
|
||||
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
|
||||
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
|
||||
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
|
||||
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
|
||||
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
|
||||
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
|
||||
];
|
||||
const SCOPE_GROUPS: [ScopeGroup, string][] = [
|
||||
['tasks', '📋 Aufgaben'],
|
||||
['lists', '🛒 Listen'],
|
||||
['inbox', '📥 Inbox'],
|
||||
];
|
||||
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
|
||||
|
||||
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||
type McpClient = 'claude' | 'codex' | 'json';
|
||||
type McpClient = 'connector' | 'claude' | 'codex' | 'json';
|
||||
type Access = 'rest' | 'mcp';
|
||||
|
||||
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
|
||||
@@ -50,7 +32,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
|
||||
const [mcpClient, setMcpClient] = useState<McpClient>('connector');
|
||||
// Address for claude.ai / ChatGPT connectors; empty while the server has no OAuth (no BASE_URL)
|
||||
const [connectorUrl, setConnectorUrl] = useState('');
|
||||
const [copiedConnector, setCopiedConnector] = useState(false);
|
||||
const [access, setAccess] = useState<Access>('rest');
|
||||
|
||||
// Live Webhook Simulator State
|
||||
@@ -72,6 +57,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
loadKeys();
|
||||
api.getAllLists().then(setLists).catch(() => {});
|
||||
api.getTaskLists().then(setTaskLists).catch(() => {});
|
||||
api
|
||||
.getAuthStatus()
|
||||
.then((st) => setConnectorUrl(st.mcpConnectorUrl || ''))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Editing an existing key: name and rights (the secret stays the same)
|
||||
@@ -145,10 +134,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id: string) => {
|
||||
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||||
const handleDeleteKey = async (k: APIKey) => {
|
||||
if (!window.confirm(k.kind === 'oauth' ? `Die Verbindung „${k.name}“ wirklich trennen?` : 'Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||||
triggerHaptic(20);
|
||||
await api.deleteAPIKey(id);
|
||||
await api.deleteAPIKey(k.id);
|
||||
await loadKeys();
|
||||
};
|
||||
|
||||
@@ -312,8 +301,9 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
</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.
|
||||
{k.kind === 'oauth'
|
||||
? 'Die Verbindung bleibt bestehen; die neuen Rechte gelten ab sofort.'
|
||||
: '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>
|
||||
@@ -321,7 +311,13 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<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.kind === 'oauth' ? (
|
||||
<span className="px-1.5 py-0.5 rounded-btn bg-accent/15 text-accent text-[10px] font-semibold" title="Verbunden über OAuth (claude.ai, ChatGPT …)">
|
||||
OAuth
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
|
||||
)}
|
||||
{k.scopes
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
@@ -346,7 +342,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteKey(k.id)}
|
||||
onClick={() => handleDeleteKey(k)}
|
||||
className="btn-icon btn-icon-danger w-7 h-7"
|
||||
aria-label={`API-Key ${k.name} löschen`}
|
||||
>
|
||||
@@ -561,7 +557,8 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
|
||||
<p className="text-xs text-muted">
|
||||
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
|
||||
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
|
||||
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten. claude.ai und ChatGPT verbinden sich ohne
|
||||
Schlüssel: MindSift fragt beim Verbinden, was der Agent darf.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -569,6 +566,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
|
||||
{(
|
||||
[
|
||||
['connector', 'claude.ai / ChatGPT'],
|
||||
['claude', 'Claude Code'],
|
||||
['codex', 'OpenAI Codex'],
|
||||
['json', 'Claude Desktop / Cursor'],
|
||||
@@ -588,7 +586,41 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
|
||||
{mcpClient === 'connector' ? (
|
||||
connectorUrl ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 p-3 bg-canvas border border-border font-mono text-xs text-main break-all">{connectorUrl}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(connectorUrl);
|
||||
setCopiedConnector(true);
|
||||
setTimeout(() => setCopiedConnector(false), 2000);
|
||||
}}
|
||||
className="btn-icon w-11 h-11 sm:w-9 sm:h-9 flex-shrink-0"
|
||||
aria-label="Adresse kopieren"
|
||||
title="Adresse kopieren"
|
||||
>
|
||||
{copiedConnector ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
In claude.ai unter <span className="text-main">Einstellungen → Connectors → Eigenen Connector hinzufügen</span>{' '}
|
||||
diese Adresse eintragen (in ChatGPT entsprechend als Connector bzw. App). Beim Verbinden öffnet sich MindSift:
|
||||
anmelden, Rechte wählen, erlauben. Die Verbindung erscheint oben in der Liste und lässt sich dort ändern oder
|
||||
trennen; unbenutzt endet sie nach einiger Zeit von selbst.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-3 bg-canvas border border-border text-xs text-muted">
|
||||
Verbinden über claude.ai und ChatGPT braucht OAuth, und das setzt <code className="font-mono">BASE_URL</code> in
|
||||
der Server-Konfiguration voraus. Bitte die Person, die MindSift betreibt, sie zu setzen.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] text-faint">
|
||||
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
|
||||
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
|
||||
@@ -598,8 +630,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
|
||||
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
||||
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
|
||||
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
|
||||
nur die Werkzeuge, für die der Schlüssel berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
|
||||
werden noch nicht unterstützt.
|
||||
nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,9 @@ vi.mock('../../../lib/api', () => ({
|
||||
api: {
|
||||
getAPIKeys: vi.fn(async () => [
|
||||
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
|
||||
{ id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' },
|
||||
]),
|
||||
getAuthStatus: vi.fn(async () => ({ mcpConnectorUrl: 'https://ms.example/api/v1/mcp' })),
|
||||
getAllLists: vi.fn(async () => []),
|
||||
getTaskLists: vi.fn(async () => []),
|
||||
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
|
||||
@@ -49,11 +51,21 @@ describe('ApiKeysPanel', () => {
|
||||
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/claude mcp add/)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('tab', { name: /KI-Agenten/ }));
|
||||
// claude.ai / ChatGPT first: the connector address, no key
|
||||
expect(await screen.findByText('https://ms.example/api/v1/mcp')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Claude Code' }));
|
||||
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
||||
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('marks OAuth connections instead of showing a key prefix', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
expect(await screen.findByText('Claude (OAuth)')).toBeTruthy();
|
||||
expect(screen.getByTitle(/Verbunden über OAuth/)).toBeTruthy();
|
||||
expect(screen.queryByText('OAuth', { selector: '.font-mono' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renames a key and changes its rights', async () => {
|
||||
render(<ApiKeysPanel />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'API-Key Rauchmelder bearbeiten' }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
APIKey,
|
||||
OAuthAuthorizeInfo,
|
||||
AuthStatus,
|
||||
DashboardStats,
|
||||
Document,
|
||||
@@ -1322,6 +1323,32 @@ export const api = {
|
||||
await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- OAUTH CONSENT (claude.ai, ChatGPT connect to MCP) ---
|
||||
/** query: the search string of /oauth/authorize as the client sent it */
|
||||
async getOAuthAuthorizeInfo(query: string): Promise<OAuthAuthorizeInfo> {
|
||||
return request<OAuthAuthorizeInfo>(`/api/v1/oauth/authorize${query}`);
|
||||
},
|
||||
|
||||
/** Allow (with the chosen rights) or refuse; returns where to send the browser */
|
||||
async answerOAuthAuthorize(query: string, approve: boolean, scopes: ApiKeyScope[]): Promise<{ redirect: string }> {
|
||||
const q = new URLSearchParams(query);
|
||||
return request<{ redirect: string }>('/api/v1/oauth/authorize', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
responseType: q.get('response_type') || '',
|
||||
clientId: q.get('client_id') || '',
|
||||
redirectUri: q.get('redirect_uri') || '',
|
||||
codeChallenge: q.get('code_challenge') || '',
|
||||
codeChallengeMethod: q.get('code_challenge_method') || '',
|
||||
state: q.get('state') || '',
|
||||
scope: q.get('scope') || '',
|
||||
resource: q.get('resource') || '',
|
||||
approve,
|
||||
scopes,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
/** Simulator: sends like an external system would, with the given key or else the own session. */
|
||||
async testWebhookIngest(
|
||||
apiKey: string | null,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiKeyScope } from './types';
|
||||
|
||||
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
|
||||
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
|
||||
export type ScopeGroup = 'tasks' | 'lists' | 'inbox';
|
||||
export const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
|
||||
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
|
||||
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
|
||||
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
|
||||
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
|
||||
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
|
||||
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
|
||||
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
|
||||
];
|
||||
export const SCOPE_GROUPS: [ScopeGroup, string][] = [
|
||||
['tasks', '📋 Aufgaben'],
|
||||
['lists', '🛒 Listen'],
|
||||
['inbox', '📥 Inbox'],
|
||||
];
|
||||
export const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
|
||||
@@ -20,8 +20,9 @@ export const isOffline = () => typeof navigator !== 'undefined' && navigator.onL
|
||||
const DB_NAME = 'mindsift-offline';
|
||||
const STORE = 'responses';
|
||||
|
||||
// Endpoints whose answers are worth keeping; streams, long polls, note sync and downloads are not
|
||||
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/];
|
||||
// Endpoints whose answers are worth keeping; streams, long polls, note sync, downloads and the OAuth
|
||||
// consent are not
|
||||
const NOT_CACHED = [/^\/api\/v1\/(events|ws)/, /\/changes/, /\/sync/, /^\/api\/v1\/export/, /^\/api\/v1\/search/, /^\/api\/v1\/oauth/];
|
||||
|
||||
export const isCacheablePath = (path: string) => path.startsWith('/api/v1/') && !NOT_CACHED.some((re) => re.test(path));
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AuthStatus {
|
||||
sharingMode?: SharingMode;
|
||||
// "Passwort vergessen?" works (SMTP and BASE_URL configured)
|
||||
passwordReset?: boolean;
|
||||
// MCP address for claude.ai / ChatGPT connectors (OAuth); empty when the server has no BASE_URL
|
||||
mcpConnectorUrl?: string;
|
||||
// Demo mode with periodic reset: next reset (ISO) and seconds until then
|
||||
demoNextReset?: string;
|
||||
demoResetInSeconds?: number;
|
||||
@@ -674,6 +676,17 @@ export interface APIKey {
|
||||
scopes: string; // comma separated ApiKeyScope values
|
||||
lastUsedAt?: string;
|
||||
createdAt: string;
|
||||
// 'oauth': a connection agreed to on the consent page (claude.ai, ChatGPT), no secret to show
|
||||
kind?: 'key' | 'oauth';
|
||||
}
|
||||
|
||||
// What the consent page (/oauth/authorize) shows; redirect alone means "go back to the client"
|
||||
export interface OAuthAuthorizeInfo {
|
||||
redirect?: string;
|
||||
clientName?: string;
|
||||
redirectHost?: string;
|
||||
scopes?: ApiKeyScope[];
|
||||
offeredScopes?: ApiKeyScope[];
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
|
||||
Reference in new issue
Block a user