mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-14 21:10:34 +01:00
Account settings page.
This commit is contained in:
parent
58a1be7a30
commit
b5cf9f8719
@ -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 (
|
|
||||||
<Page>
|
|
||||||
<h2>Account</h2>
|
|
||||||
<div className={styles.label}>username</div>
|
|
||||||
<div>{user.username}</div>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
83
components/AccountSettings.js
Normal file
83
components/AccountSettings.js
Normal file
@ -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 => (
|
||||||
|
<>
|
||||||
|
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
|
||||||
|
<div>Edit</div>
|
||||||
|
</Button>
|
||||||
|
<Button icon={<Trash />} size="small" onClick={() => setDeleteAccount(row)}>
|
||||||
|
<div>Delete</div>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PageHeader>
|
||||||
|
<div>Accounts</div>
|
||||||
|
<Button icon={<Plus />} size="small" onClick={() => setAddAccount(true)}>
|
||||||
|
<div>Add account</div>
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
<Table columns={columns} rows={data} />
|
||||||
|
{editAccount && (
|
||||||
|
<Modal title="Edit account">
|
||||||
|
<AccountEditForm values={editAccount} onSave={handleSave} onClose={handleClose} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{addAccount && (
|
||||||
|
<Modal title="Add account">
|
||||||
|
<AccountEditForm onSave={handleSave} onClose={handleClose} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
29
components/ProfileSettings.js
Normal file
29
components/ProfileSettings.js
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader>
|
||||||
|
<div>Profile</div>
|
||||||
|
<Button size="small" onClick={() => setChangePassword(true)}>
|
||||||
|
Change password
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
<dt>Username</dt>
|
||||||
|
<dd>{user.username}</dd>
|
||||||
|
{changePassword && (
|
||||||
|
<Modal title="Change password">
|
||||||
|
<ChangePasswordForm values={user} onClose={() => setChangePassword(false)} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,108 +1,26 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import Page from './layout/Page';
|
import Page from 'components/layout/Page';
|
||||||
import Table from './common/Table';
|
import MenuLayout from 'components/layout/MenuLayout';
|
||||||
import Button from './common/Button';
|
import WebsiteSettings from './WebsiteSettings';
|
||||||
import PageHeader from './layout/PageHeader';
|
import AccountSettings from './AccountSettings';
|
||||||
import Pen from 'assets/pen.svg';
|
import ProfileSettings from './ProfileSettings';
|
||||||
import Trash from 'assets/trash.svg';
|
|
||||||
import Plus from 'assets/plus.svg';
|
const menuOptions = ['Websites', 'Accounts', 'Profile'];
|
||||||
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';
|
|
||||||
|
|
||||||
export default function Settings() {
|
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 => (
|
|
||||||
<>
|
|
||||||
<Button icon={<Code />} size="S" onClick={() => setCode(row)}>
|
|
||||||
<div>Get Code</div>
|
|
||||||
</Button>
|
|
||||||
<Button icon={<Pen />} size="S" onClick={() => setEdit(row)}>
|
|
||||||
<div>Edit</div>
|
|
||||||
</Button>
|
|
||||||
<Button icon={<Trash />} size="S" onClick={() => setDelete(row)}>
|
|
||||||
<div>Delete</div>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<MenuLayout menu={menuOptions} selectedOption="Websites">
|
||||||
<div>Websites</div>
|
{option => {
|
||||||
<Button icon={<Plus />} size="S" onClick={() => setAdd(true)}>
|
if (option === 'Websites') {
|
||||||
<div>Add website</div>
|
return <WebsiteSettings />;
|
||||||
</Button>
|
} else if (option === 'Accounts') {
|
||||||
</PageHeader>
|
return <AccountSettings />;
|
||||||
<Table columns={columns} rows={data} />
|
} else if (option === 'Profile') {
|
||||||
{edit && (
|
return <ProfileSettings />;
|
||||||
<Modal title="Edit website">
|
}
|
||||||
<WebsiteEditForm values={edit} onSave={handleSave} onClose={handleClose} />
|
}}
|
||||||
</Modal>
|
</MenuLayout>
|
||||||
)}
|
|
||||||
{add && (
|
|
||||||
<Modal title="Add website">
|
|
||||||
<WebsiteEditForm
|
|
||||||
values={{ name: '', domain: '' }}
|
|
||||||
onSave={handleSave}
|
|
||||||
onClose={handleClose}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
{del && (
|
|
||||||
<Modal title="Delete website">
|
|
||||||
<WebsiteDeleteForm values={del} onSave={handleSave} onClose={handleClose} />
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
{code && (
|
|
||||||
<Modal title="Tracking code">
|
|
||||||
<WebsiteCodeForm values={code} onClose={handleClose} />
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,7 @@ export default function WebsiteList() {
|
|||||||
shallow: true,
|
shallow: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
size="S"
|
size="small"
|
||||||
>
|
>
|
||||||
<div>View details</div>
|
<div>View details</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
101
components/WebsiteSettings.js
Normal file
101
components/WebsiteSettings.js
Normal file
@ -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 => (
|
||||||
|
<>
|
||||||
|
<Button icon={<Code />} size="small" onClick={() => setShowCode(row)}>
|
||||||
|
<div>Get Code</div>
|
||||||
|
</Button>
|
||||||
|
<Button icon={<Pen />} size="small" onClick={() => setEditWebsite(row)}>
|
||||||
|
<div>Edit</div>
|
||||||
|
</Button>
|
||||||
|
<Button icon={<Trash />} size="small" onClick={() => setDeleteWebsite(row)}>
|
||||||
|
<div>Delete</div>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PageHeader>
|
||||||
|
<div>Websites</div>
|
||||||
|
<Button icon={<Plus />} size="small" onClick={() => setAddWebsite(true)}>
|
||||||
|
<div>Add website</div>
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
<Table columns={columns} rows={data} />
|
||||||
|
{editWebsite && (
|
||||||
|
<Modal title="Edit website">
|
||||||
|
<WebsiteEditForm values={editWebsite} onSave={handleSave} onClose={handleClose} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{addWebsite && (
|
||||||
|
<Modal title="Add website">
|
||||||
|
<WebsiteEditForm onSave={handleSave} onClose={handleClose} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{deleteWebsite && (
|
||||||
|
<Modal title="Delete website">
|
||||||
|
<WebsiteDeleteForm values={deleteWebsite} onSave={handleSave} onClose={handleClose} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{showCode && (
|
||||||
|
<Modal title="Tracking code">
|
||||||
|
<WebsiteCodeForm values={showCode} onClose={handleClose} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,4 +1,9 @@
|
|||||||
|
.col {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
|
flex: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
@ -16,8 +16,8 @@ export default function Button({
|
|||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
className={classNames(styles.button, className, {
|
className={classNames(styles.button, className, {
|
||||||
[styles.small]: size === 'S',
|
[styles.small]: size === 'small',
|
||||||
[styles.large]: size === 'L',
|
[styles.large]: size === 'large',
|
||||||
[styles.action]: variant === 'action',
|
[styles.action]: variant === 'action',
|
||||||
[styles.danger]: variant === 'danger',
|
[styles.danger]: variant === 'danger',
|
||||||
})}
|
})}
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
|
@ -36,7 +36,7 @@ export default function DropDown({
|
|||||||
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
|
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
|
||||||
<div className={styles.value}>
|
<div className={styles.value}>
|
||||||
{options.find(e => e.value === value)?.label}
|
{options.find(e => e.value === value)?.label}
|
||||||
<Icon icon={<Chevron />} size="S" />
|
<Icon icon={<Chevron />} size="small" />
|
||||||
</div>
|
</div>
|
||||||
{showMenu && <Menu className={menuClassName} options={options} onSelect={handleSelect} />}
|
{showMenu && <Menu className={menuClassName} options={options} onSelect={handleSelect} />}
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,10 +6,10 @@ export default function Icon({ icon, className, size = 'M' }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.icon, className, {
|
className={classNames(styles.icon, className, {
|
||||||
[styles.xl]: size === 'XL',
|
[styles.xlarge]: size === 'xlarge',
|
||||||
[styles.large]: size === 'L',
|
[styles.large]: size === 'large',
|
||||||
[styles.medium]: size === 'M',
|
[styles.medium]: size === 'medium',
|
||||||
[styles.small]: size === 'S',
|
[styles.small]: size === 'small',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
.xl > svg {
|
.xlarge > svg {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
|
@ -6,11 +6,11 @@ export default function Table({ columns, rows }) {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.table}>
|
<div className={styles.table}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{columns.map(({ key, label, header }) => (
|
{columns.map(({ key, label, className, style, header }) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={classNames(styles.head, header?.className)}
|
className={classNames(styles.head, className, header?.className)}
|
||||||
style={header?.style}
|
style={{ ...style, ...header?.style }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
@ -19,11 +19,11 @@ export default function Table({ columns, rows }) {
|
|||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{rows.map((row, rowIndex) => (
|
{rows.map((row, rowIndex) => (
|
||||||
<div className={styles.row} key={rowIndex}>
|
<div className={styles.row} key={rowIndex}>
|
||||||
{columns.map(({ key, render, cell }) => (
|
{columns.map(({ key, render, className, style, cell }) => (
|
||||||
<div
|
<div
|
||||||
key={`${rowIndex}${key}`}
|
key={`${rowIndex}${key}`}
|
||||||
className={classNames(styles.cell, cell?.className)}
|
className={classNames(styles.cell, className, cell?.className)}
|
||||||
style={cell?.style}
|
style={{ ...style, ...cell?.style }}
|
||||||
>
|
>
|
||||||
{render ? render(row) : row[key]}
|
{render ? render(row) : row[key]}
|
||||||
</div>
|
</div>
|
||||||
|
@ -24,16 +24,13 @@ export default function UserButton() {
|
|||||||
value: 'username',
|
value: 'username',
|
||||||
className: styles.username,
|
className: styles.username,
|
||||||
},
|
},
|
||||||
{ label: 'Account', value: 'account' },
|
|
||||||
{ label: 'Logout', value: 'logout' },
|
{ label: 'Logout', value: 'logout' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleSelect(value) {
|
function handleSelect(value) {
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
|
|
||||||
if (value === 'account') {
|
if (value === 'logout') {
|
||||||
router.push('/account');
|
|
||||||
} else if (value === 'logout') {
|
|
||||||
router.push('/logout');
|
router.push('/logout');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,8 +44,8 @@ export default function UserButton() {
|
|||||||
return (
|
return (
|
||||||
<div ref={ref} className={styles.container}>
|
<div ref={ref} className={styles.container}>
|
||||||
<div onClick={() => setShowMenu(state => !state)}>
|
<div onClick={() => setShowMenu(state => !state)}>
|
||||||
<Icon icon={<User />} size="L" />
|
<Icon icon={<User />} size="large" />
|
||||||
<Icon icon={<Chevron />} size="S" />
|
<Icon icon={<Chevron />} size="small" />
|
||||||
</div>
|
</div>
|
||||||
{showMenu && <Menu options={menuOptions} onSelect={handleSelect} align="right" />}
|
{showMenu && <Menu options={menuOptions} onSelect={handleSelect} align="right" />}
|
||||||
</div>
|
</div>
|
||||||
|
74
components/forms/AccountEditForm.js
Normal file
74
components/forms/AccountEditForm.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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 = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = ({ username, password }) => {
|
||||||
|
const errors = {};
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
errors.username = 'Required';
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
errors.password = 'Required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountEditForm({ values, onSave, onClose }) {
|
||||||
|
const [message, setMessage] = useState();
|
||||||
|
|
||||||
|
const handleSubmit = async values => {
|
||||||
|
const response = await post(`/api/account`, values);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
onSave();
|
||||||
|
} else {
|
||||||
|
setMessage('Something went wrong.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormLayout>
|
||||||
|
<Formik
|
||||||
|
initialValues={{ ...initialValues, ...values }}
|
||||||
|
validate={validate}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<Form>
|
||||||
|
<FormRow>
|
||||||
|
<label htmlFor="username">Username</label>
|
||||||
|
<Field name="username" type="text" />
|
||||||
|
<FormError name="username" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<Field name="password" type="text" />
|
||||||
|
<FormError name="password" />
|
||||||
|
</FormRow>
|
||||||
|
<FormButtons>
|
||||||
|
<Button type="submit" variant="action">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
</FormButtons>
|
||||||
|
<FormMessage>{message}</FormMessage>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</FormLayout>
|
||||||
|
);
|
||||||
|
}
|
85
components/forms/ChangePasswordForm.js
Normal file
85
components/forms/ChangePasswordForm.js
Normal file
@ -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 (
|
||||||
|
<FormLayout>
|
||||||
|
<Formik
|
||||||
|
initialValues={{ ...initialValues, ...values }}
|
||||||
|
validate={validate}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<Form>
|
||||||
|
<FormRow>
|
||||||
|
<label htmlFor="password">Current password</label>
|
||||||
|
<Field name="password" type="password" />
|
||||||
|
<FormError name="password" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<label htmlFor="newPassword">New password</label>
|
||||||
|
<Field name="newPassword" type="password" />
|
||||||
|
<FormError name="newPassword" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<label htmlFor="confirmPassword">Confirm password</label>
|
||||||
|
<Field name="confirmPassword" type="password" />
|
||||||
|
<FormError name="confirmPassword" />
|
||||||
|
</FormRow>
|
||||||
|
<FormButtons>
|
||||||
|
<Button type="submit" variant="action">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
</FormButtons>
|
||||||
|
<FormMessage>{message}</FormMessage>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</FormLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -9,6 +9,11 @@ import FormLayout, {
|
|||||||
FormRow,
|
FormRow,
|
||||||
} from 'components/layout/FormLayout';
|
} from 'components/layout/FormLayout';
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
name: '',
|
||||||
|
domain: '',
|
||||||
|
};
|
||||||
|
|
||||||
const validate = ({ name, domain }) => {
|
const validate = ({ name, domain }) => {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
|
|
||||||
@ -37,7 +42,11 @@ export default function WebsiteEditForm({ values, onSave, onClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormLayout>
|
<FormLayout>
|
||||||
<Formik initialValues={values} validate={validate} onSubmit={handleSubmit}>
|
<Formik
|
||||||
|
initialValues={{ ...initialValues, ...values }}
|
||||||
|
validate={validate}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
{() => (
|
{() => (
|
||||||
<Form>
|
<Form>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form label {
|
.form label {
|
||||||
display: inline-block;
|
display: block;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +37,7 @@
|
|||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error:after {
|
.error:after {
|
||||||
|
@ -15,7 +15,7 @@ export default function Header() {
|
|||||||
<div className="row align-items-center">
|
<div className="row align-items-center">
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<Icon icon={<Logo />} size="L" className={styles.logo} />
|
<Icon icon={<Logo />} size="large" className={styles.logo} />
|
||||||
{user ? <Link href="/">umami</Link> : 'umami'}
|
{user ? <Link href="/">umami</Link> : 'umami'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
25
components/layout/MenuLayout.js
Normal file
25
components/layout/MenuLayout.js
Normal file
@ -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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.menu}>
|
||||||
|
{menu.map(item => (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.option, { [styles.active]: option === item })}
|
||||||
|
onClick={() => setOption(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{typeof children === 'function' ? children(option) : children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
34
components/layout/MenuLayout.module.css
Normal file
34
components/layout/MenuLayout.module.css
Normal file
@ -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;
|
||||||
|
}
|
@ -1,12 +1,6 @@
|
|||||||
import React, { Children } from 'react';
|
import React from 'react';
|
||||||
import styles from './PageHeader.module.css';
|
import styles from './PageHeader.module.css';
|
||||||
|
|
||||||
export default function PageHeader({ children }) {
|
export default function PageHeader({ children }) {
|
||||||
const [firstChild, ...otherChildren] = Children.toArray(children);
|
return <div className={styles.header}>{children}</div>;
|
||||||
return (
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div className={styles.title}> {firstChild}</div>
|
|
||||||
{otherChildren && <div>{otherChildren}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,5 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
line-height: 80px;
|
line-height: 80px;
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: var(--font-size-large);
|
font-size: var(--font-size-large);
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { v4, v5, validate } from 'uuid';
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { JWT, JWE, JWK } from 'jose';
|
import { JWT, JWE, JWK } from 'jose';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
const KEY = JWK.asKey(Buffer.from(secret()));
|
const KEY = JWK.asKey(Buffer.from(secret()));
|
||||||
|
|
||||||
export function hash(...args) {
|
export function hash(...args) {
|
||||||
@ -23,6 +24,10 @@ export function isValidId(s) {
|
|||||||
return validate(s);
|
return validate(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashPassword(password) {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
export function checkPassword(password, hash) {
|
export function checkPassword(password, hash) {
|
||||||
return bcrypt.compare(password, hash);
|
return bcrypt.compare(password, hash);
|
||||||
}
|
}
|
||||||
|
43
lib/db.js
43
lib/db.js
@ -124,8 +124,8 @@ export async function getSession({ session_id, session_uuid }) {
|
|||||||
return runQuery(
|
return runQuery(
|
||||||
prisma.session.findOne({
|
prisma.session.findOne({
|
||||||
where: {
|
where: {
|
||||||
...(session_id && { session_id }),
|
session_id,
|
||||||
...(session_uuid && { session_uuid }),
|
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(
|
return runQuery(
|
||||||
prisma.account.findOne({
|
prisma.account.findOne({
|
||||||
where: {
|
where: {
|
||||||
username,
|
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) {
|
export async function getPageviews(website_id, start_at, end_at) {
|
||||||
return runQuery(
|
return runQuery(
|
||||||
prisma.pageview.findMany({
|
prisma.pageview.findMany({
|
||||||
|
@ -4,6 +4,11 @@ import { uuid, isValidId, parseToken } from 'lib/crypto';
|
|||||||
|
|
||||||
export async function verifySession(req) {
|
export async function verifySession(req) {
|
||||||
const { payload } = req.body;
|
const { payload } = req.body;
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Invalid request');
|
||||||
|
}
|
||||||
|
|
||||||
const { website: website_uuid, hostname, screen, language, session } = payload;
|
const { website: website_uuid, hostname, screen, language, session } = payload;
|
||||||
|
|
||||||
const token = await parseToken(session);
|
const token = await parseToken(session);
|
||||||
|
@ -11,7 +11,7 @@ export const apiRequest = (method, url, body) =>
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
return null;
|
return res.text();
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseQuery(url, params = {}) {
|
function parseQuery(url, params = {}) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Layout from 'components/layout/Layout';
|
import Layout from 'components/layout/Layout';
|
||||||
import Account from 'components/Account';
|
import AccountSettings from 'components/AccountSettings';
|
||||||
import useRequireLogin from 'hooks/useRequireLogin';
|
import useRequireLogin from 'hooks/useRequireLogin';
|
||||||
|
|
||||||
export default function AccountPage() {
|
export default function AccountPage() {
|
||||||
@ -12,7 +12,7 @@ export default function AccountPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Account />
|
<AccountSettings />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
48
pages/api/account.js
Normal file
48
pages/api/account.js
Normal file
@ -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);
|
||||||
|
};
|
@ -7,7 +7,7 @@ import Logo from 'assets/logo.svg';
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<Layout title="login" header={false} footer={false} center middle>
|
<Layout title="login" header={false} footer={false} center middle>
|
||||||
<Icon icon={<Logo />} size="XL" />
|
<Icon icon={<Logo />} size="xlarge" />
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
@ -59,10 +59,12 @@ textarea {
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
dt {
|
||||||
padding: 4px 8px;
|
font-weight: 600;
|
||||||
border: 1px solid var(--gray500) !important;
|
}
|
||||||
border-radius: 4px;
|
|
||||||
|
dd {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user