diff --git a/overseerr-api.yml b/overseerr-api.yml index a4a01aece..9b9f888ed 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -20,6 +20,9 @@ components: plexToken: type: string readOnly: true + userType: + type: integer + example: 1 permissions: type: number example: 0 @@ -44,6 +47,7 @@ components: $ref: '#/components/schemas/MediaRequest' required: - id + - userType - email - permissions - createdAt @@ -1969,6 +1973,34 @@ paths: type: string required: - authToken + /auth/local: + post: + summary: Login using a local account + description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + required: + - email + - password /auth/logout: get: summary: Logout and clear session cookie diff --git a/package.json b/package.json index 303e9fb64..a8053672a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@svgr/webpack": "^5.5.0", "ace-builds": "^1.4.12", "axios": "^0.21.1", + "bcrypt": "^5.0.0", "body-parser": "^1.19.0", "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", @@ -29,6 +30,7 @@ "express-openapi-validator": "^4.10.2", "express-session": "^1.17.1", "formik": "^2.2.6", + "gravatar-url": "^3.1.0", "intl": "^1.2.5", "lodash": "^4.17.20", "next": "10.0.3", @@ -49,6 +51,7 @@ "react-truncate-markup": "^5.0.1", "react-use-clipboard": "1.0.7", "reflect-metadata": "^0.1.13", + "secure-random-password": "^0.2.2", "sqlite3": "^5.0.0", "swagger-ui-express": "^4.1.6", "swr": "^0.3.11", @@ -71,6 +74,7 @@ "@tailwindcss/aspect-ratio": "^0.2.0", "@tailwindcss/forms": "^0.2.1", "@tailwindcss/typography": "^0.3.1", + "@types/bcrypt": "^3.0.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/email-templates": "^8.0.0", @@ -84,6 +88,7 @@ "@types/react-dom": "^17.0.0", "@types/react-toast-notifications": "^2.4.0", "@types/react-transition-group": "^4.4.0", + "@types/secure-random-password": "^0.2.0", "@types/swagger-ui-express": "^4.1.2", "@types/uuid": "^8.3.0", "@types/xml2js": "^0.4.7", diff --git a/server/entity/User.ts b/server/entity/User.ts index 35ad36e67..5ba205357 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -9,6 +9,12 @@ import { } from 'typeorm'; import { Permission, hasPermission } from '../lib/permissions'; import { MediaRequest } from './MediaRequest'; +import bcrypt from 'bcrypt'; +import path from 'path'; +import PreparedEmail from '../lib/email'; +import logger from '../logger'; +import { getSettings } from '../lib/settings'; +import { default as generatePassword } from 'secure-random-password'; @Entity() export class User { @@ -16,7 +22,7 @@ export class User { return users.map((u) => u.filter()); } - static readonly filteredFields: string[] = ['plexToken']; + static readonly filteredFields: string[] = ['plexToken', 'password']; @PrimaryGeneratedColumn() public id: number; @@ -27,8 +33,14 @@ export class User { @Column() public username: string; - @Column({ select: false }) - public plexId: number; + @Column({ nullable: true, select: false }) + public password?: string; + + @Column({ type: 'integer', default: 1 }) + public userType = 1; + + @Column({ nullable: true, select: false }) + public plexId?: number; @Column({ nullable: true, select: false }) public plexToken?: string; @@ -69,4 +81,47 @@ export class User { public hasPermission(permissions: Permission | Permission[]): boolean { return !!hasPermission(permissions, this.permissions); } + + public passwordMatch(password: string): Promise { + return new Promise((resolve, reject) => { + if (this.password) { + resolve(bcrypt.compare(password, this.password)); + } else { + return reject(false); + } + }); + } + + public async setPassword(password: string): Promise { + const hashedPassword = await bcrypt.hash(password, 12); + this.password = hashedPassword; + } + + public async resetPassword(): 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', + }); + const email = new PreparedEmail(); + await email.send({ + template: path.join(__dirname, '../templates/email/password'), + message: { + to: this.email, + }, + locals: { + password: password, + applicationUrl, + }, + }); + } catch (e) { + logger.error('Failed to send out password email', { + label: 'User creation', + message: e.message, + }); + } + } } diff --git a/server/lib/email/index.ts b/server/lib/email/index.ts new file mode 100644 index 000000000..c4c6e61a0 --- /dev/null +++ b/server/lib/email/index.ts @@ -0,0 +1,38 @@ +import nodemailer from 'nodemailer'; +import Email from 'email-templates'; +import { getSettings } from '../settings'; +class PreparedEmail extends Email { + public constructor() { + const settings = getSettings().notifications.agents.email; + + const transport = nodemailer.createTransport({ + host: settings.options.smtpHost, + port: settings.options.smtpPort, + secure: settings.options.secure, + tls: settings.options.allowSelfSigned + ? { + rejectUnauthorized: false, + } + : undefined, + auth: + settings.options.authUser && settings.options.authPass + ? { + user: settings.options.authUser, + pass: settings.options.authPass, + } + : undefined, + }); + super({ + message: { + from: { + name: settings.options.senderName, + address: settings.options.emailFrom, + }, + }, + send: true, + transport: transport, + }); + } +} + +export default PreparedEmail; diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index d983a52ed..9b60f4589 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -2,12 +2,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { hasNotificationType, Notification } from '..'; import path from 'path'; import { getSettings, NotificationAgentEmail } from '../../settings'; -import nodemailer from 'nodemailer'; -import Email from 'email-templates'; import logger from '../../../logger'; import { getRepository } from 'typeorm'; import { User } from '../../../entity/User'; import { Permission } from '../../permissions'; +import PreparedEmail from '../../email'; class EmailAgent extends BaseAgent @@ -35,42 +34,6 @@ class EmailAgent return false; } - private getSmtpTransport() { - const emailSettings = this.getSettings().options; - - return nodemailer.createTransport({ - host: emailSettings.smtpHost, - port: emailSettings.smtpPort, - secure: emailSettings.secure, - tls: emailSettings.allowSelfSigned - ? { - rejectUnauthorized: false, - } - : undefined, - auth: - emailSettings.authUser && emailSettings.authPass - ? { - user: emailSettings.authUser, - pass: emailSettings.authPass, - } - : undefined, - }); - } - - private getNewEmail() { - const settings = this.getSettings(); - return new Email({ - message: { - from: { - name: settings.options.senderName, - address: settings.options.emailFrom, - }, - }, - send: true, - transport: this.getSmtpTransport(), - }); - } - private async sendMediaRequestEmail(payload: NotificationPayload) { // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; @@ -82,7 +45,7 @@ class EmailAgent users .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) .forEach((user) => { - const email = this.getNewEmail(); + const email = new PreparedEmail(); email.send({ template: path.join( @@ -127,7 +90,7 @@ class EmailAgent users .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) .forEach((user) => { - const email = this.getNewEmail(); + const email = new PreparedEmail(); email.send({ template: path.join( @@ -166,7 +129,7 @@ class EmailAgent // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; try { - const email = this.getNewEmail(); + const email = new PreparedEmail(); await email.send({ template: path.join( @@ -203,7 +166,7 @@ class EmailAgent // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; try { - const email = this.getNewEmail(); + const email = new PreparedEmail(); await email.send({ template: path.join( @@ -240,7 +203,7 @@ class EmailAgent // This is getting main settings for the whole app const applicationUrl = getSettings().main.applicationUrl; try { - const email = this.getNewEmail(); + const email = new PreparedEmail(); await email.send({ template: path.join(__dirname, '../../../templates/email/test-email'), diff --git a/server/migration/1610070934506-LocalUsers.ts b/server/migration/1610070934506-LocalUsers.ts new file mode 100644 index 000000000..0ece00f4d --- /dev/null +++ b/server/migration/1610070934506-LocalUsers.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class LocalUsers1610070934506 implements MigrationInterface { + name = 'LocalUsers1610070934506'; + + 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 NOT NULL, "plexId" integer NOT NULL, "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), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "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), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" 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 NOT NULL, "plexId" integer NOT NULL, "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), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + 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 NOT NULL, "plexId" integer NOT NULL, "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')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b1fb4bf86..72f425b9b 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,6 +6,7 @@ import { isAuthenticated } from '../middleware/auth'; import { Permission } from '../lib/permissions'; import logger from '../logger'; import { getSettings } from '../lib/settings'; +import { UserType } from '../../src/hooks/useUser'; const authRoutes = Router(); @@ -126,6 +127,53 @@ authRoutes.post('/login', async (req, res, next) => { } }); +authRoutes.post('/local', async (req, res, next) => { + const userRepository = getRepository(User); + const body = req.body as { email?: string; password?: string }; + + if (!body.email || !body.password) { + return res + .status(500) + .json({ error: 'You must provide an email and a password' }); + } + try { + const user = await userRepository.findOne({ + select: ['id', 'password'], + where: { email: body.email, userType: UserType.LOCAL }, + }); + + const isCorrectCredentials = await user?.passwordMatch(body.password); + + // User doesn't exist or credentials are incorrect + if (!isCorrectCredentials) { + logger.info('Failed login attempt from user with incorrect credentials', { + label: 'Auth', + account: { + email: body.email, + password: '__REDACTED__', + }, + }); + return next({ + status: 403, + message: 'You do not have access to this Plex server', + }); + } + + // Set logged in session + if (user && req.session) { + req.session.userId = user.id; + } + + return res.status(200).json(user?.filter() ?? {}); + } catch (e) { + logger.error(e.message, { label: 'Auth' }); + return next({ + status: 500, + message: 'Something went wrong.', + }); + } +}); + authRoutes.get('/logout', (req, res, next) => { req.session?.destroy((err) => { if (err) { diff --git a/server/routes/user.ts b/server/routes/user.ts index acbdfdb30..3807c8497 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -6,6 +6,7 @@ import { User } from '../entity/User'; import { hasPermission, Permission } from '../lib/permissions'; import { getSettings } from '../lib/settings'; import logger from '../logger'; +import gravatarUrl from 'gravatar-url'; const router = Router(); @@ -19,13 +20,34 @@ router.get('/', async (_req, res) => { router.post('/', async (req, res, next) => { try { + const settings = getSettings().notifications.agents.email; + + const body = req.body; const userRepository = getRepository(User); + const passedExplicitPassword = body.password && body.password.length > 0; + const avatar = gravatarUrl(body.email); + + if (!passedExplicitPassword && !settings.enabled) { + throw new Error('Email notifications must be enabled'); + } + const user = new User({ - email: req.body.email, - permissions: req.body.permissions, + avatar: body.avatar ?? avatar, + username: body.username ?? body.email, + email: body.email, + password: body.password, + permissions: body.permissions, plexToken: '', + userType: body.userType, }); + + if (passedExplicitPassword) { + await user?.setPassword(body.password); + } else { + await user?.resetPassword(); + } + await userRepository.save(user); return res.status(201).json(user.filter()); } catch (e) { diff --git a/server/templates/email/password/html.pug b/server/templates/email/password/html.pug new file mode 100644 index 000000000..afa2cdb80 --- /dev/null +++ b/server/templates/email/password/html.pug @@ -0,0 +1,98 @@ +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;\ + ') + | Overseerr + 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;') + | Your new password is: + div(style='font-size: 16px; text-align: center') + | #{password} + p(style='\ + font-size: 13px;\ + line-height: 24px;\ + margin-top: 6px;\ + margin-bottom: 20px;\ + color: #51545e;\ + ') + a(href=applicationUrl style='color: #3869d4') Open Overseerr +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;\ + ') + | Overseerr. diff --git a/server/templates/email/password/subject.pug b/server/templates/email/password/subject.pug new file mode 100644 index 000000000..51196b1db --- /dev/null +++ b/server/templates/email/password/subject.pug @@ -0,0 +1 @@ += `Password reset - Overseerr` diff --git a/src/assets/useradd.svg b/src/assets/useradd.svg new file mode 100644 index 000000000..1fe26d466 --- /dev/null +++ b/src/assets/useradd.svg @@ -0,0 +1 @@ + diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx new file mode 100644 index 000000000..5e807d1e4 --- /dev/null +++ b/src/components/Login/LocalLogin.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import Button from '../Common/Button'; +import { Field, Form, Formik } from 'formik'; +import * as Yup from 'yup'; +import axios from 'axios'; + +const messages = defineMessages({ + email: 'Email Address', + password: 'Password', + validationemailrequired: 'Not a valid email address', + validationpasswordrequired: 'Password required', + loginerror: 'Something went wrong when trying to sign in', + loggingin: 'Logging in...', + login: 'Login', + goback: 'Go back', +}); + +interface LocalLoginProps { + goBack: () => void; + revalidate: () => void; +} + +const LocalLogin: React.FC = ({ goBack, revalidate }) => { + const intl = useIntl(); + const [loginError, setLoginError] = useState(null); + + const LoginSchema = Yup.object().shape({ + email: Yup.string() + .email() + .required(intl.formatMessage(messages.validationemailrequired)), + password: Yup.string().required( + intl.formatMessage(messages.validationpasswordrequired) + ), + }); + + return ( + { + try { + await axios.post('/api/v1/auth/local', { + email: values.email, + password: values.password, + }); + } catch (e) { + setLoginError(intl.formatMessage(messages.loginerror)); + } finally { + revalidate(); + } + }} + > + {({ errors, touched, isSubmitting, isValid }) => { + return ( + <> +
+
+ +
+
+ +
+ {errors.email && touched.email && ( +
{errors.email}
+ )} +
+ +
+
+ +
+ {errors.password && touched.password && ( +
{errors.password}
+ )} +
+ {loginError && ( +
+
{loginError}
+
+ )} +
+
+
+ + + + + + +
+
+
+ + ); + }} +
+ ); +}; + +export default LocalLogin; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 18eedf126..8334d66e5 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -4,17 +4,22 @@ import { useUser } from '../../hooks/useUser'; import axios from 'axios'; import { useRouter } from 'next/dist/client/router'; import ImageFader from '../Common/ImageFader'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import Transition from '../Transition'; import LanguagePicker from '../Layout/LanguagePicker'; +import Button from '../Common/Button'; +import LocalLogin from './LocalLogin'; const messages = defineMessages({ signinplex: 'Sign in to continue', + signinwithoverseerr: 'Sign in with Overseerr', }); const Login: React.FC = () => { + const intl = useIntl(); const [error, setError] = useState(''); const [isProcessing, setProcessing] = useState(false); + const [localLogin, setLocalLogin] = useState(false); const [authToken, setAuthToken] = useState(undefined); const { user, revalidate } = useUser(); const router = useRouter(); @@ -80,42 +85,67 @@ const Login: React.FC = () => { className="px-4 py-8 bg-gray-800 bg-opacity-50 shadow sm:rounded-lg" style={{ backdropFilter: 'blur(5px)' }} > - -
-
-
- -
-
-

{error}

+ {!localLogin ? ( + <> + +
+
+
+ +
+
+

+ {error} +

+
+
+
+
+ setAuthToken(authToken)} + />
-
- - setAuthToken(authToken)} - /> + + + + + ) : ( + setLocalLogin(false)} + revalidate={revalidate} + /> + )}
diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index be3f0e593..1669f9c0b 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -6,7 +6,7 @@ import Badge from '../Common/Badge'; import { FormattedDate, defineMessages, useIntl } from 'react-intl'; import Button from '../Common/Button'; import { hasPermission } from '../../../server/lib/permissions'; -import { Permission } from '../../hooks/useUser'; +import { Permission, UserType } from '../../hooks/useUser'; import { useRouter } from 'next/router'; import Header from '../Common/Header'; import Table from '../Common/Table'; @@ -15,6 +15,10 @@ import Modal from '../Common/Modal'; import axios from 'axios'; import { useToasts } from 'react-toast-notifications'; import globalMessages from '../../i18n/globalMessages'; +import { Field, Form, Formik } from 'formik'; +import * as Yup from 'yup'; +import AddUserIcon from '../../assets/useradd.svg'; +import Alert from '../Common/Alert'; const messages = defineMessages({ userlist: 'User List', @@ -38,6 +42,22 @@ const messages = defineMessages({ userdeleteerror: 'Something went wrong deleting the user', deleteconfirm: 'Are you sure you want to delete this user? All existing request data from this user will be removed.', + localuser: 'Local User', + createlocaluser: 'Create Local User', + createuser: 'Create User', + creating: 'Creating', + create: 'Create', + validationemailrequired: 'Must enter a valid email address.', + validationpasswordminchars: + 'Password is too short - should be 8 chars minimum.', + usercreatedfailed: 'Something went wrong when trying to create the user', + usercreatedsuccess: 'Successfully created the user', + email: 'Email Address', + password: 'Password', + passwordinfo: 'Password Info', + passwordinfodescription: + 'Email notification settings need to be enabled and setup in order to use the auto generated passwords', + autogeneratepassword: 'Automatically generate password', }); const UserList: React.FC = () => { @@ -53,6 +73,11 @@ const UserList: React.FC = () => { }>({ isOpen: false, }); + const [createModal, setCreateModal] = useState<{ + isOpen: boolean; + }>({ + isOpen: false, + }); const deleteUser = async () => { setDeleting(true); @@ -107,6 +132,15 @@ const UserList: React.FC = () => { return ; } + const CreateUserSchema = Yup.object().shape({ + email: Yup.string() + .email() + .required(intl.formatMessage(messages.validationemailrequired)), + password: Yup.lazy((value) => + !value ? Yup.string() : Yup.string().min(8) + ), + }); + return ( <> { {intl.formatMessage(messages.deleteconfirm)} -
-
{intl.formatMessage(messages.userlist)}
- + {({ + errors, + touched, + isSubmitting, + values, + isValid, + setFieldValue, + handleSubmit, + }) => { + return ( + } + onOk={() => handleSubmit()} + okText={ + isSubmitting + ? intl.formatMessage(messages.creating) + : intl.formatMessage(messages.create) + } + okDisabled={isSubmitting || !isValid} + okButtonType="primary" + onCancel={() => setCreateModal({ isOpen: false })} + > + + {intl.formatMessage(messages.passwordinfodescription)} + +
+
+ +
+
+ +
+ {errors.email && touched.email && ( +
{errors.email}
+ )} +
+ +
+ setFieldValue('password', '')} + /> +
+ +
+
+ +
+ {errors.password && touched.password && ( +
+ {errors.password} +
+ )} +
+
+
+
+ ); + }} + + +
+
{intl.formatMessage(messages.userlist)}
+
+ + +
@@ -198,9 +373,15 @@ const UserList: React.FC = () => {
{user.requestCount}
- - {intl.formatMessage(messages.plexuser)} - + {user.userType === UserType.PLEX ? ( + + {intl.formatMessage(messages.plexuser)} + + ) : ( + + {intl.formatMessage(messages.localuser)} + + )} {hasPermission(Permission.ADMIN, user.permissions) diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index bd6e0bb3d..c9c9a3300 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -1,12 +1,18 @@ import useSwr from 'swr'; import { hasPermission, Permission } from '../../server/lib/permissions'; +export enum UserType { + PLEX = 1, + LOCAL = 2, +} + export interface User { id: number; username: string; email: string; avatar: string; permissions: number; + userType: number; } export { Permission }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index aecdf7312..d518e9563 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -26,7 +26,16 @@ "components.Layout.Sidebar.users": "Users", "components.Layout.UserDropdown.signout": "Sign Out", "components.Layout.alphawarning": "This is ALPHA software. Almost everything is bound to be nearly broken and/or unstable. Please report issues to the Overseerr GitHub!", + "components.Login.email": "Email Address", + "components.Login.goback": "Go back", + "components.Login.loggingin": "Logging in...", + "components.Login.login": "Login", + "components.Login.loginerror": "Something went wrong when trying to sign in", + "components.Login.password": "Password", "components.Login.signinplex": "Sign in to continue", + "components.Login.signinwithoverseerr": "Sign in with Overseerr", + "components.Login.validationemailrequired": "Not a valid email address", + "components.Login.validationpasswordrequired": "Password required", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.approve": "Approve", @@ -445,24 +454,38 @@ "components.UserEdit.vote": "Vote", "components.UserEdit.voteDescription": "Grants permission to vote on requests (voting not yet implemented)", "components.UserList.admin": "Admin", + "components.UserList.autogeneratepassword": "Automatically generate password", + "components.UserList.create": "Create", "components.UserList.created": "Created", + "components.UserList.createlocaluser": "Create Local User", + "components.UserList.createuser": "Create User", + "components.UserList.creating": "Creating", "components.UserList.delete": "Delete", "components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.", "components.UserList.deleteuser": "Delete User", "components.UserList.edit": "Edit", + "components.UserList.email": "Email Address", "components.UserList.importedfromplex": "{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex", "components.UserList.importfromplex": "Import Users From Plex", "components.UserList.importfromplexerror": "Something went wrong importing users from Plex", "components.UserList.lastupdated": "Last Updated", + "components.UserList.localuser": "Local User", + "components.UserList.password": "Password", + "components.UserList.passwordinfo": "Password Info", + "components.UserList.passwordinfodescription": "Email notification settings need to be enabled and setup in order to use the auto generated passwords", "components.UserList.plexuser": "Plex User", "components.UserList.role": "Role", "components.UserList.totalrequests": "Total Requests", "components.UserList.user": "User", + "components.UserList.usercreatedfailed": "Something went wrong when trying to create the user", + "components.UserList.usercreatedsuccess": "Successfully created the user", "components.UserList.userdeleted": "User deleted", "components.UserList.userdeleteerror": "Something went wrong deleting the user", "components.UserList.userlist": "User List", "components.UserList.username": "Username", "components.UserList.usertype": "User Type", + "components.UserList.validationemailrequired": "Must enter a valid email address.", + "components.UserList.validationpasswordminchars": "Password is too short - should be 8 chars minimum.", "i18n.approve": "Approve", "i18n.approved": "Approved", "i18n.available": "Available", diff --git a/yarn.lock b/yarn.lock index 00a724f05..bc9ddc565 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1964,6 +1964,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/bcrypt@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-3.0.0.tgz#851489a9065a067cb7f3c9cbe4ce9bed8bba0876" + integrity sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ== + "@types/body-parser@*", "@types/body-parser@^1.19.0": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -2230,6 +2235,11 @@ dependencies: schema-utils "*" +"@types/secure-random-password@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@types/secure-random-password/-/secure-random-password-0.2.0.tgz#d79be2c16f6866db87d816d8a5aefd7dd4764452" + integrity sha512-eRV3pVFHA5YnRlxH8DlGPCieus1jy5j6dExTABFu/pfVGEI1N+w0ej8HveAoMspr6GJkEWOS/awA71WPJemBwA== + "@types/serve-static@*": version "1.13.5" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.5.tgz#3d25d941a18415d3ab092def846e135a08bbcf53" @@ -3179,6 +3189,14 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bcrypt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.0.0.tgz#051407c7cd5ffbfb773d541ca3760ea0754e37e2" + integrity sha512-jB0yCBl4W/kVHM2whjfyqnxTmOHkCX4kHEa5nYKSoGeYe8YrjTYTc87/6bwt1g8cmV0QrbhKriETg9jWtcREhg== + dependencies: + node-addon-api "^3.0.0" + node-pre-gyp "0.15.0" + before-after-hook@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" @@ -3239,6 +3257,11 @@ bluebird@^3.3.5, bluebird@^3.5.0, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3. resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +blueimp-md5@^2.10.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.18.0.tgz#1152be1335f0c6b3911ed9e36db54f3e6ac52935" + integrity sha512-vE52okJvzsVWhcgUHOv+69OG3Mdg151xyn41aVQN/5W5S+S43qZhxECtYLAEHMSFWX6Mv5IZrzj3T5+JqXfj5Q== + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: version "4.11.9" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" @@ -6755,6 +6778,14 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== +gravatar-url@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/gravatar-url/-/gravatar-url-3.1.0.tgz#0cbeedab7c00a7bc9b627b3716e331359efcc999" + integrity sha512-+lOs7Rz1A051OqdqE8Tm4lmeyVgkqH8c6ll5fv///ncdIaL+XnOFmKAB70ix1du/yj8c3EWKbP6OhKjihsBSfA== + dependencies: + md5-hex "^3.0.1" + type-fest "^0.8.1" + handlebars@^4.7.6: version "4.7.6" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e" @@ -8718,6 +8749,13 @@ math-interval-parser@^2.0.1: resolved "https://registry.yarnpkg.com/math-interval-parser/-/math-interval-parser-2.0.1.tgz#e22cd6d15a0a7f4c03aec560db76513da615bed4" integrity sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA== +md5-hex@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/md5-hex/-/md5-hex-3.0.1.tgz#be3741b510591434b2784d79e556eefc2c9a8e5c" + integrity sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw== + dependencies: + blueimp-md5 "^2.10.0" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -9251,6 +9289,15 @@ needle@^2.2.1: iconv-lite "^0.4.4" sax "^1.2.4" +needle@^2.5.0: + version "2.5.2" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.5.2.tgz#cf1a8fce382b5a280108bba90a14993c00e4010a" + integrity sha512-LbRIwS9BfkPvNwNHlsA41Q29kL2L/6VaOJ0qisM5lLWsTV3nP15abO5ITL6L81zqFhzjRKDAYjpcBcwM0AVvLQ== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -9340,6 +9387,11 @@ node-addon-api@2.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b" integrity sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA== +node-addon-api@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.1.0.tgz#98b21931557466c6729e51cb77cd39c965f42239" + integrity sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw== + node-addon-api@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.0.2.tgz#04bc7b83fd845ba785bb6eae25bc857e1ef75681" @@ -9442,6 +9494,22 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" +node-pre-gyp@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.15.0.tgz#c2fc383276b74c7ffa842925241553e8b40f1087" + integrity sha512-7QcZa8/fpaU/BKenjcaeFF9hLz2+7S9AqyXFhlH/rilsQ/hPZKK32RtR5EQHJElgu+q5RfbJ34KriI79UWaorA== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.3" + needle "^2.5.0" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4.4.2" + node-pre-gyp@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" @@ -12200,6 +12268,18 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" +secure-random-password@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/secure-random-password/-/secure-random-password-0.2.2.tgz#eb043bcada24bc372bc98457845222b2a96d2058" + integrity sha512-L1bcFB6CY/L4snizCej/yVmRGguor5ASgk2/ea4iYjYNbEPjJ7W++4o8hQGvfrS1WWqDKUNi/Z3QEHAjkibqfw== + dependencies: + secure-random "^1.1.2" + +secure-random@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/secure-random/-/secure-random-1.1.2.tgz#ed103b460a851632d420d46448b2a900a41e7f7c" + integrity sha512-H2bdSKERKdBV1SwoqYm6C0y+9EA94v6SUBOWO8kDndc4NoUih7Dv6Tsgma7zO1lv27wIvjlD0ZpMQk7um5dheQ== + semantic-release-docker@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/semantic-release-docker/-/semantic-release-docker-2.2.0.tgz#9a5e1c8b4fe2b85063e1dc64e15550e7bf26c26f" @@ -13222,7 +13302,7 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.13: +tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.13, tar@^4.4.2: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==