@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
622 lines (620 loc) • 20.4 kB
JavaScript
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { makeStyles } from 'tss-react/mui';
import {
sendPasswordRecovery,
setPassword as setPasswordService,
validatePasswordResetToken
} from '../../services/auth';
import { isBlank, unescapeHTML } from '../../utils/string';
import Typography from '@mui/material/Typography';
import LogInForm from '../LoginForm/LoginForm';
import MenuItem from '@mui/material/MenuItem';
import { fetchProductLanguages } from '../../services/configuration';
import WarningRounded from '@mui/icons-material/WarningRounded';
import queryString from 'query-string';
import TextField from '@mui/material/TextField';
import Snackbar from '@mui/material/Snackbar';
import PasswordTextField from '../PasswordTextField/PasswordTextField';
import { passwordRequirementMessages } from '../../env/i18n-legacy';
import { filter } from 'rxjs/operators';
import palette from '../../styles/palette';
import { buildStoredLanguageKey, dispatchLanguageChange, getCurrentLocale, setStoredLanguage } from '../../utils/i18n';
import CrafterCMSLogo from '../../icons/CrafterCMSLogo';
import LanguageRounded from '@mui/icons-material/LanguageRounded';
import Menu from '@mui/material/Menu';
import { useMount } from '../../hooks/useMount';
import { useDebouncedInput } from '../../hooks/useDebouncedInput';
import { PasswordStrengthDisplayPopper } from '../PasswordStrengthDisplayPopper';
import { USER_USERNAME_MAX_LENGTH } from '../UserManagement/utils';
import useTimer from '../../hooks/useTimer';
import { nnou } from '../../utils/object';
import moment from 'moment-timezone';
const translations = defineMessages({
loginDialogTitle: {
id: 'loginView.dialogTitleText',
defaultMessage: 'Login to CrafterCMS'
},
incorrectCredentialsMessage: {
id: 'loginView.incorrectCredentialsMessage',
defaultMessage: 'Incorrect username or password. Please try again.'
},
languageDropDownLabel: {
id: 'words.language',
defaultMessage: 'Language'
},
recoverYourPasswordViewTitle: {
id: 'loginView.recoverYourPasswordIntroText',
defaultMessage: 'If your username exists, an email will be sent to you with a reset link.'
},
recoverYourPasswordSuccessMessage: {
id: 'loginView.recoverYourPasswordSuccessMessage',
defaultMessage: 'If the user exists, a recovery email has been sent'
},
resetPasswordFieldPlaceholderLabel: {
id: 'resetView.resetPasswordFieldPlaceholderLabel',
defaultMessage: 'New Password'
},
resetPasswordConfirmFieldPlaceholderLabel: {
id: 'resetView.resetPasswordConfirmFieldPlaceholderLabel',
defaultMessage: 'Confirm Password'
},
resetPasswordSuccess: {
id: 'resetView.resetPasswordSuccess',
defaultMessage: 'Password successfully reset. Please login with your new password.'
},
resetPasswordError: {
id: 'resetView.resetPasswordError',
defaultMessage: 'Error resetting password. Token may be invalid or expired.'
},
resetPasswordInvalidToken: {
id: 'resetView.resetPasswordInvalidToken',
defaultMessage: 'Token validation failed.'
},
lockedAccountTryAgain: {
defaultMessage: 'Try again {fullTime, select, true {{time}} other {in {time} seconds}}'
}
});
const useStyles = makeStyles()((theme) => ({
dialogRoot: {
transition: 'all 600ms ease',
'& .MuiInput-input': { backgroundColor: theme.palette.background.paper },
'& .MuiFormControl-root, & .MuiButton-root': {
marginBottom: theme.spacing(1),
'&.last-before-button': { marginBottom: theme.spacing(2) }
}
},
dialogRootFetching: {
opacity: 0.2
},
dialogPaper: {
minWidth: 300,
overflow: 'visible',
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, .8)' : 'rgba(255, 255, 255, .8)'
},
logo: {
maxWidth: 250,
display: 'block',
margin: `${theme.spacing(2)} auto ${theme.spacing(1)}`
},
recoverInfoMessage: {
maxWidth: 300,
textAlign: 'center',
margin: `0 auto ${theme.spacing(1.5)}`
},
errorMessage: {
backgroundColor: palette.red.tint,
color: palette.white,
marginBottom: theme.spacing(1),
padding: theme.spacing(1),
borderRadius: theme.spacing(1),
border: `1px solid ${palette.red.main}`,
display: 'flex',
placeContent: 'center',
lineHeight: 1.7,
'& .MuiSvgIcon-root': {
marginRight: theme.spacing(0.5),
color: palette.white
}
},
resetPassword: {
marginBottom: 10
}
}));
const retrieveStoredLangPreferences = () =>
Object.keys(window.localStorage).filter((key) => key.includes('_crafterStudioLanguage'));
function LoginView(props) {
const {
children,
isFetching,
onSubmit,
classes,
setLanguage,
onRecover,
formatMessage,
xsrfParamName,
xsrfToken,
language,
lockedErrorMessage,
lockedTimeSeconds
} = props;
const [username, setUsername] = useState(() => localStorage.getItem('username') ?? '');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const lockedTimer = useTimer(lockedTimeSeconds);
const username$ = useDebouncedInput(
useCallback(
(user) => {
const key = buildStoredLanguageKey(user);
if (retrieveStoredLangPreferences().includes(key)) {
setLanguage(window.localStorage.getItem(key));
}
},
[setLanguage]
),
200
);
useMount(() => {
username$.next(username);
});
const qsError = queryString.parse(window.location.search).error;
useEffect(() => {
if (lockedErrorMessage && nnou(lockedTimer)) {
if (lockedTimer === 0) {
setError(null);
} else {
setError(
`${unescapeHTML(lockedErrorMessage)}. ${formatMessage(translations.lockedAccountTryAgain, {
fullTime: lockedTimer > 60,
time: lockedTimer > 60 ? moment().add(lockedTimer, 'seconds').fromNow() : lockedTimer
})}`
);
}
} else if (qsError) {
setError(formatMessage(translations.incorrectCredentialsMessage));
// This avoids keeping a stored language for a username that is incorrect.
// e.g. wrong username submitted.
localStorage.removeItem(buildStoredLanguageKey(username));
localStorage.removeItem('username');
}
}, [formatMessage, qsError, username, lockedErrorMessage, lockedTimeSeconds, lockedTimer]);
const handleSubmit = (e) => {
if (isBlank(password) || isBlank(username)) {
e.preventDefault();
e.stopPropagation();
} else {
localStorage.setItem('username', username);
setStoredLanguage(language, username);
setError('');
onSubmit(true);
}
};
return React.createElement(
React.Fragment,
null,
React.createElement(
DialogContent,
null,
React.createElement(HeaderView, { error: error, introMessage: '', classes: classes }),
React.createElement(LogInForm, {
children: children,
classes: classes,
onSubmit: handleSubmit,
username: username,
password: password,
isFetching: isFetching,
enableUsernameInput: true,
onSetPassword: setPassword,
onRecover: onRecover,
onSetUsername: (user) => {
setUsername(user);
username$.next(user);
},
xsrfParamName: xsrfParamName,
xsrfToken: xsrfToken
})
)
);
}
function RecoverView(props) {
const { children, isFetching, onSubmit, classes, formatMessage, onSnack, setMode } = props;
const [username, setUsername] = useState(() => localStorage.getItem('username') ?? '');
const [error, setError] = useState('');
const onSubmitRecover = (e) => {
e.preventDefault();
e.stopPropagation();
setError('');
onSubmit(true);
if (isBlank(username)) {
onSubmit(false);
} else {
sendPasswordRecovery(username).subscribe({
next() {
onSubmit(false);
setMode('login');
onSnack({
open: true,
message: formatMessage(translations.recoverYourPasswordSuccessMessage)
});
},
error(error) {
onSubmit(false);
setError(error.message);
}
});
}
};
return React.createElement(
'form',
{ onSubmit: onSubmitRecover },
React.createElement(
DialogContent,
null,
React.createElement(HeaderView, {
error: error,
classes: classes,
introMessage: formatMessage(translations.recoverYourPasswordViewTitle)
}),
children,
React.createElement(TextField, {
id: 'recoverFormUsernameField',
fullWidth: true,
autoFocus: true,
disabled: isFetching,
type: 'text',
value: username,
onChange: (e) => setUsername(e.target.value),
className: classes?.username,
label: React.createElement(FormattedMessage, {
id: 'loginView.usernameTextFieldLabel',
defaultMessage: 'Username'
}),
inputProps: { maxLength: USER_USERNAME_MAX_LENGTH }
}),
React.createElement(
Button,
{
type: 'submit',
color: 'primary',
onClick: onSubmitRecover,
disabled: isBlank(username) || isFetching,
variant: 'contained',
style: { marginTop: 15 },
fullWidth: true
},
React.createElement(FormattedMessage, { id: 'words.submit', defaultMessage: 'Submit' })
)
),
React.createElement(
DialogActions,
null,
React.createElement(
Button,
{
fullWidth: true,
type: 'button',
color: 'primary',
variant: 'text',
disabled: isFetching,
onClick: () => setMode('login')
},
'\u00AB ',
React.createElement(FormattedMessage, {
id: 'loginView.recoverYourPasswordBackButtonLabel',
defaultMessage: 'Back'
})
)
)
);
}
function ResetView(props) {
const {
children,
isFetching,
onSubmit,
classes,
formatMessage,
onSnack,
setMode,
token,
passwordRequirementsMinComplexity
} = props;
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const [isValid, setValid] = useState(null);
const [passwordsMismatch, setPasswordMismatch] = useState(false);
const [error, setError] = useState('');
const [anchorEl, setAnchorEl] = useState(null);
const submitDisabled = newPassword === '' || isFetching || !isValid;
useEffect(() => {
if (isBlank(newPasswordConfirm) || newPasswordConfirm === newPassword) {
setPasswordMismatch(false);
} else if (newPasswordConfirm !== newPassword) {
setPasswordMismatch(true);
}
}, [newPassword, newPasswordConfirm]);
useEffect(() => {
validatePasswordResetToken(token)
.pipe(filter((isValid) => !isValid))
.subscribe(
() => {
setError(formatMessage(translations.resetPasswordInvalidToken));
},
() => {
setError(formatMessage(translations.resetPasswordInvalidToken));
}
);
}, [formatMessage, token]);
const submit = (e) => {
e.preventDefault();
e.stopPropagation();
if (!isBlank(newPassword) && !isBlank(newPasswordConfirm)) {
onSubmit(true);
setPasswordService(token, newPassword, newPasswordConfirm).subscribe(
() => {
onSubmit(false);
setMode('login');
onSnack({ open: true, message: formatMessage(translations.resetPasswordSuccess) });
},
() => {
onSubmit(false);
setError(formatMessage(translations.resetPasswordError));
}
);
}
};
return React.createElement(
'form',
{ onSubmit: submit },
React.createElement(
DialogContent,
null,
React.createElement(HeaderView, {
error: error,
classes: classes,
introMessage: React.createElement(FormattedMessage, {
id: 'loginView.resetYourPasswordIntroText',
defaultMessage: 'Please enter your new password'
})
}),
React.createElement(PasswordStrengthDisplayPopper, {
open: Boolean(anchorEl),
anchorEl: anchorEl,
placement: 'top',
value: newPassword,
passwordRequirementsMinComplexity: passwordRequirementsMinComplexity,
onValidStateChanged: setValid
}),
React.createElement(PasswordTextField, {
id: 'resetFormPasswordField',
fullWidth: true,
error: isValid !== null && !isValid,
value: newPassword,
onChange: (e) => setNewPassword(e.target.value),
className: classes.resetPassword,
placeholder: formatMessage(translations.resetPasswordFieldPlaceholderLabel),
onFocus: (e) => setAnchorEl(e.target),
onBlur: () => setAnchorEl(null),
inputProps: { autoComplete: 'new-password' }
}),
React.createElement(PasswordTextField, {
id: 'resetFormPasswordConfirmField',
fullWidth: true,
helperText: passwordsMismatch ? formatMessage(passwordRequirementMessages.passwordConfirmationMismatch) : null,
error: passwordsMismatch,
value: newPasswordConfirm,
onChange: (e) => setNewPasswordConfirm(e.target.value),
className: classes.resetPassword,
placeholder: formatMessage(translations.resetPasswordConfirmFieldPlaceholderLabel)
}),
children
),
React.createElement(
DialogActions,
null,
React.createElement(
Button,
{ type: 'submit', color: 'primary', disabled: submitDisabled, variant: 'contained', fullWidth: true },
React.createElement(FormattedMessage, { id: 'words.submit', defaultMessage: 'Submit' })
)
)
);
}
function HeaderView({ error, introMessage, classes }) {
return React.createElement(
Typography,
{ variant: 'body2', className: classes[error ? 'errorMessage' : 'recoverInfoMessage'] },
error
? React.createElement(React.Fragment, null, React.createElement(WarningRounded, null), ' ', error)
: introMessage
);
}
function UnrecognizedView({ classes }) {
return React.createElement(
DialogContent,
null,
React.createElement(Typography, { variant: 'body2', className: classes.recoverInfoMessage }, 'Unrecognized mode.')
);
}
function LanguageDropDown(props) {
const { formatMessage } = useIntl();
const buttonRef = useRef();
const [openMenu, setOpenMenu] = useState(false);
const { language, languages, onChange } = props;
return React.createElement(
React.Fragment,
null,
React.createElement(
Button,
{
ref: buttonRef,
onClick: () => setOpenMenu(true),
style: { position: 'absolute', bottom: -50, width: '100%', color: 'white' },
startIcon: React.createElement(LanguageRounded, null)
},
formatMessage(translations.languageDropDownLabel)
),
React.createElement(
Menu,
{
anchorEl: buttonRef.current,
anchorOrigin: { horizontal: 'center', vertical: 'center' },
open: openMenu,
onClose: () => setOpenMenu(false)
},
languages?.map(({ id, label }) =>
React.createElement(
MenuItem,
{
selected: id === language,
key: id,
onClick: () => {
setOpenMenu(false);
onChange(id);
}
},
label
)
)
)
);
}
export function LoginViewContainer(props) {
const { formatMessage } = useIntl();
const { classes, cx } = useStyles();
const token = queryString.parse(window.location.search).token;
const { xsrfToken, xsrfParamName, passwordRequirementsMinComplexity, lockedErrorMessage, lockedTimeSeconds } = props;
const [mode, setMode] = useState(token ? 'reset' : 'login');
const [language, setLanguage] = useState(() => getCurrentLocale());
const [languages, setLanguages] = useState();
const [snack, onSnack] = useState({
open: false,
message: ''
});
const [isFetching, onSubmit] = useState(false);
let [CurrentView, setCurrentView] = useState(() => LoginView);
let currentViewProps = {
setMode,
token,
language,
formatMessage,
isFetching,
classes,
onSubmit,
onSnack,
passwordRequirementsMinComplexity,
setLanguage,
onRecover: () => setMode('recover'),
xsrfToken,
xsrfParamName,
children: null,
lockedErrorMessage,
lockedTimeSeconds
};
// Retrieve Platform Languages.
useEffect(() => {
fetchProductLanguages().subscribe(setLanguages);
}, []);
// View specific adjustments (based on mode).
useEffect(() => {
switch (mode) {
case 'login':
setCurrentView(() => LoginView);
break;
case 'recover':
setCurrentView(() => RecoverView);
const EVENT = 'keydown';
const handler = (e) => {
if (e.key === 'Escape') {
setMode('login');
}
};
document.addEventListener(EVENT, handler, false);
return () => {
document.removeEventListener(EVENT, handler, false);
};
case 'reset':
setCurrentView(() => ResetView);
break;
default:
setCurrentView(() => UnrecognizedView);
break;
}
}, [mode]);
// Dispatch custom event when language is changed.
useEffect(() => {
if (language) {
setStoredLanguage(language);
dispatchLanguageChange(language);
}
}, [language]);
return React.createElement(
React.Fragment,
null,
React.createElement(
Dialog,
{
fullWidth: true,
open: true,
maxWidth: 'xs',
className: cx(classes.dialogRoot, isFetching && classes.dialogRootFetching),
PaperProps: { className: classes.dialogPaper },
'aria-labelledby': 'loginDialog'
},
React.createElement(
DialogTitle,
{ id: 'loginDialog' },
React.createElement(CrafterCMSLogo, {
className: classes.logo,
width: 'auto',
alt: formatMessage(translations.loginDialogTitle)
})
),
React.createElement(CurrentView, { ...currentViewProps }),
React.createElement(LanguageDropDown, { language: language, languages: languages, onChange: setLanguage })
),
React.createElement(Snackbar, {
open: snack.open,
autoHideDuration: snack.autoHideDuration ?? 8000,
onClose: () => onSnack({ open: false, message: '' }),
anchorOrigin: { vertical: 'top', horizontal: 'center' },
message: snack.message
})
);
}
export default LoginViewContainer;