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
+218
View File
@@ -0,0 +1,218 @@
/**
* Component: Shared API Token Management Hook
* Documentation: documentation/backend/services/api-tokens.md
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import type { ApiToken } from '@/lib/types/api-tokens';
/** Typed request body for creating an API token */
export interface CreateTokenBody {
name: string;
expiresAt: string | null;
userId?: string;
role?: string;
}
interface UseApiTokensConfig {
/** Base API path, e.g. '/api/admin/api-tokens' or '/api/user/api-tokens' */
basePath: string;
}
export interface UseApiTokensReturn<T extends ApiToken = ApiToken> {
tokens: T[];
loading: boolean;
creating: boolean;
error: string | null;
newTokenName: string;
setNewTokenName: (name: string) => void;
newTokenExpiry: string;
setNewTokenExpiry: (expiry: string) => void;
showCreateForm: boolean;
setShowCreateForm: (show: boolean) => void;
createdToken: string | null;
copied: boolean;
deletingId: string | null;
confirmRevokeId: string | null;
setConfirmRevokeId: (id: string | null) => void;
fetchTokens: () => Promise<void>;
handleCreate: (extraBody?: Partial<CreateTokenBody>) => Promise<void>;
handleDeleteConfirmed: () => Promise<void>;
handleCopy: () => Promise<void>;
dismissCreatedToken: () => void;
resetForm: () => void;
formatDate: (dateStr: string | null) => string;
}
/**
* Shared hook for API token CRUD operations.
* Used by both the admin ApiTab and the user ApiTokensSection.
*/
export function useApiTokens<T extends ApiToken = ApiToken>(
config: UseApiTokensConfig
): UseApiTokensReturn<T> {
const [tokens, setTokens] = useState<T[]>([]);
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(config.basePath);
if (response.ok) {
const data = await response.json();
setTokens(data.tokens);
}
} catch {
setError('Failed to load API tokens');
} finally {
setLoading(false);
}
}, [config.basePath]);
useEffect(() => {
fetchTokens();
}, [fetchTokens]);
const computeExpiresAt = (): string | null => {
if (newTokenExpiry === 'never') return null;
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;
}
return date.toISOString();
};
const handleCreate = async (extraBody?: Partial<CreateTokenBody>) => {
if (!newTokenName.trim()) {
setError('Token name is required');
return;
}
setCreating(true);
setError(null);
try {
const body: CreateTokenBody = {
name: newTokenName.trim(),
expiresAt: computeExpiresAt(),
...extraBody,
};
const response = await fetchWithAuth(config.basePath, {
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');
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(`${config.basePath}/${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 dismissCreatedToken = () => setCreatedToken(null);
const resetForm = () => {
setShowCreateForm(false);
setNewTokenName('');
setNewTokenExpiry('never');
};
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return {
tokens,
loading,
creating,
error,
newTokenName,
setNewTokenName,
newTokenExpiry,
setNewTokenExpiry,
showCreateForm,
setShowCreateForm,
createdToken,
copied,
deletingId,
confirmRevokeId,
setConfirmRevokeId,
fetchTokens,
handleCreate,
handleDeleteConfirmed,
handleCopy,
dismissCreatedToken,
resetForm,
formatDate,
};
}