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.`;