Files
Jeroen UbbinkandClaude Opus 4.8 a024d5be5e feat(admin): manage nsec via API and redact secrets in responses
Add an admin endpoint to set, rotate and clear the nsec, authenticate against
the stored password hash, and redact secret values (nsec shown as [REDACTED])
in settings responses. Wire the admin UI to the new endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 10:51:20 +02:00

1011 lines
32 KiB
TypeScript

'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { AdminService } from '@/lib/api/services/admin';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
CardFooter,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCircle, Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { Switch } from '@/components/ui/switch';
interface SettingsData {
name?: string;
description?: string;
npub?: string;
nsec?: string;
enable_analytics_sharing?: boolean;
upstream_api_key?: string;
http_url?: string;
onion_url?: string;
cashu_mints?: string[];
relays?: string[];
receive_ln_address?: string;
min_payout_sat?: number;
payout_interval_seconds?: number;
[key: string]: unknown;
}
const PAYOUT_KEYS = [
'receive_ln_address',
'min_payout_sat',
'payout_interval_seconds',
] as const;
const HANDLED_KEYS = [
'name',
'description',
'http_url',
'onion_url',
'npub',
'nsec',
'cashu_mints',
'relays',
'enable_analytics_sharing',
'admin_password',
'id',
'updated_at',
...PAYOUT_KEYS,
];
const IGNORED_KEYS = [
'upstream_base_url',
'upstream_api_key',
'upstream_provider_fee',
'exchange_fee',
'models_path',
];
interface PasswordData {
current_password: string;
new_password: string;
confirm_password: string;
}
export function AdminSettings() {
const [settings, setSettings] = useState<SettingsData>({});
const [initialSettings, setInitialSettings] = useState<SettingsData>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>('');
const [showSecrets, setShowSecrets] = useState(false);
const [newMint, setNewMint] = useState('');
const [newRelay, setNewRelay] = useState('');
const [passwordData, setPasswordData] = useState<PasswordData>({
current_password: '',
new_password: '',
confirm_password: '',
});
const [passwordError, setPasswordError] = useState<string>('');
const [passwordSaving, setPasswordSaving] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
setError('');
const data = await AdminService.getSettings();
setSettings(data as SettingsData);
setInitialSettings(data as SettingsData);
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load settings';
setError(message);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
setError('');
// The nsec is a secret with its own endpoint (the general settings PATCH
// strips it); only send it when the operator actually changed it, so an
// untouched redacted value is never written back. The npub is derived
// server-side from the new nsec — fold it into the settings payload so the
// persisted blob stays consistent with the stored key.
let settingsPayload = settings;
if (hasFieldChanged('nsec')) {
const result = await AdminService.updateNsec(
(settings.nsec as string) || ''
);
settingsPayload = { ...settings, npub: result.npub };
}
const updatedData = (await AdminService.updateSettings(
settingsPayload
)) as SettingsData;
setSettings(updatedData);
setInitialSettings(updatedData);
toast.success('Settings saved successfully');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to save settings';
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
};
const handlePasswordUpdate = async () => {
try {
setPasswordSaving(true);
setPasswordError('');
if (passwordData.new_password !== passwordData.confirm_password) {
setPasswordError('New passwords do not match');
return;
}
if (passwordData.new_password.length < 8) {
setPasswordError('New password must be at least 8 characters');
return;
}
const { apiClient } = await import('@/lib/api/client');
await apiClient.patch('/admin/api/password', {
current_password: passwordData.current_password,
new_password: passwordData.new_password,
});
setPasswordData({
current_password: '',
new_password: '',
confirm_password: '',
});
toast.success('Password updated successfully');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to update password';
setPasswordError(message);
toast.error(message);
} finally {
setPasswordSaving(false);
}
};
const clearPasswordForm = () => {
setPasswordData({
current_password: '',
new_password: '',
confirm_password: '',
});
setPasswordError('');
};
const handleInputChange = (field: string, value: unknown) => {
setSettings((prev) => ({
...prev,
[field]: value,
}));
};
const addMint = () => {
if (newMint.trim()) {
setSettings((prev) => ({
...prev,
cashu_mints: [...(prev.cashu_mints || []), newMint.trim()],
}));
setNewMint('');
}
};
const removeMint = (index: number) => {
setSettings((prev) => ({
...prev,
cashu_mints: prev.cashu_mints?.filter((_, i) => i !== index) || [],
}));
};
const addRelay = () => {
if (newRelay.trim()) {
setSettings((prev) => ({
...prev,
relays: [...(prev.relays || []), newRelay.trim()],
}));
setNewRelay('');
}
};
const removeRelay = (index: number) => {
setSettings((prev) => ({
...prev,
relays: prev.relays?.filter((_, i) => i !== index) || [],
}));
};
const renderSecretField = (
field: string,
label: string,
placeholder?: string
) => {
const value = (settings[field] as string) || '';
const displayValue = showSecrets ? value : value ? '••••••••' : '';
return (
<div key={field} className='space-y-2'>
<Label htmlFor={field}>{label}</Label>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id={field}
type={showSecrets ? 'text' : 'password'}
value={displayValue}
onChange={(e) => handleInputChange(field, e.target.value)}
placeholder={placeholder}
className='flex-1'
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={() => setShowSecrets(!showSecrets)}
>
{showSecrets ? (
<EyeOff className='h-4 w-4' />
) : (
<Eye className='h-4 w-4' />
)}
</Button>
</div>
</div>
);
};
const renderDynamicField = (key: string, value: unknown) => {
const label = key
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
if (typeof value === 'boolean') {
return (
<div
key={key}
className='flex items-center justify-between space-y-0 py-4'
>
<Label htmlFor={key}>{label}</Label>
<Switch
id={key}
checked={value}
onCheckedChange={(checked) => handleInputChange(key, checked)}
/>
</div>
);
}
if (typeof value === 'number') {
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type='number'
value={value}
onChange={(e) => {
const val = e.target.value === '' ? 0 : Number(e.target.value);
handleInputChange(key, val);
}}
/>
</div>
);
}
if (Array.isArray(value)) {
const strValue = value.join(', ');
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Textarea
id={key}
value={strValue}
onChange={(e) => {
const arr = e.target.value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
handleInputChange(key, arr);
}}
placeholder='Comma separated values'
rows={2}
/>
</div>
);
}
const isSecret =
key.includes('key') ||
key.includes('password') ||
key.includes('secret') ||
key.includes('nsec');
if (isSecret) {
return renderSecretField(key, label);
}
return (
<div key={key} className='space-y-2'>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
value={(value as string) || ''}
onChange={(e) => handleInputChange(key, e.target.value)}
/>
</div>
);
};
const normalizeForCompare = (value: unknown): unknown => {
if (value === null || value === undefined) {
return '';
}
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value;
};
const areValuesEqual = (a: unknown, b: unknown): boolean => {
const normalizedA = normalizeForCompare(a);
const normalizedB = normalizeForCompare(b);
if (Array.isArray(normalizedA) && Array.isArray(normalizedB)) {
return JSON.stringify(normalizedA) === JSON.stringify(normalizedB);
}
return normalizedA === normalizedB;
};
const hasFieldChanged = (key: string): boolean =>
!areValuesEqual(settings[key], initialSettings[key]);
const basicInfoChanged = [
'name',
'description',
'http_url',
'onion_url',
].some(hasFieldChanged);
const nostrChanged = ['npub', 'nsec'].some(hasFieldChanged);
const cashuMintsChanged = hasFieldChanged('cashu_mints');
const relaysChanged = hasFieldChanged('relays');
const analyticsSharingChanged = hasFieldChanged('enable_analytics_sharing');
const payoutChanged = PAYOUT_KEYS.some(hasFieldChanged);
const advancedKeys = Object.keys(settings).filter(
(key) => !HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
);
const advancedChanged = advancedKeys.some(hasFieldChanged);
const hasPasswordChanges = Boolean(
passwordData.current_password ||
passwordData.new_password ||
passwordData.confirm_password
);
const resetFields = (keys: string[]) => {
setSettings((prev) => {
const next = { ...prev };
keys.forEach((key) => {
next[key] = initialSettings[key];
});
return next;
});
};
const resetBasicInfo = () =>
resetFields(['name', 'description', 'http_url', 'onion_url']);
const resetNostr = () => resetFields(['npub', 'nsec']);
const resetCashuMints = () => {
resetFields(['cashu_mints']);
setNewMint('');
};
const resetRelays = () => {
resetFields(['relays']);
setNewRelay('');
};
const resetAnalyticsSharing = () => resetFields(['enable_analytics_sharing']);
const resetPayout = () => resetFields([...PAYOUT_KEYS]);
const resetAdvanced = () => resetFields(advancedKeys);
const payoutFields: ReadonlyArray<{
key: (typeof PAYOUT_KEYS)[number];
label: string;
placeholder: string;
type: 'text' | 'number';
helpText: string;
min?: number;
}> = [
{
key: 'receive_ln_address',
label: 'Lightning Receive Address',
placeholder: 'you@walletofsatoshi.com or LNURL',
type: 'text',
helpText:
'Lightning address (or LNURL) profits are paid out to. Leave empty to disable periodic payouts.',
},
{
key: 'min_payout_sat',
label: 'Minimum Payout (sat)',
placeholder: '210',
type: 'number',
min: 1,
helpText:
'Wallet payouts only fire when at least this many satoshis are available. Must be > 0.',
},
{
key: 'payout_interval_seconds',
label: 'Payout Interval (seconds)',
placeholder: '900',
type: 'number',
min: 1,
helpText: 'How often the payout loop wakes up to check balances.',
},
];
if (loading) {
return (
<div className='space-y-4'>
<div className='space-y-2'>
<Skeleton className='h-7 w-48' />
<Skeleton className='h-4 w-64 max-w-full' />
</div>
<Card>
<CardHeader className='space-y-2'>
<Skeleton className='h-5 w-40' />
<Skeleton className='h-4 w-72 max-w-full' />
</CardHeader>
<CardContent className='space-y-3'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-20 w-full' />
<Skeleton className='h-10 w-full' />
</CardContent>
</Card>
<Card>
<CardHeader className='space-y-2'>
<Skeleton className='h-5 w-36' />
<Skeleton className='h-4 w-64 max-w-full' />
</CardHeader>
<CardContent className='space-y-3'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
</CardContent>
</Card>
</div>
);
}
return (
<>
{error && (
<Alert variant='destructive' className='mb-6'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className='space-y-6'>
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
Configure the basic node information and branding
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='name'>Node Name</Label>
<Input
id='name'
value={settings.name || ''}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder='ARoutstrNode'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
value={settings.description || ''}
onChange={(e) =>
handleInputChange('description', e.target.value)
}
placeholder='A Routstr Node'
rows={3}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='http_url'>HTTP URL</Label>
<Input
id='http_url'
value={settings.http_url || ''}
onChange={(e) => handleInputChange('http_url', e.target.value)}
placeholder='https://your-node.com'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='onion_url'>Onion URL (Optional)</Label>
<Input
id='onion_url'
value={settings.onion_url || ''}
onChange={(e) => handleInputChange('onion_url', e.target.value)}
placeholder='http://your-node.onion'
/>
</div>
</CardContent>
{basicInfoChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetBasicInfo}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Nostr Configuration */}
<Card>
<CardHeader>
<CardTitle>Nostr Configuration</CardTitle>
<CardDescription>
Configure Nostr public and private keys
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='npub'>Public Key (npub)</Label>
<Input
id='npub'
value={settings.npub || ''}
onChange={(e) => handleInputChange('npub', e.target.value)}
placeholder='npub1...'
/>
</div>
{renderSecretField('nsec', 'Private Key (nsec)', 'nsec1...')}
</CardContent>
{nostrChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetNostr}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Cashu Mints */}
<Card>
<CardHeader>
<CardTitle>Cashu Mints</CardTitle>
<CardDescription>
Configure Cashu mint endpoints for payments
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newMint'>Add Mint URL</Label>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='newMint'
value={newMint}
onChange={(e) => setNewMint(e.target.value)}
placeholder='https://mint.example.com'
/>
<Button
onClick={addMint}
disabled={!newMint.trim()}
className='sm:w-auto'
>
Add Mint
</Button>
</div>
</div>
{settings.cashu_mints && settings.cashu_mints.length > 0 && (
<div className='space-y-2'>
<Label>Configured Mints</Label>
<div className='space-y-2'>
{settings.cashu_mints.map((mint, index) => (
<div
key={index}
className='flex flex-col gap-2 rounded border p-2 sm:flex-row sm:items-center'
>
<span className='flex-1 text-sm'>{mint}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeMint(index)}
className='w-full sm:w-auto'
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
{cashuMintsChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetCashuMints}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Lightning Payout Settings */}
<Card>
<CardHeader>
<CardTitle>Lightning Payout Settings</CardTitle>
<CardDescription>
Tune how node profit is paid out over Lightning. Amounts must be
positive and above your wallet&apos;s minimum-invoice constraints.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{payoutFields.map((field) => {
const value = settings[field.key];
if (field.type === 'number') {
return (
<div key={field.key} className='space-y-2'>
<Label htmlFor={field.key}>{field.label}</Label>
<Input
id={field.key}
type='number'
min={field.min}
value={
typeof value === 'number'
? value
: value === undefined || value === null
? ''
: Number(value)
}
placeholder={field.placeholder}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
handleInputChange(field.key, undefined);
} else {
const parsed = Number(raw);
handleInputChange(
field.key,
Number.isFinite(parsed) ? parsed : undefined
);
}
}}
/>
<p className='text-muted-foreground text-xs'>
{field.helpText}
</p>
</div>
);
}
return (
<div key={field.key} className='space-y-2'>
<Label htmlFor={field.key}>{field.label}</Label>
<Input
id={field.key}
value={(value as string) || ''}
placeholder={field.placeholder}
onChange={(e) =>
handleInputChange(field.key, e.target.value)
}
/>
<p className='text-muted-foreground text-xs'>
{field.helpText}
</p>
</div>
);
})}
</CardContent>
{payoutChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetPayout}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Relays */}
<Card>
<CardHeader>
<CardTitle>Nostr Relays</CardTitle>
<CardDescription>
Configure Nostr relays for communication
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='newRelay'>Add Relay URL</Label>
<div className='flex flex-col gap-2 sm:flex-row'>
<Input
id='newRelay'
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder='wss://relay.example.com'
/>
<Button
onClick={addRelay}
disabled={!newRelay.trim()}
className='sm:w-auto'
>
Add Relay
</Button>
</div>
</div>
{settings.relays && settings.relays.length > 0 && (
<div className='space-y-2'>
<Label>Configured Relays</Label>
<div className='space-y-2'>
{settings.relays.map((relay, index) => (
<div
key={index}
className='flex flex-col gap-2 rounded border p-2 sm:flex-row sm:items-center'
>
<span className='flex-1 text-sm'>{relay}</span>
<Button
variant='outline'
size='sm'
onClick={() => removeRelay(index)}
className='w-full sm:w-auto'
>
Remove
</Button>
</div>
))}
</div>
</div>
)}
</CardContent>
{relaysChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetRelays}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Analytics Sharing */}
<Card>
<CardHeader>
<CardTitle>Analytics Sharing</CardTitle>
<CardDescription>
Publish aggregate usage stats to Nostr for external dashboards
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex items-center justify-between space-y-0 py-1'>
<div className='space-y-1'>
<Label htmlFor='enable_analytics_sharing'>
Share analytics to Nostr
</Label>
<p className='text-muted-foreground text-sm'>
When enabled, Routstr periodically publishes aggregate model
usage and revenue stats.
</p>
</div>
<Switch
id='enable_analytics_sharing'
checked={Boolean(settings.enable_analytics_sharing ?? true)}
onCheckedChange={(checked) =>
handleInputChange('enable_analytics_sharing', checked)
}
/>
</div>
</CardContent>
{analyticsSharingChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetAnalyticsSharing}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Other Settings */}
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
<CardDescription>
Configure additional node settings
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{Object.keys(settings)
.filter(
(key) =>
!HANDLED_KEYS.includes(key) && !IGNORED_KEYS.includes(key)
)
.map((key) => renderDynamicField(key, settings[key]))}
</CardContent>
{advancedChanged ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={resetAdvanced}
disabled={loading || saving}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading || saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
{/* Password Change */}
<Card>
<CardHeader>
<CardTitle>Change Admin Password</CardTitle>
<CardDescription>
Update your admin password for enhanced security
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{passwordError && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{passwordError}</AlertDescription>
</Alert>
)}
<div className='space-y-2'>
<Label htmlFor='current_password'>Current Password</Label>
<Input
id='current_password'
type='password'
value={passwordData.current_password}
onChange={(e) =>
setPasswordData((prev) => ({
...prev,
current_password: e.target.value,
}))
}
placeholder='Enter current password'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='new_password'>New Password</Label>
<Input
id='new_password'
type='password'
value={passwordData.new_password}
onChange={(e) =>
setPasswordData((prev) => ({
...prev,
new_password: e.target.value,
}))
}
placeholder='Enter new password (min 8 characters)'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='confirm_password'>Confirm New Password</Label>
<Input
id='confirm_password'
type='password'
value={passwordData.confirm_password}
onChange={(e) =>
setPasswordData((prev) => ({
...prev,
confirm_password: e.target.value,
}))
}
placeholder='Confirm new password'
/>
</div>
</CardContent>
{hasPasswordChanges ? (
<CardFooter className='justify-start'>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center'>
<Button
variant='outline'
onClick={clearPasswordForm}
disabled={passwordSaving}
>
Cancel
</Button>
<Button
onClick={handlePasswordUpdate}
disabled={
passwordSaving ||
!passwordData.current_password ||
!passwordData.new_password ||
!passwordData.confirm_password
}
>
{passwordSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</CardFooter>
) : null}
</Card>
</div>
</>
);
}