feat(linked-accounts): add support for linking/unlinking jellyfin accounts
This commit is contained in:
@@ -4383,6 +4383,54 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: User password updated
|
||||
/user/{userId}/settings/linked-accounts/jellyfin:
|
||||
post:
|
||||
summary: Link the provided Jellyfin account to the current user
|
||||
description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: 'Mr User'
|
||||
password:
|
||||
type: string
|
||||
example: 'supersecret'
|
||||
responses:
|
||||
'204':
|
||||
description: Linking account succeeded
|
||||
'403':
|
||||
description: Invalid credentials
|
||||
'422':
|
||||
description: Account already linked to a user
|
||||
delete:
|
||||
summary: Remove the linked Jellyfin account for a user
|
||||
description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'204':
|
||||
description: Unlinking account succeeded
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/notifications:
|
||||
get:
|
||||
summary: Get notification settings for a user
|
||||
|
||||
@@ -95,7 +95,11 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
class JellyfinAPI extends ExternalAPI {
|
||||
private userId?: string;
|
||||
|
||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
||||
constructor(
|
||||
jellyfinHost: string,
|
||||
authToken?: string | null,
|
||||
deviceId?: string | null
|
||||
) {
|
||||
let authHeaderVal: string;
|
||||
if (authToken) {
|
||||
authHeaderVal = `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`;
|
||||
|
||||
@@ -59,8 +59,8 @@ export class User {
|
||||
@Column({ nullable: true })
|
||||
public plexUsername?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinUsername?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinUsername?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public username?: string;
|
||||
@@ -80,14 +80,14 @@ export class User {
|
||||
@Column({ nullable: true, select: true })
|
||||
public plexId?: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinUserId?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinUserId?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinDeviceId?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinDeviceId?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public jellyfinAuthToken?: string;
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
public jellyfinAuthToken?: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public plexToken?: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
@@ -12,9 +14,23 @@ import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import net from 'net';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
|
||||
const isOwnProfile = (): Middleware => {
|
||||
return (req, res, next) => {
|
||||
if (req.user?.id !== Number(req.params.id)) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: "You do not have permission to view this user's settings.",
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
const isOwnProfileOrAdmin = (): Middleware => {
|
||||
const authMiddleware: Middleware = (req, res, next) => {
|
||||
if (
|
||||
@@ -290,6 +306,127 @@ userSettingsRoutes.post<
|
||||
}
|
||||
});
|
||||
|
||||
userSettingsRoutes.post<{ username: string; password: string }>(
|
||||
'/linked-accounts/jellyfin',
|
||||
isOwnProfile(),
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return next({ status: 401, message: 'Unauthorized' });
|
||||
}
|
||||
// Make sure jellyfin login is enabled
|
||||
if (settings.main.mediaServerType !== MediaServerType.JELLYFIN) {
|
||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||
}
|
||||
|
||||
// Do not allow linking of an already linked account
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUsername: req.body.username },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
error:
|
||||
'The specified Jellyfin account is already linked to a Jellyseerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
const deviceId = Buffer.from(
|
||||
`BOT_overseerr_${req.user.username ?? ''}`
|
||||
).toString('base64');
|
||||
|
||||
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||
|
||||
const ip = req.ip;
|
||||
let clientIp;
|
||||
if (ip) {
|
||||
if (net.isIPv4(ip)) {
|
||||
clientIp = ip;
|
||||
} else if (net.isIPv6(ip)) {
|
||||
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await jellyfinserver.login(
|
||||
req.body.username,
|
||||
req.body.password,
|
||||
clientIp
|
||||
);
|
||||
|
||||
// Do not allow linking of an already linked account
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
error:
|
||||
'The specified Jellyfin account is already linked to a Jellyseerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
|
||||
// valid jellyfin user found, link to current user
|
||||
user.userType = 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 Jellyfin account to user.', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
error: e,
|
||||
});
|
||||
if (
|
||||
e instanceof ApiError &&
|
||||
(e.errorCode == ApiErrorCode.InvalidCredentials ||
|
||||
e.errorCode == ApiErrorCode.NotAdmin)
|
||||
)
|
||||
return next({ status: 401, message: 'Unauthorized' });
|
||||
|
||||
return next({ status: 500 });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.delete<{ id: string }>(
|
||||
'/linked-accounts/jellyfin',
|
||||
isOwnProfileOrAdmin(),
|
||||
async (req, res, next) => {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
try {
|
||||
const user = await userRepository.findOne({
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return next({ status: 404, message: 'User not found.' });
|
||||
}
|
||||
|
||||
user.userType = UserType.LOCAL;
|
||||
user.jellyfinUserId = null;
|
||||
user.jellyfinUsername = null;
|
||||
user.jellyfinAuthToken = null;
|
||||
user.jellyfinDeviceId = null;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
'/notifications',
|
||||
isOwnProfileOrAdmin(),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { RequestError } from '@app/types/error';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const messages = defineMessages(
|
||||
'components.UserProfile.UserSettings.LinkJellyfinModal',
|
||||
{
|
||||
title: 'Link Jellyfin Account',
|
||||
description:
|
||||
'Enter your Jellyfin credentials to link your account with Jellyseerr.',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
usernameRequired: 'You must provide a username',
|
||||
passwordRequired: 'You must provide a password',
|
||||
saving: 'Adding…',
|
||||
save: 'Link',
|
||||
errorUnauthorized: 'Unable to connect to Jellyfin using your credentials',
|
||||
errorExists: 'This account is already linked to a Jellyseerr user',
|
||||
errorUnknown: 'An unknown error occurred',
|
||||
}
|
||||
);
|
||||
|
||||
interface LinkJellyfinModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const JellyfinLoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.usernameRequired)
|
||||
),
|
||||
password: Yup.string().required(
|
||||
intl.formatMessage(messages.passwordRequired)
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={show}
|
||||
enter="transition ease-in-out duration-300 transform opacity-0"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacuty-100"
|
||||
leave="transition ease-in-out duration-300 transform opacity-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={JellyfinLoginSchema}
|
||||
onSubmit={async ({ username, password }) => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new RequestError(res);
|
||||
|
||||
onSave();
|
||||
} catch (e) {
|
||||
if (e instanceof RequestError && e.status == 401) {
|
||||
setError(intl.formatMessage(messages.errorUnauthorized));
|
||||
} else if (e instanceof RequestError && e.status == 422) {
|
||||
setError(intl.formatMessage(messages.errorExists));
|
||||
} else {
|
||||
setError(intl.formatMessage(messages.errorServer));
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Modal
|
||||
onCancel={() => {
|
||||
setError(null);
|
||||
onClose();
|
||||
}}
|
||||
okButtonType="primary"
|
||||
okButtonProps={{ type: 'submit', form: 'link-jellyfin-account' }}
|
||||
okText={
|
||||
isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)
|
||||
}
|
||||
okDisabled={isSubmitting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
dialogClass="sm:max-w-lg"
|
||||
>
|
||||
<Form id="link-jellyfin-account">
|
||||
{intl.formatMessage(messages.description, {
|
||||
applicationName: settings.currentSettings.applicationTitle,
|
||||
})}
|
||||
{error && (
|
||||
<div className="mt-2">
|
||||
<Alert type="error">{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
<label htmlFor="username" className="text-label">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(messages.username)}
|
||||
/>
|
||||
</div>
|
||||
{errors.username && touched.username && (
|
||||
<div className="error">{errors.username}</div>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor="password" className="text-label">
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkJellyfinModal;
|
||||
@@ -1,10 +1,19 @@
|
||||
import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg';
|
||||
import PlexLogo from '@app/assets/services/plex.svg';
|
||||
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 { useUser } from '@app/hooks/useUser';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { TrashIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import LinkJellyfinModal from './LinkJellyfinModal';
|
||||
|
||||
const messages = defineMessages(
|
||||
'components.UserProfile.UserSettings.UserLinkedAccountsSettings',
|
||||
@@ -14,6 +23,9 @@ const messages = defineMessages(
|
||||
'These external accounts are linked to your Jellyseerr account.',
|
||||
noLinkedAccounts:
|
||||
'You do not have any external accounts linked to your account.',
|
||||
noPermissionDescription:
|
||||
"You do not have permission to modify this user's linked accounts.",
|
||||
deleteFailed: 'Unable to delete linked account.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -29,7 +41,16 @@ type LinkedAccount = {
|
||||
|
||||
const UserLinkedAccountsSettings = () => {
|
||||
const intl = useIntl();
|
||||
const { user } = useUser();
|
||||
const settings = useSettings();
|
||||
const router = useRouter();
|
||||
const { user: currentUser } = useUser();
|
||||
const {
|
||||
user,
|
||||
hasPermission,
|
||||
revalidate: revalidateUser,
|
||||
} = useUser({ id: Number(router.query.userId) });
|
||||
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const accounts: LinkedAccount[] = [
|
||||
...(user?.plexUsername
|
||||
@@ -40,6 +61,50 @@ const UserLinkedAccountsSettings = () => {
|
||||
: []),
|
||||
];
|
||||
|
||||
const linkable = [
|
||||
{
|
||||
name: 'Jellyfin',
|
||||
action: () => setShowJellyfinModal(true),
|
||||
hide:
|
||||
settings.currentSettings.mediaServerType != MediaServerType.JELLYFIN ||
|
||||
accounts.find((a) => a.type == LinkedAccountType.Jellyfin),
|
||||
},
|
||||
].filter((l) => !l.hide);
|
||||
|
||||
const deleteRequest = async (account: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/${account}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
} catch {
|
||||
setError(intl.formatMessage(messages.deleteFailed));
|
||||
}
|
||||
|
||||
revalidateUser();
|
||||
};
|
||||
|
||||
if (
|
||||
currentUser?.id !== user?.id &&
|
||||
hasPermission(Permission.ADMIN) &&
|
||||
currentUser?.id !== 1
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.linkedAccounts)}
|
||||
</h3>
|
||||
</div>
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.nopermissionDescription)}
|
||||
type="error"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -49,18 +114,39 @@ const UserLinkedAccountsSettings = () => {
|
||||
user?.displayName,
|
||||
]}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.linkedAccounts)}
|
||||
</h3>
|
||||
<h6 className="description">
|
||||
{intl.formatMessage(messages.linkedAccountsHint)}
|
||||
</h6>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div>
|
||||
<h3 className="heading">
|
||||
{intl.formatMessage(messages.linkedAccounts)}
|
||||
</h3>
|
||||
<h6 className="description">
|
||||
{intl.formatMessage(messages.linkedAccountsHint)}
|
||||
</h6>
|
||||
</div>
|
||||
{currentUser?.id == user?.id && !!linkable.length && (
|
||||
<div>
|
||||
<Dropdown text="Link Account" buttonType="ghost">
|
||||
{linkable.map(({ name, action }) => (
|
||||
<Dropdown.Item key={name} onClick={action}>
|
||||
{name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<Alert title={intl.formatMessage(globalMessages.failed)} type="error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{accounts.length ? (
|
||||
<ul className="space-y-4">
|
||||
{accounts.map((acct) => (
|
||||
<li className="flex items-center gap-4 overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6">
|
||||
{accounts.map((acct, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-lg bg-gray-800 bg-opacity-50 px-4 py-5 shadow ring-1 ring-gray-700 sm:p-6"
|
||||
>
|
||||
<div className="w-12">
|
||||
{acct.type == LinkedAccountType.Plex ? (
|
||||
<div className="flex aspect-square h-full items-center justify-center rounded-full bg-neutral-800">
|
||||
@@ -78,6 +164,18 @@ const UserLinkedAccountsSettings = () => {
|
||||
{acct.username}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<ConfirmButton
|
||||
onClick={() => {
|
||||
deleteRequest(
|
||||
acct.type == LinkedAccountType.Plex ? 'plex' : 'jellyfin'
|
||||
);
|
||||
}}
|
||||
confirmText={intl.formatMessage(globalMessages.areyousure)}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||
</ConfirmButton>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -88,6 +186,15 @@ const UserLinkedAccountsSettings = () => {
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkJellyfinModal
|
||||
show={showJellyfinModal}
|
||||
onClose={() => setShowJellyfinModal(false)}
|
||||
onSave={() => {
|
||||
setShowJellyfinModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface User {
|
||||
id: number;
|
||||
warnings: string[];
|
||||
plexUsername?: string;
|
||||
jellyfinUsername?: string;
|
||||
jellyfinUsername?: string | null;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
|
||||
@@ -1261,6 +1261,17 @@
|
||||
"components.UserProfile.ProfileHeader.profile": "View Profile",
|
||||
"components.UserProfile.ProfileHeader.settings": "Edit Settings",
|
||||
"components.UserProfile.ProfileHeader.userid": "User ID: {userid}",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.description": "Enter your Jellyfin credentials to link your account with Jellyseerr.",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorExists": "This account is already linked to a Jellyseerr user",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnauthorized": "Unable to connect to Jellyfin using your credentials",
|
||||
"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.save": "Link",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link Jellyfin Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
|
||||
@@ -1301,9 +1312,11 @@
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailrequired": "Email required",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.deleteFailed": "Unable to delete linked account.",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccounts": "Linked Accounts",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.linkedAccountsHint": "These external accounts are linked to your Jellyseerr account.",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noLinkedAccounts": "You do not have any external accounts linked to your account.",
|
||||
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.noPermissionDescription": "You do not have permission to modify this user's linked accounts.",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID",
|
||||
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account",
|
||||
|
||||
16
src/pages/users/[userId]/settings/linked-accounts.tsx
Normal file
16
src/pages/users/[userId]/settings/linked-accounts.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import UserSettings from '@app/components/UserProfile/UserSettings';
|
||||
import UserLinkedAccountsSettings from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings';
|
||||
import useRouteGuard from '@app/hooks/useRouteGuard';
|
||||
import { Permission } from '@app/hooks/useUser';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const UserLinkedAccountsPage: NextPage = () => {
|
||||
useRouteGuard(Permission.MANAGE_USERS);
|
||||
return (
|
||||
<UserSettings>
|
||||
<UserLinkedAccountsSettings />
|
||||
</UserSettings>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserLinkedAccountsPage;
|
||||
11
src/types/error.ts
Normal file
11
src/types/error.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class RequestError extends Error {
|
||||
status: number;
|
||||
res: Response;
|
||||
|
||||
constructor(res: Response) {
|
||||
const status = res.status;
|
||||
super(`Request failed with status code ${status}`);
|
||||
this.status = status;
|
||||
this.res = res;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user