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