From e5966bd3fbfe172f264f4e986ad2aecf29ae1510 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Fri, 5 Feb 2021 15:23:57 +0100 Subject: [PATCH 01/82] feat(users): add reset password flow (#772) --- overseerr-api.yml | 59 +++++- server/entity/User.ts | 60 +++++- ...78137-AddResetPasswordGuidAndExpiryDate.ts | 28 +++ server/routes/auth.ts | 76 +++++++ server/routes/user.ts | 2 +- .../{password => generatedpassword}/html.pug | 0 .../email/generatedpassword/subject.pug | 1 + server/templates/email/resetpassword/html.pug | 100 ++++++++++ .../{password => resetpassword}/subject.pug | 0 src/components/Common/Button/index.tsx | 57 ++++-- src/components/Login/LocalLogin.tsx | 14 +- .../ResetPassword/RequestResetLink.tsx | 143 ++++++++++++++ src/components/ResetPassword/index.tsx | 185 ++++++++++++++++++ src/context/UserContext.tsx | 2 +- src/i18n/locale/en.json | 14 ++ src/pages/_app.tsx | 4 +- src/pages/resetpassword/[guid]/index.tsx | 9 + src/pages/resetpassword/index.tsx | 9 + 18 files changed, 734 insertions(+), 29 deletions(-) create mode 100644 server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts rename server/templates/email/{password => generatedpassword}/html.pug (100%) create mode 100644 server/templates/email/generatedpassword/subject.pug create mode 100644 server/templates/email/resetpassword/html.pug rename server/templates/email/{password => resetpassword}/subject.pug (100%) create mode 100644 src/components/ResetPassword/RequestResetLink.tsx create mode 100644 src/components/ResetPassword/index.tsx create mode 100644 src/pages/resetpassword/[guid]/index.tsx create mode 100644 src/pages/resetpassword/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 380979b95..c49da2cad 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2520,6 +2520,64 @@ paths: status: type: string example: 'ok' + /auth/reset-password: + post: + summary: Send a reset password email + description: Sends a reset password email to the email if the user exists + security: [] + tags: + - users + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + required: + - email + /auth/reset-password/{guid}: + post: + summary: Reset the password for a user + description: Resets the password for a user if the given guid is connected to a user + security: [] + tags: + - users + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + type: string + required: + - password /user: get: summary: Get all users @@ -2603,7 +2661,6 @@ paths: type: array items: $ref: '#/components/schemas/User' - /user/{userId}: get: summary: Get user by ID diff --git a/server/entity/User.ts b/server/entity/User.ts index fd0162dda..a6ed9bea2 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -21,6 +21,7 @@ import logger from '../logger'; import { getSettings } from '../lib/settings'; import { default as generatePassword } from 'secure-random-password'; import { UserType } from '../constants/user'; +import { v4 as uuid } from 'uuid'; @Entity() export class User { @@ -28,7 +29,11 @@ export class User { return users.map((u) => u.filter()); } - static readonly filteredFields: string[] = ['plexToken', 'password']; + static readonly filteredFields: string[] = [ + 'plexToken', + 'password', + 'resetPasswordGuid', + ]; public displayName: string; @@ -47,6 +52,12 @@ export class User { @Column({ nullable: true, select: false }) public password?: string; + @Column({ nullable: true, select: false }) + public resetPasswordGuid?: string; + + @Column({ type: 'date', nullable: true }) + public recoveryLinkExpirationDate?: Date | null; + @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; @@ -111,18 +122,18 @@ export class User { this.password = hashedPassword; } - public async resetPassword(): Promise { + public async generatePassword(): Promise { const password = generatePassword.randomPassword({ length: 16 }); this.setPassword(password); const applicationUrl = getSettings().main.applicationUrl; try { - logger.info(`Sending password email for ${this.email}`, { - label: 'User creation', + logger.info(`Sending generated password email for ${this.email}`, { + label: 'User Management', }); const email = new PreparedEmail(); await email.send({ - template: path.join(__dirname, '../templates/email/password'), + template: path.join(__dirname, '../templates/email/generatedpassword'), message: { to: this.email, }, @@ -132,8 +143,43 @@ export class User { }, }); } catch (e) { - logger.error('Failed to send out password email', { - label: 'User creation', + logger.error('Failed to send out generated password email', { + label: 'User Management', + message: e.message, + }); + } + } + + public async resetPassword(): Promise { + const guid = uuid(); + this.resetPasswordGuid = guid; + + // 24 hours into the future + const targetDate = new Date(); + targetDate.setDate(targetDate.getDate() + 1); + this.recoveryLinkExpirationDate = targetDate; + + const applicationUrl = getSettings().main.applicationUrl; + const resetPasswordLink = `${applicationUrl}/resetpassword/${guid}`; + + try { + logger.info(`Sending reset password email for ${this.email}`, { + label: 'User Management', + }); + const email = new PreparedEmail(); + await email.send({ + template: path.join(__dirname, '../templates/email/resetpassword'), + message: { + to: this.email, + }, + locals: { + resetPasswordLink, + applicationUrl: resetPasswordLink, + }, + }); + } catch (e) { + logger.error('Failed to send out reset password email', { + label: 'User Management', message: e.message, }); } diff --git a/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts new file mode 100644 index 000000000..01278c017 --- /dev/null +++ b/server/migration/1612482778137-AddResetPasswordGuidAndExpiryDate.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddResetPasswordGuidAndExpiryDate1612482778137 + implements MigrationInterface { + name = 'AddResetPasswordGuidAndExpiryDate1612482778137'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 1fd21dacf..bb20b9ca0 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -197,4 +197,80 @@ authRoutes.get('/logout', (req, res, next) => { }); }); +authRoutes.post('/reset-password', async (req, res) => { + const userRepository = getRepository(User); + const body = req.body as { email?: string }; + + if (!body.email) { + return res.status(500).json({ error: 'You must provide an email' }); + } + + const user = await userRepository.findOne({ + where: { email: body.email }, + }); + + if (user) { + await user.resetPassword(); + userRepository.save(user); + logger.info('Successful request made for recovery link', { + label: 'User Management', + context: { ip: req.ip, email: body.email }, + }); + } else { + logger.info('Failed request made to reset a password', { + label: 'User Management', + context: { ip: req.ip, email: body.email }, + }); + } + + return res.status(200).json({ status: 'ok' }); +}); + +authRoutes.post('/reset-password/:guid', async (req, res, next) => { + const userRepository = getRepository(User); + + try { + if (!req.body.password || req.body.password?.length < 8) { + const message = + 'Failed to reset password. Password must be atleast 8 characters long.'; + logger.info(message, { + label: 'User Management', + context: { ip: req.ip, guid: req.params.guid }, + }); + return next({ status: 500, message: message }); + } + + const user = await userRepository.findOne({ + where: { resetPasswordGuid: req.params.guid }, + }); + + if (!user) { + throw new Error('Guid invalid.'); + } + + if ( + !user.recoveryLinkExpirationDate || + user.recoveryLinkExpirationDate <= new Date() + ) { + throw new Error('Recovery link expired.'); + } + + await user.setPassword(req.body.password); + user.recoveryLinkExpirationDate = null; + userRepository.save(user); + logger.info(`Successfully reset password`, { + label: 'User Management', + context: { ip: req.ip, guid: req.params.guid, email: user.email }, + }); + + return res.status(200).json({ status: 'ok' }); + } catch (e) { + logger.info(`Failed to reset password. ${e.message}`, { + label: 'User Management', + context: { ip: req.ip, guid: req.params.guid }, + }); + return res.status(200).json({ status: 'ok' }); + } +}); + export default authRoutes; diff --git a/server/routes/user.ts b/server/routes/user.ts index 896278efa..45117247d 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -46,7 +46,7 @@ router.post('/', async (req, res, next) => { if (passedExplicitPassword) { await user?.setPassword(body.password); } else { - await user?.resetPassword(); + await user?.generatePassword(); } await userRepository.save(user); diff --git a/server/templates/email/password/html.pug b/server/templates/email/generatedpassword/html.pug similarity index 100% rename from server/templates/email/password/html.pug rename to server/templates/email/generatedpassword/html.pug diff --git a/server/templates/email/generatedpassword/subject.pug b/server/templates/email/generatedpassword/subject.pug new file mode 100644 index 000000000..518ef82aa --- /dev/null +++ b/server/templates/email/generatedpassword/subject.pug @@ -0,0 +1 @@ += `Account Information - ${applicationTitle}` diff --git a/server/templates/email/resetpassword/html.pug b/server/templates/email/resetpassword/html.pug new file mode 100644 index 000000000..f7c8bb08d --- /dev/null +++ b/server/templates/email/resetpassword/html.pug @@ -0,0 +1,100 @@ +doctype html +head + meta(charset='utf-8') + meta(name='x-apple-disable-message-reformatting') + meta(http-equiv='x-ua-compatible' content='ie=edge') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='format-detection' content='telephone=no, date=no, address=no, email=no') + link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen') + //if mso + xml + o:officedocumentsettings + o:pixelsperinch 96 + style. + td, + th, + div, + p, + a, + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: 'Segoe UI', sans-serif; + mso-line-height-rule: exactly; + } + style. + @media (max-width: 600px) { + .sm-w-full { + width: 100% !important; + } + } +div(role='article' aria-roledescription='email' aria-label='' lang='en') + table(style="\ + background-color: #f2f4f6;\ + font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\ + width: 100%;\ + " width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center') + table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='\ + font-size: 16px;\ + padding-top: 25px;\ + padding-bottom: 25px;\ + text-align: center;\ + ') + a(href=applicationUrl style='\ + text-shadow: 0 1px 0 #ffffff;\ + font-weight: 700;\ + font-size: 16px;\ + color: #a8aaaf;\ + text-decoration: none;\ + ') + | #{applicationTitle} + tr + td(style='width: 100%' width='100%') + table.sm-w-full(align='center' style='\ + background-color: #ffffff;\ + margin-left: auto;\ + margin-right: auto;\ + width: 570px;\ + ' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation') + tr + td(style='padding: 45px') + div(style='font-size: 16px; text-align: center; padding-bottom: 14px;') + | A request to reset the password was made. Click + a(href=applicationUrl style='color: #3869d4; padding: 0px 5px;') here + | to set a new password. + div(style='font-size: 16px; text-align: center; padding-bottom: 14px;') + | If you did not request this recovery link you can safely ignore this email. + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + color: #51545e;\ + ') + a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle} +tr + td + table.sm-w-full(align='center' style='\ + margin-left: auto;\ + margin-right: auto;\ + text-align: center;\ + width: 570px;\ + ' width='570' cellpadding='0' cellspacing='0' role='presentation') + tr + td(align='center' style='font-size: 16px; padding: 45px') + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + text-align: center;\ + color: #a8aaaf;\ + ') + | #{applicationTitle}. diff --git a/server/templates/email/password/subject.pug b/server/templates/email/resetpassword/subject.pug similarity index 100% rename from server/templates/email/password/subject.pug rename to server/templates/email/resetpassword/subject.pug diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index 1f7672bc3..7207fbebe 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -1,4 +1,4 @@ -import React, { ButtonHTMLAttributes } from 'react'; +import React from 'react'; export type ButtonType = | 'default' @@ -8,18 +8,35 @@ export type ButtonType = | 'success' | 'ghost'; -interface ButtonProps extends ButtonHTMLAttributes { +// Helper type to override types (overrides onClick) +type MergeElementProps< + T extends React.ElementType, + P extends Record +> = Omit, keyof P> & P; + +type ElementTypes = 'button' | 'a'; + +type BaseProps

= { buttonType?: ButtonType; buttonSize?: 'default' | 'lg' | 'md' | 'sm'; -} + // Had to do declare this manually as typescript would assume e was of type any otherwise + onClick?: ( + e: React.MouseEvent

+ ) => void; +}; + +type ButtonProps

