Compare commits
12 Commits
preview-se
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02755f03d | ||
|
|
f2c771727c | ||
|
|
87b51b809b | ||
|
|
43553cb2d5 | ||
|
|
8bb7d4e380 | ||
|
|
8c4e39d098 | ||
|
|
973e43f1cc | ||
|
|
a93716eb15 | ||
|
|
6000c36c69 | ||
|
|
6c9aaf9777 | ||
|
|
c4d06540a6 | ||
|
|
98a6075cb6 |
111
seerr-api.yml
111
seerr-api.yml
@@ -3984,6 +3984,85 @@ paths:
|
|||||||
required:
|
required:
|
||||||
- username
|
- username
|
||||||
- password
|
- 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:
|
/auth/local:
|
||||||
post:
|
post:
|
||||||
summary: Sign in using a local account
|
summary: Sign in using a local account
|
||||||
@@ -4913,6 +4992,38 @@ paths:
|
|||||||
description: Unlink request invalid
|
description: Unlink request invalid
|
||||||
'404':
|
'404':
|
||||||
description: User does not exist
|
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:
|
||||||
|
'204':
|
||||||
|
description: Account successfully linked
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
'422':
|
||||||
|
description: Account already linked
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
/user/{userId}/settings/notifications:
|
/user/{userId}/settings/notifications:
|
||||||
get:
|
get:
|
||||||
summary: Get notification settings for a user
|
summary: Get notification settings for a user
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
|
|||||||
AccessToken: string;
|
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 {
|
export interface JellyfinUserListResponse {
|
||||||
users: JellyfinUserResponse[];
|
users: JellyfinUserResponse[];
|
||||||
}
|
}
|
||||||
@@ -216,6 +233,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 {
|
public setUserId(userId: string): void {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -594,6 +594,189 @@ 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 secret = req.query.secret as string;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!secret ||
|
||||||
|
typeof secret !== 'string' ||
|
||||||
|
secret.length < 8 ||
|
||||||
|
secret.length > 128 ||
|
||||||
|
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||||
|
) {
|
||||||
|
return next({
|
||||||
|
status: 400,
|
||||||
|
message: 'Invalid secret format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 body = req.body as { secret?: string };
|
||||||
|
|
||||||
|
if (
|
||||||
|
!body.secret ||
|
||||||
|
typeof body.secret !== 'string' ||
|
||||||
|
body.secret.length < 8 ||
|
||||||
|
body.secret.length > 128 ||
|
||||||
|
!/^[A-Fa-f0-9]+$/.test(body.secret)
|
||||||
|
) {
|
||||||
|
return next({
|
||||||
|
status: 400,
|
||||||
|
message: 'Secret required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
body.secret
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = await userRepository.findOne({
|
||||||
|
where: { jellyfinUserId: account.User.Id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).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) => {
|
authRoutes.post('/local', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
|||||||
@@ -543,6 +543,81 @@ 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 secret = req.body.secret;
|
||||||
|
if (
|
||||||
|
!secret ||
|
||||||
|
typeof secret !== 'string' ||
|
||||||
|
secret.length < 8 ||
|
||||||
|
secret.length > 128 ||
|
||||||
|
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ message: 'Invalid secret format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
`BOT_seerr_qc_link_${account.User.Id}`
|
||||||
|
).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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(500).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
isOwnProfileOrAdmin(),
|
isOwnProfileOrAdmin(),
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
|
import JellyfinQuickConnectModal from '@app/components/Login/JellyfinQuickConnectModal';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
import {
|
||||||
|
ArrowLeftOnRectangleIcon,
|
||||||
|
QrCodeIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
@@ -25,6 +30,8 @@ const messages = defineMessages('components.Login', {
|
|||||||
signingin: 'Signing In…',
|
signingin: 'Signing In…',
|
||||||
signin: 'Sign In',
|
signin: 'Sign In',
|
||||||
forgotpassword: 'Forgot Password?',
|
forgotpassword: 'Forgot Password?',
|
||||||
|
quickconnect: 'Quick Connect',
|
||||||
|
quickconnecterror: 'Quick Connect failed. Please try again.',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface JellyfinLoginProps {
|
interface JellyfinLoginProps {
|
||||||
@@ -32,13 +39,11 @@ interface JellyfinLoginProps {
|
|||||||
serverType?: MediaServerType;
|
serverType?: MediaServerType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => {
|
||||||
revalidate,
|
|
||||||
serverType,
|
|
||||||
}) => {
|
|
||||||
const toasts = useToasts();
|
const toasts = useToasts();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const [showQuickConnect, setShowQuickConnect] = useState(false);
|
||||||
|
|
||||||
const mediaServerFormatValues = {
|
const mediaServerFormatValues = {
|
||||||
mediaServerName:
|
mediaServerName:
|
||||||
@@ -49,6 +54,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
: 'Media Server',
|
: 'Media Server',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickConnectError = useCallback(
|
||||||
|
(error: string) => {
|
||||||
|
toasts.addToast(error, {
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[toasts]
|
||||||
|
);
|
||||||
|
|
||||||
const LoginSchema = Yup.object().shape({
|
const LoginSchema = Yup.object().shape({
|
||||||
username: Yup.string().required(
|
username: Yup.string().required(
|
||||||
intl.formatMessage(messages.validationusernamerequired)
|
intl.formatMessage(messages.validationusernamerequired)
|
||||||
@@ -194,6 +209,30 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Formik>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
154
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
154
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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 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',
|
||||||
|
waitingForAuth: 'Waiting for authorization...',
|
||||||
|
expired: 'Code Expired',
|
||||||
|
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||||
|
error: 'Error',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
tryAgain: 'Try Again',
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
onAuthenticated();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[onAuthenticated, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
isExpired,
|
||||||
|
errorMessage,
|
||||||
|
initiateQuickConnect,
|
||||||
|
cleanup,
|
||||||
|
} = useQuickConnect({
|
||||||
|
show: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
onAuthenticated();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
authenticate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
cleanup();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
appear
|
||||||
|
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={handleClose}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
subTitle={intl.formatMessage(messages.subtitle)}
|
||||||
|
cancelText={intl.formatMessage(messages.cancel)}
|
||||||
|
{...(hasError || isExpired
|
||||||
|
? {
|
||||||
|
okText: intl.formatMessage(messages.tryAgain),
|
||||||
|
onOk: initiateQuickConnect,
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
{intl.formatMessage(messages.instructions, {
|
||||||
|
mediaServerName,
|
||||||
|
})}
|
||||||
|
</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 JellyfinQuickConnectModal;
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import Alert from '@app/components/Common/Alert';
|
import Alert from '@app/components/Common/Alert';
|
||||||
|
import Button from '@app/components/Common/Button';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
|
import { QrCodeIcon } from '@heroicons/react/24/outline';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
@@ -27,6 +29,7 @@ const messages = defineMessages(
|
|||||||
'Unable to connect to {mediaServerName} using your credentials',
|
'Unable to connect to {mediaServerName} using your credentials',
|
||||||
errorExists: 'This account is already linked to a {applicationName} user',
|
errorExists: 'This account is already linked to a {applicationName} user',
|
||||||
errorUnknown: 'An unknown error occurred',
|
errorUnknown: 'An unknown error occurred',
|
||||||
|
quickConnect: 'Use Quick Connect',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,13 +37,15 @@ interface LinkJellyfinModalProps {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
onSwitchToQuickConnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
const LinkJellyfinModal = ({
|
||||||
show,
|
show,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) => {
|
onSwitchToQuickConnect,
|
||||||
|
}: LinkJellyfinModalProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
@@ -167,6 +172,20 @@ const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
|||||||
<div className="error">{errors.password}</div>
|
<div className="error">{errors.password}</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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 useSettings from '@app/hooks/useSettings';
|
||||||
|
import { useUser } from '@app/hooks/useUser';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
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',
|
||||||
|
waitingForAuth: 'Waiting for authorization...',
|
||||||
|
expired: 'Code Expired',
|
||||||
|
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||||
|
error: 'Error',
|
||||||
|
usePassword: 'Use Password Instead',
|
||||||
|
tryAgain: 'Try Again',
|
||||||
|
errorExists: 'This account is already linked',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
[user, onSave, onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
code,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
isExpired,
|
||||||
|
errorMessage,
|
||||||
|
initiateQuickConnect,
|
||||||
|
cleanup,
|
||||||
|
} = useQuickConnect({
|
||||||
|
show: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
onSave();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
authenticate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSwitchToPassword = () => {
|
||||||
|
cleanup();
|
||||||
|
onClose();
|
||||||
|
onSwitchToPassword();
|
||||||
|
};
|
||||||
|
|
||||||
|
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={handleSwitchToPassword}
|
||||||
|
title={intl.formatMessage(messages.title, { mediaServerName })}
|
||||||
|
subTitle={intl.formatMessage(messages.subtitle)}
|
||||||
|
cancelText={intl.formatMessage(messages.usePassword)}
|
||||||
|
{...(hasError || isExpired
|
||||||
|
? {
|
||||||
|
okText: intl.formatMessage(messages.tryAgain),
|
||||||
|
onOk: initiateQuickConnect,
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
dialogClass="sm:max-w-lg"
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
{intl.formatMessage(messages.instructions, { mediaServerName })}
|
||||||
|
</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 LinkJellyfinQuickConnectModal;
|
||||||
@@ -5,6 +5,7 @@ import Alert from '@app/components/Common/Alert';
|
|||||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
import Dropdown from '@app/components/Common/Dropdown';
|
import Dropdown from '@app/components/Common/Dropdown';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import LinkJellyfinQuickConnectModal from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
@@ -63,6 +64,8 @@ const UserLinkedAccountsSettings = () => {
|
|||||||
user ? `/api/v1/user/${user?.id}/settings/password` : null
|
user ? `/api/v1/user/${user?.id}/settings/password` : null
|
||||||
);
|
);
|
||||||
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
||||||
|
const [showJellyfinQuickConnectModal, setShowJellyfinQuickConnectModal] =
|
||||||
|
useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const applicationName = settings.currentSettings.applicationTitle;
|
const applicationName = settings.currentSettings.applicationTitle;
|
||||||
@@ -263,6 +266,23 @@ const UserLinkedAccountsSettings = () => {
|
|||||||
setShowJellyfinModal(false);
|
setShowJellyfinModal(false);
|
||||||
revalidateUser();
|
revalidateUser();
|
||||||
}}
|
}}
|
||||||
|
onSwitchToQuickConnect={() => {
|
||||||
|
setShowJellyfinModal(false);
|
||||||
|
setShowJellyfinQuickConnectModal(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinkJellyfinQuickConnectModal
|
||||||
|
show={showJellyfinQuickConnectModal}
|
||||||
|
onClose={() => setShowJellyfinQuickConnectModal(false)}
|
||||||
|
onSave={() => {
|
||||||
|
setShowJellyfinQuickConnectModal(false);
|
||||||
|
revalidateUser();
|
||||||
|
}}
|
||||||
|
onSwitchToPassword={() => {
|
||||||
|
setShowJellyfinQuickConnectModal(false);
|
||||||
|
setShowJellyfinModal(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
183
src/hooks/useQuickConnect.ts
Normal file
183
src/hooks/useQuickConnect.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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>();
|
||||||
|
const isMounted = useRef(true);
|
||||||
|
const hasInitiated = useRef(false);
|
||||||
|
const errorCount = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMounted.current = true;
|
||||||
|
const currentPollingInterval = pollingInterval.current;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted.current = false;
|
||||||
|
if (currentPollingInterval) {
|
||||||
|
clearInterval(currentPollingInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) {
|
||||||
|
hasInitiated.current = false;
|
||||||
|
}
|
||||||
|
}, [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);
|
||||||
|
|
||||||
|
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 (error) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -239,6 +239,17 @@
|
|||||||
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
||||||
"components.Layout.VersionStatus.streamdevelop": "Seerr Develop",
|
"components.Layout.VersionStatus.streamdevelop": "Seerr Develop",
|
||||||
"components.Layout.VersionStatus.streamstable": "Seerr Stable",
|
"components.Layout.VersionStatus.streamstable": "Seerr Stable",
|
||||||
|
"components.Login.JellyfinQuickConnectModal.authorizationFailed": "Quick Connect authorization failed.",
|
||||||
|
"components.Login.JellyfinQuickConnectModal.cancel": "Cancel",
|
||||||
|
"components.Login.JellyfinQuickConnectModal.error": "Error",
|
||||||
|
"components.Login.JellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.",
|
||||||
|
"components.Login.JellyfinQuickConnectModal.expired": "Code Expired",
|
||||||
|
"components.Login.JellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.",
|
||||||
|
"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.JellyfinQuickConnectModal.tryAgain": "Try Again",
|
||||||
|
"components.Login.JellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...",
|
||||||
"components.Login.adminerror": "You must use an admin account to sign in.",
|
"components.Login.adminerror": "You must use an admin account to sign in.",
|
||||||
"components.Login.back": "Go back",
|
"components.Login.back": "Go back",
|
||||||
"components.Login.credentialerror": "The username or password is incorrect.",
|
"components.Login.credentialerror": "The username or password is incorrect.",
|
||||||
@@ -257,6 +268,8 @@
|
|||||||
"components.Login.orsigninwith": "Or sign in with",
|
"components.Login.orsigninwith": "Or sign in with",
|
||||||
"components.Login.password": "Password",
|
"components.Login.password": "Password",
|
||||||
"components.Login.port": "Port",
|
"components.Login.port": "Port",
|
||||||
|
"components.Login.quickconnect": "Quick Connect",
|
||||||
|
"components.Login.quickconnecterror": "Quick Connect failed. Please try again.",
|
||||||
"components.Login.save": "Add",
|
"components.Login.save": "Add",
|
||||||
"components.Login.saving": "Adding…",
|
"components.Login.saving": "Adding…",
|
||||||
"components.Login.servertype": "Server Type",
|
"components.Login.servertype": "Server Type",
|
||||||
@@ -1383,11 +1396,23 @@
|
|||||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred",
|
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred",
|
||||||
"components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password",
|
"components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password",
|
||||||
"components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a 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.save": "Link",
|
||||||
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
|
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
|
||||||
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account",
|
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account",
|
||||||
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
|
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
|
||||||
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
|
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
|
||||||
|
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.error": "Error",
|
||||||
|
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorExists": "This account is already linked",
|
||||||
|
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.",
|
||||||
|
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expired": "Code Expired",
|
||||||
|
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.",
|
||||||
|
"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.tryAgain": "Try Again",
|
||||||
|
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.usePassword": "Use Password Instead",
|
||||||
|
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
|
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
|
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
|
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
|
||||||
|
|||||||
Reference in New Issue
Block a user