diff --git a/client/package-lock.json b/client/package-lock.json index 706f36a..fe99789 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9876,6 +9876,11 @@ "object.assign": "^4.1.2" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", diff --git a/client/package.json b/client/package.json index 1c4a25d..7c57f0e 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "axios": "^0.24.0", "external-svg-loader": "^1.3.4", "http-proxy-middleware": "^2.0.1", + "jwt-decode": "^3.1.2", "react": "^17.0.2", "react-autosuggest": "^10.1.0", "react-beautiful-dnd": "^13.1.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 28c0350..d7668a8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,7 +3,7 @@ import { actionCreators } from './store'; import 'external-svg-loader'; // Utils -import { checkVersion } from './utility'; +import { checkVersion, decodeToken } from './utility'; // Routes import { Home } from './components/Home/Home'; @@ -15,23 +15,54 @@ import { useDispatch } from 'react-redux'; import { bindActionCreators } from 'redux'; import { useEffect } from 'react'; -const App = (): JSX.Element => { +export const App = (): JSX.Element => { const dispath = useDispatch(); - const { fetchQueries, getConfig, setTheme } = bindActionCreators( - actionCreators, - dispath - ); + const { + fetchQueries, + getConfig, + setTheme, + logout, + createNotification, + autoLogin, + } = bindActionCreators(actionCreators, dispath); useEffect(() => { + // login if token exists + if (localStorage.token) { + autoLogin(); + } + + // check if token is valid + const tokenIsValid = setInterval(() => { + if (localStorage.token) { + const expiresIn = decodeToken(localStorage.token).exp * 1000; + const now = new Date().getTime(); + + if (now > expiresIn) { + logout(); + createNotification({ + title: 'Info', + message: 'Session expired. You have been logged out', + }); + } + } + }, 1000); + + // load app config getConfig(); + // set theme if (localStorage.theme) { setTheme(localStorage.theme); } + // check for updated checkVersion(); + // load custom search queries fetchQueries(); + + return () => window.clearInterval(tokenIsValid); }, []); return ( @@ -48,5 +79,3 @@ const App = (): JSX.Element => { > ); }; - -export default App; diff --git a/client/src/components/Routing/ProtectedRoute.tsx b/client/src/components/Routing/ProtectedRoute.tsx new file mode 100644 index 0000000..45b6504 --- /dev/null +++ b/client/src/components/Routing/ProtectedRoute.tsx @@ -0,0 +1,13 @@ +import { useSelector } from 'react-redux'; +import { Redirect, Route, RouteProps } from 'react-router'; +import { State } from '../../store/reducers'; + +export const ProtectedRoute = ({ ...rest }: RouteProps) => { + const { isAuthenticated } = useSelector((state: State) => state.auth); + + if (isAuthenticated) { + return ; + } else { + return ; + } +}; diff --git a/client/src/components/Settings/AppDetails/AppDetails.tsx b/client/src/components/Settings/AppDetails/AppDetails.tsx index 420b95c..1829a4d 100644 --- a/client/src/components/Settings/AppDetails/AppDetails.tsx +++ b/client/src/components/Settings/AppDetails/AppDetails.tsx @@ -1,71 +1,14 @@ -import { FormEvent, Fragment, useState } from 'react'; - -// Redux -import { useDispatch, useSelector } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { actionCreators } from '../../../store'; -import { State } from '../../../store/reducers'; - -// UI -import { Button, InputGroup, SettingsHeadline } from '../../UI'; - -// CSS +import { Fragment } from 'react'; +import { Button, SettingsHeadline } from '../../UI'; import classes from './AppDetails.module.css'; - -// Utils import { checkVersion } from '../../../utility'; +import { AuthForm } from './AuthForm/AuthForm'; export const AppDetails = (): JSX.Element => { - const { isAuthenticated } = useSelector((state: State) => state.auth); - - const dispatch = useDispatch(); - const { login, logout } = bindActionCreators(actionCreators, dispatch); - - const [password, setPassword] = useState(''); - - const formHandler = (e: FormEvent) => { - e.preventDefault(); - login(password); - setPassword(''); - }; - return ( - {!isAuthenticated ? ( - - - Password - setPassword(e.target.value)} - /> - - See - - {` project wiki `} - - to read more about authentication - - - - Login - - ) : ( - - - You are logged in. Your session will expire @@@@ - - Logout - - )} + diff --git a/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx new file mode 100644 index 0000000..3b54575 --- /dev/null +++ b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx @@ -0,0 +1,104 @@ +import { FormEvent, Fragment, useEffect, useState } from 'react'; + +// Redux +import { useSelector, useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; +import { State } from '../../../../store/reducers'; +import { decodeToken, parseTokenExpire } from '../../../../utility'; + +// Other +import { InputGroup, Button } from '../../../UI'; +import classes from '../AppDetails.module.css'; + +export const AuthForm = (): JSX.Element => { + const { isAuthenticated, token } = useSelector((state: State) => state.auth); + + const dispatch = useDispatch(); + const { login, logout } = bindActionCreators(actionCreators, dispatch); + + const [tokenExpires, setTokenExpires] = useState(''); + const [formData, setFormData] = useState({ + password: '', + duration: '14d', + }); + + useEffect(() => { + if (token) { + const decoded = decodeToken(token); + const expiresIn = parseTokenExpire(decoded.exp); + setTokenExpires(expiresIn); + } + }, [token]); + + const formHandler = (e: FormEvent) => { + e.preventDefault(); + login(formData); + setFormData({ + password: '', + duration: '14d', + }); + }; + + return ( + + {!isAuthenticated ? ( + + + Password + + setFormData({ ...formData, password: e.target.value }) + } + /> + + See + + {` project wiki `} + + to read more about authentication + + + + + Session duration + + setFormData({ ...formData, duration: e.target.value }) + } + > + dev: 5 seconds + dev: 10 seconds + 1 hour + 1 day + 2 weeks + 1 month + 1 year + + + + Login + + ) : ( + + + You are logged in. Your session will expire{' '} + {tokenExpires} + + Logout + + )} + + ); +}; diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index d506b4e..5196b6f 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -1,5 +1,9 @@ import { NavLink, Link, Switch, Route } from 'react-router-dom'; +// Redux +import { useSelector } from 'react-redux'; +import { State } from '../../store/reducers'; + // Typescript import { Route as SettingsRoute } from '../../interfaces'; @@ -14,6 +18,7 @@ import { AppDetails } from './AppDetails/AppDetails'; import { StyleSettings } from './StyleSettings/StyleSettings'; import { SearchSettings } from './SearchSettings/SearchSettings'; import { DockerSettings } from './DockerSettings/DockerSettings'; +import { ProtectedRoute } from '../Routing/ProtectedRoute'; // UI import { Container, Headline } from '../UI'; @@ -22,13 +27,17 @@ import { Container, Headline } from '../UI'; import { routes } from './settings.json'; export const Settings = (): JSX.Element => { + const { isAuthenticated } = useSelector((state: State) => state.auth); + + const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired); + return ( Go back} /> {/* NAVIGATION MENU */} - {routes.map(({ name, dest }: SettingsRoute, idx) => ( + {tabs.map(({ name, dest }: SettingsRoute, idx) => ( { - - - - - + + + + + diff --git a/client/src/components/Settings/settings.json b/client/src/components/Settings/settings.json index 5c3acc1..49f0aee 100644 --- a/client/src/components/Settings/settings.json +++ b/client/src/components/Settings/settings.json @@ -2,31 +2,38 @@ "routes": [ { "name": "Theme", - "dest": "/settings" + "dest": "/settings", + "authRequired": false }, { "name": "Weather", - "dest": "/settings/weather" + "dest": "/settings/weather", + "authRequired": true }, { "name": "Search", - "dest": "/settings/search" + "dest": "/settings/search", + "authRequired": true }, { "name": "Interface", - "dest": "/settings/interface" + "dest": "/settings/interface", + "authRequired": true }, { "name": "Docker", - "dest": "/settings/docker" + "dest": "/settings/docker", + "authRequired": true }, { "name": "CSS", - "dest": "/settings/css" + "dest": "/settings/css", + "authRequired": true }, { "name": "App", - "dest": "/settings/app" + "dest": "/settings/app", + "authRequired": false } ] } diff --git a/client/src/index.tsx b/client/src/index.tsx index c921059..d4da2cf 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -5,7 +5,7 @@ import './index.css'; import { Provider } from 'react-redux'; import { store } from './store/store'; -import App from './App'; +import { App } from './App'; ReactDOM.render( diff --git a/client/src/interfaces/Api.ts b/client/src/interfaces/Api.ts index 2174598..d757bda 100644 --- a/client/src/interfaces/Api.ts +++ b/client/src/interfaces/Api.ts @@ -7,4 +7,10 @@ export interface Model { export interface ApiResponse { success: boolean; data: T; -} \ No newline at end of file +} + +export interface Token { + app: string; + exp: number; + iat: number; +} diff --git a/client/src/interfaces/Route.ts b/client/src/interfaces/Route.ts index 9d571dd..a193ab5 100644 --- a/client/src/interfaces/Route.ts +++ b/client/src/interfaces/Route.ts @@ -1,4 +1,5 @@ export interface Route { name: string; dest: string; + authRequired: boolean; } diff --git a/client/src/store/action-creators/auth.ts b/client/src/store/action-creators/auth.ts index 6f2a775..f27c447 100644 --- a/client/src/store/action-creators/auth.ts +++ b/client/src/store/action-creators/auth.ts @@ -1,15 +1,21 @@ import { Dispatch } from 'redux'; import { ApiResponse } from '../../interfaces'; import { ActionType } from '../action-types'; -import { LoginAction, LogoutAction } from '../actions/auth'; +import { + AuthErrorAction, + AutoLoginAction, + LoginAction, + LogoutAction, +} from '../actions/auth'; import axios, { AxiosError } from 'axios'; export const login = - (password: string) => async (dispatch: Dispatch) => { + (formData: { password: string; duration: string }) => + async (dispatch: Dispatch) => { try { const res = await axios.post>( '/api/auth', - { password } + formData ); localStorage.setItem('token', res.data.data.token); @@ -19,15 +25,7 @@ export const login = payload: res.data.data.token, }); } catch (err) { - const apiError = err as AxiosError; - - dispatch({ - type: ActionType.createNotification, - payload: { - title: 'Error', - message: apiError.response?.data.error, - }, - }); + dispatch(authError(err, true)); } }; @@ -38,3 +36,37 @@ export const logout = () => (dispatch: Dispatch) => { type: ActionType.logout, }); }; + +export const autoLogin = () => async (dispatch: Dispatch) => { + const token: string = localStorage.token; + + try { + await axios.post>( + '/api/auth/validate', + { token } + ); + + dispatch({ + type: ActionType.autoLogin, + payload: token, + }); + } catch (err) { + dispatch(authError(err, false)); + } +}; + +export const authError = + (error: unknown, showNotification: boolean) => + (dispatch: Dispatch) => { + const apiError = error as AxiosError; + + if (showNotification) { + dispatch({ + type: ActionType.createNotification, + payload: { + title: 'Error', + message: apiError.response?.data.error, + }, + }); + } + }; diff --git a/client/src/store/action-types/index.ts b/client/src/store/action-types/index.ts index b68f8cf..58ca529 100644 --- a/client/src/store/action-types/index.ts +++ b/client/src/store/action-types/index.ts @@ -40,4 +40,6 @@ export enum ActionType { // AUTH login = 'LOGIN', logout = 'LOGOUT', + autoLogin = 'AUTO_LOGIN', + authError = 'AUTH_ERROR', } diff --git a/client/src/store/actions/auth.ts b/client/src/store/actions/auth.ts index 04a011f..b73249f 100644 --- a/client/src/store/actions/auth.ts +++ b/client/src/store/actions/auth.ts @@ -8,3 +8,12 @@ export interface LoginAction { export interface LogoutAction { type: ActionType.logout; } + +export interface AutoLoginAction { + type: ActionType.autoLogin; + payload: string; +} + +export interface AuthErrorAction { + type: ActionType.authError; +} diff --git a/client/src/store/actions/index.ts b/client/src/store/actions/index.ts index 48c4e90..02862b6 100644 --- a/client/src/store/actions/index.ts +++ b/client/src/store/actions/index.ts @@ -39,7 +39,12 @@ import { UpdateBookmarkAction, } from './bookmark'; -import { LoginAction, LogoutAction } from './auth'; +import { + AuthErrorAction, + AutoLoginAction, + LoginAction, + LogoutAction, +} from './auth'; export type Action = // Theme @@ -76,4 +81,6 @@ export type Action = | UpdateBookmarkAction // Auth | LoginAction - | LogoutAction; + | LogoutAction + | AutoLoginAction + | AuthErrorAction; diff --git a/client/src/store/reducers/auth.ts b/client/src/store/reducers/auth.ts index 110bd8c..4105a0f 100644 --- a/client/src/store/reducers/auth.ts +++ b/client/src/store/reducers/auth.ts @@ -28,6 +28,18 @@ export const authReducer = ( token: null, isAuthenticated: false, }; + case ActionType.autoLogin: + return { + ...state, + token: action.payload, + isAuthenticated: true, + }; + case ActionType.authError: + return { + ...state, + token: null, + isAuthenticated: false, + }; default: return state; } diff --git a/client/src/utility/decodeToken.ts b/client/src/utility/decodeToken.ts new file mode 100644 index 0000000..0c1fe45 --- /dev/null +++ b/client/src/utility/decodeToken.ts @@ -0,0 +1,23 @@ +import jwtDecode from 'jwt-decode'; +import { parseTime } from '.'; +import { Token } from '../interfaces'; + +export const decodeToken = (token: string): Token => { + const decoded = jwtDecode(token) as Token; + + return decoded; +}; + +export const parseTokenExpire = (expiresIn: number): string => { + const d = new Date(expiresIn * 1000); + const p = parseTime; + + const useAmericanDate = localStorage.useAmericanDate === 'true'; + const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; + + if (useAmericanDate) { + return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()} ${time}`; + } else { + return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()} ${time}`; + } +}; diff --git a/client/src/utility/index.ts b/client/src/utility/index.ts index fb975bc..0210d08 100644 --- a/client/src/utility/index.ts +++ b/client/src/utility/index.ts @@ -9,3 +9,4 @@ export * from './inputHandler'; export * from './storeUIConfig'; export * from './validators'; export * from './parseTime'; +export * from './decodeToken';
- You are logged in. Your session will expire @@@@ -
+ You are logged in. Your session will expire{' '} + {tokenExpires} +