Skip to content

Commit

Permalink
feat(web): caps lock detection
Browse files Browse the repository at this point in the history
Adds caps lock detection to the password field.

Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
  • Loading branch information
james-d-elliott committed Mar 4, 2024
1 parent 744b617 commit 61c30b3
Show file tree
Hide file tree
Showing 4 changed files with 197 additions and 119 deletions.
2 changes: 2 additions & 0 deletions internal/server/locales/en/portal.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
"The above application is requesting the following permissions": "The above application is requesting the following permissions",
"The One-Time Code identifier was not provided": "The One-Time Code identifier was not provided",
"The password does not meet the password policy": "The password does not meet the password policy",
"The password was entered with Caps Lock": "The password was entered with Caps Lock.",
"The password was partially entered with Caps Lock": "The password was partially entered with Caps Lock.",
"The resource you're attempting to access requires two-factor authentication": "The resource you're attempting to access requires two-factor authentication.",
"The Token was not provided": "The Token was not provided",
"There was a problem initiating the registration process": "There was a problem initiating the registration process",
Expand Down
12 changes: 12 additions & 0 deletions web/src/services/CapsLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from "react";

const safe = /^[0-9!@#$%^&*)(+=[{\]};:'",<.>/?\\|`~_-]$/i;

export function IsCapsLockModified(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key.length !== 1) return null;
if (event.ctrlKey || event.altKey || event.metaKey) return null;
if (event.key === " ") return null;
if (safe.test(event.key)) return null;

return event.getModifierState("CapsLock");
}
293 changes: 178 additions & 115 deletions web/src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
import React, { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";

import { Button, Checkbox, FormControlLabel, Grid, Link, Theme } from "@mui/material";
import { Alert, AlertTitle, Button, Checkbox, FormControl, FormControlLabel, Grid, Link, Theme } from "@mui/material";
import TextField from "@mui/material/TextField";
import makeStyles from "@mui/styles/makeStyles";
import { BroadcastChannel } from "broadcast-channel";
Expand All @@ -14,6 +14,7 @@ import { useNotifications } from "@hooks/NotificationsContext";
import { useQueryParam } from "@hooks/QueryParam";
import { useWorkflow } from "@hooks/Workflow";
import LoginLayout from "@layouts/LoginLayout";
import { IsCapsLockModified } from "@services/CapsLock.ts";
import { postFirstFactor } from "@services/FirstFactor";

export interface Props {
Expand Down Expand Up @@ -44,9 +45,10 @@ const FirstFactorForm = function (props: Props) {
const [username, setUsername] = useState("");
const [usernameError, setUsernameError] = useState(false);
const [password, setPassword] = useState("");
const [passwordCapsLock, setPasswordCapsLock] = useState(false);
const [passwordCapsLockPartial, setPasswordCapsLockPartial] = useState(false);
const [passwordError, setPasswordError] = useState(false);

// TODO (PR: #806, Issue: #511) potentially refactor
const usernameRef = useRef() as MutableRefObject<HTMLInputElement>;
const passwordRef = useRef() as MutableRefObject<HTMLInputElement>;

Expand All @@ -71,7 +73,7 @@ const FirstFactorForm = function (props: Props) {
setRememberMe(!rememberMe);
};

const handleSignIn = async () => {
const handleSignIn = useCallback(async () => {
if (username === "" || password === "") {
if (username === "") {
setUsernameError(true);
Expand All @@ -95,7 +97,18 @@ const FirstFactorForm = function (props: Props) {
setPassword("");
passwordRef.current.focus();
}
};
}, [
createErrorNotification,
loginChannel,
password,
props,
redirectionURL,
rememberMe,
requestMethod,
translate,
username,
workflow,
]);

const handleResetPasswordClick = () => {
if (props.resetPassword) {
Expand All @@ -107,122 +120,172 @@ const FirstFactorForm = function (props: Props) {
}
};

const handleUsernameKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter") {
if (!username.length) {
setUsernameError(true);
} else if (username.length && password.length) {
handleSignIn().catch(console.error);
} else {
setUsernameError(false);
passwordRef.current.focus();
}
}
},
[handleSignIn, password.length, username.length],
);

const handlePasswordKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter") {
if (!username.length) {
usernameRef.current.focus();
} else if (!password.length) {
passwordRef.current.focus();
}
handleSignIn().catch(console.error);
event.preventDefault();
}
},
[handleSignIn, password.length, username.length],
);

const handlePasswordKeyUp = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (password.length <= 1) {
setPasswordCapsLock(false);
setPasswordCapsLockPartial(false);

if (password.length === 0) {
return;
}
}

const modified = IsCapsLockModified(event);

if (modified === null) return;

if (modified) {
setPasswordCapsLock(true);
} else {
setPasswordCapsLockPartial(true);
}
},
[password.length],
);

const handleRememberMeKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "Enter") {
if (!username.length) {
usernameRef.current.focus();
} else if (!password.length) {
passwordRef.current.focus();
}
handleSignIn().catch(console.error);
}
},
[handleSignIn, password.length, username.length],
);

