Implement redux.

This commit is contained in:
Mike Cao 2020-08-04 22:45:05 -07:00
parent 9d8a2406e1
commit 5d4ff5cfa4
31 changed files with 341 additions and 85 deletions

View File

@ -1,14 +1,30 @@
import React from 'react';
import Link from 'next/link';
import { useSelector } from 'react-redux';
import classNames from 'classnames';
import Link from 'components/Link';
import styles from './Header.module.css';
export default function Header() {
const user = useSelector(state => state.user);
return (
<header className="container">
<h1>
<Link href="/">
<a>umami</a>
</Link>
</h1>
<header className={classNames(styles.header, 'container')}>
<div className="row align-items-center">
<div className="col">
<Link href="/" className={styles.title}>
umami
</Link>
</div>
{user && (
<div className="col">
<div className={styles.nav}>
<Link href="/">Dashboard</Link>
<Link href="/settings">Settings</Link>
<Link href="/logout">Logout</Link>
</div>
</div>
)}
</div>
</header>
);
}

View File

@ -0,0 +1,25 @@
.header {
display: flex;
height: 80px;
}
.header > div {
flex: 1;
}
.title {
font-size: 30px;
}
.nav {
list-style: none;
display: flex;
justify-content: flex-end;
align-items: center;
}
.nav > * {
font-size: 14px;
font-weight: 600;
margin-left: 40px;
}

View File

