From 25d35197c1b00c447c5d29524487a88a47b0431c Mon Sep 17 00:00:00 2001
From: israel <israeliyanda5@gmail.com>
Date: Fri, 13 Mar 2026 17:48:03 +0100
Subject: [PATCH 1/3] fix: enforce consistent password validation across all
 flows (#784)

Password rules (8+ chars, lowercase, uppercase, number, special char)
were only fully enforced on sign-up. Add Member, Edit Member, Change
Password, and Set Password forms had weaker or no validation.

- Create shared password schema (lib/shared/schemas/password.ts)
- Add usePasswordValidation hook for consistent UI display
- Update all 5 frontend forms to use shared schema + ValidationCheckList
- Add backend validation in set_member_password and update_user_password
- Add missing specialChar translation key for changePassword requirements
---
 .../account/components/account-form.tsx       | 142 ++++++++++-----
 .../components/member-add-dialog.tsx          |  51 ++----
 .../components/member-edit-dialog.tsx         |  22 ++-
 .../app/hooks/use-password-validation.ts      |  38 ++++
 .../platform/app/routes/_auth/sign-up.tsx     |  39 ++---
 .../convex/users/set_member_password.ts       |   7 +
 .../convex/users/update_user_password.ts      |   7 +
 .../lib/shared/schemas/password.test.ts       | 163 ++++++++++++++++++
 .../platform/lib/shared/schemas/password.ts   |  74 ++++++++
 services/platform/messages/en.json            |   3 +-
 10 files changed, 433 insertions(+), 113 deletions(-)
 create mode 100644 services/platform/app/hooks/use-password-validation.ts
 create mode 100644 services/platform/lib/shared/schemas/password.test.ts
 create mode 100644 services/platform/lib/shared/schemas/password.ts

diff --git a/services/platform/app/features/settings/account/components/account-form.tsx b/services/platform/app/features/settings/account/components/account-form.tsx
index d4a6d20e33..b9b70a3dc4 100644
--- a/services/platform/app/features/settings/account/components/account-form.tsx
+++ b/services/platform/app/features/settings/account/components/account-form.tsx
@@ -1,15 +1,22 @@
 'use client';
 
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useMemo } from 'react';
 import { useForm } from 'react-hook-form';
+import { z } from 'zod';
 
+import { ValidationCheckList } from '@/app/components/ui/feedback/validation-check-item';
 import { Form } from '@/app/components/ui/forms/form';
+import { FormSection } from '@/app/components/ui/forms/form-section';
 import { Input } from '@/app/components/ui/forms/input';
 import { NarrowContainer } from '@/app/components/ui/layout/layout';
 import { PageSection } from '@/app/components/ui/layout/page-section';
 import { Button } from '@/app/components/ui/primitives/button';
 import { useHasCredentialAccount } from '@/app/features/auth/hooks/queries';
+import { usePasswordValidation } from '@/app/hooks/use-password-validation';
 import { useToast } from '@/app/hooks/use-toast';
 import { useT } from '@/lib/i18n/client';
+import { createPasswordSchema } from '@/lib/shared/schemas/password';
 
 import { useUpdatePassword } from '../hooks/mutations';
 
