Skip to content

Commit a983c9e

Browse files
authored
one-shot quick fix on altcha (#3547)
1 parent dfdc1a9 commit a983c9e

8 files changed

Lines changed: 64 additions & 13 deletions

File tree

client/components/Altcha/Altcha.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
2828
const [simulateFailure, setSimulateFailure] = useState(false);
2929
const [widgetKey, setWidgetKey] = useState(0);
3030
const valueRef = useRef<string | null>(null);
31+
const errorRef = useRef(false);
3132
valueRef.current = value;
3233

3334
useEffect(() => {
@@ -46,6 +47,9 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
4647

4748
switch (e.detail.state) {
4849
case 'error':
50+
errorRef.current = true;
51+
setAltchaVisible(true);
52+
break;
4953
case 'code':
5054
case 'unverified':
5155
setAltchaVisible(true);
@@ -56,6 +60,7 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
5660
}
5761
break;
5862
case 'verified':
63+
errorRef.current = false;
5964
if (e.detail.payload) {
6065
setValue(e.detail.payload);
6166
setAltchaVisible(false);
@@ -80,9 +85,13 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
8085
},
8186
verify(): Promise<string> {
8287
const w = widgetRef.current;
83-
if (!w) return Promise.reject(new Error('Altcha widget not mounted'));
88+
// If widget failed to load or challenge endpoint is down,
89+
// resolve with empty string so the form can still submit.
90+
// The server decides whether to accept requests without captcha.
91+
if (!w) return Promise.resolve('');
8492
const current = valueRef.current;
8593
if (current) return Promise.resolve(current);
94+
if (errorRef.current) return Promise.resolve('');
8695
return new Promise((resolve, reject) => {
8796
const handler = (ev: Event) => {
8897
const e = ev as CustomEvent<{ payload?: string; state: string }>;
@@ -94,7 +103,9 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
94103
}
95104
if (state === 'error' || state === 'expired') {
96105
w.removeEventListener('statechange', handler);
97-
reject(new Error('Captcha verification failed'));
106+
// Resolve with empty string instead of rejecting
107+
// so the form submission can proceed to the server.
108+
resolve('');
98109
}
99110
};
100111
w.addEventListener('statechange', handler);
@@ -116,6 +127,35 @@ const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
116127
widgetRef.current?.reset();
117128
};
118129

130+
// Remove the `required` attribute that altcha-widget sets on its internal
131+
// hidden checkbox. This prevents native browser form validation from
132+
// throwing "An invalid form control is not focusable" when the challenge
133+
// endpoint is unavailable and the checkbox can never be checked.
134+
// We handle verification entirely through the imperative verify() API.
135+
// biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-attach after remount
136+
useEffect(() => {
137+
if (!loaded) return;
138+
const w = widgetRef.current;
139+
if (!w) return;
140+
const removeRequired = () => {
141+
const root = w.shadowRoot;
142+
if (root) {
143+
const checkbox = root.querySelector('input[type="checkbox"]');
144+
if (checkbox) {
145+
checkbox.removeAttribute('required');
146+
}
147+
const hiddenInput = root.querySelector('input[type="hidden"]');
148+
if (hiddenInput) {
149+
hiddenInput.removeAttribute('required');
150+
}
151+
}
152+
};
153+
// Run immediately and also after a short delay to catch async renders
154+
removeRequired();
155+
const timer = setTimeout(removeRequired, 500);
156+
return () => clearTimeout(timer);
157+
}, [loaded, widgetKey]);
158+
119159
if (!loaded) return null;
120160

121161
const devAttrs = devMode ? { debug: true, floatingpersist: 'focus' as const } : {};

client/containers/CommunityCreate/CommunityCreate.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ const CommunityCreate = () => {
172172
features and functionality are available, but only logged in Members
173173
will be able to view the community.
174174
</p>
175-
<form onSubmit={onCreateSubmit}>
175+
<form onSubmit={onCreateSubmit} noValidate>
176176
<Honeypot name="website" />
177177
<InputField
178178
label="URL"

client/containers/Login/Login.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,9 @@ const Login = () => {
7070
return altchaRef.current
7171
?.verify()
7272
.then(doLogin)
73-
.catch(() => {
73+
.catch((err) => {
7474
setLoginLoading(false);
75-
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '"Verification failed..."' is ... Remove this comment to see the full error message
76-
setLoginError('Verification failed. Please try again.');
75+
setLoginError(err?.message || 'Verification failed. Please try again.');
7776
});
7877
};
7978
const onLogoutSubmit = () => {
@@ -95,7 +94,7 @@ const Login = () => {
9594
<a href="https://www.pubpub.org">PubPub</a> account.
9695
</p>
9796
)}
98-
<form onSubmit={onLoginSubmit}>
97+
<form onSubmit={onLoginSubmit} noValidate>
9998
<InputField
10099
label="Email"
101100
placeholder="example@email.com"

client/containers/Signup/Signup.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const Signup = () => {
8686
other PubPub communities.
8787
</p>
8888
)}
89-
<form onSubmit={onSignupSubmit}>
89+
<form onSubmit={onSignupSubmit} noValidate>
9090
<InputField
9191
label="Email"
9292
placeholder="example@email.com"
@@ -125,7 +125,7 @@ const Signup = () => {
125125
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ title: string; description: Element; visua... Remove this comment to see the full error message
126126
visual="tick-circle"
127127
action={
128-
<form onSubmit={handleResendEmail}>
128+
<form onSubmit={handleResendEmail} noValidate>
129129
<Button
130130
name="resendEmail"
131131
disabled={!altchaRef.current?.value}

client/containers/UserCreate/UserCreate.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ const UserCreate = (props: Props) => {
215215
{!signupData.hashError && (
216216
<GridWrapper containerClassName="small">
217217
<h1>Create Account</h1>
218-
<form onSubmit={onCreateSubmit}>
218+
<form onSubmit={onCreateSubmit} noValidate>
219219
<InputField label="Email" isDisabled={true} value={signupData.email} />
220220
<InputField
221221
label="First Name"

server/login/api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,14 @@ export const loginFromFormRouteImplementation: AppRouteImplementation<
160160
typeof contract.auth.loginFromForm
161161
> = async ({ req, res }) => {
162162
const ok = await verifyCaptchaPayload(req.body.altcha);
163-
if (!ok) {
163+
if (!ok && req.body.altcha) {
164+
// Payload was provided but invalid — reject
164165
return { status: 400, body: 'Please complete the verification and try again.' } as const;
165166
}
167+
if (!ok) {
168+
// No payload at all (captcha service may be down) — allow login
169+
// with a warning so we can track how often this happens.
170+
console.warn('[login] captcha payload missing — allowing login without captcha');
171+
}
166172
return performLogin(req, res);
167173
};

server/signup/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ router.post('/api/signup', async (req, res) => {
1212
return res.status(201).json(true);
1313
}
1414
const ok = await verifyCaptchaPayload(req.body.altcha);
15-
if (!ok) {
15+
if (!ok && req.body.altcha) {
1616
return res.status(400).json('Please complete the verification and try again.');
1717
}
18+
if (!ok) {
19+
console.warn('[signup] captcha payload missing — allowing signup without captcha');
20+
}
1821
const { _honeypot, altcha: _altcha, ...body } = req.body;
1922
return createSignup(body, req.hostname)
2023
.then(() => res.status(201).json(true))

server/user/api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@ router.post('/api/users', async (req, res) => {
5959
throw new Error('Not Authorized');
6060
}
6161
const ok = await verifyCaptchaPayload(req.body.altcha);
62-
if (!ok) {
62+
if (!ok && req.body.altcha) {
6363
return res.status(400).json('Please complete the verification and try again.');
6464
}
65+
if (!ok) {
66+
console.warn('[user/create] captcha payload missing — allowing without captcha');
67+
}
6568
const { altcha, _honeypot, _passwordHoneypot, _formStartedAtMs, ...body } = { ...req.body };
6669
const fastHoneypotSignal = getFastHoneypotSignal({
6770
honeypot: _passwordHoneypot,

0 commit comments

Comments
 (0)