diff --git a/src/components/Form/StringField/StringField.stories.tsx b/src/components/Form/StringField/StringField.stories.tsx index 8142e5b..d908d99 100644 --- a/src/components/Form/StringField/StringField.stories.tsx +++ b/src/components/Form/StringField/StringField.stories.tsx @@ -5,13 +5,52 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { StringField } from './StringField.tsx'; import type { StringFieldComboBoxProps } from './StringFieldComboBox.tsx'; -import type { PasswordStrengthValue } from './StringFieldPassword.tsx'; +import type { PasswordStrengthValue, StringFieldPasswordProps } from './StringFieldPassword.tsx'; type Story = StoryObj; /** Typed to the combobox props so `allowCustomValue`, which the other variants lack, is readable in the decorator */ type ComboBoxStory = StoryObj; +/** Typed to the password props so `generatePassword`, which the other variants lack, may be passed */ +type PasswordStory = StoryObj; + +/** Exactly 32 words, so that reducing a random byte modulo the list length stays uniform */ +const PASSPHRASE_WORDS = [ + 'anchor', + 'bramble', + 'cinder', + 'cobalt', + 'dapple', + 'drift', + 'ember', + 'fathom', + 'flint', + 'gable', + 'garnet', + 'harbor', + 'hollow', + 'ivory', + 'jasper', + 'kindle', + 'lantern', + 'marble', + 'meadow', + 'nectar', + 'opal', + 'pebble', + 'quartz', + 'ripple', + 'saffron', + 'thistle', + 'timber', + 'umber', + 'velvet', + 'willow', + 'yonder', + 'zephyr' +]; + export default { component: StringField } as Meta; export const Short: Story = { @@ -98,6 +137,34 @@ export const PasswordWithStrength: Story = { ] }; +export const PasswordWithPassphraseGenerator: PasswordStory = { + decorators: [ + (Story) => { + const [value, setValue] = useState(); + return ( + { + return Math.min(password.length, 4) as PasswordStrengthValue; + }, + description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.', + generatePassword: () => + Array.from( + crypto.getRandomValues(new Uint8Array(4)), + (byte) => PASSPHRASE_WORDS[byte % PASSPHRASE_WORDS.length] + ).join('-'), + label: 'Password', + name: 'text', + setValue, + value, + variant: 'password' + }} + /> + ); + } + ] +}; + export const Select: Story = { decorators: [ (Story) => { diff --git a/src/components/Form/StringField/StringFieldPassword.spec.tsx b/src/components/Form/StringField/StringFieldPassword.spec.tsx new file mode 100644 index 0000000..0212838 --- /dev/null +++ b/src/components/Form/StringField/StringFieldPassword.spec.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; + +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { StringFieldPassword } from './StringFieldPassword.tsx'; + +import type { StringFieldPasswordProps } from './StringFieldPassword.tsx'; + +const TestStringFieldPassword = ({ + generatePassword, + readOnly +}: Pick) => { + const [error, setError] = useState(); + const [value, setValue] = useState(); + return ( + + ); +}; + +describe('StringFieldPassword', () => { + const getInput = () => screen.getByLabelText('password-field'); + const getGenerateButton = () => screen.getByRole('button', { name: 'Generate Passphrase' }); + + it('should not render the generate button when generatePassword is not provided', () => { + render(); + expect(() => getGenerateButton()).toThrow(); + }); + + it('should fill the field with the generated passphrase when the button is clicked', async () => { + render( 'hunter2'} />); + await userEvent.click(getGenerateButton()); + expect(getInput()).toHaveValue('hunter2'); + }); + + it('should reveal the generated passphrase', async () => { + render( 'hunter2'} />); + expect(getInput()).toHaveAttribute('type', 'password'); + await userEvent.click(getGenerateButton()); + expect(getInput()).toHaveAttribute('type', 'text'); + }); + + it('should allow reaching the generate button with the keyboard', async () => { + render( 'hunter2'} />); + getInput().focus(); + await userEvent.tab(); + expect(getGenerateButton()).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + expect(getInput()).toHaveValue('hunter2'); + }); + + it('should disable the generate button when the field is read-only', () => { + render( 'hunter2'} />); + expect(getGenerateButton()).toBeDisabled(); + }); +}); diff --git a/src/components/Form/StringField/StringFieldPassword.tsx b/src/components/Form/StringField/StringFieldPassword.tsx index b393692..1d1a4c0 100644 --- a/src/components/Form/StringField/StringFieldPassword.tsx +++ b/src/components/Form/StringField/StringFieldPassword.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; import type { StringFormField } from '@douglasneuroinformatics/libui-form-types'; -import { EyeIcon, EyeOffIcon } from 'lucide-react'; +import { EyeIcon, EyeOffIcon, SparklesIcon } from 'lucide-react'; import { motion } from 'motion/react'; -import { Input, Label } from '#components'; +import { Input, Label, Tooltip } from '#components'; +import { useTranslation } from '#hooks'; import { cn } from '#utils'; import { FieldGroup } from '../FieldGroup/FieldGroup.tsx'; @@ -14,13 +15,23 @@ import type { BaseFieldComponentProps } from '../types.ts'; export type PasswordStrengthValue = 0 | 1 | 2 | 3 | 4; export type StringFieldPasswordProps = BaseFieldComponentProps & - Extract; + Extract & { + /** + * A function used to generate a passphrase for this field. When provided, a button is rendered + * in the input that fills the field with the returned value and reveals it. + * + * Declared here rather than coming from `StringFormField`, and should be removed once the + * form types package publishes it on the password variant. + */ + generatePassword?: (this: void) => string; + }; export const StringFieldPassword = ({ calculateStrength, description, disabled, error, + generatePassword, label, name, readOnly, @@ -29,6 +40,7 @@ export const StringFieldPassword = ({ }: StringFieldPasswordProps) => { const [strength, setStrength] = useState(calculateStrength ? 0 : null); const [show, setShow] = useState(false); + const { t } = useTranslation(); useEffect(() => { if (calculateStrength) { setStrength(value ? calculateStrength(value) : 0); @@ -43,6 +55,7 @@ export const StringFieldPassword = ({ setValue(event.target.value)} /> + {generatePassword && ( + + { + setValue(generatePassword()); + // A generated passphrase the user cannot read is of little use, so reveal it. + setShow(true); + }} + > + + + +

{t({ en: 'Generate a passphrase', fr: 'Générer une phrase de passe' })}

+
+
+ )}