Moved auth form. Added auto login and logout functionality

This commit is contained in:
Paweł Malak 2021-11-11 14:45:58 +01:00
parent 1571981252
commit d1c61bb393
18 changed files with 311 additions and 98 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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 <Route {...rest} />;
} else {
return <Redirect to="/settings/app" />;
}
};

View file

@ -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 (
<Fragment>
<SettingsHeadline text="Authentication" />
{!isAuthenticated ? (
<form onSubmit={formHandler}>
<InputGroup>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<span>
See
<a
href="https://github.com/pawelmalak/flame/wiki/Authentication"
target="blank"
>
{` project wiki `}
</a>
to read more about authentication
</span>
</InputGroup>
<Button>Login</Button>
</form>
) : (
<div>
<p className={classes.text}>
You are logged in. Your session will expire <span>@@@@</span>
</p>
<Button click={logout}>Logout</Button>
</div>
)}
<AuthForm />
<hr className={classes.separator} />

View file

@ -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 (
<Fragment>
{!isAuthenticated ? (
<form onSubmit={formHandler}>
<InputGroup>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="••••••"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
<span>
See
<a
href="https://github.com/pawelmalak/flame/wiki/Authentication"
target="blank"
>
{` project wiki `}
</a>
to read more about authentication
</span>
</InputGroup>
<InputGroup>
<label htmlFor="duration">Session duration</label>
<select
id="duration"
name="duration"
value={formData.duration}
onChange={(e) =>
setFormData({ ...formData, duration: e.target.value })
}
>
<option value="5s">dev: 5 seconds</option>
<option value="10s">dev: 10 seconds</option>
<option value="1h">1 hour</option>
<option value="1d">1 day</option>
<option value="14d">2 weeks</option>
<option value="30d">1 month</option>
<option value="1y">1 year</option>
</select>
</InputGroup>
<Button>Login</Button>
</form>
) : (
<div>
<p className={classes.text}>
You are logged in. Your session will expire{' '}
<span>{tokenExpires}</span>
</p>
<Button click={logout}>Logout</Button>
</div>
)}
</Fragment>
);
};

View file

@ -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 (
<Container>
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
<div className={classes.Settings}>
{/* NAVIGATION MENU */}
<nav className={classes.SettingsNav}>
{routes.map(({ name, dest }: SettingsRoute, idx) => (
{tabs.map(({ name, dest }: SettingsRoute, idx) => (
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
@ -45,11 +54,20 @@ export const Settings = (): JSX.Element => {
<section className={classes.SettingsContent}>
<Switch>
<Route exact path="/settings" component={Themer} />
<Route path="/settings/weather" component={WeatherSettings} />
<Route path="/settings/search" component={SearchSettings} />
<Route path="/settings/interface" component={UISettings} />
<Route path="/settings/docker" component={DockerSettings} />
<Route path="/settings/css" component={StyleSettings} />
<ProtectedRoute
path="/settings/weather"
component={WeatherSettings}
/>
<ProtectedRoute
path="/settings/search"
component={SearchSettings}
/>
<ProtectedRoute path="/settings/interface" component={UISettings} />
<ProtectedRoute
path="/settings/docker"
component={DockerSettings}
/>
<ProtectedRoute path="/settings/css" component={StyleSettings} />
<Route path="/settings/app" component={AppDetails} />
</Switch>
</section>

View file

@ -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
}
]
}

View file

@ -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(
<React.StrictMode>

View file

@ -7,4 +7,10 @@ export interface Model {
export interface ApiResponse<T> {
success: boolean;
data: T;
}
}
export interface Token {
app: string;
exp: number;
iat: number;
}

View file

@ -1,4 +1,5 @@
export interface Route {
name: string;
dest: string;
authRequired: boolean;
}

View file

@ -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<LoginAction>) => {
(formData: { password: string; duration: string }) =>
async (dispatch: Dispatch<LoginAction>) => {
try {
const res = await axios.post<ApiResponse<{ token: string }>>(
'/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<any>({
type: ActionType.createNotification,
payload: {
title: 'Error',
message: apiError.response?.data.error,
},
});
dispatch<any>(authError(err, true));
}
};
@ -38,3 +36,37 @@ export const logout = () => (dispatch: Dispatch<LogoutAction>) => {
type: ActionType.logout,
});
};
export const autoLogin = () => async (dispatch: Dispatch<AutoLoginAction>) => {
const token: string = localStorage.token;
try {
await axios.post<ApiResponse<{ token: { isValid: boolean } }>>(
'/api/auth/validate',
{ token }
);
dispatch({
type: ActionType.autoLogin,
payload: token,
});
} catch (err) {
dispatch<any>(authError(err, false));
}
};
export const authError =
(error: unknown, showNotification: boolean) =>
(dispatch: Dispatch<AuthErrorAction>) => {
const apiError = error as AxiosError;
if (showNotification) {
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Error',
message: apiError.response?.data.error,
},
});
}
};

View file

@ -40,4 +40,6 @@ export enum ActionType {
// AUTH
login = 'LOGIN',
logout = 'LOGOUT',
autoLogin = 'AUTO_LOGIN',
authError = 'AUTH_ERROR',
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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}`;
}
};

View file

@ -9,3 +9,4 @@ export * from './inputHandler';
export * from './storeUIConfig';
export * from './validators';
export * from './parseTime';
export * from './decodeToken';