From 29128a71e4faad15afd553693496c311b6782bdc Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:38:08 +0800 Subject: [PATCH] fix(login): hide quick connect button for emby servers Quick Connect isn't supported by Emby, so the button was gated to only render for Jellyfin, matching what the backend can actually service. fix #3364 --- server/routes/auth.test.ts | 53 ++++--- server/routes/auth.ts | 30 +++- server/routes/user/usersettings.test.ts | 134 ++++++++++++++++++ server/routes/user/usersettings.ts | 11 +- src/components/Login/JellyfinLogin.tsx | 24 ++-- .../LinkJellyfinModal.tsx | 31 ++-- 6 files changed, 222 insertions(+), 61 deletions(-) create mode 100644 server/routes/user/usersettings.test.ts diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 5ae5ef59d5..399306d6c4 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -147,6 +147,15 @@ describe('POST /auth/jellyfin/quickconnect/initiate', () => { assert.strictEqual(initiateQCMock.mock.callCount(), 1); }); + it('returns 403 when the media server is Emby', async () => { + getSettings().main.mediaServerType = MediaServerType.EMBY; + + const res = await request(app).post('/auth/jellyfin/quickconnect/initiate'); + + assert.strictEqual(res.status, 403); + assert.strictEqual(initiateQCMock.mock.callCount(), 0); + }); + it('returns 500 when Jellyfin API fails', async () => { initiateQCMock.mock.mockImplementation(async () => { throw new Error('Connection refused'); @@ -239,6 +248,17 @@ describe('GET /auth/jellyfin/quickconnect/check', () => { assert.strictEqual(checkQCMock.mock.callCount(), 0); }); + it('returns 403 when the media server is Emby', async () => { + getSettings().main.mediaServerType = MediaServerType.EMBY; + + const res = await request(app) + .get('/auth/jellyfin/quickconnect/check') + .query({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(checkQCMock.mock.callCount(), 0); + }); + it('returns error when Jellyfin API fails', async () => { checkQCMock.mock.mockImplementation(async () => { throw new ApiError(500, ApiErrorCode.Unknown); @@ -396,38 +416,15 @@ describe('POST /auth/jellyfin/quickconnect/authenticate', () => { assert.strictEqual(meRes.status, 200); }); - it('sets userType to EMBY when media server is Emby', async () => { - const settings = getSettings(); - settings.main.mediaServerType = MediaServerType.EMBY; - settings.main.newPlexLogin = true; - - authenticateQCMock.mock.mockImplementation(async () => ({ - User: { - Id: 'emby-new-user', - Name: 'embyuser', - ServerId: 'server-1', - Policy: { IsAdministrator: false }, - }, - AccessToken: 'emby-token', - })); + it('returns 403 when the media server is Emby', async () => { + getSettings().main.mediaServerType = MediaServerType.EMBY; - const agent = request.agent(app); - const res = await agent + const res = await request(app) .post('/auth/jellyfin/quickconnect/authenticate') .send({ secret: 'abc123def456abc123def456' }); - assert.strictEqual(res.status, 200); - - const meRes = await agent.get('/auth/me'); - assert.strictEqual(meRes.status, 200); - assert.strictEqual(meRes.body.jellyfinUsername, 'embyuser'); - - const userRepo = getRepository(User); - const user = await userRepo.findOne({ - where: { jellyfinUserId: 'emby-new-user' }, - }); - assert.ok(user); - assert.strictEqual(user.userType, UserType.EMBY); + assert.strictEqual(res.status, 403); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); }); it('applies default permissions to newly created users', async () => { diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ab22ceba73..24cbb395fd 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -603,6 +603,15 @@ authRoutes.post('/jellyfin', async (req, res, next) => { }); authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => { + const settings = getSettings(); + + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return next({ + status: 403, + message: 'Quick Connect is only supported by Jellyfin.', + }); + } + try { const hostname = getHostname(); const jellyfinServer = new JellyfinAPI( @@ -630,6 +639,15 @@ authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => { }); authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => { + const settings = getSettings(); + + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return next({ + status: 403, + message: 'Quick Connect is only supported by Jellyfin.', + }); + } + const result = quickConnectSecret.safeParse(req.query); if (!result.success) { return next({ @@ -684,6 +702,13 @@ authRoutes.post( }); } + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return next({ + status: 403, + message: 'Quick Connect is only supported by Jellyfin.', + }); + } + try { const hostname = getHostname(); const jellyfinServer = new JellyfinAPI( @@ -744,10 +769,7 @@ authRoutes.post( jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, - userType: - settings.main.mediaServerType === MediaServerType.JELLYFIN - ? UserType.JELLYFIN - : UserType.EMBY, + userType: UserType.JELLYFIN, }); user.avatar = getUserAvatarUrl(user); await userRepository.save(user); diff --git a/server/routes/user/usersettings.test.ts b/server/routes/user/usersettings.test.ts new file mode 100644 index 0000000000..ba83358756 --- /dev/null +++ b/server/routes/user/usersettings.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { before, beforeEach, describe, it, mock } from 'node:test'; + +import JellyfinAPI from '@server/api/jellyfin'; +import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { getSettings } from '@server/lib/settings'; +import { checkUser, isAuthenticated } from '@server/middleware/auth'; +import authRoutes from '@server/routes/auth'; +import { setupTestDb } from '@server/test/db'; +import type { Express } from 'express'; +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import userRoutes from '.'; + +const defaultAuthenticateResponse = { + User: { + Id: 'jf-link-user-001', + Name: 'linkeduser', + ServerId: 'server-1', + Policy: { IsAdministrator: false }, + }, + AccessToken: 'fake-qc-access-token', +}; + +const authenticateQCMock = mock.method( + JellyfinAPI.prototype, + 'authenticateQuickConnect', + async () => ({ ...defaultAuthenticateResponse }) +); + +let app: Express; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use( + session({ + secret: 'test-secret', + resave: false, + saveUninitialized: false, + }) + ); + app.use(checkUser); + app.use('/auth', authRoutes); + app.use('/user', isAuthenticated(), userRoutes); + app.use( + ( + err: { status?: number; message?: string }, + _req: express.Request, + res: express.Response, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _next: express.NextFunction + ) => { + res + .status(err.status ?? 500) + .json({ status: err.status ?? 500, message: err.message }); + } + ); + return app; +} + +before(async () => { + app = createApp(); +}); + +setupTestDb(); + +function configureJellyfin() { + const settings = getSettings(); + settings.main.mediaServerType = MediaServerType.JELLYFIN; + settings.jellyfin.ip = 'localhost'; + settings.jellyfin.port = 8096; + settings.jellyfin.useSsl = false; + settings.jellyfin.urlBase = ''; +} + +async function loginAs(email: string, password: string) { + const settings = getSettings(); + settings.main.localLogin = true; + + const agent = request.agent(app); + const res = await agent.post('/auth/local').send({ email, password }); + + assert.strictEqual(res.status, 200); + return { agent, userId: res.body.id as number }; +} + +describe('POST /user/:id/settings/linked-accounts/jellyfin/quickconnect', () => { + beforeEach(() => { + authenticateQCMock.mock.resetCalls(); + authenticateQCMock.mock.mockImplementation(async () => ({ + ...defaultAuthenticateResponse, + })); + configureJellyfin(); + }); + + it('links the account when the media server is Jellyfin', async () => { + const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234'); + + const res = await agent + .post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`) + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 204); + assert.strictEqual(authenticateQCMock.mock.callCount(), 1); + + const user = await getRepository(User).findOneOrFail({ + where: { id: userId }, + }); + assert.strictEqual(user.jellyfinUserId, 'jf-link-user-001'); + assert.strictEqual(user.userType, UserType.JELLYFIN); + }); + + it('returns 403 when the media server is Emby', async () => { + const { agent, userId } = await loginAs('friend@seerr.dev', 'test1234'); + getSettings().main.mediaServerType = MediaServerType.EMBY; + + const res = await agent + .post(`/user/${userId}/settings/linked-accounts/jellyfin/quickconnect`) + .send({ secret: 'abc123def456abc123def456' }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(authenticateQCMock.mock.callCount(), 0); + + const user = await getRepository(User).findOneOrFail({ + where: { id: userId }, + }); + assert.strictEqual(user.jellyfinUserId, null); + }); +}); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index bff500d58a..c724109634 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -542,6 +542,12 @@ userSettingsRoutes.post<{ secret: string }>( .json({ message: 'Jellyfin/Emby login is disabled' }); } + if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) { + return res + .status(403) + .json({ message: 'Quick Connect is only supported by Jellyfin.' }); + } + const hostname = getHostname(); const jellyfinServer = new JellyfinAPI(hostname); @@ -563,10 +569,7 @@ userSettingsRoutes.post<{ secret: string }>( user.id === 1 ? 'BOT_seerr' : `BOT_seerr_${user.username ?? ''}` ).toString('base64'); - user.userType = - settings.main.mediaServerType === MediaServerType.EMBY - ? UserType.EMBY - : UserType.JELLYFIN; + user.userType = UserType.JELLYFIN; user.jellyfinUserId = account.User.Id; user.jellyfinUsername = account.User.Name; user.jellyfinAuthToken = account.AccessToken; diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 01278e4a91..e73d35cbaa 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -217,17 +217,19 @@ const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => { }} -
- -
+ {serverType === MediaServerType.JELLYFIN && ( +
+ +
+ )} {showQuickConnect && ( {errors.password} )} -
- -
+ {settings.currentSettings.mediaServerType === + MediaServerType.JELLYFIN && ( +
+ +
+ )} );