diff --git a/assets/src/settings/procaptcha/procaptchaSite.ts b/assets/src/settings/procaptcha/procaptchaSite.ts index d339ea6..f7982a0 100644 --- a/assets/src/settings/procaptcha/procaptchaSite.ts +++ b/assets/src/settings/procaptcha/procaptchaSite.ts @@ -2,7 +2,7 @@ import { type ProcaptchaAccount, procaptchaAccountSchema, } from "#settings/procaptcha/procaptchaAccount.js"; -import { z, type ZodType } from "zod"; +import { z, type ZodType, type ZodTypeDef } from "zod"; export interface ProcaptchaSite { account: ProcaptchaAccount; @@ -16,7 +16,22 @@ export interface ProcaptchaSite { } export interface SiteSettings { + /** + * Lower rung of the frictionless score ladder: the score a session has to + * stay under to pass without being challenged. This is the value the + * plugin has always shown as "Frictionless Threshold". + */ frictionlessThreshold: number; + /** + * Upper rung: the score at or above which a session gets an image captcha + * rather than a puzzle. Arrives nested inside `frictionlessThreshold`, not + * as a sibling of it, and is absent whenever that field is still the bare + * number the pre-ladder API sends. + * + * Not to be confused with the settings' own `imageThreshold`, which is an + * unrelated image-captcha setting on a 0..1 scale. + */ + frictionlessImageThreshold?: number; powDifficulty: number; captchaType: string; domains: string[]; @@ -28,12 +43,74 @@ export interface CaptchaUsage { total: number; } -export const siteSettingsSchema = z.object({ - frictionlessThreshold: z.number(), - powDifficulty: z.number(), - captchaType: z.string(), - domains: z.string().array(), -}) satisfies ZodType; +/** + * `SiteSettings` as it arrives on the wire, before the ladder is split into + * the two flat fields the rest of the plugin reads. Declared separately + * because the schema below transforms on parse, so its input and output + * types differ and `ZodType` needs both. + */ +export interface SiteSettingsInput + extends Omit< + SiteSettings, + "frictionlessThreshold" | "frictionlessImageThreshold" + > { + frictionlessThreshold: + | number + | { + frictionlessPuzzleThreshold?: number; + frictionlessImageThreshold?: number; + }; +} + +export interface ProcaptchaSiteInput extends Omit { + settings: SiteSettingsInput; +} + +/** + * Fallback for a ladder object that arrives without its lower rung. Matches + * the portal's own default so the label the user sees does not change + * meaning between the two shapes. + */ +const DEFAULT_FRICTIONLESS_THRESHOLD = 0.5; + +/** + * `frictionlessThreshold` is read as "number, or the two-rung ladder object" + * because both are live at once: the portal moved the field to the ladder, + * but records migrate in the background and the plugin ships independently + * of the portal, so an install can be talking to either shape. A bare number + * means what it always meant — the puzzle rung — and carries no image rung. + */ +const frictionlessThresholdSchema = z.union([ + z.number(), + z.object({ + frictionlessPuzzleThreshold: z.number().optional(), + frictionlessImageThreshold: z.number().optional(), + }), +]); + +/** + * Split the ladder into the two flat fields the rest of the plugin reads, so + * nothing downstream has to know which of the two wire shapes arrived. + */ +export const siteSettingsSchema = z + .object({ + frictionlessThreshold: frictionlessThresholdSchema, + powDifficulty: z.number(), + captchaType: z.string(), + domains: z.string().array(), + }) + .transform(({ frictionlessThreshold, ...settings }) => ({ + ...settings, + frictionlessThreshold: + "number" === typeof frictionlessThreshold + ? frictionlessThreshold + : (frictionlessThreshold.frictionlessPuzzleThreshold ?? + DEFAULT_FRICTIONLESS_THRESHOLD), + frictionlessImageThreshold: + "number" === typeof frictionlessThreshold + ? undefined + : frictionlessThreshold.frictionlessImageThreshold, + })) satisfies ZodType; export const captchaUsageSchema = z.object({ submissions: z.number(), @@ -50,4 +127,4 @@ export const procaptchaSiteSchema = z.object({ image: captchaUsageSchema, pow: captchaUsageSchema, }), -}) satisfies ZodType; +}) satisfies ZodType; diff --git a/assets/src/settings/statistics/components/appComponent.tsx b/assets/src/settings/statistics/components/appComponent.tsx index e2fc253..9f25eca 100644 --- a/assets/src/settings/statistics/components/appComponent.tsx +++ b/assets/src/settings/statistics/components/appComponent.tsx @@ -122,6 +122,11 @@ class AppComponent extends React.Component { .frictionlessThreshold, value: "...", }, + { + label: this.config.getCaptchaSettingsLabels() + .frictionlessImageThreshold, + value: "...", + }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, @@ -221,6 +226,37 @@ class AppComponent extends React.Component { return frictionlessThreshold < 0.4 ? levelLabels.high : levelLabels.low; } + /** + * Upper rung of the score ladder, shown as a level rather than a raw + * score to match its sibling above. A lower rung means more sessions + * reach an image captcha instead of a puzzle, so it reads as stricter. + * + * Banded on the portal's 1.0 default rather than a range: unlike the + * lower rung this one is deliberately allowed above 1, because the score + * it is compared against is a total that server-side penalties add to. + * + * An API still sending the pre-ladder bare `frictionlessThreshold` gives + * no upper rung at all, in which case the row shows an em dash rather + * than inventing a value. + */ + protected getFrictionlessImageThresholdLabel( + frictionlessImageThreshold: number | undefined, + ): string { + if (undefined === frictionlessImageThreshold) { + return "—"; + } + + const levelLabels = this.config.getCaptchaSettingsLabels().level; + + if (frictionlessImageThreshold < 1) { + return levelLabels.high; + } + + return 1 === frictionlessImageThreshold + ? levelLabels.normal + : levelLabels.low; + } + protected getTypeLabel(type: string): string { const typeLabels = this.config.getCaptchaSettingsLabels().types; @@ -260,6 +296,13 @@ class AppComponent extends React.Component { siteSettings.frictionlessThreshold, ), }, + { + label: this.config.getCaptchaSettingsLabels() + .frictionlessImageThreshold, + value: this.getFrictionlessImageThresholdLabel( + siteSettings.frictionlessImageThreshold, + ), + }, { label: this.config.getCaptchaSettingsLabels() .powDifficulty, diff --git a/assets/src/settings/statistics/config.ts b/assets/src/settings/statistics/config.ts index 6e5d621..82c6ee1 100644 --- a/assets/src/settings/statistics/config.ts +++ b/assets/src/settings/statistics/config.ts @@ -24,6 +24,7 @@ interface CaptchaSettingsLabels { title: string; type: string; frictionlessThreshold: string; + frictionlessImageThreshold: string; powDifficulty: string; level: { low: string; @@ -151,6 +152,9 @@ class ConfigClass implements Config { frictionlessThreshold: captchaSettingsLabels.getString( "frictionlessThreshold", ), + frictionlessImageThreshold: captchaSettingsLabels.getString( + "frictionlessImageThreshold", + ), powDifficulty: captchaSettingsLabels.getString("powDifficulty"), level: { low: level.getString("low"), diff --git a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php index 25314cf..b528499 100644 --- a/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php +++ b/prosopo-procaptcha/src/Settings/Statistics/Statistics_Settings_Tab.php @@ -71,16 +71,17 @@ function ( Upgrade_Tier_Banner $model ) { ), 'callToUpgradeElementMarkup' => $call_to_upgrade_element_markup, 'captchaSettingsLabels' => array( - 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), - 'level' => array( + 'frictionlessImageThreshold' => __( 'Image Challenge Threshold:', 'prosopo-procaptcha' ), + 'frictionlessThreshold' => __( 'Frictionless Threshold:', 'prosopo-procaptcha' ), + 'level' => array( 'high' => __( 'High', 'prosopo-procaptcha' ), 'low' => __( 'Low', 'prosopo-procaptcha' ), 'normal' => __( 'Normal', 'prosopo-procaptcha' ), ), - 'powDifficulty' => __( 'Proof of Work Difficulty:', 'prosopo-procaptcha' ), - 'title' => __( 'Captcha Settings', 'prosopo-procaptcha' ), - 'type' => __( 'Type:', 'prosopo-procaptcha' ), - 'types' => array( + 'powDifficulty' => __( 'Proof of Work Difficulty:', 'prosopo-procaptcha' ), + 'title' => __( 'Captcha Settings', 'prosopo-procaptcha' ), + 'type' => __( 'Type:', 'prosopo-procaptcha' ), + 'types' => array( 'frictionless' => __( 'Frictionless', 'prosopo-procaptcha' ), 'image' => __( 'Image', 'prosopo-procaptcha' ), 'proofOfWork' => __( 'Proof of Work', 'prosopo-procaptcha' ),