PageSourceSearch

https://www.bamboohr.com/tools/sidekick/plugins/publish-tools/publish-tools.js

js bamboohr.com collected 2026-10-01 07:06:17 UTC 9,600 bytes, 250 lines download raw bytes

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.