diff --git a/messages/en-US.json b/messages/en-US.json index ed004d99..cd31f7f9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -834,6 +834,24 @@ "pincodeRequirementsLength": "PIN must be exactly 6 digits", "pincodeRequirementsChars": "PIN must only contain numbers", "passwordRequirementsLength": "Password must be at least 1 character long", + "passwordRequirementsTitle": "Password requirements:", + "passwordRequirementLength": "At least 8 characters long", + "passwordRequirementUppercase": "At least one uppercase letter", + "passwordRequirementLowercase": "At least one lowercase letter", + "passwordRequirementNumber": "At least one number", + "passwordRequirementSpecial": "At least one special character", + "passwordRequirementsMet": "✓ Password meets all requirements", + "passwordStrength": "Password strength", + "passwordStrengthWeak": "Weak", + "passwordStrengthMedium": "Medium", + "passwordStrengthStrong": "Strong", + "passwordRequirements": "Requirements:", + "passwordRequirementLengthText": "8+ characters", + "passwordRequirementUppercaseText": "Uppercase letter (A-Z)", + "passwordRequirementLowercaseText": "Lowercase letter (a-z)", + "passwordRequirementNumberText": "Number (0-9)", + "passwordRequirementSpecialText": "Special character (!@#$%...)", + "passwordsDoNotMatch": "Passwords do not match", "otpEmailRequirementsLength": "OTP must be at least 1 character long", "otpEmailSent": "OTP Sent", "otpEmailSentDescription": "An OTP has been sent to your email", @@ -1281,4 +1299,4 @@ "and": "and", "privacyPolicy": "privacy policy" } -} +} \ No newline at end of file diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index 5494ba10..d6d79eb7 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; @@ -23,6 +23,7 @@ import { CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Progress } from "@/components/ui/progress"; import { SignUpResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; import { passwordSchema } from "@server/auth/passwordSchema"; @@ -35,6 +36,40 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; import { useTranslations } from "next-intl"; import BrandingLogo from "@app/components/BrandingLogo"; import { build } from "@server/build"; +import { Check, X } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +// Password strength calculation +const calculatePasswordStrength = (password: string) => { + const requirements = { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /[0-9]/.test(password), + special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password) + }; + + const score = Object.values(requirements).filter(Boolean).length; + let strength: "weak" | "medium" | "strong" = "weak"; + let color = "bg-red-500"; + let percentage = 0; + + if (score >= 5) { + strength = "strong"; + color = "bg-green-500"; + percentage = 100; + } else if (score >= 3) { + strength = "medium"; + color = "bg-yellow-500"; + percentage = 60; + } else if (score >= 1) { + strength = "weak"; + color = "bg-red-500"; + percentage = 30; + } + + return { requirements, strength, color, percentage, score }; +}; type SignupFormProps = { redirect?: string; @@ -71,14 +106,14 @@ export default function SignupForm({ inviteToken }: SignupFormProps) { const router = useRouter(); - - const { env } = useEnvContext(); - - const api = createApiClient({ env }); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [termsAgreedAt, setTermsAgreedAt] = useState(null); + const [passwordValue, setPasswordValue] = useState(""); + const [confirmPasswordValue, setConfirmPasswordValue] = useState(""); const form = useForm>({ resolver: zodResolver(formSchema), @@ -87,10 +122,12 @@ export default function SignupForm({ password: "", confirmPassword: "", agreeToTerms: false - } + }, + mode: "onChange" // Enable real-time validation }); - const t = useTranslations(); + const passwordStrength = calculatePasswordStrength(passwordValue); + const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue; async function onSubmit(values: z.infer) { const { email, password } = values; @@ -183,11 +220,128 @@ export default function SignupForm({ name="password" render={({ field }) => ( - {t("password")} +
+ {t("password")} + {passwordStrength.strength === "strong" && ( + + )} +
- +
+ { + field.onChange(e); + setPasswordValue(e.target.value); + }} + className={cn( + passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500", + passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500", + passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500" + )} + autoComplete="new-password" + /> +
- + + {passwordValue.length > 0 && ( +
+ {/* Password Strength Meter */} +
+
+ {t("passwordStrength")} + + {t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)} + +
+ +
+ + {/* Requirements Checklist */} +
+
{t("passwordRequirements")}
+
+
+ {passwordStrength.requirements.length ? ( + + ) : ( + + )} + + {t("passwordRequirementLengthText")} + +
+
+ {passwordStrength.requirements.uppercase ? ( + + ) : ( + + )} + + {t("passwordRequirementUppercaseText")} + +
+
+ {passwordStrength.requirements.lowercase ? ( + + ) : ( + + )} + + {t("passwordRequirementLowercaseText")} + +
+
+ {passwordStrength.requirements.number ? ( + + ) : ( + + )} + + {t("passwordRequirementNumberText")} + +
+
+ {passwordStrength.requirements.special ? ( + + ) : ( + + )} + + {t("passwordRequirementSpecialText")} + +
+
+
+
+ )} + + {/* Only show FormMessage when not showing our custom requirements */} + {passwordValue.length === 0 && }
)} /> @@ -196,13 +350,36 @@ export default function SignupForm({ name="confirmPassword" render={({ field }) => ( - - {t("confirmPassword")} - +
+ {t('confirmPassword')} + {doPasswordsMatch && ( + + )} +
- +
+ { + field.onChange(e); + setConfirmPasswordValue(e.target.value); + }} + className={cn( + doPasswordsMatch && "border-green-500 focus-visible:ring-green-500", + confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500" + )} + autoComplete="new-password" + /> +
- + {confirmPasswordValue.length > 0 && !doPasswordsMatch && ( +

+ {t("passwordsDoNotMatch")} +

+ )} + {/* Only show FormMessage when field is empty */} + {confirmPasswordValue.length === 0 && }
)} /> @@ -269,4 +446,4 @@ export default function SignupForm({ ); -} +} \ No newline at end of file