Compare commits

...

12 Commits

Author SHA1 Message Date
9qeklajc
558ef52e7c Merge branch 'fix-open-claude-cost-calculation' into otrta
# Conflicts:
#	routstr/upstream/base.py
2026-03-29 23:37:52 +02:00
9qeklajc
dcd924b57d Merge branch 'fix-display-api-key-infos' into otrta 2026-03-29 23:36:32 +02:00
9qeklajc
ce380d2aed Merge branch 'simplify-x-cashu-refund' into otrta 2026-03-29 23:36:31 +02:00
9qeklajc
8f96cb6e85 fix handling x cashu for messages endpoint 2026-03-29 23:35:26 +02:00
9qeklajc
c5a205cf98 clean up 2026-03-29 11:40:17 +02:00
9qeklajc
5174c42e7c fix claude code and open code 2026-03-28 00:48:15 +01:00
9qeklajc
8c1ab05016 fix open code cost calculation 2026-03-27 22:53:40 +01:00
9qeklajc
62185fbd38 fix build 2026-03-25 20:07:36 +01:00
9qeklajc
bb97e8dedb use react query 2026-03-25 10:21:18 +01:00
9qeklajc
a1c2a785a1 better displaying errors 2026-03-23 22:36:16 +01:00
9qeklajc
750193e99d clean up ui 2026-03-23 22:20:36 +01:00
9qeklajc
4ee654331f fix refunding not displayed & clean up 2026-03-23 22:01:54 +01:00
10 changed files with 1508 additions and 924 deletions

View File

