Skip to content
Open
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
1 change: 1 addition & 0 deletions .test/setup-env.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ process.env.ALGOLIA_ID = 'ooo';
process.env.ALGOLIA_KEY = 'ooo';
process.env.ALGOLIA_SEARCH_KEY = 'ooo';
process.env.JWT_SIGNING_SECRET = 'shhhhhh';
process.env.BYPASS_CAPTCHA = 'true';
process.env.FIREBASE_TEST_DB_URL = 'http://localhost:9875?ns=pubpub-v6';
process.env.ZOTERO_CLIENT_KEY = 'abc';
process.env.ZOTERO_CLIENT_SECRET = 'def';
Expand Down
199 changes: 199 additions & 0 deletions client/components/Altcha/Altcha.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';

import { usePageContext } from 'utils/hooks';

export type AltchaRef = {
value: string | null;
verify: () => Promise<string>;
};

type AltchaProps = {
challengeurl?: string;
auto?: 'off' | 'onfocus' | 'onload' | 'onsubmit';
onStateChange?: (ev: Event | CustomEvent<{ payload?: string; state: string }>) => void;
style?: React.CSSProperties & Record<string, string>;
};

const DEFAULT_CHALLENGE_URL = '/api/captcha/challenge';

type WidgetElement = HTMLElement & AltchaWidgetMethods;

const Altcha = forwardRef<AltchaRef, AltchaProps>((props, ref) => {
const { challengeurl = DEFAULT_CHALLENGE_URL, auto, onStateChange, style } = props;
const { locationData } = usePageContext();
const devMode = !locationData.isProd;
const widgetRef = useRef<WidgetElement | null>(null);
const [value, setValue] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const [simulateFailure, setSimulateFailure] = useState(false);
const [widgetKey, setWidgetKey] = useState(0);
const valueRef = useRef<string | null>(null);
valueRef.current = value;

useEffect(() => {
import('altcha').then(() => setLoaded(true));
}, []);

const [altchaVisible, setAltchaVisible] = useState<boolean>(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-attach after remount
useEffect(() => {
if (!loaded) return;
const w = widgetRef.current;
if (!w) return;
const handleStateChange = (ev: Event) => {
const e = ev as CustomEvent<{ payload?: string; state: string }>;
console.log('state changed', e.detail);
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The console.log statement should be removed before merging to production. Debug logging statements can add unnecessary noise to production logs and may expose implementation details about the captcha state machine.

Suggested change
console.log('state changed', e.detail);

Copilot uses AI. Check for mistakes.

switch (e.detail.state) {
case 'error':
case 'code':
case 'unverified':
setAltchaVisible(true);
break;
case 'verifying':
if (devMode) {
setAltchaVisible(true);
}
break;
case 'verified':
if (e.detail.payload) {
setValue(e.detail.payload);
setAltchaVisible(false);
}
break;
default:
break;
}

onStateChange?.(e);
};
w.addEventListener('statechange', handleStateChange);
return () => w.removeEventListener('statechange', handleStateChange);
}, [loaded, onStateChange, widgetKey]);

// biome-ignore lint/correctness/useExhaustiveDependencies: widgetKey triggers re-bind after remount
useImperativeHandle(
ref,
() => ({
get value() {
return valueRef.current;
},
verify(): Promise<string> {
const w = widgetRef.current;
if (!w) return Promise.reject(new Error('Altcha widget not mounted'));
const current = valueRef.current;
if (current) return Promise.resolve(current);
return new Promise((resolve, reject) => {
const handler = (ev: Event) => {
const e = ev as CustomEvent<{ payload?: string; state: string }>;
const state = e.detail?.state;
if (state === 'verified' && e.detail?.payload) {
w.removeEventListener('statechange', handler);
resolve(e.detail.payload);
return;
}
if (state === 'error' || state === 'expired') {
w.removeEventListener('statechange', handler);
reject(new Error('Captcha verification failed'));
}
};
w.addEventListener('statechange', handler);
w.verify();
});
},
}),
[widgetKey],
);

const handleToggleFailure = () => {
setSimulateFailure((prev) => !prev);
setValue(null);
setWidgetKey((k) => k + 1);
};

const handleReset = () => {
setValue(null);
widgetRef.current?.reset();
};

if (!loaded) return null;

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

const widget = (
<React.Fragment key={widgetKey}>
<altcha-widget
delay={500}
ref={widgetRef as any}
challengeurl={challengeurl}
{...(auto ? { auto } : {})}
floating="auto"
{...devAttrs}
{...(simulateFailure ? { mockerror: true } : {})}
style={{
display: altchaVisible ? 'block' : 'none',
zIndex: 1000,
...(style ? ({ style } as any) : {}),
}}
// disable very annoying wait alert
strings="{&quot;waitAlert&quot;:&quot;&quot;}"
/>
</React.Fragment>
);

if (!devMode) return widget;

return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
color: '#5c7080',
padding: '2px 6px',
border: '1px dashed #5c7080',
borderRadius: 3,
}}
>
{widget}
<span style={{ fontWeight: 600 }}>Captcha</span>
<label
style={{
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 3,
color: simulateFailure ? '#db3737' : undefined,
}}
>
<input
type="checkbox"
checked={simulateFailure}
onChange={handleToggleFailure}
style={{ margin: 0 }}
/>
fail
</label>
<button
type="button"
onClick={handleReset}
style={{
fontSize: 11,
padding: '1px 6px',
cursor: 'pointer',
border: '1px solid #ced9e0',
borderRadius: 3,
background: 'white',
lineHeight: '16px',
}}
>
reset
</button>
</div>
);
});

