Add separate password field component with password length slider (#883)

This commit is contained in:
Leendert de Borst
2025-07-28 09:16:22 +02:00
committed by Leendert de Borst
parent d43efb0273
commit b2177f5d98
5 changed files with 455 additions and 26 deletions

View File

@@ -37,6 +37,13 @@ const Icon: React.FC<{ name: string }> = ({ name }) => {
<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" />
</>
);
case 'settings':
return (
<>
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</>
);
default:
return null;
}

View File

@@ -0,0 +1,218 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { PasswordSettings } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
interface IPasswordConfigDialogProps {
isOpen: boolean;
onClose: () => void;
onSave: (password: string) => void;
onSettingsChange?: (settings: PasswordSettings) => void;
initialSettings: PasswordSettings;
}
/**
* Password configuration dialog component.
*/
const PasswordConfigDialog: React.FC<IPasswordConfigDialogProps> = ({
isOpen,
onClose,
onSave,
onSettingsChange,
initialSettings
}) => {
const { t } = useTranslation();
const [settings, setSettings] = useState<PasswordSettings>(initialSettings);
const [previewPassword, setPreviewPassword] = useState<string>('');
const generatePreview = useCallback((currentSettings: PasswordSettings) => {
try {
const passwordGenerator = CreatePasswordGenerator(currentSettings);
const password = passwordGenerator.generateRandomPassword();
setPreviewPassword(password);
} catch (error) {
console.error('Error generating preview password:', error);
setPreviewPassword('');
}
}, []);
// Initialize settings when dialog opens
useEffect(() => {
if (isOpen) {
setSettings({ ...initialSettings });
generatePreview({ ...initialSettings });
}
}, [isOpen, initialSettings, generatePreview]);
const handleSettingChange = useCallback((key: keyof PasswordSettings, value: boolean | number) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
generatePreview(newSettings);
onSettingsChange?.(newSettings);
}, [settings, generatePreview, onSettingsChange]);
const handleRefreshPreview = useCallback(() => {
generatePreview(settings);
}, [settings, generatePreview]);
const handleSave = useCallback(() => {
onSave(previewPassword);
onClose();
}, [previewPassword, onSave, onClose]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div className="fixed inset-0 bg-black bg-opacity-60 transition-opacity" onClick={handleCancel} />
{/* Modal */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all w-full max-w-lg">
{/* Close button */}
<button
type="button"
className="absolute right-4 top-4 text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={handleCancel}
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* Modal content */}
<div className="sm:flex sm:items-start">
<div className="w-full mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-white mb-4">
{t('credentials.advancedPasswordOptions')}
</h3>
<div className="space-y-4">
{/* Character Type Checkboxes */}
<div className="space-y-3">
<div className="flex items-center">
<input
id="use-lowercase"
type="checkbox"
checked={settings.UseLowercase}
onChange={(e) => handleSettingChange('UseLowercase', e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="use-lowercase" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
{t('credentials.includeLowercase')} (a-z)
</label>
</div>
<div className="flex items-center">
<input
id="use-uppercase"
type="checkbox"
checked={settings.UseUppercase}
onChange={(e) => handleSettingChange('UseUppercase', e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="use-uppercase" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
{t('credentials.includeUppercase')} (A-Z)
</label>
</div>
<div className="flex items-center">
<input
id="use-numbers"
type="checkbox"
checked={settings.UseNumbers}
onChange={(e) => handleSettingChange('UseNumbers', e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="use-numbers" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
{t('credentials.includeNumbers')} (0-9)
</label>
</div>
<div className="flex items-center">
<input
id="use-special-chars"
type="checkbox"
checked={settings.UseSpecialChars}
onChange={(e) => handleSettingChange('UseSpecialChars', e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="use-special-chars" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
{t('credentials.includeSpecialChars')} (!@#$%^&*)
</label>
</div>
<div className="flex items-center">
<input
id="use-non-ambiguous"
type="checkbox"
checked={settings.UseNonAmbiguousChars}
onChange={(e) => handleSettingChange('UseNonAmbiguousChars', e.target.checked)}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="use-non-ambiguous" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
{t('credentials.avoidAmbiguousChars')} (avoid 0, O, l, I, etc.)
</label>
</div>
</div>
{/* Password Preview */}
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('credentials.preview')}:
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={previewPassword}
readOnly
className="flex-1 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:text-white font-mono"
/>
<button
type="button"
onClick={handleRefreshPreview}
className="px-3 py-2 text-sm text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
title={t('credentials.generateNewPreview')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
</div>
</div>
{/* Action buttons */}
<div className="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-primary-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 sm:ml-3 sm:w-auto"
onClick={handleSave}
>
{t('common.use')}
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto dark:bg-gray-700 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-600"
onClick={handleCancel}
>
{t('common.cancel')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default PasswordConfigDialog;

View File

@@ -0,0 +1,204 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { PasswordSettings } from '@/utils/dist/shared/models/vault';
import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator';
import PasswordConfigDialog from './PasswordConfigDialog';
interface IPasswordFieldProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
error?: string;
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
initialSettings: PasswordSettings;
}
/**
* Password field component with inline length slider and advanced configuration.
*/
const PasswordField: React.FC<IPasswordFieldProps> = ({
id,
label,
value,
onChange,
placeholder,
error,
showPassword: controlledShowPassword,
onShowPasswordChange,
initialSettings
}) => {
const { t } = useTranslation();
const [internalShowPassword, setInternalShowPassword] = useState(false);
const [showConfigDialog, setShowConfigDialog] = useState(false);
const [currentSettings, setCurrentSettings] = useState<PasswordSettings>(initialSettings);
// Use controlled or uncontrolled showPassword state
const showPassword = controlledShowPassword !== undefined ? controlledShowPassword : internalShowPassword;
/**
* Set the showPassword state.
*/
const setShowPassword = useCallback((show: boolean): void => {
if (controlledShowPassword !== undefined) {
onShowPasswordChange?.(show);
} else {
setInternalShowPassword(show);
}
}, [controlledShowPassword, onShowPasswordChange]);
// Update settings when initial settings change
useEffect(() => {
setCurrentSettings({ ...initialSettings });
}, [initialSettings]);
const generatePassword = useCallback((settings: PasswordSettings) => {
try {
const passwordGenerator = CreatePasswordGenerator(settings);
const password = passwordGenerator.generateRandomPassword();
onChange(password);
setShowPassword(true);
} catch (error) {
console.error('Error generating password:', error);
}
}, [onChange, setShowPassword]);
const handleLengthChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const length = parseInt(e.target.value, 10);
const newSettings = { ...currentSettings, Length: length };
setCurrentSettings(newSettings);
// If there's already a password, regenerate it with the new length
if (value) {
generatePassword(newSettings);
}
}, [currentSettings, value, generatePassword]);
const handleRegeneratePassword = useCallback(() => {
generatePassword(currentSettings);
}, [generatePassword, currentSettings]);
const handleConfiguredPassword = useCallback((password: string) => {
onChange(password);
setShowPassword(true);
}, [onChange, setShowPassword]);
const handleAdvancedSettingsChange = useCallback((newSettings: PasswordSettings) => {
setCurrentSettings(newSettings);
}, []);
const togglePasswordVisibility = useCallback(() => {
setShowPassword(!showPassword);
}, [showPassword, setShowPassword]);
const openConfigDialog = useCallback(() => {
setShowConfigDialog(true);
}, []);
return (
<div className="space-y-2">
{/* Label */}
<label htmlFor={id} className="block text-sm font-medium text-gray-900 dark:text-white">
{label}
</label>
{/* Password Input with Buttons */}
<div className="flex">
<div className="relative flex-grow">
<input
type={showPassword ? 'text' : 'password'}
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="outline-0 shadow-sm bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-l-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
/>
</div>
<div className="flex">
{/* Show/Hide Password Button */}
<button
type="button"
onClick={togglePasswordVisibility}
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium text-sm dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
title={showPassword ? t('common.hidePassword') : t('common.showPassword')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{showPassword ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
) : (
<>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</>
)}
</svg>
</button>
{/* Generate Password Button */}
<button
type="button"
onClick={handleRegeneratePassword}
className="px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-r-lg text-sm border-l border-gray-300 dark:border-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"
title={t('credentials.generateRandomPassword')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
{/* Inline Password Length Slider */}
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<label htmlFor={`${id}-length`} className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('credentials.passwordLength')}
</label>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">
{currentSettings.Length}
</span>
</div>
<input
type="range"
id={`${id}-length`}
min="8"
max="64"
value={currentSettings.Length}
onChange={handleLengthChange}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
{/* Advanced Options Link */}
<div className="mt-2">
<button
type="button"
onClick={openConfigDialog}
className="text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline cursor-pointer bg-transparent border-none p-0"
>
{t('credentials.changePasswordComplexity')}
</button>
</div>
</div>
{/* Error Message */}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
{/* Advanced Configuration Dialog */}
<PasswordConfigDialog
isOpen={showConfigDialog}
onClose={() => setShowConfigDialog(false)}
onSave={handleConfiguredPassword}
onSettingsChange={handleAdvancedSettingsChange}
initialSettings={currentSettings}
/>
</div>
);
};
export default PasswordField;

View File

@@ -14,6 +14,8 @@ import HeaderButton from '@/entrypoints/popup/components/HeaderButton';
import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons';
import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner';
import Modal from '@/entrypoints/popup/components/Modal';
import PasswordField from '@/entrypoints/popup/components/PasswordField';
import UsernameField from '@/entrypoints/popup/components/UsernameField';
import { useDb } from '@/entrypoints/popup/context/DbContext';
import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsContext';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
@@ -377,16 +379,9 @@ const CredentialAddEdit: React.FC = () => {
}
}, [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]);
const getCurrentPasswordSettings = useCallback(() => {
return dbContext.sqliteClient!.getPasswordSettings();
}, [dbContext.sqliteClient]);
/**
* Handle form submission.
@@ -596,30 +591,16 @@ const CredentialAddEdit: React.FC = () => {
}
]}
/>
<FormInput
<PasswordField
id="password"
label={t('common.password')}
type="password"
value={watch('Password') ?? ''}
onChange={(value) => setValue('Password', value)}
error={errors.Password?.message}
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
buttons={[
{
icon: 'refresh',
onClick: generateRandomPassword,
title: t('credentials.generateRandomPassword')
}
]}
initialSettings={getCurrentPasswordSettings()}
/>
<button
type="button"
onClick={handleGenerateRandomAlias}
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
{t('credentials.generateRandomAlias')}
</button>
<FormInput
id="email"
label={t('common.email')}
@@ -633,6 +614,13 @@ const CredentialAddEdit: React.FC = () => {
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">{t('credentials.alias')}</h2>
<div className="space-y-4">
<button
type="button"
onClick={handleGenerateRandomAlias}
className="w-full bg-primary-500 text-white py-2 px-4 rounded hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
{t('credentials.generateRandomAlias')}
</button>
<FormInput
id="firstName"
label={t('credentials.firstName')}

View File

@@ -52,6 +52,7 @@
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"use": "Use",
"delete": "Delete",
"close": "Close",
"copied": "Copied!",
@@ -237,6 +238,17 @@
"loginCredentials": "Login Credentials",
"generateRandomUsername": "Generate random username",
"generateRandomPassword": "Generate random password",
"configurePassword": "Configure password",
"changePasswordComplexity": "Change password complexity",
"advancedPasswordOptions": "Advanced password options",
"passwordLength": "Password length",
"includeLowercase": "Include lowercase letters",
"includeUppercase": "Include uppercase letters",
"includeNumbers": "Include numbers",
"includeSpecialChars": "Include special characters",
"avoidAmbiguousChars": "Avoid ambiguous characters",
"preview": "Preview",
"generateNewPreview": "Generate new preview",
"generateRandomAlias": "Generate Random Alias",
"alias": "Alias",
"firstName": "First Name",