UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

461 lines (453 loc) 17.5 kB
/* * 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;