diff --git a/api/middleware/validators.js b/api/middleware/validators.js index 2aaa675..a5f3fa9 100644 --- a/api/middleware/validators.js +++ b/api/middleware/validators.js @@ -11,6 +11,12 @@ validators.bothPasswordsMatch = body('password').custom((pass, {req})=>{ return true }) +validators.oldPasswordNotSame = body('oldPassword').custom((pass, {req}) => { + if(pass === req.body.password) + throw new Error('New password is the same as old password') + return true +}) + validators.validEmail = field => body(field).isString().isEmail() .withMessage('Email invalid') diff --git a/api/users.js b/api/users.js index 6e8e958..ba7f989 100644 --- a/api/users.js +++ b/api/users.js @@ -2,6 +2,7 @@ const router = require('express-promise-router')() const parseJSON = require('body-parser').json() const db = require('../db') const ensureAdmin = require('./middleware/ensureAdmin') +const ensureUser = require('./middleware/ensureUser') const sendgrid = require('@sendgrid/mail') sendgrid.setApiKey(process.env.SENDGRID_KEY) @@ -77,4 +78,28 @@ router.delete('/:uuid/admin', ensureAdmin, async (req, res) => { res.json(user) }) +const changePasswordValidation = [ + validate.validPassword('password'), + validate.bothPasswordsMatch, + validate.oldPasswordNotSame, + validate.handleApiError +] +router.put('/current/password', parseJSON, changePasswordValidation, ensureUser, async (req, res) => { + const user = await db.user.changePassword( + req.user.uuid, + req.body.oldPassword, + req.body.password + ) + + if(!user){ + return res.status(422).json({errors: [{ + param: 'oldPassword', + msg: 'Incorrect password' + }]}) + } + + + res.json(user) +}) + module.exports = router; diff --git a/components/footer/footer.js b/components/footer/footer.js index 08582c1..030730f 100644 --- a/components/footer/footer.js +++ b/components/footer/footer.js @@ -21,7 +21,7 @@ const Footer = () => {

© 2015 - {DateTime.utc().year} Society of Socks

diff --git a/components/header/style.module.css b/components/header/style.module.css index 301fa16..31b5c79 100644 --- a/components/header/style.module.css +++ b/components/header/style.module.css @@ -141,3 +141,80 @@ h1.title a:hover { .container ul li a:hover { border-bottom: solid 1px black; } + + +@media (max-width: 900px) { + .logo { + display: none; + } + + h1.title { + margin-top: 0; + text-align: left; + position: initial; + padding-top: 10px; + margin-bottom: 0; + margin-left: 20px; + width: auto; + } + + ul.primaryNav { + position: initial; + float: none; + width: 383px; + margin-left: 20px; + margin-top: 0; + } +} + +@media (max-width: 650px) { + .container { + height: 75px; + } + + h1.title { + font-size: 20px; + position: relative; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: auto; + } + + ul.primaryNav { + width: auto; + margin-left: 0; + padding-bottom: 10px; + margin-top: 16px; + } +} + +@media (max-width: 500px) { + .container { + height: auto; + } + + h1.title { + font-size: 20px; + position: relative; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: auto; + width: 100%; + text-align: center; + margin: 0; + } + + ul.accountNav { + float: none; + width: 100%; + text-align: center; + position: relative; + top: -10px; + right: 0; + padding-left: 0; + } +} \ No newline at end of file diff --git a/components/hero/style.module.css b/components/hero/style.module.css index 83927f3..f1cac99 100644 --- a/components/hero/style.module.css +++ b/components/hero/style.module.css @@ -6,33 +6,29 @@ } .hero > div { - margin: 0; - margin-right: 0px; - margin-left: 0px; + margin: 0 auto; max-width: 2000px; - margin-left: auto; - margin-right: auto; - padding: 0; + padding: 15px; position: relative; - top: 0; background-size: cover; background-position: center center; min-height: 400px; box-shadow: inset 0 -15px 7.5px -7.5px rgba(0,0,0,0.2); + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; } -.hero img.icon { - position: absolute; - right: 60%; +.icon { margin-right: 20px; - margin-top: -10px; - top: 80px; max-width: 300px; - + height: auto; + display: block; } -.hero div.content { - position: absolute; +.content { + max-width: 400px; left: 40%; right: 20%; padding-left: 20px; @@ -49,3 +45,17 @@ .hero p { opacity: .7; } + +@media (max-width: 650px) { + .hero > div { + flex-direction: column; + } + + .icon { + margin-right: 0; + } + + .content { + padding: 0; + } +} \ No newline at end of file diff --git a/db/mappings/user.js b/db/mappings/user.js index 554e324..55b1007 100644 --- a/db/mappings/user.js +++ b/db/mappings/user.js @@ -8,6 +8,7 @@ module.exports = [ 'email_confirmed', 'time_registered', 'time_email_confirmed', + 'time_password_changed', 'last_active', 'num_orders', 'is_admin' @@ -21,6 +22,7 @@ module.exports = [ 'email_confirmed', 'time_registered', 'time_email_confirmed', + 'time_password_changed', 'is_admin' ], collections: [ diff --git a/db/models/user.js b/db/models/user.js index bcc907e..b4316dc 100644 --- a/db/models/user.js +++ b/db/models/user.js @@ -73,6 +73,36 @@ user.login = async (email, password) => { return _user } +user.changePassword = async (user_uuid, oldPassword, newPassword) => { + const _user = await user.findById(user_uuid) + + if(!_user){ + // Avoid early exit timing difference + await bcrypt.hash(oldPassword, saltRounds) + return null + } + + const passwordCorrect = await bcrypt.compare(oldPassword, _user.password_hash) + + if(!passwordCorrect) + return null + + const newHash = await bcrypt.hash(newPassword, saltRounds) + + const query = { + text: 'select * from sos.change_password($1, $2)', + values: [ + user_uuid, + newHash + ] + } + + debug(query); + + const {rows} = await pg.query(query) + return joinjs.map(rows, mappings, 'userMap', 'user_')[0]; +} + user.getOpenEmailLinks = (user_uuid) => dbUtil.executeFunction({ name: 'get_open_email_links_for_user', diff --git a/db/pg.js b/db/pg.js index f27d041..1eda961 100644 --- a/db/pg.js +++ b/db/pg.js @@ -7,7 +7,8 @@ const pool = new Pool({ host: process.env.DB_HOST, user: process.env.DB_USER, database: process.env.DB_NAME, - password: process.env.DB_PASS + password: process.env.DB_PASS, + connectionTimeoutMillis: 200 }); pool.on('error', err=>debug(err)); diff --git a/db/sql/1-tables.sql b/db/sql/1-tables.sql index 2eabe9a..3d8ec4c 100644 --- a/db/sql/1-tables.sql +++ b/db/sql/1-tables.sql @@ -9,6 +9,7 @@ create table sos."user" ( user_email citext unique not null, user_email_confirmed boolean not null default false, user_password_hash varchar(60), + user_time_password_changed timestamptz not null default now(), user_time_registered timestamptz not null default now(), user_time_email_confirmed timestamptz, user_is_admin bool not null default false diff --git a/db/sql/2-views.sql b/db/sql/2-views.sql index b227b0c..84ffe4e 100644 --- a/db/sql/2-views.sql +++ b/db/sql/2-views.sql @@ -41,6 +41,7 @@ create or replace view sos.v_session as "session_user".user_password_hash as session_user_password_hash, "session_user".user_time_registered as session_user_time_registered, "session_user".user_time_email_confirmed as session_user_time_email_confirmed, + "session_user".user_time_password_changed as session_user_time_password_changed, "session_user".user_is_admin as session_user_is_admin, v_cart.* from sos."session" diff --git a/db/sql/3-functions.sql b/db/sql/3-functions.sql index 415efd9..a265184 100644 --- a/db/sql/3-functions.sql +++ b/db/sql/3-functions.sql @@ -16,6 +16,24 @@ begin return query select * from sos."user" where user_uuid = _user_uuid; end; $function$; +create or replace function sos.change_password(_user_uuid uuid, _new_hash text) + returns setof sos.user + language plpgsql +as $function$ +begin + update sos."user" + set ( + user_password_hash, + user_time_password_changed + ) = ( + _new_hash, + now() + ) + where user_uuid = _user_uuid; + + return query select * from sos."user" where user_uuid = _user_uuid; +end; $function$; + create or replace function sos.validate_session(_session_uuid uuid) returns setof sos.v_session language plpgsql diff --git a/pages/account/change-password.js b/pages/account/change-password.js new file mode 100644 index 0000000..f480535 --- /dev/null +++ b/pages/account/change-password.js @@ -0,0 +1,40 @@ +import {useSetUser} from '~/hooks/useUser' +import Head from 'next/head' +import Router from 'next/router' +import redirect from '~/utils/redirectGetInitialProps' +import {FormController, Input, Button} from '~/components/form' + +ChangePassword.getInitialProps = async function({ctx, user}) { + if(!user) + return redirect(ctx, 302, '/login') + + if(!user.email_confirmed) + return redirect(ctx, 302, '/account/email/confirm') + + return {} +} + +export default function ChangePassword() { + const setUser = useSetUser() + + function afterChange(user) { + setUser(user); + Router.push('/account') + } + + return ( + <> + + Change Password | Society of Socks + +

Change Password

+ + (value.length >= 8)} hint="Password must be at least 8 characters long" /> + (value.length >= 8)} hint="Password must be at least 8 characters long" /> + (value === fields.password.value)} hint="Passwords must match" /> + + + + + ) +} \ No newline at end of file diff --git a/pages/account/index.js b/pages/account/index.js index 27607d9..390c8e8 100644 --- a/pages/account/index.js +++ b/pages/account/index.js @@ -1,6 +1,7 @@ import {DateTime} from 'luxon' import Head from 'next/head' import Router from 'next/router' +import Link from 'next/link' import Table from '~/components/table' import useUser from '~/hooks/useUser' @@ -40,10 +41,14 @@ export default function AccountPage({orders}) {

Email and Password

-

Email: {user.email}

+

Email: {user.email} Change

{/* TODO: Store date password was set so we can show "Set on [date]"? */} -

Password: {!user.password_hash ? 'Unset' : <>Set. }

+

Password: { + !user.password_hash + ? 'Unset' + : <>Last changed {DateTime.fromISO(user.time_password_changed).toFormat('LLLL dd yyyy, h:mm a')}. Change + }

Your Orders

diff --git a/pages/login.js b/pages/login.js index dd9cf85..81b71b7 100644 --- a/pages/login.js +++ b/pages/login.js @@ -4,6 +4,7 @@ import Head from 'next/head' import Router from 'next/router' import isEmail from 'validator/lib/isEmail' +import {useSetUser} from '~/hooks/useUser' import {FormController, Input, Button} from '~/components/form' import useAccountRedirect from '~/hooks/useAccountRedirect' import {useSetUser} from '~/hooks/useUser' @@ -11,9 +12,11 @@ import {useSetUser} from '~/hooks/useUser' export default function Login(){ const setUser = useSetUser() useAccountRedirect() + const setUser = useSetUser() const redirectAfterLogin = user => { setUser(user) + if (user.is_admin) Router.push('/admin') else