@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
461 lines (453 loc) • 17.5 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, { useEffect, useId, useRef, useState } from 'react';
import EnhancedDialog from '../EnhancedDialog';
import { FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import useUpdateRefs from '../../hooks/useUpdateRefs';
import DialogBody from '../DialogBody';
import DateTimeTimezonePicker from '../DateTimeTimezonePicker/DateTimeTimezonePicker';
import DialogFooter from '../DialogFooter';
import SecondaryButton from '../SecondaryButton';
import PrimaryButton from '../PrimaryButton';
import useSelection from '../../hooks/useSelection';
import Select from '@mui/material/Select';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import useSiteList from '../../hooks/useSiteList';
import { useTheme } from '@mui/material/styles';
import MenuItem from '@mui/material/MenuItem';
import { FormHelperText } from '@mui/material';
import FormLabel from '@mui/material/FormLabel';
import Alert from '@mui/material/Alert';
import Divider from '@mui/material/Divider';
import { encrypt } from '../../services/security';
import { showErrorDialog } from '../../state/reducers/dialogs/error';
import { copyToClipboard } from '../../utils/system';
import { showSystemNotification } from '../../state/actions/system';
import useSitesBranch from '../../hooks/useSitesBranch';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import hljs from '../../env/hljs';
import useEnv from '../../hooks/useEnv';
import useActiveSiteId from '../../hooks/useActiveSiteId';
export function CreatePreviewTokenDialog(props) {
const { onSubmittingAndOrPendingChange, onTokenGenerated, ...rest } = props;
return React.createElement(
EnhancedDialog,
{
title: React.createElement(FormattedMessage, { defaultMessage: 'Create Preview Token' }),
subtitle: React.createElement(FormattedMessage, {
defaultMessage: 'Authorize external applications to access project preview'
}),
maxWidth: 'sm',
...rest
},
React.createElement(Body, {
onTokenGenerated: onTokenGenerated,
isSubmitting: props.isSubmitting,
onSubmittingAndOrPendingChange: onSubmittingAndOrPendingChange
})
);
}
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
slotProps: {
paper: {
style: {
maxHeight: ITEM_HEIGHT * 6.5 + ITEM_PADDING_TOP,
width: 250
}
}
}
};
const COOKIE_NAME = 'crafterPreview';
const HEADER_NAME = 'X-Crafter-Preview';
function getStyles(name, personName, theme) {
return {
fontWeight: personName.includes(name) ? theme.typography.fontWeightMedium : theme.typography.fontWeightRegular
};
}
const templates = {
js: `// Setting the cookie
document.cookie = "${COOKIE_NAME}={token}; path=/; expires={expiresAt}";
// Setting the header
const headers = { '${HEADER_NAME}': "{rawToken}" };
// QSA
window.location.href = "{domain}?${COOKIE_NAME}={token}";`,
express: `// Setting the cookie
res.cookie('${COOKIE_NAME}', '{rawToken}', { path: '/', expires: new Date({time}), httpOnly: true });
// Setting the header
res.setHeader('${HEADER_NAME}', '{rawToken}');
// QSA
res.redirect('{domain}?${COOKIE_NAME}={token}');`,
next: `// Setting the cookie
res.setHeader('Set-Cookie', '${COOKIE_NAME}={rawToken}; Path=/; Expires={expiresAt}; HttpOnly; Secure');
// Setting the header
res.setHeader('${HEADER_NAME}', '{rawToken}');
// QSA
return { redirect: { destination: '{domain}?${COOKIE_NAME}={token}', permanent: false } }`,
curl: `curl --header "cookie: ${COOKIE_NAME}={token};" "{domain}/api/1/site/content_store/item.json?url=/site/website/index.xml&crafterSite={site}"`
};
const getInitialDate = () => {
const date = new Date();
date.setMonth(date.getMonth() + 1);
return date;
};
function Body(props) {
const { isSubmitting, onTokenGenerated, onClose, onSubmittingAndOrPendingChange } = props;
const id = useId();
const siteId = useActiveSiteId();
const [expiresAt, setExpiresAt] = useState(getInitialDate);
const [open, setOpen] = useState(false);
const [tab, setTab] = useState('js');
const [token, setToken] = useState();
const siteLookup = useSitesBranch().byId;
const { guestBase } = useEnv();
const sites = useSiteList();
const { formatMessage } = useIntl();
const dispatch = useDispatch();
const locale = useSelection((state) => state.uiConfig.locale);
const functionRefs = useUpdateRefs({ onSubmittingAndOrPendingChange });
const chipsContainerRef = useRef();
const theme = useTheme();
const [projects, setProjects] = React.useState([]);
const valid = projects.length > 0;
const handleProjectsChange = (event) => {
const {
target: { value }
} = event;
setProjects(
// On autofill we get a stringified value.
typeof value === 'string' ? value.split(',') : value
);
};
const handleChipDeleteButton = (e, projectId) => {
e.stopPropagation();
setProjects(projects.filter((p) => p !== projectId));
};
const handleChipClick = () => {
setOpen(true);
};
const handleSubmit = (e) => {
e.preventDefault();
e.stopPropagation();
if (!valid) return;
functionRefs.current.onSubmittingAndOrPendingChange({ isSubmitting: true });
encrypt(`${projects.join(',')}|${expiresAt.getTime()}`).subscribe({
next(token) {
functionRefs.current.onSubmittingAndOrPendingChange({ isSubmitting: false, hasPendingChanges: false });
onTokenGenerated?.(token);
setToken(token);
copy(token, false);
},
error(response) {
functionRefs.current.onSubmittingAndOrPendingChange({ isSubmitting: false });
dispatch(showErrorDialog({ error: response }));
}
});
};
const handleClose = (e) => onClose(e, null);
const handleDateChange = (date) => {
setExpiresAt(date);
};
const handleTabChanged = (event, newValue) => {
setTab(newValue);
};
const copy = (value, message = formatMessage({ defaultMessage: 'Value copied to clipboard' })) => {
copyToClipboard(value).then(() => {
typeof message === 'string' && dispatch(showSystemNotification({ message }));
});
};
useEffect(() => {
functionRefs.current.onSubmittingAndOrPendingChange({ hasPendingChanges: projects.length > 0 });
}, [functionRefs, projects]);
const selectLabel = formatMessage({ defaultMessage: 'Projects' });
const cookieSettingCode = token
? templates[tab]
.replaceAll(
'{token}',
encodeURIComponent(token).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g, decodeURIComponent)
)
.replaceAll(
'{rawToken}',
encodeURIComponent(token).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g, decodeURIComponent)
)
.replaceAll('{expiresAt}', expiresAt.toUTCString())
.replaceAll('{time}', String(expiresAt.getTime()))
.replaceAll('{domain}', guestBase)
.replaceAll('{site}', siteId)
: '';
return token
? React.createElement(
React.Fragment,
null,
React.createElement(
DialogBody,
null,
React.createElement(
Alert,
{ variant: 'outlined', severity: 'success', sx: { border: 'none' } },
React.createElement(FormattedMessage, {
defaultMessage:
'Token was created and copied to your clipboard, please store it securely. Preview tokens are not stored or displayed anywhere.'
})
),
React.createElement(
Box,
{ sx: { mb: 2 } },
React.createElement(
InputLabel,
{ sx: { ml: 1, mb: 0.5 } },
React.createElement(FormattedMessage, { defaultMessage: 'Token' })
),
React.createElement(OutlinedInput, {
readOnly: true,
fullWidth: true,
value: token,
onClick: (e) => {
e.target.select();
copy(token);
}
})
),
React.createElement(
Box,
{ sx: { ml: 1 } },
React.createElement(
Typography,
null,
React.createElement(FormattedMessage, { defaultMessage: 'Here are the ways to use the token' })
),
React.createElement(
Typography,
{ variant: 'body2', component: 'div' },
React.createElement(
'ul',
null,
React.createElement(
'li',
null,
React.createElement(FormattedMessage, {
defaultMessage: 'Set a cookie with the name <c>{name}</c>',
values: {
name: COOKIE_NAME,
c: (name) => React.createElement('code', null, name)
}
})
),
React.createElement(
'li',
null,
React.createElement(FormattedMessage, {
defaultMessage: 'Add a query string argument with the name <c>{name}</c>',
values: {
name: COOKIE_NAME,
c: (name) => React.createElement('code', null, name)
}
})
),
React.createElement(
'li',
null,
React.createElement(FormattedMessage, {
defaultMessage: 'Set a header with the name <c>{name}</c>',
values: {
name: HEADER_NAME,
c: (name) => React.createElement('code', null, name)
}
})
)
)
)
),
React.createElement(
Tabs,
{ value: tab, onChange: handleTabChanged },
React.createElement(Tab, { label: 'JavaScript', value: 'js', autoFocus: true }),
React.createElement(Tab, { label: 'Express', value: 'express' }),
React.createElement(Tab, { label: 'NextJS', value: 'next' }),
React.createElement(Tab, { label: 'Curl', value: 'curl' })
),
React.createElement(
Box,
null,
React.createElement(
Box,
{
component: 'pre',
sx: { width: '100%', overflow: 'auto', bgcolor: 'background.paper', p: 1, borderRadius: 1 },
onClick: () => {
copy(cookieSettingCode);
}
},
React.createElement('code', {
dangerouslySetInnerHTML: {
__html: hljs.highlight(cookieSettingCode, { language: tab === 'curl' ? 'plaintext' : 'js' }).value
}
})
)
)
),
React.createElement(
DialogFooter,
{ sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } },
React.createElement(
SecondaryButton,
{
onClick: () => {
setToken(undefined);
setProjects([]);
setExpiresAt(getInitialDate());
setTab('js');
}
},
React.createElement(FormattedMessage, { defaultMessage: 'Start over' })
),
React.createElement(
SecondaryButton,
{ onClick: handleClose },
React.createElement(FormattedMessage, { defaultMessage: 'Done' })
)
)
)
: React.createElement(
'form',
{ onSubmit: handleSubmit },
React.createElement(
DialogBody,
null,
React.createElement(
FormControl,
{ fullWidth: true, sx: { mb: 2 }, disabled: isSubmitting },
React.createElement(InputLabel, { id: `${id}_label` }, selectLabel),
React.createElement(
Select,
{
autoFocus: true,
labelId: `${id}_label`,
id: id,
multiple: true,
value: projects,
open: open,
onChange: handleProjectsChange,
onOpen: (e) => {
// @ts-expect-error: The key prop is present when this is called via keyboard event.
if (e.key === 'Enter' && valid) {
handleSubmit(e);
return;
}
// This allows chip click to occur, otherwise is swallowed by the Select open event somehow.
setOpen(e.target === chipsContainerRef.current || !chipsContainerRef.current?.contains(e.target));
},
onClose: () => setOpen(false),
input: React.createElement(OutlinedInput, { label: selectLabel }),
MenuProps: MenuProps,
renderValue: (selected) =>
React.createElement(
Box,
{ sx: { display: 'flex', flexWrap: 'wrap', gap: 0.5 }, ref: chipsContainerRef },
selected.map((value) =>
React.createElement(Chip, {
key: value,
label: siteLookup[value].name,
onDelete: (e) => handleChipDeleteButton(e, value),
onClick: handleChipClick
})
)
)
},
sites.map(({ id, name }) =>
React.createElement(MenuItem, { key: id, value: id, style: getStyles(name, projects, theme) }, name)
)
),
React.createElement(
FormHelperText,
null,
React.createElement(FormattedMessage, {
defaultMessage: 'Select which projects this token grants preview access'
})
)
),
React.createElement(
FormControl,
{ component: 'fieldset', disabled: isSubmitting },
React.createElement(
FormLabel,
{ component: 'legend' },
React.createElement(FormattedMessage, { defaultMessage: 'Expiration Date' })
),
React.createElement(DateTimeTimezonePicker, {
disabled: isSubmitting,
onChange: handleDateChange,
value: expiresAt,
disablePast: true,
localeCode: locale.localeCode,
dateTimeFormatOptions: locale.dateTimeFormatOptions
})
),
React.createElement(Divider, { sx: { mx: -2, my: 1 } }),
React.createElement(
Alert,
{ severity: 'warning', variant: 'outlined', sx: { py: 0.5, border: 'none' } },
React.createElement(FormattedMessage, {
defaultMessage: 'Once generated, store securely. You won\u2019t be able to see it\u2019s value again.'
})
)
),
React.createElement(
DialogFooter,
null,
React.createElement(
SecondaryButton,
{ onClick: handleClose },
React.createElement(FormattedMessage, { id: 'words.cancel', defaultMessage: 'Cancel' })
),
React.createElement(
PrimaryButton,
{
type: 'submit',
autoFocus: true,
disabled: isSubmitting || !valid,
loading: isSubmitting,
onClick: handleSubmit
},
React.createElement(FormattedMessage, { defaultMessage: 'Generate' })
)
)
);
}
export default CreatePreviewTokenDialog;