Skip to content

fix(auth): accept the same passwords on reset, sign-up and Profile - #1892

Draft
dawsontoth wants to merge 2 commits into
claude/1290-maxlength-attributesfrom
claude/1883-reset-password-max-length
Draft

dawsontoth wants to merge 2 commits into
claude/1290-maxlength-attributesfrom
claude/1883-reset-password-max-length

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

⊙ Problem

Stacked on #1888 (claude/1290-maxlength-attributes). Review and merge that first, then this PR's base moves to stage. #1888 adds the tests that keep maxLength off every password input, and this PR keeps them passing.

Closes #1883.

Reset Password refuses any new password over 50 characters ("Password cannot be longer than 50 characters."). Sign-up and Profile have no limit at all. Neither behaviour matches central manager, so the three forms disagree with each other and with the server:

Form Studio before Central manager
Sign up no limit 256: validateAddUser applies VALIDATION_CONSTRAINTS.USER.PASSWORD_MAX_LENGTH (src/validation/validation.js:12, :238)
Reset password 50 no limit: validateResetPassword has newPassword: string.required() (:475)
Profile, change password no limit no limit: validateUpdateUser has password: string.optional() (:255)

(central-manager line numbers are from 38449f3f.)

So someone whose password manager generates a 64-character password can sign up with it and change to it from Profile, but can't reset to one like it. Separately, a sign-up password over 256 characters passes the form and is then refused by the server.

The 50 doesn't come from the server. fa320a150 ("Sync validation with CM and core") removed sign-up's 50-character limit, but Reset Password, which had copied that limit a few weeks earlier (b8b47d7d7), wasn't updated.

❓ Your call: the issue only needs Reset Password's 50 removed. I also gave sign-up and Profile the same 256 limit, because the issue's open question was which limit all three forms should share. If you'd rather only fix reset here, drop the SignUp.tsx and updateUserSchema.ts lines and their tests; the reset fix doesn't depend on them.

💡 Solution

There is now one shared constant, ACCOUNT_PASSWORD_MAX_LENGTH = 256, with one message, ACCOUNT_PASSWORD_TOO_LONG_ERROR, in src/lib/zod/password.ts. Sign-up, Reset Password and Profile's new-password field all use it. 256 is central manager's only password limit, so no form now refuses a password the server would accept on sign-up, and no form lets through one that sign-up would refuse.

The issue asked whether the stored hash has a shorter effective limit than this (bcrypt, for example, ignores everything past 72 bytes). It doesn't. Central manager stores account passwords as Harper users: resetUserPassword calls alter_user (src/resources/user/userHelpers.js:93), and sign-in goes through server.authenticateUser (src/resources/Login.js:86). Harper hashes with SHA-256 by default, or argon2id or MD5 if configured (harper/utility/password.ts:28-55 at 16e5c663c). All three hash the whole password, so a 256-character password counts in full. Harper's user_validation.ts has no length limit either.

Password inputs still have no maxLength attribute, as #1888 decided: if a pasted password were clamped, the password manager would save a password that never worked. An over-long password is shown in full, with the error under it.

❓ Your call: central manager only enforces 256 on sign-up. validateResetPassword and validateUpdateUser accept any length, so a client other than Studio can still set a password longer than sign-up allows. Studio now applies the same 256 limit everywhere, which stays within what the server accepts on every path. Should central manager also apply PASSWORD_MAX_LENGTH to reset and update? I haven't filed that. It would only make the server consistent with itself: no password is truncated and sign-in has no limit, so a longer password still works.

⚖️ Alternatives

  • Remove Reset Password's limit and leave the others alone. That would also fix Reset Password refuses passwords over 50 characters that sign-up and Profile accept #1883, but sign-up would still let through passwords the server refuses, and the next form to set a password would have to choose a limit all over again.
  • Apply a limit only where central manager enforces one (sign-up). That's strictly server-true for each request, but reset and Profile could then set a password that sign-up would refuse. One limit for every form that sets a Fabric account password is simpler to explain and to test.
  • Keep the limit in each schema without a shared constant. That's how the 50 drifted in the first place.

🔧 Changes

Instance-user password forms (instance Add User / Alter User, cluster Finish Setup) are not changed. Those passwords belong to Harper users on the cluster, not Fabric accounts, and Harper doesn't limit their length.

✅ Verification

  • Reset Password (ResetPassword.test.tsx): a 257-character password is shown in full with "Password cannot be longer than 256 characters." and is not submitted. 51- and 256-character passwords are submitted unchanged. The password inputs still have no maxLength.
  • Sign-up (SignUp.test.tsx): 257 characters is refused before any request is sent, and 256 is posted.
  • Profile (ProfileIndex.test.tsx): 257 characters shows the error and disables Save, and 256 plus a matching confirmation enables Save.
  • Fails on base: with Reset Password's .max(50) restored, the 51, 256 and 257 Reset Password tests all fail.
  • Mutation checks: with the constant set to 255, every 256- and 257-character test fails (six of them). With the .max removed from each of the three schemas, each form's 257-character test fails on its own.
  • Gate (Node 24.21.0): focused vitest exit 0; npx tsc -b exit 0; npx oxlint --format stylish . exit 0; npx dprint check exit 0. The pre-commit hook's full suite: 399 files, 3,681 tests passed.
  • End to end: not run in the browser. This is client-side form validation, and the tests render the real forms with the real schemas and resolver. The only thing they don't exercise is the network call, which is mocked.
  • Cross-model review (one round): codex (graded) found nothing. Of gemini's five minor findings I took one: the Reset Password acceptance test now waits for the submit with waitFor, the same way SignUp.test.tsx does, instead of asserting straight after act. I rejected the other four, with evidence:
    • Put the limit on zodRequirePassword itself. That helper also backs sign-in and the instance-user forms. Instance users are Harper users with no limit, and capping sign-in would lock out a password longer than 256 that was set through central manager's API.
    • The .or() union hides the message. It doesn't. The Profile test checks the exact "cannot be longer than 256" text and passes.
    • An unbounded confirmPassword could exhaust memory on the server. These schemas only run in the browser, and confirmPassword is never sent.
    • Remove the comment on the constant. Kept: it records why reset and Profile apply a limit the server doesn't enforce on those paths, which the code can't say.
  • Coverage gaps: both Cursor lenses failed to fetch (no-receipt) because the 1Password SSH agent refused to sign. The domain adjudication leg failed auth (expired Claude OAuth session), so the outside findings above were triaged by me, not adjudicated.
  • Parent merged in: 672f5027 merges fix(forms): stop typing at each field's length limit instead of refusing it on submit #1888's branch, which now carries stage (Edit roles in a visual editor, with JSON as the fallback #1893's role editor). No file conflicted here. Afterwards, focused vitest (profile, auth, role modals; 23 files / 222 tests) and tsc -b both exited 0.
  • gemini-code-assist feedback: its one thread proposed replacing Profile's .or(z.string().max(0)) union with a .refine(), because a union "returns errors for both" branches. I declined it with evidence. I ran zodResolver(UpdateUserSchema) on real inputs: 257 characters gives only "Password cannot be longer than 256 characters.", short gives only "Password must be 8 characters or more.", and empty or valid input gives no error. Each failing branch has one issue, and the resolver shows the closest branch with ties going to the first (@hookform/resolvers/zod/src/zod.ts:117-131). FormMessage renders error.message alone. The union also predates this PR.

🤖 Generated with Claude Code

Related PRs: #1888 overlaps (this PR is stacked on it and extends its ResetPassword test), 2 others independent
Complexity: easy

Review-Coverage: authored=claude; ran=codex,gemini; blocked=cursor-grok(no-receipt),cursor-composer(no-receipt),domain(auth); declined=cursor-kimi,cursor-muse; rounds=1; full=1 @ 672f502

Review-Attention: study ~20m (critical: updateUserSchema.ts; sensitive: ResetPassword.tsx, SignUp.tsx; raised: degraded review) @ 672f502

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 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request standardizes the maximum password length across the application to 256 characters by introducing central constants in password validation and applying them to the sign-up, password reset, and profile update schemas, along with updating and adding corresponding tests. Feedback on the changes suggests replacing the Zod union in the user update schema with a refine check to avoid confusing, multiple validation errors when validating empty or invalid passwords.

Comment on lines 23 to 25
.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)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using .or() to allow an empty string creates a ZodUnion. When validation fails (e.g., if the password is too long or too short), Zod evaluates both branches and returns errors for both, which can lead to multiple or confusing error messages in the UI.

We can avoid the union entirely by using a single ZodString with a .refine() check. This ensures only a single, predictable error message is generated. Note that in Zod v4, .refine() accepts error as the unified message parameter.

			.max(ACCOUNT_PASSWORD_MAX_LENGTH, { error: ACCOUNT_PASSWORD_TOO_LONG_ERROR })
			.refine((val) => val.length === 0 || val.length >= 8, {
				error: 'Password must be 8 characters or more.',
			}),
References
  1. In Zod v4, '.refine()' accepts 'error' as the unified message parameter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: the form only ever shows one message here, and it is the right one. Replaying the schema through zodResolver(UpdateUserSchema) (resolvers 5.9.1, zod 4.6.5): a 257-character password yields message: "Password cannot be longer than 256 characters." and "short" yields "Password must be 8 characters or more."; the empty and valid cases yield no error. Each failing branch has exactly one issue, and the resolver surfaces the closest branch with ties going to the first (@hookform/resolvers/zod/src/zod.ts:117-131), so the .max(0) branch's text never wins. The second branch's text appears only in types under criteriaMode: 'all', which no form sets, and FormMessage renders error.message alone (src/components/ui/form/FormMessage.tsx:7). ProfileIndex.test.tsx pins both messages through the real form. The .or(z.string().max(0)) union also predates this PR, which only adds the .max.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

@dawsontoth
dawsontoth added this pull request to stack #1904 October 11, 2026 21:25
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 68.49% 10499 / 15329
🔵 Statements 68.65% 11208 / 16326
🔵 Functions 62.22% 2735 / 4395
🔵 Branches 63.68% 8034 / 12615
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/ResetPassword.tsx 80% 50% 77.77% 80% 35, 59-66
src/features/auth/SignUp.tsx 85.36% 69.23% 86.66% 87.5% 131-135
src/features/profile/mutations/updateUserSchema.ts 100% 100% 100% 100%
src/lib/zod/password.ts 100% 100% 100% 100%
Generated in workflow #2181 for commit 672f502 by the Vitest Coverage Report Action

…' into claude/1883-reset-password-max-length
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reset Password refuses passwords over 50 characters that sign-up and Profile accept

1 participant