@desolint/otp-client
v0.0.1
Published
React OTP provider, hooks and inputs for Desol Int. projects
Readme
@desolint/otp-client
A themeable OTP verification flow for React: OtpCard for a complete screen out of the box, or
headless primitives (OtpInputGroup, useOtpFlow) when you need full control over the UI. Ships
ESM and CJS.
Requirements
- React 18 or newer and react-hook-form (both peer dependencies)
- npm 7 or newer — npm 7+ installs peer dependencies automatically
Install
npm install @desolint/otp-clientreact 19.x and react-hook-form 7.x are required peer dependencies — the package uses
React 19 style hoisting to ship its default design with the components, with no separate CSS
import (see Styling below), and OtpInput/OtpInputGroup/OtpCard bind to
react-hook-form's Controller/useForm. @desolint/otp-shared is pulled in automatically.
Quick start
Components ship their own default styling — no CSS import needed.
Wrap your app in OtpFlowProvider. navigate is called when a flow needs to show the verify
screen as a page — use renderModal instead for a modal flow:
import {OtpFlowProvider} from '@desolint/otp-client';
import {useCallback} from 'react';
async function resendOtp({
payload,
token,
}: {
payload?: unknown;
token: string | null;
}) {
const res = await fetch('/api/otp/issue', {
method: 'POST',
// forward flowToken if your endpoint needs it for a resend cooldown check
body: JSON.stringify({...(payload as object), flowToken: token}),
});
return res.json(); // only `token` is used — the deadlines get recomputed from it
}
const REGENERATE = {otp: resendOtp};
function Providers({children}: {children: React.ReactNode}) {
const router = useRouter();
const navigate = useCallback(() => router.push('/verify-otp'), [router]);
return (
<OtpFlowProvider navigate={navigate} regenerate={REGENERATE}>
{children}
</OtpFlowProvider>
);
}Kick off a flow from your login form. submit reports that a code is required by calling
reportOtpRequired:
import {useOtpFlow} from '@desolint/otp-client';
function LoginForm() {
const router = useRouter();
const {startFlow, reportOtpRequired} = useOtpFlow();
const onSubmit = (values: {userId: string}) =>
startFlow({
submit: async (payload) => {
const res = await fetch('/api/otp/issue', {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await res.json();
reportOtpRequired({token: data.token, requiredChannels: data.channels});
return data;
},
payload: values,
viewType: 'page',
regenerateType: 'otp', // must match a key in `regenerate` above
callbacks: {onSuccess: () => router.push('/dashboard')},
});
// ...render your form, call onSubmit on submit
}Render the verify screen:
import {OtpCard} from '@desolint/otp-client';
export default function VerifyOtpPage() {
return <OtpCard heading='Verify code' />;
}That's a complete flow — issuing, the countdown, submit, resend, and error display all come from
OtpCard and the provider. OtpCard also takes an optional OTP charset and a side image.
For a code that mixes letters and digits (e.g. issued with
@desolint/otp-server's generateOtpCode({charset: 'alphanumeric'})), pass
the matching charset so the input accepts letters too:
<OtpCard heading='Verify code' charset='alphanumeric' />charset is 'numeric' (default), 'alpha', or 'alphanumeric' — a
convenience preset that resolves to isCharAllowed; pass isCharAllowed
directly for anything more specific.
Customization
If your screen needs its own layout, copy, or components instead of OtpCard's, compose it from
the same primitives OtpCard is built on:
import {OtpInputGroup, useOtpCountdown, useOtpFlow} from '@desolint/otp-client';
import {useForm} from 'react-hook-form';
function VerifyOtpPage() {
const {session, submitCode} = useOtpFlow();
const {control, handleSubmit} = useForm();
return (
<form onSubmit={handleSubmit(submitCode)}>
<OtpInputGroup
control={control}
fields={session.requiredChannels.map((channel) => ({name: channel}))}
/>
<Countdown
expiresAt={session.expiresAt}
regenerateAllowedAfter={session.regenerateAllowedAfter}
/>
<button type='submit'>Submit</button>
</form>
);
}
// Isolated so its per-second tick re-renders only this, not the form above it.
function Countdown(props: {expiresAt: number; regenerateAllowedAfter: number}) {
const {secondsUntilExpiry} = useOtpCountdown(props);
return <span>Expires in {secondsUntilExpiry}s</span>;
}This has no dependency on OtpCard's config — it's built from the same primitives OtpCard
uses internally.
Styling
The default design ships with the components (React 19 style hoisting) — no import required. Every component is styled through CSS custom properties, not Tailwind utilities baked into the package — override what you need, once:
:root {
--otp-color-primary: #6d28d9;
--otp-card-radius: 1rem;
}For sub-part-level control, components also accept *Classes props (groupClasses,
slotClasses, headingClasses, …) merged onto their defaults via className.
@desolint/otp-client/styles.css is still published, for two cases: controlling where the
@layer otp rules fall in your own cascade, or a host with a strict CSP that blocks the
injected <style> tag.
Development
npm install # install dependencies (from the repo root)
npm run build # build all three packages
npm test # type-check + jest
npm run lint # eslintThis package is part of the package-otp workspaces monorepo — run the commands
from the repository root, not this directory.
License
MIT © Desolint — see LICENSE.
