Turnstile improvement

This commit is contained in:
2026-09-25 12:50:15 +02:00
parent b82ef82f50
commit 6cb4168f44
17 changed files with 268 additions and 10 deletions
+3 -2
View File
@@ -10,7 +10,8 @@ export { Analytics } from '../modules/analytics/client.js';
*/
export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext, } from '../modules/consent/client.js';
export type { CookieBannerClassNames } from '../modules/consent/client.js';
export { Turnstile } from '../modules/turnstile/client.js';
export type { TurnstileProps } from '../modules/turnstile/client.js';
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
export type { FormNotificationTexts } from '../modules/notifications/types.js';
export { Turnstile } from '../modules/turnstile/client.js';
export { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js';
export type { TurnstileProps } from '../modules/turnstile/client.js';
+2 -1
View File
@@ -9,7 +9,8 @@ export { Analytics } from '../modules/analytics/client.js';
* components. Kept separate from the main entry so server bundles don't pull in
* client-only code.
*/ export { ConsentProvider, CookieBanner, CookieButton, useConsent, useConsentContext } from '../modules/consent/client.js';
export { Turnstile } from '../modules/turnstile/client.js';
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js';
export { Turnstile } from '../modules/turnstile/client.js';
export { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js';
//# sourceMappingURL=client.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","Turnstile","resolveFormMessage"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,SAAS,QAAQ,iCAAgC;AAG1D,SAASC,kBAAkB,QAAQ,iDAAgD"}
{"version":3,"sources":["../../src/exports/client.ts"],"sourcesContent":["'use client'\nexport { MaskedField } from '../globals/SiteIntegrations/components/MaskedField.js'\nexport { TestEmailButton } from '../globals/SiteIntegrations/components/TestEmailButton.js'\nexport { Analytics } from '../modules/analytics/client.js'\n/**\n * Entry point: ipal-kit/client\n *\n * Client-side ('use client') exports — React hooks, providers, and UI\n * components. Kept separate from the main entry so server bundles don't pull in\n * client-only code.\n */\nexport {\n ConsentProvider,\n CookieBanner,\n CookieButton,\n useConsent,\n useConsentContext,\n} from '../modules/consent/client.js'\nexport type { CookieBannerClassNames } from '../modules/consent/client.js'\nexport { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'\nexport type { FormNotificationTexts } from '../modules/notifications/types.js'\nexport { Turnstile } from '../modules/turnstile/client.js'\nexport { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js'\nexport type { TurnstileProps } from '../modules/turnstile/client.js'\n"],"names":["MaskedField","TestEmailButton","Analytics","ConsentProvider","CookieBanner","CookieButton","useConsent","useConsentContext","resolveFormMessage","Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,SAASA,WAAW,QAAQ,wDAAuD;AACnF,SAASC,eAAe,QAAQ,4DAA2D;AAC3F,SAASC,SAAS,QAAQ,iCAAgC;AAC1D;;;;;;CAMC,GACD,SACEC,eAAe,EACfC,YAAY,EACZC,YAAY,EACZC,UAAU,EACVC,iBAAiB,QACZ,+BAA8B;AAErC,SAASC,kBAAkB,QAAQ,iDAAgD;AAEnF,SAASC,SAAS,QAAQ,iCAAgC;AAC1D,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,iCAAgC"}
+36
View File
@@ -0,0 +1,36 @@
/**
* Provides the Turnstile site key once for the whole app, like ConsentProvider
* for cookies. The project reads the key server-side and passes it here in the
* layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form
* key wiring.
*
* // layout.tsx (server) → read key, pass to provider
* import { TurnstileProvider } from '@intecion/ipal-kit/client'
* const siteKey = await getTurnstileSiteKey() // your server helper
* <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>
*
* When siteKey is null (Turnstile not configured), widgets render nothing and
* token stays null — forms should treat "no Turnstile" as allowed in dev.
*/
export declare function TurnstileProvider({ siteKey, children, }: {
siteKey: string | null;
children: React.ReactNode;
}): import("react/jsx-runtime").JSX.Element;
/**
* Hook giving a form the Turnstile token + a widget bound to the provider's key.
* No per-form siteKey plumbing — the provider supplies it.
*
* const { token, TurnstileWidget, reset } = useTurnstile()
* // in JSX: <TurnstileWidget />
* // at submit: submitForm({ ..., turnstileToken: token })
* // after submit: reset() // clear for the next submission
*/
export declare function useTurnstile(): {
token: string | null;
TurnstileWidget: (props?: {
theme?: 'light' | 'dark' | 'auto';
}) => React.ReactNode;
reset: () => void;
/** True when Turnstile is configured (site key present). */
enabled: boolean;
};
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
import { createContext, useCallback, useContext, useState } from 'react';
import { Turnstile } from './Turnstile.js';
const TurnstileContext = /*#__PURE__*/ createContext(null);
/**
* Provides the Turnstile site key once for the whole app, like ConsentProvider
* for cookies. The project reads the key server-side and passes it here in the
* layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form
* key wiring.
*
* // layout.tsx (server) → read key, pass to provider
* import { TurnstileProvider } from '@intecion/ipal-kit/client'
* const siteKey = await getTurnstileSiteKey() // your server helper
* <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>
*
* When siteKey is null (Turnstile not configured), widgets render nothing and
* token stays null — forms should treat "no Turnstile" as allowed in dev.
*/ export function TurnstileProvider({ siteKey, children }) {
const [token, setToken] = useState(null);
return /*#__PURE__*/ _jsx(TurnstileContext.Provider, {
value: {
siteKey,
token,
setToken
},
children: children
});
}
/**
* Hook giving a form the Turnstile token + a widget bound to the provider's key.
* No per-form siteKey plumbing — the provider supplies it.
*
* const { token, TurnstileWidget, reset } = useTurnstile()
* // in JSX: <TurnstileWidget />
* // at submit: submitForm({ ..., turnstileToken: token })
* // after submit: reset() // clear for the next submission
*/ export function useTurnstile() {
const ctx = useContext(TurnstileContext);
if (!ctx) {
throw new Error('useTurnstile must be used within <TurnstileProvider>');
}
const { siteKey, token, setToken } = ctx;
const reset = useCallback(()=>setToken(null), [
setToken
]);
const TurnstileWidget = useCallback((props)=>{
if (!siteKey) return null;
return /*#__PURE__*/ _jsx(Turnstile, {
siteKey: siteKey,
onToken: setToken,
theme: props?.theme
});
}, [
siteKey,
setToken
]);
return {
token,
TurnstileWidget,
reset,
enabled: Boolean(siteKey)
};
}
//# sourceMappingURL=TurnstileProvider.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"sources":["../../../src/modules/turnstile/TurnstileProvider.tsx"],"sourcesContent":["'use client'\n\nimport { createContext, useCallback, useContext, useState } from 'react'\nimport { Turnstile } from './Turnstile.js'\n\ntype TurnstileContextValue = {\n /** Public site key from the provider (read server-side, passed once). */\n siteKey: string | null\n /** Current token (null until solved / after expiry). */\n token: string | null\n setToken: (t: string | null) => void\n}\n\nconst TurnstileContext = createContext<TurnstileContextValue | null>(null)\n\n/**\n * Provides the Turnstile site key once for the whole app, like ConsentProvider\n * for cookies. The project reads the key server-side and passes it here in the\n * layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form\n * key wiring.\n *\n * // layout.tsx (server) → read key, pass to provider\n * import { TurnstileProvider } from '@intecion/ipal-kit/client'\n * const siteKey = await getTurnstileSiteKey() // your server helper\n * <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>\n *\n * When siteKey is null (Turnstile not configured), widgets render nothing and\n * token stays null — forms should treat \"no Turnstile\" as allowed in dev.\n */\nexport function TurnstileProvider({\n siteKey,\n children,\n}: {\n siteKey: string | null\n children: React.ReactNode\n}) {\n const [token, setToken] = useState<string | null>(null)\n return (\n <TurnstileContext.Provider value={{ siteKey, token, setToken }}>\n {children}\n </TurnstileContext.Provider>\n )\n}\n\n/**\n * Hook giving a form the Turnstile token + a widget bound to the provider's key.\n * No per-form siteKey plumbing — the provider supplies it.\n *\n * const { token, TurnstileWidget, reset } = useTurnstile()\n * // in JSX: <TurnstileWidget />\n * // at submit: submitForm({ ..., turnstileToken: token })\n * // after submit: reset() // clear for the next submission\n */\nexport function useTurnstile(): {\n token: string | null\n TurnstileWidget: (props?: { theme?: 'light' | 'dark' | 'auto' }) => React.ReactNode\n reset: () => void\n /** True when Turnstile is configured (site key present). */\n enabled: boolean\n} {\n const ctx = useContext(TurnstileContext)\n if (!ctx) {\n throw new Error('useTurnstile must be used within <TurnstileProvider>')\n }\n const { siteKey, token, setToken } = ctx\n\n const reset = useCallback(() => setToken(null), [setToken])\n\n const TurnstileWidget = useCallback(\n (props?: { theme?: 'light' | 'dark' | 'auto' }) => {\n if (!siteKey) return null\n return <Turnstile siteKey={siteKey} onToken={setToken} theme={props?.theme} />\n },\n [siteKey, setToken],\n )\n\n return { token, TurnstileWidget, reset, enabled: Boolean(siteKey) }\n}\n"],"names":["createContext","useCallback","useContext","useState","Turnstile","TurnstileContext","TurnstileProvider","siteKey","children","token","setToken","Provider","value","useTurnstile","ctx","Error","reset","TurnstileWidget","props","onToken","theme","enabled","Boolean"],"mappings":"AAAA;;AAEA,SAASA,aAAa,EAAEC,WAAW,EAAEC,UAAU,EAAEC,QAAQ,QAAQ,QAAO;AACxE,SAASC,SAAS,QAAQ,iBAAgB;AAU1C,MAAMC,iCAAmBL,cAA4C;AAErE;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASM,kBAAkB,EAChCC,OAAO,EACPC,QAAQ,EAIT;IACC,MAAM,CAACC,OAAOC,SAAS,GAAGP,SAAwB;IAClD,qBACE,KAACE,iBAAiBM,QAAQ;QAACC,OAAO;YAAEL;YAASE;YAAOC;QAAS;kBAC1DF;;AAGP;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASK;IAOd,MAAMC,MAAMZ,WAAWG;IACvB,IAAI,CAACS,KAAK;QACR,MAAM,IAAIC,MAAM;IAClB;IACA,MAAM,EAAER,OAAO,EAAEE,KAAK,EAAEC,QAAQ,EAAE,GAAGI;IAErC,MAAME,QAAQf,YAAY,IAAMS,SAAS,OAAO;QAACA;KAAS;IAE1D,MAAMO,kBAAkBhB,YACtB,CAACiB;QACC,IAAI,CAACX,SAAS,OAAO;QACrB,qBAAO,KAACH;YAAUG,SAASA;YAASY,SAAST;YAAUU,OAAOF,OAAOE;;IACvE,GACA;QAACb;QAASG;KAAS;IAGrB,OAAO;QAAED;QAAOQ;QAAiBD;QAAOK,SAASC,QAAQf;IAAS;AACpE"}
+1
View File
@@ -1,2 +1,3 @@
export { Turnstile } from './Turnstile.js';
export type { TurnstileProps } from './Turnstile.js';
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
+1
View File
@@ -2,5 +2,6 @@
// Client-only exports — the Turnstile widget. Kept separate from index.ts so
// the server-only verify never leaks into a browser bundle.
export { Turnstile } from './Turnstile.js';
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
//# sourceMappingURL=client.js.map
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/turnstile/client.ts"],"sourcesContent":["'use client'\n// Client-only exports — the Turnstile widget. Kept separate from index.ts so\n// the server-only verify never leaks into a browser bundle.\nexport { Turnstile } from './Turnstile.js'\nexport type { TurnstileProps } from './Turnstile.js'\n"],"names":["Turnstile"],"mappings":"AAAA;AACA,6EAA6E;AAC7E,4DAA4D;AAC5D,SAASA,SAAS,QAAQ,iBAAgB"}
{"version":3,"sources":["../../../src/modules/turnstile/client.ts"],"sourcesContent":["'use client'\n// Client-only exports — the Turnstile widget. Kept separate from index.ts so\n// the server-only verify never leaks into a browser bundle.\nexport { Turnstile } from './Turnstile.js'\nexport type { TurnstileProps } from './Turnstile.js'\nexport { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'\n"],"names":["Turnstile","TurnstileProvider","useTurnstile"],"mappings":"AAAA;AACA,6EAA6E;AAC7E,4DAA4D;AAC5D,SAASA,SAAS,QAAQ,iBAAgB;AAE1C,SAASC,iBAAiB,EAAEC,YAAY,QAAQ,yBAAwB"}
+1
View File
@@ -1 +1,2 @@
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
export { verifyTurnstile } from './verify.js';
+1
View File
@@ -1,3 +1,4 @@
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js';
// Server-only exports. verify.ts imports 'server-only', so this must never be
// imported from a client component — use ./client for the widget instead.
export { verifyTurnstile } from './verify.js';
+1 -1
View File
@@ -1 +1 @@
{"version":3,"sources":["../../../src/modules/turnstile/index.ts"],"sourcesContent":["// Server-only exports. verify.ts imports 'server-only', so this must never be\n// imported from a client component — use ./client for the widget instead.\nexport { verifyTurnstile } from './verify.js'\n"],"names":["verifyTurnstile"],"mappings":"AAAA,8EAA8E;AAC9E,0EAA0E;AAC1E,SAASA,eAAe,QAAQ,cAAa"}
{"version":3,"sources":["../../../src/modules/turnstile/index.ts"],"sourcesContent":["export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'\n// Server-only exports. verify.ts imports 'server-only', so this must never be\n// imported from a client component — use ./client for the widget instead.\nexport { verifyTurnstile } from './verify.js'\n"],"names":["TurnstileProvider","useTurnstile","verifyTurnstile"],"mappings":"AAAA,SAASA,iBAAiB,EAAEC,YAAY,QAAQ,yBAAwB;AACxE,8EAA8E;AAC9E,0EAA0E;AAC1E,SAASC,eAAe,QAAQ,cAAa"}
+70 -1
View File
@@ -58,4 +58,73 @@ Zwraca `false` na każdy problem (brak klucza, sieć, odrzucenie) — traktuj
trafi do bundla przeglądarki.
> W formularzach zwykle nie wołasz `verifyTurnstile` wprost — robi to
> `submitForm` (patrz [forms.md](./forms.md)).
> `submitForm` (patrz [forms.md](./forms.md)).
## Uproszczone wpięcie — TurnstileProvider + useTurnstile (zalecane)
Jak CookieBanner: siteKey raz w layoutcie, formularze biorą z kontekstu. Koniec
przekazywania siteKey do każdego formularza.
### 1. Provider w layoutcie (raz, siteKey z serwera)
```tsx
// app/(frontend)/[locale]/layout.tsx (server)
import { TurnstileProvider } from '@intecion/ipal-kit/client'
import { getTurnstileSiteKey } from '@/lib/payload' // Twój helper server-side
export default async function Layout({ children }) {
const siteKey = await getTurnstileSiteKey() // z panelu (SiteIntegrations)
return (
<html>
<body>
<TurnstileProvider siteKey={siteKey}>
{children}
</TurnstileProvider>
</body>
</html>
)
}
```
### 2. Formularz — useTurnstile (zero plumbingu siteKey)
```tsx
'use client'
import { useTurnstile } from '@intecion/ipal-kit/client'
function ContactForm() {
const { token, TurnstileWidget, reset, enabled } = useTurnstile()
async function handleSubmit(data) {
const result = await submitForm({ ...data, turnstileToken: token })
if (result.ok) reset() // wyczyść token na następne wysłanie
}
return (
<form onSubmit={...}>
{/* pola formularza */}
<TurnstileWidget /> {/* widget tam, gdzie ma być */}
<button type="submit">Wyślij</button>
</form>
)
}
```
`token` → do submitForm. `TurnstileWidget` → wstaw gdzie ma być. `reset()` → po
wysłaniu. `enabled` → false gdy brak klucza (dev bez Turnstile).
### Dlaczego Turnstile NIE jest w pełni "wstaw i zapomnij" jak CookieBanner
CookieBanner jest samodzielny (renderuje się, zarządza zgodą, zero interakcji).
Turnstile z natury jest CZĘŚCIĄ formularza — zwraca token, który formularz musi
wysłać przy submit i zweryfikować server-side. Nie da się go „wstawić
gdziekolwiek" — musi być w formularzu, przy jego logice wysyłki.
Provider+hook to maksimum uproszczenia: siteKey raz (jak CookieBanner), a w
formularzu tylko `<TurnstileWidget/>` + `token`. Reszta (weryfikacja) dzieje się
w submitForm automatycznie.
### Stary sposób (nadal działa)
`<Turnstile siteKey={...} onToken={...} />` bezpośrednio — jeśli potrzebujesz
pełnej kontroli albo masz nietypowy przypadek. Provider to warstwa wygody nad tym.
+3 -3
View File
@@ -17,8 +17,8 @@ export {
useConsentContext,
} from '../modules/consent/client.js'
export type { CookieBannerClassNames } from '../modules/consent/client.js'
export { Turnstile } from '../modules/turnstile/client.js'
export type { TurnstileProps } from '../modules/turnstile/client.js'
export { resolveFormMessage } from '../modules/notifications/resolveFormMessage.js'
export type { FormNotificationTexts } from '../modules/notifications/types.js'
export { Turnstile } from '../modules/turnstile/client.js'
export { TurnstileProvider, useTurnstile } from '../modules/turnstile/client.js'
export type { TurnstileProps } from '../modules/turnstile/client.js'
@@ -0,0 +1,78 @@
'use client'
import { createContext, useCallback, useContext, useState } from 'react'
import { Turnstile } from './Turnstile.js'
type TurnstileContextValue = {
/** Public site key from the provider (read server-side, passed once). */
siteKey: string | null
/** Current token (null until solved / after expiry). */
token: string | null
setToken: (t: string | null) => void
}
const TurnstileContext = createContext<TurnstileContextValue | null>(null)
/**
* Provides the Turnstile site key once for the whole app, like ConsentProvider
* for cookies. The project reads the key server-side and passes it here in the
* layout; forms then use <TurnstileWidget /> + useTurnstile() with NO per-form
* key wiring.
*
* // layout.tsx (server) → read key, pass to provider
* import { TurnstileProvider } from '@intecion/ipal-kit/client'
* const siteKey = await getTurnstileSiteKey() // your server helper
* <TurnstileProvider siteKey={siteKey}>{children}</TurnstileProvider>
*
* When siteKey is null (Turnstile not configured), widgets render nothing and
* token stays null — forms should treat "no Turnstile" as allowed in dev.
*/
export function TurnstileProvider({
siteKey,
children,
}: {
siteKey: string | null
children: React.ReactNode
}) {
const [token, setToken] = useState<string | null>(null)
return (
<TurnstileContext.Provider value={{ siteKey, token, setToken }}>
{children}
</TurnstileContext.Provider>
)
}
/**
* Hook giving a form the Turnstile token + a widget bound to the provider's key.
* No per-form siteKey plumbing — the provider supplies it.
*
* const { token, TurnstileWidget, reset } = useTurnstile()
* // in JSX: <TurnstileWidget />
* // at submit: submitForm({ ..., turnstileToken: token })
* // after submit: reset() // clear for the next submission
*/
export function useTurnstile(): {
token: string | null
TurnstileWidget: (props?: { theme?: 'light' | 'dark' | 'auto' }) => React.ReactNode
reset: () => void
/** True when Turnstile is configured (site key present). */
enabled: boolean
} {
const ctx = useContext(TurnstileContext)
if (!ctx) {
throw new Error('useTurnstile must be used within <TurnstileProvider>')
}
const { siteKey, token, setToken } = ctx
const reset = useCallback(() => setToken(null), [setToken])
const TurnstileWidget = useCallback(
(props?: { theme?: 'light' | 'dark' | 'auto' }) => {
if (!siteKey) return null
return <Turnstile siteKey={siteKey} onToken={setToken} theme={props?.theme} />
},
[siteKey, setToken],
)
return { token, TurnstileWidget, reset, enabled: Boolean(siteKey) }
}
+1
View File
@@ -3,3 +3,4 @@
// the server-only verify never leaks into a browser bundle.
export { Turnstile } from './Turnstile.js'
export type { TurnstileProps } from './Turnstile.js'
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'
+1
View File
@@ -1,3 +1,4 @@
export { TurnstileProvider, useTurnstile } from './TurnstileProvider.js'
// Server-only exports. verify.ts imports 'server-only', so this must never be
// imported from a client component — use ./client for the widget instead.
export { verifyTurnstile } from './verify.js'