@@ -732,22 +732,20 @@ async def adjust_payment_for_tokens(
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
total_spent=col(ApiKey.total_spent) + cost.total_msats,
)
)
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
# Also update total_spent and balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + cost.total_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - cost.total_msats,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]
@@ -819,23 +817,20 @@ async def adjust_payment_for_tokens(
update(ApiKey)
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
.values(
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
total_spent=col(ApiKey.total_spent) + total_cost_msats,
)
)
await session.exec(finalize_stmt) # type: ignore[call-overload]
# Also update total_spent and reserved_balance on the child key if it's different
# Also update total_spent and balance on the child key if it's different
if billing_key.hashed_key != key.hashed_key:
child_stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(
total_spent=col(ApiKey.total_spent) + total_cost_msats,
reserved_balance=col(ApiKey.reserved_balance)
- deducted_max_cost,
balance=col(ApiKey.balance) - total_cost_msats,
)
)
await session.exec(child_stmt) # type: ignore[call-overload]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
'use client';
import { useState, useEffect } from 'react';
import * as React from 'react';
import { Input } from '@/components/ui/input';
interface ApiKeyInputProps extends React.ComponentProps<'input'> {
onApiKeyChange: (apiKey: string) => void;
}
export function ApiKeyInput({
value,
onApiKeyChange,
...props
}: ApiKeyInputProps) {
const [internalValue, setInternalValue] = useState(value || '');
useEffect(() => {
setInternalValue(value || '');
}, [value]);
useEffect(() => {
const handler = setTimeout(() => {
onApiKeyChange(internalValue as string);
}, 300);
return () => clearTimeout(handler);
}, [internalValue, onApiKeyChange]);
return (
<Input
value={internalValue}
onChange={(e) => setInternalValue(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
{...props}
/>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import { useState } from 'react';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import { WalletService } from '@/lib/api/services/wallet';
import { ApiKeyInput } from './api-key-input';
import { Button } from '@/components/ui/button';
import {
Card,
@@ -14,17 +16,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Key,
Copy,
Check,
Loader2,
RotateCcw,
Plus,
Trash2,
} from 'lucide-react';
import { Key, Copy, Check, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { KeyOptions } from './key-options';
interface KeyConfig {
@@ -42,6 +35,14 @@ interface ChildKeyCreatorProps {
costPerKeyMsats?: number;
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
export function ChildKeyCreator({
baseUrl,
apiKey: propApiKey,
@@ -50,6 +51,7 @@ export function ChildKeyCreator({
}: ChildKeyCreatorProps) {
const [internalApiKey, setInternalApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [configs, setConfigs] = useState<KeyConfig[]>([
{
id: crypto.randomUUID(),
@@ -59,15 +61,15 @@ export function ChildKeyCreator({
validityDate: '',
},
]);
const [childKeyToCheck, setChildKeyToCheck] = useState('');
const [checking, setChecking] = useState(false);
const [keyStatus, setKeyStatus] = useState<{
total_spent: number;
balance_limit: number | null;
validity_date: number | null;
is_expired: boolean;
is_drained: boolean;
} | null>(null);
const activeApiKey = propApiKey ?? internalApiKey;
const { data: walletInfo } = useWalletInfo(baseUrl ?? '', activeApiKey);
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const [newKeys, setNewKeys] = useState<string[]>([]);
const [resultInfo, setResultInfo] = useState<{
cost_msats: number;
@@ -75,13 +77,6 @@ export function ChildKeyCreator({
} | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const activeApiKey = propApiKey ?? internalApiKey;
const handleApiKeyChange = (val: string) => {
setInternalApiKey(val);
onApiKeyChange?.(val);
};
const addConfig = () => {
setConfigs([
...configs,
@@ -112,6 +107,7 @@ export function ChildKeyCreator({
}
setLoading(true);
setError(null);
try {
let allNewKeys: string[] = [];
let totalCost = 0;
@@ -152,55 +148,21 @@ export function ChildKeyCreator({
);
} catch (error) {
console.error('Failed to create child key:', error);
toast.error(
error instanceof Error ? error.message : 'Failed to create child key'
);
let errorMessage =
error instanceof Error ? error.message : 'Failed to create child key';
try {
const parsed = JSON.parse(errorMessage);
errorMessage =
parsed.detail?.error?.message ||
(typeof parsed.detail === 'string' ? parsed.detail : errorMessage);
} catch {}
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const handleCheckKey = async () => {
if (!childKeyToCheck) {
toast.error('Please provide a Child API key to check');
return;
}
setChecking(true);
setKeyStatus(null);
try {
const baseUrlToUse = baseUrl || '';
const response = await fetch(`${baseUrlToUse}/v1/balance/info`, {
headers: {
Authorization: `Bearer ${childKeyToCheck}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const info = await response.json();
const now = Math.floor(Date.now() / 1000);
setKeyStatus({
total_spent: info.total_spent,
balance_limit: info.balance_limit,
validity_date: info.validity_date,
is_expired: info.validity_date ? now > info.validity_date : false,
is_drained: info.balance_limit
? info.total_spent >= info.balance_limit
: false,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to check child key'
);
} finally {
setChecking(false);
}
};
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
@@ -243,12 +205,55 @@ export function ChildKeyCreator({
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Parent API Key
</Label>
<Input
value={activeApiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<div className='flex-1'>
<ApiKeyInput
value={activeApiKey}
onApiKeyChange={handleApiKeyChange}
/>
</div>
<Button
variant='outline'
size='icon'
onClick={() => navigator.clipboard.writeText(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
</div>
{walletInfo && (
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
</div>
)}
</div>
)}
@@ -362,11 +367,18 @@ export function ChildKeyCreator({
)}
</Button>
</div>
</div>
<p className='text-muted-foreground text-xs'>
Each key creation has a small one-time fee.
</p>
{error && (
<Alert variant='destructive'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<p className='text-muted-foreground text-xs'>
Each key creation has a small one-time fee.
</p>
</div>
{newKeys.length > 0 && (
<div className='mt-6 space-y-4'>
@@ -458,89 +470,6 @@ export function ChildKeyCreator({
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className='text-lg'>Check Child Key Status</CardTitle>
<CardDescription>
View the current spending, limit, and expiration status of any child
key.
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
<div className='space-y-2'>
<Label className='text-muted-foreground text-[0.7rem] tracking-wider'>
Child API Key
</Label>
<Input
value={childKeyToCheck}
onChange={(e) => setChildKeyToCheck(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
</div>
<Button
onClick={handleCheckKey}
disabled={checking || !childKeyToCheck}
variant='outline'
className='w-full'
>
{checking ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Checking...
</>
) : (
<>
<RotateCcw className='mr-2 h-4 w-4' />
Check Status
</>
)}
</Button>
{keyStatus && (
<div className='bg-muted/30 mt-4 space-y-3 rounded-lg border p-4 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Total Spent:</span>
<span className='font-mono font-medium'>
{keyStatus.total_spent} mSats
</span>
</div>
{keyStatus.balance_limit !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Limit:</span>
<span className='font-mono font-medium'>
{keyStatus.balance_limit} mSats
</span>
</div>
)}
{keyStatus.validity_date !== null && (
<div className='flex justify-between'>
<span className='text-muted-foreground'>Expires:</span>
<span className='font-mono font-medium'>
{new Date(
keyStatus.validity_date * 1000
).toLocaleDateString()}
</span>
</div>
)}
<div className='flex gap-2 pt-2'>
{keyStatus.is_drained && (
<Badge variant='destructive'>Drained</Badge>
)}
{keyStatus.is_expired && (
<Badge variant='destructive'>Expired</Badge>
)}
{!keyStatus.is_drained && !keyStatus.is_expired && (
<Badge>Active</Badge>
)}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -239,7 +239,7 @@ export function ApiKeyManager({
className='gap-2'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing...' : 'Refund & Delete Key'}
{isRefunding ? 'Processing...' : 'Refund Key'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.

View File

@@ -1,16 +1,17 @@
'use client';
import { type JSX, useCallback, useState } from 'react';
import { Copy, RefreshCcw, Trash2 } from 'lucide-react';
import { Copy, RefreshCcw } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { KeyOptions } from '@/components/key-options';
import { WalletBalanceStats } from './wallet-balance-stats';
import type { ChildKeyInfo, WalletSnapshot } from './key-info-details';
import { ApiKeyInput } from '../api-key-input';
import type { WalletSnapshot } from './key-info-details';
import { useWalletInfo } from '@/hooks/use-wallet-info';
export type RefundReceipt = {
token?: string;
@@ -29,80 +30,42 @@ interface CashuPaymentWorkflowProps {
onRefundComplete?: (receipt: RefundReceipt) => void;
}
async function fetchWalletInfo(
baseUrl: string,
apiKey: string
): Promise<WalletSnapshot> {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
reserved?: number;
is_child: boolean;
parent_key: string | null;
total_requests: number;
total_spent: number;
balance_limit: number | null;
balance_limit_reset: string | null;
validity_date: number | null;
child_keys?: ChildKeyInfo[];
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
export function CashuPaymentWorkflow({
baseUrl,
apiKey = '',
walletInfo = null,
walletInfo: propWalletInfo = null,
onApiKeyCreated,
onApiKeyChanged,
onWalletInfoUpdated,
onRefundComplete,
}: CashuPaymentWorkflowProps): JSX.Element {
const [initialToken, setInitialToken] = useState('');
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
const [balanceLimit, setBalanceLimit] = useState<string>('');
const [balanceLimitReset, setBalanceLimitReset] = useState<string>('');
const [validityDate, setValidityDate] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const activeApiKey = apiKeyInput.trim();
const {
data: queryWalletInfo,
refetch,
isFetching,
} = useWalletInfo(baseUrl, activeApiKey);
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
@@ -203,20 +166,18 @@ export function CashuPaymentWorkflow({
return;
}
setIsSyncingBalance(true);
setError(null);
try {
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onWalletInfoUpdated?.(snapshot);
await refetch();
toast.success('Balance synced');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
const message =
error instanceof Error ? error.message : 'Failed to sync balance';
setError(message);
toast.error(message);
}
}, [activeApiKey, baseUrl, onWalletInfoUpdated]);
}, [activeApiKey, refetch]);
const handleTopup = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
@@ -245,59 +206,25 @@ export function CashuPaymentWorkflow({
const payload = (await response.json()) as { msats: number };
toast.success(`Added ${formatSats(payload.msats)} sats`);
setTopupToken('');
const snapshot = await fetchWalletInfo(baseUrl, activeApiKey);
onApiKeyCreated?.(snapshot.apiKey, snapshot);
await refetch();
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Top-up failed');
} finally {
setIsTopupLoading(false);
}
}, [activeApiKey, baseUrl, topupToken, onApiKeyCreated]);
const handleRefund = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${activeApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const payload = (await response.json()) as RefundReceipt;
onRefundComplete?.(payload);
onWalletInfoUpdated?.(null);
setApiKeyInput('');
toast.success('Refund requested');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [activeApiKey, baseUrl, onRefundComplete, onWalletInfoUpdated]);
}, [activeApiKey, baseUrl, topupToken, refetch]);
const handleApiKeyChange = useCallback(
(newKey: string) => {
setApiKeyInput(newKey);
onApiKeyChanged?.(newKey);
if (newKey !== apiKey) {
onWalletInfoUpdated?.(null);
}
},
[apiKey, onApiKeyChanged, onWalletInfoUpdated]
[apiKey, onWalletInfoUpdated]
);
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails = hasInteractedTopup || topupToken.trim().length > 0;
const canTopup = Boolean(activeApiKey);
const showCreateDetails = initialToken.trim().length > 0;
@@ -365,40 +292,73 @@ export function CashuPaymentWorkflow({
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
<ApiKeyInput
value={apiKeyInput}
onChange={(event) => handleApiKeyChange(event.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
onApiKeyChange={handleApiKeyChange}
onFocus={() => setHasInteractedManage(true)}
/>
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10'
onClick={() => handleCopy(activeApiKey)}
disabled={!activeApiKey}
>
<Copy className='h-4 w-4' />
</Button>
<Button
variant='secondary'
size='sm'
className='gap-1'
onClick={handleSyncBalance}
disabled={isSyncingBalance || !activeApiKey}
disabled={isFetching || !activeApiKey}
>
<RefreshCcw className='h-4 w-4' />
<RefreshCcw
className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`}
/>
Sync
</Button>
</div>
</div>
{showManageDetails && (
<WalletBalanceStats
balanceMsats={walletInfo?.balanceMsats}
reservedMsats={walletInfo?.reservedMsats}
/>
{walletInfo && (
<div className='bg-muted/30 mt-2 space-y-2 rounded-lg p-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Spent
</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
</div>
)}
{error && (
<Alert variant='destructive' className='mt-2'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</section>
@@ -444,28 +404,6 @@ export function CashuPaymentWorkflow({
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='text-muted-foreground flex items-center justify-between text-[0.7rem] tracking-wider'>
<span>4 · Refund</span>
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding || !activeApiKey}
variant='destructive'
className='gap-2'
>
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-muted-foreground text-xs'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
</section>
</CardContent>
</Card>
);

View File

@@ -18,7 +18,6 @@ import {
type RefundReceipt,
} from './cashu-payment-workflow';
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
import { ApiKeyManager } from './api-key-manager';
import { KeyInfoDetails, type WalletSnapshot } from './key-info-details';
import { ChildKeyCreator } from '@/components/child-key-creator';
@@ -135,8 +134,8 @@ export function CheatSheet(): JSX.Element {
const handleRefundComplete = useCallback((receipt: RefundReceipt) => {
setRefundReceipt(receipt);
setWalletInfo(null);
setApiKeyInput('');
// setWalletInfo(null); // Keep info
// setApiKeyInput(''); // Keep input
}, []);
const handleRefreshInfo = useCallback(async (): Promise<void> => {
@@ -391,12 +390,11 @@ export function CheatSheet(): JSX.Element {
</section>
<Tabs defaultValue='cashu' className='w-full'>
<TabsList className='grid w-full grid-cols-5'>
<TabsList className='grid w-full grid-cols-4'>
<TabsTrigger value='cashu'>Cashu</TabsTrigger>
<TabsTrigger value='lightning'>Lightning</TabsTrigger>
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
<TabsTrigger value='details'>Key Details</TabsTrigger>
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
<TabsTrigger value='management'>Key Management</TabsTrigger>
</TabsList>
<TabsContent value='cashu' className='space-y-4'>
@@ -413,8 +411,8 @@ export function CheatSheet(): JSX.Element {
/>
</TabsContent>
<TabsContent value='manage' className='space-y-4'>
<ApiKeyManager
<TabsContent value='management' className='space-y-4'>
<KeyInfoDetails
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
@@ -445,7 +443,7 @@ export function CheatSheet(): JSX.Element {
<Textarea
value={refundToken}
readOnly
rows={4}
rows={15}
className='font-mono text-xs'
/>
</div>
@@ -454,16 +452,6 @@ export function CheatSheet(): JSX.Element {
)}
</TabsContent>
<TabsContent value='details' className='space-y-4'>
<KeyInfoDetails
baseUrl={normalizedBaseUrl}
apiKey={apiKeyInput}
walletInfo={walletInfo}
onApiKeyChanged={handleApiKeyChanged}
onWalletInfoUpdated={handleWalletInfoUpdated}
/>
</TabsContent>
<TabsContent value='child-keys' className='space-y-4'>
<ChildKeyCreator
baseUrl={normalizedBaseUrl}

View File

@@ -1,8 +1,10 @@
'use client';
import { type JSX, useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw } from 'lucide-react';
import { useWalletInfo } from '@/hooks/use-wallet-info';
import type { RefundReceipt } from './cashu-payment-workflow';
import { toast } from 'sonner';
import { ApiKeyInput } from '../api-key-input';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
Card,
CardContent,
@@ -12,8 +14,9 @@ import {
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { WalletService } from '@/lib/api/services/wallet';
import React, { useState, useCallback, useEffect } from 'react';
import { Copy, RefreshCcw, RotateCcw, Trash2 } from 'lucide-react';
export type ChildKeyInfo = {
api_key: string;
@@ -44,68 +47,44 @@ interface KeyInfoDetailsProps {
walletInfo?: WalletSnapshot | null;
onApiKeyChanged?: (apiKey: string) => void;
onWalletInfoUpdated?: (walletInfo: WalletSnapshot | null) => void;
onRefundComplete?: (receipt: RefundReceipt) => void;
}
export function KeyInfoDetails({
baseUrl,
apiKey = '',
walletInfo = null,
walletInfo: propWalletInfo = null,
onApiKeyChanged,
onWalletInfoUpdated,
}: KeyInfoDetailsProps): JSX.Element {
onRefundComplete,
}: KeyInfoDetailsProps): React.ReactNode {
const [apiKeyInput, setApiKeyInput] = useState(apiKey);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isResetting, setIsResetting] = useState<string | null>(null);
const [isRefunding, setIsRefunding] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
data: queryWalletInfo,
refetch,
isFetching,
} = useWalletInfo(baseUrl, apiKeyInput);
const walletInfo = propWalletInfo ?? queryWalletInfo ?? null;
// Sync internal state with props if they change
useEffect(() => {
setApiKeyInput(apiKey);
}, [apiKey]);
const fetchDetails = useCallback(
async (keyToFetch: string) => {
setIsRefreshing(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: { Authorization: `Bearer ${keyToFetch}` },
});
if (!response.ok) {
throw new Error('Failed to fetch key info');
}
const payload = await response.json();
const snapshot: WalletSnapshot = {
apiKey: payload.api_key || keyToFetch,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
onWalletInfoUpdated?.(snapshot);
toast.success('Key details synced');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to fetch details'
);
} finally {
setIsRefreshing(false);
}
},
[baseUrl, onWalletInfoUpdated]
);
const handleRefresh = async () => {
const handleRefresh = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!apiKeyInput) return;
await fetchDetails(apiKeyInput);
await refetch();
};
const handleKeyChange = (newKey: string) => {
setApiKeyInput(newKey);
setError(null);
onApiKeyChanged?.(newKey);
// Optionally clear info when key changes
if (newKey !== apiKey) {
@@ -125,7 +104,7 @@ export function KeyInfoDetails({
try {
await WalletService.resetChildKeySpent(baseUrl, apiKeyInput, childKey);
toast.success('Child key spent reset');
await fetchDetails(apiKeyInput);
await refetch();
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to reset child key'
@@ -135,6 +114,36 @@ export function KeyInfoDetails({
}
};
const handleRefund = useCallback(async (): Promise<void> => {
if (!apiKeyInput) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${baseUrl}/v1/balance/refund`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKeyInput}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Refund failed');
}
const receipt = (await response.json()) as RefundReceipt;
onRefundComplete?.(receipt);
toast.success('Refund completed');
await refetch();
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [apiKeyInput, baseUrl, onRefundComplete, refetch]);
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
const formatMsats = (msats: number) =>
@@ -153,17 +162,11 @@ export function KeyInfoDetails({
</CardHeader>
<CardContent>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder='sk-...'
className='font-mono text-sm'
/>
<ApiKeyInput value={apiKeyInput} onApiKeyChange={handleKeyChange} />
<div className='flex gap-2'>
<Button
variant='outline'
size='icon'
className='h-10 w-10 shrink-0'
onClick={() => handleCopy(apiKeyInput)}
disabled={!apiKeyInput}
>
@@ -174,15 +177,22 @@ export function KeyInfoDetails({
size='sm'
className='min-w-[80px] gap-1'
onClick={handleRefresh}
disabled={isRefreshing || !apiKeyInput}
disabled={isFetching || !apiKeyInput}
type='button'
>
<RefreshCcw
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
className={`h-8 w-4 ${isFetching ? 'animate-spin' : ''}`}
/>
{isRefreshing ? 'Syncing...' : 'Sync'}
{isFetching ? 'Syncing...' : 'Sync'}
</Button>
</div>
</div>
{error && (
<Alert variant='destructive' className='mt-2'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
@@ -228,6 +238,14 @@ export function KeyInfoDetails({
{formatDate(walletInfo.validityDate)}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Infos</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
@@ -236,14 +254,6 @@ export function KeyInfoDetails({
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Consumption</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
@@ -390,18 +400,16 @@ export function KeyInfoDetails({
</Card>
)}
<div className='flex justify-center'>
<div className='flex justify-center gap-4'>
<Button
variant='ghost'
onClick={handleRefund}
disabled={isRefunding || !apiKeyInput}
variant='destructive'
size='sm'
onClick={handleRefresh}
disabled={isRefreshing}
className='text-muted-foreground'
className='gap-2'
>
<RefreshCcw
className={`mr-2 h-3 w-3 ${isRefreshing ? 'animate-spin' : ''}`}
/>
Last synced: {new Date().toLocaleTimeString()}
<Trash2 className='h-4 w-4' />
{isRefunding ? 'Processing...' : 'Refund Key'}
</Button>
</div>
</>

View File

@@ -0,0 +1,198 @@
'use client';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Copy, RotateCcw } from 'lucide-react';
import type { WalletSnapshot } from './key-info-details';
import { toast } from 'sonner';
interface KeyInfoDisplayProps {
walletInfo: WalletSnapshot;
onResetSpent?: (childKey: string) => Promise<void>;
isResetting?: string | null;
}
const formatSats = (msats: number) =>
new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
const formatMsats = (msats: number) =>
new Intl.NumberFormat('en-US').format(msats);
const formatDate = (timestamp: number | null) =>
timestamp ? new Date(timestamp * 1000).toLocaleDateString() : 'Never';
export function KeyInfoDisplay({
walletInfo,
onResetSpent,
isResetting,
}: KeyInfoDisplayProps) {
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
};
return (
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Status & Identity</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Type</span>
<Badge variant={walletInfo.isChild ? 'secondary' : 'default'}>
{walletInfo.isChild ? 'Child Key' : 'Parent Key'}
</Badge>
</div>
{walletInfo.parentKey && (
<div className='space-y-1'>
<span className='text-muted-foreground text-xs tracking-wider'>
Parent Key
</span>
<div className='flex items-center gap-2'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{walletInfo.parentKey}
</code>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(walletInfo.parentKey!)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Validity</span>
<span className='text-sm font-medium'>
{formatDate(walletInfo.validityDate)}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className='pb-2'>
<CardTitle className='text-lg'>Infos</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spendable Balance
</span>
<span className='text-primary font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Total Requests
</span>
<span className='font-mono text-sm font-medium'>
{walletInfo.totalRequests}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Total Spent</span>
<div className='text-right'>
<p className='font-mono text-sm font-medium'>
{formatSats(walletInfo.totalSpent)} sats
</p>
<p className='text-muted-foreground font-mono text-[0.6rem]'>
{formatMsats(walletInfo.totalSpent)} msats
</p>
</div>
</div>
{walletInfo.balanceLimit !== null && (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Spend Limit
</span>
<span className='font-mono text-sm font-medium'>
{formatSats(walletInfo.balanceLimit)} sats
</span>
</div>
{walletInfo.balanceLimitReset && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
Reset Policy
</span>
<Badge variant='outline' className='capitalize'>
{walletInfo.balanceLimitReset}
</Badge>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{!walletInfo.isChild &&
walletInfo.childKeys &&
walletInfo.childKeys.length > 0 && (
<Card>
<CardHeader>
<CardTitle className='text-lg'>
Child Keys ({walletInfo.childKeys.length})
</CardTitle>
<CardDescription>
Secondary keys using this account&apos;s balance
</CardDescription>
</CardHeader>
<CardContent>
<div className='space-y-4'>
{walletInfo.childKeys.map((ck) => (
<div
key={ck.api_key}
className='space-y-3 rounded-lg border p-4'
>
<div className='flex items-center justify-between gap-4'>
<code className='bg-muted flex-1 rounded px-2 py-1 font-mono text-xs break-all'>
{ck.api_key}
</code>
<div className='flex gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(ck.api_key)}
>
<Copy className='h-4 w-4' />
</Button>
{onResetSpent && (
<Button
variant='ghost'
size='icon'
className='text-destructive h-8 w-8'
title='Reset consumption'
disabled={isResetting === ck.api_key}
onClick={() => onResetSpent(ck.api_key)}
>
{isResetting === ck.api_key ? (
<RotateCcw className='h-4 w-4 animate-spin' />
) : (
<RotateCcw className='h-4 w-4' />
)}
</Button>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import type { WalletSnapshot } from '@/components/landing/key-info-details';
export function useWalletInfo(baseUrl: string, apiKey: string) {
return useQuery({
queryKey: ['walletInfo', baseUrl, apiKey],
queryFn: async (): Promise<WalletSnapshot> => {
const response = await fetch(`${baseUrl}/v1/balance/info`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load wallet info');
}
const payload = await response.json();
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
isChild: payload.is_child,
parentKey: payload.parent_key,
totalRequests: payload.total_requests,
totalSpent: payload.total_spent,
balanceLimit: payload.balance_limit,
balanceLimitReset: payload.balance_limit_reset,
validityDate: payload.validity_date,
childKeys: payload.child_keys,
};
},
enabled: !!baseUrl && !!apiKey,
staleTime: 5000, // Consider data stale after 5 seconds
});
}