mirror of
https://github.com/seerr-team/seerr.git
synced 2026-07-30 09:37:05 -04:00
feat: add jellyfin/emby quick connect authentication (#2212)
This commit is contained in:
113
seerr-api.yml
113
seerr-api.yml
@@ -3998,6 +3998,85 @@ paths:
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
/auth/jellyfin/quickconnect/initiate:
|
||||
post:
|
||||
summary: Initiate Jellyfin Quick Connect
|
||||
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'200':
|
||||
description: Quick Connect session initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: '123456'
|
||||
secret:
|
||||
type: string
|
||||
example: 'abc123def456'
|
||||
'500':
|
||||
description: Failed to initiate Quick Connect
|
||||
/auth/jellyfin/quickconnect/check:
|
||||
get:
|
||||
summary: Check Quick Connect authorization status
|
||||
description: Checks if the Quick Connect code has been authorized by the user.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
parameters:
|
||||
- in: query
|
||||
name: secret
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The secret returned from the initiate endpoint
|
||||
responses:
|
||||
'200':
|
||||
description: Authorization status returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
authenticated:
|
||||
type: boolean
|
||||
example: false
|
||||
'404':
|
||||
description: Quick Connect session not found or expired
|
||||
/auth/jellyfin/quickconnect/authenticate:
|
||||
post:
|
||||
summary: Authenticate with Quick Connect
|
||||
description: Completes the Quick Connect authentication flow and creates a user session.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'403':
|
||||
description: Quick Connect not authorized or access denied
|
||||
'500':
|
||||
description: Authentication failed
|
||||
/auth/local:
|
||||
post:
|
||||
summary: Sign in using a local account
|
||||
@@ -5161,6 +5240,40 @@ paths:
|
||||
description: Unlink request invalid
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/linked-accounts/jellyfin/quickconnect:
|
||||
post:
|
||||
summary: Link Jellyfin/Emby account with Quick Connect
|
||||
description: Links a Jellyfin/Emby account to the user's profile using Quick Connect authentication
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'400':
|
||||
description: Invalid Quick Connect secret
|
||||
'204':
|
||||
description: Account successfully linked
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'422':
|
||||
description: Account already linked
|
||||
'500':
|
||||
description: Server error
|
||||
/user/{userId}/settings/notifications:
|
||||
get:
|
||||
summary: Get notification settings for a user
|
||||
|
||||
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
|
||||
AccessToken: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectInitiateResponse {
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectStatusResponse {
|
||||
Authenticated: boolean;
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DeviceId: string;
|
||||
DeviceName: string;
|
||||
AppName: string;
|
||||
AppVersion: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface JellyfinUserListResponse {
|
||||
users: JellyfinUserResponse[];
|
||||
}
|
||||
@@ -220,6 +237,62 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
|
||||
try {
|
||||
const response = await this.post<QuickConnectInitiateResponse>(
|
||||
'/QuickConnect/Initiate'
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while initiating Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuickConnect(
|
||||
secret: string
|
||||
): Promise<QuickConnectStatusResponse> {
|
||||
try {
|
||||
const response = await this.get<QuickConnectStatusResponse>(
|
||||
'/QuickConnect/Connect',
|
||||
{ params: { secret } }
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting Quick Connect status: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async authenticateQuickConnect(
|
||||
secret: string
|
||||
): Promise<JellyfinLoginResponse> {
|
||||
try {
|
||||
const response = await this.post<JellyfinLoginResponse>(
|
||||
'/Users/AuthenticateWithQuickConnect',
|
||||
{ Secret: secret }
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public setUserId(userId: string): void {
|
||||
this.userId = userId;
|
||||
return;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, beforeEach, describe, it, mock } from 'node:test';
|
||||
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { checkUser } from '@server/middleware/auth';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
@@ -17,6 +22,52 @@ const emailMock = mock.method(PreparedEmail.prototype, 'send', async () => {
|
||||
return undefined;
|
||||
}).mock;
|
||||
|
||||
// Jellyfin Quick Connect mocks
|
||||
const defaultInitiateResponse = {
|
||||
Secret: 'abc123def456abc123def456',
|
||||
Code: '123456',
|
||||
DateAdded: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const defaultCheckResponse = {
|
||||
Authenticated: false,
|
||||
Secret: 'abc123def456abc123def456',
|
||||
Code: '123456',
|
||||
DeviceId: 'device-1',
|
||||
DeviceName: 'Test',
|
||||
AppName: 'Seerr',
|
||||
AppVersion: '1.0',
|
||||
DateAdded: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const defaultAuthenticateResponse = {
|
||||
User: {
|
||||
Id: 'jf-qc-user-001',
|
||||
Name: 'quickconnectuser',
|
||||
ServerId: 'server-1',
|
||||
Policy: { IsAdministrator: false },
|
||||
},
|
||||
AccessToken: 'fake-qc-access-token',
|
||||
};
|
||||
|
||||
const initiateQCMock = mock.method(
|
||||
JellyfinAPI.prototype,
|
||||
'initiateQuickConnect',
|
||||
async () => ({ ...defaultInitiateResponse })
|
||||
);
|
||||
|
||||
const checkQCMock = mock.method(
|
||||
JellyfinAPI.prototype,
|
||||
'checkQuickConnect',
|
||||
async () => ({ ...defaultCheckResponse })
|
||||
);
|
||||
|
||||
const authenticateQCMock = mock.method(
|
||||
JellyfinAPI.prototype,
|
||||
'authenticateQuickConnect',
|
||||
async () => ({ ...defaultAuthenticateResponse })
|
||||
);
|
||||
|
||||
let app: Express;
|
||||
|
||||
function createApp() {
|
||||
@@ -67,6 +118,394 @@ async function authenticatedAgent(email: string, password: string) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** Configure Jellyfin settings for testing QC */
|
||||
function configureJellyfin() {
|
||||
const settings = getSettings();
|
||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||
settings.main.newPlexLogin = true;
|
||||
settings.jellyfin.ip = 'localhost';
|
||||
settings.jellyfin.port = 8096;
|
||||
settings.jellyfin.useSsl = false;
|
||||
settings.jellyfin.urlBase = '';
|
||||
}
|
||||
|
||||
describe('POST /auth/jellyfin/quickconnect/initiate', () => {
|
||||
beforeEach(() => {
|
||||
initiateQCMock.mock.resetCalls();
|
||||
initiateQCMock.mock.mockImplementation(async () => ({
|
||||
...defaultInitiateResponse,
|
||||
}));
|
||||
configureJellyfin();
|
||||
});
|
||||
|
||||
it('returns code and secret on success', async () => {
|
||||
const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.code, '123456');
|
||||
assert.strictEqual(res.body.secret, 'abc123def456abc123def456');
|
||||
assert.strictEqual(initiateQCMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('returns 500 when Jellyfin API fails', async () => {
|
||||
initiateQCMock.mock.mockImplementation(async () => {
|
||||
throw new Error('Connection refused');
|
||||
});
|
||||
|
||||
const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.match(res.body.message, /initiate quick connect/i);
|
||||
});
|
||||
|
||||
it('returns 500 when initiateQuickConnect throws ApiError', async () => {
|
||||
initiateQCMock.mock.mockImplementation(async () => {
|
||||
throw new ApiError(500, ApiErrorCode.Unknown);
|
||||
});
|
||||
|
||||
const res = await request(app).post('/auth/jellyfin/quickconnect/initiate');
|
||||
assert.strictEqual(res.status, 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/jellyfin/quickconnect/check', () => {
|
||||
beforeEach(() => {
|
||||
checkQCMock.mock.resetCalls();
|
||||
checkQCMock.mock.mockImplementation(async () => ({
|
||||
...defaultCheckResponse,
|
||||
}));
|
||||
configureJellyfin();
|
||||
});
|
||||
|
||||
it('returns authenticated: false when not yet authorized', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/jellyfin/quickconnect/check')
|
||||
.query({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.authenticated, false);
|
||||
assert.strictEqual(checkQCMock.mock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('returns authenticated: true when authorized', async () => {
|
||||
checkQCMock.mock.mockImplementation(async () => ({
|
||||
...defaultCheckResponse,
|
||||
Authenticated: true,
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/auth/jellyfin/quickconnect/check')
|
||||
.query({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.authenticated, true);
|
||||
});
|
||||
|
||||
it('returns 400 when secret is missing', async () => {
|
||||
const res = await request(app).get('/auth/jellyfin/quickconnect/check');
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.match(res.body.message, /invalid secret/i);
|
||||
assert.strictEqual(checkQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 400 when secret is too short', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/jellyfin/quickconnect/check')
|
||||
.query({ secret: 'ab12' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.match(res.body.message, /invalid secret/i);
|
||||
assert.strictEqual(checkQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 400 when secret is too long', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/jellyfin/quickconnect/check')
|
||||
.query({ secret: 'a'.repeat(129) });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.match(res.body.message, /invalid secret/i);
|
||||
assert.strictEqual(checkQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 400 when secret contains non-hex characters', async () => {
|
||||
const res = await request(app)
|
||||
.get('/auth/jellyfin/quickconnect/check')
|
||||
.query({ secret: 'zzzzzzzzzzzz' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.match(res.body.message, /invalid secret/i);
|
||||
assert.strictEqual(checkQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns error when Jellyfin API fails', async () => {
|
||||
checkQCMock.mock.mockImplementation(async () => {
|
||||
throw new ApiError(500, ApiErrorCode.Unknown);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/auth/jellyfin/quickconnect/check')
|
||||
.query({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/jellyfin/quickconnect/authenticate', () => {
|
||||
beforeEach(() => {
|
||||
authenticateQCMock.mock.resetCalls();
|
||||
authenticateQCMock.mock.mockImplementation(async () => ({
|
||||
...defaultAuthenticateResponse,
|
||||
}));
|
||||
configureJellyfin();
|
||||
});
|
||||
|
||||
it('returns 400 when secret is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({});
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 400 when secret is not a string', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 12345678 });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 400 when secret is too short', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'ab12' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
it('returns 400 when secret contains non-hex characters', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'zzzzzzzzzzzz' });
|
||||
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 403 when media server is not configured', async () => {
|
||||
const settings = getSettings();
|
||||
settings.main.mediaServerType = MediaServerType.NOT_CONFIGURED;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.match(res.body.message, /initial setup/i);
|
||||
assert.strictEqual(authenticateQCMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 403 when no users exist in the database', async () => {
|
||||
// Clear all users to simulate initial setup
|
||||
const userRepo = getRepository(User);
|
||||
await userRepo.clear();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.match(res.body.message, /initial setup/i);
|
||||
});
|
||||
|
||||
it('signs in an existing Jellyfin user and sets session', async () => {
|
||||
const userRepo = getRepository(User);
|
||||
const existingUser = new User({
|
||||
email: 'existing-qc@seerr.dev',
|
||||
jellyfinUsername: 'quickconnectuser',
|
||||
jellyfinUserId: 'jf-qc-user-001',
|
||||
jellyfinDeviceId: 'old-device-id',
|
||||
permissions: 0,
|
||||
avatar: '/avatarproxy/jf-qc-user-001?v=0',
|
||||
userType: UserType.JELLYFIN,
|
||||
});
|
||||
await userRepo.save(existingUser);
|
||||
|
||||
const agent = request.agent(app);
|
||||
|
||||
const res = await agent
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok('id' in res.body);
|
||||
assert.ok(!('password' in res.body));
|
||||
|
||||
const meRes = await agent.get('/auth/me');
|
||||
assert.strictEqual(meRes.status, 200);
|
||||
assert.strictEqual(meRes.body.jellyfinUsername, 'quickconnectuser');
|
||||
|
||||
const updatedUser = await userRepo.findOneOrFail({
|
||||
where: { jellyfinUserId: 'jf-qc-user-001' },
|
||||
select: {
|
||||
id: true,
|
||||
jellyfinAuthToken: true,
|
||||
jellyfinDeviceId: true,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(updatedUser.jellyfinAuthToken, 'fake-qc-access-token');
|
||||
assert.notStrictEqual(updatedUser.jellyfinDeviceId, 'old-device-id');
|
||||
});
|
||||
|
||||
it('creates a new user when newPlexLogin is enabled and user does not exist', async () => {
|
||||
const settings = getSettings();
|
||||
settings.main.newPlexLogin = true;
|
||||
|
||||
authenticateQCMock.mock.mockImplementation(async () => ({
|
||||
User: {
|
||||
Id: 'jf-brand-new-user',
|
||||
Name: 'brandnewuser',
|
||||
ServerId: 'server-1',
|
||||
Policy: { IsAdministrator: false },
|
||||
},
|
||||
AccessToken: 'new-user-token',
|
||||
}));
|
||||
|
||||
const agent = request.agent(app);
|
||||
|
||||
const res = await agent
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok('id' in res.body);
|
||||
|
||||
const userRepo = getRepository(User);
|
||||
const newUser = await userRepo.findOne({
|
||||
where: { jellyfinUserId: 'jf-brand-new-user' },
|
||||
});
|
||||
assert.ok(newUser);
|
||||
assert.strictEqual(newUser.jellyfinUsername, 'brandnewuser');
|
||||
assert.strictEqual(newUser.userType, UserType.JELLYFIN);
|
||||
|
||||
const meRes = await agent.get('/auth/me');
|
||||
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',
|
||||
}));
|
||||
|
||||
const agent = request.agent(app);
|
||||
const res = await agent
|
||||
.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);
|
||||
});
|
||||
|
||||
it('applies default permissions to newly created users', async () => {
|
||||
const settings = getSettings();
|
||||
settings.main.newPlexLogin = true;
|
||||
settings.main.defaultPermissions = 32;
|
||||
|
||||
authenticateQCMock.mock.mockImplementation(async () => ({
|
||||
User: {
|
||||
Id: 'jf-perms-test-user',
|
||||
Name: 'permsuser',
|
||||
ServerId: 'server-1',
|
||||
Policy: { IsAdministrator: false },
|
||||
},
|
||||
AccessToken: 'perms-token',
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo.findOneOrFail({
|
||||
where: { jellyfinUserId: 'jf-perms-test-user' },
|
||||
});
|
||||
assert.strictEqual(user.permissions, 32);
|
||||
});
|
||||
|
||||
it('returns 403 when newPlexLogin is disabled and user does not exist', async () => {
|
||||
const settings = getSettings();
|
||||
settings.main.newPlexLogin = false;
|
||||
|
||||
authenticateQCMock.mock.mockImplementation(async () => ({
|
||||
User: {
|
||||
Id: 'jf-unknown-user',
|
||||
Name: 'unknownuser',
|
||||
ServerId: 'server-1',
|
||||
Policy: { IsAdministrator: false },
|
||||
},
|
||||
AccessToken: 'unknown-token',
|
||||
}));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(res.body.message, 'Access denied.');
|
||||
});
|
||||
|
||||
it('returns error when Jellyfin authenticateQuickConnect fails', async () => {
|
||||
authenticateQCMock.mock.mockImplementation(async () => {
|
||||
throw new ApiError(401, ApiErrorCode.InvalidCredentials);
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 401);
|
||||
assert.strictEqual(res.body.message, ApiErrorCode.InvalidCredentials);
|
||||
});
|
||||
|
||||
it('returns 500 when Jellyfin throws a generic error', async () => {
|
||||
authenticateQCMock.mock.mockImplementation(async () => {
|
||||
throw new Error('Network timeout');
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/auth/jellyfin/quickconnect/authenticate')
|
||||
.send({ secret: 'abc123def456abc123def456' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/me', () => {
|
||||
it('returns 403 when not authenticated', async () => {
|
||||
const res = await request(app).get('/auth/me');
|
||||
|
||||
@@ -18,9 +18,18 @@ import axios from 'axios';
|
||||
import { Router } from 'express';
|
||||
import net from 'net';
|
||||
import validator from 'validator';
|
||||
import { z } from 'zod';
|
||||
|
||||
const authRoutes = Router();
|
||||
|
||||
export const quickConnectSecret = z.object({
|
||||
secret: z
|
||||
.string()
|
||||
.min(8)
|
||||
.max(128)
|
||||
.regex(/^[A-Fa-f0-9]+$/),
|
||||
});
|
||||
|
||||
authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
const userRepository = getRepository(User);
|
||||
if (!req.user) {
|
||||
@@ -593,6 +602,177 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.initiateQuickConnect();
|
||||
|
||||
return res.status(200).json({
|
||||
code: response.Code,
|
||||
secret: response.Secret,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Jellyfin quick connect', {
|
||||
label: 'Auth',
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Failed to initiate quick connect.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
|
||||
const result = quickConnectSecret.safeParse(req.query);
|
||||
if (!result.success) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Invalid secret format',
|
||||
});
|
||||
}
|
||||
|
||||
const { secret } = result.data;
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.checkQuickConnect(secret);
|
||||
|
||||
return res.status(200).json({ authenticated: response.Authenticated });
|
||||
} catch (e) {
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: 'Failed to check Quick Connect status',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post(
|
||||
'/jellyfin/quickconnect/authenticate',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const result = quickConnectSecret.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Secret required',
|
||||
});
|
||||
}
|
||||
|
||||
const { secret } = result.data;
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
|
||||
!(await userRepository.count())
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Quick Connect is not available during initial setup.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const account = await jellyfinServer.authenticateQuickConnect(secret);
|
||||
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
});
|
||||
|
||||
const deviceId = Buffer.from(
|
||||
`BOT_seerr_${account.User.Name ?? ''}`
|
||||
).toString('base64');
|
||||
|
||||
if (user) {
|
||||
logger.info('Quick Connect sign-in from existing user', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user = new User({
|
||||
email: account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
// Set session
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error('Quick Connect authentication failed', {
|
||||
label: 'Auth',
|
||||
error: e.message,
|
||||
ip: req.ip,
|
||||
});
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: ApiErrorCode.InvalidCredentials,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { quickConnectSecret } from '@server/routes/auth';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import {
|
||||
@@ -514,6 +515,78 @@ userSettingsRoutes.delete<{ id: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.post<{ secret: string }>(
|
||||
'/linked-accounts/jellyfin/quickconnect',
|
||||
isOwnProfile(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
|
||||
}
|
||||
|
||||
const result = quickConnectSecret.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ message: 'Invalid secret format' });
|
||||
}
|
||||
|
||||
const { secret } = result.data;
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||
) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(hostname);
|
||||
|
||||
try {
|
||||
const account = await jellyfinServer.authenticateQuickConnect(secret);
|
||||
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
message: 'The specified account is already linked to a Seerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
const deviceId = Buffer.from(
|
||||
user.id === 1 ? 'BOT_seerr' : `BOT_seerr_${user.username ?? ''}`
|
||||
).toString('base64');
|
||||
|
||||
user.userType =
|
||||
settings.main.mediaServerType === MediaServerType.EMBY
|
||||
? UserType.EMBY
|
||||
: UserType.JELLYFIN;
|
||||
user.jellyfinUserId = account.User.Id;
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Failed to link account with Quick Connect.', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
error: e,
|
||||
});
|
||||
|
||||
const status = e instanceof ApiError ? e.statusCode : 500;
|
||||
return res.status(status).send();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
'/notifications',
|
||||
isOwnProfileOrAdmin(),
|
||||
|
||||
152
src/components/Common/QuickConnectModal/index.tsx
Normal file
152
src/components/Common/QuickConnectModal/index.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import { useQuickConnect } from '@app/hooks/useQuickConnect';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Common.QuickConnectModal', {
|
||||
waitingForAuth: 'Waiting for authorization...',
|
||||
expired: 'Code Expired',
|
||||
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||
error: 'Error',
|
||||
tryAgain: 'Try Again',
|
||||
});
|
||||
|
||||
interface QuickConnectModalProps {
|
||||
show: boolean;
|
||||
title: string;
|
||||
subTitle: string;
|
||||
cancelText: string;
|
||||
instructionsMessage: string;
|
||||
dialogClass?: string;
|
||||
showInlineError?: boolean;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
onError?: (error: string) => void;
|
||||
authenticate: (secret: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const QuickConnectModal = ({
|
||||
show,
|
||||
title,
|
||||
subTitle,
|
||||
cancelText,
|
||||
instructionsMessage,
|
||||
dialogClass,
|
||||
showInlineError,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
onError,
|
||||
authenticate,
|
||||
}: QuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
} = useQuickConnect({
|
||||
show,
|
||||
onSuccess,
|
||||
onError,
|
||||
authenticate,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
cleanup();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show={show}
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Modal
|
||||
onCancel={handleCancel}
|
||||
title={title}
|
||||
subTitle={subTitle}
|
||||
cancelText={cancelText}
|
||||
dialogClass={dialogClass}
|
||||
{...(hasError || isExpired
|
||||
? {
|
||||
okText: intl.formatMessage(messages.tryAgain),
|
||||
onOk: initiateQuickConnect,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{showInlineError && errorMessage && (
|
||||
<div className="mb-4">
|
||||
<Alert type="error">{errorMessage}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasError && !isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<p className="text-center text-gray-300">{instructionsMessage}</p>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="rounded-lg bg-gray-700 px-8 py-4">
|
||||
<span className="text-4xl font-bold tracking-wider text-white">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<div className="h-4 w-4">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<span>{intl.formatMessage(messages.waitingForAuth)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-red-500">
|
||||
{intl.formatMessage(messages.error)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-yellow-500">
|
||||
{intl.formatMessage(messages.expired)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">
|
||||
{intl.formatMessage(messages.expiredMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickConnectModal;
|
||||
@@ -1,14 +1,19 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import JellyfinQuickConnectModal from '@app/components/Login/JellyfinQuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import useToasts from '@app/hooks/useToasts';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
QrCodeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/solid';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -27,6 +32,8 @@ const messages = defineMessages('components.Login', {
|
||||
signingin: 'Signing In…',
|
||||
signin: 'Sign In',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
quickconnect: 'Quick Connect',
|
||||
quickconnecterror: 'Quick Connect failed. Please try again.',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
@@ -34,13 +41,11 @@ interface JellyfinLoginProps {
|
||||
serverType?: MediaServerType;
|
||||
}
|
||||
|
||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
revalidate,
|
||||
serverType,
|
||||
}) => {
|
||||
const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const [showQuickConnect, setShowQuickConnect] = useState(false);
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
@@ -51,6 +56,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: 'Media Server',
|
||||
};
|
||||
|
||||
const handleQuickConnectError = useCallback(
|
||||
(error: string) => {
|
||||
toasts.addToast(error, {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
},
|
||||
[toasts]
|
||||
);
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
@@ -201,6 +216,30 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="button"
|
||||
onClick={() => setShowQuickConnect(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<QrCodeIcon />
|
||||
<span>{intl.formatMessage(messages.quickconnect)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showQuickConnect && (
|
||||
<JellyfinQuickConnectModal
|
||||
onClose={() => setShowQuickConnect(false)}
|
||||
onAuthenticated={() => {
|
||||
setShowQuickConnect(false);
|
||||
revalidate();
|
||||
}}
|
||||
onError={handleQuickConnectError}
|
||||
mediaServerName={mediaServerFormatValues.mediaServerName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
55
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
55
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import QuickConnectModal from '@app/components/Common/QuickConnectModal';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import axios from 'axios';
|
||||
import { useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Login.JellyfinQuickConnectModal', {
|
||||
title: 'Quick Connect',
|
||||
subtitle: 'Sign in with Quick Connect',
|
||||
instructions: 'Enter this code in your {mediaServerName} app',
|
||||
cancel: 'Cancel',
|
||||
});
|
||||
|
||||
interface JellyfinQuickConnectModalProps {
|
||||
onClose: () => void;
|
||||
onAuthenticated: () => void;
|
||||
onError: (error: string) => void;
|
||||
mediaServerName: string;
|
||||
}
|
||||
|
||||
const JellyfinQuickConnectModal = ({
|
||||
onClose,
|
||||
onAuthenticated,
|
||||
onError,
|
||||
mediaServerName,
|
||||
}: JellyfinQuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const authenticate = useCallback(async (secret: string) => {
|
||||
await axios.post('/api/v1/auth/jellyfin/quickconnect/authenticate', {
|
||||
secret,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QuickConnectModal
|
||||
show
|
||||
title={intl.formatMessage(messages.title)}
|
||||
subTitle={intl.formatMessage(messages.subtitle)}
|
||||
cancelText={intl.formatMessage(messages.cancel)}
|
||||
instructionsMessage={intl.formatMessage(messages.instructions, {
|
||||
mediaServerName,
|
||||
})}
|
||||
onCancel={onClose}
|
||||
onSuccess={() => {
|
||||
onAuthenticated();
|
||||
onClose();
|
||||
}}
|
||||
onError={onError}
|
||||
authenticate={authenticate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyfinQuickConnectModal;
|
||||
@@ -1,9 +1,11 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { QrCodeIcon } from '@heroicons/react/24/outline';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
@@ -27,6 +29,7 @@ const messages = defineMessages(
|
||||
'Unable to connect to {mediaServerName} using your credentials',
|
||||
errorExists: 'This account is already linked to a {applicationName} user',
|
||||
errorUnknown: 'An unknown error occurred',
|
||||
quickConnect: 'Use Quick Connect',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -34,13 +37,15 @@ interface LinkJellyfinModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSwitchToQuickConnect: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
const LinkJellyfinModal = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
onSwitchToQuickConnect,
|
||||
}: LinkJellyfinModalProps) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
@@ -167,6 +172,20 @@ const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
onSwitchToQuickConnect();
|
||||
}}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<QrCodeIcon />
|
||||
<span>{intl.formatMessage(messages.quickConnect)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import QuickConnectModal from '@app/components/Common/QuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages(
|
||||
'components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal',
|
||||
{
|
||||
title: 'Link {mediaServerName} Account',
|
||||
subtitle: 'Quick Connect',
|
||||
instructions: 'Enter this code in your {mediaServerName} app',
|
||||
usePassword: 'Use Password Instead',
|
||||
}
|
||||
);
|
||||
|
||||
interface LinkJellyfinQuickConnectModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSwitchToPassword: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinQuickConnectModal = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
onSwitchToPassword,
|
||||
}: LinkJellyfinQuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
|
||||
const mediaServerName =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: 'Emby';
|
||||
|
||||
const authenticate = useCallback(
|
||||
async (secret: string) => {
|
||||
await axios.post(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin/quickconnect`,
|
||||
{ secret }
|
||||
);
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
onSwitchToPassword();
|
||||
};
|
||||
|
||||
return (
|
||||
<QuickConnectModal
|
||||
show={show}
|
||||
title={intl.formatMessage(messages.title, { mediaServerName })}
|
||||
subTitle={intl.formatMessage(messages.subtitle)}
|
||||
cancelText={intl.formatMessage(messages.usePassword)}
|
||||
instructionsMessage={intl.formatMessage(messages.instructions, {
|
||||
mediaServerName,
|
||||
})}
|
||||
dialogClass="sm:max-w-lg"
|
||||
showInlineError
|
||||
onCancel={handleCancel}
|
||||
onSuccess={() => {
|
||||
onSave();
|
||||
onClose();
|
||||
}}
|
||||
authenticate={authenticate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkJellyfinQuickConnectModal;
|
||||
@@ -5,6 +5,7 @@ import Alert from '@app/components/Common/Alert';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import Dropdown from '@app/components/Common/Dropdown';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import LinkJellyfinQuickConnectModal from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
@@ -63,6 +64,8 @@ const UserLinkedAccountsSettings = () => {
|
||||
user ? `/api/v1/user/${user?.id}/settings/password` : null
|
||||
);
|
||||
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
||||
const [showJellyfinQuickConnectModal, setShowJellyfinQuickConnectModal] =
|
||||
useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applicationName = settings.currentSettings.applicationTitle;
|
||||
@@ -267,6 +270,23 @@ const UserLinkedAccountsSettings = () => {
|
||||
setShowJellyfinModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
onSwitchToQuickConnect={() => {
|
||||
setShowJellyfinModal(false);
|
||||
setShowJellyfinQuickConnectModal(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LinkJellyfinQuickConnectModal
|
||||
show={showJellyfinQuickConnectModal}
|
||||
onClose={() => setShowJellyfinQuickConnectModal(false)}
|
||||
onSave={() => {
|
||||
setShowJellyfinQuickConnectModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
onSwitchToPassword={() => {
|
||||
setShowJellyfinQuickConnectModal(false);
|
||||
setShowJellyfinModal(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
187
src/hooks/useQuickConnect.ts
Normal file
187
src/hooks/useQuickConnect.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import axios from 'axios';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('hooks.useQuickConnect', {
|
||||
errorMessage: 'Failed to initiate Quick Connect. Please try again.',
|
||||
});
|
||||
|
||||
interface UseQuickConnectOptions {
|
||||
show: boolean;
|
||||
onSuccess: () => void;
|
||||
onError?: (error: string) => void;
|
||||
authenticate: (secret: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useQuickConnect = ({
|
||||
show,
|
||||
onSuccess,
|
||||
onError,
|
||||
authenticate,
|
||||
}: UseQuickConnectOptions) => {
|
||||
const intl = useIntl();
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const pollingInterval = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const isMounted = useRef(true);
|
||||
const hasInitiated = useRef(false);
|
||||
const errorCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
hasInitiated.current = false;
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
pollingInterval.current = undefined;
|
||||
}
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
const authenticateWithQuickConnect = useCallback(
|
||||
async (secret: string) => {
|
||||
try {
|
||||
await authenticate(secret);
|
||||
if (!isMounted.current) return;
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
const errMsg =
|
||||
error?.response?.data?.message ||
|
||||
intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errMsg);
|
||||
setHasError(true);
|
||||
onError?.(errMsg);
|
||||
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
[authenticate, intl, onError, onSuccess]
|
||||
);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(secret: string) => {
|
||||
pollingInterval.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'/api/v1/auth/jellyfin/quickconnect/check',
|
||||
{
|
||||
params: { secret },
|
||||
}
|
||||
);
|
||||
|
||||
errorCount.current = 0;
|
||||
|
||||
if (!isMounted.current) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.authenticated) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
await authenticateWithQuickConnect(secret);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
setIsExpired(true);
|
||||
} else {
|
||||
errorCount.current++;
|
||||
if (errorCount.current >= 5) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
setHasError(true);
|
||||
const errorMessage = intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[authenticateWithQuickConnect, intl, onError]
|
||||
);
|
||||
|
||||
const initiateQuickConnect = useCallback(async () => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setIsExpired(false);
|
||||
setErrorMessage(null);
|
||||
setCode('');
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/api/v1/auth/jellyfin/quickconnect/initiate'
|
||||
);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setCode(response.data.code);
|
||||
setIsLoading(false);
|
||||
startPolling(response.data.secret);
|
||||
} catch {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
const errMessage = intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errMessage);
|
||||
onError?.(errMessage);
|
||||
}
|
||||
}, [startPolling, onError, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show && !hasInitiated.current) {
|
||||
hasInitiated.current = true;
|
||||
initiateQuickConnect();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,11 @@
|
||||
"components.CollectionDetails.removefromblocklistpartialcount": "{removeLabel} ({count, plural, one {# movie} other {# movies}})",
|
||||
"components.CollectionDetails.requestcollection": "Request Collection",
|
||||
"components.CollectionDetails.requestcollection4k": "Request Collection in 4K",
|
||||
"components.Common.QuickConnectModal.error": "Error",
|
||||
"components.Common.QuickConnectModal.expired": "Code Expired",
|
||||
"components.Common.QuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.",
|
||||
"components.Common.QuickConnectModal.tryAgain": "Try Again",
|
||||
"components.Common.QuickConnectModal.waitingForAuth": "Waiting for authorization...",
|
||||
"components.Discover.CreateSlider.addSlider": "Add Slider",
|
||||
"components.Discover.CreateSlider.addcustomslider": "Create Custom Slider",
|
||||
"components.Discover.CreateSlider.addfail": "Failed to create new slider.",
|
||||
@@ -244,6 +249,10 @@
|
||||
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
||||
"components.Layout.VersionStatus.streamdevelop": "Seerr Develop",
|
||||
"components.Layout.VersionStatus.streamstable": "Seerr Stable",
|
||||
"components.Login.JellyfinQuickConnectModal.cancel": "Cancel",
|
||||
"components.Login.JellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app",
|
||||
"components.Login.JellyfinQuickConnectModal.subtitle": "Sign in with Quick Connect",
|
||||
"components.Login.JellyfinQuickConnectModal.title": "Quick Connect",
|
||||
"components.Login.adminerror": "You must use an admin account to sign in.",
|
||||
"components.Login.back": "Go back",
|
||||
"components.Login.credentialerror": "The username or password is incorrect.",
|
||||
@@ -264,6 +273,8 @@
|
||||
"components.Login.orsigninwith": "Or sign in with",
|
||||
"components.Login.password": "Password",
|
||||
"components.Login.port": "Port",
|
||||
"components.Login.quickconnect": "Quick Connect",
|
||||
"components.Login.quickconnecterror": "Quick Connect failed. Please try again.",
|
||||
"components.Login.save": "Add",
|
||||
"components.Login.saveFailed": "Something went wrong while saving.",
|
||||
"components.Login.saving": "Adding…",
|
||||
@@ -1449,11 +1460,16 @@
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.quickConnect": "Use Quick Connect",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.save": "Link",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.subtitle": "Quick Connect",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.title": "Link {mediaServerName} Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.usePassword": "Use Password Instead",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
|
||||
@@ -1601,6 +1617,7 @@
|
||||
"components.UserProfile.seriesrequest": "Series Requests",
|
||||
"components.UserProfile.totalrequests": "Total Requests",
|
||||
"components.UserProfile.unlimited": "Unlimited",
|
||||
"hooks.useQuickConnect.errorMessage": "Failed to initiate Quick Connect. Please try again.",
|
||||
"i18n.addToBlocklist": "Add to Blocklist",
|
||||
"i18n.advanced": "Advanced",
|
||||
"i18n.all": "All",
|
||||
|
||||
Reference in New Issue
Block a user