Moved auth form. Added auto login and logout functionality
This commit is contained in:
parent
1571981252
commit
d1c61bb393
18 changed files with 311 additions and 98 deletions
5
client/package-lock.json
generated
5
client/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
13
client/src/components/Routing/ProtectedRoute.tsx
Normal file
13
client/src/components/Routing/ProtectedRoute.tsx
Normal 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" />;
|
||||
}
|
||||
};
|
|
@ -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} />
|
||||
|
||||
|
|
104
client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx
Normal file
104
client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -7,4 +7,10 @@ export interface Model {
|
|||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
app: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export interface Route {
|
||||
name: string;
|
||||
dest: string;
|
||||
authRequired: boolean;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -40,4 +40,6 @@ export enum ActionType {
|
|||
// AUTH
|
||||
login = 'LOGIN',
|
||||
logout = 'LOGOUT',
|
||||
autoLogin = 'AUTO_LOGIN',
|
||||
authError = 'AUTH_ERROR',
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
23
client/src/utility/decodeToken.ts
Normal file
23
client/src/utility/decodeToken.ts
Normal 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}`;
|
||||
}
|
||||
};
|
|
@ -9,3 +9,4 @@ export * from './inputHandler';
|
|||
export * from './storeUIConfig';
|
||||
export * from './validators';
|
||||
export * from './parseTime';
|
||||
export * from './decodeToken';
|
||||
|
|
Loading…
Reference in a new issue