From a50fbc721e8f668773e5a42ffe121f89e007fad5 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Wed, 4 Mar 2026 15:18:48 -0500 Subject: [PATCH] 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. --- src/app/admin/settings/tabs/ApiTab/ApiTab.tsx | 201 ++++------------ src/app/api-docs/page.tsx | 3 +- src/app/api/admin/api-tokens/route.ts | 10 + src/app/api/auth/admin/login/route.ts | 1 + src/app/api/auth/plex/callback/route.ts | 1 + src/app/api/auth/plex/switch-profile/route.ts | 1 + src/app/api/auth/refresh/route.ts | 1 + src/app/api/setup/complete/route.ts | 1 + src/components/profile/ApiTokensSection.tsx | 189 +++------------ src/lib/hooks/useApiTokens.ts | 218 ++++++++++++++++++ src/lib/middleware/auth.ts | 11 +- src/lib/services/auth/LocalAuthProvider.ts | 1 + src/lib/services/auth/OIDCAuthProvider.ts | 1 + src/lib/services/auth/PlexAuthProvider.ts | 1 + src/lib/utils/client-url.ts | 12 + src/lib/utils/jwt.ts | 1 + tests/utils/jwt.test.ts | 2 + 17 files changed, 344 insertions(+), 311 deletions(-) create mode 100644 src/lib/hooks/useApiTokens.ts create mode 100644 src/lib/utils/client-url.ts diff --git a/src/app/admin/settings/tabs/ApiTab/ApiTab.tsx b/src/app/admin/settings/tabs/ApiTab/ApiTab.tsx index 64ac43b..f57bfb0 100644 --- a/src/app/admin/settings/tabs/ApiTab/ApiTab.tsx +++ b/src/app/admin/settings/tabs/ApiTab/ApiTab.tsx @@ -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([]); + const api = useApiTokens({ basePath: '/api/admin/api-tokens' }); + + // Admin-specific state const [users, setUsers] = useState([]); - 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(null); - const [copied, setCopied] = useState(false); - const [error, setError] = useState(null); - const [deletingId, setDeletingId] = useState(null); - const [confirmRevokeId, setConfirmRevokeId] = useState(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 = { 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 = {}; + 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 (
@@ -197,14 +94,14 @@ export function ApiTab() {
{/* Error display */} - {error && ( + {api.error && (
- {error} + {api.error}
)} {/* Newly created token banner */} - {createdToken && ( + {api.createdToken && (
@@ -216,18 +113,18 @@ export function ApiTab() {

- {createdToken} + {api.createdToken}