From b8b4519cdf88ccd3a72353a2dcaa39e6325d9a5b Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sun, 11 Oct 2026 04:31:11 -0400 Subject: [PATCH] fix(auth): accept the same passwords on reset, sign-up and Profile Reset Password refused anything over 50 characters, while sign-up and Profile set no limit at all. None of those matched central manager: its only password cap is `VALIDATION_CONSTRAINTS.USER.PASSWORD_MAX_LENGTH` (256), applied by `validateAddUser` to sign-up, while `validateResetPassword` and `validateUpdateUser` take any length. So a password manager's 64-character password worked for sign-up and Profile but could not be reset to, and a sign-up password over 256 passed the form only to be refused by the server. All three forms now cap the password at one shared `ACCOUNT_PASSWORD_MAX_LENGTH` (256) with the same message. Password inputs still have no `maxLength`, so an over-long paste is shown in full with the error rather than silently clamped. Closes #1883 Co-Authored-By: Claude Opus 5.5 --- src/features/auth/ResetPassword.test.tsx | 30 ++++++++++++++----- src/features/auth/ResetPassword.tsx | 4 +-- src/features/auth/SignUp.test.tsx | 23 ++++++++++++++ src/features/auth/SignUp.tsx | 5 ++-- src/features/profile/ProfileIndex.test.tsx | 20 +++++++++++++ .../profile/mutations/updateUserSchema.ts | 2 ++ src/lib/zod/password.ts | 6 ++++ 7 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/features/auth/ResetPassword.test.tsx b/src/features/auth/ResetPassword.test.tsx index fd526e70f..4ddadc243 100644 --- a/src/features/auth/ResetPassword.test.tsx +++ b/src/features/auth/ResetPassword.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('@tanstack/react-router', () => ({ @@ -19,6 +19,14 @@ afterEach(() => { vi.clearAllMocks(); }); +async function submitNewPassword(value: string) { + await act(async () => { + fireEvent.change(screen.getByLabelText('New Password'), { target: { value } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value } }); + fireEvent.click(screen.getByRole('button', { name: 'Submit New Password' })); + }); +} + describe('ResetPassword', () => { // maxlength would silently clamp a pasted password, leaving the password manager holding one that never worked. it('lets an over-long password be typed in full and explains the limit instead', async () => { @@ -28,15 +36,23 @@ describe('ResetPassword', () => { expect(password.maxLength).toBe(-1); expect(confirmation.maxLength).toBe(-1); - await act(async () => { - fireEvent.change(password, { target: { value: 'p'.repeat(51) } }); - fireEvent.change(confirmation, { target: { value: 'p'.repeat(51) } }); - fireEvent.click(screen.getByRole('button', { name: 'Submit New Password' })); - }); + await submitNewPassword('p'.repeat(257)); - expect(await screen.findByText('Password cannot be longer than 50 characters.')).toBeTruthy(); + expect(password.value).toHaveLength(257); + expect(await screen.findByText('Password cannot be longer than 256 characters.')).toBeTruthy(); expect(mutate).not.toHaveBeenCalled(); }); + + it.each([51, 256])('accepts a %i-character password, as sign-up does', async (length) => { + render(); + const value = 'p'.repeat(length); + + await submitNewPassword(value); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate.mock.calls[0][0]).toEqual({ token: 'reset-token', password: value }); + expect(screen.queryByText(/cannot be longer than/)).toBeNull(); + }); }); describe('ResetPassword password fields', () => { diff --git a/src/features/auth/ResetPassword.tsx b/src/features/auth/ResetPassword.tsx index 36bfa3bcc..792cdbedd 100644 --- a/src/features/auth/ResetPassword.tsx +++ b/src/features/auth/ResetPassword.tsx @@ -4,7 +4,7 @@ import { FormField } from '@/components/ui/form/FormField'; import { FormItem } from '@/components/ui/form/FormItem'; import { FormLabel } from '@/components/ui/form/FormLabel'; import { FormMessage } from '@/components/ui/form/FormMessage'; -import { zodRequirePassword } from '@/lib/zod/password'; +import { ACCOUNT_PASSWORD_MAX_LENGTH, ACCOUNT_PASSWORD_TOO_LONG_ERROR, zodRequirePassword } from '@/lib/zod/password'; import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { useCallback, useEffect } from 'react'; @@ -18,7 +18,7 @@ const ResetPasswordSchema = z .object({ password: zodRequirePassword .min(8, { error: 'Password must be at least 8 characters long.' }) - .max(50, { error: 'Password cannot be longer than 50 characters.' }), + .max(ACCOUNT_PASSWORD_MAX_LENGTH, { error: ACCOUNT_PASSWORD_TOO_LONG_ERROR }), confirmPassword: z.string(), }) .refine((data) => data.password === data.confirmPassword, { diff --git a/src/features/auth/SignUp.test.tsx b/src/features/auth/SignUp.test.tsx index db938fdf7..d61e53874 100644 --- a/src/features/auth/SignUp.test.tsx +++ b/src/features/auth/SignUp.test.tsx @@ -109,6 +109,29 @@ describe('SignUp', () => { expect(maxLengthOf('Confirm Password')).toBe(-1); }); + it('refuses a password longer than central manager accepts before sending it', async () => { + renderSignUp(); + fillValidForm(); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'p'.repeat(257) } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'p'.repeat(257) } }); + submit(); + + expect(await screen.findByText('Password cannot be longer than 256 characters.')).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + + it('sends a password right at central manager’s limit', async () => { + post.mockResolvedValue({ data: { id: 'usr-1', email: 'taken@example.com' } }); + renderSignUp(); + fillValidForm(); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'p'.repeat(256) } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'p'.repeat(256) } }); + submit(); + + await waitFor(() => expect(post).toHaveBeenCalledTimes(1)); + expect(post.mock.calls[0][1]).toMatchObject({ password: 'p'.repeat(256) }); + }); + it('reveals each password independently without submitting or losing field values', () => { renderSignUp(); const password = screen.getByLabelText('Password') as HTMLInputElement; diff --git a/src/features/auth/SignUp.tsx b/src/features/auth/SignUp.tsx index e2edaf1a0..f3d59a32a 100644 --- a/src/features/auth/SignUp.tsx +++ b/src/features/auth/SignUp.tsx @@ -11,7 +11,7 @@ import { parseCompanyFromEmail } from '@/lib/string/parseCompanyFromEmail'; import { personNameRegex } from '@/lib/string/regex/personNameRegex'; import { clearUtmParamsFromUrl } from '@/lib/urls/clearUtmParams'; import { zodRequireEmail } from '@/lib/zod/email'; -import { zodRequirePassword } from '@/lib/zod/password'; +import { ACCOUNT_PASSWORD_MAX_LENGTH, ACCOUNT_PASSWORD_TOO_LONG_ERROR, zodRequirePassword } from '@/lib/zod/password'; import { zodResolver } from '@hookform/resolvers/zod'; import { Link, useNavigate, useSearch } from '@tanstack/react-router'; import { MouseEvent, useCallback, useEffect, useState } from 'react'; @@ -53,7 +53,8 @@ const SignUpSchema = z.object({ ) .max(80, { error: 'Last name cannot be longer than 80 characters.' }), password: zodRequirePassword - .min(8, { error: 'Password must be at least 8 characters long.' }), + .min(8, { error: 'Password must be at least 8 characters long.' }) + .max(ACCOUNT_PASSWORD_MAX_LENGTH, { error: ACCOUNT_PASSWORD_TOO_LONG_ERROR }), confirmPassword: z.string(), acceptTerms: z.boolean().refine(val => val === true, { message: 'You must accept the Privacy Policy and Terms of Service.', diff --git a/src/features/profile/ProfileIndex.test.tsx b/src/features/profile/ProfileIndex.test.tsx index 566178ef5..6aefffa4b 100644 --- a/src/features/profile/ProfileIndex.test.tsx +++ b/src/features/profile/ProfileIndex.test.tsx @@ -66,6 +66,26 @@ describe('ProfileIndex', () => { await waitFor(() => expect(saveButton().disabled).toBe(false)); }); + it('refuses a new password longer than sign-up accepts once the field is left', async () => { + render(); + await type('New Password', 'p'.repeat(257)); + await leave('New Password'); + + expect(await screen.findByText('Password cannot be longer than 256 characters.')).toBeTruthy(); + expect(saveButton().disabled).toBe(true); + }); + + it('accepts a new password as long as sign-up allows', async () => { + render(); + await type('New Password', 'p'.repeat(256)); + await leave('New Password'); + await type('Confirm New Password', 'p'.repeat(256)); + await leave('Confirm New Password'); + + await waitFor(() => expect(saveButton().disabled).toBe(false)); + expect(screen.queryByText(/cannot be longer than/)).toBeNull(); + }); + it('explains a short new password when Enter is pressed in it', async () => { render(); await type('New Password', 'short'); diff --git a/src/features/profile/mutations/updateUserSchema.ts b/src/features/profile/mutations/updateUserSchema.ts index 9abd86380..af2a3dec7 100644 --- a/src/features/profile/mutations/updateUserSchema.ts +++ b/src/features/profile/mutations/updateUserSchema.ts @@ -1,3 +1,4 @@ +import { ACCOUNT_PASSWORD_MAX_LENGTH, ACCOUNT_PASSWORD_TOO_LONG_ERROR } from '@/lib/zod/password'; import { z } from 'zod'; export const UpdateUserSchema = z @@ -20,6 +21,7 @@ export const UpdateUserSchema = z error: 'Please enter your new password.', }) .min(8, { error: 'Password must be 8 characters or more.' }) + .max(ACCOUNT_PASSWORD_MAX_LENGTH, { error: ACCOUNT_PASSWORD_TOO_LONG_ERROR }) .or(z.string().max(0)), confirmNewPassword: z .string() diff --git a/src/lib/zod/password.ts b/src/lib/zod/password.ts index 09039c187..c4afd3378 100644 --- a/src/lib/zod/password.ts +++ b/src/lib/zod/password.ts @@ -3,3 +3,9 @@ import { z } from 'zod'; export const zodRequirePassword = z .string() .nonempty({ error: 'Please enter your password.' }); + +// Central manager's `PASSWORD_MAX_LENGTH`, which it enforces only on sign-up. Reset and Profile use it +// too, so every form that sets a Fabric account password accepts the same passwords. +export const ACCOUNT_PASSWORD_MAX_LENGTH = 256; +export const ACCOUNT_PASSWORD_TOO_LONG_ERROR = + `Password cannot be longer than ${ACCOUNT_PASSWORD_MAX_LENGTH} characters.`;