feat(users): add reset password flow (#772)

pull/847/head
Jakob Ankarhem 4 years ago committed by GitHub
parent c0ea2bd189
commit e5966bd3fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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<void> {
public async generatePassword(): Promise<void> {
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<void> {
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,
});
}

@ -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"`);
}
}

@ -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;

@ -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);

@ -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&amp;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}.

@ -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<HTMLButtonElement> {
// Helper type to override types (overrides onClick)
type MergeElementProps<
T extends React.ElementType,
P extends Record<string, unknown>
> = Omit<React.ComponentProps<T>, keyof P> & P;
type ElementTypes = 'button' | 'a';
type BaseProps<P> = {
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<P extends 'a' ? HTMLAnchorElement : HTMLButtonElement>
) => void;
};
type ButtonProps<P extends React.ElementType> = {
as?: P;
} & MergeElementProps<P, BaseProps<P>>;
const Button: React.FC<ButtonProps> = ({
function Button<P extends ElementTypes = 'button'>({
buttonType = 'default',
buttonSize = 'default',
as,
children,
className,
...props
}) => {
}: ButtonProps<P>): 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<ButtonProps> = ({
default:
buttonStyle.push('px-4 py-2 text-sm');
}
if (className) {
buttonStyle.push(className);
buttonStyle.push(className ?? '');
if (as === 'a') {
return (
<a
className={buttonStyle.join(' ')}
{...(props as React.ComponentProps<'a'>)}
>
<span className="flex items-center">{children}</span>
</a>
);
} else {
return (
<button
className={buttonStyle.join(' ')}
{...(props as React.ComponentProps<'button'>)}
>
<span className="flex items-center">{children}</span>
</button>
);
}
return (
<button className={buttonStyle.join(' ')} {...props}>
<span className="flex items-center">{children}</span>
</button>
);
};
}
export default Button;

@ -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<LocalLoginProps> = ({ revalidate }) => {
</div>
)}
</div>
<div className="actions">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button as="a" buttonType="ghost" href="/resetpassword">
{intl.formatMessage(messages.forgotpassword)}
</Button>
</span>
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"

@ -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;

@ -25,7 +25,7 @@ export const UserContext: React.FC<UserContextProps> = ({
useEffect(() => {
if (
!router.pathname.match(/(setup|login)/) &&
!router.pathname.match(/(setup|login|resetpassword)/) &&
(!user || error) &&
!routing.current
) {

@ -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",

@ -91,7 +91,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
});
}, [currentLocale]);
if (router.pathname.match(/(login|setup)/)) {
if (router.pathname.match(/(login|setup|resetpassword)/)) {
component = <Component {...pageProps} />;
} 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',
});

@ -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…
Cancel
Save