Skip to content

Commit 93679e8

Browse files
authored
Merge pull request #236 from mchestr/feature/jellyfin-authentication
Add Jellyfin authentication and multi-service support
2 parents 0a45f16 + 238ef82 commit 93679e8

File tree

22 files changed

+1391
-89
lines changed

22 files changed

+1391
-89
lines changed

__tests__/actions/onboarding.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ describe('Onboarding Actions', () => {
7575
describe('getOnboardingStatus', () => {
7676
it('should return status when authenticated', async () => {
7777
mockGetServerSession.mockResolvedValue({ user: { id: 'user-1' } } as any)
78-
mockPrisma.user.findUnique.mockResolvedValue({ onboardingCompleted: false } as any)
78+
mockPrisma.user.findUnique.mockResolvedValue({
79+
onboardingStatus: { plex: false, jellyfin: false },
80+
primaryAuthService: 'plex'
81+
} as any)
7982

8083
const result = await getOnboardingStatus()
8184

@@ -92,22 +95,55 @@ describe('Onboarding Actions', () => {
9295
})
9396

9497
describe('completeOnboarding', () => {
95-
it('should mark user as complete', async () => {
98+
it('should mark user as complete for plex service', async () => {
99+
mockGetServerSession.mockResolvedValue({ user: { id: 'user-1' } } as any)
100+
mockPrisma.user.findUnique.mockResolvedValue({
101+
onboardingStatus: { plex: false, jellyfin: false }
102+
} as any)
103+
104+
const result = await completeOnboarding('plex')
105+
106+
expect(result.success).toBe(true)
107+
expect(mockPrisma.user.update).toHaveBeenCalledWith({
108+
where: { id: 'user-1' },
109+
data: { onboardingStatus: { plex: true, jellyfin: false } },
110+
})
111+
})
112+
113+
it('should mark user as complete for jellyfin service', async () => {
96114
mockGetServerSession.mockResolvedValue({ user: { id: 'user-1' } } as any)
115+
mockPrisma.user.findUnique.mockResolvedValue({
116+
onboardingStatus: { plex: false, jellyfin: false }
117+
} as any)
118+
119+
const result = await completeOnboarding('jellyfin')
120+
121+
expect(result.success).toBe(true)
122+
expect(mockPrisma.user.update).toHaveBeenCalledWith({
123+
where: { id: 'user-1' },
124+
data: { onboardingStatus: { plex: false, jellyfin: true } },
125+
})
126+
})
127+
128+
it('should preserve existing service completion status', async () => {
129+
mockGetServerSession.mockResolvedValue({ user: { id: 'user-1' } } as any)
130+
mockPrisma.user.findUnique.mockResolvedValue({
131+
onboardingStatus: { plex: true, jellyfin: false }
132+
} as any)
97133

98-
const result = await completeOnboarding()
134+
const result = await completeOnboarding('jellyfin')
99135

100136
expect(result.success).toBe(true)
101137
expect(mockPrisma.user.update).toHaveBeenCalledWith({
102138
where: { id: 'user-1' },
103-
data: { onboardingCompleted: true },
139+
data: { onboardingStatus: { plex: true, jellyfin: true } },
104140
})
105141
})
106142

107143
it('should fail if unauthenticated', async () => {
108144
mockGetServerSession.mockResolvedValue(null)
109145

110-
const result = await completeOnboarding()
146+
const result = await completeOnboarding('plex')
111147

112148
expect(result.success).toBe(false)
113149
expect(mockPrisma.user.update).not.toHaveBeenCalled()

actions/onboarding.ts

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,102 @@
33
import { authOptions } from "@/lib/auth"
44
import { prisma } from "@/lib/prisma"
55
import { createLogger } from "@/lib/utils/logger"
6+
import type { AuthService } from "@/types/onboarding"
67
import { getServerSession } from "next-auth"
8+
import { z } from "zod"
79

810
const logger = createLogger("ONBOARDING")
911

1012
/**
11-
* Check if the current user has completed onboarding
13+
* Zod schema for onboarding status
14+
* Validates the onboardingStatus JSON field from the database
1215
*/
13-
export async function getOnboardingStatus() {
16+
const OnboardingStatusSchema = z.object({
17+
plex: z.boolean(),
18+
jellyfin: z.boolean(),
19+
})
20+
21+
interface OnboardingStatusRecord {
22+
plex: boolean
23+
jellyfin: boolean
24+
}
25+
26+
/**
27+
* Check if the current user has completed onboarding for a specific service
28+
* @param service - The auth service ("plex" or "jellyfin"). If not specified, uses primaryAuthService
29+
*/
30+
export async function getOnboardingStatus(service?: AuthService) {
1431
try {
1532
const session = await getServerSession(authOptions)
1633
if (!session?.user?.id) {
17-
return { isComplete: true } // If not logged in, assume complete to avoid blocking
34+
return { isComplete: true, service: service || "plex" } // If not logged in, assume complete to avoid blocking
1835
}
1936

2037
const user = await prisma.user.findUnique({
2138
where: { id: session.user.id },
22-
select: { onboardingCompleted: true },
39+
select: {
40+
onboardingStatus: true,
41+
primaryAuthService: true,
42+
},
2343
})
2444

45+
const validation = OnboardingStatusSchema.safeParse(user?.onboardingStatus)
46+
const status: OnboardingStatusRecord = validation.success
47+
? validation.data
48+
: { plex: false, jellyfin: false }
49+
50+
// If no service specified, use primary auth service
51+
const targetService: AuthService = service || (user?.primaryAuthService as AuthService) || "plex"
52+
2553
return {
26-
isComplete: user?.onboardingCompleted ?? false,
54+
isComplete: status[targetService] || false,
55+
service: targetService,
56+
allStatuses: status,
2757
}
2858
} catch (error) {
29-
logger.error("Error checking onboarding status", error)
30-
return { isComplete: true } // On error, assume complete to avoid blocking
59+
logger.error("Error checking onboarding status", error, { service })
60+
return { isComplete: true, service: service || "plex" } // On error, assume complete to avoid blocking
3161
}
3262
}
3363

3464
/**
35-
* Mark onboarding as complete for the current user
65+
* Mark onboarding as complete for a specific service
66+
* @param service - The auth service ("plex" or "jellyfin")
3667
*/
37-
export async function completeOnboarding() {
68+
export async function completeOnboarding(service: AuthService) {
3869
try {
3970
const session = await getServerSession(authOptions)
4071
if (!session?.user?.id) {
4172
return { success: false, error: "Not authenticated" }
4273
}
4374

75+
// Get current onboarding status
76+
const user = await prisma.user.findUnique({
77+
where: { id: session.user.id },
78+
select: { onboardingStatus: true },
79+
})
80+
81+
const validation = OnboardingStatusSchema.safeParse(user?.onboardingStatus)
82+
const currentStatus: OnboardingStatusRecord = validation.success
83+
? validation.data
84+
: { plex: false, jellyfin: false }
85+
86+
// Update the status for the specified service
87+
const updatedStatus = {
88+
...currentStatus,
89+
[service]: true,
90+
}
91+
4492
await prisma.user.update({
4593
where: { id: session.user.id },
46-
data: { onboardingCompleted: true },
94+
data: { onboardingStatus: updatedStatus },
4795
})
4896

97+
logger.info("Onboarding completed", { userId: session.user.id, service })
98+
4999
return { success: true }
50100
} catch (error) {
51-
logger.error("Error completing onboarding", error)
101+
logger.error("Error completing onboarding", error, { service })
52102
return { success: false, error: "Failed to complete onboarding" }
53103
}
54104
}

app/(app)/page.tsx

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,78 @@
11
import { getActiveAnnouncements } from "@/actions/announcements";
22
import { getPrometheusStatus } from "@/actions/prometheus-status";
33
import { getUserFirstWatchDate } from "@/actions/users";
4-
import { PlexSignInButton } from "@/components/auth/plex-sign-in-button";
4+
import { ServiceSignInToggle } from "@/components/auth/service-sign-in-toggle";
55
import { UserDashboard } from "@/components/dashboard/user-dashboard";
66
import { authOptions } from "@/lib/auth";
77
import { prisma } from "@/lib/prisma";
8+
import type { AuthService } from "@/types/onboarding";
89
import { getServerSession } from "next-auth";
910
import { redirect } from "next/navigation";
11+
import { z } from "zod";
1012

1113
export const dynamic = 'force-dynamic'
1214

15+
const OnboardingStatusSchema = z.object({
16+
plex: z.boolean(),
17+
jellyfin: z.boolean(),
18+
})
19+
20+
interface OnboardingStatusRecord {
21+
plex: boolean
22+
jellyfin: boolean
23+
}
24+
1325
export default async function Home() {
1426
const session = await getServerSession(authOptions);
15-
const [plexServer, discordIntegration, announcements, overseerr, prometheusStatus] = await Promise.all([
27+
const [plexServer, jellyfinServer, discordIntegration, announcements, overseerr, prometheusStatus] = await Promise.all([
1628
prisma.plexServer.findFirst({
1729
where: { isActive: true },
1830
}),
31+
prisma.jellyfinServer.findFirst({
32+
where: { isActive: true },
33+
}),
1934
prisma.discordIntegration.findUnique({ where: { id: "discord" } }),
2035
getActiveAnnouncements(),
2136
prisma.overseerr.findFirst({ where: { isActive: true } }),
2237
getPrometheusStatus(),
2338
]);
24-
const serverName = plexServer?.name || "Plex";
39+
40+
// Determine server name based on what's configured
41+
const serverName = plexServer?.name || jellyfinServer?.name || "Media Server";
2542
const discordEnabled = Boolean(discordIntegration?.isEnabled && discordIntegration?.clientId && discordIntegration?.clientSecret);
2643
const overseerrUrl = overseerr?.publicUrl || overseerr?.url || null;
2744

2845
// Handle redirect logic for authenticated users
2946
if (session?.user?.id) {
3047
const userPromise = prisma.user.findUnique({
3148
where: { id: session.user.id },
32-
select: { onboardingCompleted: true, createdAt: true, plexUserId: true, email: true }
49+
select: {
50+
onboardingStatus: true,
51+
primaryAuthService: true,
52+
createdAt: true,
53+
plexUserId: true,
54+
jellyfinUserId: true,
55+
email: true,
56+
}
3357
});
3458
const discordConnectionPromise = discordEnabled
3559
? prisma.discordConnection.findUnique({ where: { userId: session.user.id } })
3660
: Promise.resolve(null);
3761

3862
const [user, discordConnection] = await Promise.all([userPromise, discordConnectionPromise]);
3963

40-
if (user && !user.onboardingCompleted) {
41-
redirect("/onboarding");
64+
// Check service-specific onboarding completion
65+
if (user) {
66+
const validation = OnboardingStatusSchema.safeParse(user.onboardingStatus)
67+
const status: OnboardingStatusRecord = validation.success
68+
? validation.data
69+
: { plex: false, jellyfin: false };
70+
const primaryService: AuthService = (user.primaryAuthService as AuthService) || "plex";
71+
72+
// Redirect to onboarding if primary service onboarding not complete
73+
if (!status[primaryService]) {
74+
redirect("/onboarding");
75+
}
4276
}
4377

4478
// Get first watch date from Tautulli for accurate membership duration
@@ -61,6 +95,11 @@ export default async function Home() {
6195
}
6296
: null;
6397

98+
// Determine media server URL based on primary auth service
99+
const mediaServerUrl = user?.primaryAuthService === "jellyfin"
100+
? jellyfinServer?.url
101+
: plexServer?.url;
102+
64103
return (
65104
<UserDashboard
66105
userId={session.user.id}
@@ -73,24 +112,41 @@ export default async function Home() {
73112
overseerrUrl={overseerrUrl}
74113
prometheusStatus={prometheusStatus}
75114
memberSince={memberSince}
115+
primaryAuthService={user?.primaryAuthService}
116+
mediaServerUrl={mediaServerUrl}
76117
/>
77118
);
78119
}
79120

121+
// Unauthenticated: Show sign-in options
122+
const hasPlex = !!plexServer;
123+
const hasJellyfin = !!jellyfinServer;
124+
125+
if (!hasPlex && !hasJellyfin) {
126+
return (
127+
<main className="fixed inset-0 flex flex-col items-center justify-center bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 px-4">
128+
<div className="flex flex-col items-center gap-6 sm:gap-8 text-center">
129+
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold bg-gradient-to-r from-cyan-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
130+
Media Server
131+
</h1>
132+
<p className="text-slate-400 text-lg">No media servers configured. Please contact your administrator.</p>
133+
</div>
134+
</main>
135+
);
136+
}
137+
80138
return (
81139
<main className="fixed inset-0 flex flex-col items-center justify-center bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 px-4">
82-
<div className="flex flex-col items-center gap-6 sm:gap-8">
140+
<div className="flex flex-col items-center gap-6 sm:gap-8 w-full max-w-md">
83141
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold bg-gradient-to-r from-cyan-400 via-purple-400 to-pink-400 bg-clip-text text-transparent text-center">
84142
{serverName}
85143
</h1>
86-
<PlexSignInButton
87-
serverName={serverName}
88-
showWarning={true}
89-
warningDelay={3000}
90-
showDisclaimer={false}
91-
buttonText="Sign in with Plex"
92-
loadingText="Signing in..."
93-
buttonClassName="px-6 sm:px-8 py-3 sm:py-4 flex justify-center items-center gap-3 text-white text-base sm:text-lg font-semibold rounded-xl bg-gradient-to-r from-cyan-600 via-purple-600 to-pink-600 hover:from-cyan-500 hover:via-purple-500 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg"
144+
145+
<ServiceSignInToggle
146+
hasPlex={hasPlex}
147+
hasJellyfin={hasJellyfin}
148+
plexServerName={plexServer?.name}
149+
jellyfinServerName={jellyfinServer?.name}
94150
/>
95151
</div>
96152
</main>

app/onboarding/page.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard"
2+
import { authOptions } from "@/lib/auth"
3+
import { prisma } from "@/lib/prisma"
4+
import type { AuthService } from "@/types/onboarding"
5+
import { getServerSession } from "next-auth"
6+
import { redirect } from "next/navigation"
27

38
export default async function OnboardingPage() {
4-
return <OnboardingWizard currentStep={1} />
9+
const session = await getServerSession(authOptions)
10+
11+
if (!session?.user?.id) {
12+
redirect("/")
13+
}
14+
15+
const user = await prisma.user.findUnique({
16+
where: { id: session.user.id },
17+
select: { primaryAuthService: true },
18+
})
19+
20+
const service: AuthService = (user?.primaryAuthService as AuthService) || "plex"
21+
22+
return <OnboardingWizard currentStep={1} service={service} />
523
}
624

0 commit comments

Comments
 (0)