Add useApiTokens hook and refactor token UI

Introduce a shared useApiTokens hook to centralize API token CRUD and UI state (fetch, create, delete, copy, formatting). Refactor ApiTab and ApiTokensSection to consume the hook and remove duplicated logic. Add getInstanceUrl utility for client origin used in curl examples. Include an id alias in TokenPayload and add id into generated JWTs across auth routes and providers; update tests accordingly. Improve auth middleware typing and add debug logging around lastUsedAt updates. Add admin logging when creating a token with a role that differs from the target user's role.
This commit is contained in:
kikootwo
2026-03-04 15:18:48 -05:00
parent d6eca611fc
commit a50fbc721e
17 changed files with 344 additions and 311 deletions
+34 -155
View File
@@ -5,135 +5,14 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
import { useApiTokens } from '@/lib/hooks/useApiTokens';
import { getInstanceUrl } from '@/lib/utils/client-url';
import Link from 'next/link';
import type { ApiToken } from '@/lib/types/api-tokens';
export function ApiTokensSection() {
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newTokenName, setNewTokenName] = useState('');
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
const [showCreateForm, setShowCreateForm] = useState(false);
const [createdToken, setCreatedToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
const fetchTokens = useCallback(async () => {
try {
const response = await fetchWithAuth('/api/user/api-tokens');
if (response.ok) {
const data = await response.json();
setTokens(data.tokens);
}
} catch {
setError('Failed to load API tokens');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchTokens();
}, [fetchTokens]);
const handleCreate = async () => {
if (!newTokenName.trim()) {
setError('Token name is required');
return;
}
setCreating(true);
setError(null);
try {
let expiresAt: string | null = null;
if (newTokenExpiry !== 'never') {
const date = new Date();
switch (newTokenExpiry) {
case '30d': date.setDate(date.getDate() + 30); break;
case '90d': date.setDate(date.getDate() + 90); break;
case '1y': date.setFullYear(date.getFullYear() + 1); break;
}
expiresAt = date.toISOString();
}
const response = await fetchWithAuth('/api/user/api-tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTokenName.trim(), expiresAt }),
});
if (response.ok) {
const data = await response.json();
setCreatedToken(data.fullToken);
setNewTokenName('');
setNewTokenExpiry('never');
setShowCreateForm(false);
await fetchTokens();
} else {
const data = await response.json();
setError(data.error || 'Failed to create token');
}
} catch {
setError('Failed to create token');
} finally {
setCreating(false);
}
};
const handleDeleteConfirmed = async () => {
const id = confirmRevokeId;
if (!id) return;
setConfirmRevokeId(null);
setDeletingId(id);
setError(null);
try {
const response = await fetchWithAuth(`/api/user/api-tokens/${id}`, {
method: 'DELETE',
});
if (response.ok) {
setTokens(tokens.filter((t) => t.id !== id));
} else {
setError('Failed to revoke token');
}
} catch {
setError('Failed to revoke token');
} finally {
setDeletingId(null);
}
};
const handleCopy = async () => {
if (createdToken) {
try {
await navigator.clipboard.writeText(createdToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setError('Failed to copy to clipboard. Please select and copy the token manually.');
}
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const api = useApiTokens<ApiToken>({ basePath: '/api/user/api-tokens' });
return (
<section>
@@ -154,14 +33,14 @@ export function ApiTokensSection() {
<div className="rounded-2xl overflow-hidden bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700/50 shadow-sm">
<div className="p-6 space-y-5">
{/* Error display */}
{error && (
{api.error && (
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 text-sm">
{error}
{api.error}
</div>
)}
{/* Newly created token banner */}
{createdToken && (
{api.createdToken && (
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -173,18 +52,18 @@ export function ApiTokensSection() {
</p>
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 text-sm bg-white dark:bg-gray-900 px-3 py-2 rounded border border-green-300 dark:border-green-700 text-gray-900 dark:text-gray-100 font-mono break-all">
{createdToken}
{api.createdToken}
</code>
<button
onClick={handleCopy}
onClick={api.handleCopy}
className="flex-shrink-0 px-3 py-2 text-sm font-medium rounded-lg bg-green-600 hover:bg-green-700 text-white transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
{api.copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
<button
onClick={() => setCreatedToken(null)}
onClick={api.dismissCreatedToken}
className="flex-shrink-0 text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -196,7 +75,7 @@ export function ApiTokensSection() {
)}
{/* Create token form */}
{showCreateForm ? (
{api.showCreateForm ? (
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">Create New Token</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -206,11 +85,11 @@ export function ApiTokensSection() {
</label>
<input
type="text"
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
value={api.newTokenName}
onChange={(e) => api.setNewTokenName(e.target.value)}
placeholder="e.g., Home Assistant, Webhook"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
onKeyDown={(e) => e.key === 'Enter' && api.handleCreate()}
/>
</div>
<div>
@@ -218,8 +97,8 @@ export function ApiTokensSection() {
Expiration
</label>
<select
value={newTokenExpiry}
onChange={(e) => setNewTokenExpiry(e.target.value)}
value={api.newTokenExpiry}
onChange={(e) => api.setNewTokenExpiry(e.target.value)}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value="never">Never</option>
@@ -231,14 +110,14 @@ export function ApiTokensSection() {
</div>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={creating || !newTokenName.trim()}
onClick={() => api.handleCreate()}
disabled={api.creating || !api.newTokenName.trim()}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white transition-colors"
>
{creating ? 'Creating...' : 'Create Token'}
{api.creating ? 'Creating...' : 'Create Token'}
</button>
<button
onClick={() => { setShowCreateForm(false); setNewTokenName(''); setNewTokenExpiry('never'); }}
onClick={api.resetForm}
className="px-4 py-2 text-sm font-medium rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 transition-colors"
>
Cancel
@@ -247,7 +126,7 @@ export function ApiTokensSection() {
</div>
) : (
<button
onClick={() => setShowCreateForm(true)}
onClick={() => api.setShowCreateForm(true)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
Create New Token
@@ -255,11 +134,11 @@ export function ApiTokensSection() {
)}
{/* Token list */}
{loading ? (
{api.loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : tokens.length === 0 ? (
) : api.tokens.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
@@ -280,7 +159,7 @@ export function ApiTokensSection() {
</tr>
</thead>
<tbody>
{tokens.map((token) => (
{api.tokens.map((token) => (
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800">
<td className="py-3 px-2 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
<td className="py-3 px-2">
@@ -288,11 +167,11 @@ export function ApiTokensSection() {
{token.tokenPrefix}...
</code>
</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{formatDate(token.lastUsedAt)}</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{api.formatDate(token.lastUsedAt)}</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">
{token.expiresAt ? (
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}>
{formatDate(token.expiresAt)}
{api.formatDate(token.expiresAt)}
{new Date(token.expiresAt) < new Date() && ' (expired)'}
</span>
) : (
@@ -301,11 +180,11 @@ export function ApiTokensSection() {
</td>
<td className="py-3 px-2 text-right">
<button
onClick={() => setConfirmRevokeId(token.id)}
disabled={deletingId === token.id}
onClick={() => api.setConfirmRevokeId(token.id)}
disabled={api.deletingId === token.id}
className="px-3 py-1 text-xs font-medium rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-300 transition-colors disabled:opacity-50"
>
{deletingId === token.id ? 'Revoking...' : 'Revoke'}
{api.deletingId === token.id ? 'Revoking...' : 'Revoke'}
</button>
</td>
</tr>
@@ -323,7 +202,7 @@ export function ApiTokensSection() {
</p>
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto">
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
${getInstanceUrl()}/api/requests`}
</pre>
</div>
</div>
@@ -331,13 +210,13 @@ export function ApiTokensSection() {
{/* Revoke confirmation dialog */}
<ConfirmModal
isOpen={confirmRevokeId !== null}
isOpen={api.confirmRevokeId !== null}
title="Revoke API token"
message={
<>
Are you sure you want to revoke{' '}
<span className="font-medium text-gray-800 dark:text-gray-100">
&ldquo;{tokens.find((t) => t.id === confirmRevokeId)?.name ?? 'this token'}&rdquo;
&ldquo;{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}&rdquo;
</span>
? Any integrations using this token will immediately lose access. This cannot be undone.
</>
@@ -345,8 +224,8 @@ export function ApiTokensSection() {
confirmText="Revoke token"
cancelText="Cancel"
variant="danger"
onConfirm={handleDeleteConfirmed}
onClose={() => setConfirmRevokeId(null)}
onConfirm={api.handleDeleteConfirmed}
onClose={() => api.setConfirmRevokeId(null)}
/>
</section>
);