Add (re)generate username and password controls (#900)

This commit is contained in:
Leendert de Borst
2025-06-11 11:48:29 +02:00
committed by Leendert de Borst
parent a93a7f7fff
commit 3d8c2b7086
3 changed files with 219 additions and 20 deletions

View File

@@ -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 (
<>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</>
);
case 'visibility-off':
return (
<>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</>
);
case 'refresh':
return (
<>
<path d="M23 4v6h-6" />
<path d="M1 20v-6h6" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</>
);
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<HTMLInputElement, FormInputProps>(({
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 (
<div>
@@ -64,14 +143,21 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(({
className={inputClasses}
/>
)}
{type === 'password' && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<button
onClick={() => setShowPassword(!showPassword)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 transition-colors duration-200"
>
{showPassword ? 'Hide' : 'Show'}
</button>
{allButtons.length > 0 && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
{allButtons.map((button, index) => (
<button
type="button"
key={index}
onClick={button.onClick}
title={button.title}
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name={button.icon} />
</svg>
</button>
))}
</div>
)}
</div>

View File

@@ -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 (
<>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</>
);
case 'visibility-off':
return (
<>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</>
);
case 'copy':
return (
<>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</>
);
case 'check':
return (
<>
<polyline points="20 6 9 17 4 12" />
</>
);
default:
return null;
}
};
/**
* Form input copy to clipboard component.
*/
@@ -71,17 +108,38 @@ export const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> =
} 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`}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
{copied && (
<span className="text-green-500 dark:text-green-400">
Copied!
</span>
{copied ? (
<button
type="button"
className="p-1 text-green-500 dark:text-green-400 transition-colors duration-200"
title="Copied!"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name="check" />
</svg>
</button>
) : (
<button
type="button"
onClick={copyToClipboard}
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
title="Copy to clipboard"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name="copy" />
</svg>
</button>
)}
{type === 'password' && (
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
className="p-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200"
title={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? 'Hide' : 'Show'}
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<Icon name={showPassword ? 'visibility-off' : 'visibility'} />
</svg>
</button>
)}
</div>

View File

@@ -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<HTMLInputElement>(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'
}`}
>
<svg className='w-5 h-5' viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg className='w-5 h-5' viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8" cy="8" r="1"/>
<circle cx="16" cy="8" r="1"/>
@@ -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'
}
]}
/>
<FormInput
id="password"
@@ -404,6 +450,15 @@ const CredentialAddEdit: React.FC = () => {
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'
}
]}
/>
<button
type="button"