feat(login): add local users functionality (#591)

pull/598/head
Jakob Ankarhem 3 years ago committed by GitHub
parent f17fa2a2db
commit 492e19df40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

@ -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<boolean> {
return new Promise((resolve, reject) => {
if (this.password) {
resolve(bcrypt.compare(password, this.password));
} else {
return reject(false);
}
});
}
public async setPassword(password: string): Promise<void> {
const hashedPassword = await bcrypt.hash(password, 12);
this.password = hashedPassword;
}
public async resetPassword(): 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',
});
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,
});
}
}
}

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

@ -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<NotificationAgentEmail>
@ -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'),

@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class LocalUsers1610070934506 implements MigrationInterface {
name = 'LocalUsers1610070934506';
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 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<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 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"`);
}
}

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

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

@ -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&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;\
')
| 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.

@ -0,0 +1 @@
= `Password reset - Overseerr`

@ -0,0 +1 @@
<svg class="w-6 h-6" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path></svg>

After

Width:  |  Height:  |  Size: 291 B

@ -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<LocalLoginProps> = ({ goBack, revalidate }) => {
const intl = useIntl();
const [loginError, setLoginError] = useState<string | null>(null);
const LoginSchema = Yup.object().shape({
email: Yup.string()
.email()
.required(intl.formatMessage(messages.validationemailrequired)),
password: Yup.string().required(
intl.formatMessage(messages.validationpasswordrequired)
),
});
return (
<Formik
initialValues={{
email: '',
password: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
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 (
<>
<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>
<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>
{loginError && (
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
<div className="mt-2 text-red-500">{loginError}</div>
</div>
)}
</div>
<div className="pt-5 mt-8 border-t border-gray-700">
<div className="flex justify-end">
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="ghost"
type="reset"
onClick={(e) => {
e.preventDefault();
goBack();
}}
>
{intl.formatMessage(messages.goback)}
</Button>
</span>
<span className="inline-flex ml-3 rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.loggingin)
: intl.formatMessage(messages.login)}
</Button>
</span>
</div>
</div>
</Form>
</>
);
}}
</Formik>
);
};
export default LocalLogin;

@ -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<string | undefined>(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)' }}
>
<Transition
show={!!error}
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="p-4 mb-4 bg-red-600 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="w-5 h-5 text-red-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-300">{error}</h3>
{!localLogin ? (
<>
<Transition
show={!!error}
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="p-4 mb-4 bg-red-600 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="w-5 h-5 text-red-300"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-300">
{error}
</h3>
</div>
</div>
</div>
</Transition>
<div className="pb-4">
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
</div>
</div>
</Transition>
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
<span className="block w-full rounded-md shadow-sm">
<Button
buttonType="primary"
className="w-full"
// type="button"
onClick={() => {
setLocalLogin(true);
}}
>
{intl.formatMessage(messages.signinwithoverseerr)}
</Button>
</span>
</>
) : (
<LocalLogin
goBack={() => setLocalLogin(false)}
revalidate={revalidate}
/>
)}
</div>
</div>
</div>

@ -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 <LoadingSpinner />;
}
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 (
<>
<Transition
@ -149,16 +183,157 @@ const UserList: React.FC = () => {
{intl.formatMessage(messages.deleteconfirm)}
</Modal>
</Transition>
<div className="flex items-center justify-between">
<Header>{intl.formatMessage(messages.userlist)}</Header>
<Button
className="mx-4 my-8"
buttonType="primary"
disabled={isImporting}
onClick={() => importFromPlex()}
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={createModal.isOpen}
>
<Formik
initialValues={{
email: '',
password: '',
genpassword: true,
}}
validationSchema={CreateUserSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/user', {
email: values.email,
password: values.genpassword ? null : values.password,
permissions: Permission.REQUEST,
userType: UserType.LOCAL,
});
addToast(intl.formatMessage(messages.usercreatedsuccess), {
appearance: 'success',
autoDismiss: true,
});
setCreateModal({ isOpen: false });
} catch (e) {
addToast(intl.formatMessage(messages.usercreatedfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{intl.formatMessage(messages.importfromplex)}
</Button>
{({
errors,
touched,
isSubmitting,
values,
isValid,
setFieldValue,
handleSubmit,
}) => {
return (
<Modal
title={intl.formatMessage(messages.createuser)}
iconSvg={<AddUserIcon className="h-6" />}
onOk={() => handleSubmit()}
okText={
isSubmitting
? intl.formatMessage(messages.creating)
: intl.formatMessage(messages.create)
}
okDisabled={isSubmitting || !isValid}
okButtonType="primary"
onCancel={() => setCreateModal({ isOpen: false })}
>
<Alert title={intl.formatMessage(messages.passwordinfo)}>
{intl.formatMessage(messages.passwordinfodescription)}
</Alert>
<Form>
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
<label
htmlFor="email"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 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="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>
<label
htmlFor="genpassword"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.autogeneratepassword)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<Field
type="checkbox"
id="genpassword"
name="genpassword"
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
onClick={() => setFieldValue('password', '')}
/>
</div>
<label
htmlFor="password"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
>
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 sm:mt-0 sm:col-span-2">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
disabled={values.genpassword}
placeholder={intl.formatMessage(messages.password)}
className="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>
</div>
</Form>
</Modal>
);
}}
</Formik>
</Transition>
<div className="flex-col sm:flex-row flex justify-between">
<Header>{intl.formatMessage(messages.userlist)}</Header>
<div className="flex">
<Button
className="mx-4 my-8 outline"
buttonType="primary"
onClick={() => setCreateModal({ isOpen: true })}
>
{intl.formatMessage(messages.createlocaluser)}
</Button>
<Button
className="mx-4 my-8"
buttonType="primary"
disabled={isImporting}
onClick={() => importFromPlex()}
>
{intl.formatMessage(messages.importfromplex)}
</Button>
</div>
</div>
<Table>
<thead>
@ -198,9 +373,15 @@ const UserList: React.FC = () => {
<div className="text-sm leading-5">{user.requestCount}</div>
</Table.TD>
<Table.TD>
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>
{user.userType === UserType.PLEX ? (
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
</Badge>
) : (
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
</Badge>
)}
</Table.TD>
<Table.TD>
{hasPermission(Permission.ADMIN, user.permissions)

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

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

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

Loading…
Cancel
Save