UNPKG

@humanmark/sdk-js

Version:

Browser-native JavaScript SDK for Humanmark human verification challenges

758 lines (757 loc) 25 kB
import { QRCodeGenerator } from "./QRCodeGenerator-997MoXR0.js"; import { C as CSS_CLASSES, A as ANIMATION_TIMINGS, F as FOCUSABLE_SELECTORS, K as KEYBOARD_KEYS, c as createSVGElement, S as SVG_DIMENSIONS, M as MESSAGES, a as createElement, b as appendChildren, d as ARIA, E as ELEMENT_IDS, U as URLS, q as querySelector, e as addClass, r as removeAllChildren, f as removeClass, g as focusElement, i as isInDOM, h as COLORS, D as DeepLinkHandler } from "./DeepLinkHandler-DqDFWmlg.js"; import { g as getTokenExpiration } from "./index-AC1SXkK1.js"; function detectDevice() { const userAgent = navigator.userAgent || ""; const uaIsMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( userAgent ); const hasTouchSupport = "ontouchstart" in window || navigator.maxTouchPoints > 0 || window.matchMedia && window.matchMedia("(pointer: coarse)").matches; const hasSmallViewport = window.innerWidth <= 768 && window.innerHeight <= 1024; const isMobile = uaIsMobile || hasTouchSupport && hasSmallViewport; const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !("MSStream" in window); const isAndroid = /Android/.test(userAgent); return { isMobile, isIOS, isAndroid, userAgent }; } function shouldShowQRCode() { const device = detectDevice(); return !device.isMobile; } function isMobileDevice() { const device = detectDevice(); return device.isMobile; } const _AccessibilityManager = class _AccessibilityManager { /** * Announces a message to screen readers * @param message - The message to announce * @param priority - 'polite' waits for current speech, 'assertive' interrupts */ static announce(message, priority = "polite") { if (!this.announcementElement) { this.announcementElement = document.createElement("div"); this.announcementElement.setAttribute("aria-live", priority); this.announcementElement.setAttribute("aria-atomic", "true"); this.announcementElement.className = CSS_CLASSES.ACCESSIBILITY.SCREEN_READER_ONLY; document.body.appendChild(this.announcementElement); } this.announcementElement.setAttribute("aria-live", priority); this.announcementElement.textContent = message; setTimeout(() => { if (this.announcementElement) { this.announcementElement.textContent = ""; } }, ANIMATION_TIMINGS.ANNOUNCEMENT_CLEAR); } /** * Creates a focus trap within an element * @param container - The container element to trap focus within * @returns Cleanup function to remove the trap */ static createFocusTrap(container) { const focusableElements = container.querySelectorAll( FOCUSABLE_SELECTORS.join(",") ); if (focusableElements.length === 0) return () => { }; const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; const previouslyFocused = document.activeElement; const handleKeyDown = (e) => { if (e.key !== KEYBOARD_KEYS.TAB) return; if (e.shiftKey) { if (document.activeElement === firstFocusable && lastFocusable) { e.preventDefault(); lastFocusable.focus(); } } else { if (document.activeElement === lastFocusable && firstFocusable) { e.preventDefault(); firstFocusable.focus(); } } }; container.addEventListener("keydown", handleKeyDown); return () => { container.removeEventListener("keydown", handleKeyDown); previouslyFocused?.focus(); }; } /** * Adds ARIA attributes for modal dialogs * @param modal - The modal element * @param labelId - ID of the element that labels the modal * @param descriptionId - ID of the element that describes the modal */ static setupModalAria(modal, labelId, descriptionId) { modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true"); modal.setAttribute("aria-labelledby", labelId); if (descriptionId) { modal.setAttribute("aria-describedby", descriptionId); } } /** * Creates a visually hidden but screen reader accessible element * @param content - The content for screen readers * @returns The created element */ static createScreenReaderOnly(content) { const span = document.createElement("span"); span.textContent = content; span.className = CSS_CLASSES.ACCESSIBILITY.SCREEN_READER_ONLY; span.setAttribute("aria-hidden", "false"); return span; } /** * Cleanup function to remove announcement element */ static cleanup() { if (this.announcementElement?.parentNode) { this.announcementElement.parentNode.removeChild(this.announcementElement); this.announcementElement = null; } } }; _AccessibilityManager.announcementElement = null; let AccessibilityManager = _AccessibilityManager; function createSuccessCheckmarkSVG() { const svg = createSVGElement("svg", { viewBox: SVG_DIMENSIONS.VIEWBOX }); const circle = createSVGElement("circle", { class: "checkmark-circle", cx: SVG_DIMENSIONS.CIRCLE.CX, cy: SVG_DIMENSIONS.CIRCLE.CY, r: SVG_DIMENSIONS.CIRCLE.R, fill: "none" }); const path = createSVGElement("path", { class: "checkmark-check", d: SVG_DIMENSIONS.CHECK_PATH, fill: "none" }); svg.appendChild(circle); svg.appendChild(path); return svg; } function createCloseButtonSVG(size = 20) { const svg = createSVGElement("svg", { width: size.toString(), height: size.toString(), viewBox: "0 0 24 24", class: "humanmark-close-icon", fill: "none" }); const path = createSVGElement("path", { d: "M18 6L6 18M6 6l12 12", stroke: "currentColor", // Inherits color from parent button "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" }); svg.appendChild(path); return svg; } function createHumanmarkIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("width", "24"); svg.setAttribute("height", "24"); svg.setAttribute("viewBox", "0 0 1024 1024"); svg.setAttribute("class", "humanmark-icon"); const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path"); path1.setAttribute("fill", "#9580ff"); path1.setAttribute( "d", "M 511.367523 495.643921 C 623.741394 495.643921 714.83429 404.54895 714.83429 292.177124 C 714.83429 179.805298 623.741394 88.710327 511.367523 88.710327 C 398.995697 88.710327 307.900726 179.805298 307.900726 292.177124 C 307.900726 404.54895 398.995697 495.643921 511.367523 495.643921 Z" ); const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path"); path2.setAttribute("fill", "#9580ff"); path2.setAttribute( "d", "M 782.453125 563.264282 C 710.045105 635.674805 613.772278 675.553711 511.365479 675.553711 C 408.964752 675.553711 312.689941 635.674805 240.279861 563.264282 L 37.999184 765.540405 C 119.708984 847.251709 218.834152 905.197937 326.988434 935.999878 C 359.412354 866.653259 429.761475 818.586548 511.365479 818.586548 C 592.975586 818.586548 663.322693 866.653259 695.744568 935.999878 C 803.900879 905.19696 903.028076 847.251709 984.73584 765.540405 L 782.453125 563.264282 Z" ); g.appendChild(path1); g.appendChild(path2); svg.appendChild(g); return svg; } function createProgressBar() { const container = createElement("div", { className: CSS_CLASSES.PROGRESS.CONTAINER }); const progressBar = createElement("div", { className: CSS_CLASSES.PROGRESS.BAR, attributes: { role: "progressbar", "aria-valuemin": ARIA.PROGRESS.VALUE_MIN, "aria-valuemax": ARIA.PROGRESS.VALUE_MAX, "aria-valuenow": ARIA.PROGRESS.VALUE_NOW, "aria-label": MESSAGES.ACCESSIBILITY.PROGRESS_LABEL } }); container.appendChild(progressBar); return container; } function createBrandedTitle(text = MESSAGES.VERIFICATION.TITLE) { const title = createElement("h2", { className: CSS_CLASSES.TITLE.CONTAINER, attributes: { id: ELEMENT_IDS.TITLE } }); const icon = createHumanmarkIcon(); title.appendChild(icon); const brandSpan = createElement("span", { className: CSS_CLASSES.TITLE.BRAND, textContent: text }); title.appendChild(brandSpan); return title; } function createSubtitle(text) { return createElement("p", { className: CSS_CLASSES.SUBTITLE, textContent: text, attributes: { id: ELEMENT_IDS.DESCRIPTION } }); } function createSuccessContent() { const container = createElement("div", { className: CSS_CLASSES.SUCCESS.CONTAINER }); const checkmarkDiv = createElement("div", { className: CSS_CLASSES.SUCCESS.CHECKMARK }); checkmarkDiv.appendChild(createSuccessCheckmarkSVG()); const message = createElement("div", { className: CSS_CLASSES.SUCCESS.MESSAGE, textContent: MESSAGES.SUCCESS.TITLE }); const submessage = createElement("div", { className: CSS_CLASSES.SUCCESS.SUBMESSAGE, textContent: MESSAGES.SUCCESS.SUBTITLE }); appendChildren(container, checkmarkDiv, message, submessage); return container; } function createQRCodeContainer() { const container = createElement("div", { className: CSS_CLASSES.QR_CODE.CONTAINER }); const qrWrapper = createElement("div", { className: CSS_CLASSES.QR_CODE.WRAPPER }); const instructions = createElement("p", { className: CSS_CLASSES.QR_CODE.INSTRUCTIONS, textContent: MESSAGES.VERIFICATION.QR_INSTRUCTIONS }); container.appendChild(qrWrapper); container.appendChild(instructions); return container; } function createMobileContainer() { return createElement("div", { className: CSS_CLASSES.MOBILE.CONTAINER }); } function createModalBody() { return createElement("div", { className: CSS_CLASSES.MODAL.BODY }); } function createScreenReaderAnnouncement(message) { return createElement("div", { className: CSS_CLASSES.ACCESSIBILITY.SCREEN_READER_ONLY, textContent: message, attributes: { role: "status", "aria-live": "polite" } }); } function createWhatIsThisLink() { return createElement("a", { className: CSS_CLASSES.LINKS.WHAT_IS_THIS, textContent: MESSAGES.VERIFICATION.WHAT_IS_THIS, attributes: { href: URLS.WHAT_IS_THIS, target: "_blank", rel: "noopener noreferrer" } }); } let cachedScrollbarWidth = null; function getScrollbarWidth() { if (cachedScrollbarWidth !== null) { return cachedScrollbarWidth; } const outer = document.createElement("div"); outer.style.visibility = "hidden"; outer.style.overflow = "scroll"; outer.style.width = "100px"; outer.style.position = "absolute"; outer.style.top = "-9999px"; document.body.appendChild(outer); const inner = document.createElement("div"); inner.style.width = "100%"; outer.appendChild(inner); const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; document.body.removeChild(outer); cachedScrollbarWidth = scrollbarWidth; return scrollbarWidth; } function hasVisibleScrollbar() { return document.documentElement.scrollHeight > window.innerHeight; } function lockBodyScroll() { if (!isMobileDevice() && hasVisibleScrollbar()) { const scrollbarWidth = getScrollbarWidth(); if (scrollbarWidth > 0) { const originalPaddingRight = window.getComputedStyle( document.body ).paddingRight; document.body.setAttribute( "data-humanmark-original-padding", originalPaddingRight ); const currentPadding = parseFloat(originalPaddingRight) || 0; document.body.style.paddingRight = `${currentPadding + scrollbarWidth}px`; } } document.body.classList.add("humanmark-modal-open"); } function unlockBodyScroll() { document.body.classList.remove("humanmark-modal-open"); const originalPadding = document.body.getAttribute( "data-humanmark-original-padding" ); if (originalPadding !== null) { document.body.style.paddingRight = originalPadding; document.body.removeAttribute("data-humanmark-original-padding"); } } const _UIManager = class _UIManager { constructor() { this.modal = null; this.focusTrapCleanup = null; this.modalClosedCallback = null; this.successCallback = null; this.progressInterval = null; this.tokenExpiration = 0; this._currentState = "initializing"; this.modalOptions = {}; this.closeButtonHandler = () => { this.setState("cancelled"); this.hideModal(); }; this.backdropClickHandler = (e) => { if (e.target === this.modal) { this.setState("cancelled"); this.hideModal(); } }; this.handleKeyDown = (event) => { if (event.key === KEYBOARD_KEYS.ESCAPE && this.modalOptions.closeOnEsc !== false) { this.setState("cancelled"); this.hideModal(); } }; this.stopPropagation = (event) => { event.stopPropagation(); }; } get currentState() { return this._currentState; } set currentState(state) { this._currentState = state; } onModalClosed(callback) { this.modalClosedCallback = callback; } onSuccess(callback) { this.successCallback = callback; } showSuccess() { if (!this.modal) return; this.setState("completed"); const modalContent = querySelector( this.modal, `.${CSS_CLASSES.MODAL.CONTENT}` ); if (!modalContent) return; const modalBody = querySelector( modalContent, `.${CSS_CLASSES.MODAL.BODY}` ); if (!modalBody) return; addClass(modalBody, CSS_CLASSES.ANIMATIONS.FADE_OUT); setTimeout(() => { removeAllChildren(modalBody); const successContainer = createSuccessContent(); modalBody.appendChild(successContainer); removeClass(modalBody, CSS_CLASSES.ANIMATIONS.FADE_OUT); requestAnimationFrame(() => { addClass(successContainer, CSS_CLASSES.SUCCESS.VISIBLE); }); AccessibilityManager.announce( MESSAGES.ACCESSIBILITY.VERIFICATION_SUCCESS, "assertive" ); const progressContainer = querySelector( modalContent, `.${CSS_CLASSES.PROGRESS.CONTAINER}` ); if (progressContainer) { addClass(progressContainer, CSS_CLASSES.PROGRESS.HIDDEN); } setTimeout(() => { if (this.successCallback) { this.successCallback(); this.successCallback = null; } }, ANIMATION_TIMINGS.SUCCESS_DISPLAY); }, ANIMATION_TIMINGS.FADE_OUT); } showModal(options) { this.modalOptions = options ?? {}; this.setState("initializing"); return Promise.resolve(); } updateContent(content) { if (!this.modal) return; const modalBody = querySelector( this.modal, `.${CSS_CLASSES.MODAL.BODY}` ); if (modalBody) { while (modalBody.firstChild) { modalBody.removeChild(modalBody.firstChild); } const contentWrapper = createElement("div"); contentWrapper.textContent = content; modalBody.appendChild(contentWrapper); } } setState(state) { const previousState = this.currentState; if (!this.isValidStateTransition(previousState, state)) { return; } this.currentState = state; const event = new CustomEvent("stateChanged", { detail: { state, previousState } }); this.modal?.dispatchEvent(event); } isValidStateTransition(from, to) { const validTransitions = { initializing: [ "showing-qr", "waiting-for-verification", "cancelled", "error" ], "showing-qr": ["waiting-for-verification", "cancelled", "error"], "waiting-for-verification": ["completed", "cancelled", "error"], completed: [], // Terminal state cancelled: [], // Terminal state error: [] // Terminal state }; return validTransitions[from]?.includes(to) ?? false; } cleanup() { this.hideModal(true); this.modalClosedCallback = null; this.successCallback = null; this.stopProgressAnimation(); AccessibilityManager.cleanup(); } async showVerificationModal(token, options) { if (this.modal && this.currentState !== "cancelled") { return; } this.modalOptions = options ?? {}; this.hideModal(true); this._currentState = "initializing"; try { this.tokenExpiration = getTokenExpiration(token); } catch { this.tokenExpiration = Date.now() + 12e4; } this.modal = this.createModal(); if (shouldShowQRCode()) { this.setState("showing-qr"); await this.addQRCodeContent(this.modal, token); } else { this.addMobileContent(this.modal, token); } this.setState("waiting-for-verification"); document.body.appendChild(this.modal); lockBodyScroll(); AccessibilityManager.setupModalAria( this.modal, _UIManager.TITLE_ID, _UIManager.DESC_ID ); this.focusTrapCleanup = AccessibilityManager.createFocusTrap(this.modal); this.startProgressAnimation(); requestAnimationFrame(() => { if (!this.modal) return; if (shouldShowQRCode()) { const title = querySelector( this.modal, `#${_UIManager.TITLE_ID}` ); focusElement(title, true); } else { const verifyButton = querySelector( this.modal, `.${CSS_CLASSES.BUTTONS.VERIFY}` ); focusElement(verifyButton); } }); AccessibilityManager.announce( MESSAGES.ACCESSIBILITY.MODAL_OPENED, "assertive" ); this.addKeyboardListeners(); } hideModal(immediate = false) { const wasVisible = !!this.modal; this.stopProgressAnimation(); if (this.modal) { if (immediate) { this.cleanupModal(); } else { addClass(this.modal, CSS_CLASSES.MODAL.CLOSING); setTimeout(() => { this.cleanupModal(); }, ANIMATION_TIMINGS.MODAL_CLOSE); } } else { const existingModal = document.getElementById(_UIManager.MODAL_ID); if (existingModal && document.body.contains(existingModal)) { document.body.removeChild(existingModal); } } if (wasVisible && this.modalClosedCallback) { this.modalClosedCallback(); this.modalClosedCallback = null; } } cleanupModal() { this.stopProgressAnimation(); if (this.modal?.parentNode) { const modalContent = querySelector( this.modal, `.${CSS_CLASSES.MODAL.CONTENT}` ); if (modalContent) { modalContent.removeEventListener("click", this.stopPropagation); const closeButton = querySelector( modalContent, `.${CSS_CLASSES.BUTTONS.CLOSE}` ); if (closeButton) { closeButton.removeEventListener("click", this.closeButtonHandler); } } this.modal.removeEventListener("click", this.backdropClickHandler); if (isInDOM(this.modal)) { document.body.removeChild(this.modal); } this.modal = null; } unlockBodyScroll(); if (this.focusTrapCleanup) { this.focusTrapCleanup(); this.focusTrapCleanup = null; } AccessibilityManager.announce( MESSAGES.ACCESSIBILITY.MODAL_CLOSED, "polite" ); this.removeKeyboardListeners(); } createModal() { const zIndex = this.modalOptions.zIndex?.toString() ?? ""; const modal = createElement("div", { className: CSS_CLASSES.MODAL.OVERLAY, attributes: { id: _UIManager.MODAL_ID, ...zIndex && { style: `z-index: ${zIndex}` } } }); const modalContent = createElement("div", { className: CSS_CLASSES.MODAL.CONTENT }); const closeButton = createElement("button", { className: CSS_CLASSES.BUTTONS.CLOSE, attributes: { "aria-label": MESSAGES.ACCESSIBILITY.CLOSE_BUTTON_LABEL, type: "button" } }); const closeIcon = createCloseButtonSVG(); closeButton.appendChild(closeIcon); closeButton.addEventListener("click", this.closeButtonHandler); modalContent.appendChild(closeButton); const progressContainer = createProgressBar(); modalContent.appendChild(progressContainer); const progressBar = querySelector( progressContainer, `.${CSS_CLASSES.PROGRESS.BAR}` ); if (progressBar && this.tokenExpiration) { const totalSeconds = Math.ceil( (this.tokenExpiration - Date.now()) / 1e3 ); progressBar.setAttribute("aria-valuemax", totalSeconds.toString()); progressBar.setAttribute("aria-valuenow", totalSeconds.toString()); } modal.appendChild(modalContent); modalContent.addEventListener("click", this.stopPropagation); if (this.modalOptions.closeOnBackdropClick !== false) { modal.addEventListener("click", this.backdropClickHandler); } return modal; } async addQRCodeContent(modal, token) { const content = querySelector( modal, `.${CSS_CLASSES.MODAL.CONTENT}` ); if (!content) return; const modalBody = createModalBody(); const title = createBrandedTitle(); modalBody.appendChild(title); const subtitle = createSubtitle(MESSAGES.VERIFICATION.DESKTOP_SUBTITLE); modalBody.appendChild(subtitle); const qrCodeContainer = createQRCodeContainer(); const qrWrapper = querySelector( qrCodeContainer, `.${CSS_CLASSES.QR_CODE.WRAPPER}` ); if (qrWrapper) { try { const qrCodeImg = await QRCodeGenerator.generateQRCodeElement(token, { color: { dark: COLORS.QR_CODE.PRIMARY, light: COLORS.QR_CODE.LIGHT } }); qrWrapper.appendChild(qrCodeImg); } catch { const errorMsg = createElement("p", { className: "humanmark-error", textContent: MESSAGES.ERRORS.QR_GENERATION_FAILED }); qrWrapper.appendChild(errorMsg); } } const timeNotice = createScreenReaderAnnouncement( MESSAGES.ACCESSIBILITY.TIME_NOTICE ); qrCodeContainer.appendChild(timeNotice); modalBody.appendChild(qrCodeContainer); const whatIsThisLink = createWhatIsThisLink(); modalBody.appendChild(whatIsThisLink); content.appendChild(modalBody); } addMobileContent(modal, token) { const content = querySelector( modal, `.${CSS_CLASSES.MODAL.CONTENT}` ); if (!content) return; const modalBody = createModalBody(); const title = createBrandedTitle(); modalBody.appendChild(title); const subtitle = createSubtitle(MESSAGES.VERIFICATION.MOBILE_SUBTITLE); modalBody.appendChild(subtitle); const mobileContainer = createMobileContainer(); const verifyButton = DeepLinkHandler.createVerifyButton(token); addClass(verifyButton, CSS_CLASSES.BUTTONS.VERIFY); mobileContainer.appendChild(verifyButton); modalBody.appendChild(mobileContainer); const whatIsThisLink = createWhatIsThisLink(); modalBody.appendChild(whatIsThisLink); content.appendChild(modalBody); } addKeyboardListeners() { document.addEventListener("keydown", this.handleKeyDown); } removeKeyboardListeners() { document.removeEventListener("keydown", this.handleKeyDown); } startProgressAnimation() { if (!this.modal) return; const progressBar = querySelector( this.modal, `.${CSS_CLASSES.PROGRESS.BAR}` ); if (!progressBar) return; this.stopProgressAnimation(); const totalDuration = this.tokenExpiration - Date.now(); const startTime = Date.now(); this.updateProgress(progressBar, startTime, totalDuration); this.progressInterval = setInterval(() => { this.updateProgress(progressBar, startTime, totalDuration); }, 100); } stopProgressAnimation() { if (this.progressInterval) { clearInterval(this.progressInterval); this.progressInterval = null; } } updateProgress(progressBar, startTime, totalDuration) { if (!this.modal || !isInDOM(this.modal)) { this.stopProgressAnimation(); return; } const elapsed = Date.now() - startTime; const remaining = Math.max(0, totalDuration - elapsed); const progress = Math.max( 0, Math.min(100, elapsed / totalDuration * 100) ); if (!isInDOM(progressBar)) { this.stopProgressAnimation(); return; } progressBar.style.width = `${100 - progress}%`; const remainingSeconds = Math.ceil(remaining / 1e3); progressBar.setAttribute("aria-valuenow", remainingSeconds.toString()); progressBar.setAttribute( "aria-label", `${remainingSeconds} seconds remaining for verification` ); if (remaining <= 0) { this.stopProgressAnimation(); } } }; _UIManager.MODAL_ID = ELEMENT_IDS.MODAL; _UIManager.TITLE_ID = ELEMENT_IDS.TITLE; _UIManager.DESC_ID = ELEMENT_IDS.DESCRIPTION; let UIManager = _UIManager; export { UIManager }; //# sourceMappingURL=UIManager-zGtGiLS0.js.map