payload-totp
Version:
Add an extra security layer to PayloadCMS using a Time-based One-time Password (TOTP).
205 lines (204 loc) • 7.58 kB
JavaScript
/* eslint-disable no-restricted-exports */ 'use client';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useCallback, useRef } from 'react';
import { cn } from '../../utilities/cn.js';
import styles from './index.module.scss';
export default function OTPInput({ name, disabled, length = 6, onFilled }) {
const inputs = useRef(Array(length).fill(null));
const hiddenInput = useRef(null);
const isFilled = hiddenInput.current?.value.length === length;
const moveToPrev = (currentIndex)=>{
if (currentIndex > 0) {
const prevInput = inputs.current[currentIndex - 1];
if (prevInput) {
focusAndSelectInput(prevInput);
}
}
};
const moveToNext = useCallback((currentIndex)=>{
if (currentIndex < length - 1) {
const nextInput = inputs.current[currentIndex + 1];
if (nextInput) {
focusAndSelectInput(nextInput);
}
}
}, [
length
]);
const updateInputValue = useCallback(()=>{
if (hiddenInput.current) {
const value = inputs.current.filter(Boolean).map((e)=>e.value).join('');
hiddenInput.current.value = value;
if (onFilled && value.length === length) {
onFilled(value);
}
}
}, [
onFilled,
length
]);
const onInput = (event, index)=>{
const nativeEvent = event.nativeEvent;
if (nativeEvent.inputType === 'deleteContentBackward') {
moveToPrev(index);
} else if (nativeEvent.inputType === 'insertText' || nativeEvent.inputType === 'deleteContentForward') {
moveToNext(index);
}
updateInputValue();
};
const onKeyDown = useCallback((event, index)=>{
if (event.nativeEvent.altKey || event.ctrlKey || event.metaKey) {
return;
}
const target = event.target;
const isDigitKey = /^\d$/.test(event.key);
const isNumericKey = isDigitKey || event.code?.startsWith('Digit') || event.code?.startsWith('Numpad') || event.key === 'Unidentified';
switch(event.key){
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
break;
case 'ArrowLeft':
moveToPrev(index);
event.preventDefault();
break;
case 'ArrowRight':
moveToNext(index);
event.preventDefault();
break;
case 'Backspace':
if (target.value.length === 0) {
moveToPrev(index);
updateInputValue();
event.preventDefault();
} else if (target.selectionStart === 0) {
target.value = '';
moveToPrev(index);
updateInputValue();
event.preventDefault();
}
break;
default:
if (!isNumericKey) {
event.preventDefault();
}
if (isDigitKey && target.value.length >= 1) {
event.preventDefault();
target.value = event.key;
updateInputValue();
moveToNext(index);
}
break;
}
}, [
moveToNext,
updateInputValue
]);
const onPaste = useCallback((event)=>{
event.preventDefault();
if (!disabled) {
let paste = event.clipboardData.getData('text').trim().split('').filter((ch)=>!isNaN(parseInt(ch)));
if (paste.length > length) {
paste = paste.slice(0, length);
}
paste.forEach((char, index)=>{
const el = inputs.current[index];
if (el) {
el.value = char;
}
});
focusAndSelectInput(inputs.current[paste.length - 1]);
updateInputValue();
}
}, [
length,
disabled,
updateInputValue
]);
const onHiddenInput = useCallback((event)=>{
if (disabled) {
return;
}
const rawValue = event.currentTarget.value;
let sanitizedValue = rawValue.trim().split('').filter((ch)=>!isNaN(parseInt(ch)));
if (sanitizedValue.length > length) {
sanitizedValue = sanitizedValue.slice(0, length);
}
sanitizedValue.forEach((char, index)=>{
const el = inputs.current[index];
if (el) {
el.value = char;
}
});
focusAndSelectInput(inputs.current[sanitizedValue.length - 1]);
if (onFilled && sanitizedValue.length === length) {
onFilled(sanitizedValue.join(''));
}
}, [
length,
disabled,
onFilled
]);
return /*#__PURE__*/ _jsxs(_Fragment, {
children: [
/*#__PURE__*/ _jsx("input", {
autoComplete: "one-time-code",
inputMode: "numeric",
name: name,
onInput: (e)=>onHiddenInput(e),
ref: hiddenInput,
style: {
border: 0,
clip: 'rect(0,0,0,0)',
height: '1px',
margin: '-1px',
overflow: 'hidden',
padding: 0,
position: 'absolute',
width: '1px'
}
}),
/*#__PURE__*/ _jsxs("div", {
className: cn(styles.root, disabled && styles.disabled),
children: [
Array.from({
length
}, (_, index)=>index).map((i)=>// eslint-disable-next-line jsx-a11y/control-has-associated-label
/*#__PURE__*/ _jsx("input", {
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus: i === 0,
// Tell 1password to ignore this input
// https://developer.1password.com/docs/web/compatible-website-design
"data-1p-ignore": true,
disabled: disabled,
inputMode: "numeric",
maxLength: 1,
onInput: (e)=>onInput(e, i),
onKeyDown: (e)=>onKeyDown(e, i),
onPaste: (e)=>onPaste(e),
ref: (el)=>{
if (el) {
inputs.current[i] = el;
}
},
type: "text"
}, i)),
/*#__PURE__*/ _jsx("div", {
className: styles.focus
}),
/*#__PURE__*/ _jsx("div", {
className: cn(styles.glow, isFilled && styles.expandGlow),
children: Array.from({
length
}, (_, index)=>index).map((i)=>/*#__PURE__*/ _jsx("div", {}, i))
})
]
})
]
});
}
function focusAndSelectInput(element) {
element.focus();
element.select();
}
//# sourceMappingURL=index.js.map