All files / src/lib/components HCaptcha.svelte

0% Statements 0/21
0% Branches 0/15
0% Functions 0/8
0% Lines 0/19

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105                                                                                                                                                                                                                 
<script lang="ts">
	import { onMount } from 'svelte';
	import { browser } from '$app/environment';
	import { PUBLIC_HCAPTCHA_SITE_KEY } from '$env/static/public';
 
	interface Props {
		/** Called with the verified token. Token is single-use; clear on submit. */
		onverify?: (token: string) => void;
		/** Called when the token expires before submit. */
		onexpire?: () => void;
		/** Called on widget error (network failure, ad-blocker, hCaptcha outage). */
		onerror?: () => void;
	}
 
	let { onverify, onexpire, onerror }: Props = $props();
 
	// `loaded` flips to true once the dynamic import resolves and the
	// <h-captcha> custom element is registered. Until then we render only the
	// SSR-stable placeholder, so initial markup matches between server and
	// client and the layout doesn't shift on hydration.
	let loaded = $state(false);
	// Reference to the underlying <h-captcha> element so we can call reset()
	// on it when a consumer's auth call fails.
	let widgetEl: HTMLElement | undefined = $state();
 
	const sitekey = PUBLIC_HCAPTCHA_SITE_KEY ?? '';
 
	/**
	 * Reset the widget — exposed via bind:this. Single-use tokens are consumed
	 * by Supabase on first verification; on a failed auth call the consumer
	 * must reset() so the user gets a fresh challenge instead of being stuck
	 * with a dead token.
	 */
	export function reset(): void {
		// Underlying element exposes reset() per @hcaptcha/vanilla-hcaptcha API.
		(widgetEl as unknown as { reset?: () => void } | undefined)?.reset?.();
	}
 
	function handleVerified(e: Event): void {
		const detail = (e as CustomEvent<{ token?: string }>).detail;
		// Older versions of the wrapper attach `token` to the event itself
		// rather than `event.detail`; accept both shapes.
		const token = detail?.token ?? (e as unknown as { token?: string }).token ?? '';
		if (token) onverify?.(token);
	}
 
	function handleExpired(): void {
		onexpire?.();
	}
 
	function handleError(): void {
		if (onerror) onerror();
	}
 
	// `challenge-expired` is hyphenated and can't be wired via the on{event}
	// attribute shorthand; subscribe imperatively once the element is bound.
	$effect(() => {
		if (!widgetEl) return;
		const el = widgetEl;
		el.addEventListener('challenge-expired', handleExpired);
		return () => el.removeEventListener('challenge-expired', handleExpired);
	});
 
	onMount(() => {
		// Web component touches `customElements`; only register client-side.
		// Dynamic import keeps the wrapper out of the SSR bundle entirely.
		if (!sitekey) {
			// Misconfiguration — surface via onerror so the consumer's submit
			// guard keeps short-circuiting. We still render nothing.
			onerror?.();
			return;
		}
		import('@hcaptcha/vanilla-hcaptcha')
			.then(() => {
				loaded = true;
			})
			.catch(() => {
				// Import failure (CDN blocked, network outage). Notify consumer
				// so the inline guard message stays visible and submit is gated.
				onerror?.();
			});
	});
</script>
 
<div class="hcaptcha" role="group" aria-label="CAPTCHA challenge" data-captcha-placeholder>
	{#if Ibrowser && loaded && sitekey}
		<h-captcha
			bind:this={widgetEl}
			sitekey={sitekey}
			size="normal"
			onverified={handleVerified}
			onexpired={handleExpired}
			onerror={handleError}
		></h-captcha>
	{/if}
</div>
 
<style>
	.hcaptcha {
		/* The widget renders a fixed 303x78 iframe; reserve space so the layout
		 * doesn't shift when the script finishes loading. */
		min-height: 78px;
	}
</style>