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 { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api'; import { fetchWithAuth } from '@/lib/utils/api';
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog'; 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 Link from 'next/link';
import type { AdminApiToken } from '@/lib/types/api-tokens'; import type { AdminApiToken } from '@/lib/types/api-tokens';
@@ -18,34 +20,12 @@ interface UserOption {
} }
export function ApiTab() { 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 [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 [newTokenUserId, setNewTokenUserId] = useState('');
const [newTokenRole, setNewTokenRole] = 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 () => { const fetchUsers = useCallback(async () => {
try { try {
@@ -60,110 +40,21 @@ export function ApiTab() {
}, []); }, []);
useEffect(() => { useEffect(() => {
fetchTokens();
fetchUsers(); fetchUsers();
}, [fetchTokens, fetchUsers]); }, [fetchUsers]);
const handleCreate = async () => { const handleCreate = async () => {
if (!newTokenName.trim()) { const extraBody: Record<string, string> = {};
setError('Token name is required'); if (newTokenUserId) extraBody.userId = newTokenUserId;
return; if (newTokenRole) extraBody.role = newTokenRole;
} await api.handleCreate(extraBody);
// Reset admin-specific fields on success
setCreating(true); if (!api.error) {
setError(null); setNewTokenUserId('');
setNewTokenRole('');
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 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) => { const handleUserChange = (userId: string) => {
setNewTokenUserId(userId); setNewTokenUserId(userId);
if (userId) { if (userId) {
@@ -176,7 +67,13 @@ export function ApiTab() {
} }
}; };
if (loading) { const handleCancel = () => {
api.resetForm();
setNewTokenUserId('');
setNewTokenRole('');
};
if (api.loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <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> <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> </div>
{/* Error display */} {/* 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"> <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> </div>
)} )}
{/* Newly created token banner */} {/* 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="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"> <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"> <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> </p>
<div className="mt-2 flex items-center gap-2"> <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"> <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> </code>
<button <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" 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> </button>
</div> </div>
</div> </div>
<button <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" 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"> <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 */} {/* 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"> <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> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -249,8 +146,8 @@ export function ApiTab() {
</label> </label>
<input <input
type="text" type="text"
value={newTokenName} value={api.newTokenName}
onChange={(e) => setNewTokenName(e.target.value)} onChange={(e) => api.setNewTokenName(e.target.value)}
placeholder="e.g., Home Assistant, Webhook" 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" 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' && handleCreate()}
@@ -261,8 +158,8 @@ export function ApiTab() {
Expiration Expiration
</label> </label>
<select <select
value={newTokenExpiry} value={api.newTokenExpiry}
onChange={(e) => setNewTokenExpiry(e.target.value)} 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" 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> <option value="never">Never</option>
@@ -306,13 +203,13 @@ export function ApiTab() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleCreate} 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" 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>
<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" 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 Cancel
@@ -321,7 +218,7 @@ export function ApiTab() {
</div> </div>
) : ( ) : (
<button <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" 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 Create New Token
@@ -329,7 +226,7 @@ export function ApiTab() {
)} )}
{/* Token list */} {/* Token list */}
{tokens.length === 0 ? ( {api.tokens.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <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"> <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" /> <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> </tr>
</thead> </thead>
<tbody> <tbody>
{tokens.map((token) => ( {api.tokens.map((token) => (
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800"> <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 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
<td className="py-3 px-2"> <td className="py-3 px-2">
@@ -372,11 +269,11 @@ export function ApiTab() {
</span> </span>
</td> </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">{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"> <td className="py-3 px-2 text-gray-500 dark:text-gray-400">
{token.expiresAt ? ( {token.expiresAt ? (
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}> <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)'} {new Date(token.expiresAt) < new Date() && ' (expired)'}
</span> </span>
) : ( ) : (
@@ -385,11 +282,11 @@ export function ApiTab() {
</td> </td>
<td className="py-3 px-2 text-right"> <td className="py-3 px-2 text-right">
<button <button
onClick={() => setConfirmRevokeId(token.id)} onClick={() => api.setConfirmRevokeId(token.id)}
disabled={deletingId === 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" 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> </button>
</td> </td>
</tr> </tr>
@@ -407,19 +304,19 @@ export function ApiTab() {
</p> </p>
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto"> <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" \\ {`curl -H "Authorization: Bearer rmab_your_token_here" \\
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`} ${getInstanceUrl()}/api/requests`}
</pre> </pre>
</div> </div>
{/* Revoke confirmation dialog */} {/* Revoke confirmation dialog */}
<ConfirmDialog <ConfirmDialog
isOpen={confirmRevokeId !== null} isOpen={api.confirmRevokeId !== null}
title="Revoke API token" title="Revoke API token"
message={ message={
<> <>
Are you sure you want to revoke{' '} Are you sure you want to revoke{' '}
<span className="font-medium text-gray-700 dark:text-gray-200"> <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> </span>
? Any integrations using this token will immediately lose access. This cannot be undone. ? Any integrations using this token will immediately lose access. This cannot be undone.
</> </>
@@ -427,8 +324,8 @@ export function ApiTab() {
confirmLabel="Revoke token" confirmLabel="Revoke token"
cancelLabel="Cancel" cancelLabel="Cancel"
confirmVariant="danger" confirmVariant="danger"
onConfirm={handleDeleteConfirmed} onConfirm={api.handleDeleteConfirmed}
onCancel={() => setConfirmRevokeId(null)} onCancel={() => api.setConfirmRevokeId(null)}
/> />
</div> </div>
); );
+2 -1
View File
@@ -15,6 +15,7 @@ import { TokenInput } from '@/components/api-docs/TokenInput';
import { EndpointCard } from '@/components/api-docs/EndpointCard'; import { EndpointCard } from '@/components/api-docs/EndpointCard';
import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens'; import { API_TOKEN_ENDPOINT_DOCS } from '@/lib/constants/api-tokens';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getInstanceUrl } from '@/lib/utils/client-url';
import Link from 'next/link'; import Link from 'next/link';
export default function ApiDocsPage() { export default function ApiDocsPage() {
@@ -101,7 +102,7 @@ export default function ApiDocsPage() {
</p> </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"> <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" \\ {`curl -H "Authorization: Bearer rmab_your_token_here" \\
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`} ${getInstanceUrl()}/api/requests`}
</pre> </pre>
</div> </div>
+10
View File
@@ -123,6 +123,16 @@ export async function POST(request: NextRequest) {
// Determine token role (defaults to target user's role) // Determine token role (defaults to target user's role)
const tokenRole = role || targetUser.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 // Generate the token
const { fullToken, tokenHash, tokenPrefix } = generateApiToken(); const { fullToken, tokenHash, tokenPrefix } = generateApiToken();
+1
View File
@@ -92,6 +92,7 @@ export async function POST(request: NextRequest) {
// Generate JWT tokens // Generate JWT tokens
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: user.id, sub: user.id,
id: user.id,
plexId: user.plexId, plexId: user.plexId,
username: user.plexUsername, username: user.plexUsername,
role: user.role, role: user.role,
+1
View File
@@ -239,6 +239,7 @@ export async function GET(request: NextRequest) {
// Generate JWT tokens // Generate JWT tokens
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: user.id, sub: user.id,
id: user.id,
plexId: user.plexId, plexId: user.plexId,
username: user.plexUsername, username: user.plexUsername,
role: user.role, role: user.role,
@@ -167,6 +167,7 @@ export async function POST(request: NextRequest) {
// Generate JWT tokens // Generate JWT tokens
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: user.id, sub: user.id,
id: user.id,
plexId: user.plexId, plexId: user.plexId,
username: user.plexUsername, username: user.plexUsername,
role: user.role, role: user.role,
+1
View File
@@ -60,6 +60,7 @@ export async function POST(request: NextRequest) {
// Generate new access token // Generate new access token
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: user.id, sub: user.id,
id: user.id,
plexId: user.plexId, plexId: user.plexId,
username: user.plexUsername, username: user.plexUsername,
role: user.role, role: user.role,
+1
View File
@@ -163,6 +163,7 @@ export async function POST(request: NextRequest) {
// Generate JWT tokens for auto-login // Generate JWT tokens for auto-login
accessToken = generateAccessToken({ accessToken = generateAccessToken({
sub: adminUser.id, sub: adminUser.id,
id: adminUser.id,
plexId: adminUser.plexId, plexId: adminUser.plexId,
username: adminUser.plexUsername, username: adminUser.plexUsername,
role: adminUser.role, role: adminUser.role,
+34 -155
View File
@@ -5,135 +5,14 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react';
import { fetchWithAuth } from '@/lib/utils/api';
import { ConfirmModal } from '@/components/ui/ConfirmModal'; 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 Link from 'next/link';
import type { ApiToken } from '@/lib/types/api-tokens'; import type { ApiToken } from '@/lib/types/api-tokens';
export function ApiTokensSection() { export function ApiTokensSection() {
const [tokens, setTokens] = useState<ApiToken[]>([]); const api = useApiTokens<ApiToken>({ basePath: '/api/user/api-tokens' });
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',
});
};
return ( return (
<section> <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="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"> <div className="p-6 space-y-5">
{/* Error display */} {/* 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"> <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> </div>
)} )}
{/* Newly created token banner */} {/* 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="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"> <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"> <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> </p>
<div className="mt-2 flex items-center gap-2"> <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"> <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> </code>
<button <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" 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> </button>
</div> </div>
</div> </div>
<button <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" 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"> <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 */} {/* 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"> <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> <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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -206,11 +85,11 @@ export function ApiTokensSection() {
</label> </label>
<input <input
type="text" type="text"
value={newTokenName} value={api.newTokenName}
onChange={(e) => setNewTokenName(e.target.value)} onChange={(e) => api.setNewTokenName(e.target.value)}
placeholder="e.g., Home Assistant, Webhook" 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" 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>
<div> <div>
@@ -218,8 +97,8 @@ export function ApiTokensSection() {
Expiration Expiration
</label> </label>
<select <select
value={newTokenExpiry} value={api.newTokenExpiry}
onChange={(e) => setNewTokenExpiry(e.target.value)} 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" 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> <option value="never">Never</option>
@@ -231,14 +110,14 @@ export function ApiTokensSection() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={handleCreate} onClick={() => api.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" 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>
<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" 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 Cancel
@@ -247,7 +126,7 @@ export function ApiTokensSection() {
</div> </div>
) : ( ) : (
<button <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" 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 Create New Token
@@ -255,11 +134,11 @@ export function ApiTokensSection() {
)} )}
{/* Token list */} {/* Token list */}
{loading ? ( {api.loading ? (
<div className="flex items-center justify-center py-8"> <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 className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div> </div>
) : tokens.length === 0 ? ( ) : api.tokens.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> <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"> <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" /> <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> </tr>
</thead> </thead>
<tbody> <tbody>
{tokens.map((token) => ( {api.tokens.map((token) => (
<tr key={token.id} className="border-b border-gray-100 dark:border-gray-800"> <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 text-gray-900 dark:text-gray-100 font-medium">{token.name}</td>
<td className="py-3 px-2"> <td className="py-3 px-2">
@@ -288,11 +167,11 @@ export function ApiTokensSection() {
{token.tokenPrefix}... {token.tokenPrefix}...
</code> </code>
</td> </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"> <td className="py-3 px-2 text-gray-500 dark:text-gray-400">
{token.expiresAt ? ( {token.expiresAt ? (
<span className={new Date(token.expiresAt) < new Date() ? 'text-red-500' : ''}> <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)'} {new Date(token.expiresAt) < new Date() && ' (expired)'}
</span> </span>
) : ( ) : (
@@ -301,11 +180,11 @@ export function ApiTokensSection() {
</td> </td>
<td className="py-3 px-2 text-right"> <td className="py-3 px-2 text-right">
<button <button
onClick={() => setConfirmRevokeId(token.id)} onClick={() => api.setConfirmRevokeId(token.id)}
disabled={deletingId === 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" 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> </button>
</td> </td>
</tr> </tr>
@@ -323,7 +202,7 @@ export function ApiTokensSection() {
</p> </p>
<pre className="text-xs bg-gray-900 dark:bg-black text-gray-100 p-3 rounded-lg overflow-x-auto"> <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" \\ {`curl -H "Authorization: Bearer rmab_your_token_here" \\
${typeof window !== 'undefined' ? window.location.origin : 'https://your-instance'}/api/requests`} ${getInstanceUrl()}/api/requests`}
</pre> </pre>
</div> </div>
</div> </div>
@@ -331,13 +210,13 @@ export function ApiTokensSection() {
{/* Revoke confirmation dialog */} {/* Revoke confirmation dialog */}
<ConfirmModal <ConfirmModal
isOpen={confirmRevokeId !== null} isOpen={api.confirmRevokeId !== null}
title="Revoke API token" title="Revoke API token"
message={ message={
<> <>
Are you sure you want to revoke{' '} Are you sure you want to revoke{' '}
<span className="font-medium text-gray-800 dark:text-gray-100"> <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> </span>
? Any integrations using this token will immediately lose access. This cannot be undone. ? Any integrations using this token will immediately lose access. This cannot be undone.
</> </>
@@ -345,8 +224,8 @@ export function ApiTokensSection() {
confirmText="Revoke token" confirmText="Revoke token"
cancelText="Cancel" cancelText="Cancel"
variant="danger" variant="danger"
onConfirm={handleDeleteConfirmed} onConfirm={api.handleDeleteConfirmed}
onClose={() => setConfirmRevokeId(null)} onClose={() => api.setConfirmRevokeId(null)}
/> />
</section> </section>
); );
+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,
};
}
+8 -3
View File
@@ -13,7 +13,7 @@ import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
const logger = RMABLogger.create('Auth'); const logger = RMABLogger.create('Auth');
export interface AuthenticatedRequest extends NextRequest { export interface AuthenticatedRequest extends NextRequest {
user?: TokenPayload & { id: string }; user?: TokenPayload;
} }
/** /**
@@ -39,7 +39,7 @@ function extractToken(request: NextRequest): string | null {
* Returns a synthetic TokenPayload if valid, null otherwise. * Returns a synthetic TokenPayload if valid, null otherwise.
* Updates lastUsedAt asynchronously. * Updates lastUsedAt asynchronously.
*/ */
async function authenticateApiToken(token: string): Promise<(TokenPayload & { id: string }) | null> { async function authenticateApiToken(token: string): Promise<TokenPayload | null> {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const apiToken = await prisma.apiToken.findUnique({ const apiToken = await prisma.apiToken.findUnique({
@@ -79,7 +79,12 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id
prisma.apiToken.update({ prisma.apiToken.update({
where: { id: apiToken.id }, where: { id: apiToken.id },
data: { lastUsedAt: new Date() }, data: { lastUsedAt: new Date() },
}).catch(() => {}); }).catch((err) => {
logger.debug('Failed to update API token lastUsedAt', {
error: err instanceof Error ? err.message : String(err),
tokenId: apiToken.id,
});
});
// Use the token's target user (userId), not the creator (createdById) // Use the token's target user (userId), not the creator (createdById)
return { return {
@@ -250,6 +250,7 @@ export class LocalAuthProvider implements IAuthProvider {
private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise<AuthTokens> { private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise<AuthTokens> {
const tokenPayload = { const tokenPayload = {
sub: userInfo.id, sub: userInfo.id,
id: userInfo.id,
plexId: userInfo.plexId, plexId: userInfo.plexId,
username: userInfo.username, username: userInfo.username,
role: userInfo.role || 'user', role: userInfo.role || 'user',
@@ -516,6 +516,7 @@ export class OIDCAuthProvider implements IAuthProvider {
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> { private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: userInfo.id, sub: userInfo.id,
id: userInfo.id,
plexId: userInfo.id, // For backwards compatibility plexId: userInfo.id, // For backwards compatibility
username: userInfo.username, username: userInfo.username,
role: userInfo.role || 'user', role: userInfo.role || 'user',
@@ -250,6 +250,7 @@ export class PlexAuthProvider implements IAuthProvider {
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> { private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
const accessToken = generateAccessToken({ const accessToken = generateAccessToken({
sub: userInfo.id, sub: userInfo.id,
id: userInfo.id,
plexId: userInfo.id, // For backwards compatibility plexId: userInfo.id, // For backwards compatibility
username: userInfo.username, username: userInfo.username,
role: userInfo.role || 'user', role: userInfo.role || 'user',
+12
View File
@@ -0,0 +1,12 @@
/**
* Component: Client-side URL Utilities
* Documentation: documentation/backend/services/api-tokens.md
*/
/**
* Get the current instance origin URL.
* Returns window.location.origin on the client, or a placeholder on the server.
*/
export function getInstanceUrl(): string {
return typeof window !== 'undefined' ? window.location.origin : 'https://your-instance';
}
+1
View File
@@ -17,6 +17,7 @@ const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
export interface TokenPayload { export interface TokenPayload {
sub: string; // User ID sub: string; // User ID
id: string; // User ID (alias for sub, used by req.user.id throughout the codebase)
plexId: string; plexId: string;
username: string; username: string;
role: string; role: string;
+2
View File
@@ -17,6 +17,7 @@ describe('JWT utilities', () => {
it('generates and verifies access tokens', () => { it('generates and verifies access tokens', () => {
const token = generateAccessToken({ const token = generateAccessToken({
sub: 'user-1', sub: 'user-1',
id: 'user-1',
plexId: 'plex-1', plexId: 'plex-1',
username: 'user', username: 'user',
role: 'admin', role: 'admin',
@@ -57,6 +58,7 @@ describe('JWT utilities', () => {
it('decodes tokens without verification', () => { it('decodes tokens without verification', () => {
const token = generateAccessToken({ const token = generateAccessToken({
sub: 'user-4', sub: 'user-4',
id: 'user-4',
plexId: 'plex-4', plexId: 'plex-4',
username: 'user', username: 'user',
role: 'user', role: 'user',