feat(users): add reset password flow (#772)
parent
c0ea2bd189
commit
e5966bd3fb
@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddResetPasswordGuidAndExpiryDate1612482778137
|
||||
implements MigrationInterface {
|
||||
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
= `Account Information - ${applicationTitle}`
|
@ -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}.
|
@ -0,0 +1,143 @@
|
||||
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';
|
||||
|
||||
const messages = defineMessages({
|
||||
forgotpassword: 'Forgot Your Password?',
|
||||
emailresetlink: 'Email Me a Recovery Link',
|
||||
email: 'Email',
|
||||
validationemailrequired: 'You must provide a valid email address',
|
||||
gobacklogin: 'Go Back to Sign-In Page',
|
||||
requestresetlinksuccessmessage:
|
||||
'A password reset link will be sent to the provided email address if it is associated with a valid user.',
|
||||
});
|
||||
|
||||
const ResetPassword: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [hasSubmitted, setSubmitted] = useState(false);
|
||||
|
||||
const ResetSchema = Yup.object().shape({
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.validationemailrequired))
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
|
||||
<ImageFader
|
||||
backgroundImages={[
|
||||
'/images/rotate1.jpg',
|
||||
'/images/rotate2.jpg',
|
||||
'/images/rotate3.jpg',
|
||||
'/images/rotate4.jpg',
|
||||
'/images/rotate5.jpg',
|
||||
'/images/rotate6.jpg',
|
||||
]}
|
||||
/>
|
||||
<div className="absolute z-50 top-4 right-4">
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img
|
||||
src="/logo.png"
|
||||
className="w-auto mx-auto max-h-32"
|
||||
alt="Overseerr Logo"
|
||||
/>
|
||||
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
|
||||
{intl.formatMessage(messages.forgotpassword)}
|
||||
</h2>
|
||||
</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"
|
||||
style={{ backdropFilter: 'blur(5px)' }}
|
||||
>
|
||||
<div className="px-10 py-8">
|
||||
{hasSubmitted ? (
|
||||
<>
|
||||
<p className="text-md text-gray-300">
|
||||
{intl.formatMessage(messages.requestresetlinksuccessmessage)}
|
||||
</p>
|
||||
<span className="flex rounded-md shadow-sm justify-center mt-4">
|
||||
<Button as="a" href="/login" buttonType="ghost">
|
||||
{intl.formatMessage(messages.gobacklogin)}
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
}}
|
||||
validationSchema={ResetSchema}
|
||||
onSubmit={async (values) => {
|
||||
const response = await axios.post(
|
||||
`/api/v1/auth/reset-password`,
|
||||
{
|
||||
email: values.email,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
setSubmitted(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="name@example.com"
|
||||
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-5 mt-4 border-t border-gray-700">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{intl.formatMessage(messages.emailresetlink)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
@ -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 (
|
||||
<div className="relative flex flex-col min-h-screen bg-gray-900 py-14">
|
||||
<ImageFader
|
||||
backgroundImages={[
|
||||
'/images/rotate1.jpg',
|
||||
'/images/rotate2.jpg',
|
||||
'/images/rotate3.jpg',
|
||||
'/images/rotate4.jpg',
|
||||
'/images/rotate5.jpg',
|
||||
'/images/rotate6.jpg',
|
||||
]}
|
||||
/>
|
||||
<div className="absolute z-50 top-4 right-4">
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
<div className="relative z-40 px-4 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<img
|
||||
src="/logo.png"
|
||||
className="w-auto mx-auto max-h-32"
|
||||
alt="Overseerr Logo"
|
||||
/>
|
||||
<h2 className="mt-2 text-3xl font-extrabold leading-9 text-center text-gray-100">
|
||||
{intl.formatMessage(messages.resetpassword)}
|
||||
</h2>
|
||||
</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"
|
||||
style={{ backdropFilter: 'blur(5px)' }}
|
||||
>
|
||||
<div className="px-10 py-8">
|
||||
{hasSubmitted ? (
|
||||
<>
|
||||
<p className="text-md text-gray-300">
|
||||
{intl.formatMessage(messages.resetpasswordsuccessmessage)}
|
||||
</p>
|
||||
<span className="flex rounded-md shadow-sm justify-center mt-4">
|
||||
<Button as="a" href="/login" buttonType="ghost">
|
||||
{intl.formatMessage(messages.gobacklogin)}
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Formik
|
||||
initialValues={{
|
||||
confirmPassword: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={ResetSchema}
|
||||
onSubmit={async (values) => {
|
||||
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 (
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.password
|
||||
)}
|
||||
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.password}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.confirmpassword)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm Password"
|
||||
type="password"
|
||||
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.confirmPassword &&
|
||||
touched.confirmPassword && (
|
||||
<div className="mt-2 text-red-500">
|
||||
{errors.confirmPassword}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-5 mt-4 border-t border-gray-700">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{intl.formatMessage(messages.resetpassword)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import ResetPassword from '../../../components/ResetPassword';
|
||||
|
||||
const ResetPasswordPage: NextPage = () => {
|
||||
return <ResetPassword />;
|
||||
};
|
||||
|
||||
export default ResetPasswordPage;
|
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import RequestResetLink from '../../components/ResetPassword/RequestResetLink';
|
||||
|
||||
const RequestResetLinkPage: NextPage = () => {
|
||||
return <RequestResetLink />;
|
||||
};
|
||||
|
||||
export default RequestResetLinkPage;
|
Loading…
Reference in new issue