summaryrefslogtreecommitdiff
path: root/web/src/views/Settings/Security
diff options
context:
space:
mode:
Diffstat (limited to 'web/src/views/Settings/Security')
-rw-r--r--web/src/views/Settings/Security/ChangePasswordDialog.tsx306
-rw-r--r--web/src/views/Settings/Security/SecurityView.tsx244
2 files changed, 550 insertions, 0 deletions
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;