diff --git a/components/Account.js b/components/Account.js deleted file mode 100644 index 08ebe815..00000000 --- a/components/Account.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import Page from './layout/Page'; -import styles from './Account.module.css'; - -export default function Account() { - const user = useSelector(state => state.user); - return ( - -

Account

-
username
-
{user.username}
-
- ); -} diff --git a/components/AccountSettings.js b/components/AccountSettings.js new file mode 100644 index 00000000..48267016 --- /dev/null +++ b/components/AccountSettings.js @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import PageHeader from './layout/PageHeader'; +import Button from './common/Button'; +import Table from './common/Table'; +import Pen from 'assets/pen.svg'; +import Plus from 'assets/plus.svg'; +import Trash from 'assets/trash.svg'; +import { get } from 'lib/web'; +import styles from './AccountSettings.module.css'; +import Modal from './common/Modal'; +import WebsiteEditForm from './forms/WebsiteEditForm'; +import AccountEditForm from './forms/AccountEditForm'; + +export default function AccountSettings() { + const user = useSelector(state => state.user); + const [data, setData] = useState(); + const [addAccount, setAddAccount] = useState(); + const [editAccount, setEditAccount] = useState(); + const [deleteAccount, setDeleteAccount] = useState(); + const [saved, setSaved] = useState(0); + + const columns = [ + { key: 'username', label: 'Username' }, + { + render: row => ( + <> + + + + ), + }, + ]; + + function handleSave() { + setSaved(state => state + 1); + handleClose(); + } + + function handleClose() { + setEditAccount(null); + setAddAccount(null); + setDeleteAccount(null); + } + + async function loadData() { + setData(await get(`/api/account`)); + } + + useEffect(() => { + loadData(); + }, [saved]); + + if (!data) { + return null; + } + + return ( + <> + +
Accounts
+ +
+ + {editAccount && ( + + + + )} + {addAccount && ( + + + + )} + + ); +} diff --git a/components/Account.module.css b/components/AccountSettings.module.css similarity index 100% rename from components/Account.module.css rename to components/AccountSettings.module.css diff --git a/components/ProfileSettings.js b/components/ProfileSettings.js new file mode 100644 index 00000000..23f037ec --- /dev/null +++ b/components/ProfileSettings.js @@ -0,0 +1,29 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import PageHeader from './layout/PageHeader'; +import Button from './common/Button'; +import ChangePasswordForm from './forms/ChangePasswordForm'; +import Modal from './common/Modal'; + +export default function ProfileSettings() { + const user = useSelector(state => state.user); + const [changePassword, setChangePassword] = useState(false); + + return ( + <> + +
Profile
+ +
+
Username
+
{user.username}
+ {changePassword && ( + + setChangePassword(false)} /> + + )} + + ); +} diff --git a/components/Settings.js b/components/Settings.js index e9c56610..4b4e4197 100644 --- a/components/Settings.js +++ b/components/Settings.js @@ -1,108 +1,26 @@ -import React, { useState, useEffect } from 'react'; -import Page from './layout/Page'; -import Table from './common/Table'; -import Button from './common/Button'; -import PageHeader from './layout/PageHeader'; -import Pen from 'assets/pen.svg'; -import Trash from 'assets/trash.svg'; -import Plus from 'assets/plus.svg'; -import Code from 'assets/code.svg'; -import { get } from 'lib/web'; -import Modal from './common/Modal'; -import WebsiteEditForm from './forms/WebsiteEditForm'; -import styles from './Settings.module.css'; -import WebsiteDeleteForm from './forms/WebsiteDeleteForm'; -import WebsiteCodeForm from './forms/WebsiteCodeForm'; +import React from 'react'; +import Page from 'components/layout/Page'; +import MenuLayout from 'components/layout/MenuLayout'; +import WebsiteSettings from './WebsiteSettings'; +import AccountSettings from './AccountSettings'; +import ProfileSettings from './ProfileSettings'; + +const menuOptions = ['Websites', 'Accounts', 'Profile']; export default function Settings() { - const [data, setData] = useState(); - const [edit, setEdit] = useState(); - const [del, setDelete] = useState(); - const [add, setAdd] = useState(); - const [code, setCode] = useState(); - const [saved, setSaved] = useState(0); - - const columns = [ - { key: 'name', label: 'Name' }, - { key: 'domain', label: 'Domain' }, - { - key: 'action', - cell: { - className: styles.buttons, - }, - render: row => ( - <> - - - - - ), - }, - ]; - - function handleSave() { - setSaved(state => state + 1); - handleClose(); - } - - function handleClose() { - setAdd(null); - setEdit(null); - setDelete(null); - setCode(null); - } - - async function loadData() { - setData(await get(`/api/website`)); - } - - useEffect(() => { - loadData(); - }, [saved]); - - if (!data) { - return null; - } - return ( - -
Websites
- -
-
- {edit && ( - - - - )} - {add && ( - - - - )} - {del && ( - - - - )} - {code && ( - - - - )} + + {option => { + if (option === 'Websites') { + return ; + } else if (option === 'Accounts') { + return ; + } else if (option === 'Profile') { + return ; + } + }} + ); } diff --git a/components/WebsiteList.js b/components/WebsiteList.js index 1ac294f5..00668673 100644 --- a/components/WebsiteList.js +++ b/components/WebsiteList.js @@ -41,7 +41,7 @@ export default function WebsiteList() { shallow: true, }) } - size="S" + size="small" >
View details
diff --git a/components/WebsiteSettings.js b/components/WebsiteSettings.js new file mode 100644 index 00000000..84f7f014 --- /dev/null +++ b/components/WebsiteSettings.js @@ -0,0 +1,101 @@ +import React, { useState, useEffect } from 'react'; +import Table from './common/Table'; +import Button from './common/Button'; +import PageHeader from './layout/PageHeader'; +import Pen from 'assets/pen.svg'; +import Trash from 'assets/trash.svg'; +import Plus from 'assets/plus.svg'; +import Code from 'assets/code.svg'; +import { get } from 'lib/web'; +import Modal from './common/Modal'; +import WebsiteEditForm from './forms/WebsiteEditForm'; +import WebsiteDeleteForm from './forms/WebsiteDeleteForm'; +import WebsiteCodeForm from './forms/WebsiteCodeForm'; +import styles from './WebsiteSettings.module.css'; + +export default function WebsiteSettings() { + const [data, setData] = useState(); + const [editWebsite, setEditWebsite] = useState(); + const [deleteWebsite, setDeleteWebsite] = useState(); + const [addWebsite, setAddWebsite] = useState(); + const [showCode, setShowCode] = useState(); + const [saved, setSaved] = useState(0); + + const columns = [ + { key: 'name', label: 'Name', className: styles.col }, + { key: 'domain', label: 'Domain', className: styles.col }, + { + key: 'action', + className: styles.buttons, + render: row => ( + <> + + + + + ), + }, + ]; + + function handleSave() { + setSaved(state => state + 1); + handleClose(); + } + + function handleClose() { + setAddWebsite(null); + setEditWebsite(null); + setDeleteWebsite(null); + setShowCode(null); + } + + async function loadData() { + setData(await get(`/api/website`)); + } + + useEffect(() => { + loadData(); + }, [saved]); + + if (!data) { + return null; + } + + return ( + <> + +
Websites
+ +
+
+ {editWebsite && ( + + + + )} + {addWebsite && ( + + + + )} + {deleteWebsite && ( + + + + )} + {showCode && ( + + + + )} + + ); +} diff --git a/components/Settings.module.css b/components/WebsiteSettings.module.css similarity index 64% rename from components/Settings.module.css rename to components/WebsiteSettings.module.css index 91af3246..9283578f 100644 --- a/components/Settings.module.css +++ b/components/WebsiteSettings.module.css @@ -1,4 +1,9 @@ +.col { + flex: 2; +} + .buttons { + flex: 3; display: flex; justify-content: flex-end; } diff --git a/components/common/Button.js b/components/common/Button.js index 7963a36a..b61f05d3 100644 --- a/components/common/Button.js +++ b/components/common/Button.js @@ -16,8 +16,8 @@ export default function Button({ + + + {message} + + )} + + + ); +} diff --git a/components/forms/ChangePasswordForm.js b/components/forms/ChangePasswordForm.js new file mode 100644 index 00000000..a59de0ba --- /dev/null +++ b/components/forms/ChangePasswordForm.js @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { Formik, Form, Field } from 'formik'; +import { post } from 'lib/web'; +import Button from 'components/common/Button'; +import FormLayout, { + FormButtons, + FormError, + FormMessage, + FormRow, +} from 'components/layout/FormLayout'; + +const initialValues = { + password: '', + newPassword: '', + defaultPassword: '', +}; + +const validate = ({ password, newPassword, confirmPassword }) => { + const errors = {}; + + if (!password) { + errors.password = 'Required'; + } + if (!newPassword) { + errors.newPassword = 'Required'; + } + if (!confirmPassword) { + errors.confirmPassword = 'Required'; + } else if (newPassword !== confirmPassword) { + errors.confirmPassword = `Passwords don't match`; + } + + return errors; +}; + +export default function ChangePasswordForm({ values, onSave, onClose }) { + const [message, setMessage] = useState(); + + const handleSubmit = async values => { + const response = await post(`/api/website`, values); + + if (response) { + onSave(); + } else { + setMessage('Something went wrong.'); + } + }; + + return ( + + + {() => ( +
+ + + + + + + + + + + + + + + + + + + + {message} + + )} +
+
+ ); +} diff --git a/components/forms/WebsiteEditForm.js b/components/forms/WebsiteEditForm.js index 10f98ff5..5473da3c 100644 --- a/components/forms/WebsiteEditForm.js +++ b/components/forms/WebsiteEditForm.js @@ -9,6 +9,11 @@ import FormLayout, { FormRow, } from 'components/layout/FormLayout'; +const initialValues = { + name: '', + domain: '', +}; + const validate = ({ name, domain }) => { const errors = {}; @@ -37,7 +42,11 @@ export default function WebsiteEditForm({ values, onSave, onClose }) { return ( - + {() => (
diff --git a/components/layout/FormLayout.module.css b/components/layout/FormLayout.module.css index f2b91596..ef73310f 100644 --- a/components/layout/FormLayout.module.css +++ b/components/layout/FormLayout.module.css @@ -5,7 +5,7 @@ } .form label { - display: inline-block; + display: block; min-width: 100px; } @@ -37,6 +37,7 @@ margin-left: 16px; padding: 4px 10px; border-radius: 4px; + white-space: nowrap; } .error:after { diff --git a/components/layout/Header.js b/components/layout/Header.js index d382bcaf..4eaa9798 100644 --- a/components/layout/Header.js +++ b/components/layout/Header.js @@ -15,7 +15,7 @@ export default function Header() {
- } size="L" className={styles.logo} /> + } size="large" className={styles.logo} /> {user ? umami : 'umami'}
diff --git a/components/layout/MenuLayout.js b/components/layout/MenuLayout.js new file mode 100644 index 00000000..c4776abe --- /dev/null +++ b/components/layout/MenuLayout.js @@ -0,0 +1,25 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import styles from './MenuLayout.module.css'; + +export default function MenuLayout({ menu, selectedOption, onMenuSelect, children }) { + const [option, setOption] = useState(selectedOption); + + return ( +
+
+ {menu.map(item => ( +
setOption(item)} + > + {item} +
+ ))} +
+
+ {typeof children === 'function' ? children(option) : children} +
+
+ ); +} diff --git a/components/layout/MenuLayout.module.css b/components/layout/MenuLayout.module.css new file mode 100644 index 00000000..cddc6c2f --- /dev/null +++ b/components/layout/MenuLayout.module.css @@ -0,0 +1,34 @@ +.container { + display: flex; + flex: 1; +} + +.menu { + display: flex; + flex-direction: column; + padding-top: 30px; +} + +.content { + display: flex; + flex-direction: column; + border-left: 1px solid var(--gray300); + padding-left: 30px; + flex: 1; +} + +.option { + padding: 8px 20px; + cursor: pointer; + min-width: 140px; + margin-right: 30px; + border-radius: 4px; +} + +.option:hover { + background: var(--gray75); +} + +.active { + font-weight: 600; +} diff --git a/components/layout/PageHeader.js b/components/layout/PageHeader.js index 9e4d8b61..62ec1947 100644 --- a/components/layout/PageHeader.js +++ b/components/layout/PageHeader.js @@ -1,12 +1,6 @@ -import React, { Children } from 'react'; +import React from 'react'; import styles from './PageHeader.module.css'; export default function PageHeader({ children }) { - const [firstChild, ...otherChildren] = Children.toArray(children); - return ( -
-
{firstChild}
- {otherChildren &&
{otherChildren}
} -
- ); + return
{children}
; } diff --git a/components/layout/PageHeader.module.css b/components/layout/PageHeader.module.css index fc93b049..0c88c0f2 100644 --- a/components/layout/PageHeader.module.css +++ b/components/layout/PageHeader.module.css @@ -3,8 +3,5 @@ justify-content: space-between; align-items: center; line-height: 80px; -} - -.title { font-size: var(--font-size-large); } diff --git a/lib/crypto.js b/lib/crypto.js index 32ce80f8..ec9be392 100644 --- a/lib/crypto.js +++ b/lib/crypto.js @@ -3,6 +3,7 @@ import { v4, v5, validate } from 'uuid'; import bcrypt from 'bcrypt'; import { JWT, JWE, JWK } from 'jose'; +const SALT_ROUNDS = 10; const KEY = JWK.asKey(Buffer.from(secret())); export function hash(...args) { @@ -23,6 +24,10 @@ export function isValidId(s) { return validate(s); } +export function hashPassword(password) { + return bcrypt.hash(password, SALT_ROUNDS); +} + export function checkPassword(password, hash) { return bcrypt.compare(password, hash); } diff --git a/lib/db.js b/lib/db.js index 515877f2..ff7774e8 100644 --- a/lib/db.js +++ b/lib/db.js @@ -124,8 +124,8 @@ export async function getSession({ session_id, session_uuid }) { return runQuery( prisma.session.findOne({ where: { - ...(session_id && { session_id }), - ...(session_uuid && { session_uuid }), + session_id, + session_uuid, }, }), ); @@ -174,16 +174,53 @@ export async function saveEvent(website_id, session_id, url, event_type, event_v ); } -export async function getAccount(username = '') { +export async function getAccounts() { + return runQuery(prisma.account.findMany()); +} + +export async function getAccount({ user_id, username }) { return runQuery( prisma.account.findOne({ where: { username, + user_id, }, }), ); } +export async function updateAccount(user_id, data) { + return runQuery( + prisma.account.update({ + where: { + user_id, + }, + data, + }), + ); +} + +export async function deleteAccount(user_id) { + return runQuery( + /* Prisma bug, does not cascade on non-nullable foreign keys + prisma.account.delete({ + where: { + user_id, + }, + }), + */ + prisma.queryRaw(`delete from account where user_id=$1`, user_id), + ); +} + +export async function createAccount(data) { + return runQuery( + prisma.account.create({ + data, + }), + ); +} + export async function getPageviews(website_id, start_at, end_at) { return runQuery( prisma.pageview.findMany({ diff --git a/lib/session.js b/lib/session.js index 9cdb1c7c..901c9023 100644 --- a/lib/session.js +++ b/lib/session.js @@ -4,6 +4,11 @@ import { uuid, isValidId, parseToken } from 'lib/crypto'; export async function verifySession(req) { const { payload } = req.body; + + if (!payload) { + throw new Error('Invalid request'); + } + const { website: website_uuid, hostname, screen, language, session } = payload; const token = await parseToken(session); diff --git a/lib/web.js b/lib/web.js index ad9db7c8..3be2ef57 100644 --- a/lib/web.js +++ b/lib/web.js @@ -11,7 +11,7 @@ export const apiRequest = (method, url, body) => if (res.ok) { return res.json(); } - return null; + return res.text(); }); function parseQuery(url, params = {}) { diff --git a/pages/account.js b/pages/account.js index d97b6325..0fddfeb1 100644 --- a/pages/account.js +++ b/pages/account.js @@ -1,6 +1,6 @@ import React from 'react'; import Layout from 'components/layout/Layout'; -import Account from 'components/Account'; +import AccountSettings from 'components/AccountSettings'; import useRequireLogin from 'hooks/useRequireLogin'; export default function AccountPage() { @@ -12,7 +12,7 @@ export default function AccountPage() { return ( - + ); } diff --git a/pages/api/account.js b/pages/api/account.js new file mode 100644 index 00000000..f04fae10 --- /dev/null +++ b/pages/api/account.js @@ -0,0 +1,48 @@ +import { getAccounts, getAccount, updateAccount, createAccount } from 'lib/db'; +import { useAuth } from 'lib/middleware'; +import { hashPassword, uuid } from 'lib/crypto'; +import { ok, unauthorized, methodNotAllowed } from 'lib/response'; + +export default async (req, res) => { + await useAuth(req, res); + + const { user_id: current_user_id, is_admin } = req.auth; + + if (req.method === 'GET') { + if (is_admin) { + const accounts = await getAccounts(); + + return ok(res, accounts); + } + + return unauthorized(res); + } + + if (req.method === 'POST') { + const { user_id, username, password } = req.body; + + if (user_id) { + const account = getAccount({ user_id }); + + if (account.user_id === current_user_id || is_admin) { + const data = { password: password ? await hashPassword(password) : undefined }; + + if (is_admin) { + data.username = username; + } + + const updated = await updateAccount(user_id, { username, password }); + + return ok(res, updated); + } + + return unauthorized(res); + } else { + const account = await createAccount({ username, password: await hashPassword(password) }); + + return ok(res, account); + } + } + + return methodNotAllowed(res); +}; diff --git a/pages/login.js b/pages/login.js index fbbab1d1..4c8228b9 100644 --- a/pages/login.js +++ b/pages/login.js @@ -7,7 +7,7 @@ import Logo from 'assets/logo.svg'; export default function LoginPage() { return ( - } size="XL" /> + } size="xlarge" /> ); diff --git a/styles/index.css b/styles/index.css index d84248f0..a6dfe968 100644 --- a/styles/index.css +++ b/styles/index.css @@ -59,10 +59,12 @@ textarea { resize: none; } -select { - padding: 4px 8px; - border: 1px solid var(--gray500) !important; - border-radius: 4px; +dt { + font-weight: 600; +} + +dd { + margin: 0 0 10px 0; } main {