diff --git a/components/Settings.js b/components/Settings.js index 267f3d60..e5432f4d 100644 --- a/components/Settings.js +++ b/components/Settings.js @@ -8,13 +8,15 @@ import Trash from 'assets/trash.svg'; import Plus from 'assets/plus.svg'; import { get } from 'lib/web'; import Modal from './common/Modal'; -import WebsiteForm from './forms/WebsiteForm'; +import WebsiteEditForm from './forms/WebsiteEditForm'; import styles from './Settings.module.css'; +import WebsiteDeleteForm from './forms/WebsiteDeleteForm'; export default function Settings() { const [data, setData] = useState(); const [edit, setEdit] = useState(); const [del, setDelete] = useState(); + const [add, setAdd] = useState(); const [saved, setSaved] = useState(0); const columns = [ @@ -44,6 +46,7 @@ export default function Settings() { } function handleClose() { + setAdd(null); setEdit(null); setDelete(null); } @@ -64,14 +67,28 @@ export default function Settings() {
Websites
-
{edit && ( - + + + )} + {add && ( + + + + )} + {del && ( + + )} diff --git a/components/common/DropDown.js b/components/common/DropDown.js index c3d6f852..f74fabff 100644 --- a/components/common/DropDown.js +++ b/components/common/DropDown.js @@ -36,7 +36,7 @@ export default function DropDown({
{options.find(e => e.value === value)?.label} - } size="S" className={styles.icon} /> + } size="S" />
{showMenu && }
diff --git a/components/common/Dropdown.module.css b/components/common/Dropdown.module.css index e78d544a..ec63552f 100644 --- a/components/common/Dropdown.module.css +++ b/components/common/Dropdown.module.css @@ -5,18 +5,12 @@ } .value { + display: flex; + justify-content: space-between; white-space: nowrap; position: relative; - padding: 4px 32px 4px 16px; + padding: 4px 16px; border: 1px solid var(--gray500); border-radius: 4px; cursor: pointer; } - -.icon { - position: absolute; - top: 0; - bottom: 0; - right: 12px; - margin: auto; -} diff --git a/components/common/Modal.module.css b/components/common/Modal.module.css index 450b5820..74aa67b9 100644 --- a/components/common/Modal.module.css +++ b/components/common/Modal.module.css @@ -31,7 +31,6 @@ border: 1px solid var(--gray300); padding: 30px; border-radius: 4px; - overflow: hidden; } .header { diff --git a/components/forms/LoginForm.js b/components/forms/LoginForm.js index 395319bb..0991c5eb 100644 --- a/components/forms/LoginForm.js +++ b/components/forms/LoginForm.js @@ -56,7 +56,7 @@ export default function LoginForm() { - diff --git a/components/forms/WebsiteDeleteForm.js b/components/forms/WebsiteDeleteForm.js new file mode 100644 index 00000000..37d4de79 --- /dev/null +++ b/components/forms/WebsiteDeleteForm.js @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { Formik, Form, Field } from 'formik'; +import { del } from 'lib/web'; +import Button from 'components/interface/Button'; +import FormLayout, { + FormButtons, + FormError, + FormMessage, + FormRow, +} from 'components/layout/FormLayout'; + +const validate = ({ confirmation }) => { + const errors = {}; + + if (confirmation !== 'DELETE') { + errors.confirmation = !confirmation ? 'Required' : 'Invalid'; + } + + return errors; +}; + +export default function WebsiteDeleteForm({ initialValues, onSave, onClose }) { + const [message, setMessage] = useState(); + + const handleSubmit = async ({ website_id }) => { + const response = await del(`/api/website/${website_id}`); + + if (response) { + onSave(); + } else { + setMessage('Something went wrong.'); + } + }; + + return ( + + + {() => ( +
+
+ Are your sure you want to delete {initialValues.name}? +
+
All associated data will be deleted as well.
+

+ Type DELETE in the box below to confirm. +

+ + + + + + + + + + {message} + + )} +
+
+ ); +} diff --git a/components/forms/WebsiteForm.js b/components/forms/WebsiteEditForm.js similarity index 89% rename from components/forms/WebsiteForm.js rename to components/forms/WebsiteEditForm.js index b131b93c..a287947a 100644 --- a/components/forms/WebsiteForm.js +++ b/components/forms/WebsiteEditForm.js @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { Formik, Form, Field } from 'formik'; -import Router from 'next/router'; import { post } from 'lib/web'; import Button from 'components/interface/Button'; import FormLayout, { @@ -23,7 +22,7 @@ const validate = ({ name, domain }) => { return errors; }; -export default function WebsiteForm({ initialValues, onSave, onClose }) { +export default function WebsiteEditForm({ initialValues, onSave, onClose }) { const [message, setMessage] = useState(); const handleSubmit = async values => { @@ -52,7 +51,9 @@ export default function WebsiteForm({ initialValues, onSave, onClose }) { - + {message} diff --git a/components/interface/Button.js b/components/interface/Button.js index 3900dc3d..73513d91 100644 --- a/components/interface/Button.js +++ b/components/interface/Button.js @@ -7,6 +7,7 @@ export default function Button({ type = 'button', icon, size, + variant, children, className, onClick = () => {}, @@ -17,6 +18,8 @@ export default function Button({ className={classNames(styles.button, className, { [styles.small]: size === 'S', [styles.large]: size === 'L', + [styles.action]: variant === 'action', + [styles.danger]: variant === 'danger', })} onClick={onClick} > diff --git a/components/interface/Button.module.css b/components/interface/Button.module.css index fc46c02d..d6e6602c 100644 --- a/components/interface/Button.module.css +++ b/components/interface/Button.module.css @@ -26,3 +26,21 @@ .large { font-size: var(--font-size-large); } + +.action { + color: var(--gray50) !important; + background: var(--gray900) !important; +} + +.action:hover { + background: var(--gray800) !important; +} + +.danger { + color: var(--gray50) !important; + background: var(--red500) !important; +} + +.danger:hover { + background: var(--red400) !important; +} diff --git a/components/layout/FormLayout.js b/components/layout/FormLayout.js index 28c5f2d5..972b9273 100644 --- a/components/layout/FormLayout.js +++ b/components/layout/FormLayout.js @@ -1,4 +1,5 @@ import React from 'react'; +import { useSpring, animated } from 'react-spring'; import classNames from 'classnames'; import { ErrorMessage } from 'formik'; import styles from './FormLayout.module.css'; @@ -11,9 +12,19 @@ export const FormButtons = ({ className, children }) => (
{children}
); -export const FormError = ({ name }) => ( - {msg =>
{msg}
}
-); +export const FormError = ({ name }) => { + return {msg => }; +}; + +const ErrorTag = ({ msg }) => { + const props = useSpring({ opacity: 1, from: { opacity: 0 } }); + + return ( + + {msg} + + ); +}; export const FormRow = ({ children }) =>
{children}
; diff --git a/components/layout/FormLayout.module.css b/components/layout/FormLayout.module.css index 9bf5a3de..f2b91596 100644 --- a/components/layout/FormLayout.module.css +++ b/components/layout/FormLayout.module.css @@ -20,11 +20,12 @@ .buttons { display: flex; justify-content: center; + margin-top: 20px; } .error { color: var(--gray50); - background: var(--color-error); + background: var(--red400); font-size: var(--font-size-small); position: absolute; display: flex; @@ -47,7 +48,7 @@ margin: auto; width: 10px; height: 10px; - background: var(--color-error); + background: var(--red400); transform: rotate(45deg); } diff --git a/lib/auth.js b/lib/auth.js index 034626ad..b9344448 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -1,9 +1,9 @@ import { parse } from 'cookie'; -import { verifySecureToken } from './crypto'; +import { parseSecureToken } from './crypto'; import { AUTH_COOKIE_NAME } from './constants'; export async function verifyAuthToken(req) { const token = parse(req.headers.cookie || '')[AUTH_COOKIE_NAME]; - return verifySecureToken(token); + return parseSecureToken(token); } diff --git a/lib/crypto.js b/lib/crypto.js index 0041adfa..dbd578bd 100644 --- a/lib/crypto.js +++ b/lib/crypto.js @@ -1,9 +1,8 @@ import crypto from 'crypto'; -import { v5 } from 'uuid'; +import { v4, v5, validate } from 'uuid'; import bcrypt from 'bcrypt'; import { JWT, JWE, JWK } from 'jose'; -const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/; const KEY = JWK.asKey(Buffer.from(secret())); export function hash(...args) { @@ -15,11 +14,13 @@ export function secret() { } export function uuid(...args) { + if (!args.length) return v4(); + return v5(args.join(''), v5(process.env.HASH_SALT, v5.DNS)); } export function isValidId(s) { - return UUID_REGEX.test(s); + return validate(s); } export function checkPassword(password, hash) { @@ -30,15 +31,24 @@ export async function createToken(payload) { return JWT.sign(payload, KEY); } -export async function verifyToken(token) { - return JWT.verify(token, KEY); +export async function parseToken(token) { + try { + return JWT.verify(token, KEY); + } catch { + return null; + } } export async function createSecureToken(payload) { return JWE.encrypt(await createToken(payload), KEY); } -export async function verifySecureToken(token) { - const result = await JWE.decrypt(token, KEY); - return verifyToken(result.toString()); +export async function parseSecureToken(token) { + try { + const result = await JWE.decrypt(token, KEY); + + return parseToken(result.toString()); + } catch { + return null; + } } diff --git a/lib/db.js b/lib/db.js index b047b866..515877f2 100644 --- a/lib/db.js +++ b/lib/db.js @@ -63,6 +63,21 @@ export async function getWebsites(user_id) { ); } +export async function createWebsite(user_id, data) { + return runQuery( + prisma.website.create({ + data: { + account: { + connect: { + user_id, + }, + }, + ...data, + }, + }), + ); +} + export async function updateWebsite(website_id, data) { return runQuery( prisma.website.update({ @@ -74,6 +89,19 @@ export async function updateWebsite(website_id, data) { ); } +export async function deleteWebsite(website_id) { + return runQuery( + /* Prisma bug, does not cascade on non-nullable foreign keys + prisma.website.delete({ + where: { + website_id, + }, + }), + */ + prisma.queryRaw(`delete from website where website_id=$1`, website_id), + ); +} + export async function createSession(website_id, data) { return runQuery( prisma.session.create({ diff --git a/lib/middleware.js b/lib/middleware.js index b0260345..1694a67a 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -17,19 +17,23 @@ export function use(middleware) { export const useCors = use(cors()); export const useSession = use(async (req, res, next) => { - try { - req.session = await verifySession(req); - } catch { + const session = await verifySession(req); + + if (!session) { return res.status(400).end(); } + + req.session = session; next(); }); export const useAuth = use(async (req, res, next) => { - try { - req.auth = await verifyAuthToken(req); - } catch { + const token = await verifyAuthToken(req); + + if (!token) { return res.status(401).end(); } + + req.auth = token; next(); }); diff --git a/lib/response.js b/lib/response.js new file mode 100644 index 00000000..54df0f6c --- /dev/null +++ b/lib/response.js @@ -0,0 +1,25 @@ +export function ok(res, data = {}) { + return res.status(200).json(data); +} + +export function redirect(res, url) { + res.setHeader('Location', url); + + return res.status(303).end(); +} + +export function badRequest(res) { + return res.status(400).end(); +} + +export function unauthorized(res) { + return res.status(401).end(); +} + +export function forbidden(res) { + return res.status(403).end(); +} + +export function methodNotAllowed(res) { + res.status(405).end(); +} diff --git a/lib/session.js b/lib/session.js index ca8bb697..9cdb1c7c 100644 --- a/lib/session.js +++ b/lib/session.js @@ -1,18 +1,14 @@ import { getWebsite, getSession, createSession } from 'lib/db'; import { getClientInfo } from 'lib/request'; -import { uuid, isValidId, verifyToken } from 'lib/crypto'; +import { uuid, isValidId, parseToken } from 'lib/crypto'; export async function verifySession(req) { const { payload } = req.body; const { website: website_uuid, hostname, screen, language, session } = payload; - if (!isValidId(website_uuid)) { - throw new Error(`Invalid website: ${website_uuid}`); - } + const token = await parseToken(session); - try { - return await verifyToken(session); - } catch { + if (!token || !isValidId(website_uuid) || token.website_uuid !== website_uuid) { const { userAgent, browser, os, ip, country, device } = await getClientInfo(req, payload); if (website_uuid) { @@ -50,4 +46,6 @@ export async function verifySession(req) { } } } + + return token; } diff --git a/lib/web.js b/lib/web.js index d3430600..ad9db7c8 100644 --- a/lib/web.js +++ b/lib/web.js @@ -26,9 +26,11 @@ function parseQuery(url, params = {}) { export const get = (url, params) => apiRequest('get', parseQuery(url, params)); +export const del = (url, params) => apiRequest('delete', parseQuery(url, params)); + export const post = (url, params) => apiRequest('post', url, JSON.stringify(params)); -export const del = (url, params) => apiRequest('del', parseQuery(url, params)); +export const put = (url, params) => apiRequest('put', url, JSON.stringify(params)); export const hook = (_this, method, callback) => { const orig = _this[method]; diff --git a/pages/api/auth/login.js b/pages/api/auth/login.js index 24ec8d9d..89833a9b 100644 --- a/pages/api/auth/login.js +++ b/pages/api/auth/login.js @@ -2,6 +2,7 @@ import { serialize } from 'cookie'; import { checkPassword, createSecureToken } from 'lib/crypto'; import { getAccount } from 'lib/db'; import { AUTH_COOKIE_NAME } from 'lib/constants'; +import { ok, unauthorized } from 'lib/response'; export default async (req, res) => { const { username, password } = req.body; @@ -19,8 +20,8 @@ export default async (req, res) => { res.setHeader('Set-Cookie', [cookie]); - return res.status(200).json({ token }); + return ok(res, { token }); } - return res.status(401).end(); + return unauthorized(res); }; diff --git a/pages/api/auth/logout.js b/pages/api/auth/logout.js index fc9cd5ba..a78152f7 100644 --- a/pages/api/auth/logout.js +++ b/pages/api/auth/logout.js @@ -1,5 +1,6 @@ import { serialize } from 'cookie'; import { AUTH_COOKIE_NAME } from 'lib/constants'; +import { redirect } from 'lib/response'; export default async (req, res) => { const cookie = serialize(AUTH_COOKIE_NAME, '', { @@ -8,9 +9,7 @@ export default async (req, res) => { maxAge: 0, }); - res.statusCode = 303; res.setHeader('Set-Cookie', [cookie]); - res.setHeader('Location', '/login'); - return res.end(); + return redirect(res, '/login'); }; diff --git a/pages/api/auth/verify.js b/pages/api/auth/verify.js index e0f503b7..d9a2bb01 100644 --- a/pages/api/auth/verify.js +++ b/pages/api/auth/verify.js @@ -1,11 +1,12 @@ import { useAuth } from 'lib/middleware'; +import { ok, unauthorized } from 'lib/response'; export default async (req, res) => { await useAuth(req, res); if (req.auth) { - return res.status(200).json(req.auth); + return ok(res, req.auth); } - return res.status(401).end(); + return unauthorized(res); }; diff --git a/pages/api/collect.js b/pages/api/collect.js index 325b45d5..86d25b63 100644 --- a/pages/api/collect.js +++ b/pages/api/collect.js @@ -1,6 +1,7 @@ import { savePageView, saveEvent } from 'lib/db'; import { useCors, useSession } from 'lib/middleware'; import { createToken } from 'lib/crypto'; +import { ok, badRequest } from 'lib/response'; export default async (req, res) => { await useCors(req, res); @@ -10,21 +11,18 @@ export default async (req, res) => { const token = await createToken(session); const { website_id, session_id } = session; const { type, payload } = req.body; - let ok = false; if (type === 'pageview') { const { url, referrer } = payload; await savePageView(website_id, session_id, url, referrer); - - ok = true; } else if (type === 'event') { const { url, event_type, event_value } = payload; await saveEvent(website_id, session_id, url, event_type, event_value); - - ok = true; + } else { + return badRequest(res); } - return res.status(200).json({ ok, session: token }); + return ok(res, { session: token }); }; diff --git a/pages/api/user.js b/pages/api/user.js index 1af56731..2b5017be 100644 --- a/pages/api/user.js +++ b/pages/api/user.js @@ -1,12 +1,13 @@ -import { verifySecureToken } from 'lib/crypto'; +import { parseSecureToken } from 'lib/crypto'; +import { ok, badRequest } from 'lib/response'; export default async (req, res) => { const { token } = req.body; try { - const payload = await verifySecureToken(token); - return res.status(200).json(payload); + const payload = await parseSecureToken(token); + return ok(res, payload); } catch { - return res.status(400).end(); + return badRequest(res); } }; diff --git a/pages/api/website.js b/pages/api/website.js index e308c65e..910d613c 100644 --- a/pages/api/website.js +++ b/pages/api/website.js @@ -1,26 +1,40 @@ -import { getWebsites, updateWebsite } from 'lib/db'; +import { getWebsites, updateWebsite, createWebsite, getWebsite } from 'lib/db'; import { useAuth } from 'lib/middleware'; +import { uuid } from 'lib/crypto'; +import { ok, unauthorized, methodNotAllowed } from 'lib/response'; export default async (req, res) => { await useAuth(req, res); - const { user_id } = req.auth; + const { user_id, is_admin } = req.auth; const { website_id } = req.body; if (req.method === 'GET') { const websites = await getWebsites(user_id); - return res.status(200).json(websites); + return ok(res, websites); } if (req.method === 'POST') { - if (website_id) { - const { name, domain } = req.body; - const website = await updateWebsite(website_id, { name, domain }); + const { name, domain } = req.body; - return res.status(200).json(website); + if (website_id) { + const website = getWebsite(website_id); + + if (website.user_id === user_id || is_admin) { + await updateWebsite(website_id, { name, domain }); + + return ok(res); + } + + return unauthorized(res); + } else { + const website_uuid = uuid(); + const website = await createWebsite(user_id, { website_uuid, name, domain }); + + return ok(res, website); } } - return res.status(405).end(); + return methodNotAllowed(res); }; diff --git a/pages/api/website/[id]/index.js b/pages/api/website/[id]/index.js index cb39c63e..ff1c219b 100644 --- a/pages/api/website/[id]/index.js +++ b/pages/api/website/[id]/index.js @@ -1,12 +1,31 @@ -import { getWebsite } from 'lib/db'; +import { deleteWebsite, getWebsite } 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 { user_id, is_admin } = req.auth; const { id } = req.query; + const website_id = +id; - const website = await getWebsite({ website_id: +id }); + if (req.method === 'GET') { + const website = await getWebsite({ website_id }); - return res.status(200).json(website); + return ok(res, website); + } + + if (req.method === 'DELETE') { + const website = await getWebsite({ website_id }); + + if (website.user_id === user_id || is_admin) { + await deleteWebsite(website_id); + + return ok(res); + } + + return unauthorized(res); + } + + return methodNotAllowed(res); }; diff --git a/pages/api/website/[id]/metrics.js b/pages/api/website/[id]/metrics.js index 234336b0..62ef6afc 100644 --- a/pages/api/website/[id]/metrics.js +++ b/pages/api/website/[id]/metrics.js @@ -1,5 +1,6 @@ import { getMetrics } from 'lib/db'; import { useAuth } from 'lib/middleware'; +import { ok } from 'lib/response'; export default async (req, res) => { await useAuth(req, res); @@ -17,5 +18,5 @@ export default async (req, res) => { return obj; }, {}); - return res.status(200).json(stats); + return ok(res, stats); }; diff --git a/pages/api/website/[id]/pageviews.js b/pages/api/website/[id]/pageviews.js index a2a2fbab..68311e3e 100644 --- a/pages/api/website/[id]/pageviews.js +++ b/pages/api/website/[id]/pageviews.js @@ -1,6 +1,7 @@ import moment from 'moment-timezone'; import { getPageviewData } from 'lib/db'; import { useAuth } from 'lib/middleware'; +import { ok, badRequest } from 'lib/response'; const unitTypes = ['month', 'hour', 'day']; @@ -10,7 +11,7 @@ export default async (req, res) => { const { id, start_at, end_at, unit, tz } = req.query; if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) { - return res.status(400).end(); + return badRequest(res); } const start = new Date(+start_at); @@ -21,5 +22,5 @@ export default async (req, res) => { getPageviewData(+id, start, end, tz, unit, 'distinct session_id'), ]); - return res.status(200).json({ pageviews, uniques }); + return ok(res, { pageviews, uniques }); }; diff --git a/pages/api/website/[id]/rankings.js b/pages/api/website/[id]/rankings.js index 4be955cf..aa58b359 100644 --- a/pages/api/website/[id]/rankings.js +++ b/pages/api/website/[id]/rankings.js @@ -1,5 +1,6 @@ import { getRankings } from 'lib/db'; import { useAuth } from 'lib/middleware'; +import { ok, badRequest } from 'lib/response'; const sessionColumns = ['browser', 'os', 'device', 'country']; const pageviewColumns = ['url', 'referrer']; @@ -10,12 +11,12 @@ export default async (req, res) => { const { id, type, start_at, end_at } = req.query; if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) { - return res.status(400).end(); + return badRequest(res); } const table = sessionColumns.includes(type) ? 'session' : 'pageview'; const rankings = await getRankings(+id, new Date(+start_at), new Date(+end_at), type, table); - return res.status(200).json(rankings); + return ok(res, rankings); }; diff --git a/pages/test.js b/pages/test.js index 3dcc834c..30c00e70 100644 --- a/pages/test.js +++ b/pages/test.js @@ -1,13 +1,21 @@ import Head from 'next/head'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import Layout from 'components/layout/Layout'; -export default function Test({ websiteId }) { +export default function Test() { + const router = useRouter(); + const { id } = router.query; + + if (!id) { + return

No id query specified.

; + } + return ( <> {typeof window !== 'undefined' && ( -