Select a file from the repository tree to inspect its code.
resources/js/pages/dashboard/tokens.tsx
Copy Code
import { Head, useForm, router } from '@inertiajs/react';
import {
Plus,
Copy,
Check,
Eye,
EyeOff,
RefreshCw,
Power,
Trash2,
Database,
Calendar,
} from 'lucide-react';
import React, { useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { store, regenerate, toggle, destroy } from '@/routes/tokens';
interface Currency {
id: number;
code: string;
name: string;
country: string | null;
rate: number;
active: boolean;
}
interface Token {
id: number;
name: string;
token: string;
default_currency: string;
active: boolean;
created_at: string;
updated_at: string;
}
interface TokensPageProps {
tokens: Token[];
currencies: Currency[];
}
// Stable locale-independent date formatter to prevent hydration mismatch
const formatDate = (dateStr: string) => {
const d = new Date(dateStr);
if (isNaN(d.getTime())) {
return dateStr;
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${day}/${month}/${year}`;
};
export default function TokensPage({
tokens = [],
currencies = [],
}: TokensPageProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [visibleTokens, setVisibleTokens] = useState<Record<number, boolean>>(
{},
);
const [copiedTokenId, setCopiedTokenId] = useState<number | null>(null);
// Form for creating a new token - default_currency starts as empty to fallback to INR
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
default_currency: '',
});
// Toggle token key visibility
const toggleTokenVisibility = (id: number) => {
setVisibleTokens((prev) => ({ ...prev, [id]: !prev[id] }));
};
// Copy token to clipboard
const copyToClipboard = (text: string, id: number) => {
navigator.clipboard.writeText(text);
setCopiedTokenId(id);
toast.success('Token copied to clipboard!');
setTimeout(() => setCopiedTokenId(null), 2000);
};
// Submit new token
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault();
post(store.url(), {
onSuccess: () => {
setIsCreateOpen(false);
reset('name', 'default_currency');
toast.success('New API key registered successfully!');
},
onError: () => {
toast.error('Token creation failed. Please check your inputs.');
},
});
};
// Regenerate token key
const handleRegenerateToken = (id: number, name: string) => {
if (
confirm(
`Are you sure you want to regenerate the API key for "${name}"? The existing key will stop working immediately.`,
)
) {
router.post(
regenerate.url(id),
{},
{
onSuccess: () =>
toast.success('API key regenerated successfully!'),
onError: () => toast.error('Failed to regenerate key.'),
},
);
}
};
// Toggle active status
const handleToggleStatus = (id: number) => {
router.patch(
toggle.url(id),
{},
{
onSuccess: () => toast.success('Key active state toggled.'),
onError: () => toast.error('Failed to toggle active status.'),
},
);
};
// Revoke/Delete token
const handleRevokeToken = (id: number, name: string) => {
if (
confirm(
`Are you sure you want to revoke and delete "${name}"? This key will be deactivated permanently.`,
)
) {
router.delete(destroy.url(id), {
onSuccess: () =>
toast.success('API key has been permanently deleted.'),
onError: () => toast.error('Failed to delete key.'),
});
}
};
// Prepare combobox options list
const currencyOptions = React.useMemo(() => {
const list = [
{
value: '',
label: 'INR (System Default Fallback)',
searchTerms: 'INR Rupee India default fallback',
},
];
currencies.forEach((c) => {
list.push({
value: c.code,
label: `${c.code} - ${c.name}`,
searchTerms: `${c.code} ${c.name} ${c.country || ''}`,
});
});
return list;
}, [currencies]);
return (
<>
<Head title="API Access Tokens" />
<div className="min-h-screen w-full space-y-6 bg-background p-4 text-foreground md:p-6">
{/* Header */}
<div className="flex flex-col gap-4 border-b border-border pb-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="rounded-lg bg-primary/10 p-1.5 text-primary">
<Database className="h-5 w-5" />
</span>
<h1 className="text-3xl font-extrabold tracking-tight text-foreground">
API Access Tokens
</h1>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Generate and manage secure authorization keys to
fetch currency exchange rates.
</p>
</div>
<Button
onClick={() => setIsCreateOpen(true)}
className="bg-primary font-medium text-primary-foreground shadow-md hover:bg-primary/90"
>
<Plus className="mr-2 h-4 w-4" /> Generate API Token
</Button>
</div>
{/* Tokens List Content (Full Width) */}
<div className="space-y-4">
{tokens.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/10 py-16 text-center">
<Database className="mx-auto mb-2 h-12 w-12 text-muted-foreground/60" />
<p className="text-sm font-semibold text-muted-foreground">
No active API keys found.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Create a new key to authorize request hits on
the endpoints.
</p>
</div>
) : (
<div className="space-y-4">
{tokens.map((token) => (
<div
key={token.id}
className="relative flex flex-col gap-3 rounded-lg border border-border bg-muted/10 p-4 transition-colors hover:border-muted"
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-foreground">
{token.name}
</span>
<div className="flex items-center gap-2">
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${
token.active
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'bg-muted text-muted-foreground'
}`}
>
{token.active
? 'Active'
: 'Disabled'}
</span>
<span className="rounded-full bg-primary/10 px-2.5 py-0.5 font-mono text-[10px] font-bold text-primary">
Base Fallback:{' '}
{token.default_currency}
</span>
</div>
</div>
{/* Masked token input */}
<div className="flex items-center gap-1 rounded-md border border-border bg-card p-2 font-mono text-xs">
<span className="flex-1 truncate px-1 select-all">
{visibleTokens[token.id]
? token.token
: `${token.token.substring(0, 8)}****************************************`}
</span>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() =>
toggleTokenVisibility(token.id)
}
title={
visibleTokens[token.id]
? 'Hide Token'
: 'Show Token'
}
>
{visibleTokens[token.id] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-primary"
onClick={() =>
copyToClipboard(
token.token,
token.id,
)
}
title="Copy Token"
>
{copiedTokenId === token.id ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{/* Actions */}
<div className="flex items-center justify-between border-t border-border/50 pt-1 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />{' '}
Created:{' '}
{formatDate(token.created_at)}
</span>
<div className="flex items-center gap-3">
<button
onClick={() =>
handleToggleStatus(token.id)
}
className={`flex items-center gap-1 font-semibold hover:underline ${token.active ? 'text-amber-500' : 'text-emerald-500'}`}
>
<Power className="h-3 w-3" />{' '}
{token.active
? 'Disable'
: 'Enable'}
</button>
<span className="text-border">
|
</span>
<button
onClick={() =>
handleRegenerateToken(
token.id,
token.name,
)
}
className="flex items-center gap-1 font-semibold text-primary hover:underline"
>
<RefreshCw className="h-3 w-3" />{' '}
Rotate Key
</button>
<span className="text-border">
|
</span>
<button
onClick={() =>
handleRevokeToken(
token.id,
token.name,
)
}
className="flex items-center gap-1 font-semibold text-destructive hover:underline"
>
<Trash2 className="h-3 w-3" />{' '}
Revoke Key
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Create API Token Dialog */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent className="border border-border bg-card text-card-foreground sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate Developer API Key</DialogTitle>
<DialogDescription>
Create a secure token for accessing currency
resources. Leave base currency blank to use the
system default (INR).
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateToken}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label
htmlFor="name"
className="text-xs font-semibold tracking-wider text-muted-foreground uppercase"
>
Token Label / Name
</label>
<input
id="name"
type="text"
placeholder="e.g. Production Application, Local Test Server"
value={data.name}
onChange={(e) =>
setData('name', e.target.value)
}
required
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-xs transition-colors focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden"
/>
{errors.name && (
<p className="text-xs text-destructive">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="default_currency"
className="text-xs font-semibold tracking-wider text-muted-foreground uppercase"
>
Default Base Currency
</label>
<Combobox
options={currencyOptions}
value={data.default_currency}
onChange={(val) =>
setData('default_currency', val)
}
placeholder="Search base currency..."
/>
{errors.default_currency && (
<p className="text-xs text-destructive">
{errors.default_currency}
</p>
)}
</div>
</div>
<DialogFooter className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={processing}
className="bg-primary text-primary-foreground shadow-md hover:bg-primary/90"
>
{processing
? 'Generating...'
: 'Generate API Key'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
TokensPage.layout = {
breadcrumbs: [
{
title: 'Dashboard',
href: '/dashboard',
},
{
title: 'API Tokens',
href: '/dashboard/tokens',
},
],
};