Skip to content

Commit 7d95b2a

Browse files
authored
Merge pull request #248 from mchestr/feature/jellyfin-login-toggle
feat: add 'Enable for Login' toggle for Jellyfin
2 parents f3b2213 + 416627a commit 7d95b2a

File tree

9 files changed

+274
-11
lines changed

9 files changed

+274
-11
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Tests for actions/admin/admin-servers.ts - Jellyfin login toggle
3+
*
4+
* These tests cover:
5+
* - toggleJellyfinLogin: enable/disable Jellyfin visibility on login page
6+
* - Admin authorization
7+
* - Input validation with Zod
8+
*/
9+
10+
import { toggleJellyfinLogin } from '@/actions/admin/admin-servers'
11+
import { prisma } from '@/lib/prisma'
12+
import { getServerSession } from 'next-auth'
13+
import { revalidatePath } from 'next/cache'
14+
15+
// Mock dependencies
16+
jest.mock('@/lib/prisma', () => ({
17+
prisma: {
18+
jellyfinServer: {
19+
updateMany: jest.fn(),
20+
},
21+
},
22+
}))
23+
24+
jest.mock('next-auth', () => ({
25+
getServerSession: jest.fn(),
26+
}))
27+
28+
jest.mock('next/cache', () => ({
29+
revalidatePath: jest.fn(),
30+
}))
31+
32+
jest.mock('@/lib/auth', () => ({
33+
authOptions: {},
34+
}))
35+
36+
const mockPrisma = prisma as jest.Mocked<typeof prisma>
37+
const mockGetServerSession = getServerSession as jest.MockedFunction<typeof getServerSession>
38+
const mockRevalidatePath = revalidatePath as jest.MockedFunction<typeof revalidatePath>
39+
40+
describe('admin-servers actions', () => {
41+
const mockAdminSession = {
42+
user: { id: 'admin-123', name: 'Admin', email: '[email protected]', isAdmin: true },
43+
expires: new Date(Date.now() + 86400000).toISOString(),
44+
}
45+
46+
const mockNonAdminSession = {
47+
user: { id: 'user-123', name: 'User', email: '[email protected]', isAdmin: false },
48+
expires: new Date(Date.now() + 86400000).toISOString(),
49+
}
50+
51+
beforeEach(() => {
52+
jest.clearAllMocks()
53+
})
54+
55+
describe('toggleJellyfinLogin', () => {
56+
it('should enable Jellyfin login when passed true', async () => {
57+
mockGetServerSession.mockResolvedValue(mockAdminSession)
58+
mockPrisma.jellyfinServer.updateMany.mockResolvedValue({ count: 1 })
59+
60+
const result = await toggleJellyfinLogin(true)
61+
62+
expect(result).toEqual({ success: true })
63+
expect(mockPrisma.jellyfinServer.updateMany).toHaveBeenCalledWith({
64+
where: { isActive: true },
65+
data: { enabledForLogin: true },
66+
})
67+
expect(mockRevalidatePath).toHaveBeenCalledWith('/admin/settings')
68+
expect(mockRevalidatePath).toHaveBeenCalledWith('/')
69+
})
70+
71+
it('should disable Jellyfin login when passed false', async () => {
72+
mockGetServerSession.mockResolvedValue(mockAdminSession)
73+
mockPrisma.jellyfinServer.updateMany.mockResolvedValue({ count: 1 })
74+
75+
const result = await toggleJellyfinLogin(false)
76+
77+
expect(result).toEqual({ success: true })
78+
expect(mockPrisma.jellyfinServer.updateMany).toHaveBeenCalledWith({
79+
where: { isActive: true },
80+
data: { enabledForLogin: false },
81+
})
82+
})
83+
84+
it('should reject non-admin users', async () => {
85+
mockGetServerSession.mockResolvedValue(mockNonAdminSession)
86+
87+
await expect(toggleJellyfinLogin(true)).rejects.toThrow()
88+
})
89+
90+
it('should reject unauthenticated users', async () => {
91+
mockGetServerSession.mockResolvedValue(null)
92+
93+
await expect(toggleJellyfinLogin(true)).rejects.toThrow()
94+
})
95+
96+
it('should return error for invalid input', async () => {
97+
mockGetServerSession.mockResolvedValue(mockAdminSession)
98+
99+
// TypeScript would normally catch this, but testing runtime validation
100+
const result = await toggleJellyfinLogin('invalid' as unknown as boolean)
101+
102+
expect(result).toEqual({
103+
success: false,
104+
error: 'Invalid input: enabled must be a boolean',
105+
})
106+
expect(mockPrisma.jellyfinServer.updateMany).not.toHaveBeenCalled()
107+
})
108+
109+
it('should handle database errors gracefully', async () => {
110+
mockGetServerSession.mockResolvedValue(mockAdminSession)
111+
mockPrisma.jellyfinServer.updateMany.mockRejectedValue(new Error('Database error'))
112+
113+
const result = await toggleJellyfinLogin(true)
114+
115+
expect(result).toEqual({
116+
success: false,
117+
error: 'Database error',
118+
})
119+
})
120+
121+
it('should handle unknown errors gracefully', async () => {
122+
mockGetServerSession.mockResolvedValue(mockAdminSession)
123+
mockPrisma.jellyfinServer.updateMany.mockRejectedValue('unknown error')
124+
125+
const result = await toggleJellyfinLogin(true)
126+
127+
expect(result).toEqual({
128+
success: false,
129+
error: 'Failed to toggle Jellyfin login setting',
130+
})
131+
})
132+
})
133+
})