@@ -91,6 +98,31 @@ function ChangePasswordForm({
   tCommon: ReturnType<typeof useT>['t'];
   tToast: ReturnType<typeof useT>['t'];
 }) {
+  const changePasswordSchema = useMemo(
+    () =>
+      z
+        .object({
+          currentPassword: z
+            .string()
+            .min(1, tAuth('changePassword.validation.currentRequired')),
+          newPassword: createPasswordSchema({
+            minLength: tAuth('validation.passwordMinLength'),
+            lowercase: tAuth('validation.passwordLowercase'),
+            uppercase: tAuth('validation.passwordUppercase'),
+            number: tAuth('validation.passwordNumber'),
+            specialChar: tAuth('validation.passwordSpecial'),
+          }),
+          confirmPassword: z
+            .string()
+            .min(1, tAuth('changePassword.validation.confirmRequired')),
+        })
+        .refine((data) => data.newPassword === data.confirmPassword, {
+          message: tAuth('changePassword.validation.mismatch'),
+          path: ['confirmPassword'],
+        }),
+    [tAuth],
+  );
+
   const {
     register,
     handleSubmit,
@@ -98,6 +130,8 @@ function ChangePasswordForm({
     reset,
     watch,
   } = useForm<ChangePasswordFormData>({
+    resolver: zodResolver(changePasswordSchema),
+    mode: 'onChange',
     defaultValues: {
       currentPassword: '',
       newPassword: '',
@@ -106,6 +140,7 @@ function ChangePasswordForm({
   });
 
   const newPassword = watch('newPassword');
+  const passwordValidationItems = usePasswordValidation(newPassword);
 
   const onSubmit = async (data: ChangePasswordFormData) => {
     try {
@@ -137,26 +172,26 @@ function ChangePasswordForm({
         placeholder={tAuth('changePassword.placeholder.current')}
         disabled={isSubmitting}
         errorMessage={errors.currentPassword?.message}
-        {...register('currentPassword', {
-          required: tAuth('changePassword.validation.currentRequired'),
-        })}
+        {...register('currentPassword')}
       />
 
-      <Input
-        id="new-password"
-        type="password"
-        label={tAuth('changePassword.newPassword')}
-        placeholder={tAuth('changePassword.placeholder.new')}
-        disabled={isSubmitting}
-        errorMessage={errors.newPassword?.message}
-        {...register('newPassword', {
-          required: tAuth('changePassword.validation.newRequired'),
-          minLength: {
-            value: 8,
-            message: tAuth('changePassword.validation.minLength'),
-          },
-        })}
-      />
+      <FormSection>
+        <Input
+          id="new-password"
+          type="password"
+          label={tAuth('changePassword.newPassword')}
+          placeholder={tAuth('changePassword.placeholder.new')}
+          disabled={isSubmitting}
+          errorMessage={errors.newPassword?.message}
+          {...register('newPassword')}
+        />
+        {newPassword && (
+          <ValidationCheckList
+            items={passwordValidationItems}
+            className="text-xs"
+          />
+        )}
+      </FormSection>
 
       <Input
         id="confirm-password"
@@ -165,12 +200,7 @@ function ChangePasswordForm({
         placeholder={tAuth('changePassword.placeholder.confirm')}
         disabled={isSubmitting}
         errorMessage={errors.confirmPassword?.message}
-        {...register('confirmPassword', {
-          required: tAuth('changePassword.validation.confirmRequired'),
-          validate: (value) =>
-            value === newPassword ||
-            tAuth('changePassword.validation.mismatch'),
-        })}
+        {...register('confirmPassword')}
       />
 
       <Button type="submit" disabled={isSubmitting} fullWidth>
@@ -195,6 +225,28 @@ function SetPasswordForm({
   tCommon: ReturnType<typeof useT>['t'];
   tToast: ReturnType<typeof useT>['t'];
 }) {
+  const setPasswordSchema = useMemo(
+    () =>
+      z
+        .object({
+          newPassword: createPasswordSchema({
+            minLength: tAuth('validation.passwordMinLength'),
+            lowercase: tAuth('validation.passwordLowercase'),
+            uppercase: tAuth('validation.passwordUppercase'),
+            number: tAuth('validation.passwordNumber'),
+            specialChar: tAuth('validation.passwordSpecial'),
+          }),
+          confirmPassword: z
+            .string()
+            .min(1, tAuth('changePassword.validation.confirmRequired')),
+        })
+        .refine((data) => data.newPassword === data.confirmPassword, {
+          message: tAuth('changePassword.validation.mismatch'),
+          path: ['confirmPassword'],
+        }),
+    [tAuth],
+  );
+
   const {
     register,
     handleSubmit,
@@ -202,6 +254,8 @@ function SetPasswordForm({
     reset,
     watch,
   } = useForm<SetPasswordFormData>({
+    resolver: zodResolver(setPasswordSchema),
+    mode: 'onChange',
     defaultValues: {
       newPassword: '',
       confirmPassword: '',
@@ -209,6 +263,7 @@ function SetPasswordForm({
   });
 
   const newPassword = watch('newPassword');
+  const passwordValidationItems = usePasswordValidation(newPassword);
 
   const onSubmit = async (data: SetPasswordFormData) => {
     try {
@@ -232,21 +287,23 @@ function SetPasswordForm({
 
   return (
     <Form onSubmit={handleSubmit(onSubmit)}>
-      <Input
-        id="new-password"
-        type="password"
-        label={tAuth('setPassword.newPassword')}
-        placeholder={tAuth('changePassword.placeholder.new')}
-        disabled={isSubmitting}
-        errorMessage={errors.newPassword?.message}
-        {...register('newPassword', {
-          required: tAuth('changePassword.validation.newRequired'),
-          minLength: {
-            value: 8,
-            message: tAuth('changePassword.validation.minLength'),
-          },
-        })}
-      />
+      <FormSection>
+        <Input
+          id="new-password"
+          type="password"
+          label={tAuth('setPassword.newPassword')}
+          placeholder={tAuth('changePassword.placeholder.new')}
+          disabled={isSubmitting}
+          errorMessage={errors.newPassword?.message}
+          {...register('newPassword')}
+        />
+        {newPassword && (
+          <ValidationCheckList
+            items={passwordValidationItems}
+            className="text-xs"
+          />
+        )}
+      </FormSection>
 
       <Input
         id="confirm-password"
@@ -255,12 +312,7 @@ function SetPasswordForm({
         placeholder={tAuth('changePassword.placeholder.confirm')}
         disabled={isSubmitting}
         errorMessage={errors.confirmPassword?.message}
-        {...register('confirmPassword', {
-          required: tAuth('changePassword.validation.confirmRequired'),
-          validate: (value) =>
-            value === newPassword ||
-            tAuth('changePassword.validation.mismatch'),
-        })}
+        {...register('confirmPassword')}
       />
 
       <Button type="submit" disabled={isSubmitting} fullWidth>
diff --git a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx
index da44d9398d..62046abf78 100644
--- a/services/platform/app/features/settings/organization/components/member-add-dialog.tsx
+++ b/services/platform/app/features/settings/organization/components/member-add-dialog.tsx
@@ -14,8 +14,10 @@ import { Input } from '@/app/components/ui/forms/input';
 import { Select } from '@/app/components/ui/forms/select';
 import { Stack } from '@/app/components/ui/layout/layout';
 import { Button } from '@/app/components/ui/primitives/button';
+import { usePasswordValidation } from '@/app/hooks/use-password-validation';
 import { useToast } from '@/app/hooks/use-toast';
 import { useT } from '@/lib/i18n/client';
+import { createOptionalPasswordSchema } from '@/lib/shared/schemas/password';
 import { narrowStringUnion } from '@/lib/utils/type-guards';
 
 import { useCreateMember } from '../hooks/mutations';
@@ -45,29 +47,17 @@ export function AddMemberDialog({
   const { t: tAuth } = useT('auth');
   const { t: tToast } = useT('toast');
 
-  // Create Zod schema with translated validation messages
   const addMemberSchema = useMemo(
     () =>
       z.object({
         email: z.string().email(tCommon('validation.email')),
-        password: z
-          .string()
-          .optional()
-          .refine(
-            (val) => {
-              // If password is provided, it must meet requirements
-              if (!val || val.length === 0) return true;
-              return (
-                val.length >= 8 &&
-                /[a-z]/.test(val) &&
-                /[A-Z]/.test(val) &&
-                /\d/.test(val)
-              );
-            },
-            {
-              message: tAuth('validation.passwordRequirements'),
-            },
-          ),
+        password: createOptionalPasswordSchema({
+          minLength: tAuth('validation.passwordMinLength'),
+          lowercase: tAuth('validation.passwordLowercase'),
+          uppercase: tAuth('validation.passwordUppercase'),
+          number: tAuth('validation.passwordNumber'),
+          specialChar: tAuth('validation.passwordSpecial'),
+        }),
         displayName: z.string().optional(),
         role: z.enum(['disabled', 'admin', 'developer', 'editor', 'member']),
       }),
@@ -98,28 +88,7 @@ export function AddMemberDialog({
   const selectedRole = watch('role');
   const password = watch('password') ?? '';
 
-  // Password validation checks for display
-  const passwordValidationItems = useMemo(
-    () => [
-      {
-        isValid: password.length >= 8,
-        message: tAuth('changePassword.requirements.length'),
-      },
-      {
-        isValid: /[a-z]/.test(password),
-        message: tAuth('changePassword.requirements.lowercase'),
-      },
-      {
-        isValid: /[A-Z]/.test(password),
-        message: tAuth('changePassword.requirements.uppercase'),
-      },
-      {
-        isValid: /\d/.test(password),
-        message: tAuth('changePassword.requirements.number'),
-      },
-    ],
-    [password, tAuth],
-  );
+  const passwordValidationItems = usePasswordValidation(password);
 
   const onSubmit = async (data: AddMemberFormData) => {
     try {
diff --git a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx
index 7250ff4db7..30a1460057 100644
--- a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx
+++ b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx
@@ -7,17 +7,20 @@ import * as z from 'zod';
 
 import { FormDialog } from '@/app/components/ui/dialog/form-dialog';
 import { Banner } from '@/app/components/ui/feedback/banner';
+import { ValidationCheckList } from '@/app/components/ui/feedback/validation-check-item';
 import { Checkbox } from '@/app/components/ui/forms/checkbox';
 import { FormSection } from '@/app/components/ui/forms/form-section';
 import { Input } from '@/app/components/ui/forms/input';
 import { Select } from '@/app/components/ui/forms/select';
 import { Text } from '@/app/components/ui/typography/text';
+import { usePasswordValidation } from '@/app/hooks/use-password-validation';
 import { toast } from '@/app/hooks/use-toast';
 import { useT } from '@/lib/i18n/client';
 import {
   memberRoleSchema,
   type MemberRole,
 } from '@/lib/shared/schemas/organizations';
+import { createOptionalPasswordSchema } from '@/lib/shared/schemas/password';
 
 import {
   useSetMemberPassword,
@@ -57,6 +60,7 @@ export function EditMemberDialog({
 }: EditMemberDialogProps) {
   const { t } = useT('settings');
   const { t: tCommon } = useT('common');
+  const { t: tAuth } = useT('auth');
 
   const editMemberSchema = useMemo(
     () =>
@@ -67,9 +71,15 @@ export function EditMemberDialog({
         role: memberRoleSchema,
         email: z.string().email(tCommon('validation.email')),
         updatePassword: z.boolean().optional(),
-        password: z.string().optional(),
+        password: createOptionalPasswordSchema({
+          minLength: tAuth('validation.passwordMinLength'),
+          lowercase: tAuth('validation.passwordLowercase'),
+          uppercase: tAuth('validation.passwordUppercase'),
+          number: tAuth('validation.passwordNumber'),
+          specialChar: tAuth('validation.passwordSpecial'),
+        }),
       }),
-    [t, tCommon],
+    [t, tCommon, tAuth],
   );
 
   const form = useForm<EditMemberFormData>({
@@ -133,6 +143,8 @@ export function EditMemberDialog({
 
   const { handleSubmit, register, reset, watch, formState } = form;
   const { isSubmitting, isDirty } = formState;
+  const password = watch('password') ?? '';
+  const passwordValidationItems = usePasswordValidation(password);
 
   const isEditingSelf = currentUserMemberId === member?._id;
 
@@ -248,6 +260,12 @@ export function EditMemberDialog({
               {...register('password')}
               className="w-full"
             />
+            {password && (
+              <ValidationCheckList
+                items={passwordValidationItems}
+                className="text-xs"
+              />
+            )}
             <Text variant="caption">
               {t('organization.userMustUpdatePassword')}
             </Text>
diff --git a/services/platform/app/hooks/use-password-validation.ts b/services/platform/app/hooks/use-password-validation.ts
new file mode 100644
index 0000000000..b4d65f9ea2
--- /dev/null
+++ b/services/platform/app/hooks/use-password-validation.ts
@@ -0,0 +1,38 @@
+import { useMemo } from 'react';
+
+import { useT } from '@/lib/i18n/client';
+import { validatePassword } from '@/lib/shared/schemas/password';
+
+/**
+ * Returns password validation check items for use with `ValidationCheckList`.
+ * Provides consistent password requirement display across all forms.
+ */
+export function usePasswordValidation(password: string) {
+  const { t: tAuth } = useT('auth');
+
+  return useMemo(() => {
+    const result = validatePassword(password);
+    return [
+      {
+        isValid: result.length,
+        message: tAuth('changePassword.requirements.length'),
+      },
+      {
+        isValid: result.lowercase,
+        message: tAuth('changePassword.requirements.lowercase'),
+      },
+      {
+        isValid: result.uppercase,
+        message: tAuth('changePassword.requirements.uppercase'),
+      },
+      {
+        isValid: result.number,
+        message: tAuth('changePassword.requirements.number'),
+      },
+      {
+        isValid: result.specialChar,
+        message: tAuth('changePassword.requirements.specialChar'),
+      },
+    ];
+  }, [password, tAuth]);
+}
diff --git a/services/platform/app/routes/_auth/sign-up.tsx b/services/platform/app/routes/_auth/sign-up.tsx
index 7d525499d8..ea4ff68112 100644
--- a/services/platform/app/routes/_auth/sign-up.tsx
+++ b/services/platform/app/routes/_auth/sign-up.tsx
@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
 import { z } from 'zod';
 
 import { MicrosoftIcon } from '@/app/components/icons/microsoft-icon';
+import { ValidationCheckList } from '@/app/components/ui/feedback/validation-check-item';
 import { Form } from '@/app/components/ui/forms/form';
 import { FormSection } from '@/app/components/ui/forms/form-section';
 import { Input } from '@/app/components/ui/forms/input';
@@ -13,9 +14,11 @@ import { Separator } from '@/app/components/ui/layout/separator';
 import { Button } from '@/app/components/ui/primitives/button';
 import { AuthFormLayout } from '@/app/features/auth/components/auth-form-layout';
 import { useIsSsoConfigured } from '@/app/features/auth/hooks/queries';
+import { usePasswordValidation } from '@/app/hooks/use-password-validation';
 import { toast } from '@/app/hooks/use-toast';
 import { authClient } from '@/lib/auth-client';
 import { useT } from '@/lib/i18n/client';
+import { createPasswordSchema } from '@/lib/shared/schemas/password';
 import { seo } from '@/lib/utils/seo';
 
 export const Route = createFileRoute('/_auth/sign-up')({
@@ -44,13 +47,13 @@ function SignUpPage() {
           .string()
           .min(1, t('validation.emailRequired'))
           .email(tCommon('validation.email')),
-        password: z
-          .string()
-          .min(8, t('validation.passwordMinLength'))
-          .regex(/[a-z]/, t('validation.passwordLowercase'))
-          .regex(/[A-Z]/, t('validation.passwordUppercase'))
-          .regex(/\d/, t('validation.passwordNumber'))
-          .regex(/[!@#$%^&*(),.?":{}|<>]/, t('validation.passwordSpecial')),
+        password: createPasswordSchema({
+          minLength: t('validation.passwordMinLength'),
+          lowercase: t('validation.passwordLowercase'),
+          uppercase: t('validation.passwordUppercase'),
+          number: t('validation.passwordNumber'),
+          specialChar: t('validation.passwordSpecial'),
+        }),
       }),
     [t, tCommon],
   );
@@ -66,6 +69,7 @@ function SignUpPage() {
 
   const { isSubmitting, isValid, errors } = form.formState;
   const password = form.watch('password');
+  const passwordValidationItems = usePasswordValidation(password);
 
   const handleSubmit = async (data: SignUpFormData) => {
     form.setError('password', { message: '' });
@@ -145,23 +149,10 @@ function SignUpPage() {
                 {...form.register('password')}
               />
               {password && (
-                <ul className="text-muted-foreground flex list-none flex-col gap-1 text-xs">
-                  <li className="relative pl-4 before:absolute before:left-0 before:content-['-']">
-                    {t('requirements.length')}
-                  </li>
-                  <li className="relative pl-4 before:absolute before:left-0 before:content-['-']">
-                    {t('requirements.lowercase')}
-                  </li>
-                  <li className="relative pl-4 before:absolute before:left-0 before:content-['-']">
-                    {t('requirements.uppercase')}
-                  </li>
-                  <li className="relative pl-4 before:absolute before:left-0 before:content-['-']">
-                    {t('requirements.number')}
-                  </li>
-                  <li className="relative pl-4 before:absolute before:left-0 before:content-['-']">
-                    {t('requirements.specialChar')}
-                  </li>
-                </ul>
+                <ValidationCheckList
+                  items={passwordValidationItems}
+                  className="text-xs"
+                />
               )}
             </Stack>
 
diff --git a/services/platform/convex/users/set_member_password.ts b/services/platform/convex/users/set_member_password.ts
index 394c5de23c..42275d1374 100644
--- a/services/platform/convex/users/set_member_password.ts
+++ b/services/platform/convex/users/set_member_password.ts
@@ -7,6 +7,7 @@
 
 import { hashPassword } from 'better-auth/crypto';
 
+import { isPasswordValid } from '../../lib/shared/schemas/password';
 import { isRecord, getString } from '../../lib/utils/type-guards';
 import { components } from '../_generated/api';
 import { MutationCtx } from '../_generated/server';
@@ -88,6 +89,12 @@ export async function setMemberPassword(
     ? credentialRaw
     : undefined;
 
+  if (!isPasswordValid(args.newPassword)) {
+    throw new Error(
+      'Password must be at least 8 characters with lowercase, uppercase, number, and special character',
+    );
+  }
+
   const passwordHash = await hashPassword(args.newPassword);
 
   if (existingCredential) {
diff --git a/services/platform/convex/users/update_user_password.ts b/services/platform/convex/users/update_user_password.ts
index 70ccf43b82..bd95921973 100644
--- a/services/platform/convex/users/update_user_password.ts
+++ b/services/platform/convex/users/update_user_password.ts
@@ -2,6 +2,7 @@
  * Update user password - Business logic
  */
 
+import { isPasswordValid } from '../../lib/shared/schemas/password';
 import { MutationCtx } from '../_generated/server';
 import { hasCredentialAccount } from '../accounts/helpers';
 import { createAuth, authComponent } from '../auth';
@@ -20,6 +21,12 @@ export async function updateUserPassword(
   ctx: MutationCtx,
   args: UpdateUserPasswordArgs,
 ): Promise<void> {
+  if (!isPasswordValid(args.newPassword)) {
+    throw new Error(
+      'Password must be at least 8 characters with lowercase, uppercase, number, and special character',
+    );
+  }
+
   const { auth, headers } = await authComponent.getAuth(createAuth, ctx);
 
   const hasPassword = await hasCredentialAccount(ctx);
diff --git a/services/platform/lib/shared/schemas/password.test.ts b/services/platform/lib/shared/schemas/password.test.ts
new file mode 100644
index 0000000000..6345a9e49a
--- /dev/null
+++ b/services/platform/lib/shared/schemas/password.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  PASSWORD_MIN_LENGTH,
+  createOptionalPasswordSchema,
+  createPasswordSchema,
+  isPasswordValid,
+  validatePassword,
+} from './password';
+
+const VALID_PASSWORD = 'Test1234!';
+const MESSAGES = {
+  minLength: 'min length',
+  lowercase: 'lowercase',
+  uppercase: 'uppercase',
+  number: 'number',
+  specialChar: 'special char',
+};
+
+describe('validatePassword', () => {
+  it('returns all true for a valid password', () => {
+    const result = validatePassword(VALID_PASSWORD);
+    expect(result).toEqual({
+      length: true,
+      lowercase: true,
+      uppercase: true,
+      number: true,
+      specialChar: true,
+    });
+  });
+
+  it('fails length for short password', () => {
+    const result = validatePassword('Ab1!');
+    expect(result.length).toBe(false);
+    expect(result.lowercase).toBe(true);
+    expect(result.uppercase).toBe(true);
+    expect(result.number).toBe(true);
+    expect(result.specialChar).toBe(true);
+  });
+
+  it('fails lowercase when missing', () => {
+    const result = validatePassword('ABCDEFG1!');
+    expect(result.length).toBe(true);
+    expect(result.lowercase).toBe(false);
+  });
+
+  it('fails uppercase when missing', () => {
+    const result = validatePassword('abcdefg1!');
+    expect(result.length).toBe(true);
+    expect(result.uppercase).toBe(false);
+  });
+
+  it('fails number when missing', () => {
+    const result = validatePassword('Abcdefgh!');
+    expect(result.length).toBe(true);
+    expect(result.number).toBe(false);
+  });
+
+  it('fails special char when missing', () => {
+    const result = validatePassword('Abcdefg1');
+    expect(result.length).toBe(true);
+    expect(result.specialChar).toBe(false);
+  });
+
+  it('fails all rules for empty string', () => {
+    const result = validatePassword('');
+    expect(Object.values(result).every((v) => v === false)).toBe(true);
+  });
+});
+
+describe('isPasswordValid', () => {
+  it('returns true for valid password', () => {
+    expect(isPasswordValid(VALID_PASSWORD)).toBe(true);
+  });
+
+  it('returns false when any rule fails', () => {
+    expect(isPasswordValid('short1!')).toBe(false);
+    expect(isPasswordValid('alllowercase1!')).toBe(false);
+    expect(isPasswordValid('ALLUPPERCASE1!')).toBe(false);
+    expect(isPasswordValid('NoNumbers!!')).toBe(false);
+    expect(isPasswordValid('NoSpecial1a')).toBe(false);
+  });
+});
+
+describe('PASSWORD_MIN_LENGTH', () => {
+  it('is 8', () => {
+    expect(PASSWORD_MIN_LENGTH).toBe(8);
+  });
+});
+
+describe('createPasswordSchema', () => {
+  const schema = createPasswordSchema(MESSAGES);
+
+  it('accepts a valid password', () => {
+    expect(schema.safeParse(VALID_PASSWORD).success).toBe(true);
+  });
+
+  it('rejects password too short', () => {
+    const result = schema.safeParse('Ab1!');
+    expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.issues[0].message).toBe('min length');
+    }
+  });
+
+  it('rejects password missing lowercase', () => {
+    const result = schema.safeParse('ABCDEFG1!');
+    expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.issues[0].message).toBe('lowercase');
+    }
+  });
+
+  it('rejects password missing uppercase', () => {
+    const result = schema.safeParse('abcdefg1!');
+    expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.issues[0].message).toBe('uppercase');
+    }
+  });
+
+  it('rejects password missing number', () => {
+    const result = schema.safeParse('Abcdefgh!');
+    expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.issues[0].message).toBe('number');
+    }
+  });
+
+  it('rejects password missing special char', () => {
+    const result = schema.safeParse('Abcdefg1');
+    expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.issues[0].message).toBe('special char');
+    }
+  });
+});
+
+describe('createOptionalPasswordSchema', () => {
+  const schema = createOptionalPasswordSchema(MESSAGES);
+
+  it('accepts undefined', () => {
+    expect(schema.safeParse(undefined).success).toBe(true);
+  });
+
+  it('accepts empty string', () => {
+    expect(schema.safeParse('').success).toBe(true);
+  });
+
+  it('accepts a valid password', () => {
+    expect(schema.safeParse(VALID_PASSWORD).success).toBe(true);
+  });
+
+  it('rejects an invalid non-empty password', () => {
+    const result = schema.safeParse('weak');
+    expect(result.success).toBe(false);
+  });
+
+  it('rejects password missing only special char', () => {
+    const result = schema.safeParse('Abcdefg1');
+    expect(result.success).toBe(false);
+  });
+});
diff --git a/services/platform/lib/shared/schemas/password.ts b/services/platform/lib/shared/schemas/password.ts
new file mode 100644
index 0000000000..231737862f
--- /dev/null
+++ b/services/platform/lib/shared/schemas/password.ts
@@ -0,0 +1,74 @@
+import { z } from 'zod';
+
+export const PASSWORD_MIN_LENGTH = 8;
+
+export const PASSWORD_RULES = {
+  minLength: PASSWORD_MIN_LENGTH,
+  lowercase: /[a-z]/,
+  uppercase: /[A-Z]/,
+  number: /\d/,
+  specialChar: /[!@#$%^&*(),.?":{}|<>]/,
+} as const;
+
+/**
+ * Validates a password string against all password policy rules.
+ * Returns an object indicating which rules pass/fail.
+ */
+export function validatePassword(password: string) {
+  return {
+    length: password.length >= PASSWORD_RULES.minLength,
+    lowercase: PASSWORD_RULES.lowercase.test(password),
+    uppercase: PASSWORD_RULES.uppercase.test(password),
+    number: PASSWORD_RULES.number.test(password),
+    specialChar: PASSWORD_RULES.specialChar.test(password),
+  };
+}
+
+/**
+ * Returns true if the password meets all policy requirements.
+ */
+export function isPasswordValid(password: string) {
+  const result = validatePassword(password);
+  return Object.values(result).every(Boolean);
+}
+
+interface PasswordValidationMessages {
+  minLength: string;
+  lowercase: string;
+  uppercase: string;
+  number: string;
+  specialChar: string;
+}
+
+/**
+ * Creates a Zod password schema with translated error messages.
+ * Enforces all 5 password rules: min length, lowercase, uppercase, number, special char.
+ */
+export function createPasswordSchema(messages: PasswordValidationMessages) {
+  return z
+    .string()
+    .min(PASSWORD_RULES.minLength, messages.minLength)
+    .regex(PASSWORD_RULES.lowercase, messages.lowercase)
+    .regex(PASSWORD_RULES.uppercase, messages.uppercase)
+    .regex(PASSWORD_RULES.number, messages.number)
+    .regex(PASSWORD_RULES.specialChar, messages.specialChar);
+}
+
+/**
+ * Creates an optional Zod password schema for forms where password is not required.
+ * Empty/missing values pass; non-empty values must meet all password rules.
+ */
+export function createOptionalPasswordSchema(
+  messages: PasswordValidationMessages,
+) {
+  return z
+    .string()
+    .optional()
+    .refine(
+      (val) => {
+        if (!val || val.length === 0) return true;
+        return isPasswordValid(val);
+      },
+      { message: messages.minLength },
+    );
+}
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index a57cfa3645..93d8395d0b 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -528,7 +528,8 @@
         "length": "At least 8 characters",
         "lowercase": "One lowercase letter",
         "uppercase": "One uppercase letter",
-        "number": "One number"
+        "number": "One number",
+        "specialChar": "One special character"
       },
       "validation": {
         "currentRequired": "Current password is required",

From 3e4103efd3b06f4babff56b56e488a69231c8086 Mon Sep 17 00:00:00 2001
From: israel <israeliyanda5@gmail.com>
Date: Fri, 13 Mar 2026 17:50:20 +0100
Subject: [PATCH 2/3] fix: remove unused PASSWORD_RULES export to satisfy knip

---
 services/platform/lib/shared/schemas/password.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/services/platform/lib/shared/schemas/password.ts b/services/platform/lib/shared/schemas/password.ts
index 231737862f..888fee2a73 100644
--- a/services/platform/lib/shared/schemas/password.ts
+++ b/services/platform/lib/shared/schemas/password.ts
@@ -2,7 +2,7 @@ import { z } from 'zod';
 
 export const PASSWORD_MIN_LENGTH = 8;
 
-export const PASSWORD_RULES = {
+const PASSWORD_RULES = {
   minLength: PASSWORD_MIN_LENGTH,
   lowercase: /[a-z]/,
   uppercase: /[A-Z]/,

From 92e4084bd517542320464476d6bda01c58c9d69b Mon Sep 17 00:00:00 2001
From: israel <israeliyanda5@gmail.com>
Date: Fri, 13 Mar 2026 18:04:38 +0100
Subject: [PATCH 3/3] fix: address CodeRabbit review feedback

- Use superRefine in createOptionalPasswordSchema for specific error messages
- Reset password field when edit member checkbox is toggled off
- Move password validation after auth check to prevent policy probing
- Move validation before DB reads in set_member_password for fail-fast
- Add error message assertions in optional schema tests
---
 .../components/member-edit-dialog.tsx         |  7 ++++-
 .../convex/users/set_member_password.ts       | 12 ++++-----
 .../convex/users/update_user_password.ts      |  4 +--
 .../lib/shared/schemas/password.test.ts       | 10 +++++--
 .../platform/lib/shared/schemas/password.ts   | 27 ++++++++++++++-----
 5 files changed, 42 insertions(+), 18 deletions(-)

diff --git a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx
index 30a1460057..25324dd150 100644
--- a/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx
+++ b/services/platform/app/features/settings/organization/components/member-edit-dialog.tsx
@@ -244,7 +244,12 @@ export function EditMemberDialog({
             <Checkbox
               id="updatePassword"
               checked={field.value}
-              onCheckedChange={field.onChange}
+              onCheckedChange={(checked) => {
+                field.onChange(checked);
+                if (!checked) {
+                  form.resetField('password');
+                }
+              }}
               label={t('organization.updatePassword')}
             />
           )}
diff --git a/services/platform/convex/users/set_member_password.ts b/services/platform/convex/users/set_member_password.ts
index 42275d1374..ec57e87973 100644
--- a/services/platform/convex/users/set_member_password.ts
+++ b/services/platform/convex/users/set_member_password.ts
@@ -27,6 +27,12 @@ export async function setMemberPassword(
     throw new Error('Unauthenticated');
   }
 
+  if (!isPasswordValid(args.newPassword)) {
+    throw new Error(
+      'Password must be at least 8 characters with lowercase, uppercase, number, and special character',
+    );
+  }
+
   // Look up the target member
   const memberRes = await ctx.runQuery(components.betterAuth.adapter.findMany, {
     model: 'member',
@@ -89,12 +95,6 @@ export async function setMemberPassword(
     ? credentialRaw
     : undefined;
 
-  if (!isPasswordValid(args.newPassword)) {
-    throw new Error(
-      'Password must be at least 8 characters with lowercase, uppercase, number, and special character',
-    );
-  }
-
   const passwordHash = await hashPassword(args.newPassword);
 
   if (existingCredential) {
diff --git a/services/platform/convex/users/update_user_password.ts b/services/platform/convex/users/update_user_password.ts
index bd95921973..637c7ae110 100644
--- a/services/platform/convex/users/update_user_password.ts
+++ b/services/platform/convex/users/update_user_password.ts
@@ -21,14 +21,14 @@ export async function updateUserPassword(
   ctx: MutationCtx,
   args: UpdateUserPasswordArgs,
 ): Promise<void> {
+  const { auth, headers } = await authComponent.getAuth(createAuth, ctx);
+
   if (!isPasswordValid(args.newPassword)) {
     throw new Error(
       'Password must be at least 8 characters with lowercase, uppercase, number, and special character',
     );
   }
 
-  const { auth, headers } = await authComponent.getAuth(createAuth, ctx);
-
   const hasPassword = await hasCredentialAccount(ctx);
 
   if (hasPassword) {
diff --git a/services/platform/lib/shared/schemas/password.test.ts b/services/platform/lib/shared/schemas/password.test.ts
index 6345a9e49a..f3561140b1 100644
--- a/services/platform/lib/shared/schemas/password.test.ts
+++ b/services/platform/lib/shared/schemas/password.test.ts
@@ -151,13 +151,19 @@ describe('createOptionalPasswordSchema', () => {
     expect(schema.safeParse(VALID_PASSWORD).success).toBe(true);
   });
 
-  it('rejects an invalid non-empty password', () => {
+  it('rejects an invalid non-empty password with correct message', () => {
     const result = schema.safeParse('weak');
     expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.issues[0].message).toBe('min length');
+    }
   });
 
-  it('rejects password missing only special char', () => {
+  it('rejects password missing only special char with correct message', () => {
     const result = schema.safeParse('Abcdefg1');
     expect(result.success).toBe(false);
+    if (!result.success) {
+      expect(result.error.issues[0].message).toBe('special char');
+    }
   });
 });
diff --git a/services/platform/lib/shared/schemas/password.ts b/services/platform/lib/shared/schemas/password.ts
index 888fee2a73..9dd852e8d4 100644
--- a/services/platform/lib/shared/schemas/password.ts
+++ b/services/platform/lib/shared/schemas/password.ts
@@ -64,11 +64,24 @@ export function createOptionalPasswordSchema(
   return z
     .string()
     .optional()
-    .refine(
-      (val) => {
-        if (!val || val.length === 0) return true;
-        return isPasswordValid(val);
-      },
-      { message: messages.minLength },
-    );
+    .superRefine((val, ctx) => {
+      if (!val || val.length === 0) return;
+
+      const result = validatePassword(val);
+      if (!result.length) {
+        ctx.addIssue({ code: 'custom', message: messages.minLength });
+      }
+      if (!result.lowercase) {
+        ctx.addIssue({ code: 'custom', message: messages.lowercase });
+      }
+      if (!result.uppercase) {
+        ctx.addIssue({ code: 'custom', message: messages.uppercase });
+      }
+      if (!result.number) {
+        ctx.addIssue({ code: 'custom', message: messages.number });
+      }
+      if (!result.specialChar) {
+        ctx.addIssue({ code: 'custom', message: messages.specialChar });
+      }
+    });
 }
