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
+49 -152
View File
@@ -8,6 +8,8 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
import { useApiTokens } from '@/lib/hooks/useApiTokens';
import { getInstanceUrl } from '@/lib/utils/client-url';
import Link from 'next/link';
import type { AdminApiToken } from '@/lib/types/api-tokens';
@@ -18,34 +20,12 @@ interface UserOption {
}
export function ApiTab() {
const [tokens, setTokens] = useState<AdminApiToken[]>([]);
const api = useApiTokens<AdminApiToken>({ basePath: '/api/admin/api-tokens' });
// Admin-specific state
const [users, setUsers] = useState<UserOption[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newTokenName, setNewTokenName] = useState('');
const [newTokenExpiry, setNewTokenExpiry] = useState('never');
const [newTokenUserId, setNewTokenUserId] = useState('');
const [newTokenRole, setNewTokenRole] = useState('');
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/admin/api-tokens');
if (response.ok) {
const data = await response.json();
setTokens(data.tokens);
}
} catch {
setError('Failed to load API tokens');
} finally {
setLoading(false);
}
}, []);
const fetchUsers = useCallback(async () => {
try {
@@ -60,110 +40,21 @@ export function ApiTab() {
}, []);
useEffect(() => {
fetchTokens();
fetchUsers();
}, [fetchTokens, fetchUsers]);
}, [fetchUsers]);
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 body: Record<string, any> = { name: newTokenName.trim(), expiresAt };
if (newTokenUserId) body.userId = newTokenUserId;
if (newTokenRole) body.role = newTokenRole;
const response = await fetchWithAuth('/api/admin/api-tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (response.ok) {
const data = await response.json();
setCreatedToken(data.fullToken);
setNewTokenName('');
setNewTokenExpiry('never');
setNewTokenUserId('');
setNewTokenRole('');
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 extraBody: Record<string, string> = {};
if (newTokenUserId) extraBody.userId = newTokenUserId;
if (newTokenRole) extraBody.role = newTokenRole;
await api.handleCreate(extraBody);
// Reset admin-specific fields on success
if (!api.error) {
setNewTokenUserId('');
setNewTokenRole('');
}
};
const handleDeleteConfirmed = async () => {
const id = confirmRevokeId;
if (!id) return;
setConfirmRevokeId(null);
setDeletingId(id);
setError(null);
try {
const response = await fetchWithAuth(`/api/admin/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',
});
};
// When a user is selected, default the role to their actual role
const handleUserChange = (userId: string) => {
setNewTokenUserId(userId);
if (userId) {
@@ -176,7 +67,13 @@ export function ApiTab() {
}
};
if (loading) {
const handleCancel = () => {
api.resetForm();
setNewTokenUserId('');
setNewTokenRole('');
};
if (api.loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
@@ -197,14 +94,14 @@ export function ApiTab() {
</div>
{/* 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">
@@ -216,18 +113,18 @@ export function ApiTab() {
</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">
@@ -239,7 +136,7 @@ export function ApiTab() {
)}
{/* 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">
@@ -249,8 +146,8 @@ export function ApiTab() {
</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()}
@@ -261,8 +158,8 @@ export function ApiTab() {
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>
@@ -306,13 +203,13 @@ export function ApiTab() {
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={creating || !newTokenName.trim()}
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'); setNewTokenUserId(''); setNewTokenRole(''); }}
onClick={handleCancel}
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
@@ -321,7 +218,7 @@ export function ApiTab() {
</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
@@ -329,7 +226,7 @@ export function ApiTab() {
)}
{/* Token list */}
{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" />
@@ -353,7 +250,7 @@ export function ApiTab() {
</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">
@@ -372,11 +269,11 @@ export function ApiTab() {
</span>
</td>
<td className="py-3 px-2 text-gray-500 dark:text-gray-400">{token.createdBy}</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>
) : (
@@ -385,11 +282,11 @@ export function ApiTab() {
</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>
@@ -407,19 +304,19 @@ export function ApiTab() {
</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>
{/* Revoke confirmation dialog */}
<ConfirmDialog
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-700 dark:text-gray-200">
&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.
</>
@@ -427,8 +324,8 @@ export function ApiTab() {
confirmLabel="Revoke token"
cancelLabel="Cancel"
confirmVariant="danger"
onConfirm={handleDeleteConfirmed}
onCancel={() => setConfirmRevokeId(null)}
onConfirm={api.handleDeleteConfirmed}
onCancel={() => api.setConfirmRevokeId(null)}
/>
</div>
);
+2 -1
View File
@@ -15,6 +15,7 @@ import { TokenInput } from '@/components/api-docs/TokenInput';
import { EndpointCard } from '@/components/api-docs/EndpointCard';
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
import { useAuth } from '@/contexts/AuthContext';
import { getInstanceUrl } from '@/lib/utils/client-url';
import Link from 'next/link';
export default function ApiDocsPage() {
@@ -101,7 +102,7 @@ export default function ApiDocsPage() {
</p>
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-4 rounded-xl overflow-x-auto font-mono leading-relaxed">
{`curl -H "Authorization: Bearer rmab_your_token_here" \\
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`}
${getInstanceUrl()}/api/requests`}
</pre>
</div>
+10
View File
@@ -123,6 +123,16 @@ export async function POST(request: NextRequest) {
// Determine token role (defaults to target user's role)
const tokenRole = role || targetUser.role;
// Log when admin explicitly overrides role to differ from user's actual role
if (role && role !== targetUser.role) {
logger.warn('Admin creating token with role different from user actual role', {
tokenRole: role,
userActualRole: targetUser.role,
targetUser: targetUser.plexUsername,
createdBy: req.user!.username,
});
}
// Generate the token
const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
+1
View File
@@ -92,6 +92,7 @@ export async function POST(request: NextRequest) {
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
+1
View File
@@ -239,6 +239,7 @@ export async function GET(request: NextRequest) {
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
@@ -167,6 +167,7 @@ export async function POST(request: NextRequest) {
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
+1
View File
@@ -60,6 +60,7 @@ export async function POST(request: NextRequest) {
// Generate new access token
const accessToken = generateAccessToken({
sub: user.id,
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
+1
View File
@@ -163,6 +163,7 @@ export async function POST(request: NextRequest) {
// Generate JWT tokens for auto-login
accessToken = generateAccessToken({
sub: adminUser.id,
id: adminUser.id,
plexId: adminUser.plexId,
username: adminUser.plexUsername,
role: adminUser.role,