@10x-media/form-builder 0.1.0-beta.13 → 0.1.0-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/collections/formSubmissions.js +40 -20
- package/dist/collections/formSubmissions.js.map +1 -1
- package/dist/collections/forms.js +20 -0
- package/dist/collections/forms.js.map +1 -1
- package/dist/exports/react.d.ts +3 -1
- package/dist/exports/rsc.d.ts +2 -1
- package/dist/exports/rsc.js +4 -3
- package/dist/form/pollState.d.ts +2 -1
- package/dist/form/pollState.js.map +1 -1
- package/dist/form/toFormDocument.d.ts +1 -0
- package/dist/form/toFormDocument.js +1 -0
- package/dist/form/toFormDocument.js.map +1 -1
- package/dist/form/types.d.ts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/poll/votes/aggregateFromVotes.js +2 -1
- package/dist/poll/votes/aggregateFromVotes.js.map +1 -1
- package/dist/poll/votes/answerValues.js +16 -0
- package/dist/poll/votes/answerValues.js.map +1 -0
- package/dist/poll/votes/voteTallyHook.js +31 -13
- package/dist/poll/votes/voteTallyHook.js.map +1 -1
- package/dist/react/Form.d.ts +12 -2
- package/dist/react/Form.js +8 -3
- package/dist/react/Form.js.map +1 -1
- package/dist/react/FormResults.d.ts +4 -2
- package/dist/react/FormResults.js +21 -8
- package/dist/react/FormResults.js.map +1 -1
- package/dist/react/Poll.d.ts +14 -1
- package/dist/react/Poll.js +66 -21
- package/dist/react/Poll.js.map +1 -1
- package/dist/react/adapters.d.ts +35 -0
- package/dist/react/adapters.js +11 -0
- package/dist/react/adapters.js.map +1 -0
- package/dist/react/captcha/HcaptchaCaptcha.d.ts +4 -1
- package/dist/react/captcha/HcaptchaCaptcha.js +2 -2
- package/dist/react/captcha/HcaptchaCaptcha.js.map +1 -1
- package/dist/react/captcha/RecaptchaCaptcha.d.ts +4 -1
- package/dist/react/captcha/RecaptchaCaptcha.js +2 -2
- package/dist/react/captcha/RecaptchaCaptcha.js.map +1 -1
- package/dist/react/captcha/TurnstileCaptcha.d.ts +4 -1
- package/dist/react/captcha/TurnstileCaptcha.js +2 -2
- package/dist/react/captcha/TurnstileCaptcha.js.map +1 -1
- package/dist/react/captcha/useCaptchaScript.js +23 -5
- package/dist/react/captcha/useCaptchaScript.js.map +1 -1
- package/dist/spam/spamGuard.js +2 -1
- package/dist/spam/spamGuard.js.map +1 -1
- package/dist/submissions/resolveVotedSubmission.d.ts +27 -0
- package/dist/submissions/resolveVotedSubmission.js +51 -0
- package/dist/submissions/resolveVotedSubmission.js.map +1 -0
- package/dist/submissions/validateSubmission.js +17 -6
- package/dist/submissions/validateSubmission.js.map +1 -1
- package/dist/submissions/verifyContext.js +1 -1
- package/dist/submissions/voteChangeEndpoint.js +94 -0
- package/dist/submissions/voteChangeEndpoint.js.map +1 -0
- package/dist/submissions/votedCookie.d.ts +2 -0
- package/dist/submissions/votedCookie.js +53 -6
- package/dist/submissions/votedCookie.js.map +1 -1
- package/dist/translations/clientRuntimeKeys.js +3 -1
- package/dist/translations/clientRuntimeKeys.js.map +1 -1
- package/dist/translations/de.js +5 -0
- package/dist/translations/de.js.map +1 -1
- package/dist/translations/en.js +5 -0
- package/dist/translations/en.js.map +1 -1
- package/dist/translations/keys.d.ts +5 -0
- package/dist/translations/keys.js +5 -0
- package/dist/translations/keys.js.map +1 -1
- package/dist/validation/builtin/notAlreadySubmitted.js +3 -1
- package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
- package/package.json +3 -3
package/dist/react/Poll.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Poll.js","names":[],"sources":["../../src/react/Poll.tsx"],"sourcesContent":["'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { isPollClosed } from '../form/pollState'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport { Form, type FormProps } from './Form'\nimport { FormResults } from './FormResults'\nimport { type FetchResultsResult, fetchFormResults } from './fetchResults'\n\nexport type PollProps = FormProps & {\n\t/** The choice field whose results are shown after voting (should match the form's public `poll.resultsField`). */\n\tresultsField: string\n\t/** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */\n\tstorageKey?: string\n\t/**\n\t * Server-known voted state, ORed with the localStorage guard: `true` marks the visitor as voted;\n\t * `false`/omitted falls back to localStorage. SSR hosts using the plugin's `poll.votedCookie`\n\t * option pass `hasVotedCookie(cookieHeader, form.id)` here.\n\t */\n\thasVoted?: boolean\n\t/**\n\t * Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module\n\t * scope, `useCallback`, or `useMemo`): an inline function re-runs the load effect and double-fetches.\n\t */\n\tfetchResultsImpl?: typeof fetchFormResults\n}\n\nconst readVoted = (key: string): boolean => {\n\ttry {\n\t\treturn window.localStorage.getItem(key) != null\n\t} catch {\n\t\treturn false\n\t}\n}\n\nconst writeVoted = (key: string): void => {\n\ttry {\n\t\twindow.localStorage.setItem(key, '1')\n\t} catch {\n\t\t// Private mode / storage disabled: the guard is best-effort UX, never integrity.\n\t}\n}\n\n/**\n * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows\n * `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated\n * notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open\n * `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A\n * recorded `outcome.winningValues` (via `resolvePollOutcome`) supersedes everything: a translated final\n * notice plus results with every winning bucket highlighted (a tie highlights more than one). A\n * per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`\n * (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not\n * integrity (bypassable): server-enforced\n * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.\n */\nexport const Poll = ({\n\tresultsField,\n\tstorageKey,\n\thasVoted,\n\tfetchResultsImpl = fetchFormResults,\n\tapiRoute,\n\tonSuccess,\n\t...formProps\n}: PollProps) => {\n\tconst key = storageKey ?? `fb-poll-${formProps.form.id}`\n\tconst poll = formProps.form.poll\n\tconst closed = isPollClosed(poll)\n\tconst winningValues = poll?.outcome?.winningValues\n\tconst finalized = Array.isArray(winningValues) && winningValues.length > 0\n\tconst resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === 'afterClose'\n\tconst [voted, setVoted] = useState(false)\n\tconst [results, setResults] = useState<FieldAggregation[] | null>(null)\n\tconst [loadFailed, setLoadFailed] = useState(false)\n\tconst translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.t])\n\n\tconst loadResults = useCallback(async () => {\n\t\tconst result: FetchResultsResult = await fetchResultsImpl({\n\t\t\tformId: formProps.form.id,\n\t\t\tfield: resultsField,\n\t\t\tapiRoute,\n\t\t})\n\t\t// A failed load surfaces as an error, not an empty result set: `[]` would read as \"no votes yet\".\n\t\tif (result.ok) {\n\t\t\tsetResults(result.results)\n\t\t\tsetLoadFailed(false)\n\t\t} else {\n\t\t\tsetLoadFailed(true)\n\t\t}\n\t}, [fetchResultsImpl, formProps.form.id, resultsField, apiRoute])\n\n\tconst resultsError = (\n\t\t<p className=\"fb-poll__error\" role=\"alert\">\n\t\t\t{translate(keys.pollResultsError)}\n\t\t</p>\n\t)\n\n\tuseEffect(() => {\n\t\tconst already = hasVoted === true || readVoted(key)\n\t\tif (already) {\n\t\t\tsetVoted(true)\n\t\t}\n\t\tif ((already && !resultsAwaitClose) || closed || finalized) {\n\t\t\tvoid loadResults()\n\t\t}\n\t}, [hasVoted, key, loadResults, closed, resultsAwaitClose, finalized])\n\n\tconst handleSuccess = useCallback<NonNullable<FormProps['onSuccess']>>(\n\t\t(submissionId, result) => {\n\t\t\twriteVoted(key)\n\t\t\tsetVoted(true)\n\t\t\tif (!resultsAwaitClose) {\n\t\t\t\tvoid loadResults()\n\t\t\t}\n\t\t\t// Forward the resolved success response so a Poll host gets the same onSuccess payload as a Form host.\n\t\t\tonSuccess?.(submissionId, result)\n\t\t},\n\t\t[key, loadResults, onSuccess, resultsAwaitClose]\n\t)\n\n\tif (finalized) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--final\">\n\t\t\t\t<p className=\"fb-poll__final\">{translate(keys.pollFinalResult)}</p>\n\t\t\t\t{loadFailed ? (\n\t\t\t\t\tresultsError\n\t\t\t\t) : results ? (\n\t\t\t\t\t<FormResults\n\t\t\t\t\t\tresults={results}\n\t\t\t\t\t\twinningValues={winningValues}\n\t\t\t\t\t\tt={formProps.t}\n\t\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t\t/>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (closed) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--closed\">\n\t\t\t\t<p className=\"fb-poll__closed\">{translate(keys.pollClosed)}</p>\n\t\t\t\t{loadFailed ? (\n\t\t\t\t\tresultsError\n\t\t\t\t) : results ? (\n\t\t\t\t\t<FormResults results={results} t={formProps.t} locale={formProps.locale} />\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (voted) {\n\t\tif (resultsAwaitClose) {\n\t\t\treturn <p className=\"fb-poll__await-close\">{translate(keys.pollResultsAfterClose)}</p>\n\t\t}\n\t\tif (loadFailed) {\n\t\t\treturn resultsError\n\t\t}\n\t\treturn results ? (\n\t\t\t<FormResults results={results} t={formProps.t} locale={formProps.locale} />\n\t\t) : null\n\t}\n\n\treturn <Form {...formProps} apiRoute={apiRoute} onSuccess={handleSuccess} />\n}\n"],"mappings":";;;;;;;;;;;AA8BA,MAAM,aAAa,QAAyB;CAC3C,IAAI;EACH,OAAO,OAAO,aAAa,QAAQ,GAAG,KAAK;CAC5C,QAAQ;EACP,OAAO;CACR;AACD;AAEA,MAAM,cAAc,QAAsB;CACzC,IAAI;EACH,OAAO,aAAa,QAAQ,KAAK,GAAG;CACrC,QAAQ,CAER;AACD;;;;;;;;;;;;;AAcA,MAAa,QAAQ,EACpB,cACA,YACA,UACA,mBAAmB,kBACnB,UACA,WACA,GAAG,gBACa;CAChB,MAAM,MAAM,cAAc,WAAW,UAAU,KAAK;CACpD,MAAM,OAAO,UAAU,KAAK;CAC5B,MAAM,SAAS,aAAa,IAAI;CAChC,MAAM,gBAAgB,MAAM,SAAS;CACrC,MAAM,YAAY,MAAM,QAAQ,aAAa,KAAK,cAAc,SAAS;CACzE,MAAM,oBAAoB,CAAC,UAAU,CAAC,aAAa,MAAM,sBAAsB;CAC/E,MAAM,CAAC,OAAO,YAAY,SAAS,KAAK;CACxC,MAAM,CAAC,SAAS,cAAc,SAAoC,IAAI;CACtE,MAAM,CAAC,YAAY,iBAAiB,SAAS,KAAK;CAClD,MAAM,YAAY,cAAc,UAAU,KAAK,cAAc,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;CAE/E,MAAM,cAAc,YAAY,YAAY;EAC3C,MAAM,SAA6B,MAAM,iBAAiB;GACzD,QAAQ,UAAU,KAAK;GACvB,OAAO;GACP;EACD,CAAC;EAED,IAAI,OAAO,IAAI;GACd,WAAW,OAAO,OAAO;GACzB,cAAc,KAAK;EACpB,OACC,cAAc,IAAI;CAEpB,GAAG;EAAC;EAAkB,UAAU,KAAK;EAAI;EAAc;CAAQ,CAAC;CAEhE,MAAM,eACL,oBAAC,KAAD;EAAG,WAAU;EAAiB,MAAK;YACjC,UAAU,KAAK,gBAAgB;CAC9B,CAAA;CAGJ,gBAAgB;EACf,MAAM,UAAU,aAAa,QAAQ,UAAU,GAAG;EAClD,IAAI,SACH,SAAS,IAAI;EAEd,IAAK,WAAW,CAAC,qBAAsB,UAAU,WAChD,YAAiB;CAEnB,GAAG;EAAC;EAAU;EAAK;EAAa;EAAQ;EAAmB;CAAS,CAAC;CAErE,MAAM,gBAAgB,aACpB,cAAc,WAAW;EACzB,WAAW,GAAG;EACd,SAAS,IAAI;EACb,IAAI,CAAC,mBACJ,YAAiB;EAGlB,YAAY,cAAc,MAAM;CACjC,GACA;EAAC;EAAK;EAAa;EAAW;CAAiB,CAChD;CAEA,IAAI,WACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAkB,UAAU,KAAK,eAAe;EAAK,CAAA,GACjE,aACA,eACG,UACH,oBAAC,aAAD;GACU;GACM;GACf,GAAG,UAAU;GACb,QAAQ,UAAU;EAClB,CAAA,IACE,IACA;;CAIP,IAAI,QACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAmB,UAAU,KAAK,UAAU;EAAK,CAAA,GAC7D,aACA,eACG,UACH,oBAAC,aAAD;GAAsB;GAAS,GAAG,UAAU;GAAG,QAAQ,UAAU;EAAS,CAAA,IACvE,IACA;;CAIP,IAAI,OAAO;EACV,IAAI,mBACH,OAAO,oBAAC,KAAD;GAAG,WAAU;aAAwB,UAAU,KAAK,qBAAqB;EAAK,CAAA;EAEtF,IAAI,YACH,OAAO;EAER,OAAO,UACN,oBAAC,aAAD;GAAsB;GAAS,GAAG,UAAU;GAAG,QAAQ,UAAU;EAAS,CAAA,IACvE;CACL;CAEA,OAAO,oBAAC,MAAD;EAAM,GAAI;EAAqB;EAAU,WAAW;CAAgB,CAAA;AAC5E"}
|
|
1
|
+
{"version":3,"file":"Poll.js","names":[],"sources":["../../src/react/Poll.tsx"],"sourcesContent":["'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { answerValues } from '../poll/votes/answerValues'\nimport type { VotedSubmission } from '../submissions/resolveVotedSubmission'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport type { VoteStorage } from './adapters'\nimport { Form, type FormProps } from './Form'\nimport { FormResults } from './FormResults'\nimport { type FetchResultsResult, fetchFormResults } from './fetchResults'\n\nexport type PollProps = FormProps & {\n\t/** The choice field whose results are shown after voting (should match the form's public `poll.resultsField`). */\n\tresultsField: string\n\t/**\n\t * Key for the per-browser voted guard (localStorage by default; overridable or disabled via\n\t * `adapters.voteStorage`). Default `fb-poll-{form.id}`.\n\t */\n\tstorageKey?: string\n\t/**\n\t * Server-known voted state, ORed with the localStorage guard: `true` marks the visitor as voted;\n\t * `false`/omitted falls back to localStorage. SSR hosts using the plugin's `poll.votedCookie`\n\t * option pass `hasVotedCookie(cookieHeader, form.id)` here.\n\t */\n\thasVoted?: boolean\n\t/**\n\t * Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module\n\t * scope, `useCallback`, or `useMemo`): an inline function re-runs the load effect and double-fetches.\n\t */\n\tfetchResultsImpl?: typeof fetchFormResults\n\t/**\n\t * The voter's server-resolved current vote (`resolveVotedSubmission`, read from the httpOnly voted\n\t * cookie). Presence implies voted; `pick` marks the voter's option in results and `value` prefills\n\t * the form when they change their vote. Read at mount: after a client-side change the component\n\t * tracks the new pick itself.\n\t */\n\tcurrentVote?: Pick<VotedSubmission, 'value' | 'pick'> | null\n}\n\nconst localStorageVoteStorage: VoteStorage = {\n\tread: (key) => {\n\t\ttry {\n\t\t\treturn window.localStorage.getItem(key) != null\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t},\n\twrite: (key) => {\n\t\ttry {\n\t\t\twindow.localStorage.setItem(key, '1')\n\t\t} catch {\n\t\t\t// Private mode / storage disabled: the guard is best-effort UX, never integrity.\n\t\t}\n\t},\n}\n\n/** `false` disables client persistence entirely (`hasVoted` still marks voted). */\nconst noopVoteStorage: VoteStorage = { read: () => false, write: () => {} }\n\nconst resolveVoteStorage = (configured: VoteStorage | false | undefined): VoteStorage => {\n\tif (configured === false) {\n\t\treturn noopVoteStorage\n\t}\n\treturn configured ?? localStorageVoteStorage\n}\n\n/**\n * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows\n * `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated\n * notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open\n * `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A\n * recorded `outcome.winningValues` (via `resolvePollOutcome`) supersedes everything: a translated final\n * notice plus results with every winning bucket highlighted (a tie highlights more than one). A\n * per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`\n * (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not\n * integrity (bypassable): server-enforced\n * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.\n */\nexport const Poll = ({\n\tresultsField,\n\tstorageKey,\n\thasVoted,\n\tfetchResultsImpl = fetchFormResults,\n\tapiRoute,\n\tonSuccess,\n\tcurrentVote,\n\t...formProps\n}: PollProps) => {\n\tconst key = storageKey ?? `fb-poll-${formProps.form.id}`\n\tconst poll = formProps.form.poll\n\tconst closed = isPollClosed(poll)\n\tconst allowChange = pollConfigOf(poll)?.allowChange === true\n\tconst winningValues = poll?.outcome?.winningValues\n\tconst finalized = Array.isArray(winningValues) && winningValues.length > 0\n\tconst resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === 'afterClose'\n\t// Server-known voted state renders the results view on the very first paint; the effect below\n\t// only adds the localStorage read.\n\tconst [voted, setVoted] = useState(hasVoted === true || currentVote != null)\n\tconst [changing, setChanging] = useState(false)\n\tconst [pick, setPick] = useState<string[] | undefined>(currentVote?.pick)\n\tconst [prefill, setPrefill] = useState<unknown>(currentVote?.value)\n\t// Changing is offered only when the signed cookie verifiably identifies a submission: at mount\n\t// via a resolved `currentVote`, or after an in-session vote (the server just issued the cookie).\n\t// A localStorage-only voted flag proves nothing about the cookie, and a \"change\" without one\n\t// would create a second submission instead of updating.\n\tconst [changeReady, setChangeReady] = useState(currentVote != null)\n\tconst [results, setResults] = useState<FieldAggregation[] | null>(null)\n\tconst [loadFailed, setLoadFailed] = useState(false)\n\tconst translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.t])\n\tconst configuredVoteStorage = formProps.adapters?.voteStorage\n\tconst voteStorage = useMemo(\n\t\t() => resolveVoteStorage(configuredVoteStorage),\n\t\t[configuredVoteStorage]\n\t)\n\n\tconst loadResults = useCallback(async () => {\n\t\tconst result: FetchResultsResult = await fetchResultsImpl({\n\t\t\tformId: formProps.form.id,\n\t\t\tfield: resultsField,\n\t\t\tapiRoute,\n\t\t})\n\t\t// A failed load surfaces as an error, not an empty result set: `[]` would read as \"no votes yet\".\n\t\tif (result.ok) {\n\t\t\tsetResults(result.results)\n\t\t\tsetLoadFailed(false)\n\t\t} else {\n\t\t\tsetLoadFailed(true)\n\t\t}\n\t}, [fetchResultsImpl, formProps.form.id, resultsField, apiRoute])\n\n\tconst resultsError = (\n\t\t<p className=\"fb-poll__error\" role=\"alert\">\n\t\t\t{translate(keys.pollResultsError)}\n\t\t</p>\n\t)\n\n\tuseEffect(() => {\n\t\tconst already = hasVoted === true || currentVote != null || voteStorage.read(key)\n\t\tif (already) {\n\t\t\tsetVoted(true)\n\t\t}\n\t\tif ((already && !resultsAwaitClose) || closed || finalized) {\n\t\t\tvoid loadResults()\n\t\t}\n\t}, [hasVoted, currentVote, key, loadResults, closed, resultsAwaitClose, finalized, voteStorage])\n\n\tconst handleSuccess = useCallback<NonNullable<FormProps['onSuccess']>>(\n\t\t(submissionId, result) => {\n\t\t\tvoteStorage.write(key)\n\t\t\tsetVoted(true)\n\t\t\tsetChanging(false)\n\t\t\tsetChangeReady(true)\n\t\t\t// Track the pick client-side so the \"your vote\" marker is fresh right after a (re-)vote,\n\t\t\t// without waiting for a server-resolved currentVote on the next page load.\n\t\t\tif (result?.values) {\n\t\t\t\tconst entry = result.values.find((row) => row.field === resultsField)\n\t\t\t\tsetPrefill(entry?.value)\n\t\t\t\tsetPick(answerValues(result.values, resultsField))\n\t\t\t}\n\t\t\tif (!resultsAwaitClose) {\n\t\t\t\tvoid loadResults()\n\t\t\t}\n\t\t\t// Forward the resolved success response so a Poll host gets the same onSuccess payload as a Form host.\n\t\t\tonSuccess?.(submissionId, result)\n\t\t},\n\t\t[key, loadResults, onSuccess, resultsAwaitClose, voteStorage, resultsField]\n\t)\n\n\tif (finalized) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--final\">\n\t\t\t\t<p className=\"fb-poll__final\">{translate(keys.pollFinalResult)}</p>\n\t\t\t\t{loadFailed ? (\n\t\t\t\t\tresultsError\n\t\t\t\t) : results ? (\n\t\t\t\t\t<FormResults\n\t\t\t\t\t\tresults={results}\n\t\t\t\t\t\twinningValues={winningValues}\n\t\t\t\t\t\tcurrentValues={pick}\n\t\t\t\t\t\tt={formProps.t}\n\t\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t\t/>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (closed) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--closed\">\n\t\t\t\t<p className=\"fb-poll__closed\">{translate(keys.pollClosed)}</p>\n\t\t\t\t{loadFailed ? (\n\t\t\t\t\tresultsError\n\t\t\t\t) : results ? (\n\t\t\t\t\t<FormResults\n\t\t\t\t\t\tresults={results}\n\t\t\t\t\t\tcurrentValues={pick}\n\t\t\t\t\t\tt={formProps.t}\n\t\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t\t/>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (voted && !changing) {\n\t\tif (resultsAwaitClose) {\n\t\t\treturn <p className=\"fb-poll__await-close\">{translate(keys.pollResultsAfterClose)}</p>\n\t\t}\n\t\tif (loadFailed) {\n\t\t\treturn resultsError\n\t\t}\n\t\treturn results ? (\n\t\t\t<div className=\"fb-poll fb-poll--voted\">\n\t\t\t\t<FormResults\n\t\t\t\t\tresults={results}\n\t\t\t\t\tcurrentValues={pick}\n\t\t\t\t\tt={formProps.t}\n\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t/>\n\t\t\t\t{allowChange && changeReady ? (\n\t\t\t\t\t<button className=\"fb-poll__change\" type=\"button\" onClick={() => setChanging(true)}>\n\t\t\t\t\t\t{translate(keys.pollChangeVote)}\n\t\t\t\t\t</button>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t) : null\n\t}\n\n\t// While changing, the current pick prefills the results field; the server identifies the\n\t// submission to update from the httpOnly voted cookie, so the client sends a normal submit.\n\tconst initialValues =\n\t\tchanging && prefill !== undefined\n\t\t\t? { ...(formProps.initialValues ?? {}), [resultsField]: prefill }\n\t\t\t: formProps.initialValues\n\treturn (\n\t\t<Form\n\t\t\t{...formProps}\n\t\t\tinitialValues={initialValues}\n\t\t\tapiRoute={apiRoute}\n\t\t\tonSuccess={handleSuccess}\n\t\t/>\n\t)\n}\n"],"mappings":";;;;;;;;;;;;AA2CA,MAAM,0BAAuC;CAC5C,OAAO,QAAQ;EACd,IAAI;GACH,OAAO,OAAO,aAAa,QAAQ,GAAG,KAAK;EAC5C,QAAQ;GACP,OAAO;EACR;CACD;CACA,QAAQ,QAAQ;EACf,IAAI;GACH,OAAO,aAAa,QAAQ,KAAK,GAAG;EACrC,QAAQ,CAER;CACD;AACD;;AAGA,MAAM,kBAA+B;CAAE,YAAY;CAAO,aAAa,CAAC;AAAE;AAE1E,MAAM,sBAAsB,eAA6D;CACxF,IAAI,eAAe,OAClB,OAAO;CAER,OAAO,cAAc;AACtB;;;;;;;;;;;;;AAcA,MAAa,QAAQ,EACpB,cACA,YACA,UACA,mBAAmB,kBACnB,UACA,WACA,aACA,GAAG,gBACa;CAChB,MAAM,MAAM,cAAc,WAAW,UAAU,KAAK;CACpD,MAAM,OAAO,UAAU,KAAK;CAC5B,MAAM,SAAS,aAAa,IAAI;CAChC,MAAM,cAAc,aAAa,IAAI,GAAG,gBAAgB;CACxD,MAAM,gBAAgB,MAAM,SAAS;CACrC,MAAM,YAAY,MAAM,QAAQ,aAAa,KAAK,cAAc,SAAS;CACzE,MAAM,oBAAoB,CAAC,UAAU,CAAC,aAAa,MAAM,sBAAsB;CAG/E,MAAM,CAAC,OAAO,YAAY,SAAS,aAAa,QAAQ,eAAe,IAAI;CAC3E,MAAM,CAAC,UAAU,eAAe,SAAS,KAAK;CAC9C,MAAM,CAAC,MAAM,WAAW,SAA+B,aAAa,IAAI;CACxE,MAAM,CAAC,SAAS,cAAc,SAAkB,aAAa,KAAK;CAKlE,MAAM,CAAC,aAAa,kBAAkB,SAAS,eAAe,IAAI;CAClE,MAAM,CAAC,SAAS,cAAc,SAAoC,IAAI;CACtE,MAAM,CAAC,YAAY,iBAAiB,SAAS,KAAK;CAClD,MAAM,YAAY,cAAc,UAAU,KAAK,cAAc,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;CAC/E,MAAM,wBAAwB,UAAU,UAAU;CAClD,MAAM,cAAc,cACb,mBAAmB,qBAAqB,GAC9C,CAAC,qBAAqB,CACvB;CAEA,MAAM,cAAc,YAAY,YAAY;EAC3C,MAAM,SAA6B,MAAM,iBAAiB;GACzD,QAAQ,UAAU,KAAK;GACvB,OAAO;GACP;EACD,CAAC;EAED,IAAI,OAAO,IAAI;GACd,WAAW,OAAO,OAAO;GACzB,cAAc,KAAK;EACpB,OACC,cAAc,IAAI;CAEpB,GAAG;EAAC;EAAkB,UAAU,KAAK;EAAI;EAAc;CAAQ,CAAC;CAEhE,MAAM,eACL,oBAAC,KAAD;EAAG,WAAU;EAAiB,MAAK;YACjC,UAAU,KAAK,gBAAgB;CAC9B,CAAA;CAGJ,gBAAgB;EACf,MAAM,UAAU,aAAa,QAAQ,eAAe,QAAQ,YAAY,KAAK,GAAG;EAChF,IAAI,SACH,SAAS,IAAI;EAEd,IAAK,WAAW,CAAC,qBAAsB,UAAU,WAChD,YAAiB;CAEnB,GAAG;EAAC;EAAU;EAAa;EAAK;EAAa;EAAQ;EAAmB;EAAW;CAAW,CAAC;CAE/F,MAAM,gBAAgB,aACpB,cAAc,WAAW;EACzB,YAAY,MAAM,GAAG;EACrB,SAAS,IAAI;EACb,YAAY,KAAK;EACjB,eAAe,IAAI;EAGnB,IAAI,QAAQ,QAAQ;GAEnB,WADc,OAAO,OAAO,MAAM,QAAQ,IAAI,UAAU,YACzC,GAAG,KAAK;GACvB,QAAQ,aAAa,OAAO,QAAQ,YAAY,CAAC;EAClD;EACA,IAAI,CAAC,mBACJ,YAAiB;EAGlB,YAAY,cAAc,MAAM;CACjC,GACA;EAAC;EAAK;EAAa;EAAW;EAAmB;EAAa;CAAY,CAC3E;CAEA,IAAI,WACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAkB,UAAU,KAAK,eAAe;EAAK,CAAA,GACjE,aACA,eACG,UACH,oBAAC,aAAD;GACU;GACM;GACf,eAAe;GACf,GAAG,UAAU;GACb,QAAQ,UAAU;EAClB,CAAA,IACE,IACA;;CAIP,IAAI,QACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAmB,UAAU,KAAK,UAAU;EAAK,CAAA,GAC7D,aACA,eACG,UACH,oBAAC,aAAD;GACU;GACT,eAAe;GACf,GAAG,UAAU;GACb,QAAQ,UAAU;EAClB,CAAA,IACE,IACA;;CAIP,IAAI,SAAS,CAAC,UAAU;EACvB,IAAI,mBACH,OAAO,oBAAC,KAAD;GAAG,WAAU;aAAwB,UAAU,KAAK,qBAAqB;EAAK,CAAA;EAEtF,IAAI,YACH,OAAO;EAER,OAAO,UACN,qBAAC,OAAD;GAAK,WAAU;aAAf,CACC,oBAAC,aAAD;IACU;IACT,eAAe;IACf,GAAG,UAAU;IACb,QAAQ,UAAU;GAClB,CAAA,GACA,eAAe,cACf,oBAAC,UAAD;IAAQ,WAAU;IAAkB,MAAK;IAAS,eAAe,YAAY,IAAI;cAC/E,UAAU,KAAK,cAAc;GACvB,CAAA,IACL,IACA;OACF;CACL;CAIA,MAAM,gBACL,YAAY,YAAY,KAAA,IACrB;EAAE,GAAI,UAAU,iBAAiB,CAAC;GAAK,eAAe;CAAQ,IAC9D,UAAU;CACd,OACC,oBAAC,MAAD;EACC,GAAI;EACW;EACL;EACV,WAAW;CACX,CAAA;AAEH"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/react/adapters.d.ts
|
|
2
|
+
/** The plugin's navigation intent for a post-submit redirect, passed through to the adapter. */
|
|
3
|
+
type NavigateOptions = {
|
|
4
|
+
replace: boolean;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Client persistence for a poll's per-browser voted flag. `read` reports whether the flag is set;
|
|
8
|
+
* `write` sets it after a successful vote.
|
|
9
|
+
*/
|
|
10
|
+
type VoteStorage = {
|
|
11
|
+
read: (key: string) => boolean;
|
|
12
|
+
write: (key: string) => void;
|
|
13
|
+
};
|
|
14
|
+
/** Loads a third-party script and resolves once it is executable (e.g. a consent-gated loader). */
|
|
15
|
+
type LoadScript = (src: string) => Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Host-owned effects. Every member is optional and defaults to the current DOM behavior, so
|
|
18
|
+
* omitting the prop entirely preserves the default semantics exactly.
|
|
19
|
+
*
|
|
20
|
+
* - `navigate`: replaces `window.location.assign`/`.replace` for the post-submit redirect. When
|
|
21
|
+
* set, the plugin never touches `window.location` for that form; a throwing adapter propagates
|
|
22
|
+
* and never falls back to a hard navigation. Not awaited.
|
|
23
|
+
* - `voteStorage`: replaces the `<Poll>` localStorage voted-flag read/write; `false` disables
|
|
24
|
+
* client persistence entirely (e.g. when the server-side `poll.votedCookie` is the source of truth).
|
|
25
|
+
* - `loadScript`: replaces the captcha widgets' `document.head` script injection (consent
|
|
26
|
+
* managers, CSP nonces). The per-src cache and evict-on-failure retry still apply.
|
|
27
|
+
*/
|
|
28
|
+
type FormAdapters = {
|
|
29
|
+
navigate?: (url: string, options: NavigateOptions) => void;
|
|
30
|
+
voteStorage?: VoteStorage | false;
|
|
31
|
+
loadScript?: LoadScript;
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { FormAdapters, LoadScript, NavigateOptions, VoteStorage };
|
|
35
|
+
//# sourceMappingURL=adapters.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/react/adapters.ts
|
|
2
|
+
/** The default navigation: a full document load, push semantics unless `replace` is requested. */
|
|
3
|
+
const defaultNavigate = (url, options) => {
|
|
4
|
+
if (typeof window === "undefined") return;
|
|
5
|
+
if (options.replace) window.location.replace(url);
|
|
6
|
+
else window.location.assign(url);
|
|
7
|
+
};
|
|
8
|
+
//#endregion
|
|
9
|
+
export { defaultNavigate };
|
|
10
|
+
|
|
11
|
+
//# sourceMappingURL=adapters.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapters.js","names":[],"sources":["../../src/react/adapters.ts"],"sourcesContent":["/** The plugin's navigation intent for a post-submit redirect, passed through to the adapter. */\nexport type NavigateOptions = { replace: boolean }\n\n/**\n * Client persistence for a poll's per-browser voted flag. `read` reports whether the flag is set;\n * `write` sets it after a successful vote.\n */\nexport type VoteStorage = {\n\tread: (key: string) => boolean\n\twrite: (key: string) => void\n}\n\n/** Loads a third-party script and resolves once it is executable (e.g. a consent-gated loader). */\nexport type LoadScript = (src: string) => Promise<void>\n\n/**\n * Host-owned effects. Every member is optional and defaults to the current DOM behavior, so\n * omitting the prop entirely preserves the default semantics exactly.\n *\n * - `navigate`: replaces `window.location.assign`/`.replace` for the post-submit redirect. When\n * set, the plugin never touches `window.location` for that form; a throwing adapter propagates\n * and never falls back to a hard navigation. Not awaited.\n * - `voteStorage`: replaces the `<Poll>` localStorage voted-flag read/write; `false` disables\n * client persistence entirely (e.g. when the server-side `poll.votedCookie` is the source of truth).\n * - `loadScript`: replaces the captcha widgets' `document.head` script injection (consent\n * managers, CSP nonces). The per-src cache and evict-on-failure retry still apply.\n */\nexport type FormAdapters = {\n\tnavigate?: (url: string, options: NavigateOptions) => void\n\tvoteStorage?: VoteStorage | false\n\tloadScript?: LoadScript\n}\n\n/** The default navigation: a full document load, push semantics unless `replace` is requested. */\nexport const defaultNavigate = (url: string, options: NavigateOptions): void => {\n\tif (typeof window === 'undefined') {\n\t\treturn\n\t}\n\tif (options.replace) {\n\t\twindow.location.replace(url)\n\t} else {\n\t\twindow.location.assign(url)\n\t}\n}\n"],"mappings":";;AAkCA,MAAa,mBAAmB,KAAa,YAAmC;CAC/E,IAAI,OAAO,WAAW,aACrB;CAED,IAAI,QAAQ,SACX,OAAO,SAAS,QAAQ,GAAG;MAE3B,OAAO,SAAS,OAAO,GAAG;AAE5B"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormAdapters } from "../adapters.js";
|
|
1
2
|
import { CaptchaWidgetHandle } from "./types.js";
|
|
2
3
|
import { Ref } from "react";
|
|
3
4
|
|
|
@@ -8,7 +9,8 @@ type HcaptchaCaptchaProps = {
|
|
|
8
9
|
onToken: (token: string | null) => void; /** Extra vendor render params (theme, size, ...). */
|
|
9
10
|
options?: Record<string, unknown>;
|
|
10
11
|
className?: string; /** Override the vendor script URL. */
|
|
11
|
-
scriptSrc?: string;
|
|
12
|
+
scriptSrc?: string; /** Host-owned effects; only `loadScript` applies here (consent-gated or CSP-nonced script loading). */
|
|
13
|
+
adapters?: Pick<FormAdapters, 'loadScript'>;
|
|
12
14
|
ref?: Ref<CaptchaWidgetHandle>;
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
@@ -21,6 +23,7 @@ declare const HcaptchaCaptcha: ({
|
|
|
21
23
|
options,
|
|
22
24
|
className,
|
|
23
25
|
scriptSrc,
|
|
26
|
+
adapters,
|
|
24
27
|
ref
|
|
25
28
|
}: HcaptchaCaptchaProps) => import("react/jsx-runtime").JSX.Element;
|
|
26
29
|
//#endregion
|
|
@@ -9,14 +9,14 @@ const HCAPTCHA_SCRIPT_URL = "https://js.hcaptcha.com/1/api.js?render=explicit";
|
|
|
9
9
|
* Headless hCaptcha widget: loads the vendor script once, renders the widget into a bare div,
|
|
10
10
|
* and reports tokens through `onToken`. Pass the latest token to `<Form captchaToken>`.
|
|
11
11
|
*/
|
|
12
|
-
const HcaptchaCaptcha = ({ siteKey, onToken, options, className, scriptSrc = HCAPTCHA_SCRIPT_URL, ref }) => {
|
|
12
|
+
const HcaptchaCaptcha = ({ siteKey, onToken, options, className, scriptSrc = HCAPTCHA_SCRIPT_URL, adapters, ref }) => {
|
|
13
13
|
const containerRef = useRef(null);
|
|
14
14
|
const widgetIdRef = useRef(null);
|
|
15
15
|
const onTokenRef = useRef(onToken);
|
|
16
16
|
onTokenRef.current = onToken;
|
|
17
17
|
const optionsRef = useRef(options);
|
|
18
18
|
optionsRef.current = options;
|
|
19
|
-
const ready = useCaptchaScript(scriptSrc);
|
|
19
|
+
const ready = useCaptchaScript(scriptSrc, adapters?.loadScript);
|
|
20
20
|
useImperativeHandle(ref, () => ({ reset: () => {
|
|
21
21
|
const api = getHcaptcha();
|
|
22
22
|
if (api && widgetIdRef.current !== null) api.reset(widgetIdRef.current);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HcaptchaCaptcha.js","names":[],"sources":["../../../src/react/captcha/HcaptchaCaptcha.tsx"],"sourcesContent":["'use client'\n\nimport { type Ref, useEffect, useImperativeHandle, useRef } from 'react'\nimport type { CaptchaWidgetHandle } from './types'\nimport { useCaptchaScript } from './useCaptchaScript'\nimport { getHcaptcha } from './vendors'\n\nexport const HCAPTCHA_SCRIPT_URL = 'https://js.hcaptcha.com/1/api.js?render=explicit'\n\nexport type HcaptchaCaptchaProps = {\n\t/** hCaptcha site key (public). */\n\tsiteKey: string\n\t/** Receives each fresh token, and null when the token expires or errors. */\n\tonToken: (token: string | null) => void\n\t/** Extra vendor render params (theme, size, ...). */\n\toptions?: Record<string, unknown>\n\tclassName?: string\n\t/** Override the vendor script URL. */\n\tscriptSrc?: string\n\tref?: Ref<CaptchaWidgetHandle>\n}\n\n/**\n * Headless hCaptcha widget: loads the vendor script once, renders the widget into a bare div,\n * and reports tokens through `onToken`. Pass the latest token to `<Form captchaToken>`.\n */\nexport const HcaptchaCaptcha = ({\n\tsiteKey,\n\tonToken,\n\toptions,\n\tclassName,\n\tscriptSrc = HCAPTCHA_SCRIPT_URL,\n\tref,\n}: HcaptchaCaptchaProps) => {\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\tconst widgetIdRef = useRef<string | null>(null)\n\tconst onTokenRef = useRef(onToken)\n\tonTokenRef.current = onToken\n\tconst optionsRef = useRef(options)\n\toptionsRef.current = options\n\tconst ready = useCaptchaScript(scriptSrc)\n\n\tuseImperativeHandle(\n\t\tref,\n\t\t() => ({\n\t\t\treset: () => {\n\t\t\t\tconst api = getHcaptcha()\n\t\t\t\tif (api && widgetIdRef.current !== null) {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t}\n\t\t\t\tonTokenRef.current(null)\n\t\t\t},\n\t\t}),\n\t\t[]\n\t)\n\n\tuseEffect(() => {\n\t\tif (!ready) {\n\t\t\treturn\n\t\t}\n\t\tconst container = containerRef.current\n\t\tconst api = getHcaptcha()\n\t\tif (!container || !api) {\n\t\t\treturn\n\t\t}\n\t\tconst widgetId = api.render(container, {\n\t\t\t...optionsRef.current,\n\t\t\tsitekey: siteKey,\n\t\t\tcallback: (token: string) => onTokenRef.current(token),\n\t\t\t'expired-callback': () => onTokenRef.current(null),\n\t\t\t'error-callback': () => onTokenRef.current(null),\n\t\t})\n\t\twidgetIdRef.current = widgetId\n\t\treturn () => {\n\t\t\twidgetIdRef.current = null\n\t\t\ttry {\n\t\t\t\tapi.remove(widgetId)\n\t\t\t} catch {}\n\t\t}\n\t}, [ready, siteKey])\n\n\treturn <div ref={containerRef} className={className} />\n}\n"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"HcaptchaCaptcha.js","names":[],"sources":["../../../src/react/captcha/HcaptchaCaptcha.tsx"],"sourcesContent":["'use client'\n\nimport { type Ref, useEffect, useImperativeHandle, useRef } from 'react'\nimport type { FormAdapters } from '../adapters'\nimport type { CaptchaWidgetHandle } from './types'\nimport { useCaptchaScript } from './useCaptchaScript'\nimport { getHcaptcha } from './vendors'\n\nexport const HCAPTCHA_SCRIPT_URL = 'https://js.hcaptcha.com/1/api.js?render=explicit'\n\nexport type HcaptchaCaptchaProps = {\n\t/** hCaptcha site key (public). */\n\tsiteKey: string\n\t/** Receives each fresh token, and null when the token expires or errors. */\n\tonToken: (token: string | null) => void\n\t/** Extra vendor render params (theme, size, ...). */\n\toptions?: Record<string, unknown>\n\tclassName?: string\n\t/** Override the vendor script URL. */\n\tscriptSrc?: string\n\t/** Host-owned effects; only `loadScript` applies here (consent-gated or CSP-nonced script loading). */\n\tadapters?: Pick<FormAdapters, 'loadScript'>\n\tref?: Ref<CaptchaWidgetHandle>\n}\n\n/**\n * Headless hCaptcha widget: loads the vendor script once, renders the widget into a bare div,\n * and reports tokens through `onToken`. Pass the latest token to `<Form captchaToken>`.\n */\nexport const HcaptchaCaptcha = ({\n\tsiteKey,\n\tonToken,\n\toptions,\n\tclassName,\n\tscriptSrc = HCAPTCHA_SCRIPT_URL,\n\tadapters,\n\tref,\n}: HcaptchaCaptchaProps) => {\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\tconst widgetIdRef = useRef<string | null>(null)\n\tconst onTokenRef = useRef(onToken)\n\tonTokenRef.current = onToken\n\tconst optionsRef = useRef(options)\n\toptionsRef.current = options\n\tconst ready = useCaptchaScript(scriptSrc, adapters?.loadScript)\n\n\tuseImperativeHandle(\n\t\tref,\n\t\t() => ({\n\t\t\treset: () => {\n\t\t\t\tconst api = getHcaptcha()\n\t\t\t\tif (api && widgetIdRef.current !== null) {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t}\n\t\t\t\tonTokenRef.current(null)\n\t\t\t},\n\t\t}),\n\t\t[]\n\t)\n\n\tuseEffect(() => {\n\t\tif (!ready) {\n\t\t\treturn\n\t\t}\n\t\tconst container = containerRef.current\n\t\tconst api = getHcaptcha()\n\t\tif (!container || !api) {\n\t\t\treturn\n\t\t}\n\t\tconst widgetId = api.render(container, {\n\t\t\t...optionsRef.current,\n\t\t\tsitekey: siteKey,\n\t\t\tcallback: (token: string) => onTokenRef.current(token),\n\t\t\t'expired-callback': () => onTokenRef.current(null),\n\t\t\t'error-callback': () => onTokenRef.current(null),\n\t\t})\n\t\twidgetIdRef.current = widgetId\n\t\treturn () => {\n\t\t\twidgetIdRef.current = null\n\t\t\ttry {\n\t\t\t\tapi.remove(widgetId)\n\t\t\t} catch {}\n\t\t}\n\t}, [ready, siteKey])\n\n\treturn <div ref={containerRef} className={className} />\n}\n"],"mappings":";;;;;;AAQA,MAAa,sBAAsB;;;;;AAqBnC,MAAa,mBAAmB,EAC/B,SACA,SACA,SACA,WACA,YAAY,qBACZ,UACA,UAC2B;CAC3B,MAAM,eAAe,OAAuB,IAAI;CAChD,MAAM,cAAc,OAAsB,IAAI;CAC9C,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,QAAQ,iBAAiB,WAAW,UAAU,UAAU;CAE9D,oBACC,YACO,EACN,aAAa;EACZ,MAAM,MAAM,YAAY;EACxB,IAAI,OAAO,YAAY,YAAY,MAClC,IAAI,MAAM,YAAY,OAAO;EAE9B,WAAW,QAAQ,IAAI;CACxB,EACD,IACA,CAAC,CACF;CAEA,gBAAgB;EACf,IAAI,CAAC,OACJ;EAED,MAAM,YAAY,aAAa;EAC/B,MAAM,MAAM,YAAY;EACxB,IAAI,CAAC,aAAa,CAAC,KAClB;EAED,MAAM,WAAW,IAAI,OAAO,WAAW;GACtC,GAAG,WAAW;GACd,SAAS;GACT,WAAW,UAAkB,WAAW,QAAQ,KAAK;GACrD,0BAA0B,WAAW,QAAQ,IAAI;GACjD,wBAAwB,WAAW,QAAQ,IAAI;EAChD,CAAC;EACD,YAAY,UAAU;EACtB,aAAa;GACZ,YAAY,UAAU;GACtB,IAAI;IACH,IAAI,OAAO,QAAQ;GACpB,QAAQ,CAAC;EACV;CACD,GAAG,CAAC,OAAO,OAAO,CAAC;CAEnB,OAAO,oBAAC,OAAD;EAAK,KAAK;EAAyB;CAAY,CAAA;AACvD"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormAdapters } from "../adapters.js";
|
|
1
2
|
import { CaptchaWidgetHandle } from "./types.js";
|
|
2
3
|
import { Ref } from "react";
|
|
3
4
|
|
|
@@ -18,7 +19,8 @@ type RecaptchaCaptchaProps = {
|
|
|
18
19
|
onToken: (token: string | null) => void; /** Extra vendor render params for v2 (theme, size, ...). */
|
|
19
20
|
options?: Record<string, unknown>;
|
|
20
21
|
className?: string; /** Override the vendor script URL (the version-specific `render` query is not appended). */
|
|
21
|
-
scriptSrc?: string;
|
|
22
|
+
scriptSrc?: string; /** Host-owned effects; only `loadScript` applies here (consent-gated or CSP-nonced script loading). */
|
|
23
|
+
adapters?: Pick<FormAdapters, 'loadScript'>;
|
|
22
24
|
ref?: Ref<RecaptchaCaptchaHandle>;
|
|
23
25
|
};
|
|
24
26
|
/**
|
|
@@ -34,6 +36,7 @@ declare const RecaptchaCaptcha: ({
|
|
|
34
36
|
options,
|
|
35
37
|
className,
|
|
36
38
|
scriptSrc,
|
|
39
|
+
adapters,
|
|
37
40
|
ref
|
|
38
41
|
}: RecaptchaCaptchaProps) => import("react/jsx-runtime").JSX.Element;
|
|
39
42
|
//#endregion
|
|
@@ -10,7 +10,7 @@ const RECAPTCHA_SCRIPT_URL = "https://www.google.com/recaptcha/api.js";
|
|
|
10
10
|
* tokens as the user solves the challenge; v3 fetches an initial token on mount and exposes
|
|
11
11
|
* `execute` on the ref for on-demand refresh. Pass the latest token to `<Form captchaToken>`.
|
|
12
12
|
*/
|
|
13
|
-
const RecaptchaCaptcha = ({ siteKey, version, action, onToken, options, className, scriptSrc, ref }) => {
|
|
13
|
+
const RecaptchaCaptcha = ({ siteKey, version, action, onToken, options, className, scriptSrc, adapters, ref }) => {
|
|
14
14
|
const src = scriptSrc ?? `https://www.google.com/recaptcha/api.js?render=${version === "v3" ? siteKey : "explicit"}`;
|
|
15
15
|
const containerRef = useRef(null);
|
|
16
16
|
const widgetIdRef = useRef(null);
|
|
@@ -20,7 +20,7 @@ const RecaptchaCaptcha = ({ siteKey, version, action, onToken, options, classNam
|
|
|
20
20
|
optionsRef.current = options;
|
|
21
21
|
const actionRef = useRef(action);
|
|
22
22
|
actionRef.current = action;
|
|
23
|
-
const ready = useCaptchaScript(src);
|
|
23
|
+
const ready = useCaptchaScript(src, adapters?.loadScript);
|
|
24
24
|
const execute = useCallback(async (overrideAction) => {
|
|
25
25
|
const api = getGrecaptcha();
|
|
26
26
|
if (!api || version !== "v3") return null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RecaptchaCaptcha.js","names":[],"sources":["../../../src/react/captcha/RecaptchaCaptcha.tsx"],"sourcesContent":["'use client'\n\nimport { type Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react'\nimport type { CaptchaWidgetHandle } from './types'\nimport { useCaptchaScript } from './useCaptchaScript'\nimport { getGrecaptcha } from './vendors'\n\nexport const RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js'\n\nexport type RecaptchaCaptchaHandle = CaptchaWidgetHandle & {\n\t/**\n\t * v3 only: fetch a fresh token on demand and report it through `onToken`. v3 tokens expire\n\t * after about two minutes, so call this right before submit for a long-lived form. Resolves\n\t * null for v2 or when execution fails.\n\t */\n\texecute: (action?: string) => Promise<string | null>\n}\n\nexport type RecaptchaCaptchaProps = {\n\t/** reCAPTCHA site key (public). */\n\tsiteKey: string\n\t/** 'v2' renders the checkbox widget; 'v3' runs invisibly and produces score-based tokens. */\n\tversion: 'v2' | 'v3'\n\t/** v3 action name reported to reCAPTCHA. Default 'submit'. */\n\taction?: string\n\t/** Receives each fresh token, and null when the token expires, errors, or is reset. */\n\tonToken: (token: string | null) => void\n\t/** Extra vendor render params for v2 (theme, size, ...). */\n\toptions?: Record<string, unknown>\n\tclassName?: string\n\t/** Override the vendor script URL (the version-specific `render` query is not appended). */\n\tscriptSrc?: string\n\tref?: Ref<RecaptchaCaptchaHandle>\n}\n\n/**\n * Headless Google reCAPTCHA widget for v2 (checkbox) and v3 (invisible, score-based). v2 reports\n * tokens as the user solves the challenge; v3 fetches an initial token on mount and exposes\n * `execute` on the ref for on-demand refresh. Pass the latest token to `<Form captchaToken>`.\n */\nexport const RecaptchaCaptcha = ({\n\tsiteKey,\n\tversion,\n\taction,\n\tonToken,\n\toptions,\n\tclassName,\n\tscriptSrc,\n\tref,\n}: RecaptchaCaptchaProps) => {\n\tconst src =\n\t\tscriptSrc ?? `${RECAPTCHA_SCRIPT_URL}?render=${version === 'v3' ? siteKey : 'explicit'}`\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\tconst widgetIdRef = useRef<number | null>(null)\n\tconst onTokenRef = useRef(onToken)\n\tonTokenRef.current = onToken\n\tconst optionsRef = useRef(options)\n\toptionsRef.current = options\n\tconst actionRef = useRef(action)\n\tactionRef.current = action\n\tconst ready = useCaptchaScript(src)\n\n\tconst execute = useCallback(\n\t\tasync (overrideAction?: string): Promise<string | null> => {\n\t\t\tconst api = getGrecaptcha()\n\t\t\tif (!api || version !== 'v3') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst token = await api.execute(siteKey, {\n\t\t\t\t\taction: overrideAction ?? actionRef.current ?? 'submit',\n\t\t\t\t})\n\t\t\t\tonTokenRef.current(token)\n\t\t\t\treturn token\n\t\t\t} catch {\n\t\t\t\tonTokenRef.current(null)\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\t\t[siteKey, version]\n\t)\n\n\tuseImperativeHandle(\n\t\tref,\n\t\t() => ({\n\t\t\treset: () => {\n\t\t\t\tconst api = getGrecaptcha()\n\t\t\t\tif (api && version === 'v2' && widgetIdRef.current !== null) {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t}\n\t\t\t\tonTokenRef.current(null)\n\t\t\t},\n\t\t\texecute,\n\t\t}),\n\t\t[execute, version]\n\t)\n\n\tuseEffect(() => {\n\t\tif (!ready) {\n\t\t\treturn\n\t\t}\n\t\tconst api = getGrecaptcha()\n\t\tif (!api) {\n\t\t\treturn\n\t\t}\n\t\tlet active = true\n\t\tif (version === 'v3') {\n\t\t\tapi.ready(() => {\n\t\t\t\tif (active) {\n\t\t\t\t\tvoid execute()\n\t\t\t\t}\n\t\t\t})\n\t\t\treturn () => {\n\t\t\t\tactive = false\n\t\t\t}\n\t\t}\n\t\tconst container = containerRef.current\n\t\tif (!container) {\n\t\t\treturn\n\t\t}\n\t\tapi.ready(() => {\n\t\t\tif (!active || widgetIdRef.current !== null) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\twidgetIdRef.current = api.render(container, {\n\t\t\t\t...optionsRef.current,\n\t\t\t\tsitekey: siteKey,\n\t\t\t\tcallback: (token: string) => onTokenRef.current(token),\n\t\t\t\t'expired-callback': () => onTokenRef.current(null),\n\t\t\t\t'error-callback': () => onTokenRef.current(null),\n\t\t\t})\n\t\t})\n\t\treturn () => {\n\t\t\tactive = false\n\t\t\tif (widgetIdRef.current !== null) {\n\t\t\t\ttry {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t} catch {}\n\t\t\t\twidgetIdRef.current = null\n\t\t\t}\n\t\t\t// grecaptcha has no remove(); clearing the container drops the detached widget DOM.\n\t\t\tcontainer.replaceChildren()\n\t\t}\n\t}, [ready, siteKey, version, execute])\n\n\treturn <div ref={containerRef} className={className} />\n}\n"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"RecaptchaCaptcha.js","names":[],"sources":["../../../src/react/captcha/RecaptchaCaptcha.tsx"],"sourcesContent":["'use client'\n\nimport { type Ref, useCallback, useEffect, useImperativeHandle, useRef } from 'react'\nimport type { FormAdapters } from '../adapters'\nimport type { CaptchaWidgetHandle } from './types'\nimport { useCaptchaScript } from './useCaptchaScript'\nimport { getGrecaptcha } from './vendors'\n\nexport const RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js'\n\nexport type RecaptchaCaptchaHandle = CaptchaWidgetHandle & {\n\t/**\n\t * v3 only: fetch a fresh token on demand and report it through `onToken`. v3 tokens expire\n\t * after about two minutes, so call this right before submit for a long-lived form. Resolves\n\t * null for v2 or when execution fails.\n\t */\n\texecute: (action?: string) => Promise<string | null>\n}\n\nexport type RecaptchaCaptchaProps = {\n\t/** reCAPTCHA site key (public). */\n\tsiteKey: string\n\t/** 'v2' renders the checkbox widget; 'v3' runs invisibly and produces score-based tokens. */\n\tversion: 'v2' | 'v3'\n\t/** v3 action name reported to reCAPTCHA. Default 'submit'. */\n\taction?: string\n\t/** Receives each fresh token, and null when the token expires, errors, or is reset. */\n\tonToken: (token: string | null) => void\n\t/** Extra vendor render params for v2 (theme, size, ...). */\n\toptions?: Record<string, unknown>\n\tclassName?: string\n\t/** Override the vendor script URL (the version-specific `render` query is not appended). */\n\tscriptSrc?: string\n\t/** Host-owned effects; only `loadScript` applies here (consent-gated or CSP-nonced script loading). */\n\tadapters?: Pick<FormAdapters, 'loadScript'>\n\tref?: Ref<RecaptchaCaptchaHandle>\n}\n\n/**\n * Headless Google reCAPTCHA widget for v2 (checkbox) and v3 (invisible, score-based). v2 reports\n * tokens as the user solves the challenge; v3 fetches an initial token on mount and exposes\n * `execute` on the ref for on-demand refresh. Pass the latest token to `<Form captchaToken>`.\n */\nexport const RecaptchaCaptcha = ({\n\tsiteKey,\n\tversion,\n\taction,\n\tonToken,\n\toptions,\n\tclassName,\n\tscriptSrc,\n\tadapters,\n\tref,\n}: RecaptchaCaptchaProps) => {\n\tconst src =\n\t\tscriptSrc ?? `${RECAPTCHA_SCRIPT_URL}?render=${version === 'v3' ? siteKey : 'explicit'}`\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\tconst widgetIdRef = useRef<number | null>(null)\n\tconst onTokenRef = useRef(onToken)\n\tonTokenRef.current = onToken\n\tconst optionsRef = useRef(options)\n\toptionsRef.current = options\n\tconst actionRef = useRef(action)\n\tactionRef.current = action\n\tconst ready = useCaptchaScript(src, adapters?.loadScript)\n\n\tconst execute = useCallback(\n\t\tasync (overrideAction?: string): Promise<string | null> => {\n\t\t\tconst api = getGrecaptcha()\n\t\t\tif (!api || version !== 'v3') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst token = await api.execute(siteKey, {\n\t\t\t\t\taction: overrideAction ?? actionRef.current ?? 'submit',\n\t\t\t\t})\n\t\t\t\tonTokenRef.current(token)\n\t\t\t\treturn token\n\t\t\t} catch {\n\t\t\t\tonTokenRef.current(null)\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\t\t[siteKey, version]\n\t)\n\n\tuseImperativeHandle(\n\t\tref,\n\t\t() => ({\n\t\t\treset: () => {\n\t\t\t\tconst api = getGrecaptcha()\n\t\t\t\tif (api && version === 'v2' && widgetIdRef.current !== null) {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t}\n\t\t\t\tonTokenRef.current(null)\n\t\t\t},\n\t\t\texecute,\n\t\t}),\n\t\t[execute, version]\n\t)\n\n\tuseEffect(() => {\n\t\tif (!ready) {\n\t\t\treturn\n\t\t}\n\t\tconst api = getGrecaptcha()\n\t\tif (!api) {\n\t\t\treturn\n\t\t}\n\t\tlet active = true\n\t\tif (version === 'v3') {\n\t\t\tapi.ready(() => {\n\t\t\t\tif (active) {\n\t\t\t\t\tvoid execute()\n\t\t\t\t}\n\t\t\t})\n\t\t\treturn () => {\n\t\t\t\tactive = false\n\t\t\t}\n\t\t}\n\t\tconst container = containerRef.current\n\t\tif (!container) {\n\t\t\treturn\n\t\t}\n\t\tapi.ready(() => {\n\t\t\tif (!active || widgetIdRef.current !== null) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\twidgetIdRef.current = api.render(container, {\n\t\t\t\t...optionsRef.current,\n\t\t\t\tsitekey: siteKey,\n\t\t\t\tcallback: (token: string) => onTokenRef.current(token),\n\t\t\t\t'expired-callback': () => onTokenRef.current(null),\n\t\t\t\t'error-callback': () => onTokenRef.current(null),\n\t\t\t})\n\t\t})\n\t\treturn () => {\n\t\t\tactive = false\n\t\t\tif (widgetIdRef.current !== null) {\n\t\t\t\ttry {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t} catch {}\n\t\t\t\twidgetIdRef.current = null\n\t\t\t}\n\t\t\t// grecaptcha has no remove(); clearing the container drops the detached widget DOM.\n\t\t\tcontainer.replaceChildren()\n\t\t}\n\t}, [ready, siteKey, version, execute])\n\n\treturn <div ref={containerRef} className={className} />\n}\n"],"mappings":";;;;;;AAQA,MAAa,uBAAuB;;;;;;AAmCpC,MAAa,oBAAoB,EAChC,SACA,SACA,QACA,SACA,SACA,WACA,WACA,UACA,UAC4B;CAC5B,MAAM,MACL,aAAa,kDAAkC,YAAY,OAAO,UAAU;CAC7E,MAAM,eAAe,OAAuB,IAAI;CAChD,MAAM,cAAc,OAAsB,IAAI;CAC9C,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,YAAY,OAAO,MAAM;CAC/B,UAAU,UAAU;CACpB,MAAM,QAAQ,iBAAiB,KAAK,UAAU,UAAU;CAExD,MAAM,UAAU,YACf,OAAO,mBAAoD;EAC1D,MAAM,MAAM,cAAc;EAC1B,IAAI,CAAC,OAAO,YAAY,MACvB,OAAO;EAER,IAAI;GACH,MAAM,QAAQ,MAAM,IAAI,QAAQ,SAAS,EACxC,QAAQ,kBAAkB,UAAU,WAAW,SAChD,CAAC;GACD,WAAW,QAAQ,KAAK;GACxB,OAAO;EACR,QAAQ;GACP,WAAW,QAAQ,IAAI;GACvB,OAAO;EACR;CACD,GACA,CAAC,SAAS,OAAO,CAClB;CAEA,oBACC,YACO;EACN,aAAa;GACZ,MAAM,MAAM,cAAc;GAC1B,IAAI,OAAO,YAAY,QAAQ,YAAY,YAAY,MACtD,IAAI,MAAM,YAAY,OAAO;GAE9B,WAAW,QAAQ,IAAI;EACxB;EACA;CACD,IACA,CAAC,SAAS,OAAO,CAClB;CAEA,gBAAgB;EACf,IAAI,CAAC,OACJ;EAED,MAAM,MAAM,cAAc;EAC1B,IAAI,CAAC,KACJ;EAED,IAAI,SAAS;EACb,IAAI,YAAY,MAAM;GACrB,IAAI,YAAY;IACf,IAAI,QACH,QAAa;GAEf,CAAC;GACD,aAAa;IACZ,SAAS;GACV;EACD;EACA,MAAM,YAAY,aAAa;EAC/B,IAAI,CAAC,WACJ;EAED,IAAI,YAAY;GACf,IAAI,CAAC,UAAU,YAAY,YAAY,MACtC;GAED,YAAY,UAAU,IAAI,OAAO,WAAW;IAC3C,GAAG,WAAW;IACd,SAAS;IACT,WAAW,UAAkB,WAAW,QAAQ,KAAK;IACrD,0BAA0B,WAAW,QAAQ,IAAI;IACjD,wBAAwB,WAAW,QAAQ,IAAI;GAChD,CAAC;EACF,CAAC;EACD,aAAa;GACZ,SAAS;GACT,IAAI,YAAY,YAAY,MAAM;IACjC,IAAI;KACH,IAAI,MAAM,YAAY,OAAO;IAC9B,QAAQ,CAAC;IACT,YAAY,UAAU;GACvB;GAEA,UAAU,gBAAgB;EAC3B;CACD,GAAG;EAAC;EAAO;EAAS;EAAS;CAAO,CAAC;CAErC,OAAO,oBAAC,OAAD;EAAK,KAAK;EAAyB;CAAY,CAAA;AACvD"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FormAdapters } from "../adapters.js";
|
|
1
2
|
import { CaptchaWidgetHandle } from "./types.js";
|
|
2
3
|
import { Ref } from "react";
|
|
3
4
|
|
|
@@ -8,7 +9,8 @@ type TurnstileCaptchaProps = {
|
|
|
8
9
|
onToken: (token: string | null) => void; /** Extra vendor render params (theme, size, appearance, ...). */
|
|
9
10
|
options?: Record<string, unknown>;
|
|
10
11
|
className?: string; /** Override the vendor script URL. */
|
|
11
|
-
scriptSrc?: string;
|
|
12
|
+
scriptSrc?: string; /** Host-owned effects; only `loadScript` applies here (consent-gated or CSP-nonced script loading). */
|
|
13
|
+
adapters?: Pick<FormAdapters, 'loadScript'>;
|
|
12
14
|
ref?: Ref<CaptchaWidgetHandle>;
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
@@ -21,6 +23,7 @@ declare const TurnstileCaptcha: ({
|
|
|
21
23
|
options,
|
|
22
24
|
className,
|
|
23
25
|
scriptSrc,
|
|
26
|
+
adapters,
|
|
24
27
|
ref
|
|
25
28
|
}: TurnstileCaptchaProps) => import("react/jsx-runtime").JSX.Element;
|
|
26
29
|
//#endregion
|
|
@@ -9,14 +9,14 @@ const TURNSTILE_SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api
|
|
|
9
9
|
* Headless Cloudflare Turnstile widget: loads the vendor script once, renders the widget into a
|
|
10
10
|
* bare div, and reports tokens through `onToken`. Pass the latest token to `<Form captchaToken>`.
|
|
11
11
|
*/
|
|
12
|
-
const TurnstileCaptcha = ({ siteKey, onToken, options, className, scriptSrc = TURNSTILE_SCRIPT_URL, ref }) => {
|
|
12
|
+
const TurnstileCaptcha = ({ siteKey, onToken, options, className, scriptSrc = TURNSTILE_SCRIPT_URL, adapters, ref }) => {
|
|
13
13
|
const containerRef = useRef(null);
|
|
14
14
|
const widgetIdRef = useRef(null);
|
|
15
15
|
const onTokenRef = useRef(onToken);
|
|
16
16
|
onTokenRef.current = onToken;
|
|
17
17
|
const optionsRef = useRef(options);
|
|
18
18
|
optionsRef.current = options;
|
|
19
|
-
const ready = useCaptchaScript(scriptSrc);
|
|
19
|
+
const ready = useCaptchaScript(scriptSrc, adapters?.loadScript);
|
|
20
20
|
useImperativeHandle(ref, () => ({ reset: () => {
|
|
21
21
|
const api = getTurnstile();
|
|
22
22
|
if (api && widgetIdRef.current !== null) api.reset(widgetIdRef.current);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TurnstileCaptcha.js","names":[],"sources":["../../../src/react/captcha/TurnstileCaptcha.tsx"],"sourcesContent":["'use client'\n\nimport { type Ref, useEffect, useImperativeHandle, useRef } from 'react'\nimport type { CaptchaWidgetHandle } from './types'\nimport { useCaptchaScript } from './useCaptchaScript'\nimport { getTurnstile } from './vendors'\n\nexport const TURNSTILE_SCRIPT_URL =\n\t'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'\n\nexport type TurnstileCaptchaProps = {\n\t/** Turnstile site key (public). */\n\tsiteKey: string\n\t/** Receives each fresh token, and null when the token expires or errors. */\n\tonToken: (token: string | null) => void\n\t/** Extra vendor render params (theme, size, appearance, ...). */\n\toptions?: Record<string, unknown>\n\tclassName?: string\n\t/** Override the vendor script URL. */\n\tscriptSrc?: string\n\tref?: Ref<CaptchaWidgetHandle>\n}\n\n/**\n * Headless Cloudflare Turnstile widget: loads the vendor script once, renders the widget into a\n * bare div, and reports tokens through `onToken`. Pass the latest token to `<Form captchaToken>`.\n */\nexport const TurnstileCaptcha = ({\n\tsiteKey,\n\tonToken,\n\toptions,\n\tclassName,\n\tscriptSrc = TURNSTILE_SCRIPT_URL,\n\tref,\n}: TurnstileCaptchaProps) => {\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\tconst widgetIdRef = useRef<string | null>(null)\n\tconst onTokenRef = useRef(onToken)\n\tonTokenRef.current = onToken\n\tconst optionsRef = useRef(options)\n\toptionsRef.current = options\n\tconst ready = useCaptchaScript(scriptSrc)\n\n\tuseImperativeHandle(\n\t\tref,\n\t\t() => ({\n\t\t\treset: () => {\n\t\t\t\tconst api = getTurnstile()\n\t\t\t\tif (api && widgetIdRef.current !== null) {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t}\n\t\t\t\tonTokenRef.current(null)\n\t\t\t},\n\t\t}),\n\t\t[]\n\t)\n\n\tuseEffect(() => {\n\t\tif (!ready) {\n\t\t\treturn\n\t\t}\n\t\tconst container = containerRef.current\n\t\tconst api = getTurnstile()\n\t\tif (!container || !api) {\n\t\t\treturn\n\t\t}\n\t\tconst widgetId = api.render(container, {\n\t\t\t...optionsRef.current,\n\t\t\tsitekey: siteKey,\n\t\t\tcallback: (token: string) => onTokenRef.current(token),\n\t\t\t'expired-callback': () => onTokenRef.current(null),\n\t\t\t'error-callback': () => onTokenRef.current(null),\n\t\t})\n\t\twidgetIdRef.current = widgetId\n\t\treturn () => {\n\t\t\twidgetIdRef.current = null\n\t\t\ttry {\n\t\t\t\tapi.remove(widgetId)\n\t\t\t} catch {}\n\t\t}\n\t}, [ready, siteKey])\n\n\treturn <div ref={containerRef} className={className} />\n}\n"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"TurnstileCaptcha.js","names":[],"sources":["../../../src/react/captcha/TurnstileCaptcha.tsx"],"sourcesContent":["'use client'\n\nimport { type Ref, useEffect, useImperativeHandle, useRef } from 'react'\nimport type { FormAdapters } from '../adapters'\nimport type { CaptchaWidgetHandle } from './types'\nimport { useCaptchaScript } from './useCaptchaScript'\nimport { getTurnstile } from './vendors'\n\nexport const TURNSTILE_SCRIPT_URL =\n\t'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'\n\nexport type TurnstileCaptchaProps = {\n\t/** Turnstile site key (public). */\n\tsiteKey: string\n\t/** Receives each fresh token, and null when the token expires or errors. */\n\tonToken: (token: string | null) => void\n\t/** Extra vendor render params (theme, size, appearance, ...). */\n\toptions?: Record<string, unknown>\n\tclassName?: string\n\t/** Override the vendor script URL. */\n\tscriptSrc?: string\n\t/** Host-owned effects; only `loadScript` applies here (consent-gated or CSP-nonced script loading). */\n\tadapters?: Pick<FormAdapters, 'loadScript'>\n\tref?: Ref<CaptchaWidgetHandle>\n}\n\n/**\n * Headless Cloudflare Turnstile widget: loads the vendor script once, renders the widget into a\n * bare div, and reports tokens through `onToken`. Pass the latest token to `<Form captchaToken>`.\n */\nexport const TurnstileCaptcha = ({\n\tsiteKey,\n\tonToken,\n\toptions,\n\tclassName,\n\tscriptSrc = TURNSTILE_SCRIPT_URL,\n\tadapters,\n\tref,\n}: TurnstileCaptchaProps) => {\n\tconst containerRef = useRef<HTMLDivElement>(null)\n\tconst widgetIdRef = useRef<string | null>(null)\n\tconst onTokenRef = useRef(onToken)\n\tonTokenRef.current = onToken\n\tconst optionsRef = useRef(options)\n\toptionsRef.current = options\n\tconst ready = useCaptchaScript(scriptSrc, adapters?.loadScript)\n\n\tuseImperativeHandle(\n\t\tref,\n\t\t() => ({\n\t\t\treset: () => {\n\t\t\t\tconst api = getTurnstile()\n\t\t\t\tif (api && widgetIdRef.current !== null) {\n\t\t\t\t\tapi.reset(widgetIdRef.current)\n\t\t\t\t}\n\t\t\t\tonTokenRef.current(null)\n\t\t\t},\n\t\t}),\n\t\t[]\n\t)\n\n\tuseEffect(() => {\n\t\tif (!ready) {\n\t\t\treturn\n\t\t}\n\t\tconst container = containerRef.current\n\t\tconst api = getTurnstile()\n\t\tif (!container || !api) {\n\t\t\treturn\n\t\t}\n\t\tconst widgetId = api.render(container, {\n\t\t\t...optionsRef.current,\n\t\t\tsitekey: siteKey,\n\t\t\tcallback: (token: string) => onTokenRef.current(token),\n\t\t\t'expired-callback': () => onTokenRef.current(null),\n\t\t\t'error-callback': () => onTokenRef.current(null),\n\t\t})\n\t\twidgetIdRef.current = widgetId\n\t\treturn () => {\n\t\t\twidgetIdRef.current = null\n\t\t\ttry {\n\t\t\t\tapi.remove(widgetId)\n\t\t\t} catch {}\n\t\t}\n\t}, [ready, siteKey])\n\n\treturn <div ref={containerRef} className={className} />\n}\n"],"mappings":";;;;;;AAQA,MAAa,uBACZ;;;;;AAqBD,MAAa,oBAAoB,EAChC,SACA,SACA,SACA,WACA,YAAY,sBACZ,UACA,UAC4B;CAC5B,MAAM,eAAe,OAAuB,IAAI;CAChD,MAAM,cAAc,OAAsB,IAAI;CAC9C,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,aAAa,OAAO,OAAO;CACjC,WAAW,UAAU;CACrB,MAAM,QAAQ,iBAAiB,WAAW,UAAU,UAAU;CAE9D,oBACC,YACO,EACN,aAAa;EACZ,MAAM,MAAM,aAAa;EACzB,IAAI,OAAO,YAAY,YAAY,MAClC,IAAI,MAAM,YAAY,OAAO;EAE9B,WAAW,QAAQ,IAAI;CACxB,EACD,IACA,CAAC,CACF;CAEA,gBAAgB;EACf,IAAI,CAAC,OACJ;EAED,MAAM,YAAY,aAAa;EAC/B,MAAM,MAAM,aAAa;EACzB,IAAI,CAAC,aAAa,CAAC,KAClB;EAED,MAAM,WAAW,IAAI,OAAO,WAAW;GACtC,GAAG,WAAW;GACd,SAAS;GACT,WAAW,UAAkB,WAAW,QAAQ,KAAK;GACrD,0BAA0B,WAAW,QAAQ,IAAI;GACjD,wBAAwB,WAAW,QAAQ,IAAI;EAChD,CAAC;EACD,YAAY,UAAU;EACtB,aAAa;GACZ,YAAY,UAAU;GACtB,IAAI;IACH,IAAI,OAAO,QAAQ;GACpB,QAAQ,CAAC;EACV;CACD,GAAG,CAAC,OAAO,OAAO,CAAC;CAEnB,OAAO,oBAAC,OAAD;EAAK,KAAK;EAAyB;CAAY,CAAA;AACvD"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
//#region src/react/captcha/useCaptchaScript.ts
|
|
4
4
|
const pending = /* @__PURE__ */ new Map();
|
|
5
|
-
const
|
|
5
|
+
const injectScript = (src) => {
|
|
6
6
|
const cached = pending.get(src);
|
|
7
7
|
if (cached) return cached;
|
|
8
8
|
const promise = new Promise((resolve, reject) => {
|
|
@@ -20,23 +20,41 @@ const loadScript = (src) => {
|
|
|
20
20
|
pending.set(src, promise);
|
|
21
21
|
return promise;
|
|
22
22
|
};
|
|
23
|
+
const customCaches = /* @__PURE__ */ new WeakMap();
|
|
24
|
+
const loadThrough = (loader, src) => {
|
|
25
|
+
let cache = customCaches.get(loader);
|
|
26
|
+
if (!cache) {
|
|
27
|
+
cache = /* @__PURE__ */ new Map();
|
|
28
|
+
customCaches.set(loader, cache);
|
|
29
|
+
}
|
|
30
|
+
const cached = cache.get(src);
|
|
31
|
+
if (cached) return cached;
|
|
32
|
+
const promise = loader(src).catch((error) => {
|
|
33
|
+
cache.delete(src);
|
|
34
|
+
throw error;
|
|
35
|
+
});
|
|
36
|
+
cache.set(src, promise);
|
|
37
|
+
return promise;
|
|
38
|
+
};
|
|
23
39
|
/**
|
|
24
40
|
* Load a vendor captcha script once per src, shared across all widget instances. SSR-safe (the
|
|
25
41
|
* DOM is only touched inside the effect). A failed load evicts the cache entry so a later mount
|
|
26
|
-
* can retry; consumers just see `ready` stay false.
|
|
42
|
+
* can retry; consumers just see `ready` stay false. A host `loadScript` (e.g. a consent-gated
|
|
43
|
+
* loader) replaces the `document.head` injection; pass a stable reference, since a new function
|
|
44
|
+
* identity re-runs the load with a fresh cache.
|
|
27
45
|
*/
|
|
28
|
-
const useCaptchaScript = (src) => {
|
|
46
|
+
const useCaptchaScript = (src, loadScript) => {
|
|
29
47
|
const [ready, setReady] = useState(false);
|
|
30
48
|
useEffect(() => {
|
|
31
49
|
let active = true;
|
|
32
50
|
setReady(false);
|
|
33
|
-
loadScript(src).then(() => {
|
|
51
|
+
(loadScript ? loadThrough(loadScript, src) : injectScript(src)).then(() => {
|
|
34
52
|
if (active) setReady(true);
|
|
35
53
|
}, () => {});
|
|
36
54
|
return () => {
|
|
37
55
|
active = false;
|
|
38
56
|
};
|
|
39
|
-
}, [src]);
|
|
57
|
+
}, [src, loadScript]);
|
|
40
58
|
return ready;
|
|
41
59
|
};
|
|
42
60
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useCaptchaScript.js","names":[],"sources":["../../../src/react/captcha/useCaptchaScript.ts"],"sourcesContent":["'use client'\n\nimport { useEffect, useState } from 'react'\n\nconst pending = new Map<string, Promise<void>>()\n\nconst
|
|
1
|
+
{"version":3,"file":"useCaptchaScript.js","names":[],"sources":["../../../src/react/captcha/useCaptchaScript.ts"],"sourcesContent":["'use client'\n\nimport { useEffect, useState } from 'react'\nimport type { LoadScript } from '../adapters'\n\nconst pending = new Map<string, Promise<void>>()\n\nconst injectScript = (src: string): Promise<void> => {\n\tconst cached = pending.get(src)\n\tif (cached) {\n\t\treturn cached\n\t}\n\tconst promise = new Promise<void>((resolve, reject) => {\n\t\tconst script = document.createElement('script')\n\t\tscript.src = src\n\t\tscript.async = true\n\t\tscript.addEventListener('load', () => resolve())\n\t\tscript.addEventListener('error', () => {\n\t\t\tpending.delete(src)\n\t\t\tscript.remove()\n\t\t\treject(new Error(`Failed to load ${src}`))\n\t\t})\n\t\tdocument.head.appendChild(script)\n\t})\n\tpending.set(src, promise)\n\treturn promise\n}\n\n// A custom loader gets the same per-src cache and evict-on-failure retry, keyed per loader so two\n// adapters (or the default injector) never share entries.\nconst customCaches = new WeakMap<LoadScript, Map<string, Promise<void>>>()\n\nconst loadThrough = (loader: LoadScript, src: string): Promise<void> => {\n\tlet cache = customCaches.get(loader)\n\tif (!cache) {\n\t\tcache = new Map()\n\t\tcustomCaches.set(loader, cache)\n\t}\n\tconst cached = cache.get(src)\n\tif (cached) {\n\t\treturn cached\n\t}\n\tconst promise = loader(src).catch((error) => {\n\t\tcache.delete(src)\n\t\tthrow error\n\t})\n\tcache.set(src, promise)\n\treturn promise\n}\n\n/**\n * Load a vendor captcha script once per src, shared across all widget instances. SSR-safe (the\n * DOM is only touched inside the effect). A failed load evicts the cache entry so a later mount\n * can retry; consumers just see `ready` stay false. A host `loadScript` (e.g. a consent-gated\n * loader) replaces the `document.head` injection; pass a stable reference, since a new function\n * identity re-runs the load with a fresh cache.\n */\nexport const useCaptchaScript = (src: string, loadScript?: LoadScript): boolean => {\n\tconst [ready, setReady] = useState(false)\n\tuseEffect(() => {\n\t\tlet active = true\n\t\tsetReady(false)\n\t\tconst load = loadScript ? loadThrough(loadScript, src) : injectScript(src)\n\t\tload.then(\n\t\t\t() => {\n\t\t\t\tif (active) {\n\t\t\t\t\tsetReady(true)\n\t\t\t\t}\n\t\t\t},\n\t\t\t() => {}\n\t\t)\n\t\treturn () => {\n\t\t\tactive = false\n\t\t}\n\t}, [src, loadScript])\n\treturn ready\n}\n"],"mappings":";;;AAKA,MAAM,0BAAU,IAAI,IAA2B;AAE/C,MAAM,gBAAgB,QAA+B;CACpD,MAAM,SAAS,QAAQ,IAAI,GAAG;CAC9B,IAAI,QACH,OAAO;CAER,MAAM,UAAU,IAAI,SAAe,SAAS,WAAW;EACtD,MAAM,SAAS,SAAS,cAAc,QAAQ;EAC9C,OAAO,MAAM;EACb,OAAO,QAAQ;EACf,OAAO,iBAAiB,cAAc,QAAQ,CAAC;EAC/C,OAAO,iBAAiB,eAAe;GACtC,QAAQ,OAAO,GAAG;GAClB,OAAO,OAAO;GACd,uBAAO,IAAI,MAAM,kBAAkB,KAAK,CAAC;EAC1C,CAAC;EACD,SAAS,KAAK,YAAY,MAAM;CACjC,CAAC;CACD,QAAQ,IAAI,KAAK,OAAO;CACxB,OAAO;AACR;AAIA,MAAM,+BAAe,IAAI,QAAgD;AAEzE,MAAM,eAAe,QAAoB,QAA+B;CACvE,IAAI,QAAQ,aAAa,IAAI,MAAM;CACnC,IAAI,CAAC,OAAO;EACX,wBAAQ,IAAI,IAAI;EAChB,aAAa,IAAI,QAAQ,KAAK;CAC/B;CACA,MAAM,SAAS,MAAM,IAAI,GAAG;CAC5B,IAAI,QACH,OAAO;CAER,MAAM,UAAU,OAAO,GAAG,EAAE,OAAO,UAAU;EAC5C,MAAM,OAAO,GAAG;EAChB,MAAM;CACP,CAAC;CACD,MAAM,IAAI,KAAK,OAAO;CACtB,OAAO;AACR;;;;;;;;AASA,MAAa,oBAAoB,KAAa,eAAqC;CAClF,MAAM,CAAC,OAAO,YAAY,SAAS,KAAK;CACxC,gBAAgB;EACf,IAAI,SAAS;EACb,SAAS,KAAK;EAEd,CADa,aAAa,YAAY,YAAY,GAAG,IAAI,aAAa,GAAG,GACpE,WACE;GACL,IAAI,QACH,SAAS,IAAI;EAEf,SACM,CAAC,CACR;EACA,aAAa;GACZ,SAAS;EACV;CACD,GAAG,CAAC,KAAK,UAAU,CAAC;CACpB,OAAO;AACR"}
|
package/dist/spam/spamGuard.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { keys } from "../translations/keys.js";
|
|
2
2
|
import { asTranslate } from "../translations/server.js";
|
|
3
|
+
import { voteChangeTargetOf } from "../submissions/votedCookie.js";
|
|
3
4
|
import { firstHop } from "./clientIp.js";
|
|
4
5
|
import { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from "./constants.js";
|
|
5
6
|
import { extractReservedValues, isHoneypotTripped } from "./reserved.js";
|
|
@@ -22,7 +23,7 @@ const warnRateLimitSkippedOnce = (req) => {
|
|
|
22
23
|
* complements edge/CDN/WAF limiting, not a DoS replacement.
|
|
23
24
|
*/
|
|
24
25
|
const buildSpamGuard = (spam) => async ({ data, operation, req }) => {
|
|
25
|
-
if (operation
|
|
26
|
+
if (!(operation === "create" || operation === "update" && voteChangeTargetOf(req) !== void 0) || !data) return data;
|
|
26
27
|
const t = asTranslate(req.i18n.t);
|
|
27
28
|
const authenticated = Boolean(req.user);
|
|
28
29
|
const identity = await spam.identify(req);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook, type PayloadRequest } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\nlet warnedRateLimitSkipped = false\n\n/** Warn once per process when rate limiting is configured but skipped for lack of a request identity. */\nconst warnRateLimitSkippedOnce = (req: PayloadRequest): void => {\n\tif (warnedRateLimitSkipped) {\n\t\treturn\n\t}\n\twarnedRateLimitSkipped = true\n\treq.payload.logger?.warn(\n\t\t'@10x-media/form-builder: submission rate limiting is configured but skipped for a request with no resolvable identity (no user and no client IP). Set spam.ipHeader for your proxy or provide a custom spam.identify.'\n\t)\n}\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\
|
|
1
|
+
{"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook, type PayloadRequest } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { voteChangeTargetOf } from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { HONEYPOT_VALUE_KEY, IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\nlet warnedRateLimitSkipped = false\n\n/** Warn once per process when rate limiting is configured but skipped for lack of a request identity. */\nconst warnRateLimitSkippedOnce = (req: PayloadRequest): void => {\n\tif (warnedRateLimitSkipped) {\n\t\treturn\n\t}\n\twarnedRateLimitSkipped = true\n\treq.payload.logger?.warn(\n\t\t'@10x-media/form-builder: submission rate limiting is configured but skipped for a request with no resolvable identity (no user and no client IP). Set spam.ipHeader for your proxy or provide a custom spam.identify.'\n\t)\n}\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\t// A flagged vote-change update is an anonymous public write like a create, so the full guard\n\t\t// (rate limit, honeypot, captcha, metadata) applies to it too; other updates stay exempt.\n\t\tconst guarded =\n\t\t\toperation === 'create' || (operation === 'update' && voteChangeTargetOf(req) !== undefined)\n\t\tif (!guarded || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tlet rateLimitState: 'enforced' | 'skipped-no-identity' | 'disabled'\n\t\tif (spam.rateLimit === false) {\n\t\t\trateLimitState = 'disabled'\n\t\t} else if (identity == null) {\n\t\t\t// Fail open (an unidentifiable submitter cannot be keyed) but never silently: record it on\n\t\t\t// meta.spam and warn once, so a deployment with no proxy or a misconfigured ipHeader is visible.\n\t\t\trateLimitState = 'skipped-no-identity'\n\t\t\twarnRateLimitSkippedOnce(req)\n\t\t} else {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t\trateLimitState = 'enforced'\n\t\t}\n\n\t\t// The decoy rides a fixed reserved key (not its cosmetic DOM name), so a real field sharing that\n\t\t// name is never stripped or mistaken for the honeypot. `spam.honeypot.fieldName` is now only the\n\t\t// client's DOM-input name hint and no longer participates in server matching.\n\t\tconst honeypotKey = spam.honeypot === false ? null : HONEYPOT_VALUE_KEY\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotKey)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotKey !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped', rateLimit: rateLimitState },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req.headers, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;;AAUA,IAAI,yBAAyB;;AAG7B,MAAM,4BAA4B,QAA8B;CAC/D,IAAI,wBACH;CAED,yBAAyB;CACzB,IAAI,QAAQ,QAAQ,KACnB,uNACD;AACD;;;;;;;;;;AAWA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CAKnC,IAAI,EADH,cAAc,YAAa,cAAc,YAAY,mBAAmB,GAAG,MAAM,KAAA,MAClE,CAAC,MAChB,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI;CACJ,IAAI,KAAK,cAAc,OACtB,iBAAiB;MACX,IAAI,YAAY,MAAM;EAG5B,iBAAiB;EACjB,yBAAyB,GAAG;CAC7B,OAAO;EACN,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;EAEhD,iBAAiB;CAClB;CAKA,MAAM,cAAc,KAAK,aAAa,QAAQ,OAAO;CAErD,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,WAAW;CACrF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,gBAAgB,QAAQ,kBAAkB,QAAQ,GACvE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM;GAAE,SAAS,iBAAiB,WAAW;GAAW,WAAW;EAAe;CACnF;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ;EAC9C,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Payload } from "payload";
|
|
2
|
+
|
|
3
|
+
//#region src/submissions/resolveVotedSubmission.d.ts
|
|
4
|
+
/** The voter's current vote for a poll, resolved server-side from the httpOnly voted cookie. */
|
|
5
|
+
type VotedSubmission = {
|
|
6
|
+
submissionId: string; /** The stored answer of the poll results field exactly as submitted (string, or string[] for a multi-select); use it to prefill the form for a vote change. */
|
|
7
|
+
value: unknown; /** The same answer normalized to non-empty strings; use it to highlight the voter's pick in results. */
|
|
8
|
+
pick: string[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The
|
|
12
|
+
* cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's
|
|
13
|
+
* `Cookie` header (e.g. Next's `(await headers()).get('cookie')`) and hands the result to
|
|
14
|
+
* `<Poll currentVote hasVoted>`. Returns null whenever the cookie is absent, is the legacy `1`
|
|
15
|
+
* marker, fails signature verification, or names a submission that no longer exists or belongs to
|
|
16
|
+
* a different form, so a null simply means "treat this visitor as not having voted". Works for any
|
|
17
|
+
* poll whose cookie carries a signed id (set for `allowChange` polls), even if `allowChange` was
|
|
18
|
+
* later turned off.
|
|
19
|
+
*/
|
|
20
|
+
declare const resolveVotedSubmission: (args: {
|
|
21
|
+
payload: Payload;
|
|
22
|
+
cookieHeader: string | null | undefined;
|
|
23
|
+
formId: number | string;
|
|
24
|
+
}) => Promise<VotedSubmission | null>;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { VotedSubmission, resolveVotedSubmission };
|
|
27
|
+
//# sourceMappingURL=resolveVotedSubmission.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { pollConfigOf } from "../form/pollState.js";
|
|
2
|
+
import { formIdOf } from "./formIdOf.js";
|
|
3
|
+
import { answerValues } from "../poll/votes/answerValues.js";
|
|
4
|
+
import { votedSubmissionIdFromCookie } from "./votedCookie.js";
|
|
5
|
+
//#region src/submissions/resolveVotedSubmission.ts
|
|
6
|
+
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
7
|
+
const FORMS_SLUG = "forms";
|
|
8
|
+
/**
|
|
9
|
+
* Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The
|
|
10
|
+
* cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's
|
|
11
|
+
* `Cookie` header (e.g. Next's `(await headers()).get('cookie')`) and hands the result to
|
|
12
|
+
* `<Poll currentVote hasVoted>`. Returns null whenever the cookie is absent, is the legacy `1`
|
|
13
|
+
* marker, fails signature verification, or names a submission that no longer exists or belongs to
|
|
14
|
+
* a different form, so a null simply means "treat this visitor as not having voted". Works for any
|
|
15
|
+
* poll whose cookie carries a signed id (set for `allowChange` polls), even if `allowChange` was
|
|
16
|
+
* later turned off.
|
|
17
|
+
*/
|
|
18
|
+
const resolveVotedSubmission = async (args) => {
|
|
19
|
+
const { payload, cookieHeader, formId } = args;
|
|
20
|
+
const submissionId = votedSubmissionIdFromCookie(cookieHeader, formId, payload.secret);
|
|
21
|
+
if (submissionId == null) return null;
|
|
22
|
+
const submission = await payload.findByID({
|
|
23
|
+
collection: FORM_SUBMISSIONS_SLUG,
|
|
24
|
+
id: submissionId,
|
|
25
|
+
depth: 0,
|
|
26
|
+
overrideAccess: true
|
|
27
|
+
}).catch(() => null);
|
|
28
|
+
if (submission == null) return null;
|
|
29
|
+
const stored = submission;
|
|
30
|
+
if (String(formIdOf(stored.form) ?? "") !== String(formId)) return null;
|
|
31
|
+
if (stored.status != null && stored.status !== "complete") return null;
|
|
32
|
+
const form = await payload.findByID({
|
|
33
|
+
collection: FORMS_SLUG,
|
|
34
|
+
id: formId,
|
|
35
|
+
depth: 0,
|
|
36
|
+
overrideAccess: true
|
|
37
|
+
}).catch(() => null);
|
|
38
|
+
const poll = form?.pollEnabled === true ? pollConfigOf(form.poll) : void 0;
|
|
39
|
+
const resultsField = typeof poll?.resultsField === "string" && poll.resultsField.length > 0 ? poll.resultsField : void 0;
|
|
40
|
+
if (!resultsField) return null;
|
|
41
|
+
const entry = Array.isArray(stored.values) ? stored.values.find((row) => row.field === resultsField) : void 0;
|
|
42
|
+
return {
|
|
43
|
+
submissionId: String(submission.id),
|
|
44
|
+
value: entry?.value,
|
|
45
|
+
pick: answerValues(stored.values, resultsField)
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { resolveVotedSubmission };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=resolveVotedSubmission.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolveVotedSubmission.js","names":[],"sources":["../../src/submissions/resolveVotedSubmission.ts"],"sourcesContent":["import type { Payload } from 'payload'\nimport { pollConfigOf } from '../form/pollState'\nimport { answerValues } from '../poll/votes/answerValues'\nimport { formIdOf } from './formIdOf'\nimport { votedSubmissionIdFromCookie } from './votedCookie'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst FORMS_SLUG = 'forms'\n\n/** The voter's current vote for a poll, resolved server-side from the httpOnly voted cookie. */\nexport type VotedSubmission = {\n\tsubmissionId: string\n\t/** The stored answer of the poll results field exactly as submitted (string, or string[] for a multi-select); use it to prefill the form for a vote change. */\n\tvalue: unknown\n\t/** The same answer normalized to non-empty strings; use it to highlight the voter's pick in results. */\n\tpick: string[]\n}\n\n/**\n * Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The\n * cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's\n * `Cookie` header (e.g. Next's `(await headers()).get('cookie')`) and hands the result to\n * `<Poll currentVote hasVoted>`. Returns null whenever the cookie is absent, is the legacy `1`\n * marker, fails signature verification, or names a submission that no longer exists or belongs to\n * a different form, so a null simply means \"treat this visitor as not having voted\". Works for any\n * poll whose cookie carries a signed id (set for `allowChange` polls), even if `allowChange` was\n * later turned off.\n */\nexport const resolveVotedSubmission = async (args: {\n\tpayload: Payload\n\tcookieHeader: string | null | undefined\n\tformId: number | string\n}): Promise<VotedSubmission | null> => {\n\tconst { payload, cookieHeader, formId } = args\n\tconst submissionId = votedSubmissionIdFromCookie(cookieHeader, formId, payload.secret)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t})\n\t\t.catch(() => null)\n\tif (submission == null) {\n\t\treturn null\n\t}\n\tconst stored = submission as { form?: unknown; status?: unknown; values?: unknown }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId)) {\n\t\treturn null\n\t}\n\tif (stored.status != null && stored.status !== 'complete') {\n\t\treturn null\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true })\n\t\t.catch(() => null)\n\tconst poll = form?.pollEnabled === true ? pollConfigOf(form.poll) : undefined\n\tconst resultsField =\n\t\ttypeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t? poll.resultsField\n\t\t\t: undefined\n\tif (!resultsField) {\n\t\treturn null\n\t}\n\tconst entry = Array.isArray(stored.values)\n\t\t? (stored.values as { field?: unknown; value?: unknown }[]).find(\n\t\t\t\t(row) => row.field === resultsField\n\t\t\t)\n\t\t: undefined\n\treturn {\n\t\tsubmissionId: String(submission.id),\n\t\tvalue: entry?.value,\n\t\tpick: answerValues(stored.values, resultsField),\n\t}\n}\n"],"mappings":";;;;;AAMA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;;;;;;;;;;AAqBnB,MAAa,yBAAyB,OAAO,SAIN;CACtC,MAAM,EAAE,SAAS,cAAc,WAAW;CAC1C,MAAM,eAAe,4BAA4B,cAAc,QAAQ,QAAQ,MAAM;CACrF,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,aAAa,MAAM,QACvB,SAAS;EACT,YAAY;EACZ,IAAI;EACJ,OAAO;EACP,gBAAgB;CACjB,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,GACxD,OAAO;CAER,IAAI,OAAO,UAAU,QAAQ,OAAO,WAAW,YAC9C,OAAO;CAER,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;CAAK,CAAC,EAC/E,YAAY,IAAI;CAClB,MAAM,OAAO,MAAM,gBAAgB,OAAO,aAAa,KAAK,IAAI,IAAI,KAAA;CACpE,MAAM,eACL,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAClE,KAAK,eACL,KAAA;CACJ,IAAI,CAAC,cACJ,OAAO;CAER,MAAM,QAAQ,MAAM,QAAQ,OAAO,MAAM,IACrC,OAAO,OAAkD,MACzD,QAAQ,IAAI,UAAU,YACxB,IACC,KAAA;CACH,OAAO;EACN,cAAc,OAAO,WAAW,EAAE;EAClC,OAAO,OAAO;EACd,MAAM,aAAa,OAAO,QAAQ,YAAY;CAC/C;AACD"}
|
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
import { keys } from "../translations/keys.js";
|
|
2
2
|
import { asFieldTranslate, asTranslate } from "../translations/server.js";
|
|
3
3
|
import { resolveConsentEntries } from "../consent/resolveConsentEntries.js";
|
|
4
|
+
import { isPollClosed, pollConfigOf } from "../form/pollState.js";
|
|
4
5
|
import { calcExpressionOf } from "../calc/computeCalcFields.js";
|
|
5
6
|
import { resolveCalcContext } from "../calc/resolveCalcContext.js";
|
|
6
|
-
import { isPollClosed, pollConfigOf } from "../form/pollState.js";
|
|
7
7
|
import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
|
|
8
8
|
import { FORMS_SLUG } from "../collections/forms.js";
|
|
9
|
+
import { formIdOf } from "./formIdOf.js";
|
|
10
|
+
import { POLL_CONTEXT_KEY, voteChangeTargetOf } from "./votedCookie.js";
|
|
9
11
|
import { IDENTITY_CONTEXT_KEY } from "../spam/constants.js";
|
|
10
12
|
import { applyPollOptions } from "../poll/applyPollOptions.js";
|
|
11
13
|
import { runSubmission } from "./runSubmission.js";
|
|
12
|
-
import { POLL_CONTEXT_KEY } from "./votedCookie.js";
|
|
13
14
|
import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
|
|
14
15
|
import { APIError, ValidationError } from "payload";
|
|
15
16
|
//#region src/submissions/validateSubmission.ts
|
|
16
17
|
/**
|
|
17
|
-
* Server-authoritative submission validation. On create
|
|
18
|
+
* Server-authoritative submission validation. On create (and on a vote-change update flagged by the
|
|
19
|
+
* vote-submit endpoint) it loads the referenced form, re-runs every
|
|
18
20
|
* field's required check, intrinsic validator, and declarative rules through `runSubmission`, threading
|
|
19
21
|
* `req`/`payload` so server-only async rules can hit the DB, and throws a Payload `ValidationError` with
|
|
20
22
|
* per-field paths on any failure. The client is never trusted.
|
|
21
23
|
* Consent fields are captured into `result.consent` (array of proofs, one per visible consent field),
|
|
22
24
|
* built from the host's sources re-resolved here rather than from anything the form or client carries.
|
|
23
25
|
*/
|
|
24
|
-
const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions, consentSources, consentSnapshot, uploadSlug, pollSourceRegistry, strictUploadOwnership }) => async ({ data, operation, req }) => {
|
|
25
|
-
|
|
26
|
+
const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions, consentSources, consentSnapshot, uploadSlug, pollSourceRegistry, strictUploadOwnership }) => async ({ data, operation, originalDoc, req }) => {
|
|
27
|
+
const changeTarget = operation === "update" ? voteChangeTargetOf(req) : void 0;
|
|
28
|
+
if (operation !== "create" && changeTarget === void 0 || !data) return data;
|
|
26
29
|
const formId = data.form;
|
|
27
30
|
if (formId == null) return data;
|
|
31
|
+
if (changeTarget !== void 0) {
|
|
32
|
+
const originalFormId = originalDoc?.form;
|
|
33
|
+
const boundFormId = originalFormId != null ? formIdOf(originalFormId) : null;
|
|
34
|
+
if (boundFormId == null || String(boundFormId) !== String(formId)) throw new APIError("form-builder: a vote change cannot move a submission across forms", 400);
|
|
35
|
+
}
|
|
28
36
|
const form = await req.payload.findByID({
|
|
29
37
|
collection: FORMS_SLUG,
|
|
30
38
|
id: formId,
|
|
@@ -34,7 +42,10 @@ const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions
|
|
|
34
42
|
});
|
|
35
43
|
const poll = pollConfigOf(form.poll);
|
|
36
44
|
const pollEnabled = form.pollEnabled === true;
|
|
37
|
-
req.context[POLL_CONTEXT_KEY] =
|
|
45
|
+
req.context[POLL_CONTEXT_KEY] = {
|
|
46
|
+
pollEnabled,
|
|
47
|
+
allowChange: poll?.allowChange === true
|
|
48
|
+
};
|
|
38
49
|
if (pollEnabled && isPollClosed(poll)) throw new APIError(asTranslate(req.i18n.t)(keys.pollClosed), 403);
|
|
39
50
|
let fields = form.fields ?? [];
|
|
40
51
|
const incoming = data.values ?? [];
|