= { + as?: P; +} & MergeElementProps>; -const Button: React.FC = ({ +function Button

({ buttonType = 'default', buttonSize = 'default', + as, children, className, ...props -}) => { +}: ButtonProps

): JSX.Element { const buttonStyle = [ 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150', ]; @@ -68,14 +85,28 @@ const Button: React.FC = ({ default: buttonStyle.push('px-4 py-2 text-sm'); } - if (className) { - buttonStyle.push(className); + + buttonStyle.push(className ?? ''); + + if (as === 'a') { + return ( + )} + > + {children} + + ); + } else { + return ( + + ); } - return ( - - ); -}; +} export default Button; diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index ccd7c3547..50c6d2066 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -12,7 +12,8 @@ const messages = defineMessages({ validationpasswordrequired: 'Password required', loginerror: 'Something went wrong while trying to sign in.', signingin: 'Signing in…', - signin: 'Sign in', + signin: 'Sign In', + forgotpassword: 'Forgot Password?', }); interface LocalLoginProps { @@ -95,9 +96,14 @@ const LocalLogin: React.FC = ({ revalidate }) => { )} -

-
- +
+
+ + + + + + + ) : ( + { + const response = await axios.post( + `/api/v1/auth/reset-password`, + { + email: values.email, + } + ); + + if (response.status === 200) { + setSubmitted(true); + } + }} + > + {({ errors, touched, isSubmitting, isValid }) => { + return ( +
+
+ +
+
+ +
+ {errors.email && touched.email && ( +
+ {errors.email} +
+ )} +
+
+
+
+ + + +
+
+
+ ); + }} +
+ )} +
+
+
+
+ ); +}; + +export default ResetPassword; diff --git a/src/components/ResetPassword/index.tsx b/src/components/ResetPassword/index.tsx new file mode 100644 index 000000000..00853bd8b --- /dev/null +++ b/src/components/ResetPassword/index.tsx @@ -0,0 +1,185 @@ +import React, { useState } from 'react'; +import ImageFader from '../Common/ImageFader'; +import { defineMessages, useIntl } from 'react-intl'; +import LanguagePicker from '../Layout/LanguagePicker'; +import Button from '../Common/Button'; +import { Field, Form, Formik } from 'formik'; +import * as Yup from 'yup'; +import axios from 'axios'; +import { useRouter } from 'next/router'; + +const messages = defineMessages({ + resetpassword: 'Reset Password', + password: 'Password', + confirmpassword: 'Confirm Password', + validationpasswordrequired: 'You must provide a password', + validationpasswordmatch: 'Password must match', + validationpasswordminchars: + 'Password is too short; should be a minimum of 8 characters', + gobacklogin: 'Go Back to Sign-In Page', + resetpasswordsuccessmessage: + 'If the link is valid and is connected to a user then the password has been reset.', +}); + +const ResetPassword: React.FC = () => { + const intl = useIntl(); + const router = useRouter(); + const [hasSubmitted, setSubmitted] = useState(false); + + const guid = router.query.guid; + + const ResetSchema = Yup.object().shape({ + password: Yup.string() + .required(intl.formatMessage(messages.validationpasswordrequired)) + .min(8, intl.formatMessage(messages.validationpasswordminchars)), + confirmPassword: Yup.string() + .required(intl.formatMessage(messages.validationpasswordmatch)) + .test( + 'passwords-match', + intl.formatMessage(messages.validationpasswordmatch), + function (value) { + return this.parent.password === value; + } + ), + }); + + return ( +
+ +
+ +
+
+ Overseerr Logo +

+ {intl.formatMessage(messages.resetpassword)} +

+
+
+
+
+ {hasSubmitted ? ( + <> +

+ {intl.formatMessage(messages.resetpasswordsuccessmessage)} +

+ + + + + ) : ( + { + const response = await axios.post( + `/api/v1/auth/reset-password/${guid}`, + { + password: values.password, + } + ); + + if (response.status === 200) { + setSubmitted(true); + } + }} + > + {({ errors, touched, isSubmitting, isValid }) => { + return ( +
+
+ +
+
+ +
+ {errors.password && touched.password && ( +
+ {errors.password} +
+ )} +
+ +
+
+ +
+ {errors.confirmPassword && + touched.confirmPassword && ( +
+ {errors.confirmPassword} +
+ )} +
+
+
+
+ + + +
+
+
+ ); + }} +
+ )} +
+
+
+
+ ); +}; + +export default ResetPassword; diff --git a/src/context/UserContext.tsx b/src/context/UserContext.tsx index ba1bc1a1f..710a208b1 100644 --- a/src/context/UserContext.tsx +++ b/src/context/UserContext.tsx @@ -25,7 +25,7 @@ export const UserContext: React.FC = ({ useEffect(() => { if ( - !router.pathname.match(/(setup|login)/) && + !router.pathname.match(/(setup|login|resetpassword)/) && (!user || error) && !routing.current ) { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index f6d758c65..51c237328 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -31,6 +31,7 @@ "components.Layout.UserDropdown.signout": "Sign Out", "components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!", "components.Login.email": "Email Address", + "components.Login.forgotpassword": "Forgot Password?", "components.Login.loginerror": "Something went wrong while trying to sign in.", "components.Login.password": "Password", "components.Login.signin": "Sign In", @@ -210,6 +211,19 @@ "components.RequestModal.seasonnumber": "Season {number}", "components.RequestModal.selectseason": "Select season(s)", "components.RequestModal.status": "Status", + "components.ResetPassword.confirmpassword": "Confirm Password", + "components.ResetPassword.email": "Email", + "components.ResetPassword.emailresetlink": "Email Me a Recovery Link", + "components.ResetPassword.forgotpassword": "Forgot Your Password?", + "components.ResetPassword.gobacklogin": "Go Back to Sign-In Page", + "components.ResetPassword.password": "Password", + "components.ResetPassword.requestresetlinksuccessmessage": "A password reset link will be sent to the provided email address if it is associated with a valid user.", + "components.ResetPassword.resetpassword": "Reset Password", + "components.ResetPassword.resetpasswordsuccessmessage": "If the link is valid and is connected to a user then the password has been reset.", + "components.ResetPassword.validationemailrequired": "You must provide a valid email address", + "components.ResetPassword.validationpasswordmatch": "Password must match", + "components.ResetPassword.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters", + "components.ResetPassword.validationpasswordrequired": "You must provide a password", "components.Search.search": "Search", "components.Search.searchresults": "Search Results", "components.Settings.Notifications.NotificationsPushover.accessToken": "Access Token", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 8254ec88e..246817c6b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -91,7 +91,7 @@ const CoreApp: Omit = ({ }); }, [currentLocale]); - if (router.pathname.match(/(login|setup)/)) { + if (router.pathname.match(/(login|setup|resetpassword)/)) { component = ; } else { component = ( @@ -184,7 +184,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)/)) { + if (!router.pathname.match(/(login|setup|resetpassword)/)) { ctx.res.writeHead(307, { Location: '/login', }); diff --git a/src/pages/resetpassword/[guid]/index.tsx b/src/pages/resetpassword/[guid]/index.tsx new file mode 100644 index 000000000..bf5ac4876 --- /dev/null +++ b/src/pages/resetpassword/[guid]/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import type { NextPage } from 'next'; +import ResetPassword from '../../../components/ResetPassword'; + +const ResetPasswordPage: NextPage = () => { + return ; +}; + +export default ResetPasswordPage; diff --git a/src/pages/resetpassword/index.tsx b/src/pages/resetpassword/index.tsx new file mode 100644 index 000000000..f14ed663f --- /dev/null +++ b/src/pages/resetpassword/index.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import type { NextPage } from 'next'; +import RequestResetLink from '../../components/ResetPassword/RequestResetLink'; + +const RequestResetLinkPage: NextPage = () => { + return ; +}; + +export default RequestResetLinkPage; From baad19a2c94728313ee996fe1a0ffc64fbd9aaa3 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Sat, 6 Feb 2021 00:08:24 -0500 Subject: [PATCH 02/82] fix(ui): fix webhook URL validation regex --- .../Settings/Notifications/NotificationsWebhook/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx index 927a76395..a0f89679c 100644 --- a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -63,7 +63,7 @@ const NotificationsWebhook: React.FC = () => { .required(intl.formatMessage(messages.validationWebhookUrl)) .matches( // eslint-disable-next-line - /^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/, + /^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i, intl.formatMessage(messages.validationWebhookUrl) ), jsonPayload: Yup.string() From 8956cb3915330eecf76b262bcf4b66c537e01dfd Mon Sep 17 00:00:00 2001 From: sct Date: Sat, 6 Feb 2021 15:07:12 +0000 Subject: [PATCH 03/82] refactor(ui): change search input design (experiment) --- src/components/CollectionDetails/index.tsx | 2 +- .../Layout/LanguagePicker/index.tsx | 2 +- src/components/Layout/SearchInput/index.tsx | 8 ++-- src/components/Layout/index.tsx | 37 ++++++++++++++++--- src/components/MovieDetails/index.tsx | 2 +- src/components/PersonDetails/index.tsx | 4 +- src/components/TvDetails/index.tsx | 2 +- 7 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index b6bec5e21..c958b9e07 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -102,7 +102,7 @@ const CollectionDetails: React.FC = ({ return (
{
-
+
-
+
@@ -52,7 +77,7 @@ const Layout: React.FC = ({ children }) => {
-
+
{router.pathname === '/' && hasPermission(Permission.ADMIN) && (
diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 095ccc138..5fd3acfca 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -133,7 +133,7 @@ const MovieDetails: React.FC = ({ movie }) => { return (
{ <> {(sortedCrew || sortedCast) && ( -
+
{ />
)} -
+
{data.profilePath && (
= ({ tv }) => { return (
Date: Sun, 7 Feb 2021 16:33:18 +0100 Subject: [PATCH 04/82] feat(requests): add language profile support (#860) --- overseerr-api.yml | 9 ++ server/api/sonarr.ts | 29 +++++ server/entity/MediaRequest.ts | 22 ++++ server/interfaces/api/serviceInterfaces.ts | 4 + server/lib/settings.ts | 2 + .../1612571545781-AddLanguageProfileId.ts | 31 +++++ server/routes/request.ts | 1 + server/routes/service.ts | 61 +++++---- server/routes/settings/sonarr.ts | 2 + .../RequestModal/AdvancedRequester/index.tsx | 101 +++++++++++++-- .../RequestModal/TvRequestModal.tsx | 3 + src/components/Settings/SonarrModal/index.tsx | 119 +++++++++++++++++- src/i18n/locale/en.json | 8 ++ 13 files changed, 359 insertions(+), 33 deletions(-) create mode 100644 server/migration/1612571545781-AddLanguageProfileId.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index c49da2cad..c28079dbc 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -376,9 +376,16 @@ components: activeDirectory: type: string example: '/tv/' + activeLanguageProfileId: + type: number + example: 1 + nullable: true activeAnimeProfileId: type: number nullable: true + activeAnimeLanguageProfileId: + type: number + nullable: true activeAnimeProfileName: type: string example: 720p/1080p @@ -3062,6 +3069,8 @@ paths: type: number rootFolder: type: string + languageProfileId: + type: number required: - mediaType - mediaId diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 681cb1f3a..1283c0bf8 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -112,6 +112,7 @@ interface AddSeriesOptions { tvdbid: number; title: string; profileId: number; + languageProfileId?: number; seasons: number[]; seasonFolder: boolean; rootFolderPath: string; @@ -120,6 +121,11 @@ interface AddSeriesOptions { searchNow?: boolean; } +export interface LanguageProfile { + id: number; + name: string; +} + class SonarrAPI extends ExternalAPI { static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ @@ -236,6 +242,7 @@ class SonarrAPI extends ExternalAPI { tvdbId: options.tvdbid, title: options.title, profileId: options.profileId, + languageProfileId: options.languageProfileId, seasons: this.buildSeasonList( options.seasons, series.seasons.map((season) => ({ @@ -321,6 +328,28 @@ class SonarrAPI extends ExternalAPI { } } + public async getLanguageProfiles(): Promise { + try { + const data = await this.getRolling( + '/v3/languageprofile', + undefined, + 3600 + ); + + return data; + } catch (e) { + logger.error( + 'Something went wrong while retrieving Sonarr language profiles.', + { + label: 'Sonarr API', + message: e.message, + } + ); + + throw new Error('Failed to get language profiles'); + } + } + private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 4337d0144..d36e28728 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -78,6 +78,9 @@ export class MediaRequest { @Column({ nullable: true }) public rootFolder: string; + @Column({ nullable: true }) + public languageProfileId: number; + constructor(init?: Partial) { Object.assign(this, init); } @@ -559,6 +562,11 @@ export class MediaRequest { ? sonarrSettings.activeAnimeProfileId : sonarrSettings.activeProfileId; + let languageProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId + ? sonarrSettings.activeAnimeLanguageProfileId + : sonarrSettings.activeLanguageProfileId; + if ( this.rootFolder && this.rootFolder !== '' && @@ -577,10 +585,24 @@ export class MediaRequest { }); } + if ( + this.languageProfileId && + this.languageProfileId !== languageProfile + ) { + languageProfile = this.languageProfileId; + logger.info( + `Request has an override Language Profile: ${languageProfile}`, + { + label: 'Media Request', + } + ); + } + // Run this asynchronously so we don't wait for it on the UI side sonarr .addSeries({ profileId: qualityProfile, + languageProfileId: languageProfile, rootFolderPath: rootFolder, title: series.name, tvdbid: tvdbId, diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index fb4b2cd56..3bfa289eb 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -1,4 +1,5 @@ import { RadarrProfile, RadarrRootFolder } from '../../api/radarr'; +import { LanguageProfile } from '../../api/sonarr'; export interface ServiceCommonServer { id: number; @@ -7,12 +8,15 @@ export interface ServiceCommonServer { isDefault: boolean; activeProfileId: number; activeDirectory: string; + activeLanguageProfileId?: number; activeAnimeProfileId?: number; activeAnimeDirectory?: string; + activeAnimeLanguageProfileId?: number; } export interface ServiceCommonServerWithDetails { server: ServiceCommonServer; profiles: RadarrProfile[]; rootFolders: Partial[]; + languageProfiles?: LanguageProfile[]; } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index f5ac5e8e8..be09d45db 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -45,6 +45,8 @@ export interface SonarrSettings extends DVRSettings { activeAnimeProfileId?: number; activeAnimeProfileName?: string; activeAnimeDirectory?: string; + activeAnimeLanguageProfileId?: number; + activeLanguageProfileId?: number; enableSeasonFolders: boolean; } diff --git a/server/migration/1612571545781-AddLanguageProfileId.ts b/server/migration/1612571545781-AddLanguageProfileId.ts new file mode 100644 index 000000000..fa89d81b7 --- /dev/null +++ b/server/migration/1612571545781-AddLanguageProfileId.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLanguageProfileId1612571545781 implements MigrationInterface { + name = 'AddLanguageProfileId1612571545781'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/routes/request.ts b/server/routes/request.ts index 25ba9c0ee..7a23ebff9 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -250,6 +250,7 @@ requestRoutes.post( serverId: req.body.serverId, profileId: req.body.profileId, rootFolder: req.body.rootFolder, + languageProfileId: req.body.languageProfileId, seasons: finalSeasons.map( (sn) => new SeasonRequest({ diff --git a/server/routes/service.ts b/server/routes/service.ts index 94b2bc727..8bf4ffce8 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -90,6 +90,8 @@ serviceRoutes.get('/sonarr', async (req, res) => { activeProfileId: sonarr.activeProfileId, activeAnimeProfileId: sonarr.activeAnimeProfileId, activeAnimeDirectory: sonarr.activeAnimeDirectory, + activeLanguageProfileId: sonarr.activeLanguageProfileId, + activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId, }) ); @@ -119,31 +121,40 @@ serviceRoutes.get<{ sonarrId: string }>( }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, }); - const profiles = await sonarr.getProfiles(); - const rootFolders = await sonarr.getRootFolders(); - - return res.status(200).json({ - server: { - id: sonarrSettings.id, - name: sonarrSettings.name, - is4k: sonarrSettings.is4k, - isDefault: sonarrSettings.isDefault, - activeDirectory: sonarrSettings.activeDirectory, - activeProfileId: sonarrSettings.activeProfileId, - activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, - activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, - }, - profiles: profiles.map((profile) => ({ - id: profile.id, - name: profile.name, - })), - rootFolders: rootFolders.map((folder) => ({ - id: folder.id, - freeSpace: folder.freeSpace, - path: folder.path, - totalSpace: folder.totalSpace, - })), - } as ServiceCommonServerWithDetails); + try { + const profiles = await sonarr.getProfiles(); + const rootFolders = await sonarr.getRootFolders(); + const languageProfiles = await sonarr.getLanguageProfiles(); + + return res.status(200).json({ + server: { + id: sonarrSettings.id, + name: sonarrSettings.name, + is4k: sonarrSettings.is4k, + isDefault: sonarrSettings.isDefault, + activeDirectory: sonarrSettings.activeDirectory, + activeProfileId: sonarrSettings.activeProfileId, + activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, + activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, + activeLanguageProfileId: sonarrSettings.activeLanguageProfileId, + activeAnimeLanguageProfileId: + sonarrSettings.activeAnimeLanguageProfileId, + }, + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })), + rootFolders: rootFolders.map((folder) => ({ + id: folder.id, + freeSpace: folder.freeSpace, + path: folder.path, + totalSpace: folder.totalSpace, + })), + languageProfiles: languageProfiles, + } as ServiceCommonServerWithDetails); + } catch (e) { + next({ status: 500, message: e.message }); + } } ); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 409530f7d..71627b78c 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -46,6 +46,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); + const languageProfiles = await sonarr.getLanguageProfiles(); return res.status(200).json({ profiles, @@ -53,6 +54,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { id: folder.id, path: folder.path, })), + languageProfiles, }); } catch (e) { logger.error('Failed to test Sonarr', { diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index c7db928b0..39be37bf0 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -21,12 +21,15 @@ const messages = defineMessages({ loadingprofiles: 'Loading profiles…', loadingfolders: 'Loading folders…', requestas: 'Request As', + languageprofile: 'Language Profile', + loadinglanguages: 'Loading languages…', }); export type RequestOverrides = { server?: number; profile?: number; folder?: string; + language?: number; user?: User; }; @@ -69,6 +72,11 @@ const AdvancedRequester: React.FC = ({ const [selectedFolder, setSelectedFolder] = useState( defaultOverrides?.folder ?? '' ); + + const [selectedLanguage, setSelectedLanguage] = useState( + defaultOverrides?.language ?? -1 + ); + const { data: serverData, isValidating, @@ -135,6 +143,13 @@ const AdvancedRequester: React.FC = ({ ? serverData.server.activeAnimeDirectory : serverData.server.activeDirectory) ); + const defaultLanguage = serverData.languageProfiles?.find( + (language) => + language.id === + (isAnime + ? serverData.server.activeAnimeLanguageProfileId + : serverData.server.activeLanguageProfileId) + ); if ( defaultProfile && @@ -149,7 +164,15 @@ const AdvancedRequester: React.FC = ({ defaultFolder.path !== selectedFolder && (!defaultOverrides || defaultOverrides.folder === null) ) { - setSelectedFolder(defaultFolder?.path ?? ''); + setSelectedFolder(defaultFolder.path ?? ''); + } + + if ( + defaultLanguage && + defaultLanguage.id !== selectedLanguage && + (!defaultOverrides || defaultOverrides.language === null) + ) { + setSelectedLanguage(defaultLanguage.id); } } }, [serverData]); @@ -178,10 +201,19 @@ const AdvancedRequester: React.FC = ({ ) { setSelectedFolder(defaultOverrides.folder); } + + if ( + defaultOverrides && + defaultOverrides.language !== null && + defaultOverrides.language !== undefined + ) { + setSelectedLanguage(defaultOverrides.language); + } }, [ defaultOverrides?.server, defaultOverrides?.folder, defaultOverrides?.profile, + defaultOverrides?.language, ]); useEffect(() => { @@ -191,9 +223,16 @@ const AdvancedRequester: React.FC = ({ profile: selectedProfile !== -1 ? selectedProfile : undefined, server: selectedServer ?? undefined, user: selectedUser ?? undefined, + language: selectedLanguage ?? undefined, }); } - }, [selectedFolder, selectedServer, selectedProfile, selectedUser]); + }, [ + selectedFolder, + selectedServer, + selectedProfile, + selectedUser, + selectedLanguage, + ]); if (!data && !error) { return ( @@ -225,7 +264,7 @@ const AdvancedRequester: React.FC = ({ {!!data && selectedServer !== null && ( <>
-
+
@@ -247,8 +286,8 @@ const AdvancedRequester: React.FC = ({ ))}
-
-
+
+ +
+
+ + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
+ {errors.activeLanguageProfileId && + touched.activeLanguageProfileId && ( +
+ {errors.activeLanguageProfileId} +
+ )} +
+
+
+ +
+
+ + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
+ {errors.activeAnimeLanguageProfileId && + touched.activeAnimeLanguageProfileId && ( +
+ {errors.activeAnimeLanguageProfileId} +
+ )} +
+
@@ -568,38 +573,38 @@ const MovieDetails: React.FC = ({ movie }) => {
)}
- {(data.voteCount > 0 || ratingData) && ( + {(data.voteCount || + (ratingData?.criticsRating && ratingData?.criticsScore) || + (ratingData?.audienceRating && ratingData?.audienceScore)) && (
- {ratingData?.criticsRating && - (ratingData?.criticsScore ?? 0) > 0 && ( - <> - - {ratingData.criticsRating === 'Rotten' ? ( - - ) : ( - - )} - - - {ratingData.criticsScore}% - - - )} - {ratingData?.audienceRating && - (ratingData?.audienceScore ?? 0) > 0 && ( - <> - - {ratingData.audienceRating === 'Spilled' ? ( - - ) : ( - - )} - - - {ratingData.audienceScore}% - - - )} + {ratingData?.criticsRating && ratingData?.criticsScore && ( + <> + + {ratingData.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + + + {ratingData.criticsScore}% + + + )} + {ratingData?.audienceRating && ratingData?.audienceScore && ( + <> + + {ratingData.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + + + {ratingData.audienceScore}% + + + )} {data.voteCount > 0 && ( <> @@ -612,19 +617,21 @@ const MovieDetails: React.FC = ({ movie }) => { )}
)} -
- - - - - - -
+ {data.releaseDate && ( +
+ + + + + + +
+ )}
@@ -700,45 +707,49 @@ const MovieDetails: React.FC = ({ movie }) => {
-
- -
- ( - 0 && ( + <> +
+ +
+ ( + + ))} /> - ))} - /> + + )} = ({ tv }) => {

- {data.name} + {data.name}{' '} {data.firstAirDate && ( - + ({data.firstAirDate.slice(0, 4)}) )} @@ -591,38 +591,38 @@ const TvDetails: React.FC = ({ tv }) => {

- {(data.voteCount > 0 || ratingData) && ( + {(data.voteCount || + (ratingData?.criticsRating && ratingData?.criticsScore) || + (ratingData?.audienceRating && ratingData?.audienceScore)) && (
- {ratingData?.criticsRating && - (ratingData?.criticsScore ?? 0) > 0 && ( - <> - - {ratingData.criticsRating === 'Rotten' ? ( - - ) : ( - - )} - - - {ratingData.criticsScore}% - - - )} - {ratingData?.audienceRating && - (ratingData?.audienceScore ?? 0) > 0 && ( - <> - - {ratingData.audienceRating === 'Spilled' ? ( - - ) : ( - - )} - - - {ratingData.audienceScore}% - - - )} + {ratingData?.criticsRating && ratingData?.criticsScore && ( + <> + + {ratingData.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + + + {ratingData.criticsScore}% + + + )} + {ratingData?.audienceRating && ratingData?.audienceScore && ( + <> + + {ratingData.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + + + {ratingData.audienceScore}% + + + )} {data.voteCount > 0 && ( <> @@ -724,45 +724,49 @@ const TvDetails: React.FC = ({ tv }) => {
-
- -
- ( - 0 && ( + <> +
+ +
+ ( + + ))} /> - ))} - /> + + )} Date: Mon, 8 Feb 2021 01:04:13 -0500 Subject: [PATCH 06/82] fix(ui): Size cards appropriately based on base font size (#871) --- src/components/Common/ListView/index.tsx | 16 +++------------- src/components/MovieDetails/MovieCast/index.tsx | 7 ++----- src/components/MovieDetails/MovieCrew/index.tsx | 7 ++----- src/components/TvDetails/TvCast/index.tsx | 7 ++----- src/components/TvDetails/TvCrew/index.tsx | 7 ++----- src/styles/globals.css | 9 +++++++++ 6 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index fd9b6c798..5b3b0bc3b 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -37,7 +37,7 @@ const ListView: React.FC = ({ {intl.formatMessage(messages.noresults)}
)} -
    +
      {items?.map((title) => { let titleCard: React.ReactNode; @@ -90,22 +90,12 @@ const ListView: React.FC = ({ break; } - return ( -
    • - {titleCard} -
    • - ); + return
    • {titleCard}
    • ; })} {isLoading && !isReachingEnd && [...Array(20)].map((_item, i) => ( -
    • +
    • ))} diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index 883de9fb1..af4e34b93 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -45,13 +45,10 @@ const MovieCast: React.FC = () => { {intl.formatMessage(messages.fullcast)}
-
    +
      {data?.credits.cast.map((person, index) => { return ( -
    • +
    • { {intl.formatMessage(messages.fullcrew)}
-
    +
      {data?.credits.crew.map((person, index) => { return ( -
    • +
    • { {intl.formatMessage(messages.fullseriescast)}
-
    +
      {data?.credits.cast.map((person) => { return ( -
    • +
    • { {intl.formatMessage(messages.fullseriescrew)}
-
    +
      {data?.credits.crew.map((person, index) => { return ( -
    • +
    • li { + @apply flex flex-col items-center col-span-1 text-center; +} + .titleCard { @apply relative bg-gray-800 bg-cover rounded-lg; padding-bottom: 150%; From a3042f8e1b05a91d98f48a4aecb08e831a48fc56 Mon Sep 17 00:00:00 2001 From: TheCatLady <52870424+TheCatLady@users.noreply.github.com> Date: Mon, 8 Feb 2021 02:39:01 -0500 Subject: [PATCH 07/82] fix(ui): Fix card sizes on person detail pages (#881) --- src/components/PersonDetails/index.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/components/PersonDetails/index.tsx b/src/components/PersonDetails/index.tsx index adb52984a..2d365ed07 100644 --- a/src/components/PersonDetails/index.tsx +++ b/src/components/PersonDetails/index.tsx @@ -92,13 +92,10 @@ const PersonDetails: React.FC = () => {
-
    +
      {sortedCast?.map((media, index) => { return ( -
    • +
    • {
-