update ui

This commit is contained in:
9qeklajc
2026-03-08 22:19:00 +01:00
parent a0f3378cf5
commit 12f2cfecc9
4 changed files with 496 additions and 210 deletions
+1 -2
View File
@@ -132,11 +132,10 @@ class RoutstrUpstreamProvider(BaseUpstreamProvider):
async def fetch_models(self) -> list[Model]:
"""Fetch models from the upstream Routstr node."""
url = f"{self.base_url}/v1/models"
headers = {"Authorization": f"Bearer {self.api_key}"}
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, headers=headers, timeout=15.0)
response = await client.get(url, headers={}, timeout=15.0)
response.raise_for_status()
data = response.json()
models = data.get("data", [])
+23 -131
View File
@@ -32,8 +32,6 @@ import {
Database,
ChevronDown,
ChevronUp,
Copy,
AlertTriangle,
} from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
@@ -58,10 +56,9 @@ import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RoutstrProviderCard } from '@/components/providers/RoutstrProviderCard';
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
import { SimpleLightningTopup } from '@/components/providers/SimpleLightningTopup';
import { SimpleCashuTopup } from '@/components/providers/SimpleCashuTopup';
import { CashuPaymentWorkflow } from '@/components/landing/cashu-payment-workflow';
import { LightningPaymentWorkflow } from '@/components/landing/lightning-payment-workflow';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -740,29 +737,6 @@ export default function ProvidersPage() {
}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='refund_address' className='text-xs'>
Global Refund Address (LNURL/Address)
</Label>
<Input
id='refund_address'
className='h-8 text-xs'
placeholder='lightning@address.com'
value={
formData.provider_settings?.refund_address || ''
}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_address: e.target.value,
},
})
}
/>
</div>
</div>
</div>
)}
@@ -874,88 +848,17 @@ export default function ProvidersPage() {
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
{formData.provider_type === 'routstr' &&
editingProvider && (
<div className='bg-muted/30 mt-4 space-y-6 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Upstream Node Wallet Management
</Label>
<Badge variant='outline' className='text-[10px]'>
External Node
</Badge>
</div>
<div className='bg-muted/30 mt-4 space-y-6 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>
Upstream Node Wallet Management
</Label>
<Badge variant='outline' className='text-[10px]'>
External Node
</Badge>
</div>
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Lightning
</Label>
<LightningPaymentWorkflow
baseUrl={formData.base_url || ''}
onApiKeyCreated={(newApiKey) => {
setFormData((prev) => ({
...prev,
api_key: newApiKey,
}));
toast.success(
'New API key created and saved'
);
}}
/>
</section>
<Separator />
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Cashu
</Label>
<CashuPaymentWorkflow
baseUrl={formData.base_url || ''}
apiKey={formData.api_key || ''}
onApiKeyCreated={(newApiKey) => {
setFormData((prev) => ({
...prev,
api_key: newApiKey,
}));
toast.success(
'New API key created and saved'
);
}}
/>
</section>
</div>
<Separator />
<section className='space-y-2'>
<Label className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
Cashu
</Label>
<CashuPaymentWorkflow
baseUrl={formData.base_url || ''}
apiKey={formData.api_key || ''}
onApiKeyCreated={(newApiKey) => {
setFormData({
...formData,
api_key: newApiKey,
});
toast.success('New API key created and saved');
}}
/>
</section>
</div>
)}
{formData.provider_type === 'routstr' && (
<RoutstrCreateKeySection
baseUrl={formData.base_url || ''}
onApiKeyCreated={(newApiKey) => {
setFormData((prev) => ({
...prev,
api_key: newApiKey,
}));
}}
/>
)}
</div>
<DialogFooter>
<Button
@@ -1215,7 +1118,6 @@ export default function ProvidersPage() {
}
onEdit={() => handleEdit(provider)}
onDelete={() => handleDelete(provider.id)}
onUpdateKey={() => handleEdit(provider)}
balanceComponent={
<ProviderBalance
providerId={provider.id}
@@ -1536,27 +1438,6 @@ export default function ProvidersPage() {
}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_refund_address' className='text-xs'>
Global Refund Address (LNURL/Address)
</Label>
<Input
id='edit_refund_address'
className='h-8 text-xs'
placeholder='lightning@address.com'
value={formData.provider_settings?.refund_address || ''}
onChange={(e) =>
setFormData({
...formData,
provider_settings: {
...formData.provider_settings,
refund_address: e.target.value,
},
})
}
/>
</div>
</div>
</div>
)}
@@ -1655,6 +1536,17 @@ export default function ProvidersPage() {
1.01 means +1% e.g. currency exchange, card fees, etc.
</p>
</div>
{formData.provider_type === 'routstr' && (
<RoutstrCreateKeySection
baseUrl={formData.base_url || ''}
onApiKeyCreated={(newApiKey) => {
setFormData((prev) => ({
...prev,
api_key: newApiKey,
}));
}}
/>
)}
</div>
<DialogFooter>
<Button
@@ -0,0 +1,295 @@
'use client';
import { useCallback, useState } from 'react';
import Image from 'next/image';
import { Copy, Loader2, Zap, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface RoutstrCreateKeySectionProps {
baseUrl: string;
onApiKeyCreated: (apiKey: string) => void;
}
async function generateQR(text: string): Promise<string> {
try {
return await QRCode.toDataURL(text, {
type: 'image/png',
width: 200,
margin: 1,
color: { dark: '#000000', light: '#FFFFFF' },
});
} catch {
return '';
}
}
export function RoutstrCreateKeySection({
baseUrl,
onApiKeyCreated,
}: RoutstrCreateKeySectionProps) {
// Lightning state
const [lnAmount, setLnAmount] = useState('');
const [lnInvoice, setLnInvoice] = useState<{
bolt11: string;
invoice_id: string;
} | null>(null);
const [lnQrCode, setLnQrCode] = useState('');
const [isCreatingLn, setIsCreatingLn] = useState(false);
const [isWaitingLn, setIsWaitingLn] = useState(false);
// Cashu state
const [cashuToken, setCashuToken] = useState('');
const [isCreatingCashu, setIsCreatingCashu] = useState(false);
if (!baseUrl) {
return (
<div className='bg-muted/30 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
Enter the upstream node Base URL above to enable key creation.
</p>
</div>
);
}
const cleanUrl = baseUrl.replace(/\/+$/, '');
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
} catch {
toast.error('Failed to copy');
}
};
const pollInvoiceStatus = (invoiceId: string) => {
let attempts = 0;
const maxAttempts = 60;
const poll = async () => {
try {
const resp = await fetch(
`${cleanUrl}/v1/balance/lightning/invoice/${invoiceId}/status`
);
if (!resp.ok) throw new Error('Failed to check status');
const status = await resp.json();
if (status.status === 'paid' && status.api_key) {
onApiKeyCreated(status.api_key);
setLnInvoice(null);
setLnQrCode('');
setIsWaitingLn(false);
setLnAmount('');
toast.success('Payment received! API key created.');
return;
}
if (status.status === 'expired' || status.status === 'cancelled') {
toast.error('Invoice expired or cancelled');
setIsWaitingLn(false);
return;
}
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000);
} else {
toast.error('Payment timeout');
setIsWaitingLn(false);
}
} catch {
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000);
} else {
setIsWaitingLn(false);
}
}
};
poll();
};
const handleCreateLightning = async () => {
const amount = parseInt(lnAmount);
if (!amount || amount <= 0) {
toast.error('Enter a valid amount in sats');
return;
}
setIsCreatingLn(true);
try {
const resp = await fetch(`${cleanUrl}/v1/balance/lightning/invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount_sats: amount, purpose: 'create' }),
});
if (!resp.ok) {
const errorText = await resp.text();
throw new Error(errorText || 'Failed to create invoice');
}
const data = await resp.json();
setLnInvoice({ bolt11: data.bolt11, invoice_id: data.invoice_id });
const qr = await generateQR(data.bolt11);
setLnQrCode(qr);
setIsWaitingLn(true);
pollInvoiceStatus(data.invoice_id);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create invoice');
} finally {
setIsCreatingLn(false);
}
};
const handleCreateCashu = async () => {
if (!cashuToken.trim()) {
toast.error('Paste a Cashu token');
return;
}
setIsCreatingCashu(true);
try {
const params = new URLSearchParams({
initial_balance_token: cashuToken.trim(),
});
const resp = await fetch(
`${cleanUrl}/v1/balance/create?${params.toString()}`,
{ method: 'GET', headers: { 'Content-Type': 'application/json' } }
);
if (!resp.ok) {
const errorText = await resp.text();
throw new Error(errorText || 'Failed to create API key');
}
const data = await resp.json();
onApiKeyCreated(data.api_key);
setCashuToken('');
toast.success('API key created');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to create key');
} finally {
setIsCreatingCashu(false);
}
};
return (
<div className='bg-muted/30 space-y-4 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<Label className='text-sm font-semibold'>Create API Key</Label>
<Badge variant='outline' className='text-[10px]'>
External Node
</Badge>
</div>
<p className='text-muted-foreground text-xs'>
Create an API key on the upstream Routstr node by paying with Lightning
or Cashu.
</p>
<Tabs defaultValue='lightning' className='w-full'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='lightning' className='gap-1 text-xs'>
<Zap className='h-3 w-3' />
Lightning
</TabsTrigger>
<TabsTrigger value='cashu' className='gap-1 text-xs'>
<KeyRound className='h-3 w-3' />
Cashu
</TabsTrigger>
</TabsList>
<TabsContent value='lightning' className='mt-3 space-y-3'>
<div className='flex gap-2'>
<Input
type='number'
placeholder='Amount in sats'
value={lnAmount}
onChange={(e) => setLnAmount(e.target.value)}
className='h-9'
disabled={isWaitingLn}
/>
<Button
onClick={handleCreateLightning}
disabled={isCreatingLn || isWaitingLn}
size='sm'
>
{isCreatingLn ? 'Creating...' : 'Get Invoice'}
</Button>
</div>
{lnInvoice && (
<div className='space-y-2 border-t pt-2'>
<div className='text-muted-foreground flex items-center justify-between text-xs'>
<span>Pay this invoice to create your key</span>
<Button
variant='ghost'
size='icon'
className='h-6 w-6'
onClick={() => handleCopy(lnInvoice.bolt11)}
>
<Copy className='h-3 w-3' />
</Button>
</div>
{lnQrCode && (
<div className='flex justify-center py-2'>
<Image
src={lnQrCode}
alt='Lightning Invoice QR Code'
className='h-48 w-48'
width={192}
height={192}
unoptimized
/>
</div>
)}
<div className='bg-muted rounded border p-2 font-mono text-[10px] break-all'>
{lnInvoice.bolt11}
</div>
{isWaitingLn && (
<div className='flex animate-pulse items-center gap-2 text-xs text-orange-600'>
<Loader2 className='h-3 w-3 animate-spin' />
Waiting for payment...
</div>
)}
</div>
)}
</TabsContent>
<TabsContent value='cashu' className='mt-3 space-y-3'>
<Textarea
placeholder='Paste Cashu token (cashuA1...)'
value={cashuToken}
onChange={(e) => setCashuToken(e.target.value)}
rows={3}
className='font-mono text-xs'
/>
<Button
onClick={handleCreateCashu}
disabled={isCreatingCashu}
size='sm'
className='w-full'
>
{isCreatingCashu ? 'Creating...' : 'Create API Key'}
</Button>
<p className='text-muted-foreground text-[10px]'>
Redeems the token instantly and returns an API key.
</p>
</TabsContent>
</Tabs>
</div>
);
}
+177 -77
View File
@@ -1,5 +1,6 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -9,6 +10,14 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Database,
Pencil,
@@ -17,10 +26,15 @@ import {
ChevronUp,
RotateCcw,
AlertTriangle,
Key,
KeyRound,
} from 'lucide-react';
import { UpstreamProvider } from '@/lib/api/services/admin';
import {
AdminService,
UpstreamProvider,
UpdateUpstreamProvider,
} from '@/lib/api/services/admin';
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
import { RoutstrCreateKeySection } from './RoutstrCreateKeySection';
import { toast } from 'sonner';
interface RoutstrProviderCardProps {
@@ -40,13 +54,14 @@ export function RoutstrProviderCard({
onToggleExpand,
onEdit,
onDelete,
onUpdateKey,
balanceComponent,
children,
}: RoutstrProviderCardProps) {
const queryClient = useQueryClient();
const [isKeyDialogOpen, setIsKeyDialogOpen] = useState(false);
const hasMint = !!provider.provider_settings?.topup_mint_url;
const hasApiKey = !!provider.api_key;
const refundMutation = useMutation({
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
@@ -58,7 +73,7 @@ export function RoutstrProviderCard({
queryClient.invalidateQueries({
queryKey: ['provider-balance', provider.id],
});
queryClient.invalidateQueries({ queryKey: ['balances'] }); // Global wallet balance
queryClient.invalidateQueries({ queryKey: ['balances'] });
} else {
toast.error('Refund failed', {
description: data.message,
@@ -70,85 +85,170 @@ export function RoutstrProviderCard({
},
});
const updateKeyMutation = useMutation({
mutationFn: (data: { id: number; data: UpdateUpstreamProvider }) =>
AdminService.updateUpstreamProvider(data.id, data.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['upstream-providers'] });
queryClient.invalidateQueries({
queryKey: ['provider-balance', provider.id],
});
setIsKeyDialogOpen(false);
toast.success('API key saved to provider');
},
onError: (error: Error) => {
toast.error(`Failed to save key: ${error.message}`);
},
});
const handleKeyCreated = (newApiKey: string) => {
updateKeyMutation.mutate({
id: provider.id,
data: { api_key: newApiKey },
});
};
return (
<Card>
<CardHeader>
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<CardTitle className='truncate text-lg'>Routstr Node</CardTitle>
<Badge
variant={provider.enabled ? 'default' : 'secondary'}
className='w-fit sm:ml-2'
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{!hasMint && (
<>
<Card>
<CardHeader>
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<CardTitle className='truncate text-lg'>Routstr Node</CardTitle>
<Badge
variant='outline'
className='flex items-center gap-1 border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-400'
title='Top-up is not possible because no top-up mint is selected in the provider settings. Please edit settings to select a mint from your node configuration.'
variant={provider.enabled ? 'default' : 'secondary'}
className='w-fit sm:ml-2'
>
<AlertTriangle className='h-3 w-3' />
Top-up Disabled: No Mint Selected
{provider.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
{!hasApiKey && (
<Badge
variant='outline'
className='flex items-center gap-1 border-red-200 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-900/20 dark:text-red-400'
>
<AlertTriangle className='h-3 w-3' />
No API Key
</Badge>
)}
{!hasMint && (
<Badge
variant='outline'
className='flex items-center gap-1 border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-400'
title='Top-up is not possible because no top-up mint is selected in the provider settings. Please edit settings to select a mint from your node configuration.'
>
<AlertTriangle className='h-3 w-3' />
Top-up Disabled: No Mint Selected
</Badge>
)}
</div>
<CardDescription className='mt-1 break-all'>
{provider.base_url}
</CardDescription>
</div>
<CardDescription className='mt-1 break-all'>
{provider.base_url}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
<div className='flex flex-col gap-1'>{balanceComponent}</div>
<Button
variant='outline'
size='sm'
onClick={() => refundMutation.mutate()}
disabled={refundMutation.isPending}
className='text-orange-600 hover:text-orange-700 dark:text-orange-400'
title='Refund balance to local wallet'
>
<RotateCcw
className={`mr-1 h-4 w-4 ${refundMutation.isPending ? 'animate-spin' : ''}`}
/>
<span className='hidden sm:inline'>Refund</span>
</Button>
<Button
variant='outline'
size='sm'
onClick={onToggleExpand}
className='w-full sm:w-auto'
>
<Database className='mr-1 h-4 w-4' />
<span className='hidden sm:inline'>Models</span>
{expanded ? (
<ChevronUp className='ml-1 h-4 w-4' />
) : (
<ChevronDown className='ml-1 h-4 w-4' />
<div className='flex flex-wrap items-center gap-2'>
{hasApiKey && (
<div className='flex flex-col gap-1'>{balanceComponent}</div>
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={onEdit}
className='w-full sm:w-auto'
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
onClick={onDelete}
className='w-full sm:w-auto'
>
<Trash2 className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setIsKeyDialogOpen(true)}
className='w-full sm:w-auto'
title={
hasApiKey
? 'Create a new key on the upstream node'
: 'Create an API key on the upstream node'
}
>
<KeyRound className='mr-1 h-4 w-4' />
<span className='hidden sm:inline'>
{hasApiKey ? 'New Key' : 'Create Key'}
</span>
</Button>
{hasApiKey && (
<Button
variant='outline'
size='sm'
onClick={() => refundMutation.mutate()}
disabled={refundMutation.isPending}
className='text-orange-600 hover:text-orange-700 dark:text-orange-400'
title='Refund balance to local wallet'
>
<RotateCcw
className={`mr-1 h-4 w-4 ${refundMutation.isPending ? 'animate-spin' : ''}`}
/>
<span className='hidden sm:inline'>Refund</span>
</Button>
)}
<Button
variant='outline'
size='sm'
onClick={onToggleExpand}
className='w-full sm:w-auto'
>
<Database className='mr-1 h-4 w-4' />
<span className='hidden sm:inline'>Models</span>
{expanded ? (
<ChevronUp className='ml-1 h-4 w-4' />
) : (
<ChevronDown className='ml-1 h-4 w-4' />
)}
</Button>
<Button
variant='outline'
size='sm'
onClick={onEdit}
className='w-full sm:w-auto'
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='sm'
onClick={onDelete}
className='w-full sm:w-auto'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
</div>
</div>
</CardHeader>
{children}
</Card>
</CardHeader>
{children}
</Card>
<Dialog open={isKeyDialogOpen} onOpenChange={setIsKeyDialogOpen}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-lg'>
<DialogHeader>
<DialogTitle>
{hasApiKey ? 'Create New Key on Upstream Node' : 'Create API Key'}
</DialogTitle>
<DialogDescription>
{hasApiKey
? 'Create a new API key on the upstream node. This will replace the current key.'
: 'Create an API key on the upstream Routstr node to enable balance, top-up, and refund operations.'}
</DialogDescription>
</DialogHeader>
<RoutstrCreateKeySection
baseUrl={provider.base_url}
onApiKeyCreated={handleKeyCreated}
/>
<DialogFooter>
<Button
variant='outline'
onClick={() => setIsKeyDialogOpen(false)}
className='w-full'
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}