Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/web/src/app/(app)/claw/components/AgentCardConnectPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useKiloClawStatus } from '@/hooks/useKiloClaw';
import { AgentCardIcon } from './icons/AgentCardIcon';

// One-time, dismissible prompt shown after first sign-in inviting the user to
// connect Agentcard. It is purely opt-in: nothing happens unless the user
// clicks Connect (which kicks off the OAuth flow). Dismissal is remembered in
// localStorage so we never nag a user who has said "Not now".
const DISMISS_KEY = 'kiloclaw:agentcard-connect-prompt:dismissed';

export function AgentCardConnectPrompt() {
const { data: status, isLoading } = useKiloClawStatus();
const [open, setOpen] = useState(false);

useEffect(() => {
if (isLoading || !status) return;
// Only prompt once the user has a live instance (skips onboarding), and
// never if they're already connected or have dismissed the prompt before.
if (!status.status) return;
if (status.agentcardOAuthConnected) return;
try {
if (localStorage.getItem(DISMISS_KEY)) return;
} catch {
// localStorage unavailable (e.g. privacy mode) — just don't prompt.
return;
}
setOpen(true);
}, [isLoading, status]);

function dismiss() {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
// ignore — worst case the prompt shows again next session.
}
setOpen(false);
}

// Connect from the dashboard; return the user here afterward.
const connectUrl = '/api/integrations/agentcard/connect?returnTo=%2Fclaw';

