pull/3015/merge
Ryan Cohen 2 weeks ago committed by GitHub
commit 2745b9efd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -149,7 +149,6 @@ describe('Discover', () => {
plexUsername: null,
username: '',
recoveryLinkExpirationDate: null,
userType: 2,
avatar:
'https://gravatar.com/avatar/c77fdc27cab83732b8623d2ea873d330?default=mm&size=200',
movieQuotaLimit: null,

@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => {
[email, password],
() => {
cy.visit('/login');
cy.contains('Use your Overseerr account').click();
cy.get('[data-testid=email]').type(email);
cy.get('[data-testid=password]').type(password);

@ -63,10 +63,6 @@ components:
plexUsername:
type: string
readOnly: true
userType:
type: integer
example: 1
readOnly: true
permissions:
type: number
example: 0
@ -85,6 +81,14 @@ components:
type: number
example: 5
readOnly: true
isPlexUser:
type: boolean
example: false
readOnly: true
isLocalUser:
type: boolean
example: true
readOnly: true
required:
- id
- email
@ -3325,6 +3329,15 @@ paths:
type: string
required:
- authToken
/auth/plex/unlink:
get:
summary: Unlink Plex from currently logged in account
description: Will remove connected Plex account information from the currently logged-in user.
tags:
- auth
responses:
'204':
description: OK
/auth/local:
post:
summary: Sign in using a local account

@ -1,4 +0,0 @@
export enum UserType {
PLEX = 1,
LOCAL = 2,
}

@ -1,5 +1,4 @@
import { MediaRequestStatus, MediaType } from '@server/constants/media';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import PreparedEmail from '@server/lib/email';
@ -35,14 +34,22 @@ export class User {
public static filterMany(
users: User[],
showFiltered?: boolean
): Partial<User>[] {
): Omit<User, keyof typeof User.filteredFields>[] {
return users.map((u) => u.filter(showFiltered));
}
static readonly filteredFields: string[] = ['email', 'plexId'];
// Fields that show only be shown to admins in user API responses
static readonly filteredFields: (keyof User)[] = ['email', 'plexId'];
// Fields that should never be shown in API responses
static readonly secureFields: (keyof User)[] = ['password'];
public displayName: string;
public isPlexUser: boolean;
public isLocalUser: boolean;
@PrimaryGeneratedColumn()
public id: number;
@ -55,13 +62,13 @@ export class User {
})
public email: string;
@Column({ nullable: true })
public plexUsername?: string;
@Column({ type: 'varchar', nullable: true })
public plexUsername?: string | null;
@Column({ nullable: true })
public username?: string;
@Column({ nullable: true, select: false })
@Column({ nullable: true })
public password?: string;
@Column({ nullable: true, select: false })
@ -70,14 +77,11 @@ export class User {
@Column({ type: 'date', nullable: true })
public recoveryLinkExpirationDate?: Date | null;
@Column({ type: 'integer', default: UserType.PLEX })
public userType: UserType;
@Column({ nullable: true, select: true })
public plexId?: number;
@Column({ type: 'int', nullable: true })
public plexId?: number | null;
@Column({ nullable: true, select: false })
public plexToken?: string;
@Column({ type: 'varchar', nullable: true, select: false })
public plexToken?: string | null;
@Column({ type: 'integer', default: 0 })
public permissions = 0;
@ -126,13 +130,17 @@ export class User {
Object.assign(this, init);
}
public filter(showFiltered?: boolean): Partial<User> {
const filtered: Partial<User> = Object.assign(
{},
...(Object.keys(this) as (keyof User)[])
.filter((k) => showFiltered || !User.filteredFields.includes(k))
.map((k) => ({ [k]: this[k] }))
);
public filter(
showFiltered?: boolean
): Omit<User, keyof typeof User.filteredFields> {
const filtered: Omit<User, keyof typeof User.filteredFields> =
Object.assign(
{},
...(Object.keys(this) as (keyof User)[])
.filter((k) => showFiltered || !User.filteredFields.includes(k))
.filter((k) => !User.secureFields.includes(k))
.map((k) => ({ [k]: this[k] }))
);
return filtered;
}
@ -229,8 +237,10 @@ export class User {
}
@AfterLoad()
public setDisplayName(): void {
public setLocalProperties(): void {
this.displayName = this.username || this.plexUsername || this.email;
this.isPlexUser = !!this.plexId;
this.isLocalUser = !!this.password;
}
public async getQuota(): Promise<QuotaResponse> {

@ -70,7 +70,7 @@ app
where: { id: 1 },
});
if (admin) {
if (admin?.plexToken) {
logger.info('Migrating Plex libraries to include media type', {
label: 'Settings',
});

@ -37,6 +37,7 @@ export interface PublicSettingsResponse {
locale: string;
emailEnabled: boolean;
newPlexLogin: boolean;
plexLoginEnabled: boolean;
}
export interface CacheItem {

@ -42,7 +42,7 @@ class AvailabilitySync {
where: { id: 1 },
});
if (admin) {
if (admin?.plexToken) {
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
} else {
logger.error('An admin is not configured.');

@ -70,6 +70,10 @@ class PlexScanner
return this.log('No admin configured. Plex scan skipped.', 'warn');
}
if (!admin.plexToken || !settings.plex.ip) {
return this.log('Plex server is not configured.', 'warn');
}
this.plexClient = new PlexAPI({ plexToken: admin.plexToken });
this.libraries = settings.plex.libraries.filter(

@ -110,7 +110,7 @@ interface PublicSettings {
initialized: boolean;
}
interface FullPublicSettings extends PublicSettings {
export interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
@ -126,6 +126,7 @@ interface FullPublicSettings extends PublicSettings {
locale: string;
emailEnabled: boolean;
newPlexLogin: boolean;
plexLoginEnabled: boolean;
}
export interface NotificationAgentConfig {
@ -508,6 +509,7 @@ class Settings {
locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled,
newPlexLogin: this.data.main.newPlexLogin,
plexLoginEnabled: false,
};
}

@ -1,5 +1,4 @@
import PlexTvAPI from '@server/api/plextv';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { Permission } from '@server/lib/permissions';
@ -7,6 +6,7 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
const authRoutes = Router();
@ -41,14 +41,21 @@ authRoutes.post('/plex', async (req, res, next) => {
const plextv = new PlexTvAPI(body.authToken);
const account = await plextv.getUser();
// Next let's see if the user already exists
let user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
let user: User | null;
// If we are already logged in, we should just get the currently logged in user
// otherwise we will try to match to an existing users email or plex ID
if (req.user) {
user = await userRepository.findOneBy({ id: req.user.id });
} else {
user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.orWhere('user.email = :email', {
email: account.email.toLowerCase(),
})
.getOne();
}
if (!user && !(await userRepository.count())) {
user = new User({
@ -58,7 +65,6 @@ authRoutes.post('/plex', async (req, res, next) => {
plexToken: account.authToken,
permissions: Permission.ADMIN,
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(user);
@ -85,13 +91,14 @@ authRoutes.post('/plex', async (req, res, next) => {
if (
account.id === mainUser.plexId ||
(user && user.id === 1 && !user.plexId) ||
(account.email === mainUser.email && !mainUser.plexId) ||
(await mainPlexTv.checkUserAccess(account.id))
) {
if (user) {
if (!user.plexId) {
logger.info(
'Found matching Plex user; updating user with Plex data',
'Found matching Plex user; updating user with Plex data. Notice: Emails are no longer synced.',
{
label: 'API',
ip: req.ip,
@ -106,9 +113,7 @@ authRoutes.post('/plex', async (req, res, next) => {
user.plexToken = body.authToken;
user.plexId = account.id;
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
user.userType = UserType.PLEX;
await userRepository.save(user);
} else if (!settings.main.newPlexLogin) {
@ -144,7 +149,6 @@ authRoutes.post('/plex', async (req, res, next) => {
plexToken: account.authToken,
permissions: settings.main.defaultPermissions,
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(user);
@ -186,6 +190,40 @@ authRoutes.post('/plex', async (req, res, next) => {
}
});
authRoutes.get('/plex/unlink', isAuthenticated(), async (req, res, next) => {
const userRepository = getRepository(User);
try {
if (!req.user) {
throw new Error('User data is not present in request.');
}
const user = await userRepository.findOneByOrFail({ id: req.user.id });
if (!user.isLocalUser) {
throw new Error('User must have a local password set to unlink Plex.');
}
user.plexId = null;
user.plexToken = null;
user.avatar = gravatarUrl(user.email, { default: 'mm', size: 200 });
user.plexUsername = null;
await userRepository.save(user);
return res.status(204).send();
} catch (e) {
logger.error('Something went wrong unlinking a Plex account', {
label: 'API',
errorMessage: e.message,
userId: req.user?.id,
});
return next({
status: 500,
message: 'Unable to unlink plex account.',
});
}
});
authRoutes.post('/local', async (req, res, next) => {
const settings = getSettings();
const userRepository = getRepository(User);
@ -199,13 +237,22 @@ authRoutes.post('/local', async (req, res, next) => {
});
}
try {
const user = await userRepository
let user = await userRepository
.createQueryBuilder('user')
.select(['user.id', 'user.email', 'user.password', 'user.plexId'])
.where('user.email = :email', { email: body.email.toLowerCase() })
.getOne();
if (!user || !(await user.passwordMatch(body.password))) {
if (!user && !(await userRepository.count())) {
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
user = new User({
email: body.email,
permissions: Permission.ADMIN,
avatar,
});
await user.setPassword(body.password);
await userRepository.save(user);
} else if (!user || !(await user.passwordMatch(body.password))) {
logger.warn('Failed sign-in attempt using invalid Overseerr password', {
label: 'API',
ip: req.ip,
@ -218,19 +265,19 @@ authRoutes.post('/local', async (req, res, next) => {
});
}
const mainUser = await userRepository.findOneOrFail({
const mainUser = await userRepository.findOne({
select: { id: true, plexToken: true, plexId: true },
where: { id: 1 },
});
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
const mainPlexTv = new PlexTvAPI(mainUser?.plexToken ?? '');
if (!user.plexId) {
if (!user.plexId && mainUser?.isPlexUser) {
try {
const plexUsersResponse = await mainPlexTv.getUsers();
const account = plexUsersResponse.MediaContainer.User.find(
(account) =>
account.$.email &&
account.$.email.toLowerCase() === user.email.toLowerCase()
account.$.email.toLowerCase() === user?.email.toLowerCase()
)?.$;
if (
@ -253,7 +300,6 @@ authRoutes.post('/local', async (req, res, next) => {
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
user.userType = UserType.PLEX;
await userRepository.save(user);
}
@ -266,6 +312,7 @@ authRoutes.post('/local', async (req, res, next) => {
}
if (
mainUser?.isPlexUser &&
user.plexId &&
user.plexId !== mainUser.plexId &&
!(await mainPlexTv.checkUserAccess(user.plexId))
@ -289,7 +336,7 @@ authRoutes.post('/local', async (req, res, next) => {
}
// Set logged in session
if (user && req.session) {
if (req.session) {
req.session.userId = user.id;
}

@ -7,9 +7,10 @@ import type {
} from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import { User } from '@server/entity/User';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import { getSettings, type FullPublicSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
@ -95,16 +96,23 @@ router.get('/status/appdata', (_req, res) => {
});
router.use('/user', isAuthenticated(), user);
router.get('/settings/public', async (req, res) => {
router.get<never, FullPublicSettings>('/settings/public', async (req, res) => {
const settings = getSettings();
const userRepository = getRepository(User);
const fullPublicSettings: FullPublicSettings = settings.fullPublicSettings;
if (!(req.user?.settings?.notificationTypes.webpush ?? true)) {
return res
.status(200)
.json({ ...settings.fullPublicSettings, enablePushRegistration: false });
} else {
return res.status(200).json(settings.fullPublicSettings);
fullPublicSettings.enablePushRegistration = false;
}
const admin = await userRepository.findOneBy({ id: 1 });
if (admin && admin.plexId) {
fullPublicSettings.plexLoginEnabled = true;
}
return res.status(200).json(fullPublicSettings);
});
router.get('/settings/discover', isAuthenticated(), async (_req, res) => {
const sliderRepository = getRepository(DiscoverSlider);

@ -17,7 +17,7 @@ import cacheManager from '@server/lib/cache';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { plexFullScanner } from '@server/lib/scanners/plex';
import type { JobId, MainSettings } from '@server/lib/settings';
import type { JobId, MainSettings, PlexSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
@ -85,10 +85,25 @@ settingsRoutes.post('/main/regenerate', (req, res, next) => {
return res.status(200).json(filteredMainSettings(req.user, main));
});
settingsRoutes.get('/plex', (_req, res) => {
type PlexSettingsResponse = PlexSettings & {
plexAvailable: boolean;
};
settingsRoutes.get<never, PlexSettingsResponse>('/plex', async (_req, res) => {
const settings = getSettings();
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: { id: true, plexToken: true },
where: { id: 1 },
});
res.status(200).json(settings.plex);
const settingsResponse: PlexSettingsResponse = {
...settings.plex,
plexAvailable: !!admin.plexToken,
};
res.status(200).json(settingsResponse);
});
settingsRoutes.post('/plex', async (req, res, next) => {
@ -100,6 +115,12 @@ settingsRoutes.post('/plex', async (req, res, next) => {
where: { id: 1 },
});
if (!admin.plexToken) {
throw new Error(
'The administrator must have their account connected to Plex to be able to set up a Plex server.'
);
}
Object.assign(settings.plex, req.body);
const plexClient = new PlexAPI({ plexToken: admin.plexToken });
@ -135,9 +156,12 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
select: { id: true, plexToken: true },
where: { id: 1 },
});
const plexTvClient = admin.plexToken
? new PlexTvAPI(admin.plexToken)
: null;
if (!admin.plexToken) {
throw new Error('Plex must be configured to retrieve servers.');
}
const plexTvClient = new PlexTvAPI(admin.plexToken);
const devices = (await plexTvClient?.getDevices())?.filter((device) => {
return device.provides.includes('server') && device.owned;
});
@ -174,7 +198,7 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
useSsl: connection.protocol === 'https',
};
const plexClient = new PlexAPI({
plexToken: admin.plexToken,
plexToken: admin.plexToken ?? '',
plexSettings: plexDeviceSettings,
timeout: 5000,
});
@ -205,7 +229,7 @@ settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
}
});
settingsRoutes.get('/plex/library', async (req, res) => {
settingsRoutes.get('/plex/library', async (req, res, next) => {
const settings = getSettings();
if (req.query.sync) {
@ -214,6 +238,14 @@ settingsRoutes.get('/plex/library', async (req, res) => {
select: { id: true, plexToken: true },
where: { id: 1 },
});
if (!admin.plexToken) {
return next({
status: '500',
message: 'Plex must be configured to retrieve libraries.',
});
}
const plexapi = new PlexAPI({ plexToken: admin.plexToken });
await plexapi.syncLibraries();

@ -1,7 +1,6 @@
import PlexTvAPI from '@server/api/plextv';
import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
@ -121,7 +120,6 @@ router.post(
password: body.password,
permissions: settings.main.defaultPermissions,
plexToken: '',
userType: UserType.LOCAL,
});
if (passedExplicitPassword) {
@ -441,12 +439,7 @@ router.post(
user.avatar = account.thumb;
user.email = account.email;
user.plexUsername = account.username;
// In case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
}
user.plexId = parseInt(account.id);
await userRepository.save(user);
} else if (!body || body.plexIds.includes(account.id)) {
if (await mainPlexTv.checkUserAccess(parseInt(account.id))) {
@ -457,7 +450,6 @@ router.post(
plexId: parseInt(account.id),
plexToken: '',
avatar: account.thumb,
userType: UserType.PLEX,
});
await userRepository.save(newUser);
createdUsers.push(newUser);

@ -1,4 +1,3 @@
import { UserType } from '@server/constants/user';
import dataSource, { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { copyFileSync } from 'fs';
@ -43,7 +42,6 @@ const prepareDb = async () => {
user.plexUsername = 'admin';
user.username = 'admin';
user.email = 'admin@seerr.dev';
user.userType = UserType.PLEX;
await user.setPassword('test1234');
user.permissions = 2;
user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 });
@ -59,7 +57,6 @@ const prepareDb = async () => {
otherUser.plexUsername = 'friend';
otherUser.username = 'friend';
otherUser.email = 'friend@seerr.dev';
otherUser.userType = UserType.PLEX;
await otherUser.setPassword('test1234');
otherUser.permissions = 32;
otherUser.avatar = gravatarUrl('friend@seerr.dev', {

@ -1 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff}.cls-2{fill:url(#a)}.cls-3{fill:#e5a00d}</style><radialGradient id="a" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#f9be03"/><stop offset=".51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><polygon points="320.03 -.09 289.96 -.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79" class="cls-1"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-2"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-3"/><path d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z" class="cls-1"/><path d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z" class="cls-1"/><path d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z" class="cls-1"/></svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="plex-logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 148 68.2" style="enable-background:new 0 0 148 68.2;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#EBAF00;}
</style>
<g>
<g>
<path class="st0" d="M24.3,12.2c-5.9,0-9.7,1.7-12.9,5.7v-4.4H0v54.2c0,0,0.2,0.1,0.8,0.2c0.8,0.2,5,1.1,8.1-1.5
c2.7-2.3,3.3-5,3.3-8v-7.8c3.3,3.5,7,5,12.2,5c11.2,0,19.8-9.1,19.8-21.2C44.2,21.3,35.9,12.2,24.3,12.2z M22.1,45.3
c-6.3,0-11.3-5.2-11.3-11.5c0-6.2,5.9-11.2,11.3-11.2c6.4,0,11.3,4.9,11.3,11.3C33.4,40.3,28.4,45.3,22.1,45.3z"/>
<path class="st0" d="M60.4,33.1c0,4.7,0.5,10.4,5.1,16.6c0.1,0.1,0.3,0.4,0.3,0.4c-1.9,3.2-4.2,5.4-7.3,5.4
c-2.4,0-4.8-1.3-6.8-3.5c-2.1-2.4-3.1-5.5-3.1-8.8l0-43.2h11.7L60.4,33.1z"/>
<polygon class="st1" points="118.3,54.2 104.1,54.2 117.9,33.9 104.1,13.5 118.3,13.5 132,33.9 "/>
<polygon class="st0" points="135.7,31.6 148,13.5 133.8,13.5 128.7,21 "/>
<path class="st0" d="M128.7,46.8c0,0,2.4,3.3,2.4,3.3c2.3,3.6,5.3,5.4,8.8,5.4c3.7-0.1,6.3-3.3,7.3-4.5c0,0-1.8-1.6-4.1-4.3
c-3.1-3.6-7.2-10.2-7.3-10.5L128.7,46.8z"/>
</g>
<path class="st0" d="M93.6,42.5c-2.4,2.2-4,3.4-7.3,3.4c-5.9,0-9.3-4.2-9.8-8.8h31.3c0.2-0.6,0.3-1.4,0.3-2.7
c0-12.7-9.3-22.2-21.5-22.2C75,12.2,65.5,21.9,65.5,34c0,12,9.5,21.5,21.4,21.5c8.3,0,15.5-4.7,19.4-13H93.6z M86.7,21.8
c5.2,0,9.1,3.4,10,7.9H76.9C77.9,25,81.6,21.8,86.7,21.8z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -1,72 +0,0 @@
import type * as React from 'react';
import { useState } from 'react';
import AnimateHeight from 'react-animate-height';
export interface AccordionProps {
children: (args: AccordionChildProps) => JSX.Element;
/** If true, only one accordion item can be open at any time */
single?: boolean;
/** If true, at least one accordion item will always be open */
atLeastOne?: boolean;
initialOpenIndexes?: number[];
}
export interface AccordionChildProps {
openIndexes: number[];
handleClick(index: number): void;
AccordionContent: typeof AccordionContent;
}
type AccordionContentProps = {
isOpen: boolean;
children: React.ReactNode;
};
export const AccordionContent = ({
isOpen,
children,
}: AccordionContentProps) => {
return <AnimateHeight height={isOpen ? 'auto' : 0}>{children}</AnimateHeight>;
};
const Accordion = ({
single,
atLeastOne,
initialOpenIndexes,
children,
}: AccordionProps) => {
const initialState = initialOpenIndexes || (atLeastOne && [0]) || [];
const [openIndexes, setOpenIndexes] = useState<number[]>(initialState);
const close = (index: number) => {
const openCount = openIndexes.length;
const newListOfIndexes =
atLeastOne && openCount === 1 && openIndexes.includes(index)
? openIndexes
: openIndexes.filter((i) => i !== index);
setOpenIndexes(newListOfIndexes);
};
const open = (index: number) => {
const newListOfIndexes = single ? [index] : [...openIndexes, index];
setOpenIndexes(newListOfIndexes);
};
const handleItemClick = (index: number) => {
const action = openIndexes.includes(index) ? 'closing' : 'opening';
if (action === 'closing') {
close(index);
} else {
open(index);
}
};
return children({
openIndexes: openIndexes,
handleClick: handleItemClick,
AccordionContent,
});
};
export default Accordion;

@ -7,7 +7,8 @@ export type ButtonType =
| 'danger'
| 'warning'
| 'success'
| 'ghost';
| 'ghost'
| 'plex';
// Helper type to override types (overrides onClick)
type MergeElementProps<
@ -74,6 +75,9 @@ function Button<P extends ElementTypes = 'button'>(
'text-white bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
case 'plex':
buttonStyle.push('plex-button');
break;
default:
buttonStyle.push(
'text-gray-200 bg-gray-800 bg-opacity-80 border-gray-600 hover:text-white hover:bg-gray-700 hover:border-gray-600 group-hover:text-white group-hover:bg-gray-700 group-hover:border-gray-600 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-700 active:border-gray-600'

@ -8,6 +8,10 @@ type TooltipProps = {
children: React.ReactElement;
tooltipConfig?: Partial<Config>;
className?: string;
/**
* When true, the tooltip will not be shown
*/
disabled?: boolean;
};
const Tooltip = ({
@ -15,6 +19,7 @@ const Tooltip = ({
content,
tooltipConfig,
className,
disabled,
}: TooltipProps) => {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
@ -24,6 +29,10 @@ const Tooltip = ({
...tooltipConfig,
});
if (disabled) {
return children;
}
const tooltipStyle = [
'z-50 text-sm absolute font-normal bg-gray-800 px-2 py-1 rounded border border-gray-600 shadow text-gray-100',
];

@ -1,6 +1,6 @@
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { UserType, useUser } from '@app/hooks/useUser';
import { useUser } from '@app/hooks/useUser';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import Link from 'next/link';
@ -22,12 +22,12 @@ const PlexWatchlistSlider = () => {
totalPages: number;
totalResults: number;
results: WatchlistItem[];
}>(user?.userType === UserType.PLEX ? '/api/v1/discover/watchlist' : null, {
}>(user?.isPlexUser ? '/api/v1/discover/watchlist' : null, {
revalidateOnMount: true,
});
if (
user?.userType !== UserType.PLEX ||
!user?.isPlexUser ||
(watchlistItems &&
watchlistItems.results.length === 0 &&
!user?.settings?.watchlistSyncMovies &&

@ -1,6 +1,7 @@
import Button from '@app/components/Common/Button';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import {
ArrowLeftOnRectangleIcon,
LifebuoyIcon,
@ -8,7 +9,7 @@ import {
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useState } from 'react';
import { useRouter } from 'next/router';
import { defineMessages, useIntl } from 'react-intl';
import * as Yup from 'yup';
@ -23,14 +24,15 @@ const messages = defineMessages({
forgotpassword: 'Forgot Password?',
});
interface LocalLoginProps {
revalidate: () => void;
}
type LocalLoginProps = {
onError: (errorMessage: string) => void;
};
const LocalLogin = ({ revalidate }: LocalLoginProps) => {
const LocalLogin = ({ onError }: LocalLoginProps) => {
const intl = useIntl();
const router = useRouter();
const { revalidate } = useUser();
const settings = useSettings();
const [loginError, setLoginError] = useState<string | null>(null);
const LoginSchema = Yup.object().shape({
email: Yup.string()
@ -54,100 +56,99 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
validationSchema={LoginSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/local', {
const response = await axios.post('/api/v1/auth/local', {
email: values.email,
password: values.password,
});
if (response.data?.id) {
const user = await revalidate();
if (user) {
router.push('/');
}
}
} catch (e) {
setLoginError(intl.formatMessage(messages.loginerror));
} finally {
revalidate();
onError(intl.formatMessage(messages.loginerror));
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => {
return (
<>
<Form>
<div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<Field
id="email"
name="email"
type="text"
inputMode="email"
data-testid="email"
/>
</div>
{errors.email &&
touched.email &&
typeof errors.email === 'string' && (
<div className="error">{errors.email}</div>
)}
<Form data-testid="local-login-form">
<div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<Field
id="email"
name="email"
type="text"
inputMode="email"
data-testid="email"
/>
</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="form-input-field">
<SensitiveInput
as="field"
id="password"
name="password"
type="password"
autoComplete="current-password"
data-testid="password"
/>
</div>
{errors.password &&
touched.password &&
typeof errors.password === 'string' && (
<div className="error">{errors.password}</div>
)}
{errors.email &&
touched.email &&
typeof errors.email === 'string' && (
<div className="error">{errors.email}</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="form-input-field">
<SensitiveInput
as="field"
id="password"
name="password"
type="password"
autoComplete="current-password"
data-testid="password"
/>
</div>
{loginError && (
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="error">{loginError}</div>
</div>
)}
{errors.password &&
touched.password &&
typeof errors.password === 'string' && (
<div className="error">{errors.password}</div>
)}
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex flex-row-reverse justify-between">
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex flex-row-reverse justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
data-testid="local-signin-button"
>
<ArrowLeftOnRectangleIcon />
<span>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</span>
</Button>
</span>
{passwordResetEnabled && (
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
data-testid="local-signin-button"
>
<ArrowLeftOnRectangleIcon />
<span>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</span>
</Button>
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
<LifebuoyIcon />
<span>
{intl.formatMessage(messages.forgotpassword)}
</span>
</Button>
</Link>
</span>
{passwordResetEnabled && (
<span className="inline-flex rounded-md shadow-sm">
<Link href="/resetpassword" passHref>
<Button as="a" buttonType="ghost">
<LifebuoyIcon />
<span>
{intl.formatMessage(messages.forgotpassword)}
</span>
</Button>
</Link>
</span>
)}
</div>
)}
</div>
</Form>
</>
</div>
</Form>
);
}}
</Formik>

@ -0,0 +1,49 @@
import PlexLoginButton from '@app/components/PlexLoginButton';
import { useUser } from '@app/hooks/useUser';
import axios from 'axios';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
loginwithplex: 'Sign In with Plex',
});
type PlexLoginProps = {
onError: (errorMessage: string) => void;
};
const PlexLogin = ({ onError }: PlexLoginProps) => {
const intl = useIntl();
const router = useRouter();
const { revalidate } = useUser();
const [isProcessing, setProcessing] = useState(false);
const login = async (authToken: string) => {
setProcessing(true);
try {
const response = await axios.post('/api/v1/auth/plex', { authToken });
if (response.data?.id) {
const user = await revalidate();
if (user) {
router.push('/');
}
}
} catch (e) {
onError(e.response.data.message);
setProcessing(false);
}
};
return (
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={login}
textOverride={intl.formatMessage(messages.loginwithplex)}
/>
);
};
export default PlexLogin;

@ -1,66 +1,25 @@
import Accordion from '@app/components/Common/Accordion';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import LocalLogin from '@app/components/Login/LocalLogin';
import PlexLoginButton from '@app/components/PlexLoginButton';
import PlexLogin from '@app/components/Login/PlexLogin';
import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { useRouter } from 'next/dist/client/router';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
signin: 'Sign In',
signinheader: 'Sign in to continue',
signinwithplex: 'Use your Plex account',
signinwithoverseerr: 'Use your {applicationTitle} account',
});
const Login = () => {
const intl = useIntl();
const [error, setError] = useState('');
const [isProcessing, setProcessing] = useState(false);
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const { user, revalidate } = useUser();
const router = useRouter();
const settings = useSettings();
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to sign in. If we get a success message, we will
// ask swr to revalidate the user which _should_ come back with a valid user.
useEffect(() => {
const login = async () => {
setProcessing(true);
try {
const response = await axios.post('/api/v1/auth/plex', { authToken });
if (response.data?.id) {
revalidate();
}
} catch (e) {
setError(e.response.data.message);
setAuthToken(undefined);
setProcessing(false);
}
};
if (authToken) {
login();
}
}, [authToken, revalidate]);
// Effect that is triggered whenever `useUser`'s user changes. If we get a new
// valid user, we redirect the user to the home page as the login was successful.
useEffect(() => {
if (user) {
router.push('/');
}
}, [user, router]);
const { data: backdrops } = useSWR<string[]>('/api/v1/backdrops', {
refreshInterval: 0,
refreshWhenHidden: false,
@ -88,7 +47,7 @@ const Login = () => {
</div>
<div className="relative z-50 mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div
className="bg-gray-800 bg-opacity-50 shadow sm:rounded-lg"
className="flex flex-col bg-gray-800 bg-opacity-50 p-4 shadow sm:rounded-lg "
style={{ backdropFilter: 'blur(5px)' }}
>
<>
@ -115,54 +74,20 @@ const Login = () => {
</div>
</div>
</Transition>
<Accordion single atLeastOne>
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${
settings.currentSettings.localLogin &&
'hover:cursor-pointer hover:bg-gray-700'
}`}
onClick={() => handleClick(0)}
disabled={!settings.currentSettings.localLogin}
>
{intl.formatMessage(messages.signinwithplex)}
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8">
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
</div>
</AccordionContent>
{settings.currentSettings.localLogin && (
<div>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
}`}
onClick={() => handleClick(1)}
>
{intl.formatMessage(messages.signinwithoverseerr, {
applicationTitle:
settings.currentSettings.applicationTitle,
})}
</button>
<AccordionContent isOpen={openIndexes.includes(1)}>
<div className="px-10 py-8">
<LocalLogin revalidate={revalidate} />
</div>
</AccordionContent>
</div>
)}
</>
{settings.currentSettings.plexLoginEnabled && (
<PlexLogin onError={(msg) => setError(msg)} />
)}
{settings.currentSettings.plexLoginEnabled &&
settings.currentSettings.localLogin && (
<div className="relative flex items-center py-4">
<div className="flex-grow border-t border-gray-500"></div>
<span className="mx-4 flex-shrink text-gray-400">or</span>
<div className="flex-grow border-t border-gray-500"></div>
</div>
)}
</Accordion>
{settings.currentSettings.localLogin && (
<LocalLogin onError={(msg) => setError(msg)} />
)}
</>
</div>
</div>

@ -1,3 +1,4 @@
import Button from '@app/components/Common/Button';
import globalMessages from '@app/i18n/globalMessages';
import PlexOAuth from '@app/utils/plex';
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
@ -11,16 +12,27 @@ const messages = defineMessages({
const plexOAuth = new PlexOAuth();
interface PlexLoginButtonProps {
type PlexLoginButtonProps = Pick<
React.ComponentPropsWithoutRef<typeof Button>,
'buttonSize' | 'buttonType'
> & {
onAuthToken: (authToken: string) => void;
isProcessing?: boolean;
onError?: (message: string) => void;
}
textOverride?: string;
svgIcon?: React.ReactNode;
disabled?: boolean;
};
const PlexLoginButton = ({
onAuthToken,
onError,
isProcessing,
textOverride,
buttonType = 'plex',
buttonSize,
svgIcon,
disabled,
}: PlexLoginButtonProps) => {
const intl = useIntl();
const [loading, setLoading] = useState(false);
@ -40,24 +52,27 @@ const PlexLoginButton = ({
};
return (
<span className="block w-full rounded-md shadow-sm">
<button
<Button
type="button"
onClick={() => {
plexOAuth.preparePopup();
setTimeout(() => getPlexLogin(), 1500);
}}
disabled={loading || isProcessing}
className="plex-button"
disabled={loading || isProcessing || disabled}
buttonType={buttonType}
buttonSize={buttonSize}
>
<ArrowLeftOnRectangleIcon />
{svgIcon ?? <ArrowLeftOnRectangleIcon />}
<span>
{loading
? intl.formatMessage(globalMessages.loading)
: isProcessing
? intl.formatMessage(messages.signingin)
: textOverride
? textOverride
: intl.formatMessage(messages.signinwithplex)}
</span>
</button>
</Button>
</span>
);
};

@ -6,6 +6,8 @@ import PageTitle from '@app/components/Common/PageTitle';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import LibraryItem from '@app/components/Settings/LibraryItem';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import LoginWithPlex from '@app/components/Setup/LoginWithPlex';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import {
@ -110,6 +112,7 @@ interface SettingsPlexProps {
}
const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
const { user } = useUser();
const [isSyncing, setIsSyncing] = useState(false);
const [isRefreshingPresets, setIsRefreshingPresets] = useState(false);
const [availableServers, setAvailableServers] = useState<PlexDevice[] | null>(
@ -119,7 +122,11 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
data,
error,
mutate: revalidate,
} = useSWR<PlexSettings>('/api/v1/settings/plex');
} = useSWR<
PlexSettings & {
plexAvailable: boolean;
}
>('/api/v1/settings/plex');
const { data: dataTautulli, mutate: revalidateTautulli } =
useSWR<TautulliSettings>('/api/v1/settings/tautulli');
const { data: dataSync, mutate: revalidateSync } = useSWR<SyncStatus>(
@ -329,7 +336,8 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
if ((!data || !dataTautulli) && !error) {
return <LoadingSpinner />;
}
return (
const TitleContent = () => (
<>
<PageTitle
title={[
@ -342,7 +350,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
<p className="description">
{intl.formatMessage(messages.plexsettingsDescription)}
</p>
{!!onComplete && (
{!!onComplete && data?.plexAvailable && (
<div className="section">
<Alert
title={intl.formatMessage(messages.settingUpPlexDescription, {
@ -362,6 +370,39 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
</div>
)}
</div>
</>
);
if (!data?.plexAvailable && user?.id !== 1) {
return (
<>
<TitleContent />
<Alert type="info">
The owner account must first link their Plex server to be able to
access these settings.
</Alert>
</>
);
}
if (!data?.plexAvailable) {
return (
<>
<TitleContent />
<Alert type="info">
You must connect your Plex account to continue configuring a Plex
Media Server.
</Alert>
<div className="mx-auto mt-8 max-w-xl">
<LoginWithPlex onComplete={() => revalidate()} />
</div>
</>
);
}
return (
<>
<TitleContent />
<Formik
initialValues={{
hostname: data?.ip,

@ -0,0 +1,104 @@
import PlexLogo from '@app/assets/services/plex.svg';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import SettingsPlex from '@app/components/Settings/SettingsPlex';
import { CheckCircleIcon } from '@heroicons/react/24/solid';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
continue: 'Continue',
goback: 'Go Back',
tip: 'Tip',
scanbackground:
'Scanning will run in the background. You can continue the setup process in the meantime.',
});
type ConfigureMediaServersProps = {
onComplete: () => void;
};
type ConfigureStatus = {
configuring?: 'plex';
};
const ConfigureMediaServers = ({ onComplete }: ConfigureMediaServersProps) => {
const intl = useIntl();
const [configureStatus, setConfigureStatus] = useState<ConfigureStatus>();
const [plexConfigured, setPlexConfigured] = useState(false);
return (
<>
{configureStatus?.configuring === 'plex' && (
<>
<SettingsPlex onComplete={() => setPlexConfigured(true)} />
<div className="mt-4 text-sm text-gray-500">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
</span>
{intl.formatMessage(messages.scanbackground)}
</div>
<div className="actions">
<div className="flex flex-row-reverse justify-between">
<Button
buttonType="primary"
disabled={!plexConfigured}
onClick={() => {
setConfigureStatus({});
setPlexConfigured(true);
}}
>
{intl.formatMessage(messages.continue)}
</Button>
{!plexConfigured && (
<Button onClick={() => setConfigureStatus({})}>
{intl.formatMessage(messages.goback)}
</Button>
)}
</div>
</div>
</>
)}
{!configureStatus?.configuring && (
<>
<h3 className="heading">Configure Media Servers</h3>
<p className="description">
Select the media servers you would like to configure below.
</p>
<div className="mt-8 flex justify-center">
<div className="w-52 divide-y divide-gray-700 rounded border border-gray-700 bg-gray-800 bg-opacity-20">
<div className="flex items-center justify-center p-8">
<PlexLogo className="w-full" />
</div>
<div className="flex items-center justify-center space-x-2 p-2">
{plexConfigured ? (
<>
<CheckCircleIcon className="w-6 text-green-500" />
<span>Configured</span>
</>
) : (
<Button
className="w-full"
onClick={() => setConfigureStatus({ configuring: 'plex' })}
>
Configure Plex
</Button>
)}
</div>
</div>
</div>
<div className="actions">
<div className="flex justify-end">
<Button buttonType="primary" onClick={() => onComplete()}>
{plexConfigured
? 'Continue'
: 'Continue without a Media Server'}
</Button>
</div>
</div>
</>
)}
</>
);
};
export default ConfigureMediaServers;

@ -0,0 +1,155 @@
import Button from '@app/components/Common/Button';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import LoginWithPlex from '@app/components/Setup/LoginWithPlex';
import { useUser } from '@app/hooks/useUser';
import { ArrowLeftIcon, UserIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import * as Yup from 'yup';
const messages = defineMessages({
welcometooverseerr: 'Welcome to Overseerr!',
getstarted:
"Let's get started! To begin, we will need to create your administrator account. You can either do this by logging in with your Plex account, or creating a local user.",
validationEmail: 'You must provide a valid email address',
validationpasswordminchars:
'Password is too short; should be a minimum of 8 characters',
});
type StepOneProps = {
onComplete: () => void;
};
const StepOne = ({ onComplete }: StepOneProps) => {
const { revalidate } = useUser();
const [showLocalCreateForm, setShowLocalCreateForm] = useState(false);
const intl = useIntl();
const CreateUserSchema = Yup.object().shape({
email: Yup.string()
.required(intl.formatMessage(messages.validationEmail))
.email(intl.formatMessage(messages.validationEmail)),
password: Yup.lazy((value) =>
!value
? Yup.string()
: Yup.string().min(
8,
intl.formatMessage(messages.validationpasswordminchars)
)
),
});
return (
<>
<h1 className="text-overseerr text-4xl font-bold">
{intl.formatMessage(messages.welcometooverseerr)}
</h1>
<p className="mt-4 mb-6">{intl.formatMessage(messages.getstarted)}</p>
<div className="mx-auto max-w-xl">
{showLocalCreateForm ? (
<Formik
initialValues={{
email: '',
password: '',
}}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/local', {
email: values.email,
password: values.password,
});
revalidate();
onComplete();
} catch (e) {
console.log(e.message);
}
}}
validationSchema={CreateUserSchema}
>
{({ isSubmitting, errors, touched, isValid }) => (
<Form>
<div>
<label htmlFor="email" className="text-label">
Email
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<Field
id="email"
name="email"
type="text"
inputMode="email"
data-testid="email"
/>
</div>
{errors.email &&
touched.email &&
typeof errors.email === 'string' && (
<div className="error">{errors.email}</div>
)}
</div>
<label htmlFor="password" className="text-label">
Password
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="form-input-field">
<SensitiveInput
as="field"
id="password"
name="password"
type="password"
autoComplete="current-password"
data-testid="password"
/>
</div>
{errors.password &&
touched.password &&
typeof errors.password === 'string' && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex flex-row-reverse justify-between">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
data-testid="local-signin-button"
>
<UserIcon />
<span>
{isSubmitting
? 'Creating Account...'
: 'Create Account'}
</span>
</Button>
<Button onClick={() => setShowLocalCreateForm(false)}>
<ArrowLeftIcon />
<span>Go Back</span>
</Button>
</div>
</div>
</Form>
)}
</Formik>
) : (
<div className="flex flex-col space-y-2">
<LoginWithPlex onComplete={onComplete} />
<Button
buttonType="primary"
onClick={() => setShowLocalCreateForm(true)}
>
<UserIcon />
<span>Create Local Account</span>
</Button>
</div>
)}
</div>
</>
);
};
export default StepOne;

@ -5,18 +5,23 @@ import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
welcome: 'Welcome to Overseerr',
signinMessage: 'Get started by signing in with your Plex account',
signinwithplex: 'Sign In with Plex',
});
interface LoginWithPlexProps {
type LoginWithPlexProps = Omit<
React.ComponentPropsWithoutRef<typeof PlexLoginButton>,
'onAuthToken'
> & {
onComplete: () => void;
}
};
const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
const LoginWithPlex = ({
onComplete,
...plexLoginButtonProps
}: LoginWithPlexProps) => {
const intl = useIntl();
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const { user, revalidate } = useUser();
const { revalidate } = useUser();
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to login. If we get a success message, we will
@ -27,32 +32,26 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
const response = await axios.post('/api/v1/auth/plex', { authToken });
if (response.data?.id) {
revalidate();
const user = await revalidate();
if (user) {
setAuthToken(undefined);
onComplete();
}
}
};
if (authToken) {
login();
}
}, [authToken, revalidate]);
// Effect that is triggered whenever `useUser`'s user changes. If we get a new
// valid user, we call onComplete which will take us to the next step in Setup.
useEffect(() => {
if (user) {
onComplete();
}
}, [user, onComplete]);
}, [authToken, revalidate, onComplete]);
return (
<form>
<div className="mb-2 flex justify-center text-xl font-bold">
{intl.formatMessage(messages.welcome)}
</div>
<div className="mb-2 flex justify-center pb-6 text-sm">
{intl.formatMessage(messages.signinMessage)}
</div>
<div className="flex items-center justify-center">
<PlexLoginButton onAuthToken={(authToken) => setAuthToken(authToken)} />
<PlexLoginButton
onAuthToken={(authToken) => setAuthToken(authToken)}
textOverride={intl.formatMessage(messages.signinwithplex)}
{...plexLoginButtonProps}
/>
</div>
</form>
);

@ -1,12 +1,11 @@
import AppDataWarning from '@app/components/AppDataWarning';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import ImageFader from '@app/components/Common/ImageFader';
import PageTitle from '@app/components/Common/PageTitle';
import LanguagePicker from '@app/components/Layout/LanguagePicker';
import SettingsPlex from '@app/components/Settings/SettingsPlex';
import SettingsServices from '@app/components/Settings/SettingsServices';
import LoginWithPlex from '@app/components/Setup/LoginWithPlex';
import ConfigureMediaServers from '@app/components/Setup/ConfigureMediaServers';
import CreateAccount from '@app/components/Setup/CreateAccount';
import SetupSteps from '@app/components/Setup/SetupSteps';
import useLocale from '@app/hooks/useLocale';
import axios from 'axios';
@ -20,8 +19,8 @@ const messages = defineMessages({
finish: 'Finish Setup',
finishing: 'Finishing…',
continue: 'Continue',
loginwithplex: 'Sign in with Plex',
configureplex: 'Configure Plex',
loginwithplex: 'Create Admin Account',
configuremediaserver: 'Configure Media Server',
configureservices: 'Configure Services',
tip: 'Tip',
scanbackground:
@ -32,7 +31,6 @@ const Setup = () => {
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
const [plexSettingsComplete, setPlexSettingsComplete] = useState(false);
const router = useRouter();
const { locale } = useLocale();
@ -70,7 +68,7 @@ const Setup = () => {
<div className="absolute top-4 right-4 z-50">
<LanguagePicker />
</div>
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-4xl">
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-7xl">
<img
src="/logo_stacked.svg"
className="mb-10 max-w-full sm:mx-auto sm:max-w-md"
@ -90,7 +88,7 @@ const Setup = () => {
/>
<SetupSteps
stepNumber={2}
description={intl.formatMessage(messages.configureplex)}
description={intl.formatMessage(messages.configuremediaserver)}
active={currentStep === 2}
completed={currentStep > 2}
/>
@ -104,31 +102,10 @@ const Setup = () => {
</nav>
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 p-4 text-white">
{currentStep === 1 && (
<LoginWithPlex onComplete={() => setCurrentStep(2)} />
<CreateAccount onComplete={() => setCurrentStep(2)} />
)}
{currentStep === 2 && (
<div>
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
<div className="mt-4 text-sm text-gray-500">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
</span>
{intl.formatMessage(messages.scanbackground)}
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
disabled={!plexSettingsComplete}
onClick={() => setCurrentStep(3)}
>
{intl.formatMessage(messages.continue)}
</Button>
</span>
</div>
</div>
</div>
<ConfigureMediaServers onComplete={() => setCurrentStep(3)} />
)}
{currentStep === 3 && (
<div>

@ -12,7 +12,7 @@ import PlexImportModal from '@app/components/UserList/PlexImportModal';
import useSettings from '@app/hooks/useSettings';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import type { User } from '@app/hooks/useUser';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import {
@ -621,7 +621,7 @@ const UserList = () => {
)}
</Table.TD>
<Table.TD>
{user.userType === UserType.PLEX ? (
{user.isPlexUser ? (
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>

@ -1,18 +1,25 @@
import Badge from '@app/components/Common/Badge';
import PlexLogo from '@app/assets/services/plex.svg';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import Tooltip from '@app/components/Common/Tooltip';
import LanguageSelector from '@app/components/LanguageSelector';
import QuotaSelector from '@app/components/QuotaSelector';
import RegionSelector from '@app/components/RegionSelector';
import LoginWithPlex from '@app/components/Setup/LoginWithPlex';
import type { AvailableLocale } from '@app/context/LanguageContext';
import { availableLanguages } from '@app/context/LanguageContext';
import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import {
ArrowPathIcon,
CheckCircleIcon,
XCircleIcon,
} from '@heroicons/react/24/solid';
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
@ -27,7 +34,7 @@ const messages = defineMessages({
general: 'General',
generalsettings: 'General Settings',
displayName: 'Display Name',
accounttype: 'Account Type',
connectedaccounts: 'Connected Accounts',
plexuser: 'Plex User',
localuser: 'Local User',
role: 'Role',
@ -55,6 +62,16 @@ const messages = defineMessages({
plexwatchlistsyncseries: 'Auto-Request Series',
plexwatchlistsyncseriestip:
'Automatically request series on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>',
noconnectedavailable: 'No connected services available.',
onlyloggedinuseredit:
'Only the logged in user can edit their own connected accounts.',
connectplexaccount: 'Connect Plex Account',
refreshedtoken: 'Refreshed Plex Token.',
refreshtoken: 'Refresh Token',
mustsetpasswordplex: 'You must set a password before disconnecting Plex.',
disconnectPlex: 'Disconnect Plex',
plexdisconnectedsuccess: 'Plex account disconnected.',
plexdisconnectedfailure: 'Failed to disconnect Plex account.',
});
const UserGeneralSettings = () => {
@ -96,6 +113,23 @@ const UserGeneralSettings = () => {
);
}, [data]);
const unlinkPlex = async () => {
try {
await axios.get('/api/v1/auth/plex/unlink');
addToast(intl.formatMessage(messages.plexdisconnectedsuccess), {
appearance: 'success',
autoDismiss: true,
});
revalidateUser();
} catch (e) {
addToast(intl.formatMessage(messages.plexdisconnectedfailure), {
appearance: 'error',
autoDismiss: true,
});
}
};
if (!data && !error) {
return <LoadingSpinner />;
}
@ -186,21 +220,96 @@ const UserGeneralSettings = () => {
<Form className="section">
<div className="form-row">
<label className="text-label">
{intl.formatMessage(messages.accounttype)}
{intl.formatMessage(messages.connectedaccounts)}
</label>
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
<div className="flex max-w-lg items-center">
{user?.userType === UserType.PLEX ? (
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>
{!currentSettings.plexLoginEnabled && user?.id !== 1 && (
<div className="mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2">
<div className="flex max-w-lg items-center">
{intl.formatMessage(messages.noconnectedavailable)}
</div>
</div>
)}
{(currentSettings.plexLoginEnabled || user?.id === 1) && (
<div className="flex items-center rounded sm:col-span-2">
<div className="mr-4 flex h-7 w-7 items-center justify-center rounded-full border border-gray-700 bg-gray-800">
<CheckCircleIcon
className={`h-full w-full ${
user?.isPlexUser ? 'text-green-500' : 'text-gray-700'
}`}
/>
</div>
<PlexLogo className="h-8 border-r border-gray-700 pr-4" />
{user?.id !== currentUser?.id ? (
<div className="ml-4 text-sm text-gray-400">
{intl.formatMessage(messages.onlyloggedinuseredit)}
</div>
) : (
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
</Badge>
<>
{!user?.isPlexUser ? (
<>
<div className="ml-4">
<LoginWithPlex
onComplete={() => {
revalidateUser();
}}
textOverride={intl.formatMessage(
messages.connectplexaccount
)}
/>
</div>
</>
) : (
<>
<div className="ml-4">
<LoginWithPlex
onComplete={() => {
addToast(
intl.formatMessage(messages.refreshedtoken),
{
appearance: 'success',
autoDismiss: true,
}
);
revalidateUser();
}}
svgIcon={<ArrowPathIcon />}
textOverride={intl.formatMessage(
messages.refreshtoken
)}
buttonSize="sm"
buttonType="primary"
/>
</div>
<Tooltip
content={intl.formatMessage(
messages.mustsetpasswordplex
)}
// We only want to show the tooltip if the user is not a local user
disabled={user?.isLocalUser}
>
<span>
<Button
type="button"
className="ml-4"
buttonSize="sm"
onClick={() => unlinkPlex()}
disabled={!user?.isLocalUser}
>
<XCircleIcon />
<span>
{intl.formatMessage(
messages.disconnectPlex
)}
</span>
</Button>
</span>
</Tooltip>
</>
)}
</>
)}
</div>
</div>
)}
</div>
<div className="form-row">
<label className="text-label">
@ -423,7 +532,7 @@ const UserGeneralSettings = () => {
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE],
{ type: 'or' }
) &&
user?.userType === UserType.PLEX && (
user?.isPlexUser && (
<div className="form-row">
<label
htmlFor="watchlistSyncMovies"
@ -471,7 +580,7 @@ const UserGeneralSettings = () => {
[Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_TV],
{ type: 'or' }
) &&
user?.userType === UserType.PLEX && (
user?.isPlexUser && (
<div className="form-row">
<label htmlFor="watchlistSyncTv" className="checkbox-label">
<span>

@ -6,7 +6,7 @@ import RequestCard from '@app/components/RequestCard';
import Slider from '@app/components/Slider';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import ProfileHeader from '@app/components/UserProfile/ProfileHeader';
import { Permission, UserType, useUser } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error';
import { ArrowRightCircleIcon } from '@heroicons/react/24/outline';
import type { WatchlistResponse } from '@server/interfaces/api/discoverInterfaces';
@ -73,14 +73,14 @@ const UserProfile = () => {
);
const { data: watchData, error: watchDataError } =
useSWR<UserWatchDataResponse>(
user?.userType === UserType.PLEX &&
user?.isPlexUser &&
(user.id === currentUser?.id || currentHasPermission(Permission.ADMIN))
? `/api/v1/user/${user.id}/watch_data`
: null
);
const { data: watchlistItems, error: watchlistError } =
useSWR<WatchlistResponse>(
user?.userType === UserType.PLEX &&
user?.isPlexUser &&
(user.id === currentUser?.id ||
currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
@ -309,7 +309,7 @@ const UserProfile = () => {
/>
</>
)}
{user.userType === UserType.PLEX &&
{user.isPlexUser &&
(user.id === currentUser?.id ||
currentHasPermission(
[Permission.MANAGE_REQUESTS, Permission.WATCHLIST_VIEW],
@ -363,7 +363,7 @@ const UserProfile = () => {
/>
</>
)}
{user.userType === UserType.PLEX &&
{user.isPlexUser &&
(user.id === currentUser?.id ||
currentHasPermission(Permission.ADMIN)) &&
(!watchData || !!watchData.recentlyWatched.length) &&

@ -24,6 +24,7 @@ const defaultSettings = {
locale: 'en',
emailEnabled: false,
newPlexLogin: true,
plexLoginEnabled: false,
};
export const SettingsContext = React.createContext<SettingsContextProps>({

@ -24,7 +24,7 @@ export const UserContext = ({ initialUser, children }: UserContextProps) => {
useEffect(() => {
if (
!router.pathname.match(/(setup|login|resetpassword)/) &&
!router.pathname.match(/(setup|login|resetpassword|loading)/) &&
(!user || error) &&
!routing.current
) {

@ -1,22 +1,22 @@
import { UserType } from '@server/constants/user';
import type { PermissionCheckOptions } from '@server/lib/permissions';
import { hasPermission, Permission } from '@server/lib/permissions';
import type { NotificationAgentKey } from '@server/lib/settings';
import type { MutatorCallback } from 'swr';
import useSWR from 'swr';
export { Permission, UserType };
export { Permission };
export type { PermissionCheckOptions };
export interface User {
id: number;
plexUsername?: string;
plexUsername?: string | null;
username?: string;
displayName: string;
email: string;
avatar: string;
permissions: number;
userType: number;
isPlexUser: boolean;
isLocalUser: boolean;
createdAt: Date;
updatedAt: Date;
requestCount: number;

@ -216,6 +216,7 @@
"components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Overseerr Develop",
"components.Layout.VersionStatus.streamstable": "Overseerr Stable",
"components.Login.PlexLogin.loginwithplex": "Sign In with Plex",
"components.Login.email": "Email Address",
"components.Login.forgotpassword": "Forgot Password?",
"components.Login.loginerror": "Something went wrong while trying to sign in.",
@ -223,8 +224,6 @@
"components.Login.signin": "Sign In",
"components.Login.signingin": "Signing In…",
"components.Login.signinheader": "Sign in to continue",
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
"components.Login.signinwithplex": "Use your Plex account",
"components.Login.validationemailrequired": "You must provide a valid email address",
"components.Login.validationpasswordrequired": "You must provide a password",
"components.ManageSlideOver.alltime": "All Time",
@ -972,17 +971,24 @@
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
"components.Settings.webhook": "Webhook",
"components.Settings.webpush": "Web Push",
"components.Setup.configureplex": "Configure Plex",
"components.Setup.ConfigureMediaServers.continue": "Continue",
"components.Setup.ConfigureMediaServers.goback": "Go Back",
"components.Setup.ConfigureMediaServers.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
"components.Setup.ConfigureMediaServers.tip": "Tip",
"components.Setup.CreateAccount.getstarted": "Let's get started! To begin, we will need to create your administrator account. You can either do this by logging in with your Plex account, or creating a local user.",
"components.Setup.CreateAccount.validationEmail": "You must provide a valid email address",
"components.Setup.CreateAccount.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters",
"components.Setup.CreateAccount.welcometooverseerr": "Welcome to Overseerr!",
"components.Setup.configuremediaserver": "Configure Media Server",
"components.Setup.configureservices": "Configure Services",
"components.Setup.continue": "Continue",
"components.Setup.finish": "Finish Setup",
"components.Setup.finishing": "Finishing…",
"components.Setup.loginwithplex": "Sign in with Plex",
"components.Setup.loginwithplex": "Create Admin Account",
"components.Setup.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
"components.Setup.setup": "Setup",
"components.Setup.signinMessage": "Get started by signing in with your Plex account",
"components.Setup.signinwithplex": "Sign In with Plex",
"components.Setup.tip": "Tip",
"components.Setup.welcome": "Welcome to Overseerr",
"components.StatusBadge.managemedia": "Manage {mediaType}",
"components.StatusBadge.openinarr": "Open in {arr}",
"components.StatusBadge.playonplex": "Play on Plex",
@ -1078,9 +1084,9 @@
"components.UserProfile.ProfileHeader.profile": "View Profile",
"components.UserProfile.ProfileHeader.settings": "Edit Settings",
"components.UserProfile.ProfileHeader.userid": "User ID: {userid}",
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
"components.UserProfile.UserSettings.UserGeneralSettings.connectedaccounts": "Connected Accounts",
"components.UserProfile.UserSettings.UserGeneralSettings.discordId": "Discord User ID",
"components.UserProfile.UserSettings.UserGeneralSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account",
"components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name",

@ -127,7 +127,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
loadLocaleData(currentLocale).then(setMessages);
}, [currentLocale]);
if (router.pathname.match(/(login|setup|resetpassword)/)) {
if (router.pathname.match(/(login|setup|resetpassword|loading)/)) {
component = <Component {...pageProps} />;
} else {
component = (
@ -198,6 +198,7 @@ CoreApp.getInitialProps = async (initialProps) => {
locale: 'en',
emailEnabled: false,
newPlexLogin: true,
plexLoginEnabled: false,
};
if (ctx.res) {
@ -211,7 +212,7 @@ CoreApp.getInitialProps = async (initialProps) => {
const initialized = response.data.initialized;
if (!initialized) {
if (!router.pathname.match(/(setup|login\/plex)/)) {
if (!router.pathname.match(/(setup|login\/plex|loading)/)) {
ctx.res.writeHead(307, {
Location: '/setup',
});
@ -241,7 +242,7 @@ CoreApp.getInitialProps = async (initialProps) => {
// If there is no user, and ctx.res is set (to check if we are on the server side)
// _AND_ we are not already on the login or setup route, redirect to /login with a 307
// before anything actually renders
if (!router.pathname.match(/(login|setup|resetpassword)/)) {
if (!router.pathname.match(/(login|setup|resetpassword|loading)/)) {
ctx.res.writeHead(307, {
Location: '/login',
});

@ -198,7 +198,7 @@ class PlexOAuth {
//Set url to login/plex/loading so browser doesn't block popup
const newWindow = window.open(
'/login/plex/loading',
'/loading',
title,
'scrollbars=yes, width=' +
w +

@ -11279,7 +11279,7 @@ promzard@^0.3.0:
dependencies:
read "1"
prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==

Loading…
Cancel
Save