mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Merge pull request #651 from ashen0x/ui/withdraw-and-clipboard-hardening
Make withdrawn tokens recoverable
This commit is contained in:
+33
-2
@@ -1811,6 +1811,21 @@ async def release_ppq_auto_topup_api(
|
||||
return {"ok": True, "released": True}
|
||||
|
||||
|
||||
def _transaction_status(tx: CashuTransaction) -> str:
|
||||
"""An outgoing admin withdrawal ends at "issued": the node hands the bearer
|
||||
token over and never learns whether it was redeemed, so its flags stay false
|
||||
and it would otherwise read pending forever. Incoming admin rows are redeemed
|
||||
by the node itself and keep the normal collected/swept lifecycle.
|
||||
"""
|
||||
if tx.swept:
|
||||
return "swept"
|
||||
if tx.collected:
|
||||
return "collected"
|
||||
if tx.source == "admin" and tx.type == "out":
|
||||
return "issued"
|
||||
return "pending"
|
||||
|
||||
|
||||
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
|
||||
async def get_transactions_api(
|
||||
type: str | None = None,
|
||||
@@ -1840,13 +1855,25 @@ async def get_transactions_api(
|
||||
base = base.where(CashuTransaction.source == source)
|
||||
if status:
|
||||
if status == "collected":
|
||||
base = base.where(CashuTransaction.collected == True) # noqa: E712
|
||||
base = base.where(
|
||||
CashuTransaction.collected == True, # noqa: E712
|
||||
CashuTransaction.swept == False, # noqa: E712
|
||||
)
|
||||
elif status == "swept":
|
||||
base = base.where(CashuTransaction.swept == True) # noqa: E712
|
||||
elif status == "issued":
|
||||
base = base.where(
|
||||
CashuTransaction.source == "admin",
|
||||
CashuTransaction.type == "out",
|
||||
CashuTransaction.collected == False, # noqa: E712
|
||||
CashuTransaction.swept == False, # noqa: E712
|
||||
)
|
||||
elif status == "pending":
|
||||
base = base.where(
|
||||
CashuTransaction.collected == False, # noqa: E712
|
||||
CashuTransaction.swept == False, # noqa: E712
|
||||
(CashuTransaction.source != "admin")
|
||||
| (CashuTransaction.type != "out"),
|
||||
)
|
||||
|
||||
if search:
|
||||
@@ -1873,7 +1900,11 @@ async def get_transactions_api(
|
||||
|
||||
return {
|
||||
"transactions": [
|
||||
tx.dict(exclude={"sweep_started_at"}) for tx in transactions
|
||||
{
|
||||
**tx.dict(exclude={"sweep_started_at"}),
|
||||
"status": _transaction_status(tx),
|
||||
}
|
||||
for tx in transactions
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core.admin import get_transactions_api
|
||||
from routstr.core.admin import _transaction_status, get_transactions_api
|
||||
from routstr.core.db import CashuTransaction
|
||||
|
||||
|
||||
@@ -33,3 +38,151 @@ async def test_transactions_api_excludes_internal_sweep_claim_timestamp() -> Non
|
||||
assert response["total"] == 1
|
||||
assert response["transactions"][0]["token"] == "cashu-token"
|
||||
assert "sweep_started_at" not in response["transactions"][0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "typ", "collected", "swept", "expected"),
|
||||
[
|
||||
("admin", "out", False, False, "issued"),
|
||||
("admin", "out", True, False, "collected"),
|
||||
("admin", "out", False, True, "swept"),
|
||||
# The node redeems an incoming top-up itself, so it is never "issued".
|
||||
("admin", "in", False, False, "pending"),
|
||||
("admin", "in", True, False, "collected"),
|
||||
("x-cashu", "out", False, False, "pending"),
|
||||
("x-cashu", "out", True, False, "collected"),
|
||||
("apikey", "out", False, True, "swept"),
|
||||
],
|
||||
)
|
||||
def test_transaction_status(
|
||||
source: str, typ: str, collected: bool, swept: bool, expected: str
|
||||
) -> None:
|
||||
transaction = CashuTransaction(
|
||||
token="t",
|
||||
amount=1,
|
||||
unit="sat",
|
||||
type=typ,
|
||||
source=source,
|
||||
collected=collected,
|
||||
swept=swept,
|
||||
)
|
||||
assert _transaction_status(transaction) == expected
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session_factory(
|
||||
tmp_path: Path,
|
||||
) -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'admin.db'}")
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(SQLModel.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
ROWS = [
|
||||
("issued-withdrawal", "admin", "out", False, False),
|
||||
("collected-withdrawal", "admin", "out", True, False),
|
||||
("swept-withdrawal", "admin", "out", False, True),
|
||||
("incoming-topup", "admin", "in", False, False),
|
||||
("pending-xcashu", "x-cashu", "out", False, False),
|
||||
("collected-apikey", "apikey", "out", True, False),
|
||||
# Nothing stops both terminal flags being set, and the sweeper can reach
|
||||
# this state when a token it claimed turns out to be already spent.
|
||||
("collected-and-swept", "x-cashu", "out", True, True),
|
||||
]
|
||||
|
||||
|
||||
async def _seed(factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
async with factory() as session:
|
||||
session.add_all(
|
||||
[
|
||||
CashuTransaction(
|
||||
id=row_id,
|
||||
token=row_id,
|
||||
amount=1,
|
||||
unit="sat",
|
||||
source=source,
|
||||
type=typ,
|
||||
collected=collected,
|
||||
swept=swept,
|
||||
)
|
||||
for row_id, source, typ, collected, swept in ROWS
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _query(
|
||||
factory: async_sessionmaker[AsyncSession], **kwargs: object
|
||||
) -> list[dict]:
|
||||
@asynccontextmanager
|
||||
async def create_session(): # type: ignore[no-untyped-def]
|
||||
async with factory() as session:
|
||||
yield session
|
||||
|
||||
with patch("routstr.core.admin.create_session", create_session):
|
||||
response = await get_transactions_api(**kwargs) # type: ignore[arg-type]
|
||||
return response["transactions"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_carries_status_for_every_row(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
await _seed(session_factory)
|
||||
|
||||
by_id = {tx["id"]: tx["status"] for tx in await _query(session_factory)}
|
||||
|
||||
assert by_id == {
|
||||
"issued-withdrawal": "issued",
|
||||
"collected-withdrawal": "collected",
|
||||
"swept-withdrawal": "swept",
|
||||
"incoming-topup": "pending",
|
||||
"pending-xcashu": "pending",
|
||||
"collected-apikey": "collected",
|
||||
"collected-and-swept": "swept",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_filters_partition_rows_by_reported_status(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
await _seed(session_factory)
|
||||
unfiltered = {tx["id"]: tx["status"] for tx in await _query(session_factory)}
|
||||
|
||||
filtered: dict[str, str] = {}
|
||||
for status in ("issued", "collected", "swept", "pending"):
|
||||
for tx in await _query(session_factory, status=status):
|
||||
assert tx["status"] == status
|
||||
filtered[tx["id"]] = status
|
||||
|
||||
assert filtered == unfiltered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issued_filter_returns_only_outstanding_withdrawals(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
await _seed(session_factory)
|
||||
|
||||
rows = await _query(session_factory, status="issued")
|
||||
|
||||
assert [tx["id"] for tx in rows] == ["issued-withdrawal"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_filter_excludes_issued_withdrawals(
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
await _seed(session_factory)
|
||||
|
||||
rows = await _query(session_factory, status="pending")
|
||||
ids = {tx["id"] for tx in rows}
|
||||
|
||||
# It is uncollected and unswept, so it matched "pending" before it had a
|
||||
# status of its own.
|
||||
assert "issued-withdrawal" not in ids
|
||||
assert ids == {"incoming-topup", "pending-xcashu"}
|
||||
|
||||
+119
-36
@@ -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,
|
||||
@@ -64,6 +66,28 @@ import { toast } from 'sonner';
|
||||
|
||||
const STORAGE_KEY = 'routstr-transaction-filters';
|
||||
|
||||
const STATUS_BADGES: Record<
|
||||
Transaction['status'],
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
issued: {
|
||||
label: 'Issued',
|
||||
className: 'border-gray-500/20 bg-gray-500/10 text-gray-500',
|
||||
},
|
||||
collected: {
|
||||
label: 'Collected',
|
||||
className: 'border-green-500/20 bg-green-500/10 text-green-500',
|
||||
},
|
||||
swept: {
|
||||
label: 'Swept',
|
||||
className: 'border-orange-500/20 bg-orange-500/10 text-orange-500',
|
||||
},
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
className: 'border-blue-500/20 bg-blue-500/10 text-blue-500',
|
||||
},
|
||||
};
|
||||
|
||||
function TransactionTable({
|
||||
transactions,
|
||||
copiedId,
|
||||
@@ -181,19 +205,36 @@ 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 token is never rendered in the table, so the
|
||||
clipboard must not be the only way to get it out. */}
|
||||
{tx.status === 'issued' && (
|
||||
<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 +443,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 +492,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 +544,7 @@ export default function TransactionsPage() {
|
||||
setStatus('all');
|
||||
setXcashuPage(0);
|
||||
setApikeyPage(0);
|
||||
setWithdrawalsPage(0);
|
||||
setLightningPage(0);
|
||||
};
|
||||
|
||||
@@ -490,30 +555,10 @@ export default function TransactionsPage() {
|
||||
};
|
||||
|
||||
const getStatusBadge = (tx: Transaction) => {
|
||||
if (tx.swept)
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-orange-500/20 bg-orange-500/10 text-orange-500'
|
||||
>
|
||||
Swept
|
||||
</Badge>
|
||||
);
|
||||
if (tx.collected)
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-green-500/20 bg-green-500/10 text-green-500'
|
||||
>
|
||||
Collected
|
||||
</Badge>
|
||||
);
|
||||
const { label, className } = STATUS_BADGES[tx.status];
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-blue-500/20 bg-blue-500/10 text-blue-500'
|
||||
>
|
||||
Pending
|
||||
<Badge variant='outline' className={className}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -533,12 +578,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 +664,7 @@ export default function TransactionsPage() {
|
||||
onClick={() => {
|
||||
xcashuQuery.refetch();
|
||||
apikeyQuery.refetch();
|
||||
withdrawalsQuery.refetch();
|
||||
lightningQuery.refetch();
|
||||
}}
|
||||
variant='outline'
|
||||
@@ -675,6 +723,7 @@ export default function TransactionsPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Statuses</SelectItem>
|
||||
<SelectItem value='pending'>Pending</SelectItem>
|
||||
<SelectItem value='issued'>Issued</SelectItem>
|
||||
<SelectItem value='collected'>Collected</SelectItem>
|
||||
<SelectItem value='swept'>Swept</SelectItem>
|
||||
<SelectItem value='paid'>Paid (Lightning)</SelectItem>
|
||||
@@ -703,7 +752,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 +771,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 +830,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>
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1261,7 +1261,8 @@ export interface Transaction {
|
||||
created_at: number;
|
||||
collected: boolean;
|
||||
swept: boolean;
|
||||
source: 'x-cashu' | 'apikey';
|
||||
status: 'issued' | 'collected' | 'swept' | 'pending';
|
||||
source: 'x-cashu' | 'apikey' | 'admin';
|
||||
api_key_hashed_key?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user