@ -3,9 +3,9 @@ import classNames from 'classnames';
import NextLink from 'next/link';
import styles from './Link.module.css';
export default function Link({ href, className, children }) {
export default function Link({ className, children, ...props }) {
return (
<NextLink href={href}>
<NextLink {...props}>
<a className={classNames(styles.link, className)}>{children}</a>
</NextLink>
);

View File

@ -20,7 +20,7 @@ export default function Login() {
const [message, setMessage] = useState();
const handleSubmit = async ({ username, password }) => {
const response = await post('/api/auth', { username, password });
const response = await post('/api/auth/login', { username, password });
if (response?.token) {
await Router.push('/');

View File

@ -3,10 +3,6 @@
}
@media only screen and (max-width: 1000px) {
.container {
padding-bottom: 20px;
}
.container > div:last-child {
display: none;
}

View File

@ -3,6 +3,8 @@
min-height: 430px;
font-size: 14px;
padding: 20px 0;
display: flex;
flex-direction: column;
}
.header {
@ -76,12 +78,14 @@
.body {
position: relative;
flex: 1;
}
.body:empty:before {
content: 'No data available';
display: block;
color: #b3b3b3;
text-align: center;
line-height: 50px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

9
components/Settings.js Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
export default function Settings() {
return (
<div>
<h2>Settings</h2>
</div>
);
}

View File

@ -1,3 +1,8 @@
.container {
background: #fff;
padding: 0 30px;
}
.chart {
margin-bottom: 30px;
}

View File

@ -18,23 +18,31 @@ export default function WebsiteList() {
}, []);
return (
<>
<div className={styles.container}>
{data &&
data.websites.map(({ website_id, label }) => (
<div key={website_id} className={styles.website}>
<div className={styles.header}>
<h2>
<Link href={`/website/${website_id}/${label}`} className={styles.title}>
<Link
href="/website/[...id]"
as={`/website/${website_id}/${label}`}
className={styles.title}
>
{label}
</Link>
</h2>
<Link href={`/website/${website_id}/${label}`} className={styles.details}>
<Link
href="/website/[...id]"
as={`/website/${website_id}/${label}`}
className={styles.details}
>
<Icon icon={<Arrow />} /> View details
</Link>
</div>
<WebsiteChart key={website_id} title={label} websiteId={website_id} />
</div>
))}
</>
</div>
);
}

View File

@ -1,3 +1,8 @@
.container {
background: #fff;
padding: 0 30px;
}
.website {
padding-bottom: 30px;
border-bottom: 1px solid #e1e1e1;

42
hooks/useUser.js Normal file
View File

@ -0,0 +1,42 @@
import { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { updateUser } from 'redux/actions/user';
export async function fetchUser() {
const res = await fetch('/api/auth/verify');
if (!res.ok) {
return null;
}
return await res.json();
}
export default function useUser() {
const dispatch = useDispatch();
const storeUser = useSelector(state => state.user);
const [loading, setLoading] = useState(!storeUser);
const [user, setUser] = useState(storeUser || null);
useEffect(() => {
if (!loading && user) {
return;
}
setLoading(true);
fetchUser().then(async user => {
if (!user) {
window.location.href = '/login';
return;
}
await dispatch(updateUser({ user: user }));
setUser(user);
setLoading(false);
});
}, []);
return { user, loading };
}

View File

@ -1,8 +1,9 @@
import { parse } from 'cookie';
import { verifySecureToken } from './crypto';
import { AUTH_COOKIE_NAME } from './constants';
export default async req => {
const token = parse(req.headers.cookie || '')['umami.auth'];
export async function verifyAuthToken(req) {
const token = parse(req.headers.cookie || '')[AUTH_COOKIE_NAME];
return verifySecureToken(token);
};
}

1
lib/constants.js Normal file
View File

@ -0,0 +1 @@
export const AUTH_COOKIE_NAME = 'umami.auth';

View File

@ -285,6 +285,8 @@ export const refFilter = data =>
data.filter(({ x }) => x !== '' && !x.startsWith('/') && !x.startsWith('#'));
export const deviceFilter = data => {
if (data.length === 0) return [];
const devices = data.reduce(
(obj, { x, y }) => {
const [width] = x.split('x');

View File

@ -1,6 +1,6 @@
import cors from 'cors';
import session from './session';
import auth from './auth';
import { verifySession } from './session';
import { verifyAuthToken } from './auth';
export function use(middleware) {
return (req, res) =>
@ -18,7 +18,7 @@ export const useCors = use(cors());
export const useSession = use(async (req, res, next) => {
try {
req.session = await session(req);
req.session = await verifySession(req);
} catch {
return res.status(400).end();
}
@ -27,7 +27,7 @@ export const useSession = use(async (req, res, next) => {
export const useAuth = use(async (req, res, next) => {
try {
req.auth = await auth(req);
req.auth = await verifyAuthToken(req);
} catch {
return res.status(401).end();
}

View File

@ -2,7 +2,7 @@ import { getWebsite, getSession, createSession } from 'lib/db';
import { getCountry, getDevice, getIpAddress } from 'lib/request';
import { uuid, isValidId, verifyToken } from 'lib/crypto';
export default async req => {
export async function verifySession(req) {
const { payload } = req.body;
const { website: website_uuid, hostname, screen, language, session } = payload;
@ -51,4 +51,4 @@ export default async req => {
}
}
}
};
}

View File

@ -39,6 +39,7 @@
},
"dependencies": {
"@prisma/client": "2.3.0",
"@reduxjs/toolkit": "^1.4.0",
"bcrypt": "^5.0.0",
"chalk": "^4.1.0",
"chart.js": "^2.9.3",
@ -60,9 +61,12 @@
"promise-polyfill": "^8.1.3",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-redux": "^7.2.1",
"react-simple-maps": "^2.1.2",
"react-spring": "^8.0.27",
"react-tooltip": "^4.2.7",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"request-ip": "^2.1.3",
"tinycolor2": "^1.4.1",
"unfetch": "^4.1.0",

View File

@ -1,7 +1,15 @@
import React from 'react';
import { Provider } from 'react-redux';
import { useStore } from 'redux/store';
import 'styles/bootstrap-grid.css';
import 'styles/index.css';
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />;
const store = useStore();
return (
<Provider store={store}>
<Component {...pageProps} />
</Provider>
);
}

View File

@ -1,6 +1,7 @@
import { serialize } from 'cookie';
import { checkPassword, createSecureToken } from 'lib/crypto';
import { getAccount } from 'lib/db';
import { AUTH_COOKIE_NAME } from 'lib/constants';
export default async (req, res) => {
const { username, password } = req.body;
@ -10,7 +11,7 @@ export default async (req, res) => {
if (account && (await checkPassword(password, account.password))) {
const { user_id, username, is_admin } = account;
const token = await createSecureToken({ user_id, username, is_admin });
const cookie = serialize('umami.auth', token, {
const cookie = serialize(AUTH_COOKIE_NAME, token, {
path: '/',
httpOnly: true,
maxAge: 60 * 60 * 24 * 365,

16
pages/api/auth/logout.js Normal file
View File

@ -0,0 +1,16 @@
import { serialize } from 'cookie';
import { AUTH_COOKIE_NAME } from 'lib/constants';
export default async (req, res) => {
const cookie = serialize(AUTH_COOKIE_NAME, '', {
path: '/',
httpOnly: true,
maxAge: 0,
});
res.statusCode = 303;
res.setHeader('Set-Cookie', [cookie]);
res.setHeader('Location', '/login');
return res.end();
};

11
pages/api/auth/verify.js Normal file
View File

@ -0,0 +1,11 @@
import { useAuth } from 'lib/middleware';
export default async (req, res) => {
await useAuth(req, res);
if (req.auth) {
return res.status(200).json(req.auth);
}
return res.status(401).end();
};

View File

@ -1,33 +1,18 @@
import React from 'react';
import { parse } from 'cookie';
import Layout from 'components/Layout';
import { verifySecureToken } from 'lib/crypto';
import WebsiteList from '../components/WebsiteList';
import WebsiteList from 'components/WebsiteList';
import useUser from 'hooks/useUser';
export default function HomePage() {
const { loading } = useUser();
if (loading) {
return null;
}
export default function HomePage({ username }) {
return (
<Layout>
<WebsiteList />
</Layout>
);
}
export async function getServerSideProps({ req, res }) {
const token = parse(req.headers.cookie || '')['umami.auth'];
try {
const payload = await verifySecureToken(token);
return {
props: {
...payload,
},
};
} catch {
res.statusCode = 303;
res.setHeader('Location', '/login');
res.end();
}
return { props: {} };
}

View File

@ -1,27 +1,9 @@
import React from 'react';
import { serialize } from 'cookie';
import Layout from 'components/Layout';
import { useEffect } from 'react';
export default function LogoutPage() {
return (
<Layout title="Logout">
<h2>You've successfully logged out..</h2>
</Layout>
);
}
export async function getServerSideProps({ res }) {
const cookie = serialize('umami.auth', '', {
path: '/',
httpOnly: true,
maxAge: 0,
});
res.statusCode = 303;
res.setHeader('Set-Cookie', [cookie]);
res.setHeader('Location', '/login');
res.end();
return { props: {} };
useEffect(() => {
fetch('/api/auth/logout').then(() => (window.location.href = '/login'));
}, []);
return null;
}

18
pages/settings.js Normal file
View File

@ -0,0 +1,18 @@
import React from 'react';
import Layout from 'components/Layout';
import Settings from 'components/Settings';
import useUser from 'hooks/useUser';
export default function SettingsPage() {
const { loading } = useUser();
if (loading) {
return null;
}
return (
<Layout>
<Settings />
</Layout>
);
}

View File

@ -1,13 +1,15 @@
import React from 'react';
import { useRouter } from 'next/router';
import Layout from 'components/Layout';
import WebsiteDetails from '../../components/WebsiteDetails';
import WebsiteDetails from 'components/WebsiteDetails';
import useUser from 'hooks/useUser';
export default function DetailsPage() {
const { loading } = useUser();
const router = useRouter();
const { id } = router.query;
if (!id) {
if (!id || loading) {
return null;
}

16
redux/actions/user.js Normal file
View File

@ -0,0 +1,16 @@
import { createSlice } from '@reduxjs/toolkit';
const user = createSlice({
name: 'user',
initialState: null,
reducers: {
updateUser(state, action) {
state = action.payload;
return state;
},
},
});
export const { updateUser } = user.actions;
export default user.reducer;

4
redux/reducers.js Normal file
View File

@ -0,0 +1,4 @@
import { combineReducers } from 'redux';
import user from './actions/user';
export default combineReducers({ user });

40
redux/store.js Normal file
View File

@ -0,0 +1,40 @@
import { useMemo } from 'react';
import { configureStore } from '@reduxjs/toolkit';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
let store;
export function getStore(preloadedState) {
return configureStore({
reducer: rootReducer,
middleware: [thunk],
preloadedState,
});
}
export const initializeStore = preloadedState => {
let _store = store ?? getStore(preloadedState);
// After navigating to a page with an initial Redux state, merge that state
// with the current state in the store, and create a new store
if (preloadedState && store) {
_store = getStore({
...store.getState(),
...preloadedState,
});
// Reset the current store
store = undefined;
}
// For SSG and SSR always create a new store
if (typeof window === 'undefined') return _store;
// Create the store once in the client
if (!store) store = _store;
return _store;
};
export function useStore(initialState) {
return useMemo(() => initializeStore(initialState), [initialState]);
}

View File

@ -60,11 +60,10 @@ select {
main {
flex: 1;
background: #fff;
}
.container {
padding: 0 20px;
padding: 0;
}
.row {

View File

@ -1102,6 +1102,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.5.5":
version "7.11.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.0.tgz#f10245877042a815e07f7e693faff0ae9d3a2aac"
integrity sha512-qArkXsjJq7H+T86WrIFV0Fnu/tNOkZ4cgXmjkzAu3b/58D5mFIO8JH/y77t7C9q0OdDRdh9s7Ue5GasYssxtXw==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.10.4", "@babel/template@^7.7.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
@ -1217,6 +1224,16 @@
dependencies:
pkg-up "^3.1.0"
"@reduxjs/toolkit@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.4.0.tgz#ee2e2384cc3d1d76780d844b9c2da3580d32710d"
integrity sha512-hkxQwVx4BNVRsYdxjNF6cAseRmtrkpSlcgJRr3kLUcHPIAMZAmMJkXmHh/eUEGTMqPzsYpJLM7NN2w9fxQDuGw==
dependencies:
immer "^7.0.3"
redux "^4.0.0"
redux-thunk "^2.3.0"
reselect "^4.0.0"
"@rollup/plugin-buble@^0.21.3":
version "0.21.3"
resolved "https://registry.yarnpkg.com/@rollup/plugin-buble/-/plugin-buble-0.21.3.tgz#1649a915b1d051a4f430d40e7734a7f67a69b33e"
@ -4529,6 +4546,11 @@ image-size@~0.5.0:
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w=
immer@^7.0.3:
version "7.0.7"
resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.7.tgz#9dfe713d49bf871cc59aedfce59b1992fa37a977"
integrity sha512-Q8yYwVADJXrNfp1ZUAh4XDHkcoE3wpdpb4mC5abDSajs2EbW8+cGdPyAnglMyLnm7EF6ojD2xBFX7L5i4TIytw==
import-fresh@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546"
@ -7328,11 +7350,22 @@ react-fast-compare@^2.0.1:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
react-is@16.13.1, react-is@^16.7.0, react-is@^16.8.1:
react-is@16.13.1, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.9.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-redux@^7.2.1:
version "7.2.1"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.1.tgz#8dedf784901014db2feca1ab633864dee68ad985"
integrity sha512-T+VfD/bvgGTUA74iW9d2i5THrDQWbweXP0AVNI8tNd1Rk5ch1rnMiJkDD67ejw7YBKM4+REvcvqRuWJb7BLuEg==
dependencies:
"@babel/runtime" "^7.5.5"
hoist-non-react-statics "^3.3.0"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-is "^16.9.0"
react-refresh@0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
@ -7445,6 +7478,19 @@ redent@^3.0.0:
indent-string "^4.0.0"
strip-indent "^3.0.0"
redux-thunk@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"
integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==
redux@^4.0.0, redux@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==
dependencies:
loose-envify "^1.4.0"
symbol-observable "^1.2.0"
reflect.ownkeys@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460"
@ -7634,6 +7680,11 @@ require-relative@^0.8.7:
resolved "https://registry.yarnpkg.com/require-relative/-/require-relative-0.8.7.tgz#7999539fc9e047a37928fa196f8e1563dabd36de"
integrity sha1-eZlTn8ngR6N5KPoZb44VY9q9Nt4=
reselect@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7"
integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==
resolve-from@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"
@ -8585,6 +8636,11 @@ svgo@^1.0.0, svgo@^1.2.2:
unquote "~1.1.1"
util.promisify "~1.0.0"
symbol-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
table@^5.2.3, table@^5.4.6:
version "5.4.6"
resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"