Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Google reCAPTCHA Enterprise checkbox site key
# Google reCAPTCHA Enterprise site key
VITE_RECAPTCHA_SITE_KEY=

# Browser-facing backend base paths. In Vercel these route through vercel.json rewrites.
Expand Down
2 changes: 1 addition & 1 deletion scripts/setupEnv.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const serverEnvLocalPath = path.join(rootDir, '.env.server.local');
const OWNER_READ_WRITE_MODE = 0o600;

const defaultEnvContent = [
'# Google reCAPTCHA Enterprise checkbox site key (required when provider is googleRecaptchaEnterprise)',
'# Google reCAPTCHA Enterprise site key (required when provider is googleRecaptchaEnterprise)',
'VITE_RECAPTCHA_SITE_KEY=',
'',
'# Backend base URLs. Provide deployed values via .env.local or secret management.',
Expand Down
9 changes: 5 additions & 4 deletions src/pages/Captcha/hooks/useCaptchaPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function useCaptchaPage(): UseCaptchaPageReturn {
const [feedbackMessage, setFeedbackMessage] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const recaptchaSiteKey = (import.meta.env.VITE_RECAPTCHA_SITE_KEY ?? '').trim();
const canSubmit = recaptchaSiteKey.length > 0 && (token?.trim().length ?? 0) > 0;
const canSubmit = recaptchaSiteKey.length > 0 && (token?.trim().length ?? 0) > 0 && !isVerifying;

const handleCaptchaTokenChange = useCallback((nextToken: string | null) => {
setToken(nextToken);
Expand All @@ -27,13 +27,14 @@ export function useCaptchaPage(): UseCaptchaPageReturn {

const handleSubmit = useCallback(async () => {
setFeedbackMessage(null);
const trimmedToken = token?.trim() ?? '';

if (recaptchaSiteKey.length === 0) {
setFeedbackMessage('reCAPTCHA 사이트 키가 비어 있습니다. `.env.local`을 확인해 주세요.');
setFeedbackMessage('reCAPTCHA 사이트 키가 비어 있습니다.');
return;
}

const trimmedToken = token?.trim() ?? '';

if (!trimmedToken) {
setFeedbackMessage('캡차를 먼저 완료해 주세요.');
return;
Expand All @@ -49,7 +50,7 @@ export function useCaptchaPage(): UseCaptchaPageReturn {
return;
}

setFeedbackMessage('캡차 검증이 완료되었습니다. 스캔 화면으로 이동합니다.');
setFeedbackMessage('캡차 검증이 완료되었습니다.');
void navigate({ to: '/qr-scan' });
} finally {
setIsVerifying(false);
Expand Down
254 changes: 254 additions & 0 deletions src/pages/Captcha/lib/recaptchaEnterprise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
type RenderCaptchaOptions = {
onError: () => void;
onExpired: () => void;
onToken: (token: string) => void;
signal?: AbortSignal;
siteKey: string;
};

type GrecaptchaEnterprise = {
ready: (callback: () => void) => void;
render: (
container: HTMLElement,
parameters: {
callback: (token: string) => void;
'error-callback': () => void;
'expired-callback': () => void;
sitekey: string;
theme?: 'light' | 'dark';
},
) => number;
reset?: (widgetId: number) => void;
};

declare global {
interface Window {
grecaptcha?: {
enterprise?: GrecaptchaEnterprise;
};
}
}

let enterpriseScriptPromise: Promise<void> | null = null;
const enterpriseScriptSources = [
'https://www.google.com/recaptcha/enterprise.js?render=explicit',
'https://www.recaptcha.net/recaptcha/enterprise.js?render=explicit',
] as const;
const enterpriseApiReadyTimeoutMs = 5_000;
const enterpriseApiReadyCheckIntervalMs = 50;

function getEnterpriseApi(): GrecaptchaEnterprise | null {
return window.grecaptcha?.enterprise ?? null;
}

function isEnterpriseApiReady(): boolean {
return typeof window.grecaptcha?.enterprise?.render === 'function';
}

function waitForEnterpriseApiReady(timeoutMs = enterpriseApiReadyTimeoutMs): Promise<void> {
if (isEnterpriseApiReady()) {
return Promise.resolve();
}

return new Promise((resolve, reject) => {
const startedAt = Date.now();

const checkReady = () => {
if (isEnterpriseApiReady()) {
resolve();
return;
}

if (Date.now() - startedAt >= timeoutMs) {
reject(new Error('Enterprise API is not ready after script load.'));
return;
}

window.setTimeout(checkReady, enterpriseApiReadyCheckIntervalMs);
};

checkReady();
});
}

function buildScriptSelector(sourceUrl: string): string {
return `script[data-recaptcha-enterprise-src="${sourceUrl}"]`;
}

function waitForScriptReady(script: HTMLScriptElement): Promise<void> {
return new Promise((resolve, reject) => {
const onLoad = () => {
void waitForEnterpriseApiReady()
.then(() => {
cleanup();
resolve();
})
.catch((error) => {
cleanup();
reject(error);
});
};

const onError = () => {
cleanup();
reject(new Error('Failed to load reCAPTCHA Enterprise script.'));
};

const cleanup = () => {
script.removeEventListener('load', onLoad);
script.removeEventListener('error', onError);
};

script.addEventListener('load', onLoad);
script.addEventListener('error', onError);

if (script.dataset.loadStatus === 'loaded' || isEnterpriseApiReady()) {
onLoad();
return;
}

if (script.dataset.loadStatus === 'error') {
onError();
}
});
}

async function loadEnterpriseScriptBySource(sourceUrl: string): Promise<void> {
const existingScript = document.querySelector<HTMLScriptElement>(buildScriptSelector(sourceUrl));

if (existingScript) {
if (existingScript.dataset.loadStatus === 'loaded' || isEnterpriseApiReady()) {
await waitForEnterpriseApiReady();
return;
}

if (existingScript.dataset.loadStatus === 'error') {
throw new Error('Failed to load reCAPTCHA Enterprise script.');
}

await waitForScriptReady(existingScript);
return;
}

const script = document.createElement('script');
script.async = true;
script.defer = true;
script.dataset.loadStatus = 'loading';
script.dataset.recaptchaEnterprise = 'true';
script.dataset.recaptchaEnterpriseSrc = sourceUrl;
script.src = sourceUrl;

const readyPromise = waitForScriptReady(script);

script.onload = () => {
script.dataset.loadStatus = 'loaded';
};

script.onerror = () => {
script.dataset.loadStatus = 'error';
};

document.head.appendChild(script);
await readyPromise;
}

export function loadRecaptchaEnterprise(): Promise<void> {
if (isEnterpriseApiReady()) {
return Promise.resolve();
}

if (enterpriseScriptPromise) {
return enterpriseScriptPromise;
}

const scriptPromise = (async () => {
let lastError: unknown = null;

for (const sourceUrl of enterpriseScriptSources) {
try {
await loadEnterpriseScriptBySource(sourceUrl);

if (isEnterpriseApiReady()) {
return;
}
} catch (error) {
lastError = error;
}
}

if (lastError instanceof Error) {
throw lastError;
}

throw new Error('Failed to load reCAPTCHA Enterprise script.');
})().catch((error) => {
enterpriseScriptPromise = null;
throw error;
});

enterpriseScriptPromise = scriptPromise;
return scriptPromise;
}

export async function renderRecaptchaEnterprise(
container: HTMLElement,
{ onError, onExpired, onToken, signal, siteKey }: RenderCaptchaOptions,
): Promise<number> {
if (signal?.aborted) {
throw new Error('RECAPTCHA_RENDER_ABORTED');
}

await loadRecaptchaEnterprise();

const enterprise = getEnterpriseApi();

if (!enterprise) {
throw new Error('reCAPTCHA Enterprise API is unavailable.');
}

return new Promise((resolve, reject) => {
const abortError = new Error('RECAPTCHA_RENDER_ABORTED');
const onAbort = () => {
reject(abortError);
};

if (signal?.aborted) {
reject(abortError);
return;
}

signal?.addEventListener('abort', onAbort, { once: true });

const detachAbortListener = () => {
signal?.removeEventListener('abort', onAbort);
};

enterprise.ready(() => {
if (signal?.aborted) {
detachAbortListener();
reject(abortError);
return;
}

try {
const widgetId = enterprise.render(container, {
callback: onToken,
'error-callback': onError,
'expired-callback': onExpired,
sitekey: siteKey,
theme: 'light',
});

detachAbortListener();
resolve(widgetId);
} catch (error) {
detachAbortListener();
reject(error);
}
});
});
}

export function resetRecaptchaEnterprise(widgetId: number): void {
window.grecaptcha?.enterprise?.reset?.(widgetId);
}
9 changes: 9 additions & 0 deletions src/pages/Captcha/styles/captchaPage.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,18 @@ export const widgetFrame = style({

export const enterpriseBox = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '78px',
gap: vars.spacing.xs,
});

export const captchaError = style({
margin: 0,
color: '#B42318',
fontSize: vars.font.size.xs,
lineHeight: 1.4,
});

export const captchaHint = style({
Expand Down
Loading
Loading