diff --git a/actions/__tests__/admin-servers.test.ts b/actions/__tests__/admin-servers.test.ts new file mode 100644 index 0000000..bcb14fb --- /dev/null +++ b/actions/__tests__/admin-servers.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for actions/admin/admin-servers.ts - Jellyfin login toggle + * + * These tests cover: + * - toggleJellyfinLogin: enable/disable Jellyfin visibility on login page + * - Admin authorization + * - Input validation with Zod + */ + +import { toggleJellyfinLogin } from '@/actions/admin/admin-servers' +import { prisma } from '@/lib/prisma' +import { getServerSession } from 'next-auth' +import { revalidatePath } from 'next/cache' + +// Mock dependencies +jest.mock('@/lib/prisma', () => ({ + prisma: { + jellyfinServer: { + updateMany: jest.fn(), + }, + }, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('next/cache', () => ({ + revalidatePath: jest.fn(), +})) + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +const mockPrisma = prisma as jest.Mocked +const mockGetServerSession = getServerSession as jest.MockedFunction +const mockRevalidatePath = revalidatePath as jest.MockedFunction + +describe('admin-servers actions', () => { + const mockAdminSession = { + user: { id: 'admin-123', name: 'Admin', email: 'admin@test.com', isAdmin: true }, + expires: new Date(Date.now() + 86400000).toISOString(), + } + + const mockNonAdminSession = { + user: { id: 'user-123', name: 'User', email: 'user@test.com', isAdmin: false }, + expires: new Date(Date.now() + 86400000).toISOString(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('toggleJellyfinLogin', () => { + it('should enable Jellyfin login when passed true', async () => { + mockGetServerSession.mockResolvedValue(mockAdminSession) + mockPrisma.jellyfinServer.updateMany.mockResolvedValue({ count: 1 }) + + const result = await toggleJellyfinLogin(true) + + expect(result).toEqual({ success: true }) + expect(mockPrisma.jellyfinServer.updateMany).toHaveBeenCalledWith({ + where: { isActive: true }, + data: { enabledForLogin: true }, + }) + expect(mockRevalidatePath).toHaveBeenCalledWith('/admin/settings') + expect(mockRevalidatePath).toHaveBeenCalledWith('/') + }) + + it('should disable Jellyfin login when passed false', async () => { + mockGetServerSession.mockResolvedValue(mockAdminSession) + mockPrisma.jellyfinServer.updateMany.mockResolvedValue({ count: 1 }) + + const result = await toggleJellyfinLogin(false) + + expect(result).toEqual({ success: true }) + expect(mockPrisma.jellyfinServer.updateMany).toHaveBeenCalledWith({ + where: { isActive: true }, + data: { enabledForLogin: false }, + }) + }) + + it('should reject non-admin users', async () => { + mockGetServerSession.mockResolvedValue(mockNonAdminSession) + + await expect(toggleJellyfinLogin(true)).rejects.toThrow() + }) + + it('should reject unauthenticated users', async () => { + mockGetServerSession.mockResolvedValue(null) + + await expect(toggleJellyfinLogin(true)).rejects.toThrow() + }) + + it('should return error for invalid input', async () => { + mockGetServerSession.mockResolvedValue(mockAdminSession) + + // TypeScript would normally catch this, but testing runtime validation + const result = await toggleJellyfinLogin('invalid' as unknown as boolean) + + expect(result).toEqual({ + success: false, + error: 'Invalid input: enabled must be a boolean', + }) + expect(mockPrisma.jellyfinServer.updateMany).not.toHaveBeenCalled() + }) + + it('should handle database errors gracefully', async () => { + mockGetServerSession.mockResolvedValue(mockAdminSession) + mockPrisma.jellyfinServer.updateMany.mockRejectedValue(new Error('Database error')) + + const result = await toggleJellyfinLogin(true) + + expect(result).toEqual({ + success: false, + error: 'Database error', + }) + }) + + it('should handle unknown errors gracefully', async () => { + mockGetServerSession.mockResolvedValue(mockAdminSession) + mockPrisma.jellyfinServer.updateMany.mockRejectedValue('unknown error') + + const result = await toggleJellyfinLogin(true) + + expect(result).toEqual({ + success: false, + error: 'Failed to toggle Jellyfin login setting', + }) + }) + }) +}) diff --git a/actions/admin/admin-servers.ts b/actions/admin/admin-servers.ts index 911c1f8..d7534c8 100644 --- a/actions/admin/admin-servers.ts +++ b/actions/admin/admin-servers.ts @@ -2,6 +2,7 @@ import { requireAdmin } from "@/lib/admin" import { prisma } from "@/lib/prisma" +import { z } from "zod" /** * Get Jellyfin libraries for invite creation @@ -569,6 +570,39 @@ export async function deleteJellyfinServer() { } } +const toggleJellyfinLoginSchema = z.boolean() + +/** + * Toggle Jellyfin login visibility (admin only) + * When disabled, Jellyfin won't appear as a login option on the home page + */ +export async function toggleJellyfinLogin(enabled: boolean) { + await requireAdmin() + + const validated = toggleJellyfinLoginSchema.safeParse(enabled) + if (!validated.success) { + return { success: false, error: "Invalid input: enabled must be a boolean" } + } + + try { + const { revalidatePath } = await import("next/cache") + + await prisma.jellyfinServer.updateMany({ + where: { isActive: true }, + data: { enabledForLogin: validated.data }, + }) + + revalidatePath("/admin/settings") + revalidatePath("/") + return { success: true } + } catch (error) { + if (error instanceof Error) { + return { success: false, error: error.message } + } + return { success: false, error: "Failed to toggle Jellyfin login setting" } + } +} + /** * Delete Prometheus configuration (admin only) */ diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index d64d72a..2d41619 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -29,7 +29,7 @@ export default async function Home() { where: { isActive: true }, }), prisma.jellyfinServer.findFirst({ - where: { isActive: true }, + where: { isActive: true, enabledForLogin: true }, }), prisma.discordIntegration.findUnique({ where: { id: "discord" } }), getActiveAnnouncements(), diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index ec4243a..78eea75 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -1,5 +1,5 @@ import { getAdminSettings } from "@/actions/admin" -import { DiscordIntegrationForm, LLMProviderForm, LLMToggle, ServerForm } from "@/components/admin/settings/settings-edit-forms" +import { DiscordIntegrationForm, JellyfinLoginToggle, LLMProviderForm, LLMToggle, ServerForm } from "@/components/admin/settings/settings-edit-forms" import { WatchlistSyncSettings } from "@/components/admin/settings/watchlist-sync-settings" import { WrappedSettingsForm } from "@/components/admin/settings/wrapped-settings-form" import { getBaseUrl } from "@/lib/utils" @@ -252,6 +252,11 @@ export default async function SettingsPage() { /> + {settings.jellyfinServer && ( + + )} {/* Tautulli */} diff --git a/components/admin/settings/JellyfinLoginToggle.tsx b/components/admin/settings/JellyfinLoginToggle.tsx new file mode 100644 index 0000000..74c3857 --- /dev/null +++ b/components/admin/settings/JellyfinLoginToggle.tsx @@ -0,0 +1,57 @@ +"use client" + +import { toggleJellyfinLogin } from "@/actions/admin/admin-servers" +import { useToast } from "@/components/ui/toast" +import { useRouter } from "next/navigation" +import { useTransition } from "react" + +interface JellyfinLoginToggleProps { + enabledForLogin: boolean +} + +export function JellyfinLoginToggle({ enabledForLogin }: JellyfinLoginToggleProps) { + const [isPending, startTransition] = useTransition() + const router = useRouter() + const toast = useToast() + + const handleToggle = () => { + startTransition(async () => { + const result = await toggleJellyfinLogin(!enabledForLogin) + if (result.success) { + toast.showSuccess( + `Jellyfin login ${!enabledForLogin ? "enabled" : "disabled"} successfully` + ) + router.refresh() + } else { + toast.showError(result.error || "Failed to update Jellyfin login setting") + } + }) + } + + return ( +
+
+
+

