Account editing and change password.

This commit is contained in:
Mike Cao 2020-08-09 02:03:37 -07:00
parent b5cf9f8719
commit b392a51676
23 changed files with 230 additions and 102 deletions

1
assets/check.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M435.848 83.466L172.804 346.51l-96.652-96.652c-4.686-4.686-12.284-4.686-16.971 0l-28.284 28.284c-4.686 4.686-4.686 12.284 0 16.971l133.421 133.421c4.686 4.686 12.284 4.686 16.971 0l299.813-299.813c4.686-4.686 4.686-12.284 0-16.971l-28.284-28.284c-4.686-4.686-12.284-4.686-16.97 0z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@ -1,16 +1,19 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import PageHeader from './layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import Button from './common/Button'; import Button from 'components/common/Button';
import Table from './common/Table'; import Icon from 'components/common/Icon';
import Table from 'components/common/Table';
import Modal from 'components/common/Modal';
import WebsiteEditForm from 'components/forms/WebsiteEditForm';
import AccountEditForm from 'components/forms/AccountEditForm';
import Pen from 'assets/pen.svg'; import Pen from 'assets/pen.svg';
import Plus from 'assets/plus.svg'; import Plus from 'assets/plus.svg';
import Trash from 'assets/trash.svg'; import Trash from 'assets/trash.svg';
import Check from 'assets/check.svg';
import { get } from 'lib/web'; import { get } from 'lib/web';
import styles from './AccountSettings.module.css'; import styles from './AccountSettings.module.css';
import Modal from './common/Modal'; import DeleteForm from './forms/DeleteForm';
import WebsiteEditForm from './forms/WebsiteEditForm';
import AccountEditForm from './forms/AccountEditForm';
export default function AccountSettings() { export default function AccountSettings() {
const user = useSelector(state => state.user); const user = useSelector(state => state.user);
@ -23,16 +26,23 @@ export default function AccountSettings() {
const columns = [ const columns = [
{ key: 'username', label: 'Username' }, { key: 'username', label: 'Username' },
{ {
render: row => ( key: 'is_admin',
<> label: 'Administrator',
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}> render: ({ is_admin }) => (is_admin ? <Icon icon={<Check />} size="medium" /> : null),
<div>Edit</div> },
</Button> {
<Button icon={<Trash />} size="small" onClick={() => setDeleteAccount(row)}> className: styles.buttons,
<div>Delete</div> render: row =>
</Button> row.username !== 'admin' ? (
</> <>
), <Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
<div>Edit</div>
</Button>
<Button icon={<Trash />} size="small" onClick={() => setDeleteAccount(row)}>
<div>Delete</div>
</Button>
</>
) : null,
}, },
]; ];
@ -70,7 +80,11 @@ export default function AccountSettings() {
<Table columns={columns} rows={data} /> <Table columns={columns} rows={data} />
{editAccount && ( {editAccount && (
<Modal title="Edit account"> <Modal title="Edit account">
<AccountEditForm values={editAccount} onSave={handleSave} onClose={handleClose} /> <AccountEditForm
values={{ ...editAccount, password: '' }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal> </Modal>
)} )}
{addAccount && ( {addAccount && (
@ -78,6 +92,15 @@ export default function AccountSettings() {
<AccountEditForm onSave={handleSave} onClose={handleClose} /> <AccountEditForm onSave={handleSave} onClose={handleClose} />
</Modal> </Modal>
)} )}
{deleteAccount && (
<Modal title="Delete account">
<DeleteForm
values={{ type: 'account', id: deleteAccount.user_id, name: deleteAccount.username }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal>
)}
</> </>
); );
} }

View File

@ -1,4 +1,4 @@
.label { .buttons {
font-size: var(--font-size-normal); display: flex;
font-weight: 600; justify-content: flex-end;
} }

View File

@ -8,6 +8,7 @@ import Modal from './common/Modal';
export default function ProfileSettings() { export default function ProfileSettings() {
const user = useSelector(state => state.user); const user = useSelector(state => state.user);
const [changePassword, setChangePassword] = useState(false); const [changePassword, setChangePassword] = useState(false);
const { user_id } = user;
return ( return (
<> <>
@ -17,11 +18,17 @@ export default function ProfileSettings() {
Change password Change password
</Button> </Button>
</PageHeader> </PageHeader>
<dt>Username</dt> <dl>
<dd>{user.username}</dd> <dt>Username</dt>
<dd>{user.username}</dd>
</dl>
{changePassword && ( {changePassword && (
<Modal title="Change password"> <Modal title="Change password">
<ChangePasswordForm values={user} onClose={() => setChangePassword(false)} /> <ChangePasswordForm
values={{ user_id }}
onSave={() => setChangePassword(false)}
onClose={() => setChangePassword(false)}
/>
</Modal> </Modal>
)} )}
</> </>

View File

@ -4,10 +4,13 @@ import MenuLayout from 'components/layout/MenuLayout';
import WebsiteSettings from './WebsiteSettings'; import WebsiteSettings from './WebsiteSettings';
import AccountSettings from './AccountSettings'; import AccountSettings from './AccountSettings';
import ProfileSettings from './ProfileSettings'; import ProfileSettings from './ProfileSettings';
import { useSelector } from 'react-redux';
const menuOptions = ['Websites', 'Accounts', 'Profile'];
export default function Settings() { export default function Settings() {
const user = useSelector(state => state.user);
const menuOptions = ['Websites', user.is_admin && 'Accounts', 'Profile'];
return ( return (
<Page> <Page>
<MenuLayout menu={menuOptions} selectedOption="Websites"> <MenuLayout menu={menuOptions} selectedOption="Websites">

View File

@ -9,7 +9,7 @@ import Code from 'assets/code.svg';
import { get } from 'lib/web'; import { get } from 'lib/web';
import Modal from './common/Modal'; import Modal from './common/Modal';
import WebsiteEditForm from './forms/WebsiteEditForm'; import WebsiteEditForm from './forms/WebsiteEditForm';
import WebsiteDeleteForm from './forms/WebsiteDeleteForm'; import DeleteForm from './forms/DeleteForm';
import WebsiteCodeForm from './forms/WebsiteCodeForm'; import WebsiteCodeForm from './forms/WebsiteCodeForm';
import styles from './WebsiteSettings.module.css'; import styles from './WebsiteSettings.module.css';
@ -88,7 +88,11 @@ export default function WebsiteSettings() {
)} )}
{deleteWebsite && ( {deleteWebsite && (
<Modal title="Delete website"> <Modal title="Delete website">
<WebsiteDeleteForm values={deleteWebsite} onSave={handleSave} onClose={handleClose} /> <DeleteForm
values={{ type: 'website', id: deleteWebsite.website_id, name: deleteWebsite.name }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal> </Modal>
)} )}
{showCode && ( {showCode && (

View File

@ -14,13 +14,13 @@ const initialValues = {
password: '', password: '',
}; };
const validate = ({ username, password }) => { const validate = ({ user_id, username, password }) => {
const errors = {}; const errors = {};
if (!username) { if (!username) {
errors.username = 'Required'; errors.username = 'Required';
} }
if (!password) { if (!user_id && !password) {
errors.password = 'Required'; errors.password = 'Required';
} }
@ -33,10 +33,10 @@ export default function AccountEditForm({ values, onSave, onClose }) {
const handleSubmit = async values => { const handleSubmit = async values => {
const response = await post(`/api/account`, values); const response = await post(`/api/account`, values);
if (response) { if (typeof response !== 'string') {
onSave(); onSave();
} else { } else {
setMessage('Something went wrong.'); setMessage(response || 'Something went wrong');
} }
}; };
@ -56,7 +56,7 @@ export default function AccountEditForm({ values, onSave, onClose }) {
</FormRow> </FormRow>
<FormRow> <FormRow>
<label htmlFor="password">Password</label> <label htmlFor="password">Password</label>
<Field name="password" type="text" /> <Field name="password" type="password" />
<FormError name="password" /> <FormError name="password" />
</FormRow> </FormRow>
<FormButtons> <FormButtons>

View File

@ -10,24 +10,24 @@ import FormLayout, {
} from 'components/layout/FormLayout'; } from 'components/layout/FormLayout';
const initialValues = { const initialValues = {
password: '', current_password: '',
newPassword: '', new_password: '',
defaultPassword: '', confirm_password: '',
}; };
const validate = ({ password, newPassword, confirmPassword }) => { const validate = ({ current_password, new_password, confirm_password }) => {
const errors = {}; const errors = {};
if (!password) { if (!current_password) {
errors.password = 'Required'; errors.current_password = 'Required';
} }
if (!newPassword) { if (!new_password) {
errors.newPassword = 'Required'; errors.new_password = 'Required';
} }
if (!confirmPassword) { if (!confirm_password) {
errors.confirmPassword = 'Required'; errors.confirm_password = 'Required';
} else if (newPassword !== confirmPassword) { } else if (new_password !== confirm_password) {
errors.confirmPassword = `Passwords don't match`; errors.confirm_password = `Passwords don't match`;
} }
return errors; return errors;
@ -37,12 +37,12 @@ export default function ChangePasswordForm({ values, onSave, onClose }) {
const [message, setMessage] = useState(); const [message, setMessage] = useState();
const handleSubmit = async values => { const handleSubmit = async values => {
const response = await post(`/api/website`, values); const response = await post(`/api/account/password`, values);
if (response) { if (typeof response !== 'string') {
onSave(); onSave();
} else { } else {
setMessage('Something went wrong.'); setMessage(response || 'Something went wrong');
} }
}; };
@ -56,19 +56,19 @@ export default function ChangePasswordForm({ values, onSave, onClose }) {
{() => ( {() => (
<Form> <Form>
<FormRow> <FormRow>
<label htmlFor="password">Current password</label> <label htmlFor="current_password">Current password</label>
<Field name="password" type="password" /> <Field name="current_password" type="password" />
<FormError name="password" /> <FormError name="current_password" />
</FormRow> </FormRow>
<FormRow> <FormRow>
<label htmlFor="newPassword">New password</label> <label htmlFor="new_password">New password</label>
<Field name="newPassword" type="password" /> <Field name="new_password" type="password" />
<FormError name="newPassword" /> <FormError name="new_password" />
</FormRow> </FormRow>
<FormRow> <FormRow>
<label htmlFor="confirmPassword">Confirm password</label> <label htmlFor="confirm_password">Confirm password</label>
<Field name="confirmPassword" type="password" /> <Field name="confirm_password" type="password" />
<FormError name="confirmPassword" /> <FormError name="confirm_password" />
</FormRow> </FormRow>
<FormButtons> <FormButtons>
<Button type="submit" variant="action"> <Button type="submit" variant="action">

View File

@ -19,16 +19,16 @@ const validate = ({ confirmation }) => {
return errors; return errors;
}; };
export default function WebsiteDeleteForm({ values, onSave, onClose }) { export default function DeleteForm({ values, onSave, onClose }) {
const [message, setMessage] = useState(); const [message, setMessage] = useState();
const handleSubmit = async ({ website_id }) => { const handleSubmit = async ({ type, id }) => {
const response = await del(`/api/website/${website_id}`); const response = await del(`/api/${type}/${id}`);
if (response) { if (typeof response !== 'string') {
onSave(); onSave();
} else { } else {
setMessage('Something went wrong.'); setMessage('Something went wrong');
} }
}; };

View File

@ -27,7 +27,7 @@ export default function LoginForm() {
if (response?.token) { if (response?.token) {
await Router.push('/'); await Router.push('/');
} else { } else {
setMessage('Incorrect username/password.'); setMessage('Incorrect username/password');
} }
}; };

View File

@ -33,10 +33,10 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
const handleSubmit = async values => { const handleSubmit = async values => {
const response = await post(`/api/website`, values); const response = await post(`/api/website`, values);
if (response) { if (typeof response !== 'string') {
onSave(); onSave();
} else { } else {
setMessage('Something went wrong.'); setMessage('Something went wrong');
} }
}; };

View File

@ -21,7 +21,7 @@ const ErrorTag = ({ msg }) => {
return ( return (
<animated.div className={styles.error} style={props}> <animated.div className={styles.error} style={props}>
{msg} <div className={styles.msg}>{msg}</div>
</animated.div> </animated.div>
); );
}; };

View File

@ -24,9 +24,6 @@
} }
.error { .error {
color: var(--gray50);
background: var(--red400);
font-size: var(--font-size-small);
position: absolute; position: absolute;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -35,7 +32,13 @@
left: 100%; left: 100%;
bottom: 0; bottom: 0;
margin-left: 16px; margin-left: 16px;
padding: 4px 10px; }
.msg {
color: var(--gray50);
background: var(--red400);
font-size: var(--font-size-small);
padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -8,14 +8,16 @@ export default function MenuLayout({ menu, selectedOption, onMenuSelect, childre
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.menu}> <div className={styles.menu}>
{menu.map(item => ( {menu.map(item =>
<div item ? (
className={classNames(styles.option, { [styles.active]: option === item })} <div
onClick={() => setOption(item)} className={classNames(styles.option, { [styles.active]: option === item })}
> onClick={() => setOption(item)}
{item} >
</div> {item}
))} </div>
) : null,
)}
</div> </div>
<div className={styles.content}> <div className={styles.content}>
{typeof children === 'function' ? children(option) : children} {typeof children === 'function' ? children(option) : children}

View File

@ -18,7 +18,7 @@
} }
.option { .option {
padding: 8px 20px; padding: 8px 16px;
cursor: pointer; cursor: pointer;
min-width: 140px; min-width: 140px;
margin-right: 30px; margin-right: 30px;

View File

@ -24,11 +24,11 @@ export function isValidId(s) {
return validate(s); return validate(s);
} }
export function hashPassword(password) { export async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS); return bcrypt.hash(password, SALT_ROUNDS);
} }
export function checkPassword(password, hash) { export async function checkPassword(password, hash) {
return bcrypt.compare(password, hash); return bcrypt.compare(password, hash);
} }

View File

@ -8,18 +8,18 @@ export function redirect(res, url) {
return res.status(303).end(); return res.status(303).end();
} }
export function badRequest(res) { export function badRequest(res, msg) {
return res.status(400).end(); return res.status(400).end(msg);
} }
export function unauthorized(res) { export function unauthorized(res, msg) {
return res.status(401).end(); return res.status(401).end(msg);
} }
export function forbidden(res) { export function forbidden(res, msg) {
return res.status(403).end(); return res.status(403).end(msg);
} }
export function methodNotAllowed(res) { export function methodNotAllowed(res, msg) {
res.status(405).end(); res.status(405).end(msg);
} }

View File

@ -11,7 +11,12 @@ export const apiRequest = (method, url, body) =>
if (res.ok) { if (res.ok) {
return res.json(); return res.json();
} }
return res.text();
if (['post', 'put', 'delete'].includes(method)) {
return res.text();
}
return null;
}); });
function parseQuery(url, params = {}) { function parseQuery(url, params = {}) {

View File

@ -1,15 +1,15 @@
import { getAccounts, getAccount, updateAccount, createAccount } from 'lib/db'; import { getAccounts, getAccount, updateAccount, createAccount } from 'lib/db';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
import { hashPassword, uuid } from 'lib/crypto'; import { hashPassword, uuid } from 'lib/crypto';
import { ok, unauthorized, methodNotAllowed } from 'lib/response'; import { ok, unauthorized, methodNotAllowed, badRequest } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
await useAuth(req, res); await useAuth(req, res);
const { user_id: current_user_id, is_admin } = req.auth; const { user_id: current_user_id, is_admin: current_user_is_admin } = req.auth;
if (req.method === 'GET') { if (req.method === 'GET') {
if (is_admin) { if (current_user_is_admin) {
const accounts = await getAccounts(); const accounts = await getAccounts();
return ok(res, accounts); return ok(res, accounts);
@ -19,28 +19,47 @@ export default async (req, res) => {
} }
if (req.method === 'POST') { if (req.method === 'POST') {
const { user_id, username, password } = req.body; const { user_id, username, password, is_admin } = req.body;
if (user_id) { if (user_id) {
const account = getAccount({ user_id }); const account = await getAccount({ user_id });
if (account.user_id === current_user_id || is_admin) { if (account.user_id === current_user_id || current_user_is_admin) {
const data = { password: password ? await hashPassword(password) : undefined }; const data = { password: password ? await hashPassword(password) : undefined };
if (is_admin) { // Only admin can change these fields
data.username = username; if (current_user_is_admin) {
// Cannot change username of admin
if (username !== 'admin') {
data.username = username;
}
data.is_admin = is_admin;
} }
const updated = await updateAccount(user_id, { username, password }); if (data.username && account.username !== data.username) {
const accountByUsername = await getAccount({ username });
if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
}
const updated = await updateAccount(user_id, data);
return ok(res, updated); return ok(res, updated);
} }
return unauthorized(res); return unauthorized(res);
} else { } else {
const account = await createAccount({ username, password: await hashPassword(password) }); const accountByUsername = await getAccount({ username });
return ok(res, account); if (accountByUsername) {
return badRequest(res, 'Account already exists');
}
const created = await createAccount({ username, password: await hashPassword(password) });
return ok(res, created);
} }
} }

33
pages/api/account/[id].js Normal file
View File

@ -0,0 +1,33 @@
import { getAccount, deleteAccount } from 'lib/db';
import { useAuth } from 'lib/middleware';
import { methodNotAllowed, ok, unauthorized } from 'lib/response';
export default async (req, res) => {
await useAuth(req, res);
const { is_admin } = req.auth;
const { id } = req.query;
const user_id = +id;
if (req.method === 'GET') {
if (is_admin) {
const account = await getAccount({ user_id });
return ok(res, account);
}
return unauthorized(res);
}
if (req.method === 'DELETE') {
if (is_admin) {
await deleteAccount(user_id);
return ok(res);
}
return unauthorized(res);
}
return methodNotAllowed(res);
};

View File

@ -0,0 +1,28 @@
import { getAccount, updateAccount } from 'lib/db';
import { useAuth } from 'lib/middleware';
import { badRequest, methodNotAllowed, ok } from 'lib/response';
import { checkPassword, hashPassword } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
const { user_id } = req.auth;
const { current_password, new_password } = req.body;
if (req.method === 'POST') {
const account = await getAccount({ user_id });
const valid = await checkPassword(current_password, account.password);
if (!valid) {
return badRequest(res, 'Current password is incorrect');
}
const password = await hashPassword(new_password);
const updated = await updateAccount(user_id, { password });
return ok(res, updated);
}
return methodNotAllowed(res);
};

View File

@ -7,7 +7,7 @@ import { ok, unauthorized } from 'lib/response';
export default async (req, res) => { export default async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
const account = await getAccount(username); const account = await getAccount({ username });
if (account && (await checkPassword(password, account.password))) { if (account && (await checkPassword(password, account.password))) {
const { user_id, username, is_admin } = account; const { user_id, username, is_admin } = account;

File diff suppressed because one or more lines are too long