- 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>
281 lines
8.9 KiB
TypeScript
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>
|
|
)
|
|
}
|