Login Settings

+

+ {enabledForLogin + ? "Jellyfin is visible on the login page" + : "Jellyfin is hidden from the login page"} +

+
+ +
+
+ ) +} diff --git a/components/admin/settings/settings-edit-forms.tsx b/components/admin/settings/settings-edit-forms.tsx index 8f704f0..6636b43 100644 --- a/components/admin/settings/settings-edit-forms.tsx +++ b/components/admin/settings/settings-edit-forms.tsx @@ -5,10 +5,12 @@ * - LLMProviderForm.tsx * - ServerForm.tsx * - LLMToggle.tsx + * - JellyfinLoginToggle.tsx * - DiscordIntegrationForm.tsx */ export { LLMProviderForm } from "./LLMProviderForm" export { ServerForm } from "./ServerForm" export { LLMToggle } from "./LLMToggle" +export { JellyfinLoginToggle } from "./JellyfinLoginToggle" export { DiscordIntegrationForm } from "./DiscordIntegrationForm" diff --git a/e2e/admin-functionality.spec.ts b/e2e/admin-functionality.spec.ts index 20334d9..dea82d5 100644 --- a/e2e/admin-functionality.spec.ts +++ b/e2e/admin-functionality.spec.ts @@ -80,4 +80,33 @@ test.describe('Admin Functionality', () => { // Wait for page content to be visible (not just accessible) await expect(adminPage.locator('main')).toBeVisible({ timeout: WAIT_TIMEOUTS.ADMIN_CONTENT }); }); + + test('should toggle Jellyfin login visibility in settings', async ({ adminPage }) => { + // Navigate to settings + await adminPage.locator('aside').getByTestId('admin-nav-settings').first().click(); + await waitForAdminContent(adminPage, [ + { type: 'heading', value: 'Settings' } + ], { timeout: WAIT_TIMEOUTS.EXTENDED_OPERATION }); + + // Look for the Jellyfin login toggle - it should only be visible if Jellyfin is configured + const jellyfinToggle = adminPage.getByTestId('jellyfin-login-toggle'); + + // If Jellyfin is configured, test the toggle functionality + if (await jellyfinToggle.isVisible({ timeout: 5000 }).catch(() => false)) { + // Get current button text + const buttonText = await jellyfinToggle.textContent(); + + // Click the toggle + await jellyfinToggle.click(); + + // Wait for the toggle to update (button text should change) + await expect(jellyfinToggle).not.toHaveText(buttonText!, { timeout: WAIT_TIMEOUTS.EXTENDED_OPERATION }); + + // Click again to restore original state + await jellyfinToggle.click(); + + // Verify it changed back + await expect(jellyfinToggle).toHaveText(buttonText!, { timeout: WAIT_TIMEOUTS.EXTENDED_OPERATION }); + } + }); }); diff --git a/prisma/migrations/20251229000000_add_jellyfin_enabled_for_login/migration.sql b/prisma/migrations/20251229000000_add_jellyfin_enabled_for_login/migration.sql new file mode 100644 index 0000000..42447dd --- /dev/null +++ b/prisma/migrations/20251229000000_add_jellyfin_enabled_for_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "JellyfinServer" ADD COLUMN "enabledForLogin" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7b7b062..cdb37c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,15 +68,16 @@ model PlexServer { } model JellyfinServer { - id String @id @default(cuid()) - name String - url String // Full URL including protocol, hostname, and port (e.g. http://jellyfin.example.com:8096) - publicUrl String? // Public facing URL (e.g. https://jellyfin.example.com) - apiKey String // Jellyfin API key for admin operations - adminUserId String? // Jellyfin user ID of the admin who configured this server - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + url String // Full URL including protocol, hostname, and port (e.g. http://jellyfin.example.com:8096) + publicUrl String? // Public facing URL (e.g. https://jellyfin.example.com) + apiKey String // Jellyfin API key for admin operations + adminUserId String? // Jellyfin user ID of the admin who configured this server + isActive Boolean @default(true) + enabledForLogin Boolean @default(true) // When false, hides Jellyfin from the login page + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([isActive]) @@index([adminUserId])