/** * Hatch setup screen. * * Four steps, one question on each screen: connect Cloudflare, choose where the * frontend lives, choose the WordPress account it reads with, then review and * deploy. Back and Next move between them. Nothing changes until Deploy. * * Everything talks to the /hatch/v1/deploy/* REST routes. The token is typed * here only long enough to send it to this site; the server stores it * encrypted if the administrator asks it to. */ import { useState, useEffect, useCallback, useRef } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { HxIcon, HxBtn, HxBadge, HxCard, HxRow, HxToggle, HxInp, HxSelect, HxSeg, Chip, HxNotice, HxSpinner, HxField, hxFetch, hxErrorNode } from '../components.jsx'; import { IC } from '../icons.jsx'; const PROVIDER = 'cloudflare'; const ACCOUNT_ID_PATTERN = /^[a-f0-9]{32}$/i; const STEP_COUNT = 4; const IDLE_DEPLOY = { phase: 'idle', error: '', message: '', warnings: [] }; const IDLE_VERIFY = { phase: 'idle', error: '', code: '', accounts: [] }; /** * The address visitors use, from the saved deploy state. * * @param {Object} state Deploy state returned by GET /deploy/status. * @return {string} Full URL, or an empty string. */ function publicUrlOf(state) { if (!state) return ''; if (state.domain) { return 'https://' + state.domain + (state.mount_mode === 'subfolder' ? state.subpath || '' : ''); } return state.origin || ''; } /** * Plain-language cause and fix for each token error the server returns. * A code that is not listed falls back to the server's own message. * * @param {string} code Error code from the verify route. * @return {{title: string, fix: string}|null} Copy to show, or null. */ function tokenErrorCopy(code) { const again = { title: __('Cloudflare did not answer', 'hatch-connect'), fix: __('Nothing was changed. Wait a minute, then press Check token again.', 'hatch-connect'), }; const rejected = { title: __('Cloudflare does not recognise this token', 'hatch-connect'), fix: __('Copy the whole token again, with nothing missing at the start or the end. If you cannot find it, create a new token with the button above.', 'hatch-connect'), }; const map = { hatch_cf_no_token: { title: __('Paste your token first', 'hatch-connect'), fix: __('Copy the token from Cloudflare, paste it in the box, then press Check token.', 'hatch-connect'), }, hatch_cf_auth: rejected, hatch_cf_rejected: rejected, hatch_cf_token_inactive: { title: __('This token is no longer active', 'hatch-connect'), fix: __('It may have expired or been turned off in Cloudflare. Create a new token with the button above.', 'hatch-connect'), }, hatch_cf_permission: { title: __('The token is missing a permission', 'hatch-connect'), fix: __('Create a new token with the button above. That page sets the permissions for you. Then paste the new token here.', 'hatch-connect'), }, hatch_cf_rate_limited: again, hatch_cf_unavailable: again, hatch_cf_unreachable: again, hatch_cf_bad_response: again, }; return map[code] || null; } /** * Progress indicator: "Step n of 4" and the four step names. The current step * carries aria-current, so the position never rests on colour alone. * * @param {number} current 1 to STEP_COUNT. STEP_COUNT + 1 means every step is done. */ function Stepper({ current }) { const steps = [ __('Connect Cloudflare', 'hatch-connect'), __('Choose where it lives', 'hatch-connect'), __('WordPress account', 'hatch-connect'), __('Review and deploy', 'hatch-connect'), ]; return (

{/* translators: 1: current step number, 2: total number of steps. */} {sprintf(__('Step %1$d of %2$d', 'hatch-connect'), Math.min(current, STEP_COUNT), STEP_COUNT)}

    {steps.map((label, i) => { const n = i + 1; const done = current > n; return (
  1. {label} {done && {' ' + __('(done)', 'hatch-connect')}}
  2. ); })}
); } /** * Title of one step and the one thing the user needs before starting it. */ function StepHead({ headRef, title, need }) { return (

{title}

{need && (

{__('You need:', 'hatch-connect')}{' '}{need}

)}
); } /** * Back and Next for a step. Back is hidden on the first step. */ function StepNav({ onBack, onNext, nextLabel, nextDisabled }) { return (
{onBack ? {__('Back', 'hatch-connect')} : } {nextLabel}
); } function FactRow({ label, children, last }) { return ( {children} ); } export default function SetupApp() { const boot = window.hatchBoot || {}; const setup = (boot.state && boot.state.setup) || {}; const disc = setup.disclosure || {}; const tabUrl = (tab) => (boot.adminUrl || 'admin.php?page=hatch') + '#' + tab; const [data, setData] = useState({ phase: 'loading', error: '', provider: null, status: null }); const [changing, setChanging] = useState(false); const [wizStep, setWizStep] = useState(1); const [token, setToken] = useState(''); const [replacing, setReplacing] = useState(false); const [verify, setVerify] = useState(IDLE_VERIFY); const [accountId, setAccountId] = useState(''); const [zones, setZones] = useState({ phase: 'idle', error: '', list: [] }); const [domain, setDomain] = useState(''); const [mountMode, setMountMode] = useState('root'); const [subpath, setSubpath] = useState('/blog'); const [saveToken, setSaveToken] = useState(true); const [publicAddress, setPublicAddress] = useState(''); const [ack, setAck] = useState(false); const [wpUser, setWpUser] = useState(''); const [wpPass, setWpPass] = useState(''); const [replacingWp, setReplacingWp] = useState(false); const [allowPermalinks, setAllowPermalinks] = useState(false); const [deploy, setDeploy] = useState(IDLE_DEPLOY); const [forget, setForget] = useState({ phase: 'idle', error: '' }); const [showToken, setShowToken] = useState(false); const stepHeadRef = useRef(null); const stepMovedRef = useRef(false); const liveRef = useRef(null); const loadData = useCallback(async () => { setData((d) => ({ ...d, phase: 'loading', error: '' })); try { const [prov, status] = await Promise.all([hxFetch('deploy/providers'), hxFetch('deploy/status')]); const provider = ((prov && prov.providers) || []).find((p) => p.id === PROVIDER) || null; setData({ phase: 'ready', error: '', provider, status }); } catch (e) { setData({ phase: 'error', error: hxErrorNode(e), provider: null, status: null }); } }, []); useEffect(() => { loadData(); }, [loadData]); const status = data.status || {}; const provider = data.provider; const hasSavedToken = !!status.has_token; const typedToken = token.trim(); const usingSavedToken = hasSavedToken && !replacing && !typedToken; const savedWp = status.wp_credential || {}; const usingSavedWp = !!savedWp.saved && !replacingWp; const wpCredentialReady = usingSavedWp || (wpUser.trim() !== '' && wpPass.trim() !== ''); const tokenBody = typedToken ? { token: typedToken } : {}; const verified = verify.phase === 'ok'; const accounts = verify.accounts || []; // Steps after the first need a verified token. Without one, the screen is step 1. const step = verified ? wizStep : 1; // Move focus to the new step heading, so keyboard and screen-reader users land // on the step they moved to. The first render is skipped. useEffect(() => { if (!stepMovedRef.current) { stepMovedRef.current = true; return; } if (stepHeadRef.current) stepHeadRef.current.focus(); }, [step]); useEffect(() => { if (deploy.phase === 'done' && liveRef.current) liveRef.current.focus(); }, [deploy.phase, data.phase]); // A deploy can take a few minutes. Ask before the tab is closed mid-way. useEffect(() => { if (deploy.phase !== 'running') return undefined; const warn = (e) => { e.preventDefault(); e.returnValue = ''; }; window.addEventListener('beforeunload', warn); return () => window.removeEventListener('beforeunload', warn); }, [deploy.phase]); const resetVerification = () => { setVerify(IDLE_VERIFY); setAccountId(''); setZones({ phase: 'idle', error: '', list: [] }); setAck(false); setAllowPermalinks(false); }; const loadZones = async (body) => { setZones({ phase: 'loading', error: '', list: [] }); try { const res = await hxFetch(`deploy/${PROVIDER}/zones`, { method: 'POST', body: JSON.stringify(body) }); setZones({ phase: 'ok', error: '', list: (res && res.zones) || [] }); } catch (e) { setZones({ phase: 'error', error: hxErrorNode(e), list: [] }); } }; const runVerify = async () => { setVerify({ phase: 'loading', error: '', code: '', accounts: [] }); setDeploy(IDLE_DEPLOY); try { const res = await hxFetch(`deploy/${PROVIDER}/verify`, { method: 'POST', body: JSON.stringify(tokenBody) }); const list = (res && res.accounts) || []; setVerify({ phase: 'ok', error: '', code: '', accounts: list }); if (list.length === 1) setAccountId(list[0].id); loadZones(tokenBody); } catch (e) { setVerify({ phase: 'error', error: hxErrorNode(e), code: e.code || '', accounts: [] }); } }; const startAgain = () => { const st = status.state || {}; setDomain(st.domain || ''); setMountMode(st.mount_mode === 'subfolder' ? 'subfolder' : 'root'); setSubpath(st.subpath || '/blog'); setToken(''); setReplacing(false); resetVerification(); setDeploy(IDLE_DEPLOY); setWizStep(1); setChanging(true); }; const forgetToken = async () => { setForget({ phase: 'busy', error: '' }); try { await hxFetch(`deploy/${PROVIDER}/token`, { method: 'DELETE' }); setForget({ phase: 'idle', error: '' }); resetVerification(); setReplacing(false); await loadData(); } catch (e) { setForget({ phase: 'error', error: hxErrorNode(e) }); } }; const accountReady = accounts.length > 1 ? accountId !== '' : accounts.length === 1 || ACCOUNT_ID_PATTERN.test(accountId.trim()); const needsPublicAddress = !!disc.wpUrlIsPrivate; const appPasswordsOff = disc.appPasswordsOn === false; const canDeploy = verified && accountReady && ack && wpCredentialReady && !appPasswordsOff && deploy.phase !== 'running'; const runDeploy = async () => { setDeploy({ phase: 'running', error: '', message: '', warnings: [] }); try { const trimmedAddress = publicAddress.trim(); if (needsPublicAddress && trimmedAddress !== '') { const saved = await hxFetch('options', { method: 'POST', body: JSON.stringify({ 'connection.wp_public_url': trimmedAddress }) }); if (!saved || !saved.applied || saved.applied['connection.wp_public_url'] === undefined) { setDeploy({ phase: 'error', error: __('That public address is not a valid web address. Use the form https://example.com.', 'hatch-connect'), message: '', warnings: [] }); return; } } const res = await hxFetch(`deploy/${PROVIDER}/run`, { method: 'POST', body: JSON.stringify({ ...tokenBody, save_token: typedToken ? saveToken : false, mount_mode: domain.trim() ? mountMode : 'root', subpath, domain: domain.trim(), account_id: accountId.trim(), consent: ack === true, ...(usingSavedWp ? {} : { wp_user: wpUser.trim(), wp_password: wpPass }), allow_permalinks: !!disc.permalinksPlain && allowPermalinks, }), }); setDeploy({ phase: 'done', error: '', message: (res && res.message) || '', warnings: (res && res.warnings) || [] }); setToken(''); setReplacing(false); setWpPass(''); setReplacingWp(false); resetVerification(); setChanging(false); await loadData(); } catch (e) { if (e.code === 'hatch_cf_choose_account' && e.data && Array.isArray(e.data.accounts)) { setVerify((v) => ({ ...v, accounts: e.data.accounts })); } setDeploy({ phase: 'error', error: hxErrorNode(e), message: '', warnings: [] }); } }; // Screen frame. stepperAt is 0 to hide the progress indicator. const frame = (body, stepperAt = 0) => (

{__('Set up Hatch', 'hatch-connect')}

{__('Publish a fast frontend for this site on your own Cloudflare account. You can undo it from the Connection tab.', 'hatch-connect')}

{stepperAt > 0 && }
{body}
{setup.completeUrl && status.deployed && ( {__('Finish and open the dashboard', 'hatch-connect')} )} {setup.skipUrl && !status.deployed && ( {__('Skip setup for now', 'hatch-connect')} )}
); // Loading, error and empty states. if (data.phase === 'loading') { return frame( ); } if (data.phase === 'error') { return frame( {__('Try again', 'hatch-connect')}} > {data.error} ); } if (!provider) { return frame( {__('Check again', 'hatch-connect')}} > {__('This site did not report a Cloudflare deploy option. Reactivate the plugin and reload this page.', 'hatch-connect')} ); } // Live view. if (status.deployed && !changing) { const st = status.state || {}; const url = publicUrlOf(st); const last = status.last; const justDone = deploy.phase === 'done'; let servedFrom = __('A free workers.dev address, no custom domain', 'hatch-connect'); if (st.domain && st.mount_mode === 'subfolder') { /* translators: 1: domain name, 2: folder path such as /blog. */ servedFrom = sprintf(__('%1$s, in the folder %2$s', 'hatch-connect'), st.domain, st.subpath || ''); } else if (st.domain) { /* translators: %s: domain name. */ servedFrom = sprintf(__('%s, the whole domain', 'hatch-connect'), st.domain); } const nextSteps = [ { href: tabUrl('design'), title: __('Change the design', 'hatch-connect'), desc: __('Pick a theme, colors and fonts for your frontend.', 'hatch-connect') }, { href: tabUrl('security'), title: __('Add spam protection', 'hatch-connect'), desc: __('Turn on Cloudflare Turnstile, a check that keeps bots out of your forms and login page. It is optional.', 'hatch-connect') }, { href: tabUrl('connection'), title: __('Check the connection', 'hatch-connect'), desc: __('See that your frontend answers and when it last updated.', 'hatch-connect') }, ]; return frame( <> {justDone && deploy.message && ( {deploy.message} )} {last && last.ok === false && ( {last.message} )}

{justDone ? __('Your frontend is live', 'hatch-connect') : __('Your frontend is deployed', 'hatch-connect')}

{__('Live', 'hatch-connect')}
{url ? ( {url} {' ' + __('(opens in a new tab)', 'hatch-connect')} ) : null}
{servedFrom} {st.script_name && {st.script_name}} {st.deployed_at ? ( {new Date(st.deployed_at * 1000).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })} ) : null}
{url && ( {IC.externalLink} {__('Open your frontend', 'hatch-connect')} )} {__('Run the setup again', 'hatch-connect')}
{deploy.warnings.map((w, i) => ( {w} ))}

{__('What to do next', 'hatch-connect')}

{__('Open the Connection tab and choose Disconnect. Hatch forgets the stored account and visitors see your WordPress site again. The Worker stays in your Cloudflare account until you delete it there.', 'hatch-connect')}
{hasSavedToken && (

{__('Saved Cloudflare token', 'hatch-connect')}

{__('Your token is saved on this site, encrypted, so you can deploy again without pasting it. Forgetting it removes it from this site. It does not cancel the token in Cloudflare.', 'hatch-connect')}

{forget.phase === 'error' && ( {forget.error} )} {forget.phase === 'confirm' ? (
{__('Forget the saved token?', 'hatch-connect')} {__('Yes, forget it', 'hatch-connect')} setForget({ phase: 'idle', error: '' })}>{__('Cancel', 'hatch-connect')}
) : ( setForget({ phase: 'confirm', error: '' })} > {forget.phase === 'busy' ? __('Forgetting', 'hatch-connect') : __('Forget saved token', 'hatch-connect')} )}
)} , justDone ? STEP_COUNT + 1 : 0 ); } // Deploy wizard. One step on screen at a time. const permissions = provider.permissions || []; const zoneChoices = (zones.list || []).filter((z) => !accountId || !z.account_id || z.account_id === accountId); const tokenError = verify.phase === 'error' ? tokenErrorCopy(verify.code) : null; const goTo = (n) => setWizStep(n); // Step 1: connect Cloudflare. const stepOne = ( {changing && status.deployed && ( {__('You are setting up a new frontend. It replaces the one that is live now, and only when you press Deploy.', 'hatch-connect')} )} {usingSavedToken && !verified && (
{__('A token is already saved on this site. Hatch checks it with Cloudflare before going on.', 'hatch-connect')}
{verify.phase === 'loading' ? __('Checking', 'hatch-connect') : __('Check saved token', 'hatch-connect')} { setReplacing(true); setVerify(IDLE_VERIFY); }}> {__('Use a different token', 'hatch-connect')}
)} {!usingSavedToken && !verified && (
{setup.cfTokenUrl && ( {IC.externalLink} {__('Create my Cloudflare token', 'hatch-connect')} )}

{__('The button opens Cloudflare in a new tab. The settings are filled in for you.', 'hatch-connect')}

  1. {__('On the Cloudflare page, follow the steps to the end and create the token.', 'hatch-connect')}
  2. {__('Copy the token before you close that page.', 'hatch-connect')}
  3. {__('Come back here and paste it below.', 'hatch-connect')}
{__('Which permissions the token gets', 'hatch-connect')}
    {permissions.map((p, i) => (
  • {p.required ? __('Required', 'hatch-connect') : __('Optional', 'hatch-connect')}
    { /* translators: 1: permission area such as Account, 2: permission name, 3: access level such as Edit. */ sprintf(__('%1$s: %2$s (%3$s)', 'hatch-connect'), p.scope, p.permission, p.access) }
    {p.reason}
  • ))}
{ setToken(e.target.value); if (verify.phase !== 'idle') setVerify(IDLE_VERIFY); }} onKeyDown={(e) => { if (e.key === 'Enter' && typedToken && verify.phase !== 'loading') { e.preventDefault(); runVerify(); } }} />
{verify.phase === 'loading' ? __('Checking', 'hatch-connect') : __('Check token', 'hatch-connect')} {hasSavedToken && ( { setReplacing(false); setToken(''); setVerify(IDLE_VERIFY); }}> {__('Use the saved token', 'hatch-connect')} )}
)} {verify.phase === 'loading' && (
)} {verify.phase === 'error' && (
{tokenError ? ( {tokenError.fix} ) : ( {verify.error} )}
)} {verified && (
{IC.check} {__('Cloudflare accepted your token.', 'hatch-connect')} { resetVerification(); setToken(''); }}> {__('Use a different token', 'hatch-connect')}
)} goTo(2)} nextLabel={__('Next', 'hatch-connect')} nextDisabled={!verified} />
); // Step 2: account and address. const stepTwo = (
{accounts.length > 1 && ( setAccountId(e.target.value)}> {accounts.map((a) => ( ))} )} {accounts.length === 1 && ( {accounts[0].name || accounts[0].id} )} {accounts.length === 0 && ( setAccountId(e.target.value)} /> )} setDomain(e.target.value)} /> {zones.phase === 'loading' && } {zones.phase === 'error' && ( {zones.error} {__('You can still type a domain above.', 'hatch-connect')} )} {zones.phase === 'ok' && zoneChoices.length === 0 && ( {__('This token cannot see any domains. Type one above, or leave it empty for the free address.', 'hatch-connect')} )} {zoneChoices.length > 0 && (
{__('Pick one of your domains', 'hatch-connect')}
{zoneChoices.slice(0, 24).map((z) => ( setDomain(domain.trim() === z.name ? '' : z.name)} /> ))}
)} {domain.trim() !== '' && (
{__('Use', 'hatch-connect')}
{mountMode === 'subfolder' && ( setSubpath(e.target.value)} /> )}
)}
goTo(1)} onNext={() => goTo(3)} nextLabel={__('Next', 'hatch-connect')} nextDisabled={!accountReady} />
); // Step 3: the WordPress account the frontend reads with. const stepThree = (

{__('Use a Subscriber account and add an application password to it. The frontend only reads, so Hatch refuses any account that can edit or manage your site.', 'hatch-connect')} {' '} {disc.newUserUrl && {__('Add a user', 'hatch-connect')}{' ' + __('(opens in a new tab)', 'hatch-connect')}} {disc.newUserUrl && disc.appPasswordsUrl ? ' ยท ' : ''} {disc.appPasswordsUrl && {__('Application passwords', 'hatch-connect')}{' ' + __('(opens in a new tab)', 'hatch-connect')}}

{appPasswordsOff && ( {__('Open your profile', 'hatch-connect')} : null} > {__('WordPress turns them off when the site is not on https, or when a plugin or setting turns them off. Switch the site to https or turn them back on, then come back here.', 'hatch-connect')} )} {usingSavedWp ? (
{__('Saved', 'hatch-connect')} { /* translators: %s: WordPress user name. */ sprintf(__('%s, password saved', 'hatch-connect'), savedWp.user || '')} setReplacingWp(true)}>{__('Replace', 'hatch-connect')}
) : (
setWpUser(e.target.value)} /> setWpPass(e.target.value)} /> {savedWp.saved && (
{ setReplacingWp(false); setWpUser(''); setWpPass(''); }}>{__('Keep the saved account', 'hatch-connect')}
)}
)} goTo(2)} onNext={() => goTo(4)} nextLabel={__('Next', 'hatch-connect')} nextDisabled={!wpCredentialReady || appPasswordsOff} />
); // Step 4: review and deploy. const stepFour = ( {needsPublicAddress && (
{ /* translators: %s: this site's address, for example http://localhost:8810. */ sprintf(__('%s is a local or private address, so the frontend cannot load your posts from it. Enter an address that works on the internet, or deploy later.', 'hatch-connect'), disc.wpUrl || '')} setPublicAddress(e.target.value)} />
)}
{typedToken && ( )} {disc.permalinksPlain && ( )}
{deploy.phase === 'error' && ( {deploy.error} )} {deploy.phase === 'running' ? ( ) : (
goTo(3)}>{__('Back', 'hatch-connect')} {IC.rocket} {__('Deploy to Cloudflare', 'hatch-connect')}
)}
); const screens = [stepOne, stepTwo, stepThree, stepFour]; return frame(screens[step - 1], step); }