Merge branch 'origin/v0.2.1' into cursor/recreate-branch-with-original-commits-gemini-3-pro-preview-bbe5

This commit is contained in:
Cursor Agent
2025-11-23 22:23:00 +00:00
14 changed files with 814 additions and 33 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ Once built, the UI is automatically served by the FastAPI backend:
- **Dashboard**: `http://localhost:8000/`
- **Login**: `http://localhost:8000/login`
- **Models Management**: `http://localhost:8000/model
- **Models Management**: `http://localhost:8000/model`
- **Providers Management**: `http://localhost:8000/providers`
- **Settings**: `http://localhost:8000/settings`
+1 -1
View File
@@ -9,7 +9,7 @@ from sqlmodel import col, update
from .core import get_logger
from .core.db import ApiKey, AsyncSession
from .core.settings import settings
from .payment.cost_caculation import (
from .payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
+1 -2
View File
@@ -182,7 +182,6 @@ async def info() -> dict:
"mints": global_settings.cashu_mints,
"http_url": global_settings.http_url,
"onion_url": global_settings.onion_url,
"models": [], # kept for back-compat; prefer /v1/models
}
@@ -335,4 +334,4 @@ app.include_router(admin_router)
app.include_router(balance_router)
app.include_router(deprecated_wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)
app.include_router(proxy_router)
+1 -1
View File
@@ -114,7 +114,7 @@ def resolve_bootstrap() -> Settings:
)
except Exception:
pass
# Map COST_PER_1K_* -> CUSTOM_PER_1K_*
# Map COST_PER_1K_* -> FIXED_PER_1K_*
if (
"COST_PER_1K_INPUT_TOKENS" in os.environ
and "FIXED_PER_1K_INPUT_TOKENS" not in os.environ
+1 -1
View File
@@ -1,4 +1,4 @@
from .cost_caculation import CostData, CostDataError, MaxCostData, calculate_cost
from .cost_calculation import CostData, CostDataError, MaxCostData, calculate_cost
__all__ = [
"CostData",
+11 -10
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import random
from pathlib import Path
from typing import Final
from urllib.request import urlopen
import httpx
@@ -19,6 +20,14 @@ logger = get_logger(__name__)
models_router = APIRouter()
DEFAULT_EXCLUDED_MODEL_IDS: Final[set[str]] = {
"openrouter/auto",
"google/gemini-2.5-pro-exp-03-25",
"opengvlab/internvl3-78b",
"openrouter/sonoma-dusk-alpha",
"openrouter/sonoma-sky-alpha",
}
class Architecture(BaseModel):
modality: str
@@ -89,11 +98,7 @@ def fetch_openrouter_models(source_filter: str | None = None) -> list[dict]:
if (
"(free)" in model.get("name", "")
or model_id == "openrouter/auto"
or model_id == "google/gemini-2.5-pro-exp-03-25"
or model_id == "opengvlab/internvl3-78b"
or model_id == "openrouter/sonoma-dusk-alpha"
or model_id == "openrouter/sonoma-sky-alpha"
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
continue
@@ -130,11 +135,7 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
if (
"(free)" in model.get("name", "")
or model_id == "openrouter/auto"
or model_id == "google/gemini-2.5-pro-exp-03-25"
or model_id == "opengvlab/internvl3-78b"
or model_id == "openrouter/sonoma-dusk-alpha"
or model_id == "openrouter/sonoma-sky-alpha"
or model_id in DEFAULT_EXCLUDED_MODEL_IDS
):
continue
+15 -2
View File
@@ -18,7 +18,7 @@ from ..core.db import ApiKey, AsyncSession, create_session
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..payment.cost_caculation import (
from ..payment.cost_calculation import (
CostData,
CostDataError,
MaxCostData,
@@ -136,6 +136,14 @@ class BaseUpstreamProvider:
if headers.pop(auth_header, None) is not None:
removed_headers.append(auth_header)
for header in ["authorization", "accept-encoding"]:
if headers.pop(header, None) is not None:
removed_headers.append(f"{header} (replaced with routstr-safe version)")
# Explicitly define the list of supported compression encodings
headers["accept-encoding"] = "gzip, deflate, br, identity"
logger.debug(
"Headers prepared for upstream",
extra={
@@ -504,11 +512,16 @@ class BaseUpstreamProvider:
)
await finalize_without_usage()
raise
# Remove inaccurate encoding headers from upstream response
response_headers = dict(response.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return StreamingResponse(
stream_with_cost(max_cost_for_model),
status_code=response.status_code,
headers=dict(response.headers),
headers=response_headers,
)
async def handle_non_streaming_chat_completion(
@@ -62,7 +62,6 @@ async def test_root_endpoint_structure_and_performance(
"mints",
"http_url",
"onion_url",
"models",
]
for field in required_fields:
assert field in data, f"Missing required field: {field}"
@@ -75,15 +74,9 @@ async def test_root_endpoint_structure_and_performance(
assert isinstance(data["mints"], list)
assert isinstance(data["http_url"], str)
assert isinstance(data["onion_url"], str)
assert isinstance(data["models"], list)
# Validate models structure if any exist
for model in data["models"]:
assert isinstance(model, dict)
# Models should have at least basic fields
model_required_fields = ["id", "name"]
for field in model_required_fields:
assert field in model, f"Model missing required field: {field}"
# Ensure models field is not present (removed as per issue #184)
assert "models" not in data, "Models field should not be present in base URL output"
# Verify no database state changes
diff = await db_snapshot.diff()
@@ -386,4 +379,4 @@ async def test_info_endpoints_response_consistency(
# Model IDs should be the same
first_ids = {m["id"] for m in first_models}
response_ids = {m["id"] for m in models}
assert first_ids == response_ids
assert first_ids == response_ids
+30 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
@@ -23,11 +23,36 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RefreshCw } from 'lucide-react';
import { useCurrencyStore } from '@/lib/stores/currency';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { CheatSheet } from '@/components/landing/cheat-sheet';
import { ConfigurationService } from '@/lib/api/services/configuration';
export default function DashboardPage() {
const [timeRange, setTimeRange] = useState('24');
const [interval, setInterval] = useState('15');
const { displayUnit } = useCurrencyStore();
const [isAuthenticated, setIsAuthenticated] = useState(() => {
if (typeof window === 'undefined') {
return false;
}
return ConfigurationService.isTokenValid();
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const syncAuthState = (): void => {
setIsAuthenticated(ConfigurationService.isTokenValid());
};
syncAuthState();
window.addEventListener('storage', syncAuthState);
return () => {
window.removeEventListener('storage', syncAuthState);
};
}, []);
const { data: btcUsdPrice } = useQuery({
queryKey: ['btc-usd-price'],
@@ -83,6 +108,10 @@ export default function DashboardPage() {
staleTime: 30_000,
});
if (!isAuthenticated) {
return <CheatSheet />;
}
const handleRefresh = () => {
refetchMetrics();
refetchSummary();
+746
View File
@@ -0,0 +1,746 @@
'use client';
import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Bolt,
Copy,
KeyRound,
RefreshCcw,
ShieldCheck,
Terminal,
} from 'lucide-react';
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 { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ConfigurationService } from '@/lib/api/services/configuration';
type NodeInfo = {
name: string;
description: string;
version: string;
npub?: string | null;
mints: string[];
http_url?: string | null;
onion_url?: string | null;
};
type WalletSnapshot = {
apiKey: string;
balanceMsats: number;
reservedMsats: number;
};
type RefundReceipt = {
token?: string;
recipient?: string;
sats?: string;
msats?: string;
};
const DEFAULT_BASE_URL = 'http://127.0.0.1:8000';
async function fetchNodeInfo(baseUrl: string): Promise<NodeInfo> {
const response = await fetch(`${baseUrl}/v1/info`, {
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Unable to load node info');
}
const payload = (await response.json()) as NodeInfo;
return {
...payload,
mints: Array.isArray(payload.mints) ? payload.mints : [],
};
}
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;
};
return {
apiKey: payload.api_key || apiKey,
balanceMsats: payload.balance ?? 0,
reservedMsats: payload.reserved ?? 0,
};
}
function normalizeBaseUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) {
return '';
}
return trimmed.replace(/\/+$/, '');
}
function formatMsats(msats: number): string {
return new Intl.NumberFormat('en-US').format(msats);
}
function formatSats(msats: number): string {
return new Intl.NumberFormat('en-US').format(Math.floor(msats / 1000));
}
export function CheatSheet(): JSX.Element {
const [baseUrl, setBaseUrl] = useState(() =>
typeof window === 'undefined' ? '' : ConfigurationService.getLocalBaseUrl()
);
const [initialToken, setInitialToken] = useState('');
const [topupToken, setTopupToken] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [walletInfo, setWalletInfo] = useState<WalletSnapshot | null>(null);
const [refundReceipt, setRefundReceipt] = useState<RefundReceipt | null>(null);
const [isCreatingKey, setIsCreatingKey] = useState(false);
const [isTopupLoading, setIsTopupLoading] = useState(false);
const [isRefunding, setIsRefunding] = useState(false);
const [isSyncingBalance, setIsSyncingBalance] = useState(false);
const [hasInteractedCreate, setHasInteractedCreate] = useState(false);
const [hasInteractedManage, setHasInteractedManage] = useState(false);
const [hasInteractedTopup, setHasInteractedTopup] = useState(false);
useEffect(() => {
if (!baseUrl && typeof window !== 'undefined') {
setBaseUrl(ConfigurationService.getLocalBaseUrl());
}
}, [baseUrl]);
const normalizedBaseUrl = useMemo(
() => normalizeBaseUrl(baseUrl) || DEFAULT_BASE_URL,
[baseUrl]
);
const activeApiKey = apiKeyInput.trim();
const {
data: nodeInfo,
isLoading: isInfoLoading,
isError: isInfoError,
refetch: refetchNodeInfo,
} = useQuery({
queryKey: ['node-info', normalizedBaseUrl],
queryFn: () => fetchNodeInfo(normalizedBaseUrl),
enabled: Boolean(normalizedBaseUrl),
refetchInterval: 300_000,
staleTime: 120_000,
});
const handleCopy = useCallback(async (value: string): Promise<void> => {
if (!value) {
return;
}
if (typeof navigator === 'undefined' || !navigator.clipboard) {
toast.error('Clipboard API unavailable');
return;
}
try {
await navigator.clipboard.writeText(value);
toast.success('Copied to clipboard');
} catch (error) {
console.error(error);
toast.error('Unable to copy');
}
}, []);
const handleCreateKey = useCallback(async (): Promise<void> => {
if (!initialToken.trim()) {
toast.error('Cashu token required');
return;
}
setIsCreatingKey(true);
setRefundReceipt(null);
try {
const params = new URLSearchParams({
initial_balance_token: initialToken.trim(),
});
const response = await fetch(
`${normalizedBaseUrl}/v1/balance/create?${params.toString()}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to create API key');
}
const payload = (await response.json()) as {
api_key: string;
balance: number;
};
const snapshot: WalletSnapshot = {
apiKey: payload.api_key,
balanceMsats: payload.balance ?? 0,
reservedMsats: 0,
};
setApiKeyInput(snapshot.apiKey);
setWalletInfo(snapshot);
setInitialToken('');
toast.success('API key ready');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to create API key'
);
} finally {
setIsCreatingKey(false);
}
}, [initialToken, normalizedBaseUrl]);
const handleSyncBalance = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsSyncingBalance(true);
try {
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
setWalletInfo(snapshot);
toast.success('Balance synced');
} catch (error) {
console.error(error);
toast.error(
error instanceof Error ? error.message : 'Failed to sync balance'
);
} finally {
setIsSyncingBalance(false);
}
}, [activeApiKey, normalizedBaseUrl]);
const handleTopup = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
if (!topupToken.trim()) {
toast.error('Cashu token required for top-up');
return;
}
setIsTopupLoading(true);
setRefundReceipt(null);
try {
const response = await fetch(`${normalizedBaseUrl}/v1/balance/topup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeApiKey}`,
},
body: JSON.stringify({ cashu_token: topupToken.trim() }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to top up');
}
const payload = (await response.json()) as { msats: number };
toast.success(`Added ${formatSats(payload.msats)} sats`);
setTopupToken('');
const snapshot = await fetchWalletInfo(normalizedBaseUrl, activeApiKey);
setWalletInfo(snapshot);
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Top-up failed');
} finally {
setIsTopupLoading(false);
}
}, [activeApiKey, normalizedBaseUrl, topupToken]);
const handleRefund = useCallback(async (): Promise<void> => {
if (!activeApiKey) {
toast.error('Paste an API key first');
return;
}
setIsRefunding(true);
try {
const response = await fetch(`${normalizedBaseUrl}/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;
setRefundReceipt(payload);
setWalletInfo(null);
toast.success('Refund requested');
} catch (error) {
console.error(error);
toast.error(error instanceof Error ? error.message : 'Refund failed');
} finally {
setIsRefunding(false);
}
}, [activeApiKey, normalizedBaseUrl]);
const handleRefreshInfo = useCallback(async (): Promise<void> => {
const result = await refetchNodeInfo();
if (result.error) {
toast.error('Unable to refresh node info');
} else {
toast.success('Node info refreshed');
}
}, [refetchNodeInfo]);
const curlSnippet = useMemo(() => {
const keyPreview = activeApiKey || 'YOUR_API_KEY';
return [
`curl -X POST "${normalizedBaseUrl}/v1/chat/completions"`,
` -H "Authorization: Bearer ${keyPreview}"`,
' -H "Content-Type: application/json"',
" -d '{",
' "model": "openai/gpt-4o-mini",',
' "messages": [',
' {"role":"system","content":"You are Routstr."},',
' {"role":"user","content":"Ping the node"}',
' ]',
" }'",
].join('\n');
}, [activeApiKey, normalizedBaseUrl]);
const showCreateDetails =
hasInteractedCreate || initialToken.trim().length > 0;
const showManageDetails = hasInteractedManage || Boolean(walletInfo);
const showTopupDetails =
hasInteractedTopup || topupToken.trim().length > 0;
const refundToken = refundReceipt?.token ?? null;
const canTopup = Boolean(activeApiKey);
return (
<div className='min-h-screen bg-gradient-to-b from-background via-background to-muted'>
<main className='mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-10 sm:px-6 lg:px-8'>
<section className='relative space-y-3 text-center md:text-left'>
<div className='absolute right-0 top-0 hidden md:block'>
<Button asChild size='sm' className='px-3 text-xs'>
<a href='/login'>Admin</a>
</Button>
</div>
<div className='inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[0.65rem] uppercase tracking-wider text-muted-foreground'>
<Bolt className='h-4 w-4 text-primary' />
Routstr cheat sheet
</div>
<h1 className='text-3xl font-semibold tracking-tight sm:text-4xl'>
Node Identity and Cheat Sheet
</h1>
<div className='md:hidden'>
<Button asChild size='sm' className='w-full sm:w-auto'>
<a href='/login'>Admin</a>
</Button>
</div>
</section>
<section className='grid gap-4 lg:grid-cols-2'>
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='flex items-center gap-2 text-lg'>
<ShieldCheck className='h-4 w-4 text-primary' />
Node identity
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
/v1/info snapshot
</p>
</div>
<Button
variant='ghost'
size='sm'
className='gap-1 text-xs'
onClick={handleRefreshInfo}
disabled={isInfoLoading}
>
<RefreshCcw className='h-4 w-4' />
Refresh
</Button>
</CardHeader>
<CardContent className='space-y-4'>
{isInfoLoading && (
<p className='text-muted-foreground text-sm'>
Loading node profile…
</p>
)}
{isInfoError && !isInfoLoading && (
<p className='text-destructive text-sm'>
Unable to reach /v1/info at {normalizedBaseUrl}
</p>
)}
{nodeInfo && (
<>
<div className='space-y-2'>
<p className='text-2xl font-medium'>{nodeInfo.name}</p>
<p className='text-muted-foreground text-sm'>
{nodeInfo.description}
</p>
</div>
<dl className='grid gap-4 sm:grid-cols-2'>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Version
</dt>
<dd className='text-base font-medium'>
{nodeInfo.version}
</dd>
</div>
<div>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
HTTP
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.http_url || normalizedBaseUrl}
</dd>
</div>
{nodeInfo.onion_url && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
Onion
</dt>
<dd className='text-base font-medium break-all'>
{nodeInfo.onion_url}
</dd>
</div>
)}
{nodeInfo.npub && (
<div className='sm:col-span-2'>
<dt className='text-muted-foreground text-xs uppercase tracking-wide'>
npub
</dt>
<dd className='flex items-center gap-2 break-all text-sm font-mono'>
{nodeInfo.npub}
<Button
variant='ghost'
size='icon'
className='h-8 w-8'
onClick={() => handleCopy(nodeInfo.npub ?? '')}
>
<Copy className='h-4 w-4' />
</Button>
</dd>
</div>
)}
</dl>
<div className='space-y-2'>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Cashu mints
</p>
<div className='flex flex-wrap gap-2'>
{nodeInfo.mints.length ? (
nodeInfo.mints.map((mint) => (
<Badge
key={mint}
variant='secondary'
className='font-mono text-xs'
>
{mint}
</Badge>
))
) : (
<p className='text-muted-foreground text-sm'>
No mint list published
</p>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Terminal className='h-4 w-4 text-primary' />
Quick docs
</CardTitle>
<span className='text-xs uppercase tracking-wide text-muted-foreground'>
curl-ready
</span>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center gap-2'>
<Input
value={baseUrl}
placeholder={normalizedBaseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
className='text-sm'
/>
<Button
variant='outline'
size='icon'
className='h-9 w-9'
onClick={() => handleCopy(normalizedBaseUrl)}
>
<Copy className='h-4 w-4' />
</Button>
</div>
<div className='rounded-lg bg-muted p-4 font-mono text-sm leading-6'>
<pre className='whitespace-pre-wrap break-all'>{curlSnippet}</pre>
</div>
<div className='flex gap-2'>
<Button
variant='secondary'
className='gap-2'
onClick={() => handleCopy(curlSnippet)}
>
<Copy className='h-4 w-4' />
Copy curl
</Button>
<Button variant='outline' asChild className='gap-2'>
<a
href='https://docs.routstr.com'
target='_blank'
rel='noreferrer'
>
<Terminal className='h-4 w-4' />
Full docs
</a>
</Button>
</div>
</CardContent>
</Card>
</section>
<Card>
<CardHeader className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-xl'>
<KeyRound className='h-5 w-5 text-primary' />
API key workflow
</CardTitle>
<p className='text-xs uppercase tracking-wide text-muted-foreground'>
Sections expand as soon as you interact
</p>
</CardHeader>
<CardContent className='space-y-6'>
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>1 · Create key</span>
{showCreateDetails && (
<span className='text-primary'>Cashu token detected</span>
)}
</header>
<Textarea
value={initialToken}
onChange={(event) => setInitialToken(event.target.value)}
placeholder='cashuA1...'
rows={showCreateDetails ? 4 : 2}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedCreate(true)}
/>
{showCreateDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleCreateKey}
disabled={isCreatingKey}
className='gap-2'
>
{isCreatingKey ? 'Creating…' : 'Create API key'}
</Button>
<span className='text-xs text-muted-foreground'>
Redeems instantly and returns <code>sk-</code> key.
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>2 · Manage key</span>
{walletInfo && (
<span className='text-primary'>
{formatSats(walletInfo.balanceMsats)} sats
</span>
)}
</header>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
value={apiKeyInput}
onChange={(event) => {
setApiKeyInput(event.target.value);
setWalletInfo(null);
}}
placeholder='sk-...'
className='font-mono text-sm'
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}
>
<RefreshCcw className='h-4 w-4' />
Sync
</Button>
</div>
</div>
{showManageDetails && (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Spendable
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.balanceMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.balanceMsats)} msats
</p>
)}
</div>
<div className='rounded-lg border p-3'>
<p className='text-[0.65rem] uppercase tracking-wide text-muted-foreground'>
Reserved
</p>
<p className='text-xl font-semibold'>
{walletInfo
? `${formatSats(walletInfo.reservedMsats)} sats`
: '—'}
</p>
{walletInfo && (
<p className='text-muted-foreground text-xs'>
{formatMsats(walletInfo.reservedMsats)} msats
</p>
)}
</div>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>3 · Top up</span>
{showTopupDetails && (
<span className={canTopup ? 'text-primary' : 'text-destructive'}>
{canTopup ? 'Ready to redeem' : 'Paste API key first'}
</span>
)}
</header>
<Textarea
value={topupToken}
onChange={(event) => setTopupToken(event.target.value)}
placeholder='cashuB1...'
rows={showTopupDetails ? 3 : 1}
className='font-mono text-sm transition-all duration-200'
onFocus={() => setHasInteractedTopup(true)}
disabled={!canTopup}
/>
{showTopupDetails && (
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleTopup}
disabled={isTopupLoading || !canTopup}
variant='outline'
className='gap-2'
>
{isTopupLoading ? 'Topping up…' : 'Top up this key'}
</Button>
<span className='text-xs text-muted-foreground'>
{canTopup
? (
<>
Adds balance to the same <code>sk-</code> token.
</>
)
: 'Enter your sk- key above to unlock top ups.'}
</span>
</div>
)}
</section>
<Separator />
<section className='space-y-2'>
<header className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>4 · Refund</span>
{refundReceipt && <span className='text-primary'>Done</span>}
</header>
<div className='flex flex-wrap gap-2'>
<Button
onClick={handleRefund}
disabled={isRefunding}
variant='destructive'
className='gap-2'
>
{isRefunding ? 'Processing…' : 'Refund remaining balance'}
</Button>
<span className='text-xs text-muted-foreground'>
Burns the key and returns a fresh Cashu token.
</span>
</div>
{refundToken && (
<div className='space-y-2 rounded-lg border bg-muted/30 p-4'>
<div className='flex items-center justify-between text-[0.7rem] uppercase tracking-wider text-muted-foreground'>
<span>Cashu refund token</span>
<Button
variant='outline'
size='sm'
className='gap-1 text-xs'
onClick={() => handleCopy(refundToken)}
>
<Copy className='h-3 w-3' />
Copy
</Button>
</div>
<Textarea
value={refundToken}
readOnly
rows={4}
className='font-mono text-xs'
/>
</div>
)}
</section>
</CardContent>
</Card>
</main>
</div>
);
}
+4 -2
View File
@@ -9,8 +9,10 @@ export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
useEffect(() => {
const publicPaths = ['/login', '/_register', '/unauthorized'];
const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));
const publicPaths = ['/', '/login', '/_register', '/unauthorized'];
const isPublicPath = publicPaths.some((path) =>
path === '/' ? pathname === '/' : pathname.startsWith(path)
);
if (!isPublicPath && !ConfigurationService.isTokenValid()) {
router.push('/login');
Vendored
-1
Submodule vendor/cdk deleted from 52d796e9fe
Submodule vendor/cdk-python deleted from 915c6966b0