jam/src/components/utils/PasswordVerificationForm.tsx
Parth d66f5e9c82
Some checks failed
Build / build (v26.3.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
chore(ui): improve mobile responsiveness (#1292)
2026-07-05 15:54:37 +02:00

145 lines
4.8 KiB
TypeScript

import { useState, useMemo } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import type { TFunction } from 'i18next'
import { EyeIcon, EyeOffIcon } from 'lucide-react'
import { useForm, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
import { hashPassword } from '@/lib/hash'
import { cn, debounce, type WalletFileName } from '@/lib/utils'
import { Field, FieldLabel } from '../ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
import { Spinner } from '../ui/spinner'
type PasswordVerificationFormValues = {
password: string
}
const debouncedHashPassword = debounce(hashPassword, 210)
type PasswordVerificationI18nKeyPrefix = 'settings.seed_modal.verification' | 'settings.xpubs_modal.verification'
const passwordVerificationFormSchema = (
walletFileName: WalletFileName,
hashedPassword: string,
t: TFunction,
i18nKeyPrefix: PasswordVerificationI18nKeyPrefix,
) => {
return yup
.object({
password: yup
.string()
.required()
.test('verify-password-test', async (value) => {
try {
const hashed = await debouncedHashPassword(value, walletFileName)
if (hashedPassword !== hashed) {
const errorMessage = t(`${i18nKeyPrefix}.text_error_password_incorrect`)
return new yup.ValidationError(errorMessage, undefined, 'password', undefined, true)
}
return true
} catch (error: unknown) {
const reason = (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown')
const errorMessage = t(`${i18nKeyPrefix}.text_error`, { reason })
throw new yup.ValidationError(errorMessage, undefined, 'password', undefined, true)
}
}),
})
.required()
}
type PasswordVerificationFormProps = {
walletFileName: WalletFileName
hashedPassword: string
onSubmit: SubmitHandler<PasswordVerificationFormValues>
onCancel?: () => void | Promise<void>
className?: string
disabled?: boolean
i18nKeyPrefix: PasswordVerificationI18nKeyPrefix
}
export const PasswordVerificationForm = ({
walletFileName,
onSubmit,
hashedPassword,
disabled,
className,
onCancel,
i18nKeyPrefix,
}: PasswordVerificationFormProps) => {
const { t } = useTranslation()
const [showPassword, setShowPassword] = useState(false)
const schema = useMemo(() => {
return passwordVerificationFormSchema(walletFileName, hashedPassword, t, i18nKeyPrefix)
}, [walletFileName, hashedPassword, t, i18nKeyPrefix])
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
mode: 'onSubmit',
reValidateMode: 'onSubmit',
resolver: yupResolver(schema),
})
const doOnSubmit = handleSubmit(onSubmit)
return (
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-2">
<Field data-invalid={errors.password !== undefined}>
<FieldLabel htmlFor="password-verification-input-password">{t(`${i18nKeyPrefix}.label_password`)}</FieldLabel>
<InputGroup>
<InputGroupInput
id="password-verification-input-password"
{...register('password', {
required: true,
disabled,
})}
type={showPassword ? 'text' : 'password'}
autoComplete="off"
placeholder={t(`${i18nKeyPrefix}.placeholder_password`)}
/>
<InputGroupAddon align="inline-end">
<Button
tabIndex={-1}
type="button"
variant="link"
size="icon"
onClick={() => setShowPassword((val) => !val)}
>
{showPassword ? <EyeIcon /> : <EyeOffIcon />}
</Button>
</InputGroupAddon>
</InputGroup>
</Field>
{errors.password?.message && <div className="text-destructive text-xs">{errors.password.message}</div>}
</div>
<Field
orientation="horizontal"
className="flex-col items-stretch justify-end sm:flex-row sm:items-center [&>*]:w-full sm:[&>*]:w-auto"
>
{onCancel !== undefined && (
<Button type="button" variant="outline" onClick={() => void onCancel()}>
{t('global.cancel')}
</Button>
)}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Spinner className="motion-reduce:hidden" />
{t(`${i18nKeyPrefix}.text_button_submitting`)}
</>
) : (
t(`${i18nKeyPrefix}.text_button_submit`)
)}
</Button>
</Field>
</form>
)
}