[FEATURE] Display TOTP secret on device registration (#1551)
* This change provides the TOTP secret which allows users to copy and utilise for password managers and other applications. * Hide TextField if secret isn't present * This ensure that the TextField is removed on a page or if there is no secret present. * Add multiple buttons and set default value to OTP URL * Remove inline icon and add icons under text field which allow copying of the secret key and the whole OTP URL. * Fix integration tests * Add notifications on click for secret buttons * Also remove autoFocus on TextField so a user can identify that the full OTP URL is in focus.pull/1566/head
parent
2763aefe81
commit
b12528a65c
|
@ -2,6 +2,7 @@ package suites
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
@ -16,8 +17,10 @@ func (wds *WebDriverSession) doRegisterTOTP(ctx context.Context, t *testing.T) s
|
|||
wds.verifyMailNotificationDisplayed(ctx, t)
|
||||
link := doGetLinkFromLastMail(t)
|
||||
wds.doVisit(t, link)
|
||||
secret, err := wds.WaitElementLocatedByID(ctx, t, "base32-secret").GetAttribute("value")
|
||||
secretURL, err := wds.WaitElementLocatedByID(ctx, t, "secret-url").GetAttribute("value")
|
||||
assert.NoError(t, err)
|
||||
|
||||
secret := secretURL[strings.LastIndex(secretURL, "=")+1:]
|
||||
assert.NotEqual(t, "", secret)
|
||||
assert.NotNil(t, secret)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useCallback, useState } from "react";
|
||||
import LoginLayout from "../../layouts/LoginLayout";
|
||||
import classnames from "classnames";
|
||||
import { makeStyles, Typography, Button, Link, CircularProgress } from "@material-ui/core";
|
||||
import { makeStyles, Typography, Button, IconButton, Link, CircularProgress, TextField } from "@material-ui/core";
|
||||
import QRCode from 'qrcode.react';
|
||||
import AppStoreBadges from "../../components/AppStoreBadges";
|
||||
import { GoogleAuthenticator } from "../../constants";
|
||||
|
@ -9,7 +9,7 @@ import { useHistory, useLocation } from "react-router";
|
|||
import { completeTOTPRegistrationProcess } from "../../services/RegisterDevice";
|
||||
import { useNotifications } from "../../hooks/NotificationsContext";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTimesCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { red } from "@material-ui/core/colors";
|
||||
import { extractIdentityToken } from "../../utils/IdentityToken";
|
||||
import { FirstFactorRoute } from "../../Routes";
|
||||
|
@ -22,7 +22,7 @@ const RegisterOneTimePassword = function () {
|
|||
// The secret retrieved from the API is all is ok.
|
||||
const [secretURL, setSecretURL] = useState("empty");
|
||||
const [secretBase32, setSecretBase32] = useState(undefined as string | undefined);
|
||||
const { createErrorNotification } = useNotifications();
|
||||
const { createSuccessNotification, createErrorNotification } = useNotifications();
|
||||
const [hasErrored, setHasErrored] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
@ -53,44 +53,67 @@ const RegisterOneTimePassword = function () {
|
|||
}, [processToken, createErrorNotification]);
|
||||
|
||||
useEffect(() => { completeRegistrationProcess() }, [completeRegistrationProcess]);
|
||||
function SecretButton(text: string | undefined, action: string, icon: IconDefinition) {
|
||||
return (
|
||||
<IconButton
|
||||
className={style.secretButtons}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${text}`);
|
||||
createSuccessNotification(`${action}`);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
const qrcodeFuzzyStyle = (isLoading || hasErrored) ? style.fuzzy : undefined
|
||||
|
||||
return (
|
||||
<LoginLayout title="Scan QRCode">
|
||||
<div className={style.root}>
|
||||
<div className={style.googleAuthenticator}>
|
||||
<Typography className={style.googleAuthenticatorText}>Need Google Authenticator?</Typography>
|
||||
<AppStoreBadges
|
||||
iconSize={128}
|
||||
targetBlank
|
||||
className={style.googleAuthenticatorBadges}
|
||||
googlePlayLink={GoogleAuthenticator.googlePlay}
|
||||
appleStoreLink={GoogleAuthenticator.appleStore} />
|
||||
</div>
|
||||
<div className={style.qrcodeContainer}>
|
||||
<Link href={secretURL}>
|
||||
<QRCode
|
||||
value={secretURL}
|
||||
className={classnames(qrcodeFuzzyStyle, style.qrcode)}
|
||||
size={256} />
|
||||
{!hasErrored && isLoading ? <CircularProgress className={style.loader} size={128} /> : null}
|
||||
{hasErrored ? <FontAwesomeIcon className={style.failureIcon} icon={faTimesCircle} /> : null}
|
||||
</Link>
|
||||
</div>
|
||||
{secretBase32
|
||||
? <input id="base32-secret"
|
||||
style={{ display: "none" }}
|
||||
value={secretBase32} /> : null}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={style.doneButton}
|
||||
onClick={handleDoneClick}
|
||||
disabled={isLoading}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</LoginLayout>
|
||||
<LoginLayout title="Scan QRCode">
|
||||
<div className={style.root}>
|
||||
<div className={style.googleAuthenticator}>
|
||||
<Typography className={style.googleAuthenticatorText}>Need Google Authenticator?</Typography>
|
||||
<AppStoreBadges
|
||||
iconSize={128}
|
||||
targetBlank
|
||||
className={style.googleAuthenticatorBadges}
|
||||
googlePlayLink={GoogleAuthenticator.googlePlay}
|
||||
appleStoreLink={GoogleAuthenticator.appleStore} />
|
||||
</div>
|
||||
<div className={style.qrcodeContainer}>
|
||||
<Link href={secretURL}>
|
||||
<QRCode
|
||||
value={secretURL}
|
||||
className={classnames(qrcodeFuzzyStyle, style.qrcode)}
|
||||
size={256} />
|
||||
{!hasErrored && isLoading ? <CircularProgress className={style.loader} size={128} /> : null}
|
||||
{hasErrored ? <FontAwesomeIcon className={style.failureIcon} icon={faTimesCircle} /> : null}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
{secretURL !== "empty"
|
||||
? <TextField
|
||||
id="secret-url"
|
||||
label="Secret"
|
||||
className={style.secret}
|
||||
value={secretURL}
|
||||
InputProps={{
|
||||
readOnly: true
|
||||
}} /> : null}
|
||||
{secretBase32 ? SecretButton(secretBase32, "OTP Secret copied to clipboard.", faKey) : null}
|
||||
{secretURL !== "empty" ? SecretButton(secretURL, "OTP URL copied to clipboard.", faCopy) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={style.doneButton}
|
||||
onClick={handleDoneClick}
|
||||
disabled={isLoading}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</LoginLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -109,14 +132,18 @@ const useStyles = makeStyles(theme => ({
|
|||
filter: "blur(10px)"
|
||||
},
|
||||
secret: {
|
||||
display: "inline-block",
|
||||
fontSize: theme.typography.fontSize * 0.9,
|
||||
marginTop: theme.spacing(1),
|
||||
marginBottom: theme.spacing(1),
|
||||
width: "256px",
|
||||
},
|
||||
googleAuthenticator: {},
|
||||
googleAuthenticatorText: {
|
||||
fontSize: theme.typography.fontSize * 0.8,
|
||||
},
|
||||
googleAuthenticatorBadges: {},
|
||||
secretButtons: {
|
||||
width: "128px",
|
||||
},
|
||||
doneButton: {
|
||||
width: "256px",
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue