diff --git a/frontend/src/components/PasswordStrengthMeter.tsx b/frontend/src/components/PasswordStrengthMeter.tsx new file mode 100644 index 00000000..a018b03b --- /dev/null +++ b/frontend/src/components/PasswordStrengthMeter.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { calculatePasswordStrength } from '../utils/passwordStrength'; + +interface Props { + password: string; +} + +export const PasswordStrengthMeter: React.FC = ({ password }) => { + const { score, label, color, feedback } = calculatePasswordStrength(password); + + // Calculate width percentage based on score (0-4) + // 0: 5%, 1: 25%, 2: 50%, 3: 75%, 4: 100% + const width = Math.max(5, (score / 4) * 100); + + // Map color name to Tailwind classes + const getColorClass = (c: string) => { + switch (c) { + case 'red': return 'bg-red-500'; + case 'yellow': return 'bg-yellow-500'; + case 'green': return 'bg-green-500'; + default: return 'bg-gray-300'; + } + }; + + const getTextColorClass = (c: string) => { + switch (c) { + case 'red': return 'text-red-500'; + case 'yellow': return 'text-yellow-600'; + case 'green': return 'text-green-600'; + default: return 'text-gray-500'; + } + }; + + if (!password) return null; + + return ( +
+
+ + {label} + + {feedback.length > 0 && ( + + {feedback[0]} + + )} +
+ +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index 87aae42d..00551e1f 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -7,6 +7,7 @@ import { toast } from '../utils/toast' import client from '../api/client' import { getProfile, regenerateApiKey } from '../api/user' import { Copy, RefreshCw, Shield } from 'lucide-react' +import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter' export default function Security() { const [oldPassword, setOldPassword] = useState('') @@ -80,13 +81,16 @@ export default function Security() { onChange={e => setOldPassword(e.target.value)} required /> - setNewPassword(e.target.value)} - required - /> +
+ setNewPassword(e.target.value)} + required + /> + +
{ const navigate = useNavigate(); @@ -104,17 +105,19 @@ const Setup: React.FC = () => { onChange={(e) => setFormData({ ...formData, email: e.target.value })} helperText="This email will be used for Let's Encrypt certificate notifications and recovery." /> - setFormData({ ...formData, password: e.target.value })} - /> +
+ setFormData({ ...formData, password: e.target.value })} + /> + +
{error && ( diff --git a/frontend/src/utils/passwordStrength.ts b/frontend/src/utils/passwordStrength.ts new file mode 100644 index 00000000..768f8c2a --- /dev/null +++ b/frontend/src/utils/passwordStrength.ts @@ -0,0 +1,80 @@ +export interface PasswordStrength { + score: number; // 0-4 + label: string; + color: string; // Tailwind color class prefix (e.g., 'red', 'yellow', 'green') + feedback: string[]; +} + +export function calculatePasswordStrength(password: string): PasswordStrength { + let score = 0; + const feedback: string[] = []; + + if (!password) { + return { + score: 0, + label: 'Empty', + color: 'gray', + feedback: [], + }; + } + + // Length check + if (password.length < 8) { + feedback.push('Too short (min 8 chars)'); + } else { + score += 1; + } + + if (password.length >= 12) { + score += 1; + } + + // Complexity checks + const hasLower = /[a-z]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasNumber = /\d/.test(password); + const hasSpecial = /[^A-Za-z0-9]/.test(password); + + const varietyCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length; + + if (varietyCount >= 3) { + score += 1; + } + if (varietyCount === 4) { + score += 1; + } + + // Penalties + if (varietyCount < 2 && password.length >= 8) { + feedback.push('Add more variety (uppercase, numbers, symbols)'); + } + + // Cap score at 4 + score = Math.min(score, 4); + + // Determine label and color + let label = 'Very Weak'; + let color = 'red'; + + switch (score) { + case 0: + case 1: + label = 'Weak'; + color = 'red'; + break; + case 2: + label = 'Fair'; + color = 'yellow'; + break; + case 3: + label = 'Good'; + color = 'green'; + break; + case 4: + label = 'Strong'; + color = 'green'; + break; + } + + return { score, label, color, feedback }; +}