mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-07 20:47:37 +00:00
refactor(turnstile-spin): tighten auth handling, expand documentation, and add tldraw-offline matcher
This commit is contained in:
1 parent
20dc5f4489
commit
03ef5c94ea
14 files changed
+1559
-499
No files matched your search
@@ -1,52 +1,76 @@
|
||||
# Next.js (App Router)
|
||||
|
||||
For `app/`-directory Next.js projects. The widget needs to run on the client, so the page or component must be `"use client"`.
|
||||
For `app/`-directory Next.js projects. The widget needs to run on the client, so the page or component must be `"use client"`. The siteverify call lives server-side, either in a Server Action or an API route.
|
||||
|
||||
```tsx title="app/signup/page.tsx"
|
||||
"use client";
|
||||
import Script from "next/script";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
|
||||
type TurnstileWidgetId = string;
|
||||
type TurnstileApi = {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
action: string;
|
||||
callback: (token: string) => void;
|
||||
},
|
||||
) => TurnstileWidgetId;
|
||||
reset: (widgetId: TurnstileWidgetId) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
onTurnstileSuccess?: (token: string) => void;
|
||||
turnstile: TurnstileApi;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
const turnstileContainer = useRef<HTMLDivElement>(null);
|
||||
const signupWidgetId = useRef<TurnstileWidgetId | null>(null);
|
||||
const [token, setToken] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
window.onTurnstileSuccess = (t: string) => setToken(t);
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("https://YOUR_WORKER_URL/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
function renderTurnstile() {
|
||||
if (!turnstileContainer.current || signupWidgetId.current !== null) return;
|
||||
signupWidgetId.current = window.turnstile.render(turnstileContainer.current, {
|
||||
sitekey: "YOUR_SITEKEY",
|
||||
action: "signup",
|
||||
callback: setToken,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch("/api/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.ok !== true) throw new Error("Submission failed");
|
||||
// proceed
|
||||
} catch {
|
||||
// surface the error
|
||||
} finally {
|
||||
if (signupWidgetId.current !== null) {
|
||||
window.turnstile.reset(signupWidgetId.current);
|
||||
setToken("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||
strategy="afterInteractive"
|
||||
onReady={renderTurnstile}
|
||||
/>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input name="email" type="email" required />
|
||||
<div
|
||||
className="cf-turnstile"
|
||||
data-sitekey="YOUR_SITEKEY"
|
||||
data-action="turnstile-spin-v1"
|
||||
data-callback="onTurnstileSuccess"
|
||||
/>
|
||||
<div ref={turnstileContainer} />
|
||||
<button type="submit" disabled={!token}>
|
||||
Sign up
|
||||
</button>
|
||||
@@ -56,7 +80,51 @@ export default function SignupPage() {
|
||||
}
|
||||
```
|
||||
|
||||
`data-callback` expects a string referencing a global function. The `useEffect` wires `window.onTurnstileSuccess` so the widget can call back into React state.
|
||||
Explicit rendering returns the widget ID for this surface. The `finally` block resets that ID after network, JSON, validation, server, or successful same-page completion.
|
||||
|
||||
API route (canonical siteverify):
|
||||
|
||||
```ts title="app/api/signup/route.ts"
|
||||
const expectedHostnames = new Set(
|
||||
(process.env.TURNSTILE_HOSTNAMES ?? "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { token } = await req.json();
|
||||
const remoteip = req.headers.get("x-forwarded-for") ?? undefined;
|
||||
|
||||
if (expectedHostnames.size === 0) {
|
||||
return new Response("forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
secret: process.env.TURNSTILE_SECRET!,
|
||||
response: token,
|
||||
...(remoteip ? { remoteip } : {}),
|
||||
}),
|
||||
});
|
||||
const result = await r.json();
|
||||
if (
|
||||
r.ok !== true ||
|
||||
result.success !== true ||
|
||||
result.action !== "signup" ||
|
||||
!expectedHostnames.has(result.hostname)
|
||||
) {
|
||||
return new Response("forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
// existing signup logic runs here
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
```
|
||||
|
||||
`signup` is the stable action for this surface. Preserve an existing custom migration action and compare the returned action to the same value. Siteverify is mandatory for every widget mode, including pre-clearance. Set `TURNSTILE_HOSTNAMES` to the deployment-specific frontend hostnames; a production value must not include `localhost` or `127.0.0.1`.
|
||||
|
||||
## Variant: Server Action
|
||||
|
||||
@@ -64,17 +132,42 @@ If you are using Server Actions, do the siteverify call from the action itself.
|
||||
|
||||
```tsx title="app/signup/actions.ts"
|
||||
"use server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export async function submitSignup(formData: FormData) {
|
||||
const token = formData.get("cf-turnstile-response") as string;
|
||||
export type SignupState = { ok?: true; error?: string } | null;
|
||||
|
||||
const res = await fetch("https://YOUR_WORKER_URL/", {
|
||||
const expectedHostnames = new Set(
|
||||
(process.env.TURNSTILE_HOSTNAMES ?? "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
export async function submitSignup(
|
||||
_previousState: SignupState,
|
||||
formData: FormData,
|
||||
): Promise<SignupState> {
|
||||
const token = formData.get("cf-turnstile-response");
|
||||
if (typeof token !== "string") return { error: "Verification failed" };
|
||||
if (expectedHostnames.size === 0) return { error: "Verification failed" };
|
||||
const remoteip = (await headers()).get("x-forwarded-for") ?? undefined;
|
||||
|
||||
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
secret: process.env.TURNSTILE_SECRET!,
|
||||
response: token,
|
||||
...(remoteip ? { remoteip } : {}),
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
const result = await r.json();
|
||||
if (
|
||||
r.ok !== true ||
|
||||
result.success !== true ||
|
||||
result.action !== "signup" ||
|
||||
!expectedHostnames.has(result.hostname)
|
||||
) {
|
||||
return { error: "Verification failed" };
|
||||
}
|
||||
|
||||
@@ -83,28 +176,86 @@ export async function submitSignup(formData: FormData) {
|
||||
}
|
||||
```
|
||||
|
||||
```tsx title="app/signup/page.tsx"
|
||||
```tsx title="app/signup/page.tsx (server-action variant)"
|
||||
"use client";
|
||||
import { submitSignup } from "./actions";
|
||||
import Script from "next/script";
|
||||
import { useActionState, useRef, useState } from "react";
|
||||
import { submitSignup, type SignupState } from "./actions";
|
||||
|
||||
type TurnstileWidgetId = string;
|
||||
type TurnstileApi = {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
action: string;
|
||||
callback: (token: string) => void;
|
||||
},
|
||||
) => TurnstileWidgetId;
|
||||
reset: (widgetId: TurnstileWidgetId) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile: TurnstileApi;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
const turnstileContainer = useRef<HTMLDivElement>(null);
|
||||
const signupActionWidgetId = useRef<TurnstileWidgetId | null>(null);
|
||||
const [token, setToken] = useState("");
|
||||
const [state, action, pending] = useActionState(
|
||||
async (previousState: SignupState, formData: FormData) => {
|
||||
try {
|
||||
return await submitSignup(previousState, formData);
|
||||
} finally {
|
||||
if (signupActionWidgetId.current !== null) {
|
||||
window.turnstile.reset(signupActionWidgetId.current);
|
||||
setToken("");
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
function renderTurnstile() {
|
||||
if (!turnstileContainer.current || signupActionWidgetId.current !== null) return;
|
||||
signupActionWidgetId.current = window.turnstile.render(
|
||||
turnstileContainer.current,
|
||||
{
|
||||
sitekey: "YOUR_SITEKEY",
|
||||
action: "signup",
|
||||
callback: setToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitSignup}>
|
||||
<input name="email" type="email" required />
|
||||
<div
|
||||
className="cf-turnstile"
|
||||
data-sitekey="YOUR_SITEKEY"
|
||||
data-action="turnstile-spin-v1"
|
||||
<>
|
||||
<Script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||
strategy="afterInteractive"
|
||||
onReady={renderTurnstile}
|
||||
/>
|
||||
<button type="submit">Sign up</button>
|
||||
</form>
|
||||
<form action={action}>
|
||||
<input name="email" type="email" required />
|
||||
<div ref={turnstileContainer} />
|
||||
{state?.error && <p role="alert">{state.error}</p>}
|
||||
<button type="submit" disabled={!token || pending}>
|
||||
Sign up
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Server Actions can return state without navigating. This wrapper accepts `previousState` before `FormData` and resets the retained widget ID in `finally`, including validation, server, and network failures.
|
||||
|
||||
## Substitutions
|
||||
|
||||
| Placeholder | Replace with |
|
||||
| ------------------ | ------------------------------------------- |
|
||||
| `YOUR_WORKER_URL` | Deployed Worker URL from Step 5 |
|
||||
| `YOUR_SITEKEY` | Widget site key from Step 4 |
|
||||
| Placeholder | Replace with |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| `YOUR_SITEKEY` | The widget site key from Step 8 |
|
||||
| `TURNSTILE_SECRET` | Env-var name. Value is the secret captured in Step 8, kept off disk. |
|
||||
Reference in new issue
Block a user