diff --git a/components/Header.js b/components/Header.js index f5b0b856..ce7ca263 100644 --- a/components/Header.js +++ b/components/Header.js @@ -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 ( -
-

- - umami - -

+
+
+
+ + umami + +
+ {user && ( +
+
+ Dashboard + Settings + Logout +
+
+ )} +
); } diff --git a/components/Header.module.css b/components/Header.module.css new file mode 100644 index 00000000..c533e1b5 --- /dev/null +++ b/components/Header.module.css @@ -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; +} diff --git a/components/Link.js b/components/Link.js index ca34110d..c3a5fa7e 100644 --- a/components/Link.js +++ b/components/Link.js @@ -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 ( - + {children} ); diff --git a/components/Login.js b/components/Login.js index f4e12a37..d3abf6e6 100644 --- a/components/Login.js +++ b/components/Login.js @@ -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('/'); diff --git a/components/MetricsBar.module.css b/components/MetricsBar.module.css index ddfe5c7d..f63d73f3 100644 --- a/components/MetricsBar.module.css +++ b/components/MetricsBar.module.css @@ -3,10 +3,6 @@ } @media only screen and (max-width: 1000px) { - .container { - padding-bottom: 20px; - } - .container > div:last-child { display: none; } diff --git a/components/RankingsChart.module.css b/components/RankingsChart.module.css index 67095377..f470199c 100644 --- a/components/RankingsChart.module.css +++ b/components/RankingsChart.module.css @@ -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%); } diff --git a/components/Settings.js b/components/Settings.js new file mode 100644 index 00000000..30da4472 --- /dev/null +++ b/components/Settings.js @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function Settings() { + return ( +
+

Settings

+
+ ); +} diff --git a/components/WebsiteDetails.module.css b/components/WebsiteDetails.module.css index cba5ed18..9e9f570f 100644 --- a/components/WebsiteDetails.module.css +++ b/components/WebsiteDetails.module.css @@ -1,3 +1,8 @@ +.container { + background: #fff; + padding: 0 30px; +} + .chart { margin-bottom: 30px; } diff --git a/components/WebsiteList.js b/components/WebsiteList.js index 64403142..14d7eb1e 100644 --- a/components/WebsiteList.js +++ b/components/WebsiteList.js @@ -18,23 +18,31 @@ export default function WebsiteList() { }, []); return ( - <> +
{data && data.websites.map(({ website_id, label }) => (

- + {label}

- + } /> View details
))} - +
); } diff --git a/components/WebsiteList.module.css b/components/WebsiteList.module.css index 12822f5b..3e8d1dd2 100644 --- a/components/WebsiteList.module.css +++ b/components/WebsiteList.module.css @@ -1,3 +1,8 @@ +.container { + background: #fff; + padding: 0 30px; +} + .website { padding-bottom: 30px; border-bottom: 1px solid #e1e1e1; diff --git a/hooks/useUser.js b/hooks/useUser.js new file mode 100644 index 00000000..81b6107e --- /dev/null +++ b/hooks/useUser.js @@ -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 }; +} diff --git a/lib/auth.js b/lib/auth.js index 926bc7a4..034626ad 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -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); -}; +} diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 00000000..d261af04 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1 @@ +export const AUTH_COOKIE_NAME = 'umami.auth'; diff --git a/lib/filters.js b/lib/filters.js index a510f392..d83be5e7 100644 --- a/lib/filters.js +++ b/lib/filters.js @@ -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'); diff --git a/lib/middleware.js b/lib/middleware.js index 1c9d0211..b0260345 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -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(); } diff --git a/lib/session.js b/lib/session.js index 53d1c12a..932c6a8b 100644 --- a/lib/session.js +++ b/lib/session.js @@ -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 => { } } } -}; +} diff --git a/package.json b/package.json index f4c53ee2..0ad526ff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/_app.js b/pages/_app.js index aa32a01c..44d6ca29 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -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 ; + const store = useStore(); + + return ( + + + + ); } diff --git a/pages/api/auth.js b/pages/api/auth/login.js similarity index 86% rename from pages/api/auth.js rename to pages/api/auth/login.js index 9170dee7..24ec8d9d 100644 --- a/pages/api/auth.js +++ b/pages/api/auth/login.js @@ -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, diff --git a/pages/api/auth/logout.js b/pages/api/auth/logout.js new file mode 100644 index 00000000..fc9cd5ba --- /dev/null +++ b/pages/api/auth/logout.js @@ -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(); +}; diff --git a/pages/api/auth/verify.js b/pages/api/auth/verify.js new file mode 100644 index 00000000..e0f503b7 --- /dev/null +++ b/pages/api/auth/verify.js @@ -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(); +}; diff --git a/pages/api/verify.js b/pages/api/user.js similarity index 100% rename from pages/api/verify.js rename to pages/api/user.js diff --git a/pages/index.js b/pages/index.js index 11e12934..fa22efa3 100644 --- a/pages/index.js +++ b/pages/index.js @@ -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 ( ); } - -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: {} }; -} diff --git a/pages/logout.js b/pages/logout.js index d31dd48a..540a93e1 100644 --- a/pages/logout.js +++ b/pages/logout.js @@ -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 ( - -

You've successfully logged out..

-
- ); -} - -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; } diff --git a/pages/settings.js b/pages/settings.js new file mode 100644 index 00000000..26e25887 --- /dev/null +++ b/pages/settings.js @@ -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 ( + + + + ); +} diff --git a/pages/website/[...id].js b/pages/website/[...id].js index ede1d709..9be769e6 100644 --- a/pages/website/[...id].js +++ b/pages/website/[...id].js @@ -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; } diff --git a/redux/actions/user.js b/redux/actions/user.js new file mode 100644 index 00000000..0d0fb4bb --- /dev/null +++ b/redux/actions/user.js @@ -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; diff --git a/redux/reducers.js b/redux/reducers.js new file mode 100644 index 00000000..f751726f --- /dev/null +++ b/redux/reducers.js @@ -0,0 +1,4 @@ +import { combineReducers } from 'redux'; +import user from './actions/user'; + +export default combineReducers({ user }); diff --git a/redux/store.js b/redux/store.js new file mode 100644 index 00000000..b0abba4c --- /dev/null +++ b/redux/store.js @@ -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]); +} diff --git a/styles/index.css b/styles/index.css index 40aba9e2..f02df7a8 100644 --- a/styles/index.css +++ b/styles/index.css @@ -60,11 +60,10 @@ select { main { flex: 1; - background: #fff; } .container { - padding: 0 20px; + padding: 0; } .row { diff --git a/yarn.lock b/yarn.lock index d2eff157..2743d66f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"