Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions actions/__tests__/admin-servers.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof prisma>
const mockGetServerSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockRevalidatePath = revalidatePath as jest.MockedFunction<typeof revalidatePath>

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',
})
})
})
})
34 changes: 34 additions & 0 deletions actions/admin/admin-servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { requireAdmin } from "@/lib/admin"
import { prisma } from "@/lib/prisma"
import { z } from "zod"

/**
* Get Jellyfin libraries for invite creation
Expand Down Expand Up @@ -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)
*/
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
7 changes: 6 additions & 1 deletion app/admin/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -252,6 +252,11 @@ export default async function SettingsPage() {
/>
</div>
<ServerForm type="jellyfin" server={settings.jellyfinServer} />
{settings.jellyfinServer && (
<JellyfinLoginToggle
enabledForLogin={settings.jellyfinServer.enabledForLogin}
/>
)}
</div>

{/* Tautulli */}
Expand Down
57 changes: 57 additions & 0 deletions components/admin/settings/JellyfinLoginToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-4 pt-4 border-t border-slate-700">
<div className="flex items-center justify-between">
<div>
<h4 className="text-xs font-medium text-slate-300">Login Settings</h4>
<p className="text-xs text-slate-500 mt-0.5">
{enabledForLogin
? "Jellyfin is visible on the login page"
: "Jellyfin is hidden from the login page"}
</p>
</div>
<button
onClick={handleToggle}
disabled={isPending}
data-testid="jellyfin-login-toggle"
className="px-3 py-1 bg-slate-800/50 hover:bg-slate-700/50 border border-slate-600 hover:border-cyan-500/50 text-slate-300 hover:text-white text-xs font-medium rounded transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center whitespace-nowrap"
>
{isPending
? "Updating..."
: enabledForLogin
? "Hide from Login"
: "Show on Login"}
</button>
</div>
</div>
)
}
2 changes: 2 additions & 0 deletions components/admin/settings/settings-edit-forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
29 changes: 29 additions & 0 deletions e2e/admin-functionality.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "JellyfinServer" ADD COLUMN "enabledForLogin" BOOLEAN NOT NULL DEFAULT true;
19 changes: 10 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down