mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { API_TOKEN_PREFIX, isEndpointAllowed } from '../constants/api-tokens';
|
||||
const logger = RMABLogger.create('Auth');
|
||||
|
||||
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.
|
||||
* 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 apiToken = await prisma.apiToken.findUnique({
|
||||
@@ -79,7 +79,12 @@ async function authenticateApiToken(token: string): Promise<(TokenPayload & { id
|
||||
prisma.apiToken.update({
|
||||
where: { id: apiToken.id },
|
||||
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)
|
||||
return {
|
||||
|
||||
@@ -250,6 +250,7 @@ export class LocalAuthProvider implements IAuthProvider {
|
||||
private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise<AuthTokens> {
|
||||
const tokenPayload = {
|
||||
sub: userInfo.id,
|
||||
id: userInfo.id,
|
||||
plexId: userInfo.plexId,
|
||||
username: userInfo.username,
|
||||
role: userInfo.role || 'user',
|
||||
|
||||
@@ -516,6 +516,7 @@ export class OIDCAuthProvider implements IAuthProvider {
|
||||
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
|
||||
const accessToken = generateAccessToken({
|
||||
sub: userInfo.id,
|
||||
id: userInfo.id,
|
||||
plexId: userInfo.id, // For backwards compatibility
|
||||
username: userInfo.username,
|
||||
role: userInfo.role || 'user',
|
||||
|
||||
@@ -250,6 +250,7 @@ export class PlexAuthProvider implements IAuthProvider {
|
||||
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
|
||||
const accessToken = generateAccessToken({
|
||||
sub: userInfo.id,
|
||||
id: userInfo.id,
|
||||
plexId: userInfo.id, // For backwards compatibility
|
||||
username: userInfo.username,
|
||||
role: userInfo.role || 'user',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const REFRESH_TOKEN_EXPIRY = '7d'; // 7 days
|
||||
|
||||
export interface TokenPayload {
|
||||
sub: string; // User ID
|
||||
id: string; // User ID (alias for sub, used by req.user.id throughout the codebase)
|
||||
plexId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
|
||||
Reference in New Issue
Block a user