Complete rewrite of the UI.
parent
694840790b
commit
605002a333
|
@ -3297,6 +3297,15 @@
|
|||
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz",
|
||||
"integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg=="
|
||||
},
|
||||
"connected-react-router": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.2.1.tgz",
|
||||
"integrity": "sha512-7QFs0wPYvwrzA7NptHx0DgblNA/nVErX0TUjTiOCwXSaqj/1Ng+nEmEczrfdA8gw7kIzFIa08WJGMymdb7bAZA==",
|
||||
"requires": {
|
||||
"immutable": "^3.8.1",
|
||||
"seamless-immutable": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"console-browserify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
|
||||
|
@ -5957,13 +5966,11 @@
|
|||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
"bundled": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
|
@ -5976,18 +5983,15 @@
|
|||
},
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
"bundled": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
"bundled": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
"bundled": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
|
@ -6090,8 +6094,7 @@
|
|||
},
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
"bundled": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
|
@ -6101,7 +6104,6 @@
|
|||
"is-fullwidth-code-point": {
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
|
@ -6114,20 +6116,17 @@
|
|||
"minimatch": {
|
||||
"version": "3.0.4",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
"bundled": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.2.4",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.1",
|
||||
"yallist": "^3.0.0"
|
||||
|
@ -6144,7 +6143,6 @@
|
|||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
|
@ -6217,8 +6215,7 @@
|
|||
},
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"bundled": true,
|
||||
"optional": true
|
||||
"bundled": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
|
@ -6228,7 +6225,6 @@
|
|||
"once": {
|
||||
"version": "1.4.0",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
|
@ -6334,7 +6330,6 @@
|
|||
"string-width": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
|
@ -7380,6 +7375,11 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-1.7.2.tgz",
|
||||
"integrity": "sha512-4Urocwu9+XLDJw4Tc6ZCg7APVjjLInCFvO4TwGsAYV5zT6YYSor14dsZR0+0tHlDIN92cFUOq+i7fC00G5vTxA=="
|
||||
},
|
||||
"immutable": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
|
||||
"integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM="
|
||||
},
|
||||
"import-cwd": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
|
||||
|
@ -15417,6 +15417,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"seamless-immutable": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz",
|
||||
"integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A=="
|
||||
},
|
||||
"select-hose": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"@types/redux-thunk": "^2.1.0",
|
||||
"await-to-js": "^2.1.1",
|
||||
"classnames": "^2.2.6",
|
||||
"connected-react-router": "^6.2.1",
|
||||
"jss": "^9.8.7",
|
||||
"node-sass": "^4.11.0",
|
||||
"qrcode.react": "^0.9.2",
|
||||
|
|
|
@ -4,22 +4,28 @@ import './App.css';
|
|||
import { Router, Route, Switch } from "react-router-dom";
|
||||
import { routes } from './routes/index';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { createStore, applyMiddleware } from 'redux';
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import reducer from './reducers';
|
||||
import { Provider } from 'react-redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import { routerMiddleware, ConnectedRouter } from 'connected-react-router';
|
||||
|
||||
const history = createBrowserHistory();
|
||||
const store = createStore(
|
||||
reducer,
|
||||
applyMiddleware(thunk)
|
||||
reducer(history),
|
||||
compose(
|
||||
applyMiddleware(
|
||||
routerMiddleware(history),
|
||||
thunk
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
class App extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Router history={history}>
|
||||
<ConnectedRouter history={history}>
|
||||
<div className="App">
|
||||
<Switch>
|
||||
{routes.map((r, key) => {
|
||||
|
@ -27,7 +33,7 @@ class App extends Component {
|
|||
})}
|
||||
</Switch>
|
||||
</div>
|
||||
</Router>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { createStyles, Theme } from "@material-ui/core";
|
||||
|
||||
const styles = createStyles((theme: Theme) => ({
|
||||
container: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
messageContainer: {
|
||||
fontSize: theme.typography.fontSize,
|
||||
marginTop: theme.spacing.unit * 2,
|
||||
marginBottom: theme.spacing.unit * 2,
|
||||
color: 'green',
|
||||
display: 'inline-block',
|
||||
marginLeft: theme.spacing.unit * 2,
|
||||
textAlign: 'left',
|
||||
|
||||
},
|
||||
successContainer: {
|
||||
verticalAlign: 'middle',
|
||||
paddingTop: theme.spacing.unit * 2,
|
||||
paddingBottom: theme.spacing.unit * 2,
|
||||
marginTop: theme.spacing.unit * 2,
|
||||
marginBottom: theme.spacing.unit * 3,
|
||||
border: '1px solid #8ae48a',
|
||||
borderRadius: '100px',
|
||||
},
|
||||
successLogoContainer: {
|
||||
display: 'inline-block',
|
||||
},
|
||||
logoutButtonContainer: {
|
||||
marginTop: theme.spacing.unit * 2,
|
||||
},
|
||||
}));
|
||||
|
||||
export default styles;
|
|
@ -3,7 +3,6 @@ import { createStyles, Theme } from "@material-ui/core";
|
|||
const styles = createStyles((theme: Theme) => ({
|
||||
fields: {
|
||||
marginTop: theme.spacing.unit * 2,
|
||||
marginBottom: theme.spacing.unit,
|
||||
},
|
||||
field: {
|
||||
paddingBottom: theme.spacing.unit * 2,
|
|
@ -9,7 +9,7 @@ const styles = createStyles((theme: Theme) => ({
|
|||
},
|
||||
messageContainer: {
|
||||
color: 'white',
|
||||
fontSize: theme.typography.fontSize * 0.9,
|
||||
fontSize: theme.typography.fontSize,
|
||||
padding: theme.spacing.unit * 2,
|
||||
border: '1px solid red',
|
||||
borderRadius: '5px',
|
||||
|
|
|
@ -29,7 +29,6 @@ const styles = createStyles((theme: Theme) => ({
|
|||
paddingRight: theme.spacing.unit * 2,
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '2px',
|
||||
textAlign: 'justify',
|
||||
},
|
||||
methodName: {
|
||||
fontSize: theme.typography.fontSize * 1.2,
|
|
@ -18,7 +18,7 @@ const styles = createStyles((theme: Theme) => ({
|
|||
title: {
|
||||
fontSize: '1.4em',
|
||||
fontWeight: 'bold',
|
||||
borderBottom: '1px solid #c7c7c7',
|
||||
borderBottom: '5px solid ' + theme.palette.primary.main,
|
||||
display: 'inline-block',
|
||||
paddingRight: '10px',
|
||||
paddingBottom: '5px',
|
||||
|
|
|
@ -7,10 +7,27 @@ const styles = createStyles((theme: Theme) => ({
|
|||
field: {
|
||||
width: '100%',
|
||||
},
|
||||
button: {
|
||||
buttonsContainer: {
|
||||
marginTop: theme.spacing.unit * 2,
|
||||
width: '100%',
|
||||
}
|
||||
},
|
||||
buttonContainer: {
|
||||
width: '50%',
|
||||
display: 'inline-block',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
buttonConfirmContainer: {
|
||||
paddingRight: theme.spacing.unit / 2,
|
||||
},
|
||||
buttonConfirm: {
|
||||
width: '100%',
|
||||
},
|
||||
buttonCancelContainer: {
|
||||
paddingLeft: theme.spacing.unit / 2,
|
||||
},
|
||||
buttonCancel: {
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
export default styles;
|
|
@ -8,6 +8,20 @@ const styles = createStyles((theme: Theme) => ({
|
|||
width: '100%',
|
||||
marginBottom: theme.spacing.unit * 2,
|
||||
},
|
||||
buttonsContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
buttonContainer: {
|
||||
width: '50%',
|
||||
boxSizing: 'border-box',
|
||||
display: 'inline-block',
|
||||
},
|
||||
buttonResetContainer: {
|
||||
paddingRight: theme.spacing.unit / 2,
|
||||
},
|
||||
buttonCancelContainer: {
|
||||
paddingLeft: theme.spacing.unit / 2,
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { Dispatch } from "redux";
|
||||
import * as AutheliaService from '../services/AutheliaService';
|
||||
import { fetchStateFailure, fetchStateSuccess } from "../reducers/Portal/Authentication/actions";
|
||||
import to from "await-to-js";
|
||||
|
||||
export default async function(dispatch: Dispatch) {
|
||||
let err, res;
|
||||
[err, res] = await to(AutheliaService.fetchState());
|
||||
if (err) {
|
||||
await dispatch(fetchStateFailure(err.message));
|
||||
return;
|
||||
}
|
||||
if (!res) {
|
||||
await dispatch(fetchStateFailure('No response'));
|
||||
return
|
||||
}
|
||||
await dispatch(fetchStateSuccess(res));
|
||||
return res;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { Dispatch } from "redux";
|
||||
import { logout, logoutFailure, logoutSuccess } from "../reducers/Portal/SecondFactor/actions";
|
||||
import to from "await-to-js";
|
||||
import * as AutheliaService from '../services/AutheliaService';
|
||||
import fetchState from "./FetchStateBehavior";
|
||||
|
||||
export default async function(dispatch: Dispatch) {
|
||||
await dispatch(logout());
|
||||
let err, res;
|
||||
[err, res] = await to(AutheliaService.postLogout());
|
||||
|
||||
if (err) {
|
||||
await dispatch(logoutFailure(err.message));
|
||||
return;
|
||||
}
|
||||
await dispatch(logoutSuccess());
|
||||
await fetchState(dispatch);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import React, { Component } from "react";
|
||||
|
||||
import styles from '../../assets/jss/components/AlreadyAuthenticated/AlreadyAuthenticated';
|
||||
import { WithStyles, withStyles, Button } from "@material-ui/core";
|
||||
import CircleLoader, { Status } from "../CircleLoader/CircleLoader";
|
||||
|
||||
export interface OwnProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DispatchProps {
|
||||
onLogoutClicked: () => void;
|
||||
}
|
||||
|
||||
export type Props = OwnProps & DispatchProps & WithStyles;
|
||||
|
||||
class AlreadyAuthenticated extends Component<Props> {
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<div className={classes.successContainer}>
|
||||
<CircleLoader status={Status.SUCCESSFUL} />
|
||||
<span className={classes.messageContainer}>
|
||||
<b>{this.props.username}</b><br/>
|
||||
you are authenticated
|
||||
</span>
|
||||
</div>
|
||||
<div>Close this tab or logout</div>
|
||||
<div className={classes.logoutButtonContainer}>
|
||||
<Button
|
||||
onClick={this.props.onLogoutClicked}
|
||||
variant="contained"
|
||||
color="primary">
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(AlreadyAuthenticated);
|
|
@ -7,40 +7,38 @@ import FormControlLabel from '@material-ui/core/FormControlLabel';
|
|||
import Checkbox from '@material-ui/core/Checkbox';
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { RouterProps, RouteProps } from "react-router";
|
||||
import { WithStyles, withStyles } from "@material-ui/core";
|
||||
|
||||
import firstFactorViewStyles from '../../assets/jss/views/FirstFactorView/FirstFactorView';
|
||||
import styles from '../../assets/jss/components/FirstFactorForm/FirstFactorForm';
|
||||
import FormNotification from "../../components/FormNotification/FormNotification";
|
||||
|
||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
|
||||
import CheckBoxIcon from '@material-ui/icons/CheckBox';
|
||||
import StateSynchronizer from "../../containers/components/StateSynchronizer/StateSynchronizer";
|
||||
import RemoteState from "../../reducers/Portal/RemoteState";
|
||||
|
||||
export interface Props extends RouteProps, RouterProps, WithStyles {
|
||||
export interface StateProps {
|
||||
formDisabled: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DispatchProps {
|
||||
onAuthenticationRequested(username: string, password: string): void;
|
||||
}
|
||||
|
||||
export type Props = StateProps & DispatchProps & WithStyles;
|
||||
|
||||
interface State {
|
||||
rememberMe: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
loginButtonDisabled: boolean;
|
||||
errorMessage: string | null;
|
||||
remoteState: RemoteState | null;
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
class FirstFactorView extends Component<Props, State> {
|
||||
class FirstFactorForm extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
rememberMe: false,
|
||||
username: '',
|
||||
password: '',
|
||||
loginButtonDisabled: false,
|
||||
errorMessage: null,
|
||||
remoteState: null,
|
||||
rememberMe: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,13 +66,13 @@ class FirstFactorView extends Component<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
private renderWithState() {
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<FormNotification
|
||||
show={this.state.errorMessage != null}>
|
||||
{this.state.errorMessage || ''}
|
||||
show={this.props.error != null}>
|
||||
{this.props.error || ''}
|
||||
</FormNotification>
|
||||
<div className={classes.fields}>
|
||||
<div className={classes.field}>
|
||||
|
@ -83,6 +81,7 @@ class FirstFactorView extends Component<Props, State> {
|
|||
variant="outlined"
|
||||
id="username"
|
||||
label="Username"
|
||||
disabled={this.props.formDisabled}
|
||||
onChange={this.onUsernameChanged}>
|
||||
</TextField>
|
||||
</div>
|
||||
|
@ -93,6 +92,7 @@ class FirstFactorView extends Component<Props, State> {
|
|||
variant="outlined"
|
||||
label="Password"
|
||||
type="password"
|
||||
disabled={this.props.formDisabled}
|
||||
onChange={this.onPasswordChanged}
|
||||
onKeyPress={this.onPasswordKeyPressed}>
|
||||
</TextField>
|
||||
|
@ -104,7 +104,7 @@ class FirstFactorView extends Component<Props, State> {
|
|||
onClick={this.onLoginClicked}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={this.state.loginButtonDisabled}>
|
||||
disabled={this.props.formDisabled}>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -132,30 +132,11 @@ class FirstFactorView extends Component<Props, State> {
|
|||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<StateSynchronizer
|
||||
onLoaded={(remoteState) => this.setState({remoteState})}/>
|
||||
{this.state.remoteState ? this.renderWithState() : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private authenticate() {
|
||||
this.setState({loginButtonDisabled: true});
|
||||
this.props.onAuthenticationRequested(
|
||||
this.state.username,
|
||||
this.state.password);
|
||||
this.setState({errorMessage: null});
|
||||
}
|
||||
|
||||
onFailure = (error: string) => {
|
||||
this.setState({
|
||||
loginButtonDisabled: false,
|
||||
errorMessage: 'An error occured. Your username/password are probably wrong.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(firstFactorViewStyles)(FirstFactorView);
|
||||
export default withStyles(styles)(FirstFactorForm);
|
|
@ -1,38 +1,52 @@
|
|||
import React, { Component } from 'react';
|
||||
import React, { Component, KeyboardEvent, ChangeEvent } from 'react';
|
||||
|
||||
import { WithStyles, withStyles, Button, TextField } from '@material-ui/core';
|
||||
|
||||
import styles from '../../assets/jss/views/SecondFactorView/SecondFactorView';
|
||||
import StateSynchronizer from '../../containers/components/StateSynchronizer/StateSynchronizer';
|
||||
import { RouterProps, Redirect } from 'react-router';
|
||||
import RemoteState from '../../reducers/Portal/RemoteState';
|
||||
import AuthenticationLevel from '../../types/AuthenticationLevel';
|
||||
import { WithState } from '../../components/StateSynchronizer/WithState';
|
||||
import styles from '../../assets/jss/components/SecondFactorForm/SecondFactorForm';
|
||||
import CircleLoader, { Status } from '../../components/CircleLoader/CircleLoader';
|
||||
import FormNotification from '../FormNotification/FormNotification';
|
||||
|
||||
export interface Props extends WithStyles, RouterProps, WithState {
|
||||
export interface OwnProps {
|
||||
username: string;
|
||||
redirection: string | null;
|
||||
}
|
||||
|
||||
export interface StateProps {
|
||||
securityKeySupported: boolean;
|
||||
securityKeyVerified: boolean;
|
||||
securityKeyError: string | null;
|
||||
|
||||
oneTimePasswordVerificationInProgress: boolean,
|
||||
oneTimePasswordVerificationError: string | null;
|
||||
}
|
||||
|
||||
export interface DispatchProps {
|
||||
onInit: () => void;
|
||||
onLogoutClicked: () => void;
|
||||
onRegisterSecurityKeyClicked: () => void;
|
||||
onRegisterOneTimePasswordClicked: () => void;
|
||||
onStateLoaded: (state: RemoteState) => void;
|
||||
};
|
||||
|
||||
onOneTimePasswordValidationRequested: (token: string) => void;
|
||||
}
|
||||
|
||||
export type Props = OwnProps & StateProps & DispatchProps & WithStyles;
|
||||
|
||||
interface State {
|
||||
remoteState: RemoteState | null;
|
||||
oneTimePassword: string;
|
||||
}
|
||||
|
||||
class SecondFactorView extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
remoteState: null,
|
||||
oneTimePassword: '',
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.onInit();
|
||||
}
|
||||
|
||||
private renderU2f(n: number) {
|
||||
const { classes } = this.props;
|
||||
let u2fStatus = Status.LOADING;
|
||||
|
@ -58,17 +72,37 @@ class SecondFactorView extends Component<Props, State> {
|
|||
)
|
||||
}
|
||||
|
||||
private onOneTimePasswordChanged = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({oneTimePassword: e.target.value});
|
||||
}
|
||||
|
||||
private onTotpKeyPressed = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.onOneTimePasswordValidationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
private onOneTimePasswordValidationRequested = () => {
|
||||
if (this.props.oneTimePasswordVerificationInProgress) return;
|
||||
this.props.onOneTimePasswordValidationRequested(this.state.oneTimePassword);
|
||||
}
|
||||
|
||||
private renderTotp(n: number) {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.methodTotp} key='totp-method'>
|
||||
<div className={classes.methodName}>Option {n} - One-Time Password</div>
|
||||
<FormNotification show={this.props.oneTimePasswordVerificationError !== null}>
|
||||
{this.props.oneTimePasswordVerificationError}
|
||||
</FormNotification>
|
||||
<TextField
|
||||
className={classes.totpField}
|
||||
name="password"
|
||||
id="password"
|
||||
name="totp-token"
|
||||
id="totp-token"
|
||||
variant="outlined"
|
||||
label="One-Time Password">
|
||||
label="One-Time Password"
|
||||
onChange={this.onOneTimePasswordChanged}
|
||||
onKeyPress={this.onTotpKeyPressed}>
|
||||
</TextField>
|
||||
<div className={classes.registerDeviceContainer}>
|
||||
<a className={classes.registerDevice} href="#"
|
||||
|
@ -79,7 +113,9 @@ class SecondFactorView extends Component<Props, State> {
|
|||
<Button
|
||||
className={classes.totpButton}
|
||||
variant="contained"
|
||||
color="primary">
|
||||
color="primary"
|
||||
onClick={this.onOneTimePasswordValidationRequested}
|
||||
disabled={this.props.oneTimePasswordVerificationInProgress}>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -103,16 +139,12 @@ class SecondFactorView extends Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private renderWithState(state: RemoteState) {
|
||||
if (state.authentication_level < AuthenticationLevel.ONE_FACTOR) {
|
||||
return <Redirect to='/' key='redirect' />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<div className={classes.header}>
|
||||
<div className={classes.hello}>Hello <b>{state.username}</b></div>
|
||||
<div className={classes.hello}>Hello <b>{this.props.username}</b></div>
|
||||
<div className={classes.logout}>
|
||||
<a onClick={this.props.onLogoutClicked} href="#">Logout</a>
|
||||
</div>
|
||||
|
@ -123,21 +155,6 @@ class SecondFactorView extends Component<Props, State> {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
onStateLoaded = (remoteState: RemoteState) => {
|
||||
this.setState({remoteState});
|
||||
this.props.onStateLoaded(remoteState);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<StateSynchronizer
|
||||
onLoaded={this.onStateLoaded}/>
|
||||
{this.state.remoteState ? this.renderWithState(this.state.remoteState) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(SecondFactorView);
|
|
@ -1,28 +0,0 @@
|
|||
import React, { Component } from "react";
|
||||
import RemoteState from "../../reducers/Portal/RemoteState";
|
||||
import { WithState } from "./WithState";
|
||||
|
||||
export type OnLoaded = (state: RemoteState) => void;
|
||||
export type OnError = (err: Error) => void;
|
||||
|
||||
export interface Props extends WithState {
|
||||
fetch: (onloaded: OnLoaded, onerror: OnError) => void;
|
||||
onLoaded: OnLoaded;
|
||||
onError?: OnError;
|
||||
}
|
||||
|
||||
class StateSynchronizer extends Component<Props> {
|
||||
componentWillMount() {
|
||||
this.props.fetch(
|
||||
(state) => this.props.onLoaded(state),
|
||||
(err: Error) => {
|
||||
if (this.props.onError) this.props.onError(err);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default StateSynchronizer;
|
|
@ -1,7 +0,0 @@
|
|||
import RemoteState from '../../reducers/Portal/RemoteState';
|
||||
|
||||
export interface WithState {
|
||||
state: RemoteState | null;
|
||||
stateError: string | null;
|
||||
stateLoading: boolean;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import { RootState } from '../../../reducers';
|
||||
import AlreadyAuthenticated, { DispatchProps } from '../../../components/AlreadyAuthenticated/AlreadyAuthenticated';
|
||||
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
|
||||
|
||||
const mapStateToProps = (state: RootState) => {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
|
||||
return {
|
||||
onLogoutClicked: () => LogoutBehavior(dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AlreadyAuthenticated);
|
|
@ -0,0 +1,53 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { Dispatch } from 'redux';
|
||||
import { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions';
|
||||
import FirstFactorForm, { StateProps } from '../../../components/FirstFactorForm/FirstFactorForm';
|
||||
import { RootState } from '../../../reducers';
|
||||
import * as AutheliaService from '../../../services/AutheliaService';
|
||||
import to from 'await-to-js';
|
||||
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => {
|
||||
return {
|
||||
error: state.firstFactor.error,
|
||||
formDisabled: state.firstFactor.loading,
|
||||
};
|
||||
}
|
||||
|
||||
function onAuthenticationRequested(dispatch: Dispatch) {
|
||||
return async (username: string, password: string) => {
|
||||
let err, res;
|
||||
|
||||
// Validate first factor
|
||||
dispatch(authenticate());
|
||||
[err, res] = await to(AutheliaService.postFirstFactorAuth(username, password));
|
||||
|
||||
if (err) {
|
||||
await dispatch(authenticateFailure(err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
await dispatch(authenticateFailure('No response'));
|
||||
return;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if ('error' in json) {
|
||||
await dispatch(authenticateFailure(json['error']));
|
||||
return;
|
||||
}
|
||||
dispatch(authenticateSuccess());
|
||||
|
||||
// fetch state
|
||||
FetchStateBehavior(dispatch);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
return {
|
||||
onAuthenticationRequested: onAuthenticationRequested(dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(FirstFactorForm);
|
|
@ -0,0 +1,114 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { RootState } from '../../../reducers';
|
||||
import { Dispatch } from 'redux';
|
||||
import u2fApi from 'u2f-api';
|
||||
import to from 'await-to-js';
|
||||
import { securityKeySignSuccess, securityKeySign, securityKeySignFailure, setSecurityKeySupported, oneTimePasswordVerification, oneTimePasswordVerificationFailure, oneTimePasswordVerificationSuccess } from '../../../reducers/Portal/SecondFactor/actions';
|
||||
import SecondFactorForm, { OwnProps, StateProps } from '../../../components/SecondFactorForm/SecondFactorForm';
|
||||
import * as AutheliaService from '../../../services/AutheliaService';
|
||||
import { push } from 'connected-react-router';
|
||||
import fetchState from '../../../behaviors/FetchStateBehavior';
|
||||
import LogoutBehavior from '../../../behaviors/LogoutBehavior';
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => ({
|
||||
securityKeySupported: state.secondFactor.securityKeySupported,
|
||||
securityKeyVerified: state.secondFactor.securityKeySignSuccess || false,
|
||||
securityKeyError: state.secondFactor.error,
|
||||
|
||||
oneTimePasswordVerificationInProgress: state.secondFactor.oneTimePasswordVerificationLoading,
|
||||
oneTimePasswordVerificationError: state.secondFactor.oneTimePasswordVerificationError,
|
||||
});
|
||||
|
||||
async function triggerSecurityKeySigning(dispatch: Dispatch) {
|
||||
let err, result;
|
||||
dispatch(securityKeySign());
|
||||
[err, result] = await to(AutheliaService.requestSigning());
|
||||
if (err) {
|
||||
await dispatch(securityKeySignFailure(err.message));
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
await dispatch(securityKeySignFailure('No response'));
|
||||
throw 'No response';
|
||||
}
|
||||
|
||||
[err, result] = await to(u2fApi.sign(result, 60));
|
||||
if (err) {
|
||||
await dispatch(securityKeySignFailure(err.message));
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
await dispatch(securityKeySignFailure('No response'));
|
||||
throw 'No response';
|
||||
}
|
||||
|
||||
[err, result] = await to(AutheliaService.completeSecurityKeySigning(result));
|
||||
if (err) {
|
||||
await dispatch(securityKeySignFailure(err.message));
|
||||
throw err;
|
||||
}
|
||||
await dispatch(securityKeySignSuccess());
|
||||
}
|
||||
|
||||
function redirectOnSuccess(dispatch: Dispatch, ownProps: OwnProps, duration?: number) {
|
||||
function redirect() {
|
||||
if (ownProps.redirection) {
|
||||
window.location.href = ownProps.redirection;
|
||||
} else {
|
||||
fetchState(dispatch);
|
||||
}
|
||||
}
|
||||
|
||||
if (duration) {
|
||||
setTimeout(redirect, duration);
|
||||
} else {
|
||||
redirect();
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, ownProps: OwnProps) => {
|
||||
return {
|
||||
onLogoutClicked: () => LogoutBehavior(dispatch),
|
||||
onRegisterSecurityKeyClicked: async () => {
|
||||
await AutheliaService.startU2FRegistrationIdentityProcess();
|
||||
await dispatch(push('/confirmation-sent'));
|
||||
},
|
||||
onRegisterOneTimePasswordClicked: async () => {
|
||||
await AutheliaService.startTOTPRegistrationIdentityProcess();
|
||||
await dispatch(push('/confirmation-sent'));
|
||||
},
|
||||
onInit: async () => {
|
||||
const isU2FSupported = await u2fApi.isSupported();
|
||||
if (isU2FSupported) {
|
||||
await dispatch(setSecurityKeySupported(true));
|
||||
await triggerSecurityKeySigning(dispatch);
|
||||
redirectOnSuccess(dispatch, ownProps, 1000);
|
||||
}
|
||||
},
|
||||
onOneTimePasswordValidationRequested: async (token: string) => {
|
||||
let err, res;
|
||||
dispatch(oneTimePasswordVerification());
|
||||
[err, res] = await to(AutheliaService.verifyTotpToken(token));
|
||||
if (err) {
|
||||
dispatch(oneTimePasswordVerificationFailure(err.message));
|
||||
throw err;
|
||||
}
|
||||
if (!res) {
|
||||
dispatch(oneTimePasswordVerificationFailure('No response'));
|
||||
throw 'No response';
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
if ('error' in body) {
|
||||
dispatch(oneTimePasswordVerificationFailure(body['error']));
|
||||
throw body['error'];
|
||||
}
|
||||
dispatch(oneTimePasswordVerificationSuccess());
|
||||
redirectOnSuccess(dispatch, ownProps);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SecondFactorForm);
|
|
@ -1,31 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import StateSynchronizer, { OnLoaded, OnError } from '../../../components/StateSynchronizer/StateSynchronizer';
|
||||
import { RootState } from '../../../reducers';
|
||||
import { fetchStateSuccess, fetchState, fetchStateFailure } from '../../../reducers/Portal/FirstFactor/actions';
|
||||
import RemoteState from '../../../reducers/Portal/RemoteState';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
state: state.firstFactor.remoteState,
|
||||
stateError: state.firstFactor.remoteStateError,
|
||||
stateLoading: state.firstFactor.remoteStateLoading,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
return {
|
||||
fetch: (onloaded: OnLoaded, onerror: OnError) => {
|
||||
dispatch(fetchState());
|
||||
fetch('/api/state').then(async (res) => {
|
||||
const body = await res.json() as RemoteState;
|
||||
await dispatch(fetchStateSuccess(body));
|
||||
await onloaded(body);
|
||||
})
|
||||
.catch(async (err) => {
|
||||
await dispatch(fetchStateFailure(err));
|
||||
await onerror(err);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(StateSynchronizer);
|
|
@ -2,8 +2,6 @@ import { connect } from 'react-redux';
|
|||
import PortalLayout from '../../../layouts/PortalLayout/PortalLayout';
|
||||
import { RootState } from '../../../reducers';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
authenticationLevel: (state.firstFactor.remoteState) ? state.firstFactor.remoteState.authentication_level : 0,
|
||||
});
|
||||
const mapStateToProps = (state: RootState) => ({});
|
||||
|
||||
export default connect(mapStateToProps)(PortalLayout);
|
|
@ -0,0 +1,42 @@
|
|||
import { connect } from 'react-redux';
|
||||
import AuthenticationView, {StateProps, Stage, DispatchProps} from '../../../views/AuthenticationView/AuthenticationView';
|
||||
import { RootState } from '../../../reducers';
|
||||
import { Dispatch } from 'redux';
|
||||
import AuthenticationLevel from '../../../types/AuthenticationLevel';
|
||||
import FetchStateBehavior from '../../../behaviors/FetchStateBehavior';
|
||||
import { setRedirectionUrl } from '../../../reducers/Portal/Authentication/actions';
|
||||
|
||||
function authenticationLevelToStage(level: AuthenticationLevel): Stage {
|
||||
switch (level) {
|
||||
case AuthenticationLevel.NOT_AUTHENTICATED:
|
||||
return Stage.FIRST_FACTOR;
|
||||
case AuthenticationLevel.ONE_FACTOR:
|
||||
return Stage.SECOND_FACTOR;
|
||||
case AuthenticationLevel.TWO_FACTOR:
|
||||
return Stage.ALREADY_AUTHENTICATED;
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => {
|
||||
const stage = (state.authentication.remoteState)
|
||||
? authenticationLevelToStage(state.authentication.remoteState.authentication_level)
|
||||
: Stage.FIRST_FACTOR;
|
||||
return {
|
||||
redirectionUrl: state.authentication.redirectionUrl,
|
||||
remoteState: state.authentication.remoteState,
|
||||
stage: stage,
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => {
|
||||
return {
|
||||
onInit: async (redirectionUrl?: string) => {
|
||||
await FetchStateBehavior(dispatch);
|
||||
if (redirectionUrl) {
|
||||
await dispatch(setRedirectionUrl(redirectionUrl));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AuthenticationView);
|
|
@ -1,57 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import QueryString from 'query-string';
|
||||
import FirstFactorView, { Props } from '../../../views/FirstFactorView/FirstFactorView';
|
||||
import { Dispatch } from 'redux';
|
||||
import { authenticateFailure, authenticateSuccess, authenticate } from '../../../reducers/Portal/FirstFactor/actions';
|
||||
import { RootState } from '../../../reducers';
|
||||
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({});
|
||||
|
||||
function redirect2FA(props: Props) {
|
||||
if (!props.location) {
|
||||
props.history.push('/2fa');
|
||||
return;
|
||||
}
|
||||
const params = QueryString.parse(props.location.search);
|
||||
|
||||
if ('rd' in params) {
|
||||
const rd = params['rd'] as string;
|
||||
props.history.push(`/2fa?rd=${rd}`);
|
||||
return;
|
||||
}
|
||||
props.history.push('/2fa');
|
||||
}
|
||||
|
||||
function onAuthenticationRequested(dispatch: Dispatch, ownProps: Props) {
|
||||
return async (username: string, password: string) => {
|
||||
dispatch(authenticate());
|
||||
fetch('/api/firstfactor', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
})
|
||||
}).then(async (res) => {
|
||||
const json = await res.json();
|
||||
if ('error' in json) {
|
||||
dispatch(authenticateFailure(json['error']));
|
||||
return;
|
||||
}
|
||||
dispatch(authenticateSuccess());
|
||||
redirect2FA(ownProps);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
|
||||
return {
|
||||
onAuthenticationRequested: onAuthenticationRequested(dispatch, ownProps),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(FirstFactorView);
|
|
@ -0,0 +1,31 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { RootState } from '../../../reducers';
|
||||
import { Dispatch } from 'redux';
|
||||
import { push } from 'connected-react-router';
|
||||
import * as AutheliaService from '../../../services/AutheliaService';
|
||||
import ForgotPasswordView from '../../../views/ForgotPasswordView/ForgotPasswordView';
|
||||
import { forgotPasswordRequest, forgotPasswordSuccess, forgotPasswordFailure } from '../../../reducers/Portal/ForgotPassword/actions';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
disabled: state.forgotPassword.loading,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
return {
|
||||
onPasswordResetRequested: async (username: string) => {
|
||||
try {
|
||||
dispatch(forgotPasswordRequest());
|
||||
await AutheliaService.initiatePasswordResetIdentityValidation(username);
|
||||
dispatch(forgotPasswordSuccess());
|
||||
await dispatch(push('/confirmation-sent'));
|
||||
} catch (err) {
|
||||
dispatch(forgotPasswordFailure(err.message));
|
||||
}
|
||||
},
|
||||
onCancelClicked: async () => {
|
||||
dispatch(push('/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ForgotPasswordView);
|
|
@ -4,7 +4,7 @@ import { RootState } from '../../../reducers';
|
|||
import { Dispatch } from 'redux';
|
||||
import {to} from 'await-to-js';
|
||||
import { generateTotpSecret, generateTotpSecretSuccess, generateTotpSecretFailure } from '../../../reducers/Portal/OneTimePasswordRegistration/actions';
|
||||
import { Props } from '../../../views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView';
|
||||
import { push } from 'connected-react-router';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
error: state.oneTimePasswordRegistration.error,
|
||||
|
@ -46,7 +46,7 @@ async function tryGenerateTotpSecret(dispatch: Dispatch, token: string) {
|
|||
dispatch(generateTotpSecretSuccess(result));
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
let internalToken: string;
|
||||
return {
|
||||
onInit: async (token: string) => {
|
||||
|
@ -57,10 +57,10 @@ const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
|
|||
await tryGenerateTotpSecret(dispatch, internalToken);
|
||||
},
|
||||
onCancelClicked: () => {
|
||||
ownProps.history.push('/2fa');
|
||||
dispatch(push('/'));
|
||||
},
|
||||
onLoginClicked: () => {
|
||||
ownProps.history.push('/2fa');
|
||||
dispatch(push('/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { RootState } from '../../../reducers';
|
||||
import { Dispatch } from 'redux';
|
||||
import { push } from 'connected-react-router';
|
||||
import * as AutheliaService from '../../../services/AutheliaService';
|
||||
import ResetPasswordView, { StateProps } from '../../../views/ResetPasswordView/ResetPasswordView';
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => ({
|
||||
disabled: state.resetPassword.loading,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => {
|
||||
return {
|
||||
onInit: async (token: string) => {
|
||||
await AutheliaService.completePasswordResetIdentityValidation(token);
|
||||
},
|
||||
onPasswordResetRequested: async (newPassword: string) => {
|
||||
await AutheliaService.resetPassword(newPassword);
|
||||
await dispatch(push('/'));
|
||||
},
|
||||
onCancelClicked: async () => {
|
||||
await dispatch(push('/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ResetPasswordView);
|
|
@ -1,136 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import QueryString from 'query-string';
|
||||
import SecondFactorView, {Props} from '../../../views/SecondFactorView/SecondFactorView';
|
||||
import { RootState } from '../../../reducers';
|
||||
import { Dispatch } from 'redux';
|
||||
import u2fApi, { SignResponse } from 'u2f-api';
|
||||
import to from 'await-to-js';
|
||||
import { logoutSuccess, logoutFailure, logout, securityKeySignSuccess, securityKeySign, securityKeySignFailure, setSecurityKeySupported } from '../../../reducers/Portal/SecondFactor/actions';
|
||||
import AuthenticationLevel from '../../../types/AuthenticationLevel';
|
||||
import RemoteState from '../../../reducers/Portal/RemoteState';
|
||||
|
||||
const mapStateToProps = (state: RootState) => ({
|
||||
state: state.firstFactor.remoteState,
|
||||
stateError: state.firstFactor.remoteStateError,
|
||||
securityKeySupported: state.secondFactor.securityKeySupported,
|
||||
securityKeyVerified: state.secondFactor.securityKeySignSuccess || false,
|
||||
securityKeyError: state.secondFactor.error,
|
||||
});
|
||||
|
||||
async function requestSigning() {
|
||||
return fetch('/api/u2f/sign_request')
|
||||
.then(async (res) => {
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Status code ' + res.status);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function completeSecurityKeySigning(response: u2fApi.SignResponse) {
|
||||
return fetch('/api/u2f/sign', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(response),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Status code ' + res.status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function triggerSecurityKeySigning(dispatch: Dispatch, props: Props) {
|
||||
let err, result;
|
||||
dispatch(securityKeySign());
|
||||
[err, result] = await to(requestSigning());
|
||||
if (err) {
|
||||
dispatch(securityKeySignFailure(err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
[err, result] = await to(u2fApi.sign(result, 60));
|
||||
if (err) {
|
||||
dispatch(securityKeySignFailure(err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
[err, result] = await to(completeSecurityKeySigning(result as SignResponse));
|
||||
if (err) {
|
||||
dispatch(securityKeySignFailure(err.message));
|
||||
return;
|
||||
}
|
||||
dispatch(securityKeySignSuccess());
|
||||
await redirectUponAuthentication(props);
|
||||
}
|
||||
|
||||
async function redirectUponAuthentication(props: Props) {
|
||||
const params = QueryString.parse(props.history.location.search);
|
||||
if ('rd' in params) {
|
||||
setTimeout(() => {
|
||||
window.location.replace(params['rd'] as string);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch, ownProps: Props) => {
|
||||
return {
|
||||
onLogoutClicked: () => {
|
||||
dispatch(logout());
|
||||
fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status != 200) {
|
||||
throw new Error('Status code ' + res.status);
|
||||
}
|
||||
await dispatch(logoutSuccess());
|
||||
ownProps.history.push('/');
|
||||
})
|
||||
.catch(async (err: string) => {
|
||||
console.error(err);
|
||||
await dispatch(logoutFailure(err));
|
||||
});
|
||||
},
|
||||
onRegisterSecurityKeyClicked: () => {
|
||||
fetch('/api/secondfactor/u2f/identity/start', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status != 200) {
|
||||
throw new Error('Status code ' + res.status);
|
||||
}
|
||||
ownProps.history.push('/confirmation-sent');
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
},
|
||||
onRegisterOneTimePasswordClicked: () => {
|
||||
fetch('/api/secondfactor/totp/identity/start', {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.status != 200) {
|
||||
throw new Error('Status code ' + res.status);
|
||||
}
|
||||
ownProps.history.push('/confirmation-sent');
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
},
|
||||
onStateLoaded: async (state: RemoteState) => {
|
||||
if (state.authentication_level < AuthenticationLevel.ONE_FACTOR) {
|
||||
ownProps.history.push('/');
|
||||
return;
|
||||
}
|
||||
const isU2FSupported = await u2fApi.isSupported();
|
||||
if (isU2FSupported) {
|
||||
await dispatch(setSecurityKeySupported(true));
|
||||
await triggerSecurityKeySigning(dispatch, ownProps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(SecondFactorView);
|
|
@ -7,11 +7,8 @@ import { AUTHELIA_GITHUB_URL } from "../../constants";
|
|||
import { WithStyles, withStyles } from "@material-ui/core";
|
||||
|
||||
import styles from '../../assets/jss/layouts/PortalLayout/PortalLayout';
|
||||
import AuthenticationLevel from "../../types/AuthenticationLevel";
|
||||
|
||||
interface Props extends RouterProps, RouteProps, WithStyles {
|
||||
authenticationLevel: AuthenticationLevel;
|
||||
}
|
||||
interface Props extends RouterProps, RouteProps, WithStyles {}
|
||||
|
||||
class PortalLayout extends Component<Props> {
|
||||
private renderTitle() {
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { createAction } from 'typesafe-actions';
|
||||
import {
|
||||
FETCH_STATE_REQUEST,
|
||||
FETCH_STATE_SUCCESS,
|
||||
FETCH_STATE_FAILURE,
|
||||
SET_REDIRECTION_URL,
|
||||
} from "../../constants";
|
||||
import RemoteState from '../../../views/AuthenticationView/RemoteState';
|
||||
|
||||
/* FETCH_STATE */
|
||||
export const fetchState = createAction(FETCH_STATE_REQUEST);
|
||||
export const fetchStateSuccess = createAction(FETCH_STATE_SUCCESS, resolve => {
|
||||
return (state: RemoteState) => {
|
||||
return resolve(state);
|
||||
}
|
||||
});
|
||||
export const fetchStateFailure = createAction(FETCH_STATE_FAILURE, resolve => {
|
||||
return (err: string) => {
|
||||
return resolve(err);
|
||||
}
|
||||
});
|
||||
|
||||
export const setRedirectionUrl = createAction(SET_REDIRECTION_URL, resolve => {
|
||||
return (url: string) => resolve(url);
|
||||
})
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
import * as Actions from './actions';
|
||||
import { ActionType, getType } from 'typesafe-actions';
|
||||
import RemoteState from '../../../views/AuthenticationView/RemoteState';
|
||||
|
||||
export type Action = ActionType<typeof Actions>;
|
||||
|
||||
interface State {
|
||||
redirectionUrl : string | null;
|
||||
remoteState: RemoteState | null;
|
||||
remoteStateLoading: boolean;
|
||||
remoteStateError: string | null;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
redirectionUrl: null,
|
||||
|
||||
remoteState: null,
|
||||
remoteStateLoading: false,
|
||||
remoteStateError: null,
|
||||
}
|
||||
|
||||
export default (state = initialState, action: Action): State => {
|
||||
switch(action.type) {
|
||||
case getType(Actions.fetchState):
|
||||
return {
|
||||
...state,
|
||||
remoteState: null,
|
||||
remoteStateError: null,
|
||||
remoteStateLoading: true,
|
||||
};
|
||||
case getType(Actions.fetchStateSuccess):
|
||||
return {
|
||||
...state,
|
||||
remoteState: action.payload,
|
||||
remoteStateLoading: false,
|
||||
};
|
||||
case getType(Actions.fetchStateFailure):
|
||||
return {
|
||||
...state,
|
||||
remoteStateError: action.payload,
|
||||
remoteStateLoading: false,
|
||||
};
|
||||
case getType(Actions.setRedirectionUrl):
|
||||
return {
|
||||
...state,
|
||||
redirectionUrl: action.payload,
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
|
@ -2,25 +2,8 @@ import { createAction } from 'typesafe-actions';
|
|||
import {
|
||||
AUTHENTICATE_REQUEST,
|
||||
AUTHENTICATE_SUCCESS,
|
||||
AUTHENTICATE_FAILURE,
|
||||
FETCH_STATE_REQUEST,
|
||||
FETCH_STATE_SUCCESS,
|
||||
FETCH_STATE_FAILURE,
|
||||
AUTHENTICATE_FAILURE
|
||||
} from "../../constants";
|
||||
import RemoteState from '../RemoteState';
|
||||
|
||||
/* FETCH_STATE */
|
||||
export const fetchState = createAction(FETCH_STATE_REQUEST);
|
||||
export const fetchStateSuccess = createAction(FETCH_STATE_SUCCESS, resolve => {
|
||||
return (state: RemoteState) => {
|
||||
return resolve(state);
|
||||
}
|
||||
});
|
||||
export const fetchStateFailure = createAction(FETCH_STATE_FAILURE, resolve => {
|
||||
return (err: string) => {
|
||||
return resolve(err);
|
||||
}
|
||||
})
|
||||
|
||||
/* AUTHENTICATE_REQUEST */
|
||||
export const authenticate = createAction(AUTHENTICATE_REQUEST);
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
import * as Actions from './actions';
|
||||
import { ActionType, getType, StateType } from 'typesafe-actions';
|
||||
import RemoteState from '../RemoteState';
|
||||
import { ActionType, getType } from 'typesafe-actions';
|
||||
|
||||
export type FirstFactorAction = ActionType<typeof Actions>;
|
||||
|
||||
|
@ -11,28 +10,19 @@ enum Result {
|
|||
FAILURE,
|
||||
}
|
||||
|
||||
interface State {
|
||||
interface FirstFactorState {
|
||||
lastResult: Result;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
remoteState: RemoteState | null;
|
||||
remoteStateLoading: boolean;
|
||||
remoteStateError: string | null;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
const firstFactorInitialState: FirstFactorState = {
|
||||
lastResult: Result.NONE,
|
||||
loading: false,
|
||||
error: null,
|
||||
remoteState: null,
|
||||
remoteStateLoading: false,
|
||||
remoteStateError: null,
|
||||
}
|
||||
|
||||
export type PortalState = StateType<State>;
|
||||
|
||||
export default (state = initialState, action: FirstFactorAction) => {
|
||||
export default (state = firstFactorInitialState, action: FirstFactorAction): FirstFactorState => {
|
||||
switch(action.type) {
|
||||
case getType(Actions.authenticate):
|
||||
return {
|
||||
|
@ -54,26 +44,6 @@ export default (state = initialState, action: FirstFactorAction) => {
|
|||
loading: false,
|
||||
error: action.payload,
|
||||
};
|
||||
|
||||
case getType(Actions.fetchState):
|
||||
return {
|
||||
...state,
|
||||
remoteState: null,
|
||||
remoteStateError: null,
|
||||
remoteStateLoading: true,
|
||||
};
|
||||
case getType(Actions.fetchStateSuccess):
|
||||
return {
|
||||
...state,
|
||||
remoteState: action.payload,
|
||||
remoteStateLoading: false,
|
||||
};
|
||||
case getType(Actions.fetchStateFailure):
|
||||
return {
|
||||
...state,
|
||||
remoteStateError: action.payload,
|
||||
remoteStateLoading: false,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { createAction } from 'typesafe-actions';
|
||||
import {
|
||||
FORGOT_PASSWORD_REQUEST,
|
||||
FORGOT_PASSWORD_SUCCESS,
|
||||
FORGOT_PASSWORD_FAILURE
|
||||
} from "../../constants";
|
||||
|
||||
/* AUTHENTICATE_REQUEST */
|
||||
export const forgotPasswordRequest = createAction(FORGOT_PASSWORD_REQUEST);
|
||||
export const forgotPasswordSuccess = createAction(FORGOT_PASSWORD_SUCCESS);
|
||||
export const forgotPasswordFailure = createAction(FORGOT_PASSWORD_FAILURE, resolve => {
|
||||
return (error: string) => resolve(error);
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
|
||||
import * as Actions from './actions';
|
||||
import { ActionType, getType } from 'typesafe-actions';
|
||||
|
||||
export type Action = ActionType<typeof Actions>;
|
||||
|
||||
|
||||
interface State {
|
||||
loading: boolean;
|
||||
success: boolean | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
loading: false,
|
||||
success: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
export default (state = initialState, action: Action): State => {
|
||||
switch(action.type) {
|
||||
case getType(Actions.forgotPasswordRequest):
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
error: null
|
||||
};
|
||||
case getType(Actions.forgotPasswordSuccess):
|
||||
return {
|
||||
...state,
|
||||
success: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
case getType(Actions.forgotPasswordFailure):
|
||||
return {
|
||||
...state,
|
||||
success: false,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
|
@ -4,28 +4,19 @@ import { Secret } from "../../../views/OneTimePasswordRegistrationView/Secret";
|
|||
|
||||
type OneTimePasswordRegistrationAction = ActionType<typeof Actions>
|
||||
|
||||
export interface State {
|
||||
export interface OneTimePasswordRegistrationState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
secret: Secret | null;
|
||||
}
|
||||
|
||||
let initialState: State = {
|
||||
let oneTimePasswordRegistrationInitialState: OneTimePasswordRegistrationState = {
|
||||
loading: true,
|
||||
error: null,
|
||||
secret: null,
|
||||
}
|
||||
|
||||
initialState = {
|
||||
secret: {
|
||||
base32_secret: 'PBSFWU2RM42HG3TNIRHUQMKSKVUW6NCNOBNFOLCFJZATS6CTI47A',
|
||||
otpauth_url: 'PBSFWU2RM42HG3TNIRHUQMKSKVUW6NCNOBNFOLCFJZATS6CTI47A',
|
||||
},
|
||||
error: null,
|
||||
loading: false,
|
||||
}
|
||||
|
||||
export default (state = initialState, action: OneTimePasswordRegistrationAction) => {
|
||||
export default (state = oneTimePasswordRegistrationInitialState, action: OneTimePasswordRegistrationAction): OneTimePasswordRegistrationState => {
|
||||
switch(action.type) {
|
||||
case getType(Actions.generateTotpSecret):
|
||||
return {
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { createAction } from 'typesafe-actions';
|
||||
import { RESET_PASSWORD_REQUEST, RESET_PASSWORD_SUCCESS, RESET_PASSWORD_FAILURE } from "../../constants";
|
||||
|
||||
/* AUTHENTICATE_REQUEST */
|
||||
export const resetPasswordRequest = createAction(RESET_PASSWORD_REQUEST);
|
||||
export const resetPasswordSuccess = createAction(RESET_PASSWORD_SUCCESS);
|
||||
export const resetPasswordFailure = createAction(RESET_PASSWORD_FAILURE, resolve => {
|
||||
return (error: string) => resolve(error);
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
|
||||
import * as Actions from './actions';
|
||||
import { ActionType, getType } from 'typesafe-actions';
|
||||
|
||||
export type Action = ActionType<typeof Actions>;
|
||||
|
||||
|
||||
interface State {
|
||||
loading: boolean;
|
||||
success: boolean | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
loading: false,
|
||||
success: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
export default (state = initialState, action: Action): State => {
|
||||
switch(action.type) {
|
||||
case getType(Actions.resetPasswordRequest):
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
error: null
|
||||
};
|
||||
case getType(Actions.resetPasswordSuccess):
|
||||
return {
|
||||
...state,
|
||||
success: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
case getType(Actions.resetPasswordFailure):
|
||||
return {
|
||||
...state,
|
||||
success: false,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
|
@ -6,7 +6,10 @@ import {
|
|||
SECURITY_KEY_SIGN,
|
||||
SECURITY_KEY_SIGN_SUCCESS,
|
||||
SECURITY_KEY_SIGN_FAILURE,
|
||||
SET_SECURITY_KEY_SUPPORTED
|
||||
SET_SECURITY_KEY_SUPPORTED,
|
||||
ONE_TIME_PASSWORD_VERIFICATION_REQUEST,
|
||||
ONE_TIME_PASSWORD_VERIFICATION_SUCCESS,
|
||||
ONE_TIME_PASSWORD_VERIFICATION_FAILURE
|
||||
} from "../../constants";
|
||||
|
||||
export const setSecurityKeySupported = createAction(SET_SECURITY_KEY_SUPPORTED, resolve => {
|
||||
|
@ -17,7 +20,14 @@ export const securityKeySign = createAction(SECURITY_KEY_SIGN);
|
|||
export const securityKeySignSuccess = createAction(SECURITY_KEY_SIGN_SUCCESS);
|
||||
export const securityKeySignFailure = createAction(SECURITY_KEY_SIGN_FAILURE, resolve => {
|
||||
return (error: string) => resolve(error);
|
||||
})
|
||||
});
|
||||
|
||||
export const oneTimePasswordVerification = createAction(ONE_TIME_PASSWORD_VERIFICATION_REQUEST);
|
||||
export const oneTimePasswordVerificationSuccess = createAction(ONE_TIME_PASSWORD_VERIFICATION_SUCCESS);
|
||||
export const oneTimePasswordVerificationFailure = createAction(ONE_TIME_PASSWORD_VERIFICATION_FAILURE, resolve => {
|
||||
return (err: string) => resolve(err);
|
||||
});
|
||||
|
||||
|
||||
export const logout = createAction(LOGOUT_REQUEST);
|
||||
export const logoutSuccess = createAction(LOGOUT_SUCCESS);
|
||||
|
|
|
@ -4,7 +4,7 @@ import { ActionType, getType, StateType } from 'typesafe-actions';
|
|||
|
||||
export type SecondFactorAction = ActionType<typeof Actions>;
|
||||
|
||||
interface State {
|
||||
interface SecondFactorState {
|
||||
logoutLoading: boolean;
|
||||
logoutSuccess: boolean | null;
|
||||
error: string | null;
|
||||
|
@ -12,9 +12,13 @@ interface State {
|
|||
securityKeySupported: boolean;
|
||||
securityKeySignLoading: boolean;
|
||||
securityKeySignSuccess: boolean | null;
|
||||
|
||||
oneTimePasswordVerificationLoading: boolean,
|
||||
oneTimePasswordVerificationSuccess: boolean | null,
|
||||
oneTimePasswordVerificationError: string | null,
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
const secondFactorInitialState: SecondFactorState = {
|
||||
logoutLoading: false,
|
||||
logoutSuccess: null,
|
||||
error: null,
|
||||
|
@ -22,11 +26,15 @@ const initialState: State = {
|
|||
securityKeySupported: false,
|
||||
securityKeySignLoading: false,
|
||||
securityKeySignSuccess: null,
|
||||
|
||||
oneTimePasswordVerificationLoading: false,
|
||||
oneTimePasswordVerificationError: null,
|
||||
oneTimePasswordVerificationSuccess: null,
|
||||
}
|
||||
|
||||
export type PortalState = StateType<State>;
|
||||
export type PortalState = StateType<SecondFactorState>;
|
||||
|
||||
export default (state = initialState, action: SecondFactorAction): State => {
|
||||
export default (state = secondFactorInitialState, action: SecondFactorAction): SecondFactorState => {
|
||||
switch(action.type) {
|
||||
case getType(Actions.logout):
|
||||
return {
|
||||
|
@ -47,6 +55,7 @@ export default (state = initialState, action: SecondFactorAction): State => {
|
|||
logoutLoading: false,
|
||||
error: action.payload,
|
||||
}
|
||||
|
||||
case getType(Actions.securityKeySign):
|
||||
return {
|
||||
...state,
|
||||
|
@ -65,11 +74,31 @@ export default (state = initialState, action: SecondFactorAction): State => {
|
|||
securityKeySignLoading: false,
|
||||
securityKeySignSuccess: false,
|
||||
};
|
||||
|
||||
case getType(Actions.setSecurityKeySupported):
|
||||
return {
|
||||
...state,
|
||||
securityKeySupported: action.payload,
|
||||
};
|
||||
|
||||
case getType(Actions.oneTimePasswordVerification):
|
||||
return {
|
||||
...state,
|
||||
oneTimePasswordVerificationLoading: true,
|
||||
oneTimePasswordVerificationError: null,
|
||||
}
|
||||
case getType(Actions.oneTimePasswordVerificationSuccess):
|
||||
return {
|
||||
...state,
|
||||
oneTimePasswordVerificationLoading: false,
|
||||
oneTimePasswordVerificationSuccess: true,
|
||||
}
|
||||
case getType(Actions.oneTimePasswordVerificationFailure):
|
||||
return {
|
||||
...state,
|
||||
oneTimePasswordVerificationLoading: false,
|
||||
oneTimePasswordVerificationError: action.payload,
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
|
@ -3,17 +3,17 @@ import * as Actions from './actions';
|
|||
|
||||
type SecurityKeyRegistrationAction = ActionType<typeof Actions>
|
||||
|
||||
export interface State {
|
||||
export interface SecurityKeyRegistrationState {
|
||||
error: string | null;
|
||||
success: boolean | null;
|
||||
}
|
||||
|
||||
let initialState: State = {
|
||||
let securityKeyRegistrationInitialState: SecurityKeyRegistrationState = {
|
||||
error: null,
|
||||
success: null,
|
||||
}
|
||||
|
||||
export default (state = initialState, action: SecurityKeyRegistrationAction): State => {
|
||||
export default (state = securityKeyRegistrationInitialState, action: SecurityKeyRegistrationAction): SecurityKeyRegistrationState => {
|
||||
switch(action.type) {
|
||||
case getType(Actions.registerSecurityKey):
|
||||
return {
|
||||
|
|
|
@ -4,10 +4,25 @@ import FirstFactorReducer from './FirstFactor/reducer';
|
|||
import SecondFactorReducer from './SecondFactor/reducer';
|
||||
import OneTimePasswordRegistrationReducer from './OneTimePasswordRegistration/reducer';
|
||||
import SecurityKeyRegistrationReducer from './SecurityKeyRegistration/reducer';
|
||||
import AuthenticationReducer from './Authentication/reducer';
|
||||
import ForgotPasswordReducer from './ForgotPassword/reducer';
|
||||
import ResetPasswordReducer from './ResetPassword/reducer';
|
||||
|
||||
export default combineReducers({
|
||||
firstFactor: FirstFactorReducer,
|
||||
secondFactor: SecondFactorReducer,
|
||||
oneTimePasswordRegistration: OneTimePasswordRegistrationReducer,
|
||||
securityKeyRegistration: SecurityKeyRegistrationReducer,
|
||||
});
|
||||
import { connectRouter } from 'connected-react-router'
|
||||
import { History } from 'history';
|
||||
|
||||
function reducer(history: History) {
|
||||
return combineReducers({
|
||||
router: connectRouter(history),
|
||||
authentication: AuthenticationReducer,
|
||||
firstFactor: FirstFactorReducer,
|
||||
secondFactor: SecondFactorReducer,
|
||||
oneTimePasswordRegistration: OneTimePasswordRegistrationReducer,
|
||||
securityKeyRegistration: SecurityKeyRegistrationReducer,
|
||||
forgotPassword: ForgotPasswordReducer,
|
||||
resetPassword: ResetPasswordReducer,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export default reducer;
|
|
@ -3,6 +3,10 @@ export const FETCH_STATE_REQUEST = '@portal/fetch_state_request';
|
|||
export const FETCH_STATE_SUCCESS = '@portal/fetch_state_success';
|
||||
export const FETCH_STATE_FAILURE = '@portal/fetch_state_failure';
|
||||
|
||||
// AUTHENTICATION PROCESS
|
||||
|
||||
export const SET_REDIRECTION_URL = '@portal/authenticate/set_redirection_url';
|
||||
|
||||
export const AUTHENTICATE_REQUEST = '@portal/authenticate_request';
|
||||
export const AUTHENTICATE_SUCCESS = '@portal/authenticate_success';
|
||||
export const AUTHENTICATE_FAILURE = '@portal/authenticate_failure';
|
||||
|
@ -14,6 +18,10 @@ export const SECURITY_KEY_SIGN = '@portal/second_factor/security_key_sign';
|
|||
export const SECURITY_KEY_SIGN_SUCCESS = '@portal/second_factor/security_key_sign_success';
|
||||
export const SECURITY_KEY_SIGN_FAILURE = '@portal/second_factor/security_key_sign_failure';
|
||||
|
||||
export const ONE_TIME_PASSWORD_VERIFICATION_REQUEST = '@portal/second_factor/one_time_password_verification_request';
|
||||
export const ONE_TIME_PASSWORD_VERIFICATION_SUCCESS = '@portal/second_factor/one_time_password_verification_success';
|
||||
export const ONE_TIME_PASSWORD_VERIFICATION_FAILURE = '@portal/second_factor/one_time_password_verification_failure';
|
||||
|
||||
export const LOGOUT_REQUEST = '@portal/logout_request';
|
||||
export const LOGOUT_SUCCESS = '@portal/logout_success';
|
||||
export const LOGOUT_FAILURE = '@portal/logout_failure';
|
||||
|
@ -26,4 +34,14 @@ export const GENERATE_TOTP_SECRET_FAILURE = '@portal/generate_totp_secret_failur
|
|||
// U2F REGISTRATION
|
||||
export const REGISTER_SECURITY_KEY_REQUEST = '@portal/security_key_registration/register_request';
|
||||
export const REGISTER_SECURITY_KEY_SUCCESS = '@portal/security_key_registration/register_success';
|
||||
export const REGISTER_SECURITY_KEY_FAILURE = '@portal/security_key_registration/register_failed';
|
||||
export const REGISTER_SECURITY_KEY_FAILURE = '@portal/security_key_registration/register_failed';
|
||||
|
||||
// FORGOT PASSWORD
|
||||
export const FORGOT_PASSWORD_REQUEST = '@portal/forgot_password/forgot_password_request';
|
||||
export const FORGOT_PASSWORD_SUCCESS = '@portal/forgot_password/forgot_password_success';
|
||||
export const FORGOT_PASSWORD_FAILURE = '@portal/forgot_password/forgot_password_failure';
|
||||
|
||||
// FORGOT PASSWORD
|
||||
export const RESET_PASSWORD_REQUEST = '@portal/forgot_password/reset_password_request';
|
||||
export const RESET_PASSWORD_SUCCESS = '@portal/forgot_password/reset_password_success';
|
||||
export const RESET_PASSWORD_FAILURE = '@portal/forgot_password/reset_password_failure';
|
|
@ -1,6 +1,11 @@
|
|||
import PortalReducer from './Portal';
|
||||
import { StateType } from 'typesafe-actions';
|
||||
|
||||
export type RootState = StateType<typeof PortalReducer>;
|
||||
function getReturnType<R> (f: (...args: any[]) => R): R {
|
||||
return null!;
|
||||
}
|
||||
|
||||
const t = getReturnType(PortalReducer)
|
||||
export type RootState = StateType<typeof t>;
|
||||
|
||||
export default PortalReducer;
|
|
@ -1,19 +1,14 @@
|
|||
import FirstFactorView from "../containers/views/FirstFactorView/FirstFactorView";
|
||||
import SecondFactorView from "../containers/views/SecondFactorView/SecondFactorView";
|
||||
import ConfirmationSentView from "../views/ConfirmationSentView/ConfirmationSentView";
|
||||
import OneTimePasswordRegistrationView from "../containers/views/OneTimePasswordRegistrationView/OneTimePasswordRegistrationView";
|
||||
import SecurityKeyRegistrationView from "../containers/views/SecurityKeyRegistrationView/SecurityKeyRegistrationView";
|
||||
import ForgotPasswordView from "../views/ForgotPasswordView/ForgotPasswordView";
|
||||
import ResetPasswordView from "../views/ResetPasswordView/ResetPasswordView";
|
||||
import ForgotPasswordView from "../containers/views/ForgotPasswordView/ForgotPasswordView";
|
||||
import ResetPasswordView from "../containers/views/ResetPasswordView/ResetPasswordView";
|
||||
import AuthenticationView from "../containers/views/AuthenticationView/AuthenticationView";
|
||||
|
||||
export const routes = [{
|
||||
path: '/',
|
||||
title: 'Login',
|
||||
component: FirstFactorView,
|
||||
}, {
|
||||
path: '/2fa',
|
||||
title: '2-factor',
|
||||
component: SecondFactorView,
|
||||
component: AuthenticationView,
|
||||
}, {
|
||||
path: '/confirmation-sent',
|
||||
title: 'e-mail sent',
|
||||
|
|
|
@ -0,0 +1,117 @@
|
|||
import RemoteState from "../views/AuthenticationView/RemoteState";
|
||||
import u2fApi, { SignRequest } from "u2f-api";
|
||||
|
||||
async function fetchSafe(url: string, options?: RequestInit) {
|
||||
return fetch(url, options)
|
||||
.then(async (res) => {
|
||||
if (res.status !== 200 && res.status !== 204) {
|
||||
throw new Error('Status code ' + res.status);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current authentication state.
|
||||
*/
|
||||
export async function fetchState() {
|
||||
return fetchSafe('/api/state')
|
||||
.then(async (res) => {
|
||||
const body = await res.json() as RemoteState;
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
export async function postFirstFactorAuth(username: string, password: string) {
|
||||
return fetchSafe('/api/firstfactor', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function postLogout() {
|
||||
return fetchSafe('/api/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function startU2FRegistrationIdentityProcess() {
|
||||
return fetchSafe('/api/secondfactor/u2f/identity/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function startTOTPRegistrationIdentityProcess() {
|
||||
return fetchSafe('/api/secondfactor/totp/identity/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestSigning() {
|
||||
return fetchSafe('/api/u2f/sign_request')
|
||||
.then(async (res) => {
|
||||
const body = await res.json();
|
||||
return body as SignRequest;
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeSecurityKeySigning(response: u2fApi.SignResponse) {
|
||||
return fetchSafe('/api/u2f/sign', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(response),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(token: string) {
|
||||
return fetchSafe('/api/totp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({token}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function initiatePasswordResetIdentityValidation(username: string) {
|
||||
return fetchSafe('/api/password-reset/identity/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({username})
|
||||
});
|
||||
}
|
||||
|
||||
export async function completePasswordResetIdentityValidation(token: string) {
|
||||
return fetch(`/api/password-reset/identity/finish?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetPassword(newPassword: string) {
|
||||
return fetchSafe('/api/password-reset', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({password: newPassword})
|
||||
});
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import React, { Component } from "react";
|
||||
import AlreadyAuthenticated from "../../containers/components/AlreadyAuthenticated/AlreadyAuthenticated";
|
||||
import FirstFactorForm from "../../containers/components/FirstFactorForm/FirstFactorForm";
|
||||
import SecondFactorForm from "../../containers/components/SecondFactorForm/SecondFactorForm";
|
||||
import RemoteState from "./RemoteState";
|
||||
import { RouterProps, Redirect } from "react-router";
|
||||
import queryString from 'query-string';
|
||||
|
||||
export enum Stage {
|
||||
FIRST_FACTOR,
|
||||
SECOND_FACTOR,
|
||||
ALREADY_AUTHENTICATED,
|
||||
}
|
||||
|
||||
export interface StateProps {
|
||||
stage: Stage;
|
||||
remoteState: RemoteState | null;
|
||||
redirectionUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DispatchProps {
|
||||
onInit: (redirectionUrl?: string) => void;
|
||||
}
|
||||
|
||||
export type Props = StateProps & DispatchProps & RouterProps;
|
||||
|
||||
class AuthenticationView extends Component<Props> {
|
||||
componentDidMount() {
|
||||
if (this.props.history.location) {
|
||||
const params = queryString.parse(this.props.history.location.search);
|
||||
if ('rd' in params) {
|
||||
this.props.onInit(params['rd'] as string);
|
||||
}
|
||||
}
|
||||
this.props.onInit();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.remoteState) return null;
|
||||
|
||||
if (this.props.stage === Stage.SECOND_FACTOR) {
|
||||
return <SecondFactorForm
|
||||
username={this.props.remoteState.username}
|
||||
redirection={this.props.redirectionUrl} />;
|
||||
} else if (this.props.stage === Stage.ALREADY_AUTHENTICATED) {
|
||||
return <AlreadyAuthenticated
|
||||
username={this.props.remoteState.username}/>;
|
||||
}
|
||||
return <FirstFactorForm />;
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthenticationView;
|
|
@ -1,31 +1,84 @@
|
|||
import React, { Component } from "react";
|
||||
import React, { Component, ChangeEvent, KeyboardEvent } from "react";
|
||||
import { TextField, WithStyles, withStyles, Button } from "@material-ui/core";
|
||||
import classnames from 'classnames';
|
||||
|
||||
import styles from '../../assets/jss/views/ForgotPasswordView/ForgotPasswordView';
|
||||
import { RouterProps } from "react-router";
|
||||
|
||||
interface Props extends WithStyles, RouterProps {}
|
||||
export interface StateProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface DispatchProps {
|
||||
onPasswordResetRequested: (username: string) => void;
|
||||
onCancelClicked: () => void;
|
||||
}
|
||||
|
||||
export type Props = StateProps & DispatchProps & WithStyles;
|
||||
|
||||
interface State {
|
||||
username: string;
|
||||
}
|
||||
|
||||
class ForgotPasswordView extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: '',
|
||||
}
|
||||
}
|
||||
|
||||
private onUsernameChanged = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({username: e.target.value});
|
||||
}
|
||||
|
||||
private onKeyPressed = (e: KeyboardEvent) => {
|
||||
if (e.key == 'Enter') {
|
||||
this.onPasswordResetRequested();
|
||||
}
|
||||
}
|
||||
|
||||
private onPasswordResetRequested = () => {
|
||||
if (this.state.username.length == 0) return;
|
||||
this.props.onPasswordResetRequested(this.state.username);
|
||||
}
|
||||
|
||||
class ForgotPasswordView extends Component<Props> {
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<div>What's you e-mail address?</div>
|
||||
<div>What's your username?</div>
|
||||
<div className={classes.form}>
|
||||
<TextField
|
||||
className={classes.field}
|
||||
variant="outlined"
|
||||
id="email"
|
||||
label="E-mail">
|
||||
id="username"
|
||||
label="Username"
|
||||
onChange={this.onUsernameChanged}
|
||||
onKeyPress={this.onKeyPressed}
|
||||
value={this.state.username}
|
||||
disabled={this.props.disabled}>
|
||||
</TextField>
|
||||
<Button
|
||||
onClick={() => this.props.history.push('/confirmation-sent')}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.button}>
|
||||
Next
|
||||
</Button>
|
||||
<div className={classes.buttonsContainer}>
|
||||
<div className={classnames(classes.buttonContainer, classes.buttonConfirmContainer)}>
|
||||
<Button
|
||||
onClick={this.onPasswordResetRequested}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.buttonConfirm}
|
||||
disabled={this.props.disabled}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
<div className={classnames(classes.buttonContainer, classes.buttonCancelContainer)}>
|
||||
<Button
|
||||
onClick={this.props.onCancelClicked}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.buttonCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,16 +1,86 @@
|
|||
import React, { Component } from "react";
|
||||
import React, { Component, KeyboardEvent, ChangeEvent } from "react";
|
||||
import { TextField, Button, WithStyles, withStyles } from "@material-ui/core";
|
||||
import { RouterProps } from "react-router";
|
||||
import classnames from 'classnames';
|
||||
import QueryString from 'query-string';
|
||||
|
||||
import styles from '../../assets/jss/views/ResetPasswordView/ResetPasswordView';
|
||||
import FormNotification from "../../components/FormNotification/FormNotification";
|
||||
|
||||
interface Props extends RouterProps, WithStyles {};
|
||||
export interface StateProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface DispatchProps {
|
||||
onInit: (token: string) => void;
|
||||
onPasswordResetRequested: (password: string) => void;
|
||||
onCancelClicked: () => void;
|
||||
}
|
||||
|
||||
export type Props = StateProps & DispatchProps & RouterProps & WithStyles;
|
||||
|
||||
interface State {
|
||||
password1: string;
|
||||
password2: string;
|
||||
error: string | null,
|
||||
}
|
||||
|
||||
class ResetPasswordView extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
password1: '',
|
||||
password2: '',
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
if (!this.props.history.location) {
|
||||
console.error('There is no location to retrieve query params from...');
|
||||
return;
|
||||
}
|
||||
const params = QueryString.parse(this.props.history.location.search);
|
||||
if (!('token' in params)) {
|
||||
console.error('Token parameter is expected and not provided');
|
||||
return;
|
||||
}
|
||||
this.props.onInit(params['token'] as string);
|
||||
}
|
||||
|
||||
private onPasswordResetRequested() {
|
||||
if (this.state.password1 && this.state.password1 === this.state.password2) {
|
||||
this.props.onPasswordResetRequested(this.state.password1);
|
||||
} else {
|
||||
this.setState({error: 'The passwords are different.'});
|
||||
}
|
||||
}
|
||||
|
||||
private onKeyPressed = (e: KeyboardEvent) => {
|
||||
if (e.key == 'Enter') {
|
||||
this.onPasswordResetRequested();
|
||||
}
|
||||
}
|
||||
|
||||
private onResetClicked = () => {
|
||||
this.onPasswordResetRequested();
|
||||
}
|
||||
|
||||
private onPassword1Changed = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({password1: e.target.value});
|
||||
}
|
||||
|
||||
private onPassword2Changed = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({password2: e.target.value});
|
||||
}
|
||||
|
||||
class ResetPasswordView extends Component<Props> {
|
||||
render() {
|
||||
const { classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<FormNotification show={this.state.error !== null}>
|
||||
{this.state.error}
|
||||
</FormNotification>
|
||||
<div>Enter your new password</div>
|
||||
<div className={classes.form}>
|
||||
<TextField
|
||||
|
@ -18,6 +88,9 @@ class ResetPasswordView extends Component<Props> {
|
|||
variant="outlined"
|
||||
type="password"
|
||||
id="password1"
|
||||
value={this.state.password1}
|
||||
onChange={this.onPassword1Changed}
|
||||
disabled={this.props.disabled}
|
||||
label="New password">
|
||||
</TextField>
|
||||
<TextField
|
||||
|
@ -25,15 +98,33 @@ class ResetPasswordView extends Component<Props> {
|
|||
variant="outlined"
|
||||
type="password"
|
||||
id="password2"
|
||||
value={this.state.password2}
|
||||
onKeyPress={this.onKeyPressed}
|
||||
onChange={this.onPassword2Changed}
|
||||
disabled={this.props.disabled}
|
||||
label="Confirm password">
|
||||
</TextField>
|
||||
<Button
|
||||
onClick={() => this.props.history.push('/')}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classes.button}>
|
||||
Next
|
||||
</Button>
|
||||
<div className={classes.buttonsContainer}>
|
||||
<div className={classnames(classes.buttonContainer, classes.buttonResetContainer)}>
|
||||
<Button
|
||||
onClick={this.onResetClicked}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={this.props.disabled}
|
||||
className={classnames(classes.button, classes.buttonReset)}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<div className={classnames(classes.buttonContainer, classes.buttonCancelContainer)}>
|
||||
<Button
|
||||
onClick={this.props.onCancelClicked}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={classnames(classes.button, classes.buttonCancel)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -94,8 +94,8 @@ export default function (vars: ServerVariables) {
|
|||
})
|
||||
.catch(Exceptions.LdapBindError, function (err: Error) {
|
||||
vars.regulator.mark(username, false);
|
||||
return ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED)(err);
|
||||
return ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.AUTHENTICATION_FAILED)(err);
|
||||
})
|
||||
.catch(ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.OPERATION_FAILED));
|
||||
.catch(ErrorReplies.replyWithError200(req, res, vars.logger, UserMessages.AUTHENTICATION_FAILED));
|
||||
};
|
||||
}
|
|
@ -28,7 +28,7 @@ export default class PasswordResetHandler implements IdentityValidable {
|
|||
preValidationInit(req: express.Request): BluebirdPromise<Identity> {
|
||||
const that = this;
|
||||
const userid: string =
|
||||
objectPath.get<express.Request, string>(req, "query.userid");
|
||||
objectPath.get<express.Request, string>(req, "body.username");
|
||||
return BluebirdPromise.resolve()
|
||||
.then(function () {
|
||||
that.logger.debug(req, "User '%s' requested a password reset", userid);
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
|
||||
import express = require("express");
|
||||
import BluebirdPromise = require("bluebird");
|
||||
import objectPath = require("object-path");
|
||||
import exceptions = require("../../../Exceptions");
|
||||
|
||||
import Constants = require("./../constants");
|
||||
|
||||
const TEMPLATE_NAME = "password-reset-request";
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ export default function (vars: ServerVariables) {
|
|||
return Bluebird.resolve();
|
||||
})
|
||||
.catch(ErrorReplies.replyWithError200(req, res, vars.logger,
|
||||
UserMessages.OPERATION_FAILED));
|
||||
UserMessages.AUTHENTICATION_TOTP_FAILED));
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ export default function (vars: ServerVariables) {
|
|||
return BluebirdPromise.resolve();
|
||||
})
|
||||
.catch(ErrorReplies.replyWithError200(req, res, vars.logger,
|
||||
UserMessages.OPERATION_FAILED));
|
||||
UserMessages.AUTHENTICATION_U2F_FAILED));
|
||||
}
|
||||
|
||||
return handler;
|
||||
|
|
|
@ -187,7 +187,7 @@ export const RESET_PASSWORD_FORM_POST = "/api/password-reset";
|
|||
*
|
||||
* @apiDescription Serve a page that requires the username.
|
||||
*/
|
||||
export const RESET_PASSWORD_REQUEST_GET = "/password-reset/request";
|
||||
export const RESET_PASSWORD_REQUEST_GET = "/api/password-reset/request";
|
||||
|
||||
|
||||
|
||||
|
@ -201,7 +201,7 @@ export const RESET_PASSWORD_REQUEST_GET = "/password-reset/request";
|
|||
*
|
||||
* @apiDescription Start password reset request.
|
||||
*/
|
||||
export const RESET_PASSWORD_IDENTITY_START_GET = "/password-reset/identity/start";
|
||||
export const RESET_PASSWORD_IDENTITY_START_GET = "/api/password-reset/identity/start";
|
||||
|
||||
|
||||
|
||||
|
@ -215,7 +215,7 @@ export const RESET_PASSWORD_IDENTITY_START_GET = "/password-reset/identity/start
|
|||
*
|
||||
* @apiDescription Start password reset request.
|
||||
*/
|
||||
export const RESET_PASSWORD_IDENTITY_FINISH_GET = "/password-reset/identity/finish";
|
||||
export const RESET_PASSWORD_IDENTITY_FINISH_GET = "/api/password-reset/identity/finish";
|
||||
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue