mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -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">
|
||||
“{tokens.find((t) => t.id === confirmRevokeId)?.name ?? 'this token'}”
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,135 +5,14 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
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 type { ApiToken } from '@/lib/types/api-tokens';
|
||||
|
||||
export function ApiTokensSection() {
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
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',
|
||||
});
|
||||
};
|
||||
const api = useApiTokens<ApiToken>({ basePath: '/api/user/api-tokens' });
|
||||
|
||||
return (
|
||||
<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="p-6 space-y-5">
|
||||
{/* 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">
|
||||
@@ -173,18 +52,18 @@ export function ApiTokensSection() {
|
||||
</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">
|
||||
@@ -196,7 +75,7 @@ export function ApiTokensSection() {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
@@ -206,11 +85,11 @@ export function ApiTokensSection() {
|
||||
</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()}
|
||||
onKeyDown={(e) => e.key === 'Enter' && api.handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -218,8 +97,8 @@ export function ApiTokensSection() {
|
||||
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>
|
||||
@@ -231,14 +110,14 @@ export function ApiTokensSection() {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newTokenName.trim()}
|
||||
onClick={() => api.handleCreate()}
|
||||
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'); }}
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
@@ -247,7 +126,7 @@ export function ApiTokensSection() {
|
||||
</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
|
||||
@@ -255,11 +134,11 @@ export function ApiTokensSection() {
|
||||
)}
|
||||
|
||||
{/* Token list */}
|
||||
{loading ? (
|
||||
{api.loading ? (
|
||||
<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>
|
||||
) : 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" />
|
||||
@@ -280,7 +159,7 @@ export function ApiTokensSection() {
|
||||
</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">
|
||||
@@ -288,11 +167,11 @@ export function ApiTokensSection() {
|
||||
{token.tokenPrefix}...
|
||||
</code>
|
||||
</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>
|
||||
) : (
|
||||
@@ -301,11 +180,11 @@ export function ApiTokensSection() {
|
||||
</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>
|
||||
@@ -323,7 +202,7 @@ export function ApiTokensSection() {
|
||||
</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>
|
||||
</div>
|
||||
@@ -331,13 +210,13 @@ export function ApiTokensSection() {
|
||||
|
||||
{/* Revoke confirmation dialog */}
|
||||
<ConfirmModal
|
||||
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-800 dark:text-gray-100">
|
||||
“{tokens.find((t) => t.id === confirmRevokeId)?.name ?? 'this token'}”
|
||||
“{api.tokens.find((t) => t.id === api.confirmRevokeId)?.name ?? 'this token'}”
|
||||
</span>
|
||||
? Any integrations using this token will immediately lose access. This cannot be undone.
|
||||
</>
|
||||
@@ -345,8 +224,8 @@ export function ApiTokensSection() {
|
||||
confirmText="Revoke token"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={handleDeleteConfirmed}
|
||||
onClose={() => setConfirmRevokeId(null)}
|
||||
onConfirm={api.handleDeleteConfirmed}
|
||||
onClose={() => api.setConfirmRevokeId(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('JWT utilities', () => {
|
||||
it('generates and verifies access tokens', () => {
|
||||
const token = generateAccessToken({
|
||||
sub: 'user-1',
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'admin',
|
||||
@@ -57,6 +58,7 @@ describe('JWT utilities', () => {
|
||||
it('decodes tokens without verification', () => {
|
||||
const token = generateAccessToken({
|
||||
sub: 'user-4',
|
||||
id: 'user-4',
|
||||
plexId: 'plex-4',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
|
||||
Reference in New Issue
Block a user