Altcha.displayName = 'Altcha';

export default Altcha;
1 change: 1 addition & 0 deletions client/components/Altcha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { type AltchaRef, default } from './Altcha';
73 changes: 55 additions & 18 deletions client/components/GlobalControls/CreatePubButton.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,72 @@
import React, { useState } from 'react';
import type * as types from 'types';

import React, { useCallback, useRef, useState } from 'react';

import { apiFetch } from 'client/utils/apiFetch';
import { Altcha, Honeypot } from 'components';
import { usePageContext } from 'utils/hooks';

import GlobalControlsButton from './GlobalControlsButton';

const CreatePubButton = () => {
const [isLoading, setIsLoading] = useState(false);
const altchaRef = useRef<import('components').AltchaRef>(null);
const { communityData } = usePageContext();

const handleCreatePub = () => {
setIsLoading(true);
return apiFetch
.post('/api/pubs', { communityId: communityData.id })
.then((newPub) => {
const handleCreatePub = useCallback(
async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const formData = new FormData(evt.currentTarget);
const honeypot = (formData.get('description') as string) ?? '';
setIsLoading(true);
try {
const altchaPayload = await altchaRef.current?.verify();
if (!altchaPayload) return;
const newPub = await apiFetch.post<types.Pub>('/api/pubs/fromForm', {
communityId: communityData.id,
altcha: altchaPayload,
_honeypot: honeypot,
});
window.location.href = `/pub/${newPub.slug}`;
})
.catch((err) => {
console.error(err);
} catch (error) {
console.error('Error in handleCreatePub', error);
} finally {
setIsLoading(false);
});
};
}
},
[communityData.id],
);

const formRef = useRef<HTMLFormElement>(null);

return (
<GlobalControlsButton
loading={isLoading}
aria-label="Create Pub"
onClick={handleCreatePub}
desktop={{ text: 'Create Pub' }}
mobile={{ icon: 'pubDocNew' }}
/>
<form
onSubmit={handleCreatePub}
ref={formRef}
// only relevant in dev
style={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<Altcha
ref={altchaRef}
onStateChange={(state) => {
if (!isLoading) {
setIsLoading(true);
}
// @ts-ignore
if (state.detail.state === 'verified') {
setIsLoading(false);
}
}}
Comment on lines +51 to +59
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a logic issue with the loading state management. Lines 52-54 set isLoading to true whenever onStateChange fires and isLoading is false, but line 21 also sets it to true. This creates a race condition where the loading state might be incorrectly set. The onStateChange handler should only manage loading state for the captcha verification itself, not for the entire form submission. Consider removing the isLoading management from onStateChange or using separate state variables for captcha loading vs. form submission loading.

Copilot uses AI. Check for mistakes.
/>
<Honeypot name="description" />
<GlobalControlsButton
loading={isLoading}
aria-label="Create Pub"
type="submit"
desktop={{ text: 'Create Pub' }}
mobile={{ icon: 'pubDocNew' }}
/>
</form>
);
};

Expand Down
54 changes: 54 additions & 0 deletions client/components/Honeypot/Honeypot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';

import { usePageContext } from 'utils/hooks';

import './honeypot.scss';

type HoneypotProps = {
name: string;
};

const Honeypot = (props: HoneypotProps) => {
const { name } = props;
const { locationData } = usePageContext();
const devMode = !locationData.isProd;

if (devMode) {
return (
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
color: 'orange',
fontWeight: 600,
padding: '2px 6px',
border: '1px dashed orange',
borderRadius: 3,
}}
>
Honeypot
<input
type="text"
name={name}
autoComplete="off"
style={{ width: 80, fontSize: 11, padding: '1px 4px' }}
/>
</label>
);
}

return (
<input
type="text"
className="honeypot-input"
name={name}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
/>
);
};

export default Honeypot;
8 changes: 8 additions & 0 deletions client/components/Honeypot/honeypot.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.honeypot-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
1 change: 1 addition & 0 deletions client/components/Honeypot/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './Honeypot';
Loading