return (
<Dialog open={open} onOpenChange={next => (next ? setOpen(true) : dismiss())}>
<DialogContent>
<DialogHeader>
<div className="mb-1 flex items-center gap-2">
<AgentCardIcon className="h-6 w-auto shrink-0" />
<DialogTitle>Connect Agentcard?</DialogTitle>
</div>
<DialogDescription>
Give your agent the ability to create and spend virtual debit cards, with per-task spend
limits enforced by Agentcard. You authenticate with your own Agentcard account — Kilo
never sees a long-lived key.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2">
<p className="text-amber-400 text-xs font-medium">
Warning: this can permit your agent to spend real money. Use caution.
</p>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={dismiss}>
Not now
</Button>
<Button asChild size="sm" onClick={() => setOpen(false)}>
<Link href={connectUrl}>Connect Agentcard</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
254 changes: 162 additions & 92 deletions apps/web/src/app/(app)/claw/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { OpenclawImportCard } from './OpenclawImportCard';
import { AgentCardIcon } from './icons/AgentCardIcon';

import { usePostHog } from 'posthog-js/react';
import Link from 'next/link';
Expand Down Expand Up @@ -221,85 +222,165 @@ function OnePasswordSetupGuide() {
}

// ---------------------------------------------------------------------------
// AgentCard setup guide dialog
// AgentCard (OAuth "Connect" button — replaces the legacy paste-a-token flow)
// ---------------------------------------------------------------------------

function AgentCardSetupGuide() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Info className="h-3.5 w-3.5" />
Advanced Setup Required
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>AgentCard Setup</DialogTitle>
<DialogDescription>
Give your agent the ability to create and spend virtual debit cards.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2">
<p className="text-amber-400 text-xs font-medium">
Warning: this can permit your agent to spend real money. Use caution.
</p>
<p className="text-amber-400/70 mt-1 text-xs">
AgentCard is currently in beta. Card issuance may be limited or waitlisted.
</p>
</div>

<div>
<p className="mb-2 font-medium">1. Create an AgentCard account</p>
<p className="text-muted-foreground text-xs">Run these commands:</p>
<pre className="bg-muted mt-1 rounded-md p-2 text-xs">
<code>npm install -g agent-cards{'\n'}agent-cards signup</code>
</pre>
</div>
// Capabilities the agent gains once connected, mirrored from AgentCard's MCP
// tool set (create_card, list_cards, check_balance, …).
const AGENTCARD_FEATURES: Array<{ included: boolean; label: string }> = [
{ included: true, label: 'Create and manage virtual debit cards for your agent' },
{ included: true, label: 'Check balances and review transactions' },
{ included: true, label: 'Per-task spend limits enforced by Agentcard' },
];

<div>
<p className="mb-2 font-medium">2. Add a payment method</p>
<p className="text-muted-foreground text-xs">
Run <code className="bg-muted rounded px-1">agent-cards payment-method</code> to link
a card via Stripe. This funds any virtual cards your agent creates.
</p>
</div>
/**
* Settings card for connecting AgentCard via OAuth. Connect/disconnect reuse
* the /api/integrations/agentcard/{connect,disconnect} routes; disconnect is a
* native same-origin form POST so the route's Origin check passes and the 303
* redirect lands back on settings with a success/error param.
*
* This replaces the old "paste your AgentCard API key" flow: the user clicks
* Connect, authenticates with their own AgentCard account (magic-link +
* consent), and Kilo stores a per-user OAuth token — Kilo never sees a
* long-lived API key.
*/
function AgentCardCard({
connected,
oauthStatus,
accountEmail,
organizationId,
}: {
connected: boolean;
oauthStatus: 'active' | 'action_required' | 'disconnected';
accountEmail: string | null;
organizationId: string | null;
}) {
const [open, setOpen] = useState(false);
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const disconnectFormRef = useRef<HTMLFormElement>(null);

<div>
<p className="mb-2 font-medium">3. Copy your API key</p>
<p className="text-muted-foreground text-xs">
Open <code className="bg-muted rounded px-1">~/.agent-cards/config.json</code> and
copy the <strong>jwt</strong> value into the field above.
</p>
</div>
const settingsPath = organizationId
? `/organizations/${organizationId}/claw/settings`
: '/claw/settings';
const connectParams = new URLSearchParams({ returnTo: settingsPath });
if (organizationId) {
connectParams.set('organizationId', organizationId);
}
const connectUrl = `/api/integrations/agentcard/connect?${connectParams.toString()}`;
const disconnectAction = organizationId
? `/api/integrations/agentcard/disconnect?organizationId=${encodeURIComponent(organizationId)}`
: '/api/integrations/agentcard/disconnect';

<div>
<p className="mb-2 font-medium">4. Upgrade your instance</p>
<p className="text-muted-foreground text-xs">
This feature requires the most recent version of OpenClaw. After saving your
credentials, use <strong>Upgrade</strong> (not Redeploy) to install the latest image
and activate AgentCard. Your agent will then have access to tools like{' '}
<code className="bg-muted rounded px-1">create_card</code>,{' '}
<code className="bg-muted rounded px-1">list_cards</code>, and{' '}
<code className="bg-muted rounded px-1">check_balance</code>.
</p>
</div>
const needsReconnect = oauthStatus === 'action_required';
const isHealthyConnected = connected && !needsReconnect;

<p className="text-muted-foreground border-t pt-3 text-xs">
Learn more at{' '}
<a
href="https://agentcard.sh"
target="_blank"
rel="noopener noreferrer"
className="underline"
return (
<>
<Collapsible open={open} onOpenChange={setOpen}>
<div className="rounded-lg border">
<CollapsibleTrigger asChild>
<button
type="button"
className="hover:bg-muted/50 flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 transition-colors"
>
agentcard.sh
</a>
</p>
<AgentCardIcon className="h-5 w-auto shrink-0" />
<div className="flex min-w-0 flex-1 flex-col items-start">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Agentcard</span>
<Badge
variant={isHealthyConnected ? 'default' : 'secondary'}
className="px-1.5 py-0 text-[10px] leading-4"
>
{connected ? (needsReconnect ? 'Reconnect' : 'Connected') : 'Not connected'}
</Badge>
</div>
<span className="text-muted-foreground text-xs">
Virtual debit cards for agent spending · via Agentcard OAuth
</span>
</div>
<ChevronDown
className={`text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
/>
</button>
</CollapsibleTrigger>

<CollapsibleContent>
<Separator />
<div className="space-y-4 px-4 py-3">
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2">
<p className="text-amber-400 text-xs font-medium">
Warning: this can permit your agent to spend real money. Use caution.
</p>
</div>
{isHealthyConnected ? (
<>
<p className="text-muted-foreground text-xs">
{accountEmail ? `Connected as ${accountEmail}` : 'Connected'} · your agent can
create and spend virtual cards.
</p>
<Button
variant="outline"
size="sm"
disabled={isDisconnecting}
onClick={() => setConfirmDisconnect(true)}
>
<X className="h-4 w-4" />
{isDisconnecting ? 'Disconnecting...' : 'Disconnect'}
</Button>
</>
) : (
<>
{needsReconnect && (
<p className="text-xs text-amber-500">
Your AgentCard connection needs to be re-authorized. Reconnect to resume
access.
</p>
)}
<ul className="space-y-2">
{AGENTCARD_FEATURES.map(feature => (
<li key={feature.label} className="flex items-start gap-2">
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-500" />
<span className="text-muted-foreground text-xs">{feature.label}</span>
</li>
))}
</ul>
<Button asChild size="sm">
<Link href={connectUrl}>
{needsReconnect ? 'Reconnect Agentcard' : 'Connect Agentcard'}
</Link>
</Button>
</>
)}
</div>
</CollapsibleContent>
</div>
</DialogContent>
</Dialog>
</Collapsible>

{/* Native form POST so the disconnect route's same-origin Origin check
passes; the 303 redirect navigates back to settings. */}
<form ref={disconnectFormRef} method="POST" action={disconnectAction} className="hidden" />

<ConfirmActionDialog
open={confirmDisconnect}
onOpenChange={setConfirmDisconnect}
title="Disconnect Agentcard"
description="This removes your agent's access to Agentcard virtual cards. You can reconnect anytime."
confirmLabel="Disconnect"
confirmIcon={<X className="mr-1 h-4 w-4" />}
isPending={isDisconnecting}
pendingLabel="Disconnecting..."
onConfirm={() => {
const form = disconnectFormRef.current;
if (!form) {
toast.error('Could not disconnect Agentcard. Please try again.');
return;
}
setIsDisconnecting(true);
form.submit();
}}
/>
</>
);
}

Expand Down Expand Up @@ -2604,28 +2685,17 @@ export function SettingsTab({
)}

{/* ── Payments ── */}
{toolEntries.some(e => e.id === 'agentcard') && (
<div>
<h2 className="text-foreground mb-3 text-base font-semibold">Payments</h2>
<div className="space-y-3">
{toolEntries
.filter(e => e.id === 'agentcard')
.map(entry => (
<SecretEntrySection
key={entry.id}
entry={entry}
configured={configuredSecrets[entry.id] ?? false}
mutations={mutations}
onSecretsChanged={onSecretsChanged}
isDirty={dirtySecrets.has(entry.id)}
onRedeploy={onRequestUpgrade ?? onRedeploy}
redeployLabel="Upgrade"
actionRowExtra={<AgentCardSetupGuide />}
/>
))}
</div>
<div>
<h2 className="text-foreground mb-3 text-base font-semibold">Payments</h2>
<div className="space-y-3">
<AgentCardCard
connected={status.agentcardOAuthConnected ?? false}
oauthStatus={status.agentcardOAuthStatus ?? 'disconnected'}
accountEmail={status.agentcardOAuthAccountEmail ?? null}
organizationId={organizationId ?? null}
/>
</div>
)}
</div>

{/* ── Password Managers ── */}
{toolEntries.some(e => e.id === 'onepassword') && (
Expand Down
22 changes: 16 additions & 6 deletions apps/web/src/app/(app)/claw/components/icons/AgentCardIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
// The real Agentcard brand mark, taken from agentcard.sh
// (public/landing/home/agentcard-logo-new.svg): a filled card with a chip
// grid. The website asset uses a white gradient (for dark backgrounds); here
// we fill with currentColor so the mark inherits the surrounding text color
// and works in both light and dark themes. viewBox preserves the logo's
// natural 39:28 aspect ratio.
export function AgentCardIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className} xmlns="http://www.w3.org/2000/svg">
<svg
viewBox="0 0 39 28"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15A2.25 2.25 0 0 0 2.25 6.75v10.5A2.25 2.25 0 0 0 4.5 19.5z"
stroke="#22C55E"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
fillRule="evenodd"
clipRule="evenodd"
d="M2.925 0H36.075C38.025 0 39 0.982456 39 2.94737V25.0526C39 27.0175 38.025 28 36.075 28H2.925C0.975 28 0 27.0175 0 25.0526V2.94737C0 0.982456 0.975 0 2.925 0ZM9.2625 6.14035H29.7375V6.87719H9.2625V6.14035ZM9.2625 21.1228H29.7375V21.8596H9.2625V21.1228ZM9.2625 6.87719H9.99375V21.1228H9.2625V6.87719ZM29.0062 6.87719H29.7375V21.1228H29.0062V6.87719ZM9.99375 14.4912H29.0062V15.2281H9.99375V14.4912ZM19.0125 6.87719H19.7437V14.4912H19.0125V6.87719Z"
fill="currentColor"
/>
</svg>
);
Expand Down
Loading