Add reCAPTCHA enterprise support and refactor invite flow (#35134)

GitOrigin-RevId: 4a4ebbd9e5ec55c3532a0cd3fa6c0feda3e2733c
This commit is contained in:
Anna Claire Fields
2026-09-17 11:22:01 +00:00
committed by Copybot
parent 5a602e1b1d
commit da7a2f0e72
9 changed files with 165 additions and 15 deletions
@@ -4,7 +4,6 @@ import AuthorizationMiddleware from '../Authorization/AuthorizationMiddleware.mj
import CollaboratorsInviteController from './CollaboratorsInviteController.mjs'
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
import CaptchaMiddleware from '../Captcha/CaptchaMiddleware.mjs'
import AnalyticsRegistrationSourceMiddleware from '../Analytics/AnalyticsRegistrationSourceMiddleware.mjs'
const rateLimiters = {
@@ -84,7 +83,6 @@ export default {
RateLimiterMiddleware.rateLimit(rateLimiters.inviteToProjectByIp, {
ipOnly: true,
}),
CaptchaMiddleware.validateCaptcha('invite'),
AuthenticationController.requireLogin(),
AuthorizationMiddleware.ensureUserCanAdminProject,
CollaboratorsInviteController.inviteToProject
@@ -387,6 +387,7 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
projectUploadTimeout: Settings.projectUploadTimeout,
recaptchaSiteKey: Settings.recaptcha?.siteKey,
recaptchaSiteKeyV3: Settings.recaptcha?.siteKeyV3,
recaptchaEnterpriseSiteKey: Settings.recaptcha?.enterpriseSiteKey,
recaptchaDisabled: Settings.recaptcha?.disabled,
textExtensions: Settings.textExtensions,
editableFilenames: Settings.editableFilenames,
@@ -8,7 +8,8 @@ import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { useProjectContext } from '@/shared/context/project-context'
import {
resendInvite,
sendInvite,
sendInviteParams,
useSendInvite,
} from '@/features/share-project-modal/utils/api'
import { ContactItem } from '@/features/share-project-modal/components/select-collaborators'
import { useShareProjectContext } from '@/features/share-project-modal/components/share-project-modal'
@@ -47,6 +48,7 @@ function AddCollaboratorsSelect({
const { setInFlight, setError } = useShareProjectContext()
const [isSubmitting, setIsSubmitting] = useState(false)
const { projectId, project, updateProject } = useProjectContext()
const sendInviteHook = useSendInvite()
const { members, invites } = project || {}
const privilegeOptions = useMemo(() => {
@@ -130,7 +132,9 @@ function AddCollaboratorsSelect({
if (invite) {
data = await resendInvite(projectId, invite)
} else {
data = await sendInvite(projectId, email, privileges)
data = await sendInviteHook.run(
...sendInviteParams(projectId, email, privileges)
)
}
const role = data?.invite?.privileges
@@ -245,6 +249,7 @@ function AddCollaboratorsSelect({
projectId,
reset,
selectedItems,
sendInviteHook,
setError,
setInFlight,
updateProject,
@@ -299,6 +304,7 @@ function AddCollaboratorsSelect({
>
{t('invite')}
</ClickableElementEnhancer>
{sendInviteHook.renderRecaptcha()}
</>
)
}
@@ -5,9 +5,8 @@ import {
postJSON,
putJSON,
} from '../../../infrastructure/fetch-json'
import { executeV2Captcha } from './captcha'
import getMeta from '@/utils/meta'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { useFetchWithRecaptcha } from '@/shared/hooks/fetch-with-recaptcha/fetch-with-recaptcha'
export type SharingLinkPrivileges =
| 'readAndWrite'
@@ -35,22 +34,24 @@ export function updateSharingLink(
})
}
export function sendInvite(
export function sendInviteParams(
projectId: string,
email: string,
privileges: PermissionsLevel
) {
return executeV2Captcha(
getMeta('ol-ExposedSettings').recaptchaDisabled?.invite
).then(grecaptchaResponse => {
return postJSON(`/project/${projectId}/invite`, {
return [
`/project/${projectId}/invite`,
{
body: {
email, // TODO: normalisedEmail?
privileges,
'g-recaptcha-response': grecaptchaResponse,
},
})
})
},
] as const
}
export function useSendInvite() {
return useFetchWithRecaptcha(postJSON, { action: 'invite' })
}
export function resendInvite(projectId: string, invite: ProjectMember) {
@@ -0,0 +1,86 @@
import { useCallback, useState } from 'react'
import getMeta from '@/utils/meta'
import { withCaptchaRetry, type FetchMethod } from './with-retry'
import { OLModal, OLModalBody } from '@/shared/components/ol/ol-modal'
import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
import OLSpinner from '@/shared/components/ol/ol-spinner'
const { recaptchaEnterpriseSiteKey } = getMeta('ol-ExposedSettings')
function loadRecaptchaEnterprise(siteKey: string): Promise<void> {
return new Promise((resolve, reject) => {
if (window.grecaptcha?.enterprise) {
resolve()
return
}
const script = document.createElement('script')
script.src = `https://www.google.com/recaptcha/enterprise.js?render=${siteKey}`
script.async = true
script.onload = () => {
window.grecaptcha!.enterprise!.ready(() => resolve())
}
script.onerror = () => reject(new Error('Failed to load reCAPTCHA'))
document.head.appendChild(script)
})
}
async function getEnterpriseToken(
siteKey: string,
action: string
): Promise<string> {
await loadRecaptchaEnterprise(siteKey)
return window.grecaptcha!.enterprise!.execute(siteKey, { action })
}
export const useFetchWithRecaptcha = <R, M extends FetchMethod<R>>(
method: M,
{ action }: { action: string }
) => {
const [isRetrying, setIsRetrying] = useState(false)
const run = useCallback(
async (...args: Parameters<M>): Promise<R> => {
const result = await withCaptchaRetry<R, M>(method, ...args)
if (result.status === 'ok') return result.data
// Without the ability to retry, we throw the original error
if (!recaptchaEnterpriseSiteKey) throw result.error
setIsRetrying(true)
try {
const token = await getEnterpriseToken(
recaptchaEnterpriseSiteKey,
action
)
const data = await result.retry(token)
return data
} finally {
setIsRetrying(false)
}
},
[method, action]
)
const renderRecaptcha = useCallback(
() =>
isRetrying ? (
<OLModal
show
size="sm"
animation
backdrop
className="m-5"
onHide={() => {}}
>
<OLSpinner size="lg" className="m-auto mt-3" />
<OLModalBody>
<RecaptchaConditions />
</OLModalBody>
</OLModal>
) : null,
[isRetrying]
)
return { run, renderRecaptcha }
}
@@ -0,0 +1,53 @@
import {
deleteJSON,
getJSON,
postJSON,
putJSON,
FetchError,
} from '@/infrastructure/fetch-json'
const isCloudArmorReject = (response: Response) => {
// 'x-served-by' is only sent by our backend, so if it's missing and we have a 403,
// it's likely a Cloud Armor block page
return response.status === 403 && !response.headers.get('x-served-by')
}
export type FetchMethod<R> =
| typeof getJSON<R>
| typeof postJSON<R>
| typeof deleteJSON<R>
| typeof putJSON<R>
/**
* Wraps a fetch method so it can be retried with a reCAPTCHA token if it fails due to Cloud Armor.
*/
export const withCaptchaRetry = async <R, M extends FetchMethod<R>>(
method: M,
...fetchArgs: Parameters<M>
): Promise<
| { status: 'ok'; data: R }
| { status: 'retry'; retry: (token: string) => Promise<R>; error: FetchError }
> => {
try {
// @ts-expect-error - typescript does not infer the args type correctly here
const data = (await method(...fetchArgs)) satisfies R
return { status: 'ok', data }
} catch (error) {
if (
error instanceof FetchError &&
error.response &&
isCloudArmorReject(error.response)
) {
const retry = async (token: string) => {
const [path, options, ...rest] = fetchArgs
const newOptions = {
...options,
headers: { ...(options?.headers || {}), 'x-recaptcha-token': token },
}
return await method(path, newOptions, ...rest)
}
return { status: 'retry', retry, error }
}
throw error
}
}
@@ -1,5 +1,5 @@
import { useRef } from 'react'
import ReCAPTCHA from 'react-google-recaptcha'
import type ReCAPTCHA from 'react-google-recaptcha'
export const useRecaptcha = () => {
const ref = useRef<ReCAPTCHA | null>(null)
+1
View File
@@ -37,6 +37,7 @@ export type ExposedSettings = {
}
recaptchaSiteKeyV3?: string
recaptchaSiteKey?: string
recaptchaEnterpriseSiteKey?: string
samlInitPath?: string
sentryAllowedOriginRegex: string
sentryDsn?: string
+4
View File
@@ -8,4 +8,8 @@ export interface ReCaptchaInstance {
render: (element: HTMLElement | string, config: ReCaptchaConfig) => string
execute: (recaptchaId: string) => Promise<string>
reset: (recaptchaId?: string) => void
enterprise?: {
ready: (callback: () => void) => void
execute: (siteKey: string, options: { action: string }) => Promise<string>
}
}