Handle website delete. Added response helper functions.

This commit is contained in:
Mike Cao 2020-08-07 17:19:42 -07:00
parent 0a411a9ad6
commit c4b75e4aec
31 changed files with 314 additions and 96 deletions

View File

@ -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() {
<Page>
<PageHeader>
<div>Websites</div>
<Button icon={<Plus />} size="S">
<Button icon={<Plus />} size="S" onClick={() => setAdd(true)}>
<div>Add website</div>
</Button>
</PageHeader>
<Table columns={columns} rows={data} />
{edit && (
<Modal title="Edit website">
<WebsiteForm initialValues={edit} onSave={handleSave} onClose={handleClose} />
<WebsiteEditForm initialValues={edit} onSave={handleSave} onClose={handleClose} />
</Modal>
)}
{add && (
<Modal title="Add website">
<WebsiteEditForm
initialValues={{ name: '', domain: '' }}
onSave={handleSave}
onClose={handleClose}
/>
</Modal>
)}
{del && (
<Modal title="Delete website">
<WebsiteDeleteForm initialValues={del} onSave={handleSave} onClose={handleClose} />
</Modal>
)}
</Page>

View File

@ -36,7 +36,7 @@ export default function DropDown({
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}>
{options.find(e => e.value === value)?.label}
<Icon icon={<Chevron />} size="S" className={styles.icon} />
<Icon icon={<Chevron />} size="S" />
</div>
{showMenu && <Menu className={menuClassName} options={options} onSelect={handleSelect} />}
</div>

View File

@ -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;
}

View File

@ -31,7 +31,6 @@
border: 1px solid var(--gray300);
padding: 30px;
border-radius: 4px;
overflow: hidden;
}
.header {

View File

@ -56,7 +56,7 @@ export default function LoginForm() {
<FormError name="password" />
</FormRow>
<FormButtons>
<Button className={styles.button} type="submit">
<Button type="submit" variant="action">
Login
</Button>
</FormButtons>

View File

@ -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 (
<FormLayout>
<Formik
initialValues={{ confirmation: '', ...initialValues }}
validate={validate}
onSubmit={handleSubmit}
>
{() => (
<Form>
<div>
Are your sure you want to delete <b>{initialValues.name}</b>?
</div>
<div>All associated data will be deleted as well.</div>
<p>
Type <b>DELETE</b> in the box below to confirm.
</p>
<FormRow>
<label htmlFor="confirmation">Confirm</label>
<Field name="confirmation" />
<FormError name="confirmation" />
</FormRow>
<FormButtons>
<Button type="submit" variant="danger">
Delete
</Button>
<Button onClick={onClose}>Cancel</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View File

@ -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 }) {
<FormError name="domain" />
</FormRow>
<FormButtons>
<Button type="submit">Save</Button>
<Button type="submit" variant="action">
Save
</Button>
<Button onClick={onClose}>Cancel</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>

View File

@ -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}
>

View File

@ -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;
}

View File

@ -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 }) => (
<div className={classNames(styles.buttons, className)}>{children}</div>
);
export const FormError = ({ name }) => (
<ErrorMessage name={name}>{msg => <div className={styles.error}>{msg}</div>}</ErrorMessage>
);
export const FormError = ({ name }) => {
return <ErrorMessage name={name}>{msg => <ErrorTag msg={msg} />}</ErrorMessage>;
};
const ErrorTag = ({ msg }) => {
const props = useSpring({ opacity: 1, from: { opacity: 0 } });
return (
<animated.div className={styles.error} style={props}>
{msg}
</animated.div>
);
};
export const FormRow = ({ children }) => <div className={styles.row}>{children}</div>;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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({

View File

@ -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();
});

25
lib/response.js Normal file
View File

@ -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();
}

View File

@ -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;
}

View File

@ -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];

View File

@ -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);
};

View File

@ -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');
};

View File

@ -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);
};

View File

@ -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 });
};

View File

@ -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);
}
};

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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 });
};

View File

@ -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);
};

View File

@ -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 <h1>No id query specified.</h1>;
}
return (
<>
<Head>
{typeof window !== 'undefined' && (
<script async defer data-website-id={websiteId} src="/umami.js" />
<script async defer data-website-id={id} src="/umami.js" />
)}
</Head>
<Layout>
@ -17,17 +25,17 @@ export default function Test({ websiteId }) {
trigger page views. Clicking on the button should trigger an event.
</p>
<h2>Page links</h2>
<Link href="?q=1">
<Link href={`?id=${id}&q=1`}>
<a>Page One</a>
</Link>
<br />
<Link href="?q=2">
<Link href={`?id=${id}&q=2`}>
<a>Page Two</a>
</Link>
<h2>Events</h2>
<button
id="primary-button"
className="otherClass umami--click--primary-button"
className="otherClass umami--click--primary-button align-self-start"
type="button"
>
Button
@ -36,11 +44,3 @@ export default function Test({ websiteId }) {
</>
);
}
export async function getStaticProps() {
return {
props: {
websiteId: process.env.TEST_WEBSITE_ID,
},
};
}

View File

@ -32,7 +32,7 @@ create table session (
create table pageview (
view_id serial primary key,
website_id int not null references website(website_id),
website_id int not null references website(website_id) on delete cascade,
session_id int not null references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp,
url varchar(500) not null,
@ -41,7 +41,7 @@ create table pageview (
create table event (
event_id serial primary key,
website_id int not null references website(website_id),
website_id int not null references website(website_id) on delete cascade,
session_id int not null references session(session_id) on delete cascade,
created_at timestamp with time zone default current_timestamp,
url varchar(500) not null,

View File

@ -27,5 +27,8 @@
--grid-size-large: 992px;
--grid-size-xlarge: 1140px;
--color-error: #e34850;
--red400: #e34850;
--red500: #d7373f;
--red600: #c9252d;
--red700: #bb121a;
}