Skip to content

Commit 33d34df

Browse files
feat: Add Login UI MFA Flow
1 parent 5fc47a4 commit 33d34df

17 files changed

Lines changed: 1894 additions & 868 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"clean": "find . -name \"node_modules\" -type d -prune -exec rm -rf '{}' + && yarn",
99
"build-dev": "./node_modules/.bin/webpack --config webpack.dev.js",
1010
"build": "./node_modules/.bin/webpack --config webpack.prod.js",
11-
"serve": "webpack-dev-server --open --port=8888 --https --config webpack.dev.js",
11+
"serve": "webpack-dev-server --open --port=8888 --server-type https --config webpack.dev.js",
1212
"test": "jest --watch"
1313
},
1414
"devDependencies": {

resources/js/base_actions.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,40 @@ export const postRawRequest = (endpoint) => (params, headers = {}) => {
9292
})
9393
}
9494

95+
// Like postRawRequest, but also surfaces the final URL the browser landed on after the
96+
// XHR transparently followed any 3xx redirects (res.xhr.responseURL) and the HTTP status.
97+
// Used by flows that complete via a server-side redirect (e.g. 2FA verify) so the SPA can
98+
// navigate the top window to the post-login destination.
99+
export const postRawRequestFull = (endpoint) => (params, headers = {}) => {
100+
let url = URI(endpoint);
101+
102+
if (!isObjectEmpty(params))
103+
url = url.query(params);
104+
105+
let key = url.toString();
106+
107+
cancel(key);
108+
109+
let req = http.post(url.toString());
110+
111+
schedule(key, req);
112+
113+
return req.set(headers).send(params).timeout({
114+
response: 60000,
115+
deadline: 60000,
116+
}).then((res) => {
117+
end(key);
118+
return Promise.resolve({
119+
response: res.body,
120+
status: res.status,
121+
finalUrl: (res.xhr && res.xhr.responseURL) ? res.xhr.responseURL : null,
122+
});
123+
}).catch((error) => {
124+
end(key);
125+
return Promise.reject(error);
126+
})
127+
}
128+
95129
export const putRawRequest = (endpoint) => (payload = null, params={}, headers = {}) => {
96130
let url = URI(endpoint);
97131

resources/js/login/actions.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {postRawRequest} from '../base_actions'
1+
import {postRawRequest, postRawRequestFull } from '../base_actions'
2+
import { CAPTCHA_FIELD, FLOW } from './constants';
23

34
export const verifyAccount = (email, token) => {
45

@@ -27,3 +28,36 @@ export const resendVerificationEmail = (email, token) => {
2728

2829
return postRawRequest(window.RESEND_VERIFICATION_EMAIL_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
2930
}
31+
32+
// verify / recovery complete login via a server-side redirect, so use the *Full helper to
33+
// recover the final URL for top-window navigation. resend returns plain JSON, no redirect.
34+
export const verify2FA = (otpValue, method, trustDevice, token) => {
35+
const params = {
36+
otp_value: otpValue,
37+
method: method,
38+
trust_device: trustDevice ? 1 : 0
39+
};
40+
41+
return postRawRequestFull(window.VERIFY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
42+
}
43+
44+
export const resend2FA = (method, token) => {
45+
const params = {
46+
method: method
47+
};
48+
49+
return postRawRequestFull(window.RESEND_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
50+
}
51+
52+
export const verifyRecoveryCode = (recoveryCode, token) => {
53+
const params = {
54+
recovery_code: recoveryCode
55+
};
56+
57+
return postRawRequestFull(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
58+
}
59+
60+
export const authenticateWithPassword = (formData, token) => {
61+
const params = Object.fromEntries(formData.entries());
62+
return postRawRequestFull(window.FORM_ACTION_ENDPOINT)(params, {'X-CSRF-TOKEN': token});
63+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from "react";
2+
import Grid from "@material-ui/core/Grid";
3+
import Button from "@material-ui/core/Button";
4+
import styles from "../login.module.scss";
5+
6+
const EmailErrorActions = ({
7+
emitOtpAction,
8+
createAccountAction,
9+
onValidateEmail,
10+
disableInput,
11+
}) => {
12+
return (
13+
<Grid container spacing={1}>
14+
<Grid
15+
container
16+
item
17+
spacing={1}
18+
justifyContent="center"
19+
alignItems="center"
20+
>
21+
<Grid item>
22+
<Button
23+
variant="contained"
24+
onClick={emitOtpAction}
25+
type="button"
26+
className={styles.secondary_btn}
27+
color="primary"
28+
>
29+
Email me a one time use code
30+
</Button>
31+
</Grid>
32+
<Grid item>
33+
<Button
34+
variant="contained"
35+
href={createAccountAction}
36+
type="button"
37+
target="_self"
38+
className={styles.secondary_btn}
39+
color="primary"
40+
>
41+
Register and set a password
42+
</Button>
43+
</Grid>
44+
<Grid item>
45+
<Button
46+
variant="text"
47+
onClick={onValidateEmail}
48+
disabled={disableInput}
49+
className={styles.secondary_btn}
50+
color="primary"
51+
>
52+
Adjust email above and try again
53+
</Button>
54+
</Grid>
55+
</Grid>
56+
</Grid>
57+
);
58+
};
59+
60+
export default EmailErrorActions;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from "react";
2+
import Paper from "@material-ui/core/Paper";
3+
import TextField from "@material-ui/core/TextField";
4+
import Button from "@material-ui/core/Button";
5+
import styles from "../login.module.scss";
6+
7+
const EmailInputForm = ({
8+
value,
9+
onValidateEmail,
10+
onHandleUserNameChange,
11+
disableInput,
12+
emailError,
13+
}) => {
14+
return (
15+
<>
16+
<Paper
17+
elevation={0}
18+
component="form"
19+
target="_self"
20+
className={styles.paper_root}
21+
onSubmit={onValidateEmail}
22+
>
23+
<TextField
24+
id="email"
25+
name="email"
26+
value={value}
27+
autoComplete="email"
28+
variant="outlined"
29+
margin="normal"
30+
required
31+
fullWidth
32+
disabled={disableInput}
33+
label="Email Address"
34+
autoFocus={true}
35+
onChange={onHandleUserNameChange}
36+
error={emailError != ""}
37+
/>
38+
{emailError == "" && (
39+
<Button
40+
variant="contained"
41+
color="primary"
42+
title="Continue"
43+
className={styles.apply_button}
44+
disabled={disableInput}
45+
onClick={onValidateEmail}
46+
>
47+
&gt;
48+
</Button>
49+
)}
50+
</Paper>
51+
{emailError != "" && (
52+
<p
53+
className={styles.error_label}
54+
dangerouslySetInnerHTML={{ __html: emailError }}
55+
></p>
56+
)}
57+
</>
58+
);
59+
};
60+
61+
export default EmailInputForm;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React from "react";
2+
import Grid from "@material-ui/core/Grid";
3+
import Button from "@material-ui/core/Button";
4+
import Link from "@material-ui/core/Link";
5+
import styles from "../login.module.scss";
6+
7+
const ExistingAccountActions = ({
8+
emitOtpAction,
9+
forgotPasswordAction,
10+
userName,
11+
disableInput,
12+
}) => {
13+
let forgotPasswordActionHref = forgotPasswordAction;
14+
15+
if (userName) {
16+
forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`;
17+
}
18+
19+
return (
20+
<Grid container spacing={1} style={{ marginTop: "30px" }}>
21+
<Grid item xs={12}>
22+
<Button
23+
variant="contained"
24+
onClick={emitOtpAction}
25+
type="button"
26+
disabled={disableInput}
27+
className={styles.secondary_btn}
28+
color="primary"
29+
>
30+
Sign in by emailing me a single-use code
31+
</Button>
32+
</Grid>
33+
<Grid item xs={12}>
34+
<Link
35+
disabled={disableInput}
36+
href={forgotPasswordActionHref}
37+
target="_self"
38+
variant="body2"
39+
>
40+
Reset your password
41+
</Link>
42+
</Grid>
43+
</Grid>
44+
);
45+
};
46+
47+
export default ExistingAccountActions;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React, { useMemo } from "react";
2+
import Link from "@material-ui/core/Link";
3+
import styles from "../login.module.scss";
4+
5+
const HelpLinks = ({
6+
userName,
7+
showEmitOtpAction,
8+
forgotPasswordAction,
9+
showForgotPasswordAction,
10+
showVerifyEmailAction,
11+
verifyEmailAction,
12+
showHelpAction,
13+
helpAction,
14+
appName,
15+
emitOtpAction,
16+
}) => {
17+
const actions = useMemo(() => {
18+
let forgotPasswordActionHref = forgotPasswordAction;
19+
if (userName) {
20+
forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`;
21+
}
22+
23+
return [
24+
{
25+
show: showEmitOtpAction,
26+
href: "#",
27+
onClick: emitOtpAction,
28+
label: "Get A Single-use Code emailed to you",
29+
},
30+
{
31+
show: showForgotPasswordAction,
32+
href: forgotPasswordActionHref,
33+
label: "Reset your password",
34+
},
35+
{
36+
show: showVerifyEmailAction,
37+
href: verifyEmailAction,
38+
label: `Verify ${appName}`,
39+
},
40+
{
41+
show: showHelpAction,
42+
href: helpAction,
43+
label: "Having trouble?",
44+
},
45+
].filter((action) => action.show);
46+
}, [
47+
showEmitOtpAction,
48+
showForgotPasswordAction,
49+
showVerifyEmailAction,
50+
showHelpAction,
51+
userName,
52+
forgotPasswordAction,
53+
verifyEmailAction,
54+
helpAction,
55+
appName,
56+
emitOtpAction,
57+
]);
58+
59+
return (
60+
<>
61+
<hr className={styles.separator} />
62+
{actions.map((action, index) => (
63+
<Link
64+
key={index}
65+
href={action.href}
66+
onClick={action.onClick}
67+
variant="body2"
68+
target="_self"
69+
>
70+
{action.label}
71+
</Link>
72+
))}
73+
</>
74+
);
75+
};
76+
77+
export default HelpLinks;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React from "react";
2+
import Link from "@material-ui/core/Link";
3+
import styles from "../login.module.scss";
4+
5+
const OTPHelpLinks = ({ emitOtpAction }) => {
6+
return (
7+
<>
8+
<hr className={styles.separator} />
9+
<p className={styles.otp_p}>Didn't receive it ?</p>
10+
<p className={styles.otp_p}>
11+
Check your spam folder or{" "}
12+
<Link href="#" onClick={emitOtpAction} variant="body2" target="_self">
13+
resend email.
14+
</Link>
15+
</p>
16+
</>
17+
);
18+
};
19+
20+
export default OTPHelpLinks;

0 commit comments

Comments
 (0)