fix(nextly): stop a custom user field from displacing a built-in one#166
Conversation
A custom user field's name becomes a `user_ext` column and a key on the user object. The built-ins are assigned first and the custom fields over them, so a field named `email` replaces the real address, and one named `id` replaces the identity that `authenticate` hands to session creation. The merged Zod schema has the same precedence: a custom `email` of type text turns the built-in `z.string().email()` into a bare optional string, so a user can be created with no address at all. `defineConfig()` has always refused these names. Nothing else did: the admin dispatcher, `POST /api/user-fields` and `PATCH /api/user-fields/:id` all reach the service, and the service checked only that the field was not code-sourced. The one reserved-name check on that path was in the browser, and skipped in edit mode. The name check moves into a `checkUserFieldName` that `defineConfig()` and the service now share, so the two cannot drift, and the service applies it to every create. Name and type are also fixed at creation: both identify the column, and the reconciler only adds columns, so a rename strands the old column and its data while a type change leaves the column as it was. Values echoed back unchanged are still accepted. A row written before this check must not be honoured either, so `loadMergedFields` drops one whose name would displace a built-in and logs which field it dropped. That covers the column list, the select and both schemas at once. The row is left in place so the name can be recovered.
Adds the layer the other two structurally cannot reach. jsdom has no layout engine, so `getBoundingClientRect()` returns zeros and computed styles are never cascaded — which makes a whole class of defect not untested but untestable there. Two that shipped are the argument: the permissions matrix Name column reaching 1024px, and control outlines at 1.35:1 and 1.14:1 against a 3:1 requirement. Both were invisible to the 3,800 tests that already existed, and both are now measured. Fourteen tests, in about twenty seconds. Deliberately small: it only earns its place while people still run it, so anything expressible in Vitest belongs in Vitest. **It runs in CI.** That is the point rather than a detail — the repo already contains two Playwright specs written in July that have never run once, because nothing installed the runner and nothing invoked them, and their package's tsconfig excluded the folder so the broken import typechecked clean for months. A harness nobody runs is worse than none, because it reads as coverage. **It owns its database.** The suite runs the playground on port 3100 against `data/e2e.db`, emptied before the server opens it. That is why a test here may delete things: nothing in it was ever wanted. A separate port alone is not enough — two `next dev` processes on one app fight over `.next`, so the suite gets its own `distDir` and a contributor's dev server on 3000 keeps running beside it. It lives at the repo root because it tests neither a package nor an app, but the composed product: playground, admin, core and plugins together. `packages/` is what ships and `apps/` is what runs; the workspace glob and the lint filter name it explicitly instead. Three things worth knowing, each of which cost a wrong guess first: dev auto-login issues no session for `127.0.0.1`, so the admin renders an empty shell forever and every test times out; the theme class lands on `.nextly-admin.dark` rather than `html.dark`, so setting the latter by hand measures nothing; and console errors are not a failure signal, because dev mode reports its own HMR socket and the dashboard feature-detects seeding with a HEAD it expects to fail. The suite asserts on 5xx responses and uncaught exceptions, which mean something.
One changeset for the whole PR, all 18 packages at patch, written from what changes for someone using Nextly rather than from the diff. Leads with the two behaviour changes an upgrade can be felt through: a stored field whose name displaces a built-in stops being applied, and a field's name and type are fixed once it exists.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThis PR centralizes user-field validation, enforces immutable field identity, adds admin UI locks, and introduces an isolated Playwright E2E workspace with browser coverage and CI execution. ChangesUser Field Guardrails
Browser E2E Pipeline
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant CI
participant Turbo
participant Playwright
participant E2E_DB
participant Playground
CI->>Turbo: Run test:e2e
Turbo->>Playwright: Start browser tests
Playwright->>E2E_DB: Reset e2e SQLite files
Playwright->>Playground: Start isolated dev server
Playground-->>Playwright: Health endpoint ready
Playwright->>Playground: Visit /admin
Playground-->>Playwright: Return authenticated session cookie
Playwright->>Playground: Run admin and user-field tests
Suggested labels: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/nextly/src/domains/users/services/user-field-definition-service.ts (1)
291-301: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winStale docstring contradicts the new immutability guard.
The comment Note:
sourcecannot be changed after creation, butnamecan be updated. is now incorrect —assertIdentityUnchanged(called at Line 320) rejects anynameortypechange. Update the note to reflect that both are now fixed at creation.📝 Proposed fix
- * Note: `source` cannot be changed after creation, but `name` can be updated. + * Note: `source`, `name`, and `type` cannot be changed after creation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/nextly/src/domains/users/services/user-field-definition-service.ts` around lines 291 - 301, Update the docstring for the existing user field definition update method to state that both name and type are immutable after creation, matching the behavior enforced by assertIdentityUnchanged. Remove the outdated claim that name can be updated while preserving the source immutability note.
🧹 Nitpick comments (2)
e2e/tests/user-fields.spec.ts (1)
26-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winProvision the field independently for each test.
The suite explicitly depends on an earlier test creating
job_title. Running a test alone—or losing the creation test—causes unrelated guardrail coverage to fail or be skipped. Create an idempotent fixture in setup and clean it afterward.Also applies to: 102-115
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/tests/user-fields.spec.ts` at line 26, Update the user-fields test suite setup around test.describe.configure and the affected tests so each test independently provisions the job_title field through an idempotent fixture. Remove reliance on an earlier test’s creation, ensure the fixture runs before every test, and clean up the provisioned field afterward.e2e/global-setup.ts (1)
8-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated
storageStatepath definition. The session file path is defined twice: once as the exportedSTORAGE_STATEconstant, and again as a hardcoded literal in the project config. Consolidate to a single source of truth.
e2e/playwright.config.ts#L64-73: importSTORAGE_STATEfrom./global-setupand use it instead of the literal"./.playwright/session.json".e2e/global-setup.ts#L8: keep this as the canonical export; no change needed here beyond being imported by the config.Suggested fix
+import { STORAGE_STATE } from "./global-setup"; + export default defineConfig({ ... projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"], - storageState: "./.playwright/session.json", + storageState: STORAGE_STATE, }, }, ],🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/global-setup.ts` at line 8, Use the exported STORAGE_STATE constant from global-setup as the single source of truth: import it into the Playwright configuration and replace the hardcoded session path in the configuration’s storageState setting. Keep the existing STORAGE_STATE definition unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/ci.yml:
- Around line 122-124: Update the e2e job’s Checkout step using
actions/checkout@v7 to set persist-credentials to false, leaving the rest of the
job unchanged.
In `@e2e/playwright.config.ts`:
- Around line 43-45: Update the workers setting in the Playwright configuration
to use exactly one worker for local runs as well as CI, while preserving the
existing forbidOnly and fullyParallel settings.
In `@e2e/tests/user-fields.spec.ts`:
- Around line 117-120: Update the rename assertion around the PATCH request and
renamed response to validate the 400 error payload, asserting path is "name" and
code is "USER_FIELD_NAME_IMMUTABLE", matching the reserved-name assertions
above.
In `@e2e/tsconfig.json`:
- Around line 5-12: Update the e2e TypeScript configuration near the existing
"include" entries to enable allowJs, ensuring the included
e2e/scripts/reset-e2e-db.mjs file is type-checked by check-types while
preserving the current TypeScript and module settings.
In
`@packages/admin/src/components/features/settings/UserFieldForm/schemas/userFieldSchema.ts`:
- Around line 187-190: Update the edit-mode validation around the user field
name schema so immutable names bypass all name validation, including the base
regex, while preserving the existing create-mode checks and reserved-name
refinement. Use a mode-specific schema or compare the submitted name with its
original value to allow backend-valid stored names such as camelCase during
edits.
In `@turbo.jsonc`:
- Around line 297-311: The test:e2e task’s ^build dependency does not build the
packages whose dist outputs the suite consumes. Update the test:e2e
configuration to explicitly build the relevant packages before running, or wire
dependencies so nextly, `@nextlyhq/admin`, and playground builds are included;
preserve the uncached behavior and existing environment settings.
---
Outside diff comments:
In `@packages/nextly/src/domains/users/services/user-field-definition-service.ts`:
- Around line 291-301: Update the docstring for the existing user field
definition update method to state that both name and type are immutable after
creation, matching the behavior enforced by assertIdentityUnchanged. Remove the
outdated claim that name can be updated while preserving the source immutability
note.
---
Nitpick comments:
In `@e2e/global-setup.ts`:
- Line 8: Use the exported STORAGE_STATE constant from global-setup as the
single source of truth: import it into the Playwright configuration and replace
the hardcoded session path in the configuration’s storageState setting. Keep the
existing STORAGE_STATE definition unchanged.
In `@e2e/tests/user-fields.spec.ts`:
- Line 26: Update the user-fields test suite setup around
test.describe.configure and the affected tests so each test independently
provisions the job_title field through an idempotent fixture. Remove reliance on
an earlier test’s creation, ensure the fixture runs before every test, and clean
up the provisioned field afterward.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 99662f3f-5572-4a66-b104-e9f2096b6e52
⛔ Files ignored due to path filters (2)
.changeset/user-field-guards-and-browser-tests.mdis excluded by!.changeset/**pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml,!**/pnpm-lock.yaml
📒 Files selected for processing (25)
.github/workflows/ci.yml.gitignoreapps/playground/next.config.tse2e/README.mde2e/eslint.config.mjse2e/global-setup.tse2e/package.jsone2e/playwright.config.tse2e/scripts/reset-e2e-db.mjse2e/tests/admin-smoke.spec.tse2e/tests/permissions-matrix.spec.tse2e/tests/support/admin.tse2e/tests/user-fields.spec.tse2e/tsconfig.jsonpackages/admin/src/components/features/settings/UserFieldForm/UserFieldForm.tsxpackages/admin/src/components/features/settings/UserFieldForm/schemas/userFieldSchema.tspackages/nextly/src/di/registrations/register-users.tspackages/nextly/src/domains/users/services/__tests__/user-ext-reserved-names.test.tspackages/nextly/src/domains/users/services/__tests__/user-field-guards.integration.test.tspackages/nextly/src/domains/users/services/user-ext-schema-service.tspackages/nextly/src/domains/users/services/user-field-definition-service.tspackages/nextly/src/users/config/__tests__/check-user-field-name.test.tspackages/nextly/src/users/config/validate-user-config.tspnpm-workspace.yamlturbo.jsonc
Six review findings, each checked against the code before being acted on. One was a real bug reachable by a user. **The edit form refused to save a field with a camelCase name.** The server accepts `/^[a-zA-Z][a-zA-Z0-9_]*$/`, which is what a `defineConfig()` field usually looks like, but the form's name rule is lowercase-only and ran even in edit mode — where the input is disabled and the stored value is submitted straight back. So a field named `phoneNumber` opened fine and then would not save a label change, silently: the submit did nothing. The name is now validated only when the form is inventing it, on create. Proven by a browser test that creates such a field through the API and edits it through the form, which fails against the previous schema. The rest are the harness paying for its own keep: - `workers: 1`, not just `fullyParallel: false`. The latter serialises only within a spec file; separate files still get separate workers, and both would share one database and one session. Two files that create fields could race. Nothing to win by parallelising a 20s suite. - `allowJs` on the e2e tsconfig. Without it the `.mjs` reset script was included and then skipped — the exact silent gap the include comment claims to have closed. Confirmed by injecting an error and watching check-types catch it. - The e2e package now declares the packages the playground loads from dist. `dependsOn: ["^build"]` was building only its own config deps, so `turbo test:e2e` could run against a stale build. CI was safe only by an explicit build step. - `persist-credentials: false` on the browser-tests checkout. The job never pushes; leaving GITHUB_TOKEN in the git config across a pnpm install and a browser download is blast radius for nothing. - The rename test now asserts the error payload — path `name`, code `USER_FIELD_NAME_IMMUTABLE` — so "says why" is checked, not just the 400.
|
All 6 findings addressed in f57413c. Each verified against the code first; one was a real user-facing bug.
The one that mattered: the edit form ran its lowercase-only name rule in edit mode, where the input is disabled and the value round-trips untouched. The server accepts camelCase, so a field named Verification. E2E is now 15 tests (was 14, +1 for the camelCase case), all green in ~28s. One honest note on #6: CI was never actually broken — it has an explicit build step. The fix makes |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@e2e/tests/user-fields.spec.ts`:
- Around line 126-130: Extend the update flow after the redirect assertion in
the user-fields test to verify that the label persisted as “Mobile Number.”
Re-open the updated field or query the resulting list/API and assert the saved
label, using the existing field-identification and assertion helpers rather than
only checking navigation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 9d5de667-aebf-4c9e-8e74-533dc56cdeec
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml,!**/pnpm-lock.yaml
📒 Files selected for processing (7)
.github/workflows/ci.ymle2e/package.jsone2e/playwright.config.tse2e/tests/user-fields.spec.tse2e/tsconfig.jsonpackages/admin/src/components/features/settings/UserFieldForm/schemas/userFieldSchema.tsturbo.jsonc
🚧 Files skipped from review as they are similar to previous changes (6)
- e2e/tsconfig.json
- turbo.jsonc
- e2e/package.json
- packages/admin/src/components/features/settings/UserFieldForm/schemas/userFieldSchema.ts
- .github/workflows/ci.yml
- e2e/playwright.config.ts
The camelCase edit test proved the form navigated back to the list, which is what the original bug did not do — but a redirect only says the submit was not blocked, not that the PATCH wrote anything. Read the field back and assert the label is "Mobile Number". Confirmed it bites: with the label dropped from the update on the server, the URL assertion still passes and this one fails, which is the gap it closes.
A custom user field's name becomes a database column and a key on the user object. The built-ins are assigned first and the custom fields over them, so the custom value wins. That makes a field named
emailreplace the real address, and one namedidreplace the identityauthenticatehands to session creation.The merged Zod schema has the same precedence. Proven rather than reasoned:
A custom text field named
emailturns the built-inz.string().email()into a bare optional string.Password checking is not affected — the hash comes from a separate, column-scoped query that custom fields cannot reach, and
AuthService.verifyCredentialsnever touches the merge. What is affected is the identity of the object returned after the credential check, which is whatcreateSessionis given.defineConfig()has always refused these names. Nothing else did: the admin dispatcher,POST /api/user-fieldsandPATCH /api/user-fields/:idall reach the same service, and it checked only that the field was not code-sourced. The only reserved-name check on that path was in the browser, gated onmode === "create"— with a comment claiming the name was immutable in edit mode, which was false.What changed
The name check moved into one place both callers share.
checkUserFieldNameis now used bydefineConfig()and by the service, so the two lists cannot drift. The service applies it to every create, and the reason reaches the client as a field error rather than a generic failure.Name and type are fixed at creation. Both identify the backing column, and the reconciler only ever adds columns — a rename strands the old column and its data, and a type change leaves the column as it was. Values echoed back unchanged are still accepted, so a client sending a whole field back does not have to special-case two keys. The form matches the server and says why, rather than going quietly grey.
A row written before the check is no longer honoured.
loadMergedFieldsdrops a stored field whose name would displace a built-in and logs which one. One place covers the column list, the select and both schemas at once. The row is left alone so the name can be recovered by hand.Browser tests
Added, and running in CI — 14 tests, ~22 seconds, against a real server and a real database.
They cover the class of defect the other two layers structurally cannot: jsdom has no layout engine, so
getBoundingClientRect()returns zeros whether a bug is present or not, and it does not cascade CSS. The two defects that reached a person's eyes in this admin — a column at 1024px and control outlines at 1.35:1 — were invisible to the ~3,800 tests that already existed.Verified by reintroducing the regression:
The suite runs the playground on its own port against its own throwaway sqlite file, emptied before each run, so a test may delete things: nothing in it was ever wanted. It also takes its own
distDir, because a separate port is not enough — twonext devprocesses on one app fight over.next. A contributor's dev server on 3000 keeps running beside it.It lives at the repo root because it tests neither a package nor an app but the composed product.
packages/is what ships,apps/is what runs; the workspace glob and the lint filter name./e2eexplicitly.Verification
Every fix is pinned by a test that fails without it — 34 for the guards (24 unit, 10 merge-boundary), 17 integration, 14 browser.
Failures unchanged everywhere; passes up by exactly the new tests. Typecheck, lint and prettier clean.
Upgrade note
Two behaviour changes worth reading before merging:
Not in scope
minLength/maxLength/min/maxare read by the validator and stored by nothing, so a constraint declared indefineConfig()is silently dropped — including for code-first fields, where the public API documents it. That is a schema change and a broken published promise rather than the UI gap it was filed as, and it needs its own decisions. Written up separately.Summary by CodeRabbit