diff --git a/apps/web/public/locales/en/auth.json b/apps/web/public/locales/en/auth.json index a54f223bd..c1d9dd51e 100644 --- a/apps/web/public/locales/en/auth.json +++ b/apps/web/public/locales/en/auth.json @@ -26,7 +26,6 @@ "passwordHint": "Ensure it's at least 8 characters", "repeatPasswordHint": "Type your password again", "repeatPassword": "Repeat password", - "passwordsDoNotMatch": "The passwords do not match", "passwordForgottenQuestion": "Password forgotten?", "passwordResetLabel": "Reset Password", "passwordResetSubheading": "Enter your email address below. You will receive a link to reset your password.", @@ -69,6 +68,11 @@ "default": "We have encountered an error. Please ensure you have a working internet connection and try again", "generic": "Sorry, we weren't able to authenticate you. Please try again.", "link": "Sorry, we encountered an error while sending your link. Please try again.", - "codeVerifierMismatch": "It looks like you're trying to sign in using a different browser than the one you used to request the sign in link. Please try again using the same browser." + "codeVerifierMismatch": "It looks like you're trying to sign in using a different browser than the one you used to request the sign in link. Please try again using the same browser.", + "minPasswordLength": "Password must be at least 8 characters long", + "passwordsDoNotMatch": "The passwords do not match", + "minPasswordNumbers": "Password must contain at least one number", + "minPasswordSpecialChars": "Password must contain at least one special character", + "uppercasePassword": "Password must contain at least one uppercase letter" } } diff --git a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx index 80786c91f..17447bf82 100644 --- a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx @@ -142,6 +142,8 @@ function useSetSession(tokens: { accessToken: string; refreshToken: string }) { queryKey: ['impersonate-user', tokens.accessToken, tokens.refreshToken], gcTime: 0, queryFn: async () => { + await supabase.auth.signOut(); + await supabase.auth.setSession({ refresh_token: tokens.refreshToken, access_token: tokens.accessToken, diff --git a/packages/features/auth/src/schemas/password-reset.schema.ts b/packages/features/auth/src/schemas/password-reset.schema.ts index 8c0c43fce..193edd600 100644 --- a/packages/features/auth/src/schemas/password-reset.schema.ts +++ b/packages/features/auth/src/schemas/password-reset.schema.ts @@ -1,11 +1,10 @@ import { z } from 'zod'; +import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; + export const PasswordResetSchema = z .object({ - password: z.string().min(8).max(99), - repeatPassword: z.string().min(8).max(99), + password: RefinedPasswordSchema, + repeatPassword: RefinedPasswordSchema, }) - .refine((data) => data.password === data.repeatPassword, { - message: 'Passwords do not match', - path: ['repeatPassword'], - }); + .superRefine(refineRepeatPassword); diff --git a/packages/features/auth/src/schemas/password-sign-in.schema.ts b/packages/features/auth/src/schemas/password-sign-in.schema.ts index 3a2efec9f..823446c08 100644 --- a/packages/features/auth/src/schemas/password-sign-in.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-in.schema.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; +import { PasswordSchema } from './password.schema'; + export const PasswordSignInSchema = z.object({ email: z.string().email(), - password: z.string().min(8).max(99), + password: PasswordSchema, }); diff --git a/packages/features/auth/src/schemas/password-sign-up.schema.ts b/packages/features/auth/src/schemas/password-sign-up.schema.ts index 0f62e4ed6..828924d12 100644 --- a/packages/features/auth/src/schemas/password-sign-up.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-up.schema.ts @@ -1,17 +1,11 @@ import { z } from 'zod'; +import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; + export const PasswordSignUpSchema = z .object({ email: z.string().email(), - password: z.string().min(8).max(99), - repeatPassword: z.string().min(8).max(99), + password: RefinedPasswordSchema, + repeatPassword: RefinedPasswordSchema, }) - .refine( - (schema) => { - return schema.password === schema.repeatPassword; - }, - { - message: 'Passwords do not match', - path: ['repeatPassword'], - }, - ); + .superRefine(refineRepeatPassword); diff --git a/packages/features/auth/src/schemas/password.schema.ts b/packages/features/auth/src/schemas/password.schema.ts new file mode 100644 index 000000000..c31b697d5 --- /dev/null +++ b/packages/features/auth/src/schemas/password.schema.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +/** + * Password requirements + * These are the requirements for the password when signing up or changing the password + */ +const requirements = { + minLength: 8, + maxLength: 99, + specialChars: + process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS === 'true', + numbers: process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_NUMBERS === 'true', + uppercase: process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_UPPERCASE === 'true', +}; + +/** + * Password schema + * This is used to validate the password on sign in (for existing users when requirements are not enforced) + */ +export const PasswordSchema = z + .string() + .min(requirements.minLength) + .max(requirements.maxLength); + +/** + * Refined password schema with additional requirements + * This is required to validate the password requirements on sign up and password change + */ +export const RefinedPasswordSchema = PasswordSchema.superRefine((val, ctx) => + validatePassword(val, ctx), +); + +export function refineRepeatPassword( + data: { password: string; repeatPassword: string }, + ctx: z.RefinementCtx, +) { + if (data.password !== data.repeatPassword) { + ctx.addIssue({ + message: 'auth:errors.passwordsDoNotMatch', + path: ['repeatPassword'], + code: 'custom', + }); + } + + return true; +} + +function validatePassword(password: string, ctx: z.RefinementCtx) { + if (requirements.specialChars) { + const specialCharsCount = + password.match(/[!@#$%^&*(),.?":{}|<>]/g)?.length ?? 0; + + if (specialCharsCount < 1) { + ctx.addIssue({ + message: 'auth:errors.minPasswordSpecialChars', + code: 'custom', + }); + } + } + + if (requirements.numbers) { + const numbersCount = password.match(/\d/g)?.length ?? 0; + + if (numbersCount < 1) { + ctx.addIssue({ + message: 'auth:errors.minPasswordNumbers', + code: 'custom', + }); + } + } + + if (requirements.uppercase) { + if (!/[A-Z]/.test(password)) { + ctx.addIssue({ + message: 'auth:errors.uppercasePassword', + code: 'custom', + }); + } + } + + return true; +} diff --git a/packages/ui/src/shadcn/form.tsx b/packages/ui/src/shadcn/form.tsx index 939e7cc1b..298da78a2 100644 --- a/packages/ui/src/shadcn/form.tsx +++ b/packages/ui/src/shadcn/form.tsx @@ -9,6 +9,7 @@ import { Controller, FormProvider, useFormContext } from 'react-hook-form'; import { cn } from '../utils'; import { Label } from './label'; +import {Trans} from "../makerkit/trans"; const Form = FormProvider; @@ -156,7 +157,7 @@ const FormMessage = React.forwardRef< className={cn('text-[0.8rem] font-medium text-destructive', className)} {...props} > - {body} + {typeof body === 'string' ? : body}

); });