From 274990256498069a862adf4a20aff32a92dcc7cc Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 11:04:01 +0000 Subject: [PATCH] feat(auth): add admin login token management --- src/app/admin/users/page.tsx | 29 ++++- .../api/admin/users/[id]/login-token/route.ts | 101 ++++++++++++++++++ src/app/api/admin/users/route.ts | 8 +- .../admin/users/UserPermissionsModal.tsx | 84 +++++++++++++++ 4 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/app/api/admin/users/[id]/login-token/route.ts diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 1a95f7f..a9f7898 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -29,6 +29,7 @@ interface User { autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; downloadAccess: boolean | null; + hasLoginToken: boolean; _count: { requests: number; }; @@ -220,6 +221,7 @@ function AdminUsersPageContent() { const [globalDownloadAccess, setGlobalDownloadAccess] = useState(true); const [globalSettingsOpen, setGlobalSettingsOpen] = useState(false); const [permissionsUserId, setPermissionsUserId] = useState(null); + const [generatedToken, setGeneratedToken] = useState(null); const toast = useToast(); const isLoading = !data && !error; @@ -363,6 +365,24 @@ function AdminUsersPageContent() { } }; + const handleToggleToken = async (user: User, newValue: boolean) => { + try { + if (newValue) { + const result = await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'POST' }); + setGeneratedToken(result.fullToken); + toast.success(`Login token generated for ${user.plexUsername}`); + } else { + await fetchJSON(`/api/admin/users/${user.id}/login-token`, { method: 'DELETE' }); + setGeneratedToken(null); + toast.success(`Login token revoked for ${user.plexUsername}`); + } + mutate(); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to update login token'; + toast.error(errorMsg); + } + }; + const showEditDialog = (user: User) => { setEditRole(user.role); setEditDialog({ isOpen: true, user }); @@ -968,11 +988,15 @@ function AdminUsersPageContent() { {/* User Permissions Modal */} setPermissionsUserId(null)} + onClose={() => { + setPermissionsUserId(null); + setGeneratedToken(null); + }} user={permissionsUser} globalAutoApprove={globalAutoApprove} globalInteractiveSearch={globalInteractiveSearch} globalDownloadAccess={globalDownloadAccess} + generatedToken={generatedToken} onToggleAutoApprove={(user, newValue) => { handleUserAutoApproveToggle(user as User, newValue); }} @@ -982,6 +1006,9 @@ function AdminUsersPageContent() { onToggleDownloadAccess={(user, newValue) => { handleUserDownloadAccessToggle(user as User, newValue); }} + onToggleToken={(user, newValue) => { + handleToggleToken(user as unknown as User, newValue); + }} /> diff --git a/src/app/api/admin/users/[id]/login-token/route.ts b/src/app/api/admin/users/[id]/login-token/route.ts new file mode 100644 index 0000000..026aca0 --- /dev/null +++ b/src/app/api/admin/users/[id]/login-token/route.ts @@ -0,0 +1,101 @@ +/** + * Component: Admin User Login Token + * Documentation: documentation/backend/services/auth.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; +import { prisma } from '@/lib/db'; +import { RMABLogger } from '@/lib/utils/logger'; +import { generateApiToken } from '@/lib/utils/api-token'; +import crypto from 'crypto'; + +const logger = RMABLogger.create('API.Admin.Users.LoginToken'); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id } = await params; + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { plexUsername: true, deletedAt: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + if (targetUser.deletedAt) { + return NextResponse.json( + { error: 'Cannot generate token for deleted user' }, + { status: 403 } + ); + } + + const { fullToken } = generateApiToken(); + const tokenHash = crypto.createHash('sha256').update(fullToken).digest('hex'); + + await prisma.user.update({ + where: { id }, + data: { loginTokenHash: tokenHash }, + }); + + logger.info('Admin generated login token for user', { + targetUser: targetUser.plexUsername, + createdBy: req.user!.username, + }); + + return NextResponse.json({ fullToken }, { status: 201 }); + } catch (error) { + logger.error('Failed to generate login token', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to generate login token' }, { status: 500 }); + } + }); + }); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const { id } = await params; + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { plexUsername: true }, + }); + + if (!targetUser) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + await prisma.user.update({ + where: { id }, + data: { loginTokenHash: null }, + }); + + logger.info('Admin revoked login token for user', { + targetUser: targetUser.plexUsername, + revokedBy: req.user!.username, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + logger.error('Failed to revoke login token', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Failed to revoke login token' }, { status: 500 }); + } + }); + }); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index d97373b..dd88ad0 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -33,6 +33,7 @@ export async function GET(request: NextRequest) { autoApproveRequests: true, interactiveSearchAccess: true, downloadAccess: true, + loginTokenHash: true, _count: { select: { requests: true, @@ -44,7 +45,12 @@ export async function GET(request: NextRequest) { }, }); - return NextResponse.json({ users }); + return NextResponse.json({ + users: users.map(({ loginTokenHash, ...u }) => ({ + ...u, + hasLoginToken: loginTokenHash !== null, + })), + }); } catch (error) { logger.error('Failed to fetch users', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( diff --git a/src/components/admin/users/UserPermissionsModal.tsx b/src/components/admin/users/UserPermissionsModal.tsx index 060c483..843a3cd 100644 --- a/src/components/admin/users/UserPermissionsModal.tsx +++ b/src/components/admin/users/UserPermissionsModal.tsx @@ -16,6 +16,7 @@ interface UserPermissionsUser { autoApproveRequests: boolean | null; interactiveSearchAccess: boolean | null; downloadAccess: boolean | null; + hasLoginToken: boolean; } interface UserPermissionsModalProps { @@ -25,9 +26,11 @@ interface UserPermissionsModalProps { globalAutoApprove: boolean; globalInteractiveSearch: boolean; globalDownloadAccess: boolean; + generatedToken: string | null; onToggleAutoApprove: (user: UserPermissionsUser, newValue: boolean) => void; onToggleInteractiveSearch: (user: UserPermissionsUser, newValue: boolean) => void; onToggleDownloadAccess: (user: UserPermissionsUser, newValue: boolean) => void; + onToggleToken: (user: UserPermissionsUser, newValue: boolean) => void; } interface PermissionToggleProps { @@ -83,6 +86,78 @@ function PermissionToggle({ label, ariaLabel, value, disabled, disabledMessage, ); } +interface LoginTokenRowProps { + value: boolean; + generatedToken: string | null; + onToggle: () => void; +} + +function LoginTokenRow({ value, generatedToken, onToggle }: LoginTokenRowProps) { + const loginUrl = generatedToken + ? `${typeof window !== 'undefined' ? window.location.origin : ''}/auth/token/login?token=${generatedToken}` + : null; + + const copyUrl = async () => { + if (!loginUrl) return; + try { + await navigator.clipboard.writeText(loginUrl); + } catch { + // ignore + } + }; + + return ( +
+
+ +
+
+ Login Token +
+

+ When enabled, this user can log in via a direct URL without credentials +

+
+
+ + {loginUrl && ( +
+

+ Copy the login URL - it won't be shown again +

+
+ + {loginUrl} + + +
+
+ )} +
+ ); +} + export function UserPermissionsModal({ isOpen, onClose, @@ -90,9 +165,11 @@ export function UserPermissionsModal({ globalAutoApprove, globalInteractiveSearch, globalDownloadAccess, + generatedToken, onToggleAutoApprove, onToggleInteractiveSearch, onToggleDownloadAccess, + onToggleToken, }: UserPermissionsModalProps) { if (!user) return null; @@ -201,6 +278,13 @@ export function UserPermissionsModal({ description="When enabled, this user can download audiobook files directly" onToggle={() => onToggleDownloadAccess(user, !downloadValue)} /> + + {/* Login Token */} + onToggleToken(user, !(user.hasLoginToken || generatedToken !== null))} + />