diff --git a/apps/browser-extension/src/entrypoints/popup/components/FormInput.tsx b/apps/browser-extension/src/entrypoints/popup/components/FormInput.tsx index c64c12a2c..6b706ea72 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/FormInput.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/FormInput.tsx @@ -1,5 +1,46 @@ import React, { forwardRef } from 'react'; +/** + * Button configuration for form input. + */ +type FormInputButton = { + icon: string; + onClick: () => void; + title?: string; +} + +/** + * Icon component for form input buttons. + */ +const Icon: React.FC<{ name: string }> = ({ name }) => { + switch (name) { + case 'visibility': + return ( + <> + + + + ); + case 'visibility-off': + return ( + <> + + + + ); + case 'refresh': + return ( + <> + + + + + ); + default: + return null; + } +}; + /** * Form input props. */ @@ -14,6 +55,9 @@ type FormInputProps = { multiline?: boolean; rows?: number; error?: string; + buttons?: FormInputButton[]; + showPassword?: boolean; + onShowPasswordChange?: (show: boolean) => void; } /** @@ -29,13 +73,48 @@ export const FormInput = forwardRef(({ required = false, multiline = false, rows = 1, - error + error, + buttons = [], + showPassword: controlledShowPassword, + onShowPasswordChange }, ref) => { - const [showPassword, setShowPassword] = React.useState(false); + const [internalShowPassword, setInternalShowPassword] = React.useState(false); + + /** + * Use controlled or uncontrolled showPassword state. + * If controlledShowPassword is provided, use that value and call onShowPasswordChange. + * Otherwise, use internal state. + */ + const showPassword = controlledShowPassword !== undefined ? controlledShowPassword : internalShowPassword; + + /** + * Set the showPassword state. + * If controlledShowPassword is provided, use that value and call onShowPasswordChange. + * Otherwise, use internal state. + */ + const setShowPassword = (value: boolean): void => { + if (controlledShowPassword !== undefined) { + onShowPasswordChange?.(value); + } else { + setInternalShowPassword(value); + } + }; const inputClasses = `mt-1 block w-full rounded-md ${ error ? 'border-red-500' : 'border-gray-300 dark:border-gray-700' - } text-gray-900 sm:text-sm rounded-lg shadow-sm border focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 p-3`; + } text-gray-900 sm:text-sm rounded-lg shadow-sm border focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400 py-2 px-3`; + + // Add password visibility button if type is password + const allButtons = type === 'password' + ? [...buttons, { + icon: showPassword ? 'visibility-off' : 'visibility', + /** + * Toggle password visibility. + */ + onClick: (): void => setShowPassword(!showPassword), + title: showPassword ? 'Hide password' : 'Show password' + }] + : buttons; return (
@@ -64,14 +143,21 @@ export const FormInput = forwardRef(({ className={inputClasses} /> )} - {type === 'password' && ( -
- + {allButtons.length > 0 && ( +
+ {allButtons.map((button, index) => ( + + ))}
)}
diff --git a/apps/browser-extension/src/entrypoints/popup/components/FormInputCopyToClipboard.tsx b/apps/browser-extension/src/entrypoints/popup/components/FormInputCopyToClipboard.tsx index 91daaf978..5aae6cfc9 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/FormInputCopyToClipboard.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/FormInputCopyToClipboard.tsx @@ -14,6 +14,43 @@ type FormInputCopyToClipboardProps = { const clipboardService = new ClipboardCopyService(); +/** + * Icon component for form input buttons. + */ +const Icon: React.FC<{ name: string }> = ({ name }) => { + switch (name) { + case 'visibility': + return ( + <> + + + + ); + case 'visibility-off': + return ( + <> + + + + ); + case 'copy': + return ( + <> + + + + ); + case 'check': + return ( + <> + + + ); + default: + return null; + } +}; + /** * Form input copy to clipboard component. */ @@ -71,17 +108,38 @@ export const FormInputCopyToClipboard: React.FC = } text-gray-900 sm:text-sm rounded-lg shadow-sm focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:placeholder-gray-400`} />
- {copied && ( - - Copied! - + {copied ? ( + + ) : ( + )} {type === 'password' && ( )}
diff --git a/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx index 1a633c049..2e6ab0859 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/CredentialAddEdit.tsx @@ -14,7 +14,7 @@ import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsConte import { useWebApi } from '@/entrypoints/popup/context/WebApiContext'; import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate'; -import { IdentityHelperUtils, CreateIdentityGenerator } from '@/utils/shared/identity-generator'; +import { IdentityHelperUtils, CreateIdentityGenerator, CreateUsernameEmailGenerator, Identity, Gender } from '@/utils/shared/identity-generator'; import type { Credential } from '@/utils/shared/models/vault'; import { CreatePasswordGenerator } from '@/utils/shared/password-generator'; @@ -66,6 +66,7 @@ const CredentialAddEdit: React.FC = () => { const { setHeaderButtons } = useHeaderButtons(); const { setIsInitialLoading } = useLoading(); const [localLoading, setLocalLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); const webApi = useWebApi(); const serviceNameRef = useRef(null); @@ -175,7 +176,7 @@ const CredentialAddEdit: React.FC = () => { const identity = identityGenerator.generateRandomIdentity(); const password = passwordGenerator.generateRandomPassword(); - const metadata = await dbContext!.getVaultMetadata(); + const metadata = await dbContext.getVaultMetadata(); const privateEmailDomains = metadata?.privateEmailDomains ?? []; const publicEmailDomains = metadata?.publicEmailDomains ?? []; @@ -212,6 +213,44 @@ const CredentialAddEdit: React.FC = () => { void generateRandomAlias(); }, [generateRandomAlias]); + const generateRandomUsername = useCallback(async () => { + try { + const usernameEmailGenerator = CreateUsernameEmailGenerator(); + + let gender = Gender.Other; + try { + gender = watch('Alias.Gender') as Gender; + } catch { + // Gender parsing failed, default to other. + } + + const identity: Identity = { + firstName: watch('Alias.FirstName') ?? '', + lastName: watch('Alias.LastName') ?? '', + nickName: watch('Alias.NickName') ?? '', + gender: gender, + birthDate: new Date(watch('Alias.BirthDate') ?? ''), + emailPrefix: watch('Alias.Email') ?? '', + }; + + const username = usernameEmailGenerator.generateUsername(identity); + setValue('Username', username); + } catch (error) { + console.error('Error generating random username:', error); + } + }, [setValue, watch]); + + const generateRandomPassword = useCallback(async () => { + try { + const { passwordGenerator } = await initializeGenerators(); + const password = passwordGenerator.generateRandomPassword(); + setValue('Password', password); + setShowPassword(true); + } catch (error) { + console.error('Error generating random password:', error); + } + }, [initializeGenerators, setValue]); + /** * Handle form submission. */ @@ -336,7 +375,7 @@ const CredentialAddEdit: React.FC = () => { mode === 'random' ? 'bg-primary-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' }`} > - + @@ -396,6 +435,13 @@ const CredentialAddEdit: React.FC = () => { value={watch('Username') ?? ''} onChange={(value) => setValue('Username', value)} error={errors.Username?.message} + buttons={[ + { + icon: 'refresh', + onClick: generateRandomUsername, + title: 'Generate random username' + } + ]} /> { value={watch('Password') ?? ''} onChange={(value) => setValue('Password', value)} error={errors.Password?.message} + showPassword={showPassword} + onShowPasswordChange={setShowPassword} + buttons={[ + { + icon: 'refresh', + onClick: generateRandomPassword, + title: 'Generate random password' + } + ]} />