feat: add a Withdrawals tab so issued tokens can be recovered

This commit is contained in:
Ashen
2026-08-17 12:23:22 +05:30
parent 87787bd976
commit 456090f756
5 changed files with 184 additions and 40 deletions
+106 -13
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { downloadText } from '@/lib/utils';
import { AppPageShell } from '@/components/app-page-shell';
import { PageHeader } from '@/components/page-header';
import {
@@ -53,6 +54,7 @@ import {
Zap,
ChevronLeft,
ChevronRight,
Download,
} from 'lucide-react';
import {
AdminService,
@@ -181,19 +183,37 @@ function TransactionTable({
{format(tx.created_at * 1000, 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className='text-right'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
<div className='flex items-center justify-end gap-1'>
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => onCopy(tx.token, tx.id + '-token')}
title='Copy Token'
>
{copiedId === tx.id + '-token' ? (
<Check className='h-4 w-4' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
{/* The sweeper marks a withdrawal collected or swept once
it can no longer be redeemed, and the token is never
rendered anywhere. */}
{tx.source === 'admin' && !tx.swept && !tx.collected && (
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() =>
downloadText(`cashu-token-${tx.id}.txt`, tx.token)
}
title='Download Token'
>
<Download className='h-4 w-4' />
</Button>
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -402,6 +422,7 @@ export default function TransactionsPage() {
const [activeTab, setActiveTab] = useState<string>('x-cashu');
const [xcashuPage, setXcashuPage] = useState(0);
const [apikeyPage, setApikeyPage] = useState(0);
const [withdrawalsPage, setWithdrawalsPage] = useState(0);
const [lightningPage, setLightningPage] = useState(0);
const typeParam = type === 'all' ? undefined : type;
@@ -450,6 +471,28 @@ export default function TransactionsPage() {
placeholderData: keepPreviousData,
});
// Withdrawals are stored with source "admin" and keep their one-time token.
const withdrawalsQuery = useQuery({
queryKey: [
'transactions',
'admin',
typeParam,
statusParam,
searchParam,
withdrawalsPage,
],
queryFn: () =>
AdminService.getTransactions(
typeParam,
statusParam,
searchParam,
'admin',
PAGE_SIZE,
withdrawalsPage * PAGE_SIZE
),
placeholderData: keepPreviousData,
});
const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled'];
const lightningStatusParam = LIGHTNING_STATUSES.includes(status)
? status
@@ -480,6 +523,7 @@ export default function TransactionsPage() {
setStatus('all');
setXcashuPage(0);
setApikeyPage(0);
setWithdrawalsPage(0);
setLightningPage(0);
};
@@ -508,6 +552,18 @@ export default function TransactionsPage() {
Collected
</Badge>
);
// An outstanding withdrawal ends at "issued": the node hands over a
// bearer token and never learns whether it was redeemed, so it would
// otherwise read Pending forever.
if (tx.source === 'admin')
return (
<Badge
variant='outline'
className='border-gray-500/20 bg-gray-500/10 text-gray-500'
>
Issued
</Badge>
);
return (
<Badge
variant='outline'
@@ -533,12 +589,14 @@ export default function TransactionsPage() {
useEffect(() => {
setXcashuPage(0);
setApikeyPage(0);
setWithdrawalsPage(0);
setLightningPage(0);
}, [type, status, search]);
const isRefetching =
xcashuQuery.isRefetching ||
apikeyQuery.isRefetching ||
withdrawalsQuery.isRefetching ||
lightningQuery.isRefetching;
const renderCardContent = (
@@ -617,6 +675,7 @@ export default function TransactionsPage() {
onClick={() => {
xcashuQuery.refetch();
apikeyQuery.refetch();
withdrawalsQuery.refetch();
lightningQuery.refetch();
}}
variant='outline'
@@ -703,7 +762,7 @@ export default function TransactionsPage() {
value={activeTab}
onValueChange={setActiveTab}
>
<TabsList className='mb-4'>
<TabsList className='mb-4 max-w-full justify-start overflow-x-auto'>
<TabsTrigger value='x-cashu' className='flex items-center gap-2'>
<Zap className='h-4 w-4' />
X-Cashu
@@ -722,6 +781,18 @@ export default function TransactionsPage() {
</Badge>
)}
</TabsTrigger>
<TabsTrigger
value='withdrawals'
className='flex items-center gap-2'
>
<ArrowUpRight className='h-4 w-4' />
Withdrawals
{withdrawalsQuery.data && (
<Badge variant='secondary' className='ml-1'>
{withdrawalsQuery.data.total}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value='lightning' className='flex items-center gap-2'>
<Zap className='h-4 w-4' />
Lightning
@@ -769,6 +840,28 @@ export default function TransactionsPage() {
</Card>
</TabsContent>
<TabsContent value='withdrawals'>
<Card>
<CardHeader>
<div className='flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between'>
<CardTitle>Withdrawal History</CardTitle>
{hasActiveFilters && (
<CardDescription>
Filtered by {activeFilterDescription}
</CardDescription>
)}
</div>
</CardHeader>
<CardContent className='overflow-hidden'>
{renderCardContent(
withdrawalsQuery,
withdrawalsPage,
setWithdrawalsPage
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value='lightning'>
<Card>
<CardHeader>
+7 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, AlertCircle, Wallet, User, Coins } from 'lucide-react';
import { WalletService, BalanceDetail } from '@/lib/api/services/wallet';
import { getApiErrorMessage } from '@/lib/api/errors';
@@ -45,6 +45,7 @@ export function DetailedWalletBalance({
usdPerSat: number | null;
}) {
const [withdrawModalOpen, setWithdrawModalOpen] = useState(false);
const queryClient = useQueryClient();
const { data, isLoading, isError, error, isFetching, refetch } = useQuery({
queryKey: ['detailed-wallet-balance'],
@@ -391,6 +392,11 @@ export function DetailedWalletBalance({
balances={data || []}
onSuccess={() => {
refetch();
// The success dialog tells the operator the token is in the
// Withdrawals tab; without this the cached tab stays stale for 5 min.
queryClient.invalidateQueries({
queryKey: ['transactions', 'admin'],
});
}}
/>
</>
+61 -25
View File
@@ -11,10 +11,11 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Select,
SelectContent,
@@ -22,7 +23,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AlertCircle, Copy, CheckCircle } from 'lucide-react';
import { AlertCircle, Copy, CheckCircle, Download } from 'lucide-react';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { getApiErrorMessage } from '@/lib/api/errors';
import { downloadText } from '@/lib/utils';
interface WithdrawModalProps {
open: boolean;
@@ -40,7 +44,8 @@ export function WithdrawModal({
const [selectedMintUnit, setSelectedMintUnit] = useState('');
const [amount, setAmount] = useState('');
const [withdrawnToken, setWithdrawnToken] = useState('');
const [copiedToken, setCopiedToken] = useState(false);
const [confirmedSaved, setConfirmedSaved] = useState(false);
const { copied: copiedToken, copy } = useCopyToClipboard();
const availableBalances = balances.filter(
(b) => !b.error && b.wallet_balance > 0
@@ -82,16 +87,20 @@ export function WithdrawModal({
setWithdrawnToken('');
setAmount('');
setSelectedMintUnit('');
setCopiedToken(false);
setConfirmedSaved(false);
}
}, [open]);
const handleCopyToken = () => {
navigator.clipboard.writeText(withdrawnToken);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
// The token is a bearer instrument. Escape, backdrop, X and drag-dismiss all
// route through here; none of them may destroy it before the user confirms.
const handleOpenChange = (next: boolean) => {
if (!next && withdrawnToken && !confirmedSaved) return;
onOpenChange(next);
};
const handleDownloadToken = () =>
downloadText(`cashu-token-${Date.now()}.txt`, withdrawnToken);
const amountNum = parseInt(amount) || 0;
const showWarning =
selectedBalance &&
@@ -100,33 +109,30 @@ export function WithdrawModal({
if (withdrawnToken) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-xl'>
<Dialog open={open} onOpenChange={handleOpenChange}>
{/* The X would route to handleOpenChange, which refuses until the token
is confirmed saved, so it would render as a dead control. */}
<DialogContent className='sm:max-w-xl' showCloseButton={false}>
<DialogHeader>
<DialogTitle>Withdrawal Successful</DialogTitle>
<DialogTitle>Withdrawal complete</DialogTitle>
<DialogDescription>
Save this token! It represents your withdrawn balance.
This token is the money. Anyone holding it can redeem it, so save
it before closing.
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<Alert>
<CheckCircle className='h-5 w-5' />
<AlertTitle>Withdrawal Token</AlertTitle>
<AlertDescription>
Save this token now. It represents your withdrawn balance.
</AlertDescription>
</Alert>
<Textarea
readOnly
value={withdrawnToken}
onFocus={(e) => e.target.select()}
className='font-mono text-xs leading-relaxed'
rows={6}
/>
<div className='flex flex-col gap-2 sm:flex-row'>
<Button
onClick={handleCopyToken}
onClick={() => copy(withdrawnToken)}
className='w-full flex-1'
variant={copiedToken ? 'outline' : 'default'}
>
@@ -143,13 +149,41 @@ export function WithdrawModal({
)}
</Button>
<Button
onClick={() => onOpenChange(false)}
onClick={handleDownloadToken}
variant='outline'
className='w-full sm:w-auto'
className='w-full flex-1'
>
Close
<Download className='mr-2 h-4 w-4' />
Download .txt
</Button>
</div>
<p className='text-muted-foreground text-xs'>
A copy is also kept under Transactions in the Withdrawals tab,
where you can copy it again later.
</p>
<div className='flex items-center gap-2'>
<Checkbox
id='token-saved'
checked={confirmedSaved}
onCheckedChange={(checked) =>
setConfirmedSaved(checked === true)
}
/>
<Label htmlFor='token-saved' className='text-sm font-normal'>
I have saved this token
</Label>
</div>
<Button
onClick={() => handleOpenChange(false)}
variant='outline'
disabled={!confirmedSaved}
className='w-full'
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
@@ -233,8 +267,10 @@ export function WithdrawModal({
<Alert variant='destructive'>
<AlertCircle className='h-5 w-5' />
<AlertDescription>
{(withdrawMutation.error as Error).message ||
'Failed to withdraw'}
{getApiErrorMessage(
withdrawMutation.error,
'Failed to withdraw'
)}
</AlertDescription>
</Alert>
)}
+1 -1
View File
@@ -1261,7 +1261,7 @@ export interface Transaction {
created_at: number;
collected: boolean;
swept: boolean;
source: 'x-cashu' | 'apikey';
source: 'x-cashu' | 'apikey' | 'admin';
api_key_hashed_key?: string;
}
+9
View File
@@ -4,3 +4,12 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function downloadText(filename: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}