diff --git a/.env.docker.example b/.env.docker.example index 70841edd0..9223417d4 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -35,12 +35,11 @@ THE_GAMES_DB_API_KEY="your_tgdb_api_key_here" TUNNEL_TOKEN="your_cloudflare_tunnel_token_here" # =========================================== -# RECAPTCHA (Optional) +# CLOUDFLARE TURNSTILE (Optional) # =========================================== -# Google reCAPTCHA for form protection -NEXT_PUBLIC_RECAPTCHA_SITE_KEY="your_recaptcha_site_key_here" -RECAPTCHA_SECRET_KEY="your_recaptcha_secret_key_here" -NEXT_PUBLIC_DISABLE_RECAPTCHA="false" +# Human verification +NEXT_PUBLIC_TURNSTILE_SITE_KEY="your_turnstile_site_key_here" +TURNSTILE_SECRET_KEY="your_turnstile_secret_key_here" # =========================================== # EMAIL SERVICES (Optional) diff --git a/.env.example b/.env.example index 5efdaeff9..592c1934d 100644 --- a/.env.example +++ b/.env.example @@ -16,10 +16,9 @@ NEXT_PUBLIC_THE_GAMES_DB_API_KEY="The-Games-DB-Public-API-KEY" NEXT_PUBLIC_IGDB_CLIENT_ID="IGDB-Client-ID" IGDB_CLIENT_KEY="IGDB-Client-Secret" -# reCAPTCHA v3 -NEXT_PUBLIC_RECAPTCHA_SITE_KEY=site-key-here -RECAPTCHA_SECRET_KEY=secret-key-here -NEXT_PUBLIC_DISABLE_RECAPTCHA=false +# Cloudflare Turnstile +NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4XXXXXXXXXXXXXXXXXXXXX +TURNSTILE_SECRET_KEY=0x4XXXXXXXXXXXXXXXXXXXXXXXX-XXXXXXX # Email Providers (For the future) EMAIL_ENABLED=false diff --git a/.env.test.example b/.env.test.example index ff39873e6..8f23094b8 100644 --- a/.env.test.example +++ b/.env.test.example @@ -6,10 +6,9 @@ DATABASE_DIRECT_URL="postgres://postgres:pooler.supabase.com:5432/postgres" NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -# reCAPTCHA V3 -NEXT_PUBLIC_RECAPTCHA_SITE_KEY= -RECAPTCHA_SECRET_KEY= -NEXT_PUBLIC_DISABLE_RECAPTCHA=false +# Cloudflare Turnstile +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= # Email (For the future) EMAIL_ENABLED=false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..e0c538a40 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,123 @@ +# Project Guide + +This file is the source of working guidance for AI coding agents in this repository. + +## Commands + +- Use `pnpm`, not `npm` or `npx`. +- Common checks: + - `pnpm lint` + - `pnpm types` + - `pnpm test` + - `pnpm check` +- Prisma: + - `pnpm db:generate` generates Prisma Client and TypedSQL. + - `pnpm prisma validate` validates the schema. + - Database-backed Prisma commands must use the project scripts that wrap `scripts/db-cmd.sh` when available. +- Do not run `pnpm dev`, `pnpm build`, `pnpm start`, `pnpm run deploy`, migrations, seeds, or data scripts unless the user explicitly asks. +- Never deploy, commit, or push unless the user explicitly asks. +- Use the current git user as commit author; never add Codex/AI authorship or AI-themed branch names. + +## Domain Rules + +- A Listing is a handheld compatibility report: game plus handheld device plus emulator. +- A PC Listing is a PC compatibility report: game plus PC hardware plus emulator. +- User-facing UI should say "Compatibility Report", "Handheld Report", "PC Report", or "PC Compatibility Report", not "listing". +- Listings and PC Listings must stay behaviorally aligned for voting, comments, moderation, trust effects, notifications, and approval flows. +- Shared cross-listing behavior belongs in utilities instead of duplicated implementations. + +## Architecture + +- Feature folders should follow the project-structure guidance from Bulletproof React: + https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md +- Within `src/features/*`, prefer scoped subdirectories such as `components`, `hooks`, `utils`, `server`, and `shared` instead of flat feature folders. +- Routers in `src/server/api/routers/` are thin orchestration layers. They handle auth context, schema-validated input, repository/service calls, and response formatting. +- Do not put raw Prisma queries or business logic in routers. +- Define Zod schemas in `src/schemas/*`; do not define inline schemas in router `.input(...)` calls. +- All database access belongs in repository classes under `src/server/repositories/` extending `BaseRepository`. +- Repositories should use project error helpers and consistent database operation handling. +- Multi-step business logic, external API orchestration, and complex calculations belong in services under `src/server/services/`. +- Use `AppError` and `ResourceError` helpers instead of raw `Error`, raw strings, or one-off `TRPCError` usage. +- Use specialized procedures such as `protectedProcedure`, `adminProcedure`, and `permissionProcedure(...)` instead of ad hoc permission checks. + +## Database And Prisma + +- Treat database changes as high risk. +- Do not run migrations, seeds, `db:push`, or data scripts without explicit user approval. +- Prisma migration commands may only target the local database configured by `.env.local`, never a hosted or Supabase database, unless the user explicitly instructs otherwise. +- Do not create or edit migration SQL manually. Use Prisma migration commands. +- Do not edit an existing migration after it has been created. Create a new migration when a schema change is required. +- Prefer `migrate deploy` for applying migrations. Never use `db:push` outside disposable local development. +- Prisma Client is generated to `prisma/generated/client`; app imports use `@orm`, `@orm/client`, and `@orm/sql`. +- Use `pnpm prisma ...` or project `pnpm db:*` scripts, not `npx prisma`. + +## TypeScript And Code Quality + +- Do not use `any` or `z.any()`. Use concrete types, discriminated unions, or `unknown` with narrowing. +- Do not use `@ts-ignore`, `@ts-expect-error`, or `eslint-disable` comments. +- Do not use casts to hide type problems. Fix the underlying type issue. +- Handle null and undefined explicitly. +- Use generated Prisma types where appropriate. +- Do not add unused functions, exports, or speculative helpers. +- Remove dead code when refactoring. +- Prefer function declarations for top-level functions/components. +- Component props interfaces should be named `Props`. +- Do not destructure component props in function parameters; use `props.foo`. +- Keep `useEffect` dependencies correct. +- Comments should be rare, factual, and explain only non-obvious external constraints or business invariants. + +## Enums + +- Import enum values from `@orm`; do not use string literals for enum values in application code. +- This applies to UI state, filters, schemas, comparisons, routers, services, and repositories. +- Prefer `z.nativeEnum(SomeEnum)` over hard-coded `z.enum([...])` when a Prisma enum exists. +- Tests may use string literals only when mocking requires it. + +## UI + +- Before adding custom UI markup, check existing components in `src/components/ui/`. +- Prefer extending shared UI components over duplicating badge, button, modal, table, loading, card, or form markup. +- Use `Button`, `Badge`, `Card`, table utilities, form components, `LoadingSpinner`, and dialog components from the shared UI library where applicable. +- Never use `window.confirm()`. Use `useConfirmDialog` from `@/components/ui`. +- Keep admin pages consistent: table controls, search/filtering, pagination, statistics, and bulk actions should follow existing admin patterns. + +## Filters + +- Controllers own filter behavior: interactions, analytics, collapsed badges, active summaries, and calls into presentational content. +- Content components should only render fields and call handlers passed by controllers. +- URL/state hooks own URL sync and local UI state; they must not emit per-filter analytics. +- Filter analytics should be emitted once from controllers, using `filterAnalytics` and `selectedLabels`. +- Call `onChange` before emitting analytics. +- Use shared filter UI pieces: `FilterSidebarShell`, `CollapsedBadges`, `ActiveFiltersSummary`, and `MobileFilterSheet`. +- Use shared option mappers from `src/utils/options.ts`. + +## Async Multi-Selects + +- Use `src/components/ui/form/async-multi-select/AsyncMultiSelect.tsx` as the base. +- Entity wrappers such as CPU, GPU, Device, and SoC selects should stay thin: call TRPC, map to `Option[]`, manage pagination state, and pass data to the base component. +- Selected chips must persist by deriving them from `options` plus `selectedByIds`. + +## Security + +- For write operations, never trust a user ID supplied by input when ownership matters. Use `ctx.session.user.id`. +- Pass `requestingUserRole` when admin override behavior is supported. +- Reads may accept user IDs for filtering, but writes must validate ownership or permission. +- Use transactions for multi-step writes that must stay consistent. +- Validate input at API boundaries with Zod schemas. + +## Verification + +- Run the smallest relevant checks first, then broader checks when risk warrants it. +- Before claiming a fix is complete, verify the actual failing behavior when possible. +- For API changes, exercise the endpoint or generated contract. +- For UI changes, run the relevant UI/test path. +- For Prisma changes, run schema validation and generation. Only run migration commands with explicit approval. +- If a check cannot be run, report the exact blocker. + + + +# Next.js: ALWAYS read docs before coding + +Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth. + + diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index a2d2f52ae..34f096c90 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -36,7 +36,7 @@ CLERK_SECRET_KEY=... NEXT_PUBLIC_APP_URL=http://localhost:3000 ``` -External provider keys such as `RAWG_API_KEY`, `THE_GAMES_DB_API_KEY`, and reCAPTCHA keys are only needed for the features that call those services. +External provider keys such as `RAWG_API_KEY`, `THE_GAMES_DB_API_KEY`, and Cloudflare Turnstile keys are only needed for the features that call those services. ## Troubleshooting diff --git a/docs/MOBILE_API.md b/docs/MOBILE_API.md index 6e8591f48..57e751106 100644 --- a/docs/MOBILE_API.md +++ b/docs/MOBILE_API.md @@ -1,6 +1,6 @@ # EmuReady Mobile API (tRPC) -*Auto-generated on: 2026-02-17T20:21:36.450Z* +*Auto-generated on: 2026-05-25T13:13:26.417Z* ## Summary - **Total Endpoints**: 112 @@ -710,7 +710,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 27. **get** - **Method**: GET - **Path**: `/preferences.get` -- **Description**: Get user preferences +- **Description**: get - preferences - **Tags**: preferences - **Authentication**: Bearer token required @@ -718,7 +718,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 28. **update** - **Method**: POST - **Path**: `/preferences.update` -- **Description**: Update user preferences +- **Description**: update - preferences - **Tags**: preferences - **Request Body**: JSON object required - **Content-Type**: application/json @@ -727,7 +727,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 29. **addDevice** - **Method**: POST - **Path**: `/preferences.addDevice` -- **Description**: Add device preference +- **Description**: addDevice - preferences - **Tags**: preferences - **Request Body**: JSON object required - **Content-Type**: application/json @@ -736,7 +736,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 30. **removeDevice** - **Method**: POST - **Path**: `/preferences.removeDevice` -- **Description**: Remove device preference +- **Description**: removeDevice - preferences - **Tags**: preferences - **Request Body**: JSON object required - **Content-Type**: application/json @@ -745,7 +745,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 31. **bulkUpdateDevices** - **Method**: POST - **Path**: `/preferences.bulkUpdateDevices` -- **Description**: Bulk update device preferences +- **Description**: bulkUpdateDevices - preferences - **Tags**: preferences - **Request Body**: JSON object required - **Content-Type**: application/json @@ -754,7 +754,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 32. **bulkUpdateSocs** - **Method**: POST - **Path**: `/preferences.bulkUpdateSocs` -- **Description**: Bulk update SOC preferences +- **Description**: bulkUpdateSocs - preferences - **Tags**: preferences - **Request Body**: JSON object required - **Content-Type**: application/json @@ -763,7 +763,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 33. **currentProfile** - **Method**: GET - **Path**: `/preferences.currentProfile` -- **Description**: Get current user's profile +- **Description**: currentProfile - preferences - **Tags**: preferences - **Authentication**: Bearer token required @@ -771,7 +771,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 34. **profile** - **Method**: GET - **Path**: `/preferences.profile` -- **Description**: Get user profile by ID +- **Description**: profile - preferences - **Tags**: preferences - **Authentication**: Bearer token required @@ -779,7 +779,7 @@ Protected endpoints require Bearer token authentication using Clerk JWT. #### 35. **updateProfile** - **Method**: POST - **Path**: `/preferences.updateProfile` -- **Description**: Update profile +- **Description**: updateProfile - preferences - **Tags**: preferences - **Request Body**: JSON object required - **Content-Type**: application/json diff --git a/eslint.config.mjs b/eslint.config.mjs index 235e19c2f..64b1f9ba1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,24 @@ +import { existsSync, readdirSync } from 'node:fs' + import typescriptEslint from '@typescript-eslint/eslint-plugin' import typescriptParser from '@typescript-eslint/parser' import eslintConfigPrettier from 'eslint-config-prettier' import nextCoreWebVitals from 'eslint-config-next/core-web-vitals' import nextTypeScript from 'eslint-config-next/typescript' +const featureNames = existsSync('./src/features') + ? readdirSync('./src/features', { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : [] + +const featureBoundaryZones = featureNames.map((featureName) => ({ + target: `./src/features/${featureName}`, + from: './src/features', + except: [`./${featureName}`], + message: 'Features must not import from other features. Compose features at the route layer.', +})) + const eslintConfig = [ { ignores: [ @@ -137,6 +152,7 @@ const eslintConfig = [ tsx: 'never', }, ], + 'import/no-restricted-paths': ['error', { zones: featureBoundaryZones }], }, }, // UI component import cycle override { diff --git a/next.config.ts b/next.config.ts index 9dff5afa0..c043f273e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,18 +5,6 @@ import type { Configuration as WebpackConfiguration } from 'webpack' const isVercelBuild = process.env.VERCEL === '1' -const recaptchaScriptSources = [ - 'https://www.google.com/recaptcha/', - 'https://www.gstatic.com/recaptcha/', -] - -const recaptchaConnectSources = ['https://www.google.com/recaptcha/'] - -const recaptchaFrameSources = [ - 'https://www.google.com/recaptcha/', - 'https://recaptcha.google.com/recaptcha/', -] - const contentSecurityPolicyDirectives = [ { name: 'default-src', @@ -40,7 +28,6 @@ const contentSecurityPolicyDirectives = [ 'https://storage.ko-fi.com', 'https://ko-fi.com', 'https://unpkg.com', - ...recaptchaScriptSources, ], }, { @@ -117,7 +104,6 @@ const contentSecurityPolicyDirectives = [ 'https://*.r2.cloudflarestorage.com', 'https://cdn.emuready.com', 'https://retrocatalog.com', - ...recaptchaConnectSources, ], }, { @@ -132,7 +118,6 @@ const contentSecurityPolicyDirectives = [ 'https://vercel.live', 'https://*.vercel.live', 'https://ko-fi.com', - ...recaptchaFrameSources, ], }, { diff --git a/package.json b/package.json index 3005b00cb..c6b2216e6 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-error-boundary": "^6.0.0", - "react-google-recaptcha-v3": "^1.11.0", "react-hook-form": "^7.56.4", "react-syntax-highlighter": "^15.6.6", "remeda": "^2.22.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f77ee367..079b3450a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,9 +173,6 @@ importers: react-error-boundary: specifier: ^6.0.0 version: 6.0.0(react@19.2.6) - react-google-recaptcha-v3: - specifier: ^1.11.0 - version: 1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-hook-form: specifier: ^7.56.4 version: 7.58.1(react@19.2.6) @@ -5518,9 +5515,6 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.12.21: resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} engines: {node: '>=16.9.0'} @@ -6733,12 +6727,6 @@ packages: peerDependencies: react: '>=16.13.1' - react-google-recaptcha-v3@1.11.0: - resolution: {integrity: sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==} - peerDependencies: - react: ^16.3 || ^17.0 || ^18.0 || ^19.0 - react-dom: ^17.0 || ^18.0 || ^19.0 - react-hook-form@7.58.1: resolution: {integrity: sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==} engines: {node: '>=18.0.0'} @@ -14195,10 +14183,6 @@ snapshots: highlightjs-vue@1.0.0: {} - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - hono@4.12.21: {} hookable@5.5.3: {} @@ -15551,12 +15535,6 @@ snapshots: '@babel/runtime': 7.27.6 react: 19.2.6 - react-google-recaptcha-v3@1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-hook-form@7.58.1(react@19.2.6): dependencies: react: 19.2.6 diff --git a/public/api-docs/mobile-openapi.json b/public/api-docs/mobile-openapi.json index c1e3d9356..397c14f31 100644 --- a/public/api-docs/mobile-openapi.json +++ b/public/api-docs/mobile-openapi.json @@ -481,9 +481,16 @@ "not": {} }, { - "type": "object", - "properties": {}, - "additionalProperties": false + "anyOf": [ + { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + { + "type": "null" + } + ] } ] }, @@ -714,162 +721,53 @@ "type": "number" }, "notes": { - "type": "string" + "anyOf": [ + { + "type": "string", + "maxLength": 5000 + }, + { + "type": "null" + } + ] }, "customFieldValues": { - "type": "array", - "items": { - "anyOf": [ - { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "TEXT" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "customFieldDefinitionId": { + "type": "string", + "format": "uuid" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "TEXTAREA" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { + "value": { + "anyOf": [ + { "type": "string" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "URL" }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "string", - "format": "uri" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "BOOLEAN" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" + { + "type": "number" }, - "value": { + { "type": "boolean" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "SELECT" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "RANGE" + { + "type": "null" }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" + { + "type": "array", + "items": { + "$ref": "#/definitions/CreateListingSchema/properties/customFieldValues/anyOf/0/items/properties/value" + } }, - "value": { - "type": "number" + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/CreateListingSchema/properties/customFieldValues/anyOf/0/items/properties/value" + } } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false - } - ] - }, - { - "type": "object", - "properties": { - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": [ - "string", - "number", - "boolean", - "null" ] } }, @@ -879,8 +777,11 @@ ], "additionalProperties": false } - ] - } + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1065,17 +966,36 @@ "format": "uuid" }, "value": { - "type": [ - "string", - "number", - "boolean", - "null" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/UpdateListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/UpdateListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + } + } ] } }, "required": [ - "customFieldDefinitionId", - "value" + "customFieldDefinitionId" ], "additionalProperties": false } @@ -1441,7 +1361,8 @@ "type": "number" }, "memorySize": { - "type": "number", + "type": "integer", + "exclusiveMinimum": 0, "minimum": 1, "maximum": 256 }, @@ -1460,179 +1381,57 @@ "minLength": 1 }, "notes": { - "type": "string" + "type": "string", + "maxLength": 5000 }, "customFieldValues": { "type": "array", "items": { - "anyOf": [ - { + "type": "object", + "properties": { + "customFieldDefinitionId": { + "type": "string", + "format": "uuid" + }, + "value": { "anyOf": [ { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "TEXT" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false + "type": "string" }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "TEXTAREA" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false + "type": "number" }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "URL" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "string", - "format": "uri" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false + "type": "boolean" }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "BOOLEAN" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "boolean" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false + "type": "null" }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "SELECT" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "string" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false + "type": "array", + "items": { + "$ref": "#/definitions/CreatePcListingSchema/properties/customFieldValues/items/properties/value" + } }, { "type": "object", - "properties": { - "type": { - "type": "string", - "const": "RANGE" - }, - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": "number" - } - }, - "required": [ - "type", - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false + "additionalProperties": { + "$ref": "#/definitions/CreatePcListingSchema/properties/customFieldValues/items/properties/value" + } } ] - }, - { - "type": "object", - "properties": { - "customFieldDefinitionId": { - "type": "string", - "format": "uuid" - }, - "value": { - "type": [ - "string", - "number", - "boolean", - "null" - ] - } - }, - "required": [ - "customFieldDefinitionId", - "value" - ], - "additionalProperties": false } - ] + }, + "required": [ + "customFieldDefinitionId" + ], + "additionalProperties": false } } }, "required": [ "gameId", "cpuId", - "gpuId", "emulatorId", "performanceId", "memorySize", @@ -1838,17 +1637,36 @@ "format": "uuid" }, "value": { - "type": [ - "string", - "number", - "boolean", - "null" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/UpdatePcListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/UpdatePcListingSchema/properties/customFieldValues/items/anyOf/1/properties/value" + } + } ] } }, "required": [ - "customFieldDefinitionId", - "value" + "customFieldDefinitionId" ], "additionalProperties": false } @@ -11349,7 +11167,6 @@ "example": { "gameId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "cpuId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "gpuId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "emulatorId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "performanceId": 1, "memorySize": 1, @@ -12496,8 +12313,7 @@ }, "/preferences.get": { "get": { - "summary": "Get user preferences", - "description": "Get user preferences", + "summary": "get - preferences", "tags": [ "preferences" ], @@ -12628,8 +12444,7 @@ }, "/preferences.update": { "post": { - "summary": "Update user preferences", - "description": "Update user preferences", + "summary": "update - preferences", "tags": [ "preferences" ], @@ -12772,8 +12587,7 @@ }, "/preferences.addDevice": { "post": { - "summary": "Add device preference", - "description": "Add device preference", + "summary": "addDevice - preferences", "tags": [ "preferences" ], @@ -12918,8 +12732,7 @@ }, "/preferences.removeDevice": { "post": { - "summary": "Remove device preference", - "description": "Remove device preference", + "summary": "removeDevice - preferences", "tags": [ "preferences" ], @@ -13064,8 +12877,7 @@ }, "/preferences.bulkUpdateDevices": { "post": { - "summary": "Bulk update device preferences", - "description": "Bulk update device preferences", + "summary": "bulkUpdateDevices - preferences", "tags": [ "preferences" ], @@ -13213,8 +13025,7 @@ }, "/preferences.bulkUpdateSocs": { "post": { - "summary": "Bulk update SOC preferences", - "description": "Bulk update SOC preferences", + "summary": "bulkUpdateSocs - preferences", "tags": [ "preferences" ], @@ -13362,8 +13173,7 @@ }, "/preferences.currentProfile": { "get": { - "summary": "Get current user's profile", - "description": "Get current user's profile", + "summary": "currentProfile - preferences", "tags": [ "preferences" ], @@ -13494,8 +13304,7 @@ }, "/preferences.profile": { "get": { - "summary": "Get user profile by ID", - "description": "Get user profile by ID", + "summary": "profile - preferences", "tags": [ "preferences" ], @@ -13637,8 +13446,7 @@ }, "/preferences.updateProfile": { "post": { - "summary": "Update profile", - "description": "Update profile", + "summary": "updateProfile - preferences", "tags": [ "preferences" ], diff --git a/src/app/listings/[id]/components/CommentForm.tsx b/src/app/listings/[id]/components/CommentForm.tsx index 4bb2653eb..22ee9dadf 100644 --- a/src/app/listings/[id]/components/CommentForm.tsx +++ b/src/app/listings/[id]/components/CommentForm.tsx @@ -3,7 +3,6 @@ import { GenericCommentForm, type CommentFormConfig } from '@/components/comments' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForComment } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' interface Props { @@ -20,8 +19,6 @@ interface Props { } function CommentForm(props: Props) { - const { executeForComment, isCaptchaEnabled } = useRecaptchaForComment() - const createComment = api.listings.createComment.useMutation({ onSuccess: (data) => { if (data?.id) { @@ -63,7 +60,6 @@ function CommentForm(props: Props) { reply: 'Write your reply...', }, maxLength: 1000, - enableRecaptcha: isCaptchaEnabled, showSignInPrompt: true, buttonStyle: 'default', } @@ -71,13 +67,13 @@ function CommentForm(props: Props) { const handleSubmit = async (data: { content: string parentId?: string - recaptchaToken?: string | null + humanVerificationToken?: string }) => { await createComment.mutateAsync({ listingId: props.listingId, content: data.content, parentId: data.parentId, - ...(data.recaptchaToken && { recaptchaToken: data.recaptchaToken }), + humanVerificationToken: data.humanVerificationToken, } satisfies RouterInput['listings']['createComment']) } @@ -105,7 +101,6 @@ function CommentForm(props: Props) { onUpdate={handleUpdate} onSuccess={props.onCommentSuccess} onCancel={props.onCancelEdit} - getRecaptchaToken={isCaptchaEnabled ? executeForComment : undefined} isCreating={createComment.isPending} isUpdating={editComment.isPending} /> diff --git a/src/app/listings/[id]/components/CommentThread.tsx b/src/app/listings/[id]/components/CommentThread.tsx index 08cd630a8..1a01675ba 100644 --- a/src/app/listings/[id]/components/CommentThread.tsx +++ b/src/app/listings/[id]/components/CommentThread.tsx @@ -11,7 +11,6 @@ import { import useVerifiedDeveloper from '@/hooks/useVerifiedDeveloper' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForComment } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -28,7 +27,6 @@ interface Props { function CommentThread(props: Props) { const { user } = useUser() const [sortBy, setSortBy] = useState(props.initialSortBy ?? 'newest') - const { executeForComment, isCaptchaEnabled } = useRecaptchaForComment() const listingsQuery = api.listings.getSortedComments.useQuery( { listingId: props.listingId, sortBy }, @@ -120,7 +118,6 @@ function CommentThread(props: Props) { entityIdField: 'listingId', entityType: 'listing', enableVoting: true, - enableRecaptcha: true, enableAnalytics: true, sortOptions: [ { value: 'newest', label: 'Newest' }, @@ -141,7 +138,6 @@ function CommentThread(props: Props) { reply: 'Write your reply...', }, maxLength: 1000, - enableRecaptcha: isCaptchaEnabled, showSignInPrompt: true, buttonStyle: 'default', } @@ -162,13 +158,13 @@ function CommentThread(props: Props) { const handleCreateComment = async (data: { content: string parentId?: string - recaptchaToken?: string | null + humanVerificationToken?: string }) => { const mutation = createComment.mutateAsync({ listingId: props.listingId, content: data.content, parentId: data.parentId, - ...(data.recaptchaToken && { recaptchaToken: data.recaptchaToken }), + humanVerificationToken: data.humanVerificationToken, } satisfies RouterInput['listings']['createComment']) // Track analytics for replies @@ -234,7 +230,6 @@ function CommentThread(props: Props) { onUpdate={handleEditComment} onSuccess={onSuccess} onCancel={onCancel} - getRecaptchaToken={isCaptchaEnabled ? executeForComment : undefined} isCreating={createComment.isPending} isUpdating={editComment.isPending} /> diff --git a/src/app/listings/[id]/components/VoteButtons.tsx b/src/app/listings/[id]/components/VoteButtons.tsx index 331281403..14aa19b64 100644 --- a/src/app/listings/[id]/components/VoteButtons.tsx +++ b/src/app/listings/[id]/components/VoteButtons.tsx @@ -2,7 +2,6 @@ import { VoteButtons as SharedVoteButtons } from '@/components/ui' import { api } from '@/lib/api' -import { useRecaptchaForVote } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' import VotingHelpModal from './VotingHelpModal' @@ -19,8 +18,6 @@ interface Props { } export function VoteButtons(props: Props) { - const { executeForVote, isCaptchaEnabled } = useRecaptchaForVote() - const voteMutation = api.listings.vote.useMutation({ onSuccess: () => { props.onVoteSuccess?.() @@ -28,14 +25,9 @@ export function VoteButtons(props: Props) { }) const handleVote = async (value: boolean) => { - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (isCaptchaEnabled) recaptchaToken = await executeForVote() - voteMutation.mutate({ listingId: props.listingId, value, - ...(recaptchaToken && { recaptchaToken }), } satisfies RouterInput['listings']['vote']) } diff --git a/src/app/listings/components/shared/details/utils/logVoteError.test.ts b/src/app/listings/components/shared/details/utils/logVoteError.test.ts index 8722b1544..6c1329967 100644 --- a/src/app/listings/components/shared/details/utils/logVoteError.test.ts +++ b/src/app/listings/components/shared/details/utils/logVoteError.test.ts @@ -58,7 +58,7 @@ describe('logPcVoteError', () => { const PC_LISTING_ID = '00000000-0000-4000-a000-000000000011' it('passes pcListingId as extra context to logger.error', () => { - const error = new Error('CAPTCHA failed') + const error = new Error('Vote failed') logPcVoteError({ error, pcListingId: PC_LISTING_ID }) diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index baded4f21..48f5efb75 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -18,9 +18,9 @@ import '@/shared/emulator-config/eden' import '@/shared/emulator-config/azahar' import '@/shared/emulator-config/gamenative' import { Button, LoadingSpinner } from '@/components/ui' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForCreateListing } from '@/lib/captcha/hooks' import { MarkdownEditor } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -56,7 +56,7 @@ function AddListingPage() { const router = useRouter() const searchParams = useSearchParams() const utils = api.useUtils() - const { executeForCreateListing, isCaptchaEnabled } = useRecaptchaForCreateListing() + const submitWithHumanVerification = useSubmitWithHumanVerification() const gameIdFromUrl = searchParams.get('gameId') @@ -409,9 +409,6 @@ function AddListingPage() { toast.success('Handheld Report successfully submitted for review!') router.push('/listings') }, - onError: (error) => { - toast.error(`Error creating Handheld Report: ${getErrorMessage(error)}`) - }, }) useEffect(() => { @@ -424,19 +421,16 @@ function AddListingPage() { return toast.error('You must be signed in to create a Compatibility Report.') } - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (isCaptchaEnabled) { - recaptchaToken = await executeForCreateListing() - if (!recaptchaToken) { - return toast.error('CAPTCHA verification could not start. Please refresh and try again.') - } + try { + await submitWithHumanVerification((humanVerificationToken) => + createListingMutation.mutateAsync({ + ...data, + humanVerificationToken, + }), + ) + } catch (error) { + toast.error(`Error creating Handheld Report: ${getErrorMessage(error)}`) } - - createListingMutation.mutate({ - ...data, - ...(recaptchaToken && { recaptchaToken }), - }) } return ( diff --git a/src/app/pc-listings/[id]/components/PcCommentForm.tsx b/src/app/pc-listings/[id]/components/PcCommentForm.tsx index 7f1277075..2fc9c717e 100644 --- a/src/app/pc-listings/[id]/components/PcCommentForm.tsx +++ b/src/app/pc-listings/[id]/components/PcCommentForm.tsx @@ -25,16 +25,20 @@ function PcCommentForm(props: Props) { reply: 'Write your reply...', }, maxLength: 2000, - enableRecaptcha: false, showSignInPrompt: false, buttonStyle: 'compact', } - const handleSubmit = async (data: { content: string; parentId?: string }) => { + const handleSubmit = async (data: { + content: string + parentId?: string + humanVerificationToken?: string + }) => { await createComment.mutateAsync({ pcListingId: props.pcListingId, content: data.content, parentId: data.parentId, + humanVerificationToken: data.humanVerificationToken, }) } diff --git a/src/app/pc-listings/[id]/components/PcCommentThread.tsx b/src/app/pc-listings/[id]/components/PcCommentThread.tsx index aac881288..c1f80821f 100644 --- a/src/app/pc-listings/[id]/components/PcCommentThread.tsx +++ b/src/app/pc-listings/[id]/components/PcCommentThread.tsx @@ -104,7 +104,6 @@ function PcCommentThread(props: Props) { reply: 'Write your reply...', }, maxLength: 2000, - enableRecaptcha: false, showSignInPrompt: false, buttonStyle: 'compact', } @@ -113,11 +112,16 @@ function PcCommentThread(props: Props) { await deleteComment.mutateAsync({ commentId }) } - const handleCreateComment = async (data: { content: string; parentId?: string }) => { + const handleCreateComment = async (data: { + content: string + parentId?: string + humanVerificationToken?: string + }) => { await createComment.mutateAsync({ pcListingId: props.pcListingId, content: data.content, parentId: data.parentId, + humanVerificationToken: data.humanVerificationToken, }) } diff --git a/src/app/pc-listings/[id]/components/PcVoteButtons.tsx b/src/app/pc-listings/[id]/components/PcVoteButtons.tsx index d193a5cb6..9b89fb900 100644 --- a/src/app/pc-listings/[id]/components/PcVoteButtons.tsx +++ b/src/app/pc-listings/[id]/components/PcVoteButtons.tsx @@ -3,7 +3,6 @@ import VotingHelpModal from '@/app/listings/[id]/components/VotingHelpModal' import { VoteButtons } from '@/components/ui' import { api } from '@/lib/api' -import { useRecaptchaForVote } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' interface Props { @@ -20,8 +19,6 @@ interface Props { } function PcVoteButtons(props: Props) { - const { executeForVote, isCaptchaEnabled } = useRecaptchaForVote() - const voteMutation = api.pcListings.vote.useMutation({ onSuccess: () => { props.onVoteSuccess?.() @@ -29,16 +26,9 @@ function PcVoteButtons(props: Props) { }) const handleVote = async (value: boolean) => { - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (isCaptchaEnabled) { - recaptchaToken = await executeForVote() - } - voteMutation.mutate({ pcListingId: props.pcListingId, value, - ...(recaptchaToken && { recaptchaToken }), } satisfies RouterInput['pcListings']['vote']) } diff --git a/src/app/pc-listings/new/NewPcListingPage.tsx b/src/app/pc-listings/new/NewPcListingPage.tsx index e79c13518..55772811d 100644 --- a/src/app/pc-listings/new/NewPcListingPage.tsx +++ b/src/app/pc-listings/new/NewPcListingPage.tsx @@ -22,9 +22,9 @@ import { } from '@/app/listings/hooks' import { Autocomplete, Button, Input, LoadingSpinner, SelectInput } from '@/components/ui' import { PC_OS_OPTIONS } from '@/data/pc-os' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForCreateListing } from '@/lib/captcha/hooks' import { MarkdownEditor } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { type RouterInput, type RouterOutput } from '@/types/trpc' @@ -46,6 +46,7 @@ function AddPcListingPage() { const router = useRouter() const searchParams = useSearchParams() const currentUserQuery = api.users.me.useQuery() + const submitWithHumanVerification = useSubmitWithHumanVerification() const gameIdFromUrl = searchParams.get('gameId') @@ -64,7 +65,6 @@ function AddPcListingPage() { const createPcListing = api.pcListings.create.useMutation() const performanceScalesQuery = api.performanceScales.get.useQuery() const presetsQuery = api.pcListings.presets.get.useQuery({}) - const { executeForCreateListing, isCaptchaEnabled } = useRecaptchaForCreateListing() const { handleKeyDown } = useFormKeyDown() const { gameSearchTerm, setGameSearchTerm, loadGameItems } = useGameLoader() @@ -194,15 +194,12 @@ function AddPcListingPage() { return toast.error('You must be signed in to create a Compatibility Report.') } try { - const recaptchaToken = isCaptchaEnabled ? await executeForCreateListing() : null - if (isCaptchaEnabled && !recaptchaToken) { - return toast.error('CAPTCHA verification could not start. Please refresh and try again.') - } - - const result = await createPcListing.mutateAsync({ - ...data, - ...(recaptchaToken && { recaptchaToken }), - }) + const result = await submitWithHumanVerification((humanVerificationToken) => + createPcListing.mutateAsync({ + ...data, + humanVerificationToken, + }), + ) analytics.listing.created({ listingId: result.id, @@ -230,9 +227,8 @@ function AddPcListingPage() { }, [ currentUserQuery.data?.id, - executeForCreateListing, - isCaptchaEnabled, createPcListing, + submitWithHumanVerification, selectedGame?.system?.id, parsedCustomFields.length, router, diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 342ec70a1..e0d99140d 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,35 +1,18 @@ 'use client' -import { type PropsWithChildren, type ReactNode } from 'react' -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' +import { type PropsWithChildren } from 'react' +import { HumanVerificationProvider } from '@/features/human-verification/client' import { TRPCProvider } from '@/lib/api' -import { RECAPTCHA_CONFIG, isCaptchaClientEnabled } from '@/lib/captcha/config' import ThemeProvider from './ThemeProvider' import { ConfirmDialogProvider } from './ui' function Providers(props: PropsWithChildren) { - const recaptchaWrapper = (children: ReactNode) => { - return !isCaptchaClientEnabled() ? ( - <>{children} - ) : ( - - {children} - - ) - } - return ( - {recaptchaWrapper(props.children)} + + {props.children} + ) diff --git a/src/components/comments/GenericCommentForm.tsx b/src/components/comments/GenericCommentForm.tsx index cad70589a..337519ff8 100644 --- a/src/components/comments/GenericCommentForm.tsx +++ b/src/components/comments/GenericCommentForm.tsx @@ -4,6 +4,7 @@ import { useUser, SignInButton } from '@clerk/nextjs' import { Send, X } from 'lucide-react' import { useState, type FormEvent } from 'react' import { Button } from '@/components/ui' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { MarkdownEditor } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -16,7 +17,6 @@ export interface CommentFormConfig { reply?: string } maxLength?: number - enableRecaptcha?: boolean showSignInPrompt?: boolean buttonStyle?: 'default' | 'compact' } @@ -30,15 +30,12 @@ interface GenericCommentFormProps { onSubmit: (data: { content: string parentId?: string - recaptchaToken?: string | null + humanVerificationToken?: string }) => Promise onUpdate?: (data: { commentId: string; content: string }) => Promise onSuccess?: () => void onCancel?: () => void - getRecaptchaToken?: () => Promise - - // Loading states isCreating?: boolean isUpdating?: boolean } @@ -46,6 +43,7 @@ interface GenericCommentFormProps { export function GenericCommentForm(props: GenericCommentFormProps) { const { user } = useUser() const [content, setContent] = useState(props.editingComment?.content ?? '') + const submitWithHumanVerification = useSubmitWithHumanVerification() const isLoading = props.isCreating || props.isUpdating const maxLength = props.config.maxLength ?? 2000 @@ -77,24 +75,18 @@ export function GenericCommentForm(props: GenericCommentFormProps) { try { if (props.editingComment && props.onUpdate) { - // Update existing comment await props.onUpdate({ commentId: props.editingComment.id, content: trimmedContent, }) toast.success('Comment updated successfully') } else { - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (props.config.enableRecaptcha && props.getRecaptchaToken) { - recaptchaToken = await props.getRecaptchaToken() - } - - // Create new comment - await props.onSubmit({ - content: trimmedContent, - parentId: props.parentId, - recaptchaToken, + await submitWithHumanVerification((humanVerificationToken) => { + return props.onSubmit({ + content: trimmedContent, + parentId: props.parentId, + humanVerificationToken, + }) }) toast.success('Comment posted successfully') } @@ -187,7 +179,6 @@ export function GenericCommentForm(props: GenericCommentFormProps) { ) } - // Default style return (
({ + env: { + TURNSTILE_SITE_KEY: 'site-key', + }, +})) + +vi.mock('next/script', () => ({ + default: function Script() { + return null + }, +})) + +interface CaptureButtonProps { + onRequest: (promise: Promise) => void +} + +function CaptureButton(props: CaptureButtonProps) { + const requestVerification = useHumanVerification() + + return ( + + ) +} + +describe('HumanVerificationProvider', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('rejects concurrent verification requests instead of replacing the pending request', async () => { + const requests: Promise[] = [] + + render( + + requests.push(promise)} /> + , + ) + + const button = screen.getByRole('button', { name: /request verification/i }) + fireEvent.click(button) + fireEvent.click(button) + + expect(requests).toHaveLength(2) + await expect(requests[1]).rejects.toThrow(/already in progress/i) + }) +}) diff --git a/src/features/human-verification/client/components/HumanVerificationProvider.tsx b/src/features/human-verification/client/components/HumanVerificationProvider.tsx new file mode 100644 index 000000000..6b8b3895c --- /dev/null +++ b/src/features/human-verification/client/components/HumanVerificationProvider.tsx @@ -0,0 +1,180 @@ +'use client' + +import Script from 'next/script' +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type PropsWithChildren, +} from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui' +import { env } from '@/lib/env' +import { type HUMAN_VERIFICATION_ACTION } from '../../shared/constants' + +type TurnstileWidgetId = string + +interface TurnstileApi { + render: ( + container: HTMLElement, + options: Record, + ) => TurnstileWidgetId | undefined + remove: (widgetId: TurnstileWidgetId) => void + reset: (widgetId: TurnstileWidgetId) => void +} + +declare global { + interface Window { + turnstile?: TurnstileApi + } +} + +interface VerificationRequest { + action: typeof HUMAN_VERIFICATION_ACTION +} + +type RequestVerification = (request: VerificationRequest) => Promise +type ScriptStatus = 'idle' | 'ready' | 'failed' + +const HumanVerificationContext = createContext(null) + +interface PendingRequest { + action: typeof HUMAN_VERIFICATION_ACTION + resolve: (token: string) => void + reject: (error: Error) => void +} + +export function HumanVerificationProvider(props: PropsWithChildren) { + const [pendingRequest, setPendingRequest] = useState(null) + const [scriptStatus, setScriptStatus] = useState('idle') + const widgetContainerRef = useRef(null) + const widgetIdRef = useRef(null) + const pendingRequestRef = useRef(null) + const turnstileSiteKey = env.TURNSTILE_SITE_KEY + + const requestVerification = useCallback( + (request) => { + if (!turnstileSiteKey) { + return Promise.reject(new Error('Human verification is not configured.')) + } + + if (scriptStatus === 'failed') { + return Promise.reject(new Error('Human verification failed to load.')) + } + + if (pendingRequestRef.current) { + return Promise.reject(new Error('Human verification is already in progress.')) + } + + return new Promise((resolve, reject) => { + const nextRequest = { action: request.action, resolve, reject } + pendingRequestRef.current = nextRequest + setPendingRequest(nextRequest) + }) + }, + [scriptStatus, turnstileSiteKey], + ) + + const closeDialog = useCallback(() => { + if (widgetIdRef.current && window.turnstile) { + window.turnstile.remove(widgetIdRef.current) + widgetIdRef.current = null + } + pendingRequestRef.current = null + setPendingRequest(null) + }, []) + + useEffect(() => { + if ( + !pendingRequest || + scriptStatus !== 'ready' || + !widgetContainerRef.current || + !window.turnstile + ) { + return + } + + if (widgetIdRef.current) { + window.turnstile.remove(widgetIdRef.current) + widgetIdRef.current = null + } + + const widgetId = window.turnstile.render(widgetContainerRef.current, { + sitekey: turnstileSiteKey, + action: pendingRequest.action, + theme: 'auto', + callback: (token: string) => { + pendingRequest.resolve(token) + closeDialog() + }, + 'error-callback': () => { + pendingRequest.reject(new Error('Human verification failed. Please try again.')) + closeDialog() + }, + 'expired-callback': () => { + if (widgetIdRef.current && window.turnstile) { + window.turnstile.reset(widgetIdRef.current) + } + }, + }) + + widgetIdRef.current = widgetId ?? null + }, [closeDialog, pendingRequest, scriptStatus, turnstileSiteKey]) + + const handleOpenChange = (open: boolean) => { + if (open || !pendingRequest) return + pendingRequest.reject(new Error('Human verification was cancelled.')) + closeDialog() + } + + return ( + + {turnstileSiteKey && ( +