Compare commits
16 Commits
renovate/s
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abcf78f966 | ||
|
|
fa0b855259 | ||
|
|
cf26b01f79 | ||
|
|
5b7ee3d43d | ||
|
|
1706a713de | ||
|
|
e9545d44a4 | ||
|
|
6f5f615ffe | ||
|
|
ca01d4dd6f | ||
|
|
285c7f38a8 | ||
|
|
320179bed6 | ||
|
|
7032ffec8d | ||
|
|
6cfa409fc1 | ||
|
|
2f5eb49e54 | ||
|
|
4219a2b9b6 | ||
|
|
6a4d884754 | ||
|
|
a231eba896 |
@@ -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
|
||||
@@ -3287,6 +3291,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({ type: 'int', nullable: true })
|
||||
public plexId?: number | null;
|
||||
|
||||
@Column({ nullable: true, select: true })
|
||||
public plexId?: number;
|
||||
|
||||
@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(
|
||||
|
||||
@@ -108,7 +108,7 @@ interface PublicSettings {
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
interface FullPublicSettings extends PublicSettings {
|
||||
export interface FullPublicSettings extends PublicSettings {
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
@@ -124,6 +124,7 @@ interface FullPublicSettings extends PublicSettings {
|
||||
locale: string;
|
||||
emailEnabled: boolean;
|
||||
newPlexLogin: boolean;
|
||||
plexLoginEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationAgentConfig {
|
||||
@@ -504,6 +505,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,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';
|
||||
@@ -94,16 +95,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) => {
|
||||
const settings = getSettings();
|
||||
type PlexSettingsResponse = PlexSettings & {
|
||||
plexAvailable: boolean;
|
||||
};
|
||||
|
||||
res.status(200).json(settings.plex);
|
||||
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 },
|
||||
});
|
||||
|
||||
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>
|
||||
<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">
|
||||
<Link href="/resetpassword" passHref>
|
||||
<Button as="a" buttonType="ghost">
|
||||
<LifebuoyIcon />
|
||||
<span>
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<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>
|
||||
|
||||
49
src/components/Login/PlexLogin/index.tsx
Normal file
49
src/components/Login/PlexLogin/index.tsx
Normal file
@@ -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,
|
||||
|
||||
104
src/components/Setup/ConfigureMediaServers/index.tsx
Normal file
104
src/components/Setup/ConfigureMediaServers/index.tsx
Normal file
@@ -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;
|
||||
155
src/components/Setup/CreateAccount/index.tsx
Normal file
155
src/components/Setup/CreateAccount/index.tsx
Normal file
@@ -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",
|
||||
@@ -968,17 +967,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",
|
||||
@@ -1074,9 +1080,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",
|
||||
|
||||
@@ -113,7 +113,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 = (
|
||||
@@ -184,6 +184,7 @@ CoreApp.getInitialProps = async (initialProps) => {
|
||||
locale: 'en',
|
||||
emailEnabled: false,
|
||||
newPlexLogin: true,
|
||||
plexLoginEnabled: false,
|
||||
};
|
||||
|
||||
if (ctx.res) {
|
||||
@@ -197,7 +198,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',
|
||||
});
|
||||
@@ -227,7 +228,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==
|
||||
|
||||
Reference in New Issue
Block a user