diff options
Diffstat (limited to 'web/src/views/Settings')
| -rw-r--r-- | web/src/views/Settings/Common/IdentityVerificationDialog.tsx | 17 | ||||
| -rw-r--r-- | web/src/views/Settings/Security/ChangePasswordDialog.tsx | 306 | ||||
| -rw-r--r-- | web/src/views/Settings/Security/SecurityView.tsx | 244 | ||||
| -rw-r--r-- | web/src/views/Settings/SettingsRouter.tsx | 4 |
4 files changed, 570 insertions, 1 deletions
diff --git a/web/src/views/Settings/Common/IdentityVerificationDialog.tsx b/web/src/views/Settings/Common/IdentityVerificationDialog.tsx index b9b12f942..7eaa4f129 100644 --- a/web/src/views/Settings/Common/IdentityVerificationDialog.tsx +++ b/web/src/views/Settings/Common/IdentityVerificationDialog.tsx @@ -131,6 +131,22 @@ const IdentityVerificationDialog = function (props: Props) { } }, [codeInput, handleFailure, handleSuccess]); + const handleSubmitKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "Enter") { + if (!codeInput.length) { + setCodeError(true); + } else if (codeInput.length) { + handleSubmit(); + } else { + setCodeError(false); + codeRef.current?.focus(); + } + } + }, + [codeInput.length, handleSubmit], + ); + useEffect(() => { if (closing || !props.opening || !props.elevation) { return; @@ -190,6 +206,7 @@ const IdentityVerificationDialog = function (props: Props) { error={codeError} disabled={loading} inputRef={codeRef} + onKeyDown={handleSubmitKeyDown} /> </Box> </DialogContent> diff --git a/web/src/views/Settings/Security/ChangePasswordDialog.tsx b/web/src/views/Settings/Security/ChangePasswordDialog.tsx new file mode 100644 index 000000000..4bed82e67 --- /dev/null +++ b/web/src/views/Settings/Security/ChangePasswordDialog.tsx @@ -0,0 +1,306 @@ +import React, { MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; + +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + Grid2, + TextField, +} from "@mui/material"; +import axios from "axios"; +import { useTranslation } from "react-i18next"; + +import PasswordMeter from "@components/PasswordMeter"; +import useCheckCapsLock from "@hooks/CapsLock"; +import { useNotifications } from "@hooks/NotificationsContext"; +import { PasswordPolicyConfiguration, PasswordPolicyMode } from "@models/PasswordPolicy"; +import { postPasswordChange } from "@services/ChangePassword"; +import { getPasswordPolicyConfiguration } from "@services/PasswordPolicyConfiguration"; + +interface Props { + username: string; + disabled?: boolean; + open: boolean; + setClosed: () => void; +} + +const ChangePasswordDialog = (props: Props) => { + const { t: translate } = useTranslation("settings"); + + const { createSuccessNotification, createErrorNotification } = useNotifications(); + + const [loading, setLoading] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [oldPasswordError, setOldPasswordError] = useState(false); + const [newPassword, setNewPassword] = useState(""); + const [newPasswordError, setNewPasswordError] = useState(false); + const [repeatNewPassword, setRepeatNewPassword] = useState(""); + const [repeatNewPasswordError, setRepeatNewPasswordError] = useState(false); + const [isCapsLockOnOldPW, setIsCapsLockOnOldPW] = useState(false); + const [isCapsLockOnNewPW, setIsCapsLockOnNewPW] = useState(false); + const [isCapsLockOnRepeatNewPW, setIsCapsLockOnRepeatNewPW] = useState(false); + + const oldPasswordRef = useRef() as MutableRefObject<HTMLInputElement>; + const newPasswordRef = useRef() as MutableRefObject<HTMLInputElement>; + const repeatNewPasswordRef = useRef() as MutableRefObject<HTMLInputElement>; + + const [pPolicy, setPPolicy] = useState<PasswordPolicyConfiguration>({ + max_length: 0, + min_length: 8, + min_score: 0, + require_lowercase: false, + require_number: false, + require_special: false, + require_uppercase: false, + mode: PasswordPolicyMode.Disabled, + }); + + const resetPasswordErrors = useCallback(() => { + setOldPasswordError(false); + setNewPasswordError(false); + setRepeatNewPasswordError(false); + }, []); + + const resetCapsLockErrors = useCallback(() => { + setIsCapsLockOnOldPW(false); + setIsCapsLockOnNewPW(false); + setIsCapsLockOnRepeatNewPW(false); + }, []); + + const resetStates = useCallback(() => { + setOldPassword(""); + setNewPassword(""); + setRepeatNewPassword(""); + + resetPasswordErrors(); + resetCapsLockErrors(); + + setLoading(false); + }, [resetPasswordErrors, resetCapsLockErrors]); + + const handleClose = useCallback(() => { + props.setClosed(); + resetStates(); + }, [props, resetStates]); + + const asyncProcess = useCallback(async () => { + try { + setLoading(true); + const policy = await getPasswordPolicyConfiguration(); + setPPolicy(policy); + setLoading(false); + } catch { + createErrorNotification( + translate("There was an issue completing the process the verification token might have expired"), + ); + setLoading(true); + } + }, [createErrorNotification, translate]); + + useEffect(() => { + asyncProcess(); + }, [asyncProcess]); + + const handlePasswordChange = useCallback(async () => { + setLoading(true); + if (oldPassword.trim() === "" || newPassword.trim() === "" || repeatNewPassword.trim() === "") { + if (oldPassword.trim() === "") { + setOldPasswordError(true); + } + if (newPassword.trim() === "") { + setNewPasswordError(true); + } + if (repeatNewPassword.trim() === "") { + setRepeatNewPasswordError(true); + } + setLoading(false); + return; + } + if (newPassword !== repeatNewPassword) { + setNewPasswordError(true); + setRepeatNewPasswordError(true); + createErrorNotification(translate("Passwords do not match")); + setLoading(false); + return; + } + + try { + await postPasswordChange(props.username, oldPassword, newPassword); + createSuccessNotification(translate("Password changed successfully")); + handleClose(); + } catch (err) { + resetPasswordErrors(); + setLoading(false); + if (axios.isAxiosError(err) && err.response) { + switch (err.response.status) { + case 400: // Bad Request - Weak Password + setNewPasswordError(true); + setRepeatNewPasswordError(true); + createErrorNotification( + translate("Your supplied password does not meet the password policy requirements"), + ); + break; + + case 401: // Unauthorized - Incorrect Password + setOldPasswordError(true); + createErrorNotification(translate("Incorrect password")); + break; + + case 500: // Internal Server Error + default: + createErrorNotification( + translate("There was an issue changing the {{item}}", { item: translate("password") }), + ); + break; + } + } else { + // Handle non-axios errors + createErrorNotification( + translate("There was an issue changing the {{item}}", { item: translate("password") }), + ); + } + return; + } + }, [ + createErrorNotification, + createSuccessNotification, + resetPasswordErrors, + handleClose, + newPassword, + oldPassword, + repeatNewPassword, + props.username, + translate, + ]); + + const useHandleKeyDown = ( + passwordState: string, + setError: React.Dispatch<React.SetStateAction<boolean>>, + nextRef?: React.MutableRefObject<HTMLInputElement>, + ) => { + return useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "Enter") { + if (!passwordState.length) { + setError(true); + } else if (!nextRef) { + handlePasswordChange().catch(console.error); + } else { + nextRef?.current.focus(); + } + } + }, + [nextRef, passwordState.length, setError], + ); + }; + + const handleOldPWKeyDown = useHandleKeyDown(oldPassword, setOldPasswordError, newPasswordRef); + const handleNewPWKeyDown = useHandleKeyDown(newPassword, setNewPasswordError, repeatNewPasswordRef); + const handleRepeatNewPWKeyDown = useHandleKeyDown(repeatNewPassword, setRepeatNewPasswordError); + + const disabled = props.disabled || false; + + return ( + <Dialog open={props.open} maxWidth="xs"> + <DialogTitle>{translate("Change {{item}}", { item: translate("Password") })}</DialogTitle> + <DialogContent> + <FormControl id={"change-password-form"} disabled={loading}> + <Grid2 container spacing={1} alignItems={"center"} justifyContent={"center"} textAlign={"center"}> + <Grid2 size={{ xs: 12 }} sx={{ pt: 3 }}> + <TextField + inputRef={oldPasswordRef} + id="old-password" + label={translate("Old Password")} + variant="outlined" + required + value={oldPassword} + error={oldPasswordError} + disabled={disabled} + fullWidth + onChange={(v) => setOldPassword(v.target.value)} + onFocus={() => setOldPasswordError(false)} + type="password" + autoCapitalize="off" + autoComplete="off" + onKeyDown={handleOldPWKeyDown} + onKeyUp={useCheckCapsLock(setIsCapsLockOnOldPW)} + helperText={isCapsLockOnOldPW ? translate("Caps Lock is on") : " "} + color={isCapsLockOnOldPW ? "error" : "primary"} + onBlur={() => setIsCapsLockOnOldPW(false)} + /> + </Grid2> + <Grid2 size={{ xs: 12 }} sx={{ mt: 3 }}> + <TextField + inputRef={newPasswordRef} + id="new-password" + label={translate("New Password")} + variant="outlined" + required + fullWidth + disabled={disabled} + value={newPassword} + error={newPasswordError} + onChange={(v) => setNewPassword(v.target.value)} + onFocus={() => setNewPasswordError(false)} + type="password" + autoCapitalize="off" + autoComplete="off" + onKeyDown={handleNewPWKeyDown} + onKeyUp={useCheckCapsLock(setIsCapsLockOnNewPW)} + helperText={isCapsLockOnNewPW ? translate("Caps Lock is on") : " "} + color={isCapsLockOnNewPW ? "error" : "primary"} + onBlur={() => setIsCapsLockOnNewPW(false)} + /> + {pPolicy.mode === PasswordPolicyMode.Disabled ? null : ( + <PasswordMeter value={newPassword} policy={pPolicy} /> + )} + </Grid2> + <Grid2 size={{ xs: 12 }}> + <TextField + inputRef={repeatNewPasswordRef} + id="repeat-new-password" + label={translate("Repeat New Password")} + variant="outlined" + required + fullWidth + disabled={disabled} + value={repeatNewPassword} + error={repeatNewPasswordError} + onChange={(v) => setRepeatNewPassword(v.target.value)} + onFocus={() => setRepeatNewPasswordError(false)} + type="password" + autoCapitalize="off" + autoComplete="off" + onKeyDown={handleRepeatNewPWKeyDown} + onKeyUp={useCheckCapsLock(setIsCapsLockOnRepeatNewPW)} + helperText={isCapsLockOnRepeatNewPW ? translate("Caps Lock is on") : " "} + color={isCapsLockOnRepeatNewPW ? "error" : "primary"} + onBlur={() => setIsCapsLockOnRepeatNewPW(false)} + /> + </Grid2> + </Grid2> + </FormControl> + </DialogContent> + <DialogActions> + <Button id={"password-change-dialog-cancel"} color={"error"} onClick={handleClose}> + {translate("Cancel")} + </Button> + <Button + id={"password-change-dialog-submit"} + color={"primary"} + onClick={handlePasswordChange} + disabled={!(oldPassword.length && newPassword.length && repeatNewPassword.length) || loading} + startIcon={loading ? <CircularProgress color="inherit" size={20} /> : <></>} + > + {translate("Submit")} + </Button> + </DialogActions> + </Dialog> + ); +}; + +export default ChangePasswordDialog; diff --git a/web/src/views/Settings/Security/SecurityView.tsx b/web/src/views/Settings/Security/SecurityView.tsx new file mode 100644 index 000000000..6761591fa --- /dev/null +++ b/web/src/views/Settings/Security/SecurityView.tsx @@ -0,0 +1,244 @@ +import { Fragment, useCallback, useEffect, useState } from "react"; + +import { Box, Button, Container, List, ListItem, Paper, Stack, Tooltip, Typography, useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import { useConfiguration } from "@hooks/Configuration"; +import { useNotifications } from "@hooks/NotificationsContext"; +import { useUserInfoGET } from "@hooks/UserInfo"; +import { UserSessionElevation, getUserSessionElevation } from "@services/UserSessionElevation"; +import IdentityVerificationDialog from "@views/Settings/Common/IdentityVerificationDialog"; +import SecondFactorDialog from "@views/Settings/Common/SecondFactorDialog"; +import ChangePasswordDialog from "@views/Settings/Security/ChangePasswordDialog"; + +const SettingsView = function () { + const { t: translate } = useTranslation("settings"); + const theme = useTheme(); + const { createErrorNotification } = useNotifications(); + + const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET(); + const [elevation, setElevation] = useState<UserSessionElevation>(); + const [dialogSFOpening, setDialogSFOpening] = useState(false); + const [dialogIVOpening, setDialogIVOpening] = useState(false); + const [dialogPWChangeOpen, setDialogPWChangeOpen] = useState(false); + const [dialogPWChangeOpening, setDialogPWChangeOpening] = useState(false); + const [configuration, fetchConfiguration, , fetchConfigurationError] = useConfiguration(); + + const handleResetStateOpening = () => { + setDialogSFOpening(false); + setDialogIVOpening(false); + setDialogPWChangeOpening(false); + }; + + const handleResetState = useCallback(() => { + handleResetStateOpening(); + + setElevation(undefined); + setDialogPWChangeOpen(false); + }, []); + + const handleOpenChangePWDialog = useCallback(() => { + handleResetStateOpening(); + setDialogPWChangeOpen(true); + }, []); + + const handleSFDialogClosed = (ok: boolean, changed: boolean) => { + if (!ok) { + console.warn("Second Factor dialog close callback failed, it was likely cancelled by the user."); + + handleResetState(); + + return; + } + + if (changed) { + handleElevationRefresh() + .catch(console.error) + .then(() => { + setDialogIVOpening(true); + }); + } else { + setDialogIVOpening(true); + } + }; + + const handleSFDialogOpened = () => { + setDialogSFOpening(false); + }; + + const handleIVDialogClosed = useCallback( + (ok: boolean) => { + if (!ok) { + console.warn( + "Identity Verification dialog close callback failed, it was likely cancelled by the user.", + ); + + handleResetState(); + + return; + } + + setElevation(undefined); + if (dialogPWChangeOpening) { + handleOpenChangePWDialog(); + } + }, + [dialogPWChangeOpening, handleOpenChangePWDialog, handleResetState], + ); + + const handleIVDialogOpened = () => { + setDialogIVOpening(false); + }; + + const handleElevationRefresh = async () => { + try { + const result = await getUserSessionElevation(); + setElevation(result); + } catch { + createErrorNotification(translate("Failed to get session elevation status")); + } + }; + + const handleElevation = () => { + handleElevationRefresh().catch(console.error); + + setDialogSFOpening(true); + }; + + const handleChangePassword = () => { + setDialogPWChangeOpening(true); + + handleElevation(); + }; + + useEffect(() => { + if (fetchUserInfoError) { + createErrorNotification(translate("There was an issue retrieving user preferences")); + } + if (fetchConfigurationError) { + createErrorNotification(translate("There was an issue retrieving configuration")); + } + }, [fetchUserInfoError, fetchConfigurationError, createErrorNotification, translate]); + + useEffect(() => { + fetchUserInfo(); + fetchConfiguration(); + }, [fetchUserInfo, fetchConfiguration]); + + const PasswordChangeButton = () => { + const buttonContent = ( + <Button + id="change-password-button" + variant="contained" + sx={{ p: 1, width: "100%" }} + onClick={handleChangePassword} + disabled={configuration?.password_change_disabled || false} + > + {translate("Change Password")} + </Button> + ); + + return configuration?.password_change_disabled ? ( + <Tooltip title={translate("This is disabled by your administrator.")}> + <span>{buttonContent}</span> + </Tooltip> + ) : ( + buttonContent + ); + }; + + return ( + <Fragment> + <SecondFactorDialog + info={userInfo} + elevation={elevation} + opening={dialogSFOpening} + handleClosed={handleSFDialogClosed} + handleOpened={handleSFDialogOpened} + /> + <IdentityVerificationDialog + opening={dialogIVOpening} + elevation={elevation} + handleClosed={handleIVDialogClosed} + handleOpened={handleIVDialogOpened} + /> + <ChangePasswordDialog + username={userInfo?.display_name || ""} + open={dialogPWChangeOpen} + setClosed={() => { + handleResetState(); + }} + /> + + <Container + sx={{ + display: "flex", + justifyContent: "center", + alignItems: "flex-start", + height: "100vh", + pt: 8, + }} + > + <Paper + variant="outlined" + sx={{ + display: "flex", + justifyContent: "center", + alignItems: "center", + height: "auto", + }} + > + <Stack spacing={2} sx={{ m: 2, width: "100%" }}> + <Box sx={{ p: { xs: 1, md: 3 } }}> + <Box + sx={{ + width: "100%", + p: 1.25, + mb: 1, + border: `1px solid ${theme.palette.grey[600]}`, + borderRadius: 1, + }} + > + <Box display="flex" alignItems="center"> + <Typography sx={{ mr: 1 }}>{translate("Email")}:</Typography> + <Typography>{userInfo?.emails?.[0] || ""}</Typography> + </Box> + {userInfo?.emails && userInfo.emails.length > 1 && ( + <List sx={{ width: "100%", padding: 0, pl: 4 }}> + {" "} + {userInfo.emails.slice(1).map((email: string, index: number) => ( + <ListItem key={index} sx={{ paddingTop: 0, paddingBottom: 0 }}> + <Typography>{email}</Typography> + </ListItem> + ))} + </List> + )} + </Box> + <Box + sx={{ + width: "100%", + p: 1.25, + mb: 1, + border: `1px solid ${theme.palette.grey[600]}`, + borderRadius: 1, + }} + > + <Typography> + {translate("Username")}: {userInfo?.display_name || ""} + </Typography> + </Box> + <Box + sx={{ p: 1.25, mb: 1, border: `1px solid ${theme.palette.grey[600]}`, borderRadius: 1 }} + > + <Typography>{translate("Password")}: ●●●●●●●●</Typography> + </Box> + <PasswordChangeButton /> + </Box> + </Stack> + </Paper> + </Container> + </Fragment> + ); +}; + +export default SettingsView; diff --git a/web/src/views/Settings/SettingsRouter.tsx b/web/src/views/Settings/SettingsRouter.tsx index ba8fdf469..601d08a8e 100644 --- a/web/src/views/Settings/SettingsRouter.tsx +++ b/web/src/views/Settings/SettingsRouter.tsx @@ -2,11 +2,12 @@ import React, { useEffect } from "react"; import { Route, Routes } from "react-router-dom"; -import { IndexRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; +import { IndexRoute, SecuritySubRoute, SettingsTwoFactorAuthenticationSubRoute } from "@constants/Routes"; import { useRouterNavigate } from "@hooks/RouterNavigate"; import { useAutheliaState } from "@hooks/State"; import SettingsLayout from "@layouts/SettingsLayout"; import { AuthenticationLevel } from "@services/State"; +import SecurityView from "@views/Settings/Security/SecurityView"; import SettingsView from "@views/Settings/SettingsView"; import TwoFactorAuthenticationView from "@views/Settings/TwoFactorAuthentication/TwoFactorAuthenticationView"; @@ -30,6 +31,7 @@ const SettingsRouter = function (props: Props) { <SettingsLayout> <Routes> <Route path={IndexRoute} element={<SettingsView />} /> + <Route path={SecuritySubRoute} element={<SecurityView />} /> <Route path={SettingsTwoFactorAuthenticationSubRoute} element={<TwoFactorAuthenticationView />} /> </Routes> </SettingsLayout> |
