mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Add reCAPTCHA enterprise support and refactor invite flow (#35134)
GitOrigin-RevId: 4a4ebbd9e5ec55c3532a0cd3fa6c0feda3e2733c
This commit is contained in:
committed by
Copybot
parent
5a602e1b1d
commit
da7a2f0e72
@@ -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
-2
@@ -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)
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ExposedSettings = {
|
||||
}
|
||||
recaptchaSiteKeyV3?: string
|
||||
recaptchaSiteKey?: string
|
||||
recaptchaEnterpriseSiteKey?: string
|
||||
samlInitPath?: string
|
||||
sentryAllowedOriginRegex: string
|
||||
sentryDsn?: string
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user