← vibesdk
src/components/cloudflare-account-selector.tsx
13.6 KB
Download file
/**
* Cloudflare Account & Gateway Selector
* Allows users to select their active Cloudflare account and AI Gateway
*/
import { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button, DropdownMenu, LayerCard } from '@cloudflare/kumo';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api-client';
import { CloudflareLogo } from '@cloudflare/kumo';
import {
Loader2,
CheckCircle2,
AlertCircle,
MoreVertical,
RefreshCw,
} from 'lucide-react';
import { useLimitsContext } from '@/contexts/limits-context';
import { CloudflareLogoThemed } from './shared/CloudflareLogoThemed';
import { startCloudflareConnect } from '@/lib/cloudflare-connect';
import { ArrowSquareOutIcon, SignOutIcon } from '@phosphor-icons/react';
interface CloudflareAccount {
id: string;
userId: string;
accountId: string;
accountName: string;
accountEmail?: string;
isActive: boolean;
isDefault: boolean;
createdAt: string;
gateways: Gateway[];
}
interface Gateway {
id: string;
userId: string;
cloudflareAccountId: string;
gatewayId: string;
gatewayName: string;
gatewaySlug: string;
creditsRemaining: number | null;
creditsLastUpdated: string | null;
isDefault: boolean;
autoCreated: boolean;
isActive: boolean;
createdAt: string;
}
export function CloudflareAccountSelector() {
const [accounts, setAccounts] = useState<CloudflareAccount[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [selectedAccountId, setSelectedAccountId] = useState<string>('');
const [selectedGatewayId, setSelectedGatewayId] = useState<string>('');
const [availableGateways, setAvailableGateways] = useState<Gateway[]>([]);
// Whether an OAuth cookie is live on the server. Backend refreshes it
// transparently, so the client only needs to know if it still exists.
const { data: limitsData, refetch: refreshLimits } = useLimitsContext();
const isConnected = !!limitsData?.hasUserToken;
// AI Gateway usage toggle. Reflects the resolved server preference; updates optimistically.
const [aiGatewayEnabled, setAiGatewayEnabled] = useState<boolean>(false);
const [togglingGateway, setTogglingGateway] = useState(false);
useEffect(() => {
if (typeof limitsData?.aiGatewayEnabled === 'boolean') {
setAiGatewayEnabled(limitsData.aiGatewayEnabled);
}
}, [limitsData?.aiGatewayEnabled]);
const handleToggleAiGateway = async (next: boolean) => {
const previous = aiGatewayEnabled;
setAiGatewayEnabled(next);
setTogglingGateway(true);
try {
const response = await apiClient.setAiGatewayPreference(next);
if (response.success && response.data) {
setAiGatewayEnabled(response.data.enabled);
} else {
setAiGatewayEnabled(previous);
}
await refreshLimits?.();
} catch (error) {
console.error('Error updating AI Gateway preference:', error);
setAiGatewayEnabled(previous);
} finally {
setTogglingGateway(false);
}
};
// Fetch accounts and gateways
useEffect(() => {
fetchAccounts();
}, []);
const fetchAccounts = async ({ refresh = false }: { refresh?: boolean } = {}) => {
try {
if (refresh) {
setRefreshing(true);
} else {
setLoading(true);
}
const response = await fetch(
`/api/cloudflare/accounts${refresh ? '?refresh=true' : ''}`,
{
credentials: 'include',
},
);
if (!response.ok) {
throw new Error('Failed to fetch accounts');
}
const result = await response.json();
const accountsData = result.data || [];
setAccounts(accountsData);
// Find the currently selected gateway (where isActive=true)
let foundActive = false;
for (const account of accountsData) {
const activeGateway = account.gateways.find((g: Gateway) => g.isActive);
if (activeGateway) {
setSelectedAccountId(account.id);
setAvailableGateways(account.gateways);
setSelectedGatewayId(activeGateway.id);
foundActive = true;
break;
}
}
// If no active gateway found, auto-select if only 1 account
if (!foundActive) {
if (accountsData.length === 1) {
const singleAccount = accountsData[0];
setSelectedAccountId(singleAccount.id);
setAvailableGateways(singleAccount.gateways);
if (singleAccount.gateways.length === 1) {
setSelectedGatewayId(singleAccount.gateways[0].id);
}
} else {
setSelectedAccountId('');
setSelectedGatewayId('');
setAvailableGateways([]);
}
}
} catch (error) {
console.error('Error fetching accounts:', error);
toast.error('Failed to load Cloudflare accounts');
} finally {
setLoading(false);
setRefreshing(false);
}
};
const handleRefresh = async () => {
await fetchAccounts({ refresh: true });
toast.success('Refreshed Cloudflare accounts and gateways');
};
// Update available gateways when account changes
const handleAccountChange = (accountId: string) => {
setSelectedAccountId(accountId);
const account = accounts.find(a => a.id === accountId);
if (account) {
setAvailableGateways(account.gateways);
// Auto-select first gateway
if (account.gateways.length > 0) {
setSelectedGatewayId(account.gateways[0].id);
} else {
setSelectedGatewayId('');
}
}
};
// Save selection
const handleSave = async () => {
if (!selectedAccountId || !selectedGatewayId) {
toast.error('Please select both an account and a gateway');
return;
}
try {
setSaving(true);
const response = await apiClient.setCloudflareSelection(selectedAccountId, selectedGatewayId);
if (response.success) {
toast.success('Cloudflare configuration saved successfully');
// Refresh the page to update the badge
window.location.reload();
}
} catch (error) {
console.error('Error saving selection:', error);
// apiClient already shows toast for errors, but we can add additional handling if needed
} finally {
setSaving(false);
}
};
// Disconnect: ask the backend to clear the HttpOnly OAuth cookie.
const handleDisconnect = async () => {
try {
await apiClient.disconnectCloudflare();
toast.success('Disconnected from Cloudflare. You can reconnect anytime.');
await refreshLimits?.();
setTimeout(() => window.location.reload(), 500);
} catch (error) {
console.error('Error disconnecting:', error);
}
};
const handleReconnect = () => {
void startCloudflareConnect(window.location.pathname + window.location.search);
};
const cardHeader = (
<div className="flex items-center gap-2 font-funky-mono tracking-tighter">
<span className="h-lh flex items-center">
<CloudflareLogoThemed className="size-5" />
</span>
<span>Cloudflare AI Gateway</span>
</div>
);
if (loading) {
return (
<LayerCard>
<LayerCard.Secondary>{cardHeader}</LayerCard.Secondary>
<LayerCard.Primary>
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-kumo-subtle" />
</div>
</LayerCard.Primary>
</LayerCard>
);
}
if (accounts.length === 0) {
return (
<LayerCard>
<LayerCard.Secondary>{cardHeader}</LayerCard.Secondary>
<LayerCard.Primary>
<div className="grid gap-4">
<p className="text-sm text-kumo-subtle">
You haven't connected any Cloudflare accounts yet.
</p>
<div>
<Button onClick={handleReconnect} variant="secondary" className="gap-2">
<CloudflareLogo variant="glyph" className="size-4" />
Connect Cloudflare
</Button>
</div>
</div>
</LayerCard.Primary>
</LayerCard>
);
}
const selectedAccount = accounts.find(a => a.id === selectedAccountId);
const selectedGateway = availableGateways.find(g => g.id === selectedGatewayId);
const gatewayDashUrl = selectedAccount && selectedGateway
? `https://dash.cloudflare.com/${selectedAccount.accountId}/ai/ai-gateway/gateways/${selectedGateway.gatewaySlug}`
: null;
return (
<LayerCard>
<LayerCard.Secondary className="flex items-center justify-between gap-3">
{cardHeader}
<div className="flex items-center gap-2">
{isConnected && (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<span className="h-lh flex items-center">
<CheckCircle2 className="size-3.5" />
</span>
<span>Connected</span>
</div>
)}
{!isConnected && (
<div className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<span className="h-lh flex items-center">
<AlertCircle className="size-3.5" />
</span>
<span>Not connected</span>
</div>
)}
{isConnected && (
<>
<Button
variant="ghost"
size="sm"
shape="square"
onClick={handleRefresh}
disabled={refreshing}
aria-label="Refresh accounts and gateways"
>
<RefreshCw className={`size-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<DropdownMenu>
<DropdownMenu.Trigger
render={
<Button
variant="ghost"
size="sm"
shape="square"
aria-label="Account actions"
>
<MoreVertical className="size-4" />
</Button>
}
/>
<DropdownMenu.Content align="end">
{gatewayDashUrl && (
<DropdownMenu.Item
icon={ArrowSquareOutIcon}
onClick={() => window.open(gatewayDashUrl, '_blank', 'noopener,noreferrer')}
>
View gateway
</DropdownMenu.Item>
)}
<DropdownMenu.Item
icon={SignOutIcon}
onClick={handleDisconnect}
variant="danger"
>
Disconnect
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</>
)}
</div>
</LayerCard.Secondary>
<LayerCard.Primary>
{!isConnected ? (
<div className="grid gap-4">
<p className="text-sm text-kumo-subtle">
Connect your Cloudflare account to use your own AI Gateway and credits.
</p>
<div>
<Button onClick={handleReconnect} variant="secondary" className="gap-2">
<CloudflareLogo variant="glyph" className="size-4" />
Connect Cloudflare
</Button>
</div>
</div>
) : (
<div className="grid gap-4">
<div className="flex items-start justify-between gap-4 rounded-lg ring ring-kumo-line px-4 py-3">
<div className="grid gap-1.5">
<Label htmlFor="ai-gateway-toggle" className="text-sm font-medium">
Use my AI Gateway
</Label>
<p className="text-sm text-kumo-subtle">
When enabled, requests run through your Cloudflare AI Gateway and
use your own credits. When disabled, the platform's free tier and
limits apply.
</p>
</div>
<Switch
id="ai-gateway-toggle"
checked={aiGatewayEnabled}
disabled={togglingGateway}
onCheckedChange={handleToggleAiGateway}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="account-select">Cloudflare account</Label>
<Select value={selectedAccountId || undefined} onValueChange={handleAccountChange}>
<SelectTrigger id="account-select" className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex flex-col">
<span className="font-medium">{account.accountName}</span>
{account.accountEmail && (
<span className="text-xs text-kumo-subtle">{account.accountEmail}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="gateway-select">AI Gateway</Label>
<Select
value={selectedGatewayId || undefined}
onValueChange={setSelectedGatewayId}
disabled={availableGateways.length === 0}
>
<SelectTrigger id="gateway-select" className="w-full">
<SelectValue placeholder="Select a gateway" />
</SelectTrigger>
<SelectContent>
{availableGateways.map((gateway) => (
<SelectItem key={gateway.id} value={gateway.id}>
{gateway.gatewayId}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedGateway && selectedGateway.creditsRemaining !== null && (
<p className="text-sm text-kumo-subtle">
Current balance: ${selectedGateway.creditsRemaining.toFixed(2)}
</p>
)}
{availableGateways.length === 0 && selectedAccountId && (
<p className="text-sm text-amber-600">
No gateways available for this account. Please create one in your Cloudflare dashboard.
</p>
)}
</div>
<div>
<Button
onClick={handleSave}
disabled={saving || !selectedAccountId || !selectedGatewayId}
>
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
</>
) : (
'Save configuration'
)}
</Button>
</div>
</div>
)}
</LayerCard.Primary>
</LayerCard>
);
}