mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-10 04:35:46 +00:00
fix(auth): put the captcha challenge where the user is looking (#33)
Driving the real login page in a browser showed what shipping this actually looks like when Cloudflare decides a visitor must click: the submit button sits on 'Signing in...' while an unnoticed 'Verify you are human' checkbox waits in the bottom-right corner. Nothing tells the user it is there. They conclude login is broken, and they are not wrong. Two changes: - The challenge is centred over a dimmed backdrop while it is visible, and returns to the corner once answered. Detected by measuring the container rather than by Turnstile's interactive callbacks: those callbacks are not in the documentation this was written against, and a callback name that turns out to be wrong fails silently — leaving the challenge in the corner, which is the bug being fixed. - A 90 second cap on waiting for a token. Without it, a challenge nobody answers leaves the button spinning forever with no error and no way back. Every caller already surfaces a rejection to the user. The ResizeObserver fallback polls instead of returning a no-op. Doing nothing there would restore the original bug on those browsers and do it without an error, which is the worst way for a fix to be missing.
This commit is contained in:
committed by
GitHub
parent
c26eeee12c
commit
338472629d
+80
-5
@@ -66,18 +66,78 @@ function loadScript(): Promise<void> {
|
||||
return scriptPromise;
|
||||
}
|
||||
|
||||
// How long to wait for a token before giving the caller an error. Generous,
|
||||
// because it covers a human reading and clicking a challenge — but finite, so a
|
||||
// challenge that is never answered ends as a message instead of a button stuck
|
||||
// on "Signing in..." forever.
|
||||
const TOKEN_TIMEOUT_MS = 90_000;
|
||||
|
||||
function ensureContainer(): HTMLElement {
|
||||
if (container?.isConnected) return container;
|
||||
container = document.createElement('div');
|
||||
container.id = CONTAINER_ID;
|
||||
container.style.position = 'fixed';
|
||||
container.style.bottom = '1rem';
|
||||
container.style.right = '1rem';
|
||||
container.style.zIndex = '2147483647';
|
||||
document.body.appendChild(container);
|
||||
parkWidget(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
/** Out of the way: nothing is being asked of the user. */
|
||||
function parkWidget(el: HTMLElement) {
|
||||
el.style.inset = 'auto 1rem 1rem auto';
|
||||
el.style.transform = 'none';
|
||||
el.style.boxShadow = 'none';
|
||||
el.style.borderRadius = '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Centre the challenge over a dimmed backdrop.
|
||||
*
|
||||
* A challenge in the corner is a challenge people miss: the submit button sits
|
||||
* on "Signing in..." while an unnoticed checkbox waits at the edge of the
|
||||
* screen. Verified in a real browser — that is exactly what it looked like.
|
||||
* When Cloudflare decides it needs a click, put the click where the user is
|
||||
* already looking.
|
||||
*/
|
||||
function highlightWidget(el: HTMLElement) {
|
||||
el.style.inset = '50% auto auto 50%';
|
||||
el.style.transform = 'translate(-50%, -50%)';
|
||||
el.style.boxShadow = '0 0 0 100vmax rgba(0,0,0,.6), 0 10px 40px rgba(0,0,0,.5)';
|
||||
el.style.borderRadius = '4px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for the widget becoming visible and centre it when it does.
|
||||
*
|
||||
* Turnstile has callbacks that fire around interactive challenges, but they are
|
||||
* not in the documentation this was written against, and a callback name that
|
||||
* turns out to be wrong fails silently — leaving the challenge in the corner,
|
||||
* which is the bug being fixed. Measuring the element is checkable: with
|
||||
* `interaction-only` the container has no size until there is something to
|
||||
* click.
|
||||
*/
|
||||
function watchVisibility(el: HTMLElement): () => void {
|
||||
const apply = () => {
|
||||
const visible = el.getBoundingClientRect().height > 0;
|
||||
if (visible) highlightWidget(el);
|
||||
else parkWidget(el);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver(apply);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
// No ResizeObserver: poll instead of doing nothing. Returning a no-op here
|
||||
// would leave the challenge in the corner on those browsers — the exact bug
|
||||
// this function exists to fix — and it would do so without any error, which
|
||||
// is the worst way for a fix to be missing.
|
||||
const poll = setInterval(apply, 400);
|
||||
return () => clearInterval(poll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a fresh Turnstile token, or undefined when no site key is configured.
|
||||
*
|
||||
@@ -111,14 +171,29 @@ export async function getCaptchaToken(): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let stopWatching = () => {};
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
clearTimeout(timer);
|
||||
stopWatching();
|
||||
parkWidget(el);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(
|
||||
() => settle(() => reject(new Error('Captcha timed out'))),
|
||||
TOKEN_TIMEOUT_MS
|
||||
);
|
||||
|
||||
stopWatching = watchVisibility(el);
|
||||
|
||||
widgetId = turnstile.render(el, {
|
||||
sitekey: SITE_KEY,
|
||||
execution: 'execute',
|
||||
appearance: 'interaction-only',
|
||||
action: 'turnstile-spin-v2',
|
||||
callback: (token: string) => resolve(token),
|
||||
'error-callback': () => reject(new Error('Captcha failed')),
|
||||
'timeout-callback': () => reject(new Error('Captcha timed out')),
|
||||
callback: (token: string) => settle(() => resolve(token)),
|
||||
'error-callback': () => settle(() => reject(new Error('Captcha failed'))),
|
||||
});
|
||||
|
||||
turnstile.execute(el);
|
||||
|
||||
Reference in New Issue
Block a user