Merge branch 'master' into feature/63-delete-account-frontend
This commit is contained in:
commit
a35289d9b8
6745
app/package-lock.json
generated
6745
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@
|
|||||||
"leaflet": "^1.5.1",
|
"leaflet": "^1.5.1",
|
||||||
"mapnik": "^4.2.1",
|
"mapnik": "^4.2.1",
|
||||||
"node-fs": "^0.1.7",
|
"node-fs": "^0.1.7",
|
||||||
|
"nodemailer": "^6.3.0",
|
||||||
"pg-promise": "^8.7.5",
|
"pg-promise": "^8.7.5",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"query-string": "^6.8.2",
|
"query-string": "^6.8.2",
|
||||||
@ -39,7 +40,8 @@
|
|||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/express-session": "^1.15.13",
|
"@types/express-session": "^1.15.13",
|
||||||
"@types/jest": "^24.0.17",
|
"@types/jest": "^24.0.17",
|
||||||
"@types/node": "^12.7.1",
|
"@types/node": "^8.10.52",
|
||||||
|
"@types/nodemailer": "^6.2.1",
|
||||||
"@types/prop-types": "^15.7.1",
|
"@types/prop-types": "^15.7.1",
|
||||||
"@types/react": "^16.9.1",
|
"@types/react": "^16.9.1",
|
||||||
"@types/react-dom": "^16.8.5",
|
"@types/react-dom": "^16.8.5",
|
||||||
|
@ -1,49 +1,20 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
|
|
||||||
import { authUser, createUser, getUserById, getNewUserAPIKey, deleteUser } from './services/user';
|
import { authUser, createUser, getUserById, getNewUserAPIKey, deleteUser, logout } from './services/user';
|
||||||
import { queryLocation } from './services/search';
|
import { queryLocation } from './services/search';
|
||||||
|
|
||||||
import buildingsRouter from './routes/buildingsRouter';
|
import buildingsRouter from './routes/buildingsRouter';
|
||||||
|
import usersRouter from './routes/usersRouter';
|
||||||
|
|
||||||
|
|
||||||
const server = express();
|
const server = express.Router();
|
||||||
|
|
||||||
// parse POSTed json body
|
// parse POSTed json body
|
||||||
server.use(bodyParser.json());
|
server.use(bodyParser.json());
|
||||||
|
|
||||||
server.use('/buildings', buildingsRouter);
|
server.use('/buildings', buildingsRouter);
|
||||||
|
server.use('/users', usersRouter);
|
||||||
// POST new user
|
|
||||||
server.post('/users', function (req, res) {
|
|
||||||
const user = req.body;
|
|
||||||
if (req.session.user_id) {
|
|
||||||
res.send({ error: 'Already signed in' });
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.email) {
|
|
||||||
if (user.email != user.confirm_email) {
|
|
||||||
res.send({ error: 'Email did not match confirmation.' });
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
user.email = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
createUser(user).then(function (result) {
|
|
||||||
if (result.user_id) {
|
|
||||||
req.session.user_id = result.user_id;
|
|
||||||
res.send({ user_id: result.user_id });
|
|
||||||
} else {
|
|
||||||
req.session.user_id = undefined;
|
|
||||||
res.send({ error: result.error });
|
|
||||||
}
|
|
||||||
}).catch(function (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.send(err)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET own user info
|
// GET own user info
|
||||||
server.route('/users/me')
|
server.route('/users/me')
|
||||||
@ -98,16 +69,6 @@ server.post('/logout', function (req, res) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function logout(session) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
session.user_id = undefined;
|
|
||||||
session.destroy(err => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
return resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST generate API key
|
// POST generate API key
|
||||||
server.post('/api/key', function (req, res) {
|
server.post('/api/key', function (req, res) {
|
||||||
if (!req.session.user_id) {
|
if (!req.session.user_id) {
|
||||||
@ -157,8 +118,20 @@ server.get('/search', function (req, res) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.use((err, req, res, next) => {
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err != undefined) {
|
||||||
|
console.log('Global error handler: ', err);
|
||||||
|
res.status(500).send({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
server.use((req, res) => {
|
server.use((req, res) => {
|
||||||
res.status(404).json({ error: 'Resource not found'});
|
res.status(404).json({ error: 'Resource not found'});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
export default server;
|
export default server;
|
104
app/src/api/controllers/userController.ts
Normal file
104
app/src/api/controllers/userController.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { URL } from 'url';
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import * as userService from '../services/user';
|
||||||
|
import * as passwordResetService from '../services/passwordReset';
|
||||||
|
import { TokenVerificationError } from '../services/passwordReset';
|
||||||
|
|
||||||
|
function createUser(req, res) {
|
||||||
|
const user = req.body;
|
||||||
|
if (req.session.user_id) {
|
||||||
|
res.send({ error: 'Already signed in' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.email) {
|
||||||
|
if (user.email != user.confirm_email) {
|
||||||
|
res.send({ error: 'Email did not match confirmation.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.email = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
userService.createUser(user).then(function (result) {
|
||||||
|
if (result.user_id) {
|
||||||
|
req.session.user_id = result.user_id;
|
||||||
|
res.send({ user_id: result.user_id });
|
||||||
|
} else {
|
||||||
|
req.session.user_id = undefined;
|
||||||
|
res.send({ error: result.error });
|
||||||
|
}
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.send(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentUser(req, res) {
|
||||||
|
if (!req.session.user_id) {
|
||||||
|
res.send({ error: 'Must be logged in' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userService.getUserById(req.session.user_id).then(function (user) {
|
||||||
|
res.send(user);
|
||||||
|
}).catch(function (error) {
|
||||||
|
res.send(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCurrentUser(req, res) {
|
||||||
|
if (!req.session.user_id) {
|
||||||
|
return res.send({ error: 'Must be logged in' });
|
||||||
|
}
|
||||||
|
console.log(`Deleting user ${req.session.user_id}`);
|
||||||
|
|
||||||
|
userService.deleteUser(req.session.user_id).then(
|
||||||
|
() => userService.logout(req.session)
|
||||||
|
).then(() => {
|
||||||
|
res.send({ success: true });
|
||||||
|
}).catch(err => {
|
||||||
|
res.send({ error: err });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(req: express.Request, res: express.Response) {
|
||||||
|
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) {
|
||||||
|
return res.send({ error: 'Expected an email address or password reset token in the request body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if(req.body.email != undefined) {
|
||||||
|
// first stage: send reset token to email address
|
||||||
|
|
||||||
|
// this relies on the API being on the same hostname as the frontend
|
||||||
|
const { origin } = new URL(req.protocol + '://' + req.headers.host);
|
||||||
|
await passwordResetService.sendPasswordResetToken(req.body.email, origin);
|
||||||
|
|
||||||
|
return res.status(202).send({ success: true });
|
||||||
|
} else if (req.body.token != undefined) {
|
||||||
|
// second stage: verify token and reset password
|
||||||
|
if (req.body.password == undefined) {
|
||||||
|
return res.send({ error: 'Expected a new password' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await passwordResetService.resetPassword(req.body.token, req.body.password);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TokenVerificationError) {
|
||||||
|
return res.send({ error: 'Could not verify token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.send({ success: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createUser,
|
||||||
|
getCurrentUser,
|
||||||
|
deleteCurrentUser,
|
||||||
|
resetPassword
|
||||||
|
};
|
21
app/src/api/routes/usersRouter.ts
Normal file
21
app/src/api/routes/usersRouter.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
import userController from '../controllers/userController';
|
||||||
|
|
||||||
|
const asyncMiddleware = fn =>
|
||||||
|
(req, res, next) => {
|
||||||
|
Promise.resolve(fn(req, res, next))
|
||||||
|
.catch(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/', userController.createUser);
|
||||||
|
|
||||||
|
router.route('/me')
|
||||||
|
.get(userController.getCurrentUser)
|
||||||
|
.delete(userController.deleteCurrentUser);
|
||||||
|
|
||||||
|
router.put('/password', asyncMiddleware(userController.resetPassword));
|
||||||
|
|
||||||
|
export default router;
|
11
app/src/api/services/email.ts
Normal file
11
app/src/api/services/email.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import * as nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
export const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.MAIL_SERVER_HOST,
|
||||||
|
port: parseInt(process.env.MAIL_SERVER_PORT),
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: process.env.MAIL_SERVER_USER,
|
||||||
|
pass: process.env.MAIL_SERVER_PASSWORD
|
||||||
|
}
|
||||||
|
});
|
121
app/src/api/services/passwordReset.ts
Normal file
121
app/src/api/services/passwordReset.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import url, { URL } from 'url';
|
||||||
|
import { errors } from 'pg-promise';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
import db from '../../db';
|
||||||
|
import * as userService from './user';
|
||||||
|
import { transporter } from './email';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a password reset token for the specified account and send the password reset link by email
|
||||||
|
* @param email the email address for which to generate a password reset token
|
||||||
|
* @param siteOrigin the origin of the website, without a path element - e.g. https://beta.colouring.london
|
||||||
|
*/
|
||||||
|
export async function sendPasswordResetToken(email: string, siteOrigin: string): Promise<void> {
|
||||||
|
const user = await userService.getUserByEmail(email);
|
||||||
|
|
||||||
|
// if no user found for email, do nothing
|
||||||
|
if (user == undefined) return;
|
||||||
|
|
||||||
|
const token = await createPasswordResetToken(user.user_id);
|
||||||
|
const message = getPasswordResetEmail(email, token, siteOrigin);
|
||||||
|
await transporter.sendMail(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
|
||||||
|
const userId = await verifyPasswordResetToken(passwordResetToken);
|
||||||
|
if (userId != undefined) {
|
||||||
|
await updatePasswordForUser(userId, newPassword);
|
||||||
|
//TODO: expire other tokens of the user?
|
||||||
|
} else {
|
||||||
|
throw new TokenVerificationError('Password reset token could not be verified');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenVerificationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TokenVerificationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPasswordResetEmail(email: string, token: string, siteOrigin: string): nodemailer.SendMailOptions {
|
||||||
|
const linkUrl = new URL(siteOrigin);
|
||||||
|
linkUrl.pathname = '/password-reset.html';
|
||||||
|
linkUrl.search = `?token=${token}`;
|
||||||
|
|
||||||
|
const linkString = url.format(linkUrl);
|
||||||
|
|
||||||
|
const messageBody = `Hi there,
|
||||||
|
|
||||||
|
Someone has requested a password reset for the Colouring London account associated with this email address.
|
||||||
|
Click on the following link within the next 24 hours to reset your password:
|
||||||
|
|
||||||
|
${linkString}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: messageBody,
|
||||||
|
subject: 'Reset your Colouring London password',
|
||||||
|
to: email,
|
||||||
|
from: 'no-reply@colouring.london'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPasswordResetToken(userId: string) {
|
||||||
|
const { token } = await db.one(
|
||||||
|
`INSERT INTO
|
||||||
|
user_password_reset_tokens (user_id, expires_on)
|
||||||
|
VALUES
|
||||||
|
($1::uuid, now() at time zone 'utc' + INTERVAL '$2 day')
|
||||||
|
RETURNING
|
||||||
|
token
|
||||||
|
`, [userId, 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that the password reset token is valid and expire the token
|
||||||
|
* @param token password reset token to verify and use
|
||||||
|
* @returns the UUID of the user whose token was used
|
||||||
|
*/
|
||||||
|
async function verifyPasswordResetToken(token: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
// verify and deactivate the token in one operation
|
||||||
|
const usedToken = await db.one(
|
||||||
|
`UPDATE
|
||||||
|
user_password_reset_tokens
|
||||||
|
SET used = true
|
||||||
|
WHERE
|
||||||
|
token = $1::uuid
|
||||||
|
AND NOT used
|
||||||
|
AND now() at time zone 'utc' < expires_on
|
||||||
|
RETURNING *`, [token]
|
||||||
|
);
|
||||||
|
console.log('verified');
|
||||||
|
|
||||||
|
return usedToken.user_id;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof errors.QueryResultError) {
|
||||||
|
console.log(err.code);
|
||||||
|
if (err.code === errors.queryResultErrorCode.noData) return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePasswordForUser(userId: string, newPassword: string): Promise<null> {
|
||||||
|
return db.none(
|
||||||
|
`UPDATE
|
||||||
|
users
|
||||||
|
SET
|
||||||
|
pass = crypt($1, gen_salt('bf'))
|
||||||
|
WHERE
|
||||||
|
user_id = $2::uuid
|
||||||
|
`, [newPassword, userId]);
|
||||||
|
}
|
||||||
|
|
@ -86,6 +86,21 @@ function getUserById(id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserByEmail(email: string) {
|
||||||
|
return db.one(
|
||||||
|
`SELECT
|
||||||
|
user_id, username, email
|
||||||
|
FROM
|
||||||
|
users
|
||||||
|
WHERE
|
||||||
|
email = $1
|
||||||
|
`, [email]
|
||||||
|
).catch(function(error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getNewUserAPIKey(id) {
|
function getNewUserAPIKey(id) {
|
||||||
return db.one(
|
return db.one(
|
||||||
`UPDATE
|
`UPDATE
|
||||||
@ -140,4 +155,23 @@ function deleteUser(id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getUserById, createUser, authUser, getNewUserAPIKey, authAPIUser, deleteUser }
|
function logout(session: Express.Session) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
session.user_id = undefined;
|
||||||
|
session.destroy(err => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getUserById,
|
||||||
|
getUserByEmail,
|
||||||
|
createUser,
|
||||||
|
authUser,
|
||||||
|
getNewUserAPIKey,
|
||||||
|
authAPIUser,
|
||||||
|
deleteUser,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
@ -20,6 +20,8 @@ import Welcome from './welcome';
|
|||||||
import { parseCategoryURL } from '../parse';
|
import { parseCategoryURL } from '../parse';
|
||||||
import PrivacyPolicyPage from './privacy-policy';
|
import PrivacyPolicyPage from './privacy-policy';
|
||||||
import ContributorAgreementPage from './contributor-agreement';
|
import ContributorAgreementPage from './contributor-agreement';
|
||||||
|
import ForgottenPassword from './forgotten-password';
|
||||||
|
import PasswordReset from './password-reset';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App component
|
* App component
|
||||||
@ -247,6 +249,8 @@ class App extends React.Component<any, any> { // TODO: add proper types
|
|||||||
<Route exact path="/login.html">
|
<Route exact path="/login.html">
|
||||||
<Login user={this.state.user} login={this.login} />
|
<Login user={this.state.user} login={this.login} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
|
||||||
|
<Route exact path="/password-reset.html" component={PasswordReset} />
|
||||||
<Route exact path="/sign-up.html">
|
<Route exact path="/sign-up.html">
|
||||||
<SignUp user={this.state.user} login={this.login} />
|
<SignUp user={this.state.user} login={this.login} />
|
||||||
</Route>
|
</Route>
|
||||||
|
84
app/src/frontend/forgotten-password.tsx
Normal file
84
app/src/frontend/forgotten-password.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React, { FormEvent, ChangeEvent } from 'react';
|
||||||
|
import InfoBox from './info-box';
|
||||||
|
import ErrorBox from './error-box';
|
||||||
|
|
||||||
|
interface ForgottenPasswordState {
|
||||||
|
success: boolean;
|
||||||
|
error: string;
|
||||||
|
email: string;
|
||||||
|
emailUsed: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ForgottenPassword extends React.Component<{}, ForgottenPasswordState> {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
error: undefined,
|
||||||
|
success: undefined,
|
||||||
|
email: undefined,
|
||||||
|
emailUsed: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const { name, value } = event.currentTarget;
|
||||||
|
this.setState({ [name]: value } as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.setState({ error: undefined, success: undefined });
|
||||||
|
|
||||||
|
const emailSent = this.state.email;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/password', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ email: emailSent }),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error != undefined) {
|
||||||
|
this.setState({ error: data.error });
|
||||||
|
} else if (data.success === true) {
|
||||||
|
this.setState({ success: true, emailUsed: emailSent});
|
||||||
|
} else {
|
||||||
|
this.setState({ error: 'Unexpected result.' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ error: 'Something went wrong.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<section className="main-col">
|
||||||
|
<h1 className="h2">Forgotten password</h1>
|
||||||
|
<p>Please provide the e-mail address associated with your account. A password reset link will be sent to your mailbox.</p>
|
||||||
|
<ErrorBox msg={this.state.error} />
|
||||||
|
<InfoBox msg="">
|
||||||
|
{this.state.success ?
|
||||||
|
`A password reset link has been sent to ${this.state.emailUsed}. Please check your inbox.` :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</InfoBox>
|
||||||
|
<form onSubmit={e => this.handleSubmit(e)}>
|
||||||
|
<label htmlFor="email">E-mail</label>
|
||||||
|
<input name="email" id="email"
|
||||||
|
className="form-control" type="email"
|
||||||
|
placeholder="Your e-mail address" required
|
||||||
|
onChange={e => this.handleChange(e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="buttons-container">
|
||||||
|
<input type="submit" value="Request password reset" className="btn btn-primary" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -111,6 +111,8 @@ class Login extends Component<any, any> { // TODO: add proper types
|
|||||||
<label htmlFor="show_password" className="form-check-label">Show password?</label>
|
<label htmlFor="show_password" className="form-check-label">Show password?</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Link to="/forgotten-password.html">Forgotten password?</Link>
|
||||||
|
|
||||||
<div className="buttons-container">
|
<div className="buttons-container">
|
||||||
<input type="submit" value="Log In" className="btn btn-primary" />
|
<input type="submit" value="Log In" className="btn btn-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
122
app/src/frontend/password-reset.tsx
Normal file
122
app/src/frontend/password-reset.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React, { ChangeEvent, FormEvent } from 'react';
|
||||||
|
import { RouteComponentProps, Redirect } from 'react-router';
|
||||||
|
|
||||||
|
import ErrorBox from './error-box';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface PasswordResetState {
|
||||||
|
error: string;
|
||||||
|
success: boolean;
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PasswordReset extends React.Component<RouteComponentProps, PasswordResetState> {
|
||||||
|
|
||||||
|
static tokenMissingMessage = 'Password reset token is missing. Make sure to follow the link from a password reset email!';
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
error: undefined,
|
||||||
|
success: undefined,
|
||||||
|
token: undefined,
|
||||||
|
password: undefined,
|
||||||
|
confirmPassword: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const queryParams = new URLSearchParams(this.props.location.search);
|
||||||
|
const token = queryParams.get('token');
|
||||||
|
if(token == undefined) {
|
||||||
|
this.setState({ error: PasswordReset.tokenMissingMessage });
|
||||||
|
} else {
|
||||||
|
this.setState({ token: token });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange(event: FormEvent<HTMLInputElement>) {
|
||||||
|
const { name, value } = event.currentTarget;
|
||||||
|
this.setState({
|
||||||
|
[name]: value,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.setState({ error: undefined });
|
||||||
|
|
||||||
|
if(this.state.token == undefined) {
|
||||||
|
this.setState({ error: PasswordReset.tokenMissingMessage });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(this.state.password !== this.state.confirmPassword) {
|
||||||
|
this.setState({ error: 'Passwords do not match' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
token: this.state.token,
|
||||||
|
password: this.state.password
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/users/password', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(requestData),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.error != undefined) {
|
||||||
|
this.setState({ error: data.error });
|
||||||
|
} else if (data.success === true) {
|
||||||
|
this.setState({ success: true });
|
||||||
|
} else {
|
||||||
|
this.setState({ error: 'Unexpected result.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.success) {
|
||||||
|
return <Redirect to="/my-account.html" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<section className="main-col">
|
||||||
|
<h1 className="h2">Forgotten password</h1>
|
||||||
|
<p>Please input the new password you want to set for your account. If your password reset token does not work, go back to the <Link to="/forgotten-password.html">forgotten password page</Link> to request a new link.</p>
|
||||||
|
<ErrorBox msg={this.state.error} />
|
||||||
|
<form onSubmit={e => this.handleSubmit(e)}>
|
||||||
|
<label htmlFor="email">New password</label>
|
||||||
|
<input name="password"
|
||||||
|
className="form-control" type="password"
|
||||||
|
placeholder="New password" required
|
||||||
|
onChange={e => this.handleChange(e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="email">Confirm new password</label>
|
||||||
|
<input name="confirmPassword"
|
||||||
|
className="form-control" type="password"
|
||||||
|
placeholder="Confirm new password" required
|
||||||
|
onChange={e => this.handleChange(e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input name="token" id="token" type="hidden" value={this.state.token} />
|
||||||
|
|
||||||
|
<div className="buttons-container">
|
||||||
|
<input type="submit" value="Change password" className="btn btn-primary" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -21,7 +21,10 @@ module.exports = {
|
|||||||
PGUSER: "username",
|
PGUSER: "username",
|
||||||
PGPASSWORD: "longrandomsecret",
|
PGPASSWORD: "longrandomsecret",
|
||||||
APP_COOKIE_SECRET: "longrandomsecret",
|
APP_COOKIE_SECRET: "longrandomsecret",
|
||||||
|
MAIL_SERVER_HOST: "mail_hostname",
|
||||||
|
MAIL_SERVER_PORT: 587,
|
||||||
|
MAIL_SERVER_USER: "mail_username",
|
||||||
|
MAIL_SERVER_PASSWORD: "longrandompassword",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -17,7 +17,11 @@ module.exports = {
|
|||||||
PGUSER: "username",
|
PGUSER: "username",
|
||||||
PGPASSWORD: "longrandomsecret",
|
PGPASSWORD: "longrandomsecret",
|
||||||
APP_COOKIE_SECRET: "longrandomsecret",
|
APP_COOKIE_SECRET: "longrandomsecret",
|
||||||
TILECACHE_PATH: "/path/to/tile/cache"
|
TILECACHE_PATH: "/path/to/tile/cache",
|
||||||
|
MAIL_SERVER_HOST: "mail_hostname",
|
||||||
|
MAIL_SERVER_PORT: 587,
|
||||||
|
MAIL_SERVER_USER: "mail_username",
|
||||||
|
MAIL_SERVER_PASSWORD: "longrandompassword",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
1
migrations/010.password-reset.down.sql
Normal file
1
migrations/010.password-reset.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS user_password_reset_tokens;
|
6
migrations/010.password-reset.up.sql
Normal file
6
migrations/010.password-reset.up.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_password_reset_tokens (
|
||||||
|
token uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid,
|
||||||
|
expires_on timestamp NOT NULL,
|
||||||
|
used boolean NOT NULL DEFAULT false
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user