mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat(users): add reset password flow (#772)
This commit is contained in:
@@ -2520,6 +2520,64 @@ paths:
|
|||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
example: 'ok'
|
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:
|
/user:
|
||||||
get:
|
get:
|
||||||
summary: Get all users
|
summary: Get all users
|
||||||
@@ -2603,7 +2661,6 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/User'
|
$ref: '#/components/schemas/User'
|
||||||
|
|
||||||
/user/{userId}:
|
/user/{userId}:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get user by ID
|
||||||
|
@@ -21,6 +21,7 @@ import logger from '../logger';
|
|||||||
import { getSettings } from '../lib/settings';
|
import { getSettings } from '../lib/settings';
|
||||||
import { default as generatePassword } from 'secure-random-password';
|
import { default as generatePassword } from 'secure-random-password';
|
||||||
import { UserType } from '../constants/user';
|
import { UserType } from '../constants/user';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class User {
|
export class User {
|
||||||
@@ -28,7 +29,11 @@ export class User {
|
|||||||
return users.map((u) => u.filter());
|
return users.map((u) => u.filter());
|
||||||
}
|
}
|
||||||
|
|
||||||
static readonly filteredFields: string[] = ['plexToken', 'password'];
|
static readonly filteredFields: string[] = [
|
||||||
|
'plexToken',
|
||||||
|
'password',
|
||||||
|
'resetPasswordGuid',
|
||||||
|
];
|
||||||
|
|
||||||
public displayName: string;
|
public displayName: string;
|
||||||
|
|
||||||
@@ -47,6 +52,12 @@ export class User {
|
|||||||
@Column({ nullable: true, select: false })
|
@Column({ nullable: true, select: false })
|
||||||
public password?: string;
|
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 })
|
@Column({ type: 'integer', default: UserType.PLEX })
|
||||||
public userType: UserType;
|
public userType: UserType;
|
||||||
|
|
||||||
@@ -111,18 +122,18 @@ export class User {
|
|||||||
this.password = hashedPassword;
|
this.password = hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resetPassword(): Promise<void> {
|
public async generatePassword(): Promise<void> {
|
||||||
const password = generatePassword.randomPassword({ length: 16 });
|
const password = generatePassword.randomPassword({ length: 16 });
|
||||||
this.setPassword(password);
|
this.setPassword(password);
|
||||||
|
|
||||||
const applicationUrl = getSettings().main.applicationUrl;
|
const applicationUrl = getSettings().main.applicationUrl;
|
||||||
try {
|
try {
|
||||||
logger.info(`Sending password email for ${this.email}`, {
|
logger.info(`Sending generated password email for ${this.email}`, {
|
||||||
label: 'User creation',
|
label: 'User Management',
|
||||||
});
|
});
|
||||||
const email = new PreparedEmail();
|
const email = new PreparedEmail();
|
||||||
await email.send({
|
await email.send({
|
||||||
template: path.join(__dirname, '../templates/email/password'),
|
template: path.join(__dirname, '../templates/email/generatedpassword'),
|
||||||
message: {
|
message: {
|
||||||
to: this.email,
|
to: this.email,
|
||||||
},
|
},
|
||||||
@@ -132,8 +143,43 @@ export class User {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to send out password email', {
|
logger.error('Failed to send out generated password email', {
|
||||||
label: 'User creation',
|
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,
|
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;
|
export default authRoutes;
|
||||||
|
@@ -46,7 +46,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
if (passedExplicitPassword) {
|
if (passedExplicitPassword) {
|
||||||
await user?.setPassword(body.password);
|
await user?.setPassword(body.password);
|
||||||
} else {
|
} else {
|
||||||
await user?.resetPassword();
|
await user?.generatePassword();
|
||||||
}
|
}
|
||||||
|
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
|
1
server/templates/email/generatedpassword/subject.pug
Normal file
1
server/templates/email/generatedpassword/subject.pug
Normal file
@@ -0,0 +1 @@
|
|||||||
|
= `Account Information - ${applicationTitle}`
|
100
server/templates/email/resetpassword/html.pug
Normal file
100
server/templates/email/resetpassword/html.pug
Normal file
@@ -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}.
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { ButtonHTMLAttributes } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export type ButtonType =
|
export type ButtonType =
|
||||||
| 'default'
|
| 'default'
|
||||||
@@ -8,18 +8,35 @@ export type ButtonType =
|
|||||||
| 'success'
|
| 'success'
|
||||||
| 'ghost';
|
| '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;
|
buttonType?: ButtonType;
|
||||||
buttonSize?: 'default' | 'lg' | 'md' | 'sm';
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
const Button: React.FC<ButtonProps> = ({
|
type ButtonProps<P extends React.ElementType> = {
|
||||||
|
as?: P;
|
||||||
|
} & MergeElementProps<P, BaseProps<P>>;
|
||||||
|
|
||||||
|
function Button<P extends ElementTypes = 'button'>({
|
||||||
buttonType = 'default',
|
buttonType = 'default',
|
||||||
buttonSize = 'default',
|
buttonSize = 'default',
|
||||||
|
as,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}: ButtonProps<P>): JSX.Element {
|
||||||
const buttonStyle = [
|
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',
|
'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:
|
default:
|
||||||
buttonStyle.push('px-4 py-2 text-sm');
|
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;
|
export default Button;
|
||||||
|
@@ -12,7 +12,8 @@ const messages = defineMessages({
|
|||||||
validationpasswordrequired: 'Password required',
|
validationpasswordrequired: 'Password required',
|
||||||
loginerror: 'Something went wrong while trying to sign in.',
|
loginerror: 'Something went wrong while trying to sign in.',
|
||||||
signingin: 'Signing in…',
|
signingin: 'Signing in…',
|
||||||
signin: 'Sign in',
|
signin: 'Sign In',
|
||||||
|
forgotpassword: 'Forgot Password?',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface LocalLoginProps {
|
interface LocalLoginProps {
|
||||||
@@ -95,9 +96,14 @@ const LocalLogin: React.FC<LocalLoginProps> = ({ revalidate }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="pt-5 mt-8 border-t border-gray-700">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-between">
|
||||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
<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
|
<Button
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
143
src/components/ResetPassword/RequestResetLink.tsx
Normal file
143
src/components/ResetPassword/RequestResetLink.tsx
Normal file
@@ -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;
|
185
src/components/ResetPassword/index.tsx
Normal file
185
src/components/ResetPassword/index.tsx
Normal file
@@ -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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!router.pathname.match(/(setup|login)/) &&
|
!router.pathname.match(/(setup|login|resetpassword)/) &&
|
||||||
(!user || error) &&
|
(!user || error) &&
|
||||||
!routing.current
|
!routing.current
|
||||||
) {
|
) {
|
||||||
|
@@ -31,6 +31,7 @@
|
|||||||
"components.Layout.UserDropdown.signout": "Sign Out",
|
"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.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.email": "Email Address",
|
||||||
|
"components.Login.forgotpassword": "Forgot Password?",
|
||||||
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
"components.Login.loginerror": "Something went wrong while trying to sign in.",
|
||||||
"components.Login.password": "Password",
|
"components.Login.password": "Password",
|
||||||
"components.Login.signin": "Sign In",
|
"components.Login.signin": "Sign In",
|
||||||
@@ -210,6 +211,19 @@
|
|||||||
"components.RequestModal.seasonnumber": "Season {number}",
|
"components.RequestModal.seasonnumber": "Season {number}",
|
||||||
"components.RequestModal.selectseason": "Select season(s)",
|
"components.RequestModal.selectseason": "Select season(s)",
|
||||||
"components.RequestModal.status": "Status",
|
"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.search": "Search",
|
||||||
"components.Search.searchresults": "Search Results",
|
"components.Search.searchresults": "Search Results",
|
||||||
"components.Settings.Notifications.NotificationsPushover.accessToken": "Access Token",
|
"components.Settings.Notifications.NotificationsPushover.accessToken": "Access Token",
|
||||||
|
@@ -91,7 +91,7 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
|
|||||||
});
|
});
|
||||||
}, [currentLocale]);
|
}, [currentLocale]);
|
||||||
|
|
||||||
if (router.pathname.match(/(login|setup)/)) {
|
if (router.pathname.match(/(login|setup|resetpassword)/)) {
|
||||||
component = <Component {...pageProps} />;
|
component = <Component {...pageProps} />;
|
||||||
} else {
|
} else {
|
||||||
component = (
|
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)
|
// 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
|
// _AND_ we are not already on the login or setup route, redirect to /login with a 307
|
||||||
// before anything actually renders
|
// before anything actually renders
|
||||||
if (!router.pathname.match(/(login|setup)/)) {
|
if (!router.pathname.match(/(login|setup|resetpassword)/)) {
|
||||||
ctx.res.writeHead(307, {
|
ctx.res.writeHead(307, {
|
||||||
Location: '/login',
|
Location: '/login',
|
||||||
});
|
});
|
||||||
|
9
src/pages/resetpassword/[guid]/index.tsx
Normal file
9
src/pages/resetpassword/[guid]/index.tsx
Normal file
@@ -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;
|
9
src/pages/resetpassword/index.tsx
Normal file
9
src/pages/resetpassword/index.tsx
Normal file
@@ -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;
|
Reference in New Issue
Block a user