diff --git a/messages/en-US.json b/messages/en-US.json index 1936a3dc..f8b3f8b9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -833,6 +833,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", @@ -1327,4 +1345,4 @@ "resourceEnableProxy": "Enable Public Proxy", "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", "externalProxyEnabled": "External Proxy Enabled" -} +} \ No newline at end of file diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 837ef179..174600fc 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -189,7 +189,7 @@ export async function inviteUser( ) ); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( @@ -241,7 +241,7 @@ export async function inviteUser( }); }); - const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`; + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index 5494ba10..f4690683 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,11 +36,46 @@ 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; inviteId?: string; inviteToken?: string; + emailParam?: string; }; const formSchema = z @@ -68,29 +104,32 @@ const formSchema = z export default function SignupForm({ redirect, inviteId, - inviteToken + inviteToken, + emailParam }: 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), defaultValues: { - email: "", + email: emailParam || "", 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; @@ -172,7 +211,10 @@ export default function SignupForm({ {t("email")} - + @@ -183,11 +225,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 +355,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 +451,4 @@ export default function SignupForm({ ); -} +} \ No newline at end of file diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index debd7c58..673e69bf 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -11,7 +11,10 @@ import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; export default async function Page(props: { - searchParams: Promise<{ redirect: string | undefined }>; + searchParams: Promise<{ + redirect: string | undefined; + email: string | undefined; + }>; }) { const searchParams = await props.searchParams; const getUser = cache(verifySession); @@ -69,6 +72,7 @@ export default async function Page(props: { redirect={redirectUrl} inviteToken={inviteToken} inviteId={inviteId} + emailParam={searchParams.email} />

diff --git a/src/app/invite/InviteStatusCard.tsx b/src/app/invite/InviteStatusCard.tsx index 3ecf16f5..6d7db4dc 100644 --- a/src/app/invite/InviteStatusCard.tsx +++ b/src/app/invite/InviteStatusCard.tsx @@ -17,11 +17,13 @@ import { useTranslations } from "next-intl"; type InviteStatusCardProps = { type: "rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in"; token: string; + email?: string; }; export default function InviteStatusCard({ type, token, + email, }: InviteStatusCardProps) { const router = useRouter(); const api = createApiClient(useEnvContext()); @@ -29,12 +31,18 @@ export default function InviteStatusCard({ async function goToLogin() { await api.post("/auth/logout", {}); - router.push(`/auth/login?redirect=/invite?token=${token}`); + const redirectUrl = email + ? `/auth/login?redirect=/invite?token=${token}&email=${encodeURIComponent(email)}` + : `/auth/login?redirect=/invite?token=${token}`; + router.push(redirectUrl); } async function goToSignup() { await api.post("/auth/logout", {}); - router.push(`/auth/signup?redirect=/invite?token=${token}`); + const redirectUrl = email + ? `/auth/signup?redirect=/invite?token=${token}&email=${encodeURIComponent(email)}` + : `/auth/signup?redirect=/invite?token=${token}`; + router.push(redirectUrl); } function renderBody() { diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index 014fb45b..0df7b810 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -14,6 +14,7 @@ export default async function InvitePage(props: { const params = await props.searchParams; const tokenParam = params.token as string; + const emailParam = params.email as string; if (!tokenParam) { redirect("/"); @@ -70,16 +71,22 @@ export default async function InvitePage(props: { const type = cardType(); if (!user && type === "user_does_not_exist") { - redirect(`/auth/signup?redirect=/invite?token=${params.token}`); + const redirectUrl = emailParam + ? `/auth/signup?redirect=/invite?token=${params.token}&email=${encodeURIComponent(emailParam)}` + : `/auth/signup?redirect=/invite?token=${params.token}`; + redirect(redirectUrl); } if (!user && type === "not_logged_in") { - redirect(`/auth/login?redirect=/invite?token=${params.token}`); + const redirectUrl = emailParam + ? `/auth/login?redirect=/invite?token=${params.token}&email=${encodeURIComponent(emailParam)}` + : `/auth/login?redirect=/invite?token=${params.token}`; + redirect(redirectUrl); } return ( <> - + ); }