return (
<LoginLayout id="first-factor-stage" title={translate("Sign in")} showBrand>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
// TODO (PR: #806, Issue: #511) potentially refactor
inputRef={usernameRef}
id="username-textfield"
label={translate("Username")}
variant="outlined"
required
value={username}
error={usernameError}
disabled={disabled}
fullWidth
onChange={(v) => setUsername(v.target.value)}
onFocus={() => setUsernameError(false)}
autoCapitalize="none"
autoComplete="username"
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
setUsernameError(true);
} else if (username.length && password.length) {
handleSignIn();
} else {
setUsernameError(false);
passwordRef.current.focus();
}
}
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
// TODO (PR: #806, Issue: #511) potentially refactor
inputRef={passwordRef}
id="password-textfield"
label={translate("Password")}
variant="outlined"
required
fullWidth
disabled={disabled}
value={password}
error={passwordError}
onChange={(v) => setPassword(v.target.value)}
onFocus={() => setPasswordError(false)}
type="password"
autoComplete="current-password"
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
usernameRef.current.focus();
} else if (!password.length) {
passwordRef.current.focus();
}
handleSignIn();
ev.preventDefault();
}
}}
/>
</Grid>
{props.rememberMe ? (
<Grid item xs={12} className={classnames(styles.actionRow)}>
<FormControlLabel
control={
<Checkbox
id="remember-checkbox"
disabled={disabled}
checked={rememberMe}
onChange={handleRememberMeChange}
onKeyDown={(ev) => {
if (ev.key === "Enter") {
if (!username.length) {
usernameRef.current.focus();
} else if (!password.length) {
passwordRef.current.focus();
}
handleSignIn();
}
}}
value="rememberMe"
color="primary"
/>
}
className={styles.rememberMe}
label={translate("Remember me")}
<FormControl id={"form-login"}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
inputRef={usernameRef}
id="username-textfield"
label={translate("Username")}
variant="outlined"
required
value={username}
error={usernameError}
disabled={disabled}
fullWidth
onChange={(v) => setUsername(v.target.value)}
onFocus={() => setUsernameError(false)}
autoCapitalize="none"
autoComplete="username"
onKeyDown={handleUsernameKeyDown}
/>
</Grid>
) : null}
<Grid item xs={12}>
<Button
id="sign-in-button"
variant="contained"
color="primary"
fullWidth
disabled={disabled}
onClick={handleSignIn}
>
{translate("Sign in")}
</Button>
</Grid>
{props.resetPassword ? (
<Grid item xs={12} className={classnames(styles.actionRow, styles.flexEnd)}>
<Link
id="reset-password-button"
component="button"
onClick={handleResetPasswordClick}
className={styles.resetLink}
underline="hover"
<Grid item xs={12}>
<TextField
inputRef={passwordRef}
id="password-textfield"
label={translate("Password")}
variant="outlined"
required
fullWidth
disabled={disabled}
value={password}
error={passwordError}
onChange={(v) => setPassword(v.target.value)}
onFocus={() => setPasswordError(false)}
type="password"
autoComplete="current-password"
onKeyDown={handlePasswordKeyDown}
onKeyUp={handlePasswordKeyUp}
/>
</Grid>
{passwordCapsLock ? (
<Grid item xs={12} marginX={2}>
<Alert severity={"warning"}>
<AlertTitle>{translate("Warning")}</AlertTitle>
{passwordCapsLockPartial
? translate("The password was partially entered with Caps Lock")
: translate("The password was entered with Caps Lock")}
</Alert>
</Grid>
) : null}
{props.rememberMe ? (
<Grid item xs={12} className={classnames(styles.actionRow)}>
<FormControlLabel
control={
<Checkbox
id="remember-checkbox"
disabled={disabled}
checked={rememberMe}
onChange={handleRememberMeChange}
onKeyDown={handleRememberMeKeyDown}
value="rememberMe"
color="primary"
/>
}
className={styles.rememberMe}
label={translate("Remember me")}
/>
</Grid>
) : null}
<Grid item xs={12}>
<Button
id="sign-in-button"
variant="contained"
color="primary"
fullWidth
disabled={disabled}
onClick={handleSignIn}
>
{translate("Reset password?")}
</Link>
{translate("Sign in")}
</Button>
</Grid>
) : null}
</Grid>
{props.resetPassword ? (
<Grid item xs={12} className={classnames(styles.actionRow, styles.flexEnd)}>
<Link
id="reset-password-button"
component="button"
onClick={handleResetPasswordClick}
className={styles.resetLink}
underline="hover"
>
{translate("Reset password?")}
</Link>
</Grid>
) : null}
</Grid>
</FormControl>
</LoginLayout>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,23 +87,24 @@ const WebAuthnCredentialRegisterDialog = function (props: Props) {

switch (response.status) {
case AttestationResult.Success:
handleClose();
break;
case AttestationResult.Failure:
createErrorNotification(response.message);
break;
}

return;
} else {
createErrorNotification(AttestationResultFailureString(resultCredentialCreation.result));
setState(WebAuthnTouchState.Failure);
}

createErrorNotification(AttestationResultFailureString(resultCredentialCreation.result));
setState(WebAuthnTouchState.Failure);
} catch (err) {
console.error(err);
createErrorNotification(
"Failed to register your credential. The identity verification process might have timed out.",
);
} finally {
handleClose();
}
}, [props.open, options, createErrorNotification, handleClose]);

Expand Down

0 comments on commit 61c30b3

Please sign in to comment.