1/** 2 * Common functionality for publish and unpublish requests 3 */ 4 5/** 6 * Fetches the status of a page from the Helix admin API. 7 * 8 * No credentials are passed and none are needed: the AEM Sidekick extension 9 * injects the signed-in user's admin auth token into requests to 10 * admin.hlx.page made from the tab, so this call is authenticated as the 11 * sidekick user. That is also why the response carries a `profile` block (see 12 * extractRequester) and why the same URL returns 401 from curl or from a 13 * browser without the extension. 14 * 15 * @param {string} org - The organization/owner of the repository. 16 * @param {string} site - The repository name. 17 * @param {string} ref - The branch/ref name (e.g., 'main'). 18 * @param {string} [path=''] - The path to the resource. 19 * @returns {Promise<object>} A promise that resolves to the status JSON object. 20 */ 21export const fetchStatus = async (org, site, ref, path = '') => { 22 const url = `https://admin.hlx.page/status/${org}/${site}/${ref}/${path}`; 23 const response = await fetch(url); 24 if (!response.ok) { 25 throw new Error(`Failed to fetch status: ${response.statusText}`); 26 } 27 const data = await response.json(); 28 29 return data; 30}; 31 32export const WEBHOOK_BASE_URL = 'https://bamboohr-corp-agents-use2-website-publish-request.production.artoo.bamboohr.io'; 33 34/** 35 * Resolves the admin API's owner/repo/ref for the current page. 36 * 37 * The sidekick hands these to the tool panels as query parameters, but page 38 * context (see milo-utils initSidekick) has to derive them from the host: 39 * `{ref}--{repo}--{owner}.aem.page|live|reviews`. Production hosts such as 40 * www.bamboohr.com carry no ref, so they fall back to the prod project. 41 * 42 * @param {string} [host=window.location.host] - Host to parse. 43 * @returns {{ owner: string, repo: string, ref: string }} 44 */ 45export const resolveProject = (host = window.location.host) => { 46 const match = host.match(/^(.+?)--(.+?)--(.+?)\.aem\.(?:page|live|reviews)$/); 47 if (match) { 48 const [, ref, repo, owner] = match; 49 return { owner, repo, ref }; 50 } 51 52 return { owner: 'bamboohr', repo: 'bamboohr-website', ref: 'main' }; 53}; 54 55/** 56 * Pulls the requester's identity out of an admin API status response. 57 * 58 * `status.profile` is the decoded claims of the sidekick user's admin auth 59 * token, supplied by admin.hlx.page because the sidekick extension 60 * authenticated the request (see fetchStatus). It is the identity the backend 61 * attributes the request to, so a missing profile means we cannot say who is 62 * asking and the submit must stop rather than file an anonymous request. 63 * 64 * @param {object} status - The status JSON from fetchStatus(). 65 * @returns {{ requester_email: string, requester_name: string }} 66 */ 67export const extractRequester = (status) => { 68 const email = status?.profile?.email; 69 if (!email) { 70 throw new Error('Could not tell who you are. Sign in from the sidekick profile menu, reload the page, and try again.'); 71 } 72 73 return { 74 requester_email: email, 75 requester_name: status.profile.name || email.split('@')[0], 76 }; 77}; 78 79/** 80 * Submits a publish or unpublish request to the webhook backend. 81 * 82 * The requester is identified by the `requester_email`/`requester_name` fields 83 * that extractRequester() pulls from the sidekick's admin session; the backend 84 * validates the domain but cannot cryptographically verify the identity, so 85 * these requests are attributed, not authenticated. 86 * 87 * `endpoint` selects the backend route. The default files a publish/unpublish 88 * request; the Review Only panel passes `/webhook/review-request`, which runs 89 * the same review but posts it to the author in Slack instead of queuing the 90 * page for the publisher. 91 * 92 * @param {{ request_type?: string, web_path: string, requester_email: string, requester_name: string, preview_url?: string, notes?: string, redirect_url?: string }} body 93 * @param {string} [endpoint='/webhook/publish-request'] - Backend route to POST to. 94 * @returns {Promise<{ workflow_id: string, status: string }>} 95 */ 96export async function submitPublishWebhook(body, endpoint = '/webhook/publish-request') { 97 const response = await fetch(`${WEBHOOK_BASE_URL}${endpoint}`, { 98 method: 'POST', 99 headers: { 'Content-Type': 'application/json' }, 100 body: JSON.stringify({ ...body }), 101 }); 102 103 if (response.status === 403) { 104 throw new Error('Access denied. The sidekick reported a non-BambooHR account.'); 105 } 106 if (response.status === 422) { 107 throw new Error('Request rejected: requester details were missing. Reload the page and try again.'); 108 } 109 if (response.status === 503) { 110 throw new Error('Service is starting up. Please try again in a moment.'); 111 } 112 if (!response.ok) { 113 const text = await response.text().catch(() => response.statusText); 114 throw new Error(`Request failed (${response.status}): ${text}`); 115 } 116 117 return response.json(); 118} 119 120/** 121 * Notifies the webhook backend that a page has finished publishing/unpublishing. 122 * Fire this once AEM reports the publish/unpublish actually completed. 123 * 124 * IMPORTANT: request_type and web_path MUST match exactly what was sent on the 125 * original /webhook/publish-request â the completion workflow looks up the stored 126 * record by (web_path, request_type) to find which Slack message to mark complete 127 * and who to notify. A mismatch silently no-ops (no error, but no notification). 128 * 129 * @param {{ request_type: 'publish' | 'unpublish', web_path: string, requester_email?: string }} body 130 * @returns {Promise<{ workflow_id: string, status: string }>} 131 */ 132export async function submitPublishComplete(body) { 133 const response = await fetch(`${WEBHOOK_BASE_URL}/webhook/publish-complete`, { 134 method: 'POST', 135 // keepalive lets this request complete even if the sidekick reloads/navigates 136 // the page right after the publish/unpublish event fires. Without it the 137 // browser cancels the in-flight fetch on unload and the backend never hears 138 // that publishing finished. Body is tiny, well under the 64KB keepalive cap. 139 keepalive: true, 140 headers: { 'Content-Type': 'application/json' }, 141 body: JSON.stringify({ 142 request_type: body.request_type, 143 web_path: body.web_path, 144 // Optional: only used for the backend's audit log, since the completion 145 // record is looked up by (web_path, request_type). 146 requester_email: body.requester_email || '', 147 }), 148 }); 149 150 if (response.status === 503) { 151 throw new Error('Service is starting up. Please try again in a moment.'); 152 } 153 if (!response.ok) { 154 const text = await response.text().catch(() => response.statusText); 155 throw new Error(`Publish-complete failed (${response.status}): ${text}`); 156 } 157 158 return response.json(); 159} 160 161/**
162 * Function to adjust textarea height 163 * @param {HTMLTextAreaElement} textarea - The textarea element to adjust 164 */ 165export const adjustTextareaHeight = (textarea) => { 166 textarea.style.height = 'auto'; 167 textarea.style.height = `${textarea.scrollHeight}px`; 168}; 169 170/** 171 * Sets up textarea auto-resize functionality 172 * @param {HTMLTextAreaElement} textarea - Textarea element to setup 173 */ 174export const setupTextarea = (textarea) => { 175 // Add event listeners for textarea auto-expand 176 textarea.addEventListener('input', () => adjustTextareaHeight(textarea)); 177 textarea.addEventListener('change', () => adjustTextareaHeight(textarea)); 178 179 // Initial height adjustment 180 adjustTextareaHeight(textarea); 181}; 182 183/** 184 * Extracts parameters from the iframe query string 185 * @returns {Object} Object containing parameters 186 */ 187export const extractParameters = () => { 188 const iframeQueryParameters = new URLSearchParams(window.location.search.substring(1)); 189 const iframeQueryParametersObj = Object.fromEntries(iframeQueryParameters.entries()); 190 const { owner, repo, ref, referrer } = iframeQueryParametersObj; 191 192 const refererPath = referrer ? new URL(referrer).pathname : ''; 193 194 return { 195 owner, 196 repo, 197 ref, 198 referrer, 199 refererPath 200 }; 201}; 202 203 204 205/** 206 * Initializes form event listeners and elements 207 * @param {Object} options - Configuration options 208 * @param {string} options.formSelector - Selector for the form element 209 * @param {string|null} [options.notesSelector=null] - Selector for the notes 210 * textarea. Omit it for a form without notes (the Review Only panel); when 211 * given, the textarea must exist. 212 * @param {string|null} [options.redirectUrlSelector=null] - Selector for the redirect URL input 213 * @param {boolean} [options.isUnpublish=false] - Whether the redirect URL input is required 214 * @returns {Object|null} Object containing form elements or null if initialization failed 215 */ 216export const initializeForm = ({ 217 formSelector, 218 notesSelector = null, 219 redirectUrlSelector = null, 220 isUnpublish = false, 221}) => { 222 const form = document.querySelector(formSelector); 223 const submitButton = form?.querySelector('button[type="submit"]'); 224 const statusElement = document.getElementById('status-message'); 225 const textarea = notesSelector ? form?.querySelector(notesSelector) : null; 226 const redirectUrlInput = redirectUrlSelector ? form?.querySelector(redirectUrlSelector) : null; 227 228 // Check if we have all the required elements 229 if (!form || !submitButton || !statusElement || (notesSelector && !textarea)) { 230 return null; 231 } 232 233 // If this is an unpublish form, we need the redirect URL input 234 if (isUnpublish && !redirectUrlInput) { 235 return null; 236 } 237 238 // Setup textarea auto-expand 239 if (textarea) { 240 setupTextarea(textarea); 241 } 242 243 return { 244 form, 245 submitButton, 246 statusElement, 247 textarea, 248 redirectUrlInput 249 }; 250};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.