actions/admin/admin-servers.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { requireAdmin } from "@/lib/admin"
44
import { prisma } from "@/lib/prisma"
5+
import { z } from "zod"
56

67
/**
78
* Get Jellyfin libraries for invite creation
@@ -569,6 +570,39 @@ export async function deleteJellyfinServer() {
569570
}
570571
}
571572

573+
const toggleJellyfinLoginSchema = z.boolean()
574+
575+
/**
576+
* Toggle Jellyfin login visibility (admin only)
577+
* When disabled, Jellyfin won't appear as a login option on the home page
578+
*/
579+
export async function toggleJellyfinLogin(enabled: boolean) {
580+
await requireAdmin()
581+
582+
const validated = toggleJellyfinLoginSchema.safeParse(enabled)
583+
if (!validated.success) {
584+
return { success: false, error: "Invalid input: enabled must be a boolean" }
585+
}
586+
587+
try {
588+
const { revalidatePath } = await import("next/cache")
589+
590+
await prisma.jellyfinServer.updateMany({
591+
where: { isActive: true },
592+
data: { enabledForLogin: validated.data },
593+
})
594+
595+
revalidatePath("/admin/settings")
596+
revalidatePath("/")
597+
return { success: true }
598+
} catch (error) {
599+
if (error instanceof Error) {
600+
return { success: false, error: error.message }
601+
}
602+
return { success: false, error: "Failed to toggle Jellyfin login setting" }
603+
}
604+
}
605+
572606
/**
573607
* Delete Prometheus configuration (admin only)
574608
*/

app/(app)/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export default async function Home() {
2929
where: { isActive: true },
3030
}),
3131
prisma.jellyfinServer.findFirst({
32-
where: { isActive: true },
32+
where: { isActive: true, enabledForLogin: true },
3333
}),
3434
prisma.discordIntegration.findUnique({ where: { id: "discord" } }),
3535
getActiveAnnouncements(),

app/admin/settings/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getAdminSettings } from "@/actions/admin"
2-
import { DiscordIntegrationForm, LLMProviderForm, LLMToggle, ServerForm } from "@/components/admin/settings/settings-edit-forms"
2+
import { DiscordIntegrationForm, JellyfinLoginToggle, LLMProviderForm, LLMToggle, ServerForm } from "@/components/admin/settings/settings-edit-forms"
33
import { WatchlistSyncSettings } from "@/components/admin/settings/watchlist-sync-settings"
44
import { WrappedSettingsForm } from "@/components/admin/settings/wrapped-settings-form"
55
import { getBaseUrl } from "@/lib/utils"
@@ -252,6 +252,11 @@ export default async function SettingsPage() {
252252
/>
253253
</div>
254254
<ServerForm type="jellyfin" server={settings.jellyfinServer} />
255+
{settings.jellyfinServer && (
256+
<JellyfinLoginToggle
257+
enabledForLogin={settings.jellyfinServer.enabledForLogin}
258+
/>
259+
)}
255260
</div>
256261

257262
{/* Tautulli */}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client"
2+
3+
import { toggleJellyfinLogin } from "@/actions/admin/admin-servers"
4+
import { useToast } from "@/components/ui/toast"
5+
import { useRouter } from "next/navigation"
6+
import { useTransition } from "react"
7+
8+
interface JellyfinLoginToggleProps {
9+
enabledForLogin: boolean
10+
}
11+
12+
export function JellyfinLoginToggle({ enabledForLogin }: JellyfinLoginToggleProps) {
13+
const [isPending, startTransition] = useTransition()
14+
const router = useRouter()
15+
const toast = useToast()
16+
17+
const handleToggle = () => {
18+
startTransition(async () => {
19+
const result = await toggleJellyfinLogin(!enabledForLogin)
20+
if (result.success) {
21+
toast.showSuccess(
22+
`Jellyfin login ${!enabledForLogin ? "enabled" : "disabled"} successfully`
23+
)
24+
router.refresh()
25+
} else {
26+
toast.showError(result.error || "Failed to update Jellyfin login setting")
27+
}
28+
})
29+
}
30+
31+
return (
32+
<div className="mt-4 pt-4 border-t border-slate-700">
33+
<div className="flex items-center justify-between">
34+
<div>
35+
<h4 className="text-xs font-medium text-slate-300">Login Settings</h4>
36+
<p className="text-xs text-slate-500 mt-0.5">
37+
{enabledForLogin
38+
? "Jellyfin is visible on the login page"
39+
: "Jellyfin is hidden from the login page"}
40+
</p>
41+
</div>
42+
<button
43+
onClick={handleToggle}
44+
disabled={isPending}
45+
data-testid="jellyfin-login-toggle"
46+
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"
47+
>
48+
{isPending
49+
? "Updating..."
50+
: enabledForLogin
51+
? "Hide from Login"
52+
: "Show on Login"}
53+
</button>
54+
</div>
55+
</div>
56+
)
57+
}

components/admin/settings/settings-edit-forms.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
* - LLMProviderForm.tsx
66
* - ServerForm.tsx
77
* - LLMToggle.tsx
8+
* - JellyfinLoginToggle.tsx
89
* - DiscordIntegrationForm.tsx
910
*/
1011

1112
export { LLMProviderForm } from "./LLMProviderForm"
1213
export { ServerForm } from "./ServerForm"
1314
export { LLMToggle } from "./LLMToggle"
15+
export { JellyfinLoginToggle } from "./JellyfinLoginToggle"
1416
export { DiscordIntegrationForm } from "./DiscordIntegrationForm"

e2e/admin-functionality.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,33 @@ test.describe('Admin Functionality', () => {
8080
// Wait for page content to be visible (not just accessible)
8181
await expect(adminPage.locator('main')).toBeVisible({ timeout: WAIT_TIMEOUTS.ADMIN_CONTENT });
8282
});
83+
84+
test('should toggle Jellyfin login visibility in settings', async ({ adminPage }) => {
85+
// Navigate to settings
86+
await adminPage.locator('aside').getByTestId('admin-nav-settings').first().click();
87+
await waitForAdminContent(adminPage, [
88+
{ type: 'heading', value: 'Settings' }
89+
], { timeout: WAIT_TIMEOUTS.EXTENDED_OPERATION });
90+
91+
// Look for the Jellyfin login toggle - it should only be visible if Jellyfin is configured
92+
const jellyfinToggle = adminPage.getByTestId('jellyfin-login-toggle');
93+
94+
// If Jellyfin is configured, test the toggle functionality
95+
if (await jellyfinToggle.isVisible({ timeout: 5000 }).catch(() => false)) {
96+
// Get current button text
97+
const buttonText = await jellyfinToggle.textContent();
98+
99+
// Click the toggle
100+
await jellyfinToggle.click();
101+
102+
// Wait for the toggle to update (button text should change)
103+
await expect(jellyfinToggle).not.toHaveText(buttonText!, { timeout: WAIT_TIMEOUTS.EXTENDED_OPERATION });
104+
105+
// Click again to restore original state
106+
await jellyfinToggle.click();
107+
108+
// Verify it changed back
109+
await expect(jellyfinToggle).toHaveText(buttonText!, { timeout: WAIT_TIMEOUTS.EXTENDED_OPERATION });
110+
}
111+
});
83112
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "JellyfinServer" ADD COLUMN "enabledForLogin" BOOLEAN NOT NULL DEFAULT true;

prisma/schema.prisma

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,16 @@ model PlexServer {
6868
}
6969

7070
model JellyfinServer {
71-
id String @id @default(cuid())
72-
name String
73-
url String // Full URL including protocol, hostname, and port (e.g. http://jellyfin.example.com:8096)
74-
publicUrl String? // Public facing URL (e.g. https://jellyfin.example.com)
75-
apiKey String // Jellyfin API key for admin operations
76-
adminUserId String? // Jellyfin user ID of the admin who configured this server
77-
isActive Boolean @default(true)
78-
createdAt DateTime @default(now())
79-
updatedAt DateTime @updatedAt
71+
id String @id @default(cuid())
72+
name String
73+
url String // Full URL including protocol, hostname, and port (e.g. http://jellyfin.example.com:8096)
74+
publicUrl String? // Public facing URL (e.g. https://jellyfin.example.com)
75+
apiKey String // Jellyfin API key for admin operations
76+
adminUserId String? // Jellyfin user ID of the admin who configured this server
77+
isActive Boolean @default(true)
78+
enabledForLogin Boolean @default(true) // When false, hides Jellyfin from the login page
79+
createdAt DateTime @default(now())
80+
updatedAt DateTime @updatedAt
8081
8182
@@index([isActive])
8283
@@index([adminUserId])

0 commit comments

Comments
 (0)