diff --git a/ghost/admin-x-settings/package.json b/ghost/admin-x-settings/package.json index 2a2ee5df79..2e66a9fd6e 100644 --- a/ghost/admin-x-settings/package.json +++ b/ghost/admin-x-settings/package.json @@ -44,7 +44,8 @@ "@ebay/nice-modal-react": "^1.2.10", "@tryghost/timezone-data": "0.3.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "validator": "7.2.0" }, "devDependencies": { "@storybook/addon-essentials": "7.0.18", @@ -59,6 +60,7 @@ "@tailwindcss/line-clamp": "0.4.4", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", + "@types/validator": "^13.7.17", "@typescript-eslint/eslint-plugin": "5.57.1", "@typescript-eslint/parser": "5.57.1", "@vitejs/plugin-react": "4.0.0", diff --git a/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx b/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx index c0ce86c42a..907d5a1e80 100644 --- a/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx +++ b/ghost/admin-x-settings/src/admin-x-ds/global/TextField.tsx @@ -15,20 +15,22 @@ interface TextFieldProps { hint?: React.ReactNode; clearBg?: boolean; onChange?: (event: React.ChangeEvent) => void; + onBlur?: (event: React.FocusEvent) => void; className?: string; maxLength?: number; } const TextField: React.FC = ({ - type = 'text', - inputRef, - title, - value, - error, - placeholder, - hint, - clearBg = false, - onChange, + type = 'text', + inputRef, + title, + value, + error, + placeholder, + hint, + clearBg = false, + onChange, + onBlur, className = '', maxLength, ...props @@ -37,12 +39,13 @@ const TextField: React.FC = ({
{title && {title}} {hint && {hint}} diff --git a/ghost/admin-x-settings/src/components/settings/general/SocialAccounts.tsx b/ghost/admin-x-settings/src/components/settings/general/SocialAccounts.tsx index 6ebe7d51d5..d392bed941 100644 --- a/ghost/admin-x-settings/src/components/settings/general/SocialAccounts.tsx +++ b/ghost/admin-x-settings/src/components/settings/general/SocialAccounts.tsx @@ -1,28 +1,83 @@ -import React, {useContext, useState} from 'react'; +import React, {useRef, useState} from 'react'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import TextField from '../../../admin-x-ds/global/TextField'; -import {SettingsContext} from '../../providers/SettingsProvider'; -import {TSettingGroupStates} from '../../../admin-x-ds/settings/SettingGroup'; -import {getSettingValue} from '../../../utils/helpers'; +import useSettingGroup from '../../../hooks/useSettingGroup'; +import validator from 'validator'; + +function validateFacebookUrl(newUrl: string) { + const errMessage = 'The URL must be in a format like https://www.facebook.com/yourPage'; + if (!newUrl) { + return ''; + } + + // strip any facebook URLs out + newUrl = newUrl.replace(/(https?:\/\/)?(www\.)?facebook\.com/i, ''); + + // don't allow any non-facebook urls + if (newUrl.match(/^(http|\/\/)/i)) { + throw new Error(errMessage); + } + + // strip leading / if we have one then concat to full facebook URL + newUrl = newUrl.replace(/^\//, ''); + newUrl = `https://www.facebook.com/${newUrl}`; + + // don't allow URL if it's not valid + if (!validator.isURL(newUrl)) { + throw new Error(errMessage); + } + + return newUrl; +} + +function validateTwitterUrl(newUrl: string) { + if (!newUrl) { + return ''; + } + if (newUrl.match(/(?:twitter\.com\/)(\S+)/) || newUrl.match(/([a-z\d.]+)/i)) { + let username = []; + + if (newUrl.match(/(?:twitter\.com\/)(\S+)/)) { + [, username] = newUrl.match(/(?:twitter\.com\/)(\S+)/); + } else { + [username] = newUrl.match(/([^/]+)\/?$/mi); + } + + // check if username starts with http or www and show error if so + if (username.match(/^(http|www)|(\/)/) || !username.match(/^[a-z\d._]{1,15}$/mi)) { + const message = !username.match(/^[a-z\d._]{1,15}$/mi) + ? 'Your Username is not a valid Twitter Username' + : 'The URL must be in a format like https://twitter.com/yourUsername'; + throw new Error(message); + } + return `https://twitter.com/${username}`; + } else { + const message = 'The URL must be in a format like https://twitter.com/yourUsername'; + throw new Error(message); + } +} const SocialAccounts: React.FC = () => { - const [currentState, setCurrentState] = useState('view'); + const { + currentState, + saveState, + handleSave, + handleCancel, + updateSetting, + focusRef, + getSettingValues, + handleStateChange + } = useSettingGroup(); - const handleStateChange = (newState: TSettingGroupStates) => { - setCurrentState(newState); - }; + const [errors, setErrors] = useState<{ + facebook?: string; + twitter?: string; + }>({}); - const {settings, saveSettings} = useContext(SettingsContext) || {}; - const savedFacebookUser = (getSettingValue(settings, 'facebook') || '') as string; - const savedTwitterUser = (getSettingValue(settings, 'twitter') || '') as string; - let [, fbUser] = savedFacebookUser.match(/(\S+)/) || []; - let [, twitterUser] = savedTwitterUser.match(/@?([^/]*)/) || []; + const twitterInputRef = useRef(null); - const savedFacebookUrl = `https://www.facebook.com/${fbUser}`; - const savedTwitterUrl = `https://twitter.com/${twitterUser}`; - const [facebookUrl, setFacebookUrl] = useState(savedFacebookUrl); - const [twitterUrl, setTwitterUrl] = useState(savedTwitterUrl); + const [facebookUrl, twitterUrl] = getSettingValues(['facebook', 'twitter']) as string[]; const values = ( { ); const handleChange = (e: React.ChangeEvent, type:'facebook' | 'twitter') => { - setCurrentState('unsaved'); + handleStateChange('unsaved'); if (type === 'facebook') { - setFacebookUrl(e.target.value); + updateSetting('facebook', e.target.value); } else { - setTwitterUrl(e.target.value); + updateSetting('twitter', e.target.value); } }; const inputs = ( { + try { + const newUrl = validateFacebookUrl(e.target.value); + updateSetting('facebook', newUrl); + if (focusRef.current) { + focusRef.current.value = newUrl; + } + } catch (err: any) { + // ignore error + } + }} onChange={(e) => { handleChange(e, 'facebook'); }} /> { + try { + const newUrl = validateTwitterUrl(e.target.value); + updateSetting('twitter', newUrl); + if (twitterInputRef.current) { + twitterInputRef.current.value = newUrl; + } + } catch (err: any) { + // ignore error + } + }} onChange={(e) => { handleChange(e, 'twitter'); }} @@ -71,29 +154,36 @@ const SocialAccounts: React.FC = () => { ); - const handleSave = () => { - let [, facebookUser] = facebookUrl.match(/(?:https:\/\/)(?:www\.)(?:facebook\.com)\/(?:#!\/)?(\w+\/?\S+)/mi) || []; - let [, twUser] = twitterUrl.match(/(?:https:\/\/)(?:twitter\.com)\/(?:#!\/)?@?([^/]*)/) || []; - saveSettings?.([ - { - key: 'facebook', - value: facebookUser - }, - { - key: 'twitter', - value: `@${twUser}` - } - ]); - setCurrentState('view'); - }; - return ( { + const formErrors: { + facebook?: string; + twitter?: string; + } = {}; + try { + validateFacebookUrl(facebookUrl); + } catch (e: any) { + formErrors.facebook = e?.message; + } + + try { + validateTwitterUrl(twitterUrl); + } catch (e: any) { + formErrors.twitter = e?.message; + } + + setErrors(formErrors); + if (Object.keys(formErrors).length === 0) { + handleSave(); + } + }} onStateChange={handleStateChange} > {currentState === 'view' ? values : inputs} diff --git a/yarn.lock b/yarn.lock index 0dfa2bd26d..83518ac236 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7783,6 +7783,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/validator@^13.7.17": + version "13.7.17" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.17.tgz#0a6d1510395065171e3378a4afc587a3aefa7cc1" + integrity sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ== + "@types/ws@^8.5.1": version "8.5.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5"