feat(mcp): lead with Claude Desktop/ChatGPT connect steps, collapse tokens

The AI Connector page was token-first, but tokens are only needed for
Claude Code. Restructure it around the OAuth flow most people use:

- Show per-app connection instructions first, in a Claude Desktop / ChatGPT
  tab selector; each tab renders the exact ordered steps for that app.
- Call out that ChatGPT needs developer mode enabled to add a custom
  connector.
- Move token management and the Claude Code CLI command into a
  "Connect with Claude Code" section that is collapsed by default and
  auto-opens right after a token is minted.
- Keep rotate/revoke working without collapsing the section (preserveState
  on the Inertia visits).
This commit is contained in:
Víctor Falcón 2026-07-18 11:43:21 +02:00
parent 5621e90879
commit 6d45e01c3f
2 changed files with 435 additions and 275 deletions

View File

@ -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.",

View File

@ -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>