Files
smesh/src/components/TokenDisplay/index.tsx
mleku a268c63082 Auto-acquire Cashu tokens for NIP-46 bunker connections (v0.2.0)
- Add acquireTokenIfNeeded() in BunkerSigner to get CAT before connecting
- Check /cashu/info to detect CAT-enabled relays
- Request token via cashuTokenService with NIP-98 auth using ephemeral key
- Store and reuse tokens across sessions
- Pass token as query parameter on WebSocket connection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 19:33:19 +02:00

281 lines
8.9 KiB
TypeScript

import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
import { useNostr } from '@/providers/NostrProvider'
import { Clock, Copy, Key, RefreshCw, Shield } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import QrCode from '../QrCode'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import * as utils from '@noble/curves/abstract/utils'
dayjs.extend(relativeTime)
interface TokenDisplayProps {
bunkerPubkey: string
mintUrl: string
}
export default function TokenDisplay({ bunkerPubkey, mintUrl }: TokenDisplayProps) {
const { t } = useTranslation()
const { signHttpAuth, pubkey } = useNostr()
const [currentToken, setCurrentToken] = useState<TCashuToken | null>(null)
const [nextToken, setNextToken] = useState<TCashuToken | null>(null)
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
// Load tokens on mount
useEffect(() => {
const stored = cashuTokenService.loadTokens(bunkerPubkey)
if (stored) {
setCurrentToken(stored.current || null)
setNextToken(stored.next || null)
}
}, [bunkerPubkey])
// Request a new token
const requestToken = useCallback(async () => {
if (!pubkey) {
toast.error(t('You must be logged in to request a token'))
return
}
setLoading(true)
try {
cashuTokenService.setMint(mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkey = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NIP46,
userPubkey,
signHttpAuth,
[24133] // NIP-46 kind
)
// Store the token
if (currentToken && cashuTokenService.verifyToken(currentToken)) {
// Current still valid, store new as next
cashuTokenService.storeTokens(bunkerPubkey, currentToken, token)
setNextToken(token)
} else {
// Current expired or missing, use new as current
cashuTokenService.storeTokens(bunkerPubkey, token)
setCurrentToken(token)
setNextToken(null)
}
toast.success(t('Token obtained successfully'))
} catch (err) {
toast.error(t('Failed to get token') + ': ' + (err as Error).message)
} finally {
setLoading(false)
}
}, [bunkerPubkey, mintUrl, pubkey, signHttpAuth, currentToken, t])
// Refresh tokens (promote next to current if needed)
const refreshTokens = useCallback(async () => {
if (!pubkey) return
setRefreshing(true)
try {
// Check if current needs refresh
if (currentToken && cashuTokenService.needsRefresh(currentToken)) {
// Request a new token as next
if (!nextToken) {
await requestToken()
}
}
// Promote next to current if current expired
const now = Date.now() / 1000
if (currentToken && currentToken.expiry <= now && nextToken) {
cashuTokenService.storeTokens(bunkerPubkey, nextToken)
setCurrentToken(nextToken)
setNextToken(null)
toast.info(t('Token rotated'))
}
} finally {
setRefreshing(false)
}
}, [bunkerPubkey, currentToken, nextToken, pubkey, requestToken, t])
// Copy token to clipboard
const copyToken = useCallback(
(token: TCashuToken) => {
const encoded = cashuTokenService.encodeToken(token)
navigator.clipboard.writeText(encoded)
toast.success(t('Token copied to clipboard'))
},
[t]
)
// Format expiry time
const formatExpiry = (expiry: number) => {
const date = dayjs.unix(expiry)
const now = dayjs()
if (date.isBefore(now)) {
return t('Expired')
}
return date.fromNow()
}
// Check if token is expired
const isExpired = (token: TCashuToken) => {
return token.expiry < Date.now() / 1000
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
{t('Access Tokens')}
</CardTitle>
<CardDescription>
{t('Cashu tokens for authenticated bunker access')}
</CardDescription>
</CardHeader>
<CardContent>
{!currentToken && !nextToken ? (
<div className="text-center py-8">
<Key className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{t('No tokens available. Request one to enable bunker access.')}
</p>
<Button onClick={requestToken} disabled={loading}>
{loading ? t('Requesting...') : t('Request Token')}
</Button>
</div>
) : (
<Tabs defaultValue="current" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="current" className="relative">
{t('Current')}
{currentToken && isExpired(currentToken) && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-destructive" />
)}
</TabsTrigger>
<TabsTrigger value="next">
{t('Next')}
{nextToken && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-green-500" />
)}
</TabsTrigger>
</TabsList>
<TabsContent value="current" className="space-y-4">
{currentToken ? (
<TokenCard
token={currentToken}
formatExpiry={formatExpiry}
isExpired={isExpired(currentToken)}
onCopy={() => copyToken(currentToken)}
/>
) : (
<div className="text-center py-4 text-muted-foreground">
{t('No current token')}
</div>
)}
</TabsContent>
<TabsContent value="next" className="space-y-4">
{nextToken ? (
<TokenCard
token={nextToken}
formatExpiry={formatExpiry}
isExpired={isExpired(nextToken)}
onCopy={() => copyToken(nextToken)}
/>
) : (
<div className="text-center py-4 text-muted-foreground">
{t('No pending token. One will be requested before current expires.')}
</div>
)}
</TabsContent>
</Tabs>
)}
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={refreshTokens} disabled={refreshing} className="flex-1">
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('Refresh')}
</Button>
<Button onClick={requestToken} disabled={loading} className="flex-1">
{loading ? t('Requesting...') : t('Request New Token')}
</Button>
</CardFooter>
</Card>
)
}
// Individual token display card
function TokenCard({
token,
formatExpiry,
isExpired,
onCopy
}: {
token: TCashuToken
formatExpiry: (expiry: number) => string
isExpired: boolean
onCopy: () => void
}) {
const { t } = useTranslation()
const encoded = cashuTokenService.encodeToken(token)
return (
<div className="space-y-4">
<div className="flex justify-center">
<QrCode value={encoded} size={200} />
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Scope')}</span>
<span className="font-mono">{token.scope}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Keyset')}</span>
<span className="font-mono text-xs">{token.keysetId}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{t('Expires')}
</span>
<span className={isExpired ? 'text-destructive' : 'text-green-600'}>
{formatExpiry(token.expiry)}
</span>
</div>
{token.kinds && token.kinds.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Kinds')}</span>
<span className="font-mono text-xs">{token.kinds.join(', ')}</span>
</div>
)}
</div>
<Button variant="outline" onClick={onCopy} className="w-full">
<Copy className="h-4 w-4 mr-2" />
{t('Copy Token')}
</Button>
</div>
)
}