Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/react/src/components/factories/FieldFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,18 @@ export interface FieldConfig {
* Display name for the field.
*/
label: string;
/**
* Number of characters for OTP fields.
*/
length?: number;
/**
* The name of the field.
*/
name: string;
/**
* Whether an OTP field accepts digits only. Defaults to true.
*/
numericOnly?: boolean;
/**
* Callback function when the field loses focus.
*/
Expand Down Expand Up @@ -139,6 +147,8 @@ export const createField = (config: FieldConfig): ReactElement => {
options = [],
touched = false,
placeholder,
length,
numericOnly = true,
} = config;

const validationError: string | null = error || validateFieldValue(value, type, required, touched);
Expand Down Expand Up @@ -203,7 +213,13 @@ export const createField = (config: FieldConfig): ReactElement => {
}
case FieldType.Otp:
return (
<OtpField {...commonProps} onChange={(e: ChangeEvent<HTMLInputElement>): void => onChange(e.target.value)} />
<OtpField
{...commonProps}
length={length}
type={numericOnly ? 'number' : 'text'}
uppercase={!numericOnly}
onChange={(e: ChangeEvent<HTMLInputElement>): void => onChange(e.target.value)}
/>
);
case FieldType.Number:
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,12 +342,21 @@ const createAuthComponentFromFlow = (
const isTouched: boolean = touchedFields[identifier] || false;
const error: string = isTouched ? formErrors[identifier] : undefined!;

// The server reports the length and character set of the code it generated, so the field
// matches the OTP the user received. An older server omits both and the defaults apply.
const reportedLength: number = Number(options.additionalData?.['otpLength']);
const otpLength: number | undefined =
Number.isInteger(reportedLength) && reportedLength > 0 ? reportedLength : undefined;
const numericOnly: boolean = options.additionalData?.['otpNumericOnly'] !== 'false';
Comment on lines +347 to +350

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Set a maximum OTP length.

Line 349 accepts every positive integer. An otpLength of 1000000 causes OtpField to allocate state and render one million inputs. This can lock the authentication UI.

Reject values above the backend protocol maximum before calling createField.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/components/presentation/auth/AuthOptionFactory.tsx` around
lines 347 - 350, Update the otpLength validation in AuthOptionFactory so
positive integers above the backend protocol maximum are rejected before
createField is called. Preserve valid lengths and the existing undefined
fallback for invalid values, using the established protocol maximum constant if
one exists.


const field: any = createField({
className: cx(options.inputClassName, component.classes),
error,
id: component.id,
label: resolve(component.label) || '',
length: otpLength,
name: identifier,
numericOnly,
onBlur: () => options.onInputBlur?.(identifier),
onChange: (newValue: string) => onInputChange(identifier, newValue),
placeholder: resolve(component.placeholder) || '',
Expand Down
12 changes: 10 additions & 2 deletions packages/react/src/components/primitives/OtpField/OtpField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export interface OtpInputProps {
* Type of input (text, number, password)
*/
type?: OtpFieldType;
/**
* Upper-case entered characters before emitting them
*/
uppercase?: boolean;
/**
* Current OTP value
*/
Expand All @@ -100,6 +104,7 @@ const OtpField: FC<OtpInputProps> = ({
style = {},
autoFocus = false,
pattern,
uppercase = false,
}: OtpInputProps) => {
const {theme, colorScheme}: ReturnType<typeof useTheme> = useTheme();
const styles: Record<string, string> = useStyles(theme, colorScheme, !!disabled, !!error, length);
Expand Down Expand Up @@ -129,7 +134,7 @@ const OtpField: FC<OtpInputProps> = ({
}, [autoFocus]);

const handleChange = (index: number, event: ChangeEvent<HTMLInputElement>): void => {
const newValue: string = event.target.value;
const newValue: string = uppercase ? event.target.value.toUpperCase() : event.target.value;

if (newValue.length > 1) return;

Expand Down Expand Up @@ -186,7 +191,10 @@ const OtpField: FC<OtpInputProps> = ({
const handlePaste = (event: ClipboardEvent<HTMLInputElement>): void => {
event.preventDefault();

const pastedData: string = event.clipboardData.getData('text').slice(0, length);
const rawData: string = event.clipboardData.getData('text');
// Filter first and let the copy loop below bound the result, so surrounding text such as
// "Your code is 123456" does not push the code itself past the cut-off.
const pastedData: string = uppercase ? rawData.toUpperCase() : rawData;

let validData = '';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {render, screen, cleanup, fireEvent} from '@testing-library/react';
import {createTheme} from '@thunderid/browser';
import {ReactElement} from 'react';
import {afterEach, describe, expect, it, vi} from 'vitest';
import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext';
import OtpField from '../OtpField';

const themeContextValue: ThemeContextValue = {
colorScheme: 'light',
direction: 'ltr',
theme: createTheme(),
toggleTheme: vi.fn(),
};

const withTheme = (ui: ReactElement): ReactElement => (
<ThemeContext.Provider value={themeContextValue}>{ui}</ThemeContext.Provider>
);

const boxes = (): HTMLInputElement[] => screen.getAllByRole('textbox');

const paste = (target: HTMLElement, text: string): void => {
const event: Event = new Event('paste', {bubbles: true, cancelable: true});
Object.defineProperty(event, 'clipboardData', {value: {getData: () => text}});
fireEvent(target, event);
};

describe('OtpField', () => {
afterEach(() => {
cleanup();
});

it('renders six boxes by default', () => {
render(withTheme(<OtpField />));
expect(boxes()).toHaveLength(6);
});

it('renders the requested number of boxes', () => {
render(withTheme(<OtpField length={8} />));
expect(boxes()).toHaveLength(8);
});

it('rejects a letter when the field is numeric', () => {
const onChange = vi.fn();
render(withTheme(<OtpField type="number" onChange={onChange} />));

fireEvent.change(boxes()[0], {target: {value: 'a'}});

expect(onChange).not.toHaveBeenCalled();
});

it('accepts a digit when the field is numeric', () => {
const onChange = vi.fn();
render(withTheme(<OtpField type="number" onChange={onChange} />));

fireEvent.change(boxes()[0], {target: {value: '7'}});

expect(onChange).toHaveBeenCalledWith({target: {value: '7'}});
});

it('marks a numeric field with the numeric input mode', () => {
render(withTheme(<OtpField type="number" />));
expect(boxes()[0]).toHaveAttribute('inputmode', 'numeric');
});

it('accepts a letter when the field is alphanumeric', () => {
const onChange = vi.fn();
render(withTheme(<OtpField onChange={onChange} />));

fireEvent.change(boxes()[0], {target: {value: 'K'}});

expect(onChange).toHaveBeenCalledWith({target: {value: 'K'}});
});

it('upper-cases entered characters when asked to', () => {
const onChange = vi.fn();
render(withTheme(<OtpField uppercase onChange={onChange} />));

fireEvent.change(boxes()[0], {target: {value: 'k'}});

expect(onChange).toHaveBeenCalledWith({target: {value: 'K'}});
});

it('upper-cases a pasted code when asked to', () => {
const onChange = vi.fn();
render(withTheme(<OtpField uppercase onChange={onChange} />));

paste(boxes()[0], 'k7gx2m');

expect(onChange).toHaveBeenCalledWith({target: {value: 'K7GX2M'}});
});

it('keeps a pasted code intact when surrounded by other text', () => {
const onChange = vi.fn();
render(withTheme(<OtpField type="number" onChange={onChange} />));

paste(boxes()[0], 'Your code is 123456');

expect(onChange).toHaveBeenCalledWith({target: {value: '123456'}});
});

it('calls onComplete once every box is filled', () => {
const onComplete = vi.fn();
render(withTheme(<OtpField type="number" onComplete={onComplete} />));

paste(boxes()[0], '123456');

expect(onComplete).toHaveBeenCalledWith('123456');
});
Comment on lines +104 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file=$(fd -i -t f 'OtpField.test.tsx' . | head -n 1)
printf '%s\n' "TEST_FILE=$file"
wc -l "$file"
sed -n '1,180p' "$file"

printf '%s\n' '--- OtpField candidates ---'
fd -i -t f 'OtpField' packages/react packages 2>/dev/null | head -n 30

printf '%s\n' '--- completion references ---'
rg -n -C 4 'onComplete|complete|paste' packages/react/src/components/primitives/OtpField packages/react/src 2>/dev/null | head -n 240

Repository: thunder-id/javascript-sdks

Length of output: 25262


🏁 Script executed:

#!/bin/bash
set -eu

file=packages/react/src/components/primitives/OtpField/OtpField.tsx
wc -l "$file"
sed -n '120,230p' "$file"

printf '%s\n' '--- test and implementation event-handler declarations ---'
rg -n 'const handle(Change|KeyDown|Paste)|onPaste|onChange=|onKeyDown=|onComplete' \
  packages/react/src/components/primitives/OtpField/OtpField.tsx \
  packages/react/src/components/primitives/OtpField/__tests__/OtpField.test.tsx

Repository: thunder-id/javascript-sdks

Length of output: 6990


🏁 Script executed:

#!/bin/bash
set -eu

file=packages/react/src/components/primitives/OtpField/OtpField.tsx

python3 - <<'PY'
from pathlib import Path
import re

path = Path("packages/react/src/components/primitives/OtpField/OtpField.tsx")
source = path.read_text()

paste = re.search(
    r"const handlePaste.*?(?=\n\s*return \()",
    source,
    re.S,
)
if not paste:
    raise SystemExit("handlePaste implementation not found")

body = paste.group(0)
calls = re.findall(r"\bonComplete\s*\(", body)
print(f"handlePaste onComplete call sites: {len(calls)}")
print("single paste path invokes onComplete at most once:", len(calls) == 1)

test = Path(
    "packages/react/src/components/primitives/OtpField/__tests__/OtpField.test.tsx"
).read_text()
section = re.search(
    r"it\('calls onComplete once every box is filled'.*?\n\s*\}\);",
    test,
    re.S,
)
if not section:
    raise SystemExit("target test not found")

assertion = section.group(0)
print("exact call-count assertion present:",
      bool(re.search(r"toHaveBeenCalledTimes\s*\(\s*1\s*\)", assertion)))
PY

Repository: thunder-id/javascript-sdks

Length of output: 299


Assert the single-completion contract.

Add expect(onComplete).toHaveBeenCalledTimes(1) so the test detects duplicate callbacks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/react/src/components/primitives/OtpField/__tests__/OtpField.test.tsx`
around lines 104 - 111, Add a call-count assertion to the `calls onComplete once
every box is filled` test, verifying `onComplete` is invoked exactly once after
pasting the complete OTP while preserving the existing argument assertion.

});
6 changes: 6 additions & 0 deletions packages/vue/src/components/factories/FieldFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export interface FieldConfig {
disabled?: boolean;
error?: string;
label: string;
length?: number;
name: string;
numericOnly?: boolean;
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Expose and forward the OTP options from FieldFactory.

FieldConfig and createField support length and numericOnly, but the FieldFactory component does not declare either prop or pass either value to createField at Lines 224-238. Component callers therefore receive the default length of 6 and numeric-only mode. This breaks configured alphanumeric and non-six-digit OTP flows.

Forward the props through the component wrapper
 interface FieldFactorySetupProps {
   className?: string;
   disabled: boolean;
   error?: string;
   label: string;
+  length?: number;
   name: string;
+  numericOnly: boolean;
   options: SelectOption[];
   placeholder?: string;
   required: boolean;
   touched: boolean;
   type: FieldType;
   value: string;
 }

   props: {
     className: {default: undefined, type: String},
     disabled: {default: false, type: Boolean},
     error: {default: undefined, type: String},
     label: {required: true, type: String},
+    length: {default: undefined, type: Number},
     name: {required: true, type: String},
+    numericOnly: {default: true, type: Boolean},
     options: {default: () => [], type: Array as PropType<SelectOption[]>},
     placeholder: {default: undefined, type: String},
     required: {default: false, type: Boolean},
     touched: {default: false, type: Boolean},
     type: {required: true, type: String as PropType<FieldType>},
     value: {default: '', type: String},
   },

       createField({
         className: props.className,
         disabled: props.disabled,
         error: props.error,
         label: props.label,
+        length: props.length,
         name: props.name,
+        numericOnly: props.numericOnly,
         onBlur: () => emit('blur'),

This follows the PR objective that Vue field configuration passes OTP length and numeric-only settings into the OTP field.

Also applies to: 98-99, 159-160

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vue/src/components/factories/FieldFactory.ts` around lines 21 - 23,
Update the FieldFactory component props and its createField invocation to
declare and forward both length and numericOnly from component configuration.
Preserve these values when creating OTP fields so configured lengths and
alphanumeric behavior reach the existing FieldConfig/createField implementation
instead of falling back to defaults.

onBlur?: () => void;
onChange: (value: string) => void;
options?: SelectOption[];
Expand Down Expand Up @@ -93,6 +95,8 @@ export const createField = (config: FieldConfig): VNode => {
options = [],
touched = false,
placeholder,
length,
numericOnly = true,
} = config;

const validationError: string | null | undefined = error || validateFieldValue(value, type, required, touched);
Expand Down Expand Up @@ -152,6 +156,8 @@ export const createField = (config: FieldConfig): VNode => {
case FieldType.Otp:
return h(OtpField, {
...commonProps,
...(length ? {length} : {}),
numericOnly,
'onUpdate:modelValue': onChange,
} as Record<string, unknown>);

Expand Down
14 changes: 12 additions & 2 deletions packages/vue/src/components/primitives/OtpField/OtpField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
import {withVendorCSSClassPrefix} from '@thunderid/browser';
import {type Component, type Ref, type SetupContext, type VNode, defineComponent, h, nextTick, ref} from 'vue';

// Alphanumeric OTPs are minted from an uppercase charset, so the accepted characters are digits
// and uppercase letters only.
const NON_NUMERIC_OTP_CHARS = /[^0-9]/g;
const NON_ALPHANUMERIC_OTP_CHARS = /[^0-9A-Z]/g;

type OtpFieldProps = Readonly<{
disabled: boolean;
error: string | undefined;
label: string | undefined;
length: number;
modelValue: string;
name: string | undefined;
numericOnly: boolean;
required: boolean;
}>;

Expand All @@ -23,6 +29,7 @@ const OtpField: Component = defineComponent({
length: {default: 6, type: Number},
modelValue: {default: '', type: String},
name: {default: undefined, type: String},
numericOnly: {default: true, type: Boolean},
required: {default: false, type: Boolean},
},
emits: ['update:modelValue'],
Expand All @@ -35,7 +42,10 @@ const OtpField: Component = defineComponent({

const handleInput = (index: number, e: Event): void => {
const target: HTMLInputElement = e.target as HTMLInputElement;
const val: string = target.value.replace(/\D/g, '').slice(0, 1);
// Alphanumeric codes are minted from an uppercase charset and verified case-sensitively.
const val: string = props.numericOnly
? target.value.replace(NON_NUMERIC_OTP_CHARS, '').slice(0, 1)
: target.value.toUpperCase().replace(NON_ALPHANUMERIC_OTP_CHARS, '').slice(0, 1);
target.value = val;

const current: string[] = (props.modelValue || '').split('');
Expand Down Expand Up @@ -79,7 +89,7 @@ const OtpField: Component = defineComponent({
'aria-label': `Digit ${i + 1}`,
class: withVendorCSSClassPrefix('otp-field__digit'),
disabled: props.disabled,
inputmode: 'numeric',
inputmode: props.numericOnly ? 'numeric' : 'text',
key: i,
maxlength: 1,
onInput: (e: Event) => handleInput(i, e),
Expand Down
Loading