feat(mcp): lead with Claude Desktop/ChatGPT connect steps, collapse tokens (#695)
## What & why The **AI Connector** settings page was built token-first, but tokens are only needed for **Claude Code**. Most people connect via **Claude Desktop** or **ChatGPT**, which sign in with OAuth (no token). This reorders the page around that reality. ## Changes - **Lead with the connection instructions.** A **Claude Desktop / ChatGPT** tab selector (`ToggleGroup`) is now the first card. Each tab renders the exact ordered steps for that app, with the copyable connector URL inline. - **ChatGPT developer mode.** The ChatGPT tab's first step tells the user to turn on developer mode, which is currently required to add a custom connector. - **Collapse the token stuff.** Token management (Create a token, Your tokens, rotate/revoke) and the Claude Code CLI command moved into a **"Connect with Claude Code"** section that is **collapsed by default**. It auto-opens right after a token is minted so the one-time secret and the list stay visible. - **Copy tweaks.** The one-time-secret alert stays pinned at the top; the non-Pro alert copy no longer references "create a token" as the primary action. ## Review notes (findings applied) - **Bug fix:** rotate/revoke used `router` visits without `preserveState`, which remounted the page and snapped the collapsed section shut (and reset the selected tab). Both now pass `preserveState: true`. - Trigger markup made valid (no block elements inside `<button>`), consistency nits (`cursor-pointer`, chevron animation), and orphaned `es.json` keys removed. ## Trade-off to confirm Per the request, rotate/revoke now live inside the collapsed "Connect with Claude Code" section. Revocation is therefore one expand away rather than always visible. Flagging in case we want to surface revoke outside the collapsible later. ## Demo https://github.com/user-attachments/assets/82b4060d-b7fe-40b3-a3a6-3d9d5425800c ## QA Browser QA (Playwright, Pro account) covering: initial layout (instructions first, token section collapsed), switching Claude Desktop ↔ ChatGPT tabs, expanding the developer section, creating a token (one-time secret shown), and **revoking a token with the section staying open** (verifies the `preserveState` fix). All steps passed.
This commit is contained in:
parent
5621e90879
commit
2d7e905ddd
17
lang/es.json
17
lang/es.json
|
|
@ -2263,18 +2263,25 @@
|
|||
"Rotate this token?": "¿Rotar este token?",
|
||||
"Rotating replaces the secret. Any AI client using the current token will stop working until you reconnect it with the new one.": "Rotar reemplaza el secreto. Cualquier cliente de IA que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo.",
|
||||
"AI Connector": "Conector de IA",
|
||||
"You can create a token now, but it only works on a paid plan.": "Puedes crear un token ahora, pero solo funciona con un plan de pago.",
|
||||
"You can set it up now, but it only works on a paid plan.": "Puedes configurarlo ahora, pero solo funciona con un plan de pago.",
|
||||
"Anything you ask about is sent to the AI app you connect, and we cannot control what it does with your data. Connect one only if you are comfortable with that. You can revoke a token any time to cut off access.": "Todo lo que consultes se envía a la app de IA que conectes, y no podemos controlar qué hace con tus datos. Conéctala solo si te parece bien. Puedes revocar un token cuando quieras para cortar el acceso.",
|
||||
"This is the only time you will see it, so copy it somewhere safe now.": "Es la única vez que lo verás, así que cópialo ahora en un lugar seguro.",
|
||||
"Tokens can read and analyse your data, but never change it.": "Los tokens pueden leer y analizar tus datos, pero nunca modificarlos.",
|
||||
"Rotate a token if it leaks, or revoke it to cut off access.": "Rota un token si se filtra, o revócalo para cortar el acceso.",
|
||||
"Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.": "Al rotar obtienes un secreto nuevo y se cancela el anterior. Lo que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo secreto.",
|
||||
"Anything using this token loses access right away, and you cannot undo it.": "Cualquier cosa que use este token pierde el acceso al instante, y no se puede deshacer.",
|
||||
"Connect your AI assistant using the details for it below.": "Conecta tu asistente de IA usando los datos correspondientes de abajo.",
|
||||
"Run this, using one of your tokens in place of <token>.": "Ejecuta esto, usando uno de tus tokens en lugar de <token>.",
|
||||
"Claude Desktop & ChatGPT": "Claude Desktop y ChatGPT",
|
||||
"These apps sign in instead of using a token. Add a custom connector pointing at the URL below, then approve the connection on the Whisper Money screen that opens. No token needed.": "Estas apps inician sesión en lugar de usar un token. Añade un conector personalizado que apunte a la URL de abajo y luego aprueba la conexión en la pantalla de Whisper Money que se abre. No hace falta ningún token.",
|
||||
"Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.": "Las apps conectadas pueden leer, analizar y modificar tus datos (las cuentas conectadas a un banco siguen siendo de solo lectura). Tú apruebas la conexión en la pantalla de Whisper Money.",
|
||||
"Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only).": "Las apps conectadas pueden leer, analizar y modificar tus datos (las cuentas conectadas a un banco siguen siendo de solo lectura).",
|
||||
"Pick your app, then follow the steps. You sign in and approve the connection — no token needed.": "Elige tu app y sigue los pasos. Inicias sesión y apruebas la conexión: no hace falta ningún token.",
|
||||
"Claude Desktop": "Claude Desktop",
|
||||
"Open Settings → Connectors in Claude Desktop.": "Abre Ajustes → Conectores en Claude Desktop.",
|
||||
"Click \"Add custom connector\".": "Haz clic en «Añadir conector personalizado».",
|
||||
"Give it a name and paste this URL:": "Ponle un nombre y pega esta URL:",
|
||||
"Approve the connection on the Whisper Money screen that opens.": "Aprueba la conexión en la pantalla de Whisper Money que se abre.",
|
||||
"Turn on developer mode: Settings → Connectors → Advanced → Developer mode. Custom connectors are only available with it on.": "Activa el modo desarrollador: Ajustes → Conectores → Avanzado → Modo desarrollador. Los conectores personalizados solo están disponibles con él activado.",
|
||||
"In Connectors, click \"Create\".": "En Conectores, haz clic en «Crear».",
|
||||
"Connect with Claude Code": "Conectar con Claude Code",
|
||||
"For developers. Claude Code signs in with a token instead of the browser flow.": "Para desarrolladores. Claude Code inicia sesión con un token en lugar del flujo del navegador.",
|
||||
"Connect to :app": "Conectar con :app",
|
||||
"Connect :client to :app": "Conectar :client con :app",
|
||||
":client is asking to read and make changes to your finance data.": ":client solicita leer y modificar tus datos financieros.",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
|
|
@ -36,13 +41,23 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { useClipboard } from '@/hooks/use-clipboard';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { Copy, KeyRound, RefreshCw, ShieldAlert, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TokenRow {
|
||||
|
|
@ -69,10 +84,18 @@ function formatDate(value: string | null): string {
|
|||
return value ? new Date(value).toLocaleString() : __('Never');
|
||||
}
|
||||
|
||||
type ConnectorApp = 'claude' | 'chatgpt';
|
||||
|
||||
export default function Mcp() {
|
||||
const { tokens, serverUrl, oauthUrl, subscribeUrl, newToken, auth } =
|
||||
usePage<SharedData & McpPageProps>().props;
|
||||
const [, copy] = useClipboard();
|
||||
const [connector, setConnector] = useState<ConnectorApp>('claude');
|
||||
// A freshly minted token means the user just used the developer flow, so
|
||||
// keep that section open; otherwise it starts collapsed for everyone else.
|
||||
const [showDeveloper, setShowDeveloper] = useState<boolean>(
|
||||
Boolean(newToken),
|
||||
);
|
||||
|
||||
const form = useForm<{ name: string; scope: 'read' | 'read_write' }>({
|
||||
name: '',
|
||||
|
|
@ -95,6 +118,59 @@ export default function Mcp() {
|
|||
});
|
||||
}
|
||||
|
||||
const oauthUrlBlock = (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{oauthUrl}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyValue(oauthUrl)}
|
||||
aria-label={__('Copy')}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const connectors: Record<
|
||||
ConnectorApp,
|
||||
{ label: string; steps: ReactNode[] }
|
||||
> = {
|
||||
claude: {
|
||||
label: __('Claude Desktop'),
|
||||
steps: [
|
||||
__('Open Settings → Connectors in Claude Desktop.'),
|
||||
__('Click "Add custom connector".'),
|
||||
<>
|
||||
{__('Give it a name and paste this URL:')}
|
||||
{oauthUrlBlock}
|
||||
</>,
|
||||
__(
|
||||
'Approve the connection on the Whisper Money screen that opens.',
|
||||
),
|
||||
],
|
||||
},
|
||||
chatgpt: {
|
||||
label: __('ChatGPT'),
|
||||
steps: [
|
||||
__(
|
||||
'Turn on developer mode: Settings → Connectors → Advanced → Developer mode. Custom connectors are only available with it on.',
|
||||
),
|
||||
__('In Connectors, click "Create".'),
|
||||
<>
|
||||
{__('Give it a name and paste this URL:')}
|
||||
{oauthUrlBlock}
|
||||
</>,
|
||||
__(
|
||||
'Approve the connection on the Whisper Money screen that opens.',
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title={__('AI Connector')} />
|
||||
|
|
@ -118,7 +194,7 @@ export default function Mcp() {
|
|||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{__(
|
||||
'You can create a token now, but it only works on a paid plan.',
|
||||
'You can set it up now, but it only works on a paid plan.',
|
||||
)}{' '}
|
||||
<Link
|
||||
href={subscribeUrl}
|
||||
|
|
@ -172,286 +248,363 @@ export default function Mcp() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Create token */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Create a token')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__(
|
||||
'Read-only tokens can analyse your data. Read & write tokens can also create, edit and delete transactions, categories, labels and automation rules.',
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={createToken}
|
||||
className="flex flex-col gap-4 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="token-name">
|
||||
{__('Name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
value={form.data.name}
|
||||
onChange={(e) =>
|
||||
form.setData('name', e.target.value)
|
||||
}
|
||||
placeholder={__('e.g. Claude Desktop')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-scope">
|
||||
{__('Access')}
|
||||
</Label>
|
||||
{/* Radix Select renders a hidden native <select> next to the
|
||||
trigger; wrap it so the parent's space-y never pushes that
|
||||
hidden node below the trigger and breaks the row alignment. */}
|
||||
<div className="w-full sm:w-48">
|
||||
<Select
|
||||
value={form.data.scope}
|
||||
onValueChange={(value) =>
|
||||
form.setData(
|
||||
'scope',
|
||||
value as
|
||||
| 'read'
|
||||
| 'read_write',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="token-scope"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read">
|
||||
{__('Read only')}
|
||||
</SelectItem>
|
||||
<SelectItem value="read_write">
|
||||
{__('Read & write')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.processing}
|
||||
>
|
||||
{__('Create token')}
|
||||
</Button>
|
||||
</form>
|
||||
{form.errors.name && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{form.errors.name}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Token list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Your tokens')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__(
|
||||
'Rotate a token if it leaks, or revoke it to cut off access.',
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tokens.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('You have no tokens yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<li
|
||||
key={token.id}
|
||||
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{token.name}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{token.scope ===
|
||||
'read_write'
|
||||
? __('Read & write')
|
||||
: __('Read only')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Created')}:{' '}
|
||||
{formatDate(
|
||||
token.created_at,
|
||||
)}{' '}
|
||||
· {__('Last used')}:{' '}
|
||||
{formatDate(
|
||||
token.last_used_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{__('Rotate')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{__(
|
||||
'Rotate this token?',
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__(
|
||||
'Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.',
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{__('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
router.post(
|
||||
rotate(
|
||||
token.id,
|
||||
).url,
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{__('Rotate')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{__('Revoke')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{__(
|
||||
'Revoke this token?',
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__(
|
||||
'Anything using this token loses access right away, and you cannot undo it.',
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{__('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
router.delete(
|
||||
destroy(
|
||||
token.id,
|
||||
).url,
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{__('Revoke')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection instructions */}
|
||||
{/* Connect Claude Desktop / ChatGPT — the primary flow */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('How to connect')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__(
|
||||
'Connect your AI assistant using the details for it below.',
|
||||
'Pick your app, then follow the steps. You sign in and approve the connection — no token needed.',
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{__('Claude Desktop & ChatGPT')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'These apps sign in instead of using a token. Add a custom connector pointing at the URL below, then approve the connection on the Whisper Money screen that opens. No token needed.',
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{oauthUrl}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => copyValue(oauthUrl)}
|
||||
aria-label={__('Copy')}
|
||||
<CardContent className="space-y-4">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={connector}
|
||||
onValueChange={(value) =>
|
||||
value && setConnector(value as ConnectorApp)
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{(
|
||||
Object.keys(connectors) as ConnectorApp[]
|
||||
).map((key) => (
|
||||
<ToggleGroupItem
|
||||
key={key}
|
||||
value={key}
|
||||
className="flex-1 cursor-pointer aria-checked:bg-primary/10"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="pt-1 text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{connectors[key].label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{__('Claude Code')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Run this, using one of your tokens in place of <token>.',
|
||||
)}
|
||||
</p>
|
||||
<code className="block overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer <token>"`}
|
||||
</code>
|
||||
</div>
|
||||
<ol className="list-decimal space-y-3 pl-5 text-sm text-muted-foreground marker:font-medium marker:text-foreground">
|
||||
{connectors[connector].steps.map(
|
||||
(step, index) => (
|
||||
<li key={index}>{step}</li>
|
||||
),
|
||||
)}
|
||||
</ol>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only).',
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Developer flow: Claude Code + token management, hidden by default */}
|
||||
<Collapsible
|
||||
open={showDeveloper}
|
||||
onOpenChange={setShowDeveloper}
|
||||
>
|
||||
<Card className="gap-0 py-0">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-4 px-6 py-6 text-left"
|
||||
>
|
||||
<span className="flex flex-col gap-1.5">
|
||||
<span className="leading-none font-semibold">
|
||||
{__('Connect with Claude Code')}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'For developers. Claude Code signs in with a token instead of the browser flow.',
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200',
|
||||
showDeveloper && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-6 pb-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Run this, using one of your tokens in place of <token>.',
|
||||
)}
|
||||
</p>
|
||||
<code className="block overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer <token>"`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Create token */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{__('Create a token')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Read-only tokens can analyse your data. Read & write tokens can also create, edit and delete transactions, categories, labels and automation rules.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={createToken}
|
||||
className="flex flex-col gap-4 pt-2 sm:flex-row sm:items-end"
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="token-name">
|
||||
{__('Name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
value={form.data.name}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={__(
|
||||
'e.g. Claude Desktop',
|
||||
)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-scope">
|
||||
{__('Access')}
|
||||
</Label>
|
||||
{/* Radix Select renders a hidden native <select> next to the
|
||||
trigger; wrap it so the parent's space-y never pushes that
|
||||
hidden node below the trigger and breaks the row alignment. */}
|
||||
<div className="w-full sm:w-48">
|
||||
<Select
|
||||
value={form.data.scope}
|
||||
onValueChange={(
|
||||
value,
|
||||
) =>
|
||||
form.setData(
|
||||
'scope',
|
||||
value as
|
||||
| 'read'
|
||||
| 'read_write',
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="token-scope"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read">
|
||||
{__(
|
||||
'Read only',
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="read_write">
|
||||
{__(
|
||||
'Read & write',
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.processing}
|
||||
>
|
||||
{__('Create token')}
|
||||
</Button>
|
||||
</form>
|
||||
{form.errors.name && (
|
||||
<p className="mt-2 text-sm text-destructive">
|
||||
{form.errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token list */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{__('Your tokens')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'Rotate a token if it leaks, or revoke it to cut off access.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{tokens.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('You have no tokens yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{tokens.map((token) => (
|
||||
<li
|
||||
key={token.id}
|
||||
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{token.name}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{token.scope ===
|
||||
'read_write'
|
||||
? __(
|
||||
'Read & write',
|
||||
)
|
||||
: __(
|
||||
'Read only',
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Created')}:{' '}
|
||||
{formatDate(
|
||||
token.created_at,
|
||||
)}{' '}
|
||||
·{' '}
|
||||
{__(
|
||||
'Last used',
|
||||
)}
|
||||
:{' '}
|
||||
{formatDate(
|
||||
token.last_used_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{__(
|
||||
'Rotate',
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{__(
|
||||
'Rotate this token?',
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__(
|
||||
'Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.',
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{__(
|
||||
'Cancel',
|
||||
)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
router.post(
|
||||
rotate(
|
||||
token.id,
|
||||
)
|
||||
.url,
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{__(
|
||||
'Rotate',
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{__(
|
||||
'Revoke',
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{__(
|
||||
'Revoke this token?',
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__(
|
||||
'Anything using this token loses access right away, and you cannot undo it.',
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{__(
|
||||
'Cancel',
|
||||
)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
router.delete(
|
||||
destroy(
|
||||
token.id,
|
||||
)
|
||||
.url,
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{__(
|
||||
'Revoke',
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</AppLayout>
|
||||
|
|
|
|||
Loading…
Reference in New Issue