diff --git a/.eslintrc b/.eslintrc index 8c2212d51a..106a9acc1a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,7 +39,15 @@ "react/no-find-dom-node": 0, "jsx-a11y/label-has-for": 0, "react/no-unescaped-entities": 0, - "@typescript-eslint/no-inferrable-types": 0 + "@typescript-eslint/no-inferrable-types": 0, + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ] }, "settings": { "react": { diff --git a/src/api/patronBlockingRules.ts b/src/api/patronBlockingRules.ts new file mode 100644 index 0000000000..76a7e44d91 --- /dev/null +++ b/src/api/patronBlockingRules.ts @@ -0,0 +1,49 @@ +import { PatronBlockingRule } from "../interfaces"; + +const VALIDATE_URL = "/admin/patron_auth_service_validate_patron_blocking_rule"; + +/** + * Validate a patron blocking rule expression against live ILS data on the server. + * + * The server loads the saved PatronAuthService by serviceId, makes a live + * authentication call using its configured test credentials, and evaluates + * the rule expression against the real patron data returned. Only parse/eval + * success or failure is reported — the boolean result is discarded. + * + * Returns null on success, or an error message string on failure. + */ +export const validatePatronBlockingRuleExpression = async ( + serviceId: number | undefined, + rule: PatronBlockingRule, + csrfToken: string | undefined +): Promise => { + const formData = new FormData(); + if (serviceId !== undefined) { + formData.append("service_id", String(serviceId)); + } + formData.append("rule", rule.rule); + formData.append("name", rule.name); + + const headers: Record = {}; + if (csrfToken) { + headers["X-CSRF-Token"] = csrfToken; + } + + const res = await fetch(VALIDATE_URL, { + method: "POST", + headers, + body: formData, + credentials: "same-origin", + }); + + if (res.ok) { + return null; + } + + try { + const data = await res.json(); + return data.detail || "Rule validation failed."; + } catch { + return "Rule validation failed."; + } +}; diff --git a/src/components/PatronAuthServiceEditForm.tsx b/src/components/PatronAuthServiceEditForm.tsx new file mode 100644 index 0000000000..0628217a86 --- /dev/null +++ b/src/components/PatronAuthServiceEditForm.tsx @@ -0,0 +1,183 @@ +import * as React from "react"; +import { + LibraryWithSettingsData, + PatronAuthServicesData, + ProtocolData, +} from "../interfaces"; +import ServiceEditForm from "./ServiceEditForm"; +import PatronBlockingRulesEditor, { + PatronBlockingRulesEditorHandle, +} from "./PatronBlockingRulesEditor"; +import { supportsPatronBlockingRules } from "../utils/patronBlockingRules"; + +const NEW_LIBRARY_KEY = "__new__"; + +/** Extends ServiceEditForm with patron-blocking-rules support for protocols that + * support it. The editor is injected via the hook methods added + * to ServiceEditForm; editLibrary and addLibrary are overridden to collect the + * rules from the editor ref and persist them in library state. */ +export default class PatronAuthServiceEditForm extends ServiceEditForm< + PatronAuthServicesData +> { + private newLibraryRulesRef = React.createRef< + PatronBlockingRulesEditorHandle + >(); + private libraryRulesRefs = new Map< + string, + React.RefObject + >(); + + // Tracks whether any rule editor is currently blocking save due to pending + // or failed validation, or duplicate names. Updated via onValidationStateChange. + // Stored as an instance variable (not React state) to avoid TypeScript state + // type conflicts with the parent class; forceUpdate() triggers re-render. + private rulesBlockingSave: { [shortName: string]: boolean } = {}; + + private getOrCreateLibraryRef( + shortName: string + ): React.RefObject { + if (!this.libraryRulesRefs.has(shortName)) { + this.libraryRulesRefs.set( + shortName, + React.createRef() + ); + } + return this.libraryRulesRefs.get(shortName); + } + + handleRulesValidationStateChange( + shortName: string, + isBlocking: boolean + ): void { + if (this.rulesBlockingSave[shortName] !== isBlocking) { + this.rulesBlockingSave = { + ...this.rulesBlockingSave, + [shortName]: isBlocking, + }; + this.forceUpdate(); + } + } + + isLibrarySaveDisabled(library: LibraryWithSettingsData): boolean { + return !!this.rulesBlockingSave[library.short_name]; + } + + isAddLibraryDisabled(): boolean { + return !!this.rulesBlockingSave[NEW_LIBRARY_KEY]; + } + + protocolHasLibrarySettings(protocol: ProtocolData): boolean { + return ( + super.protocolHasLibrarySettings(protocol) || + supportsPatronBlockingRules(protocol && protocol.name) + ); + } + + renderExtraAssociatedLibrarySettings( + library: LibraryWithSettingsData, + protocol: ProtocolData, + disabled: boolean + ): React.ReactNode { + if (!supportsPatronBlockingRules(protocol && protocol.name)) { + return null; + } + return ( + + this.handleRulesValidationStateChange(library.short_name, isBlocking) + } + /> + ); + } + + renderExtraNewLibrarySettings( + protocol: ProtocolData, + disabled: boolean + ): React.ReactNode { + if (!supportsPatronBlockingRules(protocol && protocol.name)) { + return null; + } + return ( + + this.handleRulesValidationStateChange(NEW_LIBRARY_KEY, isBlocking) + } + /> + ); + } + + editLibrary(library: LibraryWithSettingsData, protocol: ProtocolData) { + const libraries = this.state.libraries.filter( + (stateLibrary) => stateLibrary.short_name !== library.short_name + ); + const expandedLibraries = this.state.expandedLibraries.filter( + (shortName) => shortName !== library.short_name + ); + const newLibrary: LibraryWithSettingsData = { + short_name: library.short_name, + }; + for (const setting of this.protocolLibrarySettings(protocol)) { + const value = (this.refs[ + `${library.short_name}_${setting.key}` + ] as any).getValue(); + if (value) { + ((newLibrary as unknown) as Record)[ + setting.key + ] = value; + } + } + if (supportsPatronBlockingRules(protocol && protocol.name)) { + const editorRef = this.libraryRulesRefs.get(library.short_name); + if (editorRef?.current) { + newLibrary.patron_blocking_rules = editorRef.current.getValue(); + } + } + libraries.push(newLibrary); + this.setState( + Object.assign({}, this.state, { libraries, expandedLibraries }) + ); + } + + addLibrary(protocol: ProtocolData) { + const name = this.state.selectedLibrary; + const newLibrary: LibraryWithSettingsData = { short_name: name }; + for (const setting of this.protocolLibrarySettings(protocol)) { + const value = (this.refs[setting.key] as any).getValue(); + if (value) { + ((newLibrary as unknown) as Record)[ + setting.key + ] = value; + } + (this.refs[setting.key] as any).clear(); + } + if (supportsPatronBlockingRules(protocol && protocol.name)) { + if (this.newLibraryRulesRef.current) { + newLibrary.patron_blocking_rules = this.newLibraryRulesRef.current.getValue(); + } + } + const libraries = this.state.libraries.concat(newLibrary); + this.setState( + Object.assign({}, this.state, { libraries, selectedLibrary: null }) + ); + } +} diff --git a/src/components/PatronAuthServices.tsx b/src/components/PatronAuthServices.tsx index 7f084cd5e1..aee6770bce 100644 --- a/src/components/PatronAuthServices.tsx +++ b/src/components/PatronAuthServices.tsx @@ -7,7 +7,7 @@ import EditableConfigList, { import { connect } from "react-redux"; import ActionCreator from "../actions"; import { PatronAuthServicesData, PatronAuthServiceData } from "../interfaces"; -import ServiceEditForm from "./ServiceEditForm"; +import PatronAuthServiceEditForm from "./PatronAuthServiceEditForm"; import NeighborhoodAnalyticsForm from "./NeighborhoodAnalyticsForm"; /** Right panel for patron authentication services on the system @@ -18,7 +18,7 @@ export class PatronAuthServices extends EditableConfigList< PatronAuthServicesData, PatronAuthServiceData > { - EditForm = ServiceEditForm; + EditForm = PatronAuthServiceEditForm; ExtraFormSection = NeighborhoodAnalyticsForm; extraFormKey = "neighborhood_mode"; listDataKey = "patron_auth_services"; @@ -70,6 +70,7 @@ function mapStateToProps(state, ownProps) { // of the create/edit form. return { data: data, + additionalData: { csrfToken: ownProps.csrfToken }, responseBody: state.editor.patronAuthServices && state.editor.patronAuthServices.successMessage, diff --git a/src/components/PatronBlockingRulesEditor.tsx b/src/components/PatronBlockingRulesEditor.tsx new file mode 100644 index 0000000000..a520c8a18c --- /dev/null +++ b/src/components/PatronBlockingRulesEditor.tsx @@ -0,0 +1,377 @@ +import * as React from "react"; +import { Button } from "library-simplified-reusable-components"; +import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces"; +import { PatronBlockingRule } from "../interfaces"; +import EditableInput from "./EditableInput"; +import WithRemoveButton from "./WithRemoveButton"; +import { validatePatronBlockingRuleExpression } from "../api/patronBlockingRules"; + +/** Returns the set of non-empty rule names that appear more than once. */ +function getDuplicateNameSet(rules: RuleEntry[]): Set { + const counts: { [name: string]: number } = {}; + rules.forEach((r) => { + if (r.name) counts[r.name] = (counts[r.name] || 0) + 1; + }); + return new Set( + Object.entries(counts) + .filter(([, n]) => n > 1) + .map(([name]) => name) + ); +} + +function extractErrorMessage(error: FetchErrorData): string | null { + if (!error || error.status < 400) return null; + const resp = error.response as string; + if (!resp) return null; + try { + return JSON.parse(resp).detail || resp; + } catch { + return resp; + } +} + +export interface PatronBlockingRulesEditorProps { + value?: PatronBlockingRule[]; + disabled?: boolean; + error?: FetchErrorData; + csrfToken?: string; + serviceId?: number; + /** Called whenever the "save should be blocked" state changes. + * True while any rule is incomplete, awaiting or has failed server-side + * validation, or while any two rules share the same name. */ + onValidationStateChange?: (isBlocking: boolean) => void; +} + +export interface PatronBlockingRulesEditorHandle { + getValue: () => PatronBlockingRule[]; + validateAndGetValue: () => PatronBlockingRule[] | null; +} + +type RuleEntry = PatronBlockingRule & { _id: number }; +type ClientErrors = { [index: number]: { name?: boolean; rule?: boolean } }; +type ServerErrors = { [index: number]: string | null }; + +interface RuleFormListItemProps { + rule: RuleEntry; + index: number; + disabled: boolean; + rowErrors: { name?: boolean; rule?: boolean }; + nameDuplicateError?: boolean; + serverRuleError?: string | null; + error?: FetchErrorData; + onRemove: () => void; + onUpdate: (field: keyof PatronBlockingRule, value: string) => void; + onRuleBlur: () => void; +} + +function RuleFormListItem({ + rule, + index, + disabled, + rowErrors, + nameDuplicateError, + serverRuleError, + error, + onRemove, + onUpdate, + onRuleBlur, +}: RuleFormListItemProps) { + const nameClientError = !!rowErrors.name; + const ruleClientError = !!rowErrors.rule; + + return ( +
  • + +
    + {nameClientError && ( +

    + Rule Name is required. +

    + )} + {nameDuplicateError && ( +

    + Rule Name must be unique within this library. +

    + )} + onUpdate("name", value)} + /> + {ruleClientError && ( +

    + Rule Expression is required. +

    + )} + {serverRuleError && ( +

    + {serverRuleError} +

    + )} + {/* This div captures the focusout event that bubbles up from the + textarea inside it, triggering server-side rule validation on blur. + It is not an interactive element and has no keyboard/mouse role — + the textarea itself handles all user interaction. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
    + onUpdate("rule", value)} + /> +
    + onUpdate("message", value)} + /> +
    +
    +
  • + ); +} + +/** Protocol-agnostic editor for a list of patron blocking rules stored in library settings. */ +const PatronBlockingRulesEditor = React.forwardRef< + PatronBlockingRulesEditorHandle, + PatronBlockingRulesEditorProps +>( + ( + { + value = [], + disabled = false, + error, + csrfToken, + serviceId, + onValidationStateChange, + }, + ref + ) => { + const nextId = React.useRef(0); + const newId = () => nextId.current++; + + const [rules, setRules] = React.useState(() => + (value || []).map((r) => ({ ...r, _id: newId() })) + ); + const [clientErrors, setClientErrors] = React.useState({}); + const [serverErrors, setServerErrors] = React.useState({}); + const [pendingValidationIds, setPendingValidationIds] = React.useState< + Set + >(new Set()); + + // Keep a stable ref to the latest callback so the useEffect below does not + // need it as a dependency (avoids extra calls when a class-component parent + // creates a new arrow function on every render). + const onValidationStateChangeRef = React.useRef(onValidationStateChange); + React.useLayoutEffect(() => { + onValidationStateChangeRef.current = onValidationStateChange; + }, [onValidationStateChange]); + + React.useEffect(() => { + const hasIncomplete = rules.some((r) => !r.name || !r.rule); + const isBlocking = + pendingValidationIds.size > 0 || + getDuplicateNameSet(rules).size > 0 || + hasIncomplete; + onValidationStateChangeRef.current?.(isBlocking); + }, [pendingValidationIds, rules]); + + const serverErrorMessage = extractErrorMessage(error); + + React.useImperativeHandle( + ref, + () => ({ + getValue: () => rules.map(({ _id, ...r }) => r), + validateAndGetValue: () => { + const errors: ClientErrors = {}; + let valid = true; + rules.forEach((r, i) => { + const rowErrors: { name?: boolean; rule?: boolean } = {}; + if (!r.name) { + rowErrors.name = true; + valid = false; + } + if (!r.rule) { + rowErrors.rule = true; + valid = false; + } + if (Object.keys(rowErrors).length > 0) { + errors[i] = rowErrors; + } + }); + setClientErrors(errors); + return valid ? rules.map(({ _id, ...r }) => r) : null; + }, + }), + [rules] + ); + + const addRule = () => { + const newEntry: RuleEntry = { + name: "", + rule: "", + message: "", + _id: newId(), + }; + setRules((prev) => [...prev, newEntry]); + // Only track pending validation when we have a saved service to validate against. + if (serviceId !== undefined) { + setPendingValidationIds((prev) => new Set(prev).add(newEntry._id)); + } + }; + + const removeRule = (index: number) => { + const removedId = rules[index]._id; + setRules((prev) => prev.filter((_, i) => i !== index)); + setPendingValidationIds((prev) => { + const next = new Set(prev); + next.delete(removedId); + return next; + }); + setClientErrors((prev) => { + const next: ClientErrors = {}; + Object.entries(prev).forEach(([key, val]) => { + const k = Number(key); + if (k < index) next[k] = val; + else if (k > index) next[k - 1] = val; + // k === index is dropped + }); + return next; + }); + setServerErrors((prev) => { + const next: ServerErrors = {}; + Object.entries(prev).forEach(([key, val]) => { + const k = Number(key); + if (k < index) next[k] = val; + else if (k > index) next[k - 1] = val; + // k === index is dropped + }); + return next; + }); + }; + + const updateRule = ( + index: number, + field: keyof PatronBlockingRule, + value: string + ) => { + setRules((prev) => + prev.map((r, i) => (i === index ? { ...r, [field]: value } : r)) + ); + if (field === "name" || field === "rule") { + setClientErrors((prev) => { + if (!prev[index]) return prev; + return { ...prev, [index]: { ...prev[index], [field]: !value } }; + }); + } + if (field === "rule") { + setServerErrors((prev) => ({ ...prev, [index]: null })); + // Mark as needing re-validation whenever the expression changes. + if (serviceId !== undefined) { + setPendingValidationIds((prev) => + new Set(prev).add(rules[index]._id) + ); + } + } + }; + + const handleRuleBlur = async (index: number) => { + const rule = rules[index]; + if (!rule || !rule.rule) { + // Empty expression — skip server call; leave in pending so Save stays + // disabled until the user fills in the expression or removes the rule. + return; + } + // NOTE: There is a possible, though unlikely, race condition when rendering multiple rules, + // each with their own onRuleBlur because there could potentially be multiple + // validatePatronBlockingRuleExpression fetches in flight. In that case, + // the last one to resolve will win, which might in correctly set the save button state. + // TODO: address the race condition if users see it occurring. + const errorMessage = await validatePatronBlockingRuleExpression( + serviceId, + rule, + csrfToken + ); + setServerErrors((prev) => ({ ...prev, [index]: errorMessage })); + if (!errorMessage) { + // Validation passed — unblock save for this rule. + setPendingValidationIds((prev) => { + const next = new Set(prev); + next.delete(rule._id); + return next; + }); + } + }; + + const hasIncompleteRule = rules.some((r) => !r.name || !r.rule); + const duplicateNameSet = getDuplicateNameSet(rules); + + return ( +
    +
    + +
    + {serverErrorMessage && ( +

    + {serverErrorMessage} +

    + )} + {rules.length === 0 && ( +

    No patron blocking rules defined.

    + )} +
      + {rules.map((rule, index) => ( + removeRule(index)} + onUpdate={(field, value) => updateRule(index, field, value)} + onRuleBlur={() => handleRuleBlur(index)} + /> + ))} +
    +
    + ); + } +); + +PatronBlockingRulesEditor.displayName = "PatronBlockingRulesEditor"; + +export default PatronBlockingRulesEditor; diff --git a/src/components/ServiceEditForm.tsx b/src/components/ServiceEditForm.tsx index c49203051b..82407f5735 100644 --- a/src/components/ServiceEditForm.tsx +++ b/src/components/ServiceEditForm.tsx @@ -17,6 +17,7 @@ import { FetchErrorData } from "@thepalaceproject/web-opds-client/lib/interfaces export interface ServiceEditFormProps { data: T; item?: ServiceData; + additionalData?: any; disabled: boolean; save?: (data: FormData) => void; urlBase: string; @@ -227,6 +228,32 @@ export default class ServiceEditForm< return []; } + /** Hook for subclasses to inject extra fields into the expanded per-library settings panel. + * Rendered after protocol library_settings fields and before the Save button. */ + renderExtraAssociatedLibrarySettings( + _library: LibraryWithSettingsData, + _protocol: ProtocolData, + _disabled: boolean + ): React.ReactNode { + return null; + } + + /** Hook for subclasses to inject extra fields into the add-new-library panel. + * Rendered after protocol library_settings fields and before the Add Library button. */ + renderExtraNewLibrarySettings( + _protocol: ProtocolData, + _disabled: boolean + ): React.ReactNode { + return null; + } + + /** Returns true when this protocol has any editable per-library settings, + * either from the protocol definition or injected by a subclass. + * Subclasses should override this when they add extra library-level fields. */ + protocolHasLibrarySettings(protocol: ProtocolData): boolean { + return this.protocolLibrarySettings(protocol).length > 0; + } + renderRequiredFields( requiredFields, protocol: ProtocolData, @@ -357,8 +384,7 @@ export default class ServiceEditForm< > {this.props.data && this.props.data.protocols && - this.protocolLibrarySettings(protocol) && - this.protocolLibrarySettings(protocol).length > 0 && ( + this.protocolHasLibrarySettings(protocol) && ( this.expandLibrary(library)} @@ -370,8 +396,7 @@ export default class ServiceEditForm< {!( this.props.data && this.props.data.protocols && - this.protocolLibrarySettings(protocol) && - this.protocolLibrarySettings(protocol).length > 0 + this.protocolHasLibrarySettings(protocol) ) && this.getLibrary(library.short_name) && this.getLibrary(library.short_name).name} @@ -386,14 +411,23 @@ export default class ServiceEditForm< key={setting.key} setting={setting} disabled={disabled} - value={library[setting.key]} + value={ + ((library as unknown) as Record)[ + setting.key + ] + } ref={library.short_name + "_" + setting.key} /> ))} + {this.renderExtraAssociatedLibrarySettings( + library, + protocol, + disabled + )}