From 3281e8d1c90ea518ab0e52a59b5946bf13463e1a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:51:03 +0800 Subject: [PATCH 1/2] feat(plugin-form): pre-fill the current_user defaultValue token on create forms (#5683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine resolves defaultValue: 'current_user' to the acting user's id at INSERT (ObjectQL.applyFieldDefaults -> execCtx.userId), but no client code resolved it, so a spec-legal "default applicant = me" opened as an empty control and read as a broken change — the live prod shape on 报销流程's applicant lookup. This is the "surface what the server WILL supply" follow-up the #4069 notes promised. - permissions: PermissionContextValue.userId (from /me/permissions; null = unknown), threaded by MePermissionsProvider; the role-based provider and the no-provider fallback report null. - schemaDefaults: schemaDefaultValues/seedCreateValues accept an optional SeedContext{currentUserId}. current_user seeds the known user id on the token's two legal field shapes only (type 'user', or 'lookup' with reference/reference_to 'sys_user' — mirroring the field.zod #7127 authoring rule). NOW() and CEL envelopes stay server-owned: form-open time is not insert time. - All seven create seeding sites (ObjectForm + Modal/Drawer/Tabbed/ Split/Wizard) thread usePermissions().userId. Unknown user seeds nothing — empty control, key omitted, engine resolves: the exact pre-#5683 contract, which is why every #4047/#4068/#4069 test passes unchanged. Closes #5683 Co-Authored-By: Claude Opus 5 --- .../permissions/src/MePermissionsProvider.tsx | 3 + packages/permissions/src/PermissionContext.ts | 10 ++ .../permissions/src/PermissionProvider.tsx | 4 + packages/permissions/src/usePermissions.ts | 2 + packages/plugin-form/src/DrawerForm.tsx | 4 +- packages/plugin-form/src/ModalForm.tsx | 2 +- packages/plugin-form/src/ObjectForm.tsx | 8 +- packages/plugin-form/src/SplitForm.tsx | 4 +- packages/plugin-form/src/TabbedForm.tsx | 4 +- packages/plugin-form/src/WizardForm.tsx | 8 +- .../src/currentUserDefault.test.tsx | 165 ++++++++++++++++++ packages/plugin-form/src/schemaDefaults.ts | 75 +++++++- 12 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 packages/plugin-form/src/currentUserDefault.test.tsx diff --git a/packages/permissions/src/MePermissionsProvider.tsx b/packages/permissions/src/MePermissionsProvider.tsx index 363db5843b..70f69df2e9 100644 --- a/packages/permissions/src/MePermissionsProvider.tsx +++ b/packages/permissions/src/MePermissionsProvider.tsx @@ -290,6 +290,9 @@ export function MePermissionsProvider({ getRowFilter, getObjectApiOperations, roles: data?.roles ?? [], + // [objectui#5683] `null` while unloaded/anonymous — never ''. Consumers + // treat null as "unknown" and defer to the server. + userId: data?.userId ?? null, // [objectui#4656] Forward the raw signal — do NOT `?? []` this. A // backend predating ADR-0066 omits `systemPermissions` from the // response entirely, and defaulting that to `[]` here made it diff --git a/packages/permissions/src/PermissionContext.ts b/packages/permissions/src/PermissionContext.ts index c3ebafc756..a37ca12288 100644 --- a/packages/permissions/src/PermissionContext.ts +++ b/packages/permissions/src/PermissionContext.ts @@ -28,6 +28,16 @@ export interface PermissionContextValue { getObjectApiOperations: (object: string) => string[] | undefined; /** Current user roles */ roles: string[]; + /** + * [objectui#5683] The acting user's id, from `/me/permissions` (`userId`) — + * or `null` when the mounted provider has no backend answer to give (the + * role-based `PermissionProvider`, no provider at all, or an anonymous + * session). `null` means "unknown", and consumers must fall back to + * server-side behavior rather than substituting any other identity — the + * create-form `current_user` default seeding leaves the field empty and the + * key omitted, which is exactly the case the engine resolves at insert. + */ + userId: string | null; /** * [ADR-0066] System capabilities held by the user (union of permission-set * `systemPermissions`), when the backend actually reports them. diff --git a/packages/permissions/src/PermissionProvider.tsx b/packages/permissions/src/PermissionProvider.tsx index a52bc16335..b1c76c2a0e 100644 --- a/packages/permissions/src/PermissionProvider.tsx +++ b/packages/permissions/src/PermissionProvider.tsx @@ -131,6 +131,10 @@ export function PermissionProvider({ // operation set — return undefined so consumers keep current behavior. getObjectApiOperations: () => undefined, roles: userRoles, + // [objectui#5683] Role-based provider never learns who the user IS — + // unreported (`null`), so create-form current_user seeding stays + // server-side under this provider. + userId: null, // This role-based provider has no backend answer to give — it never // fetches /me/permissions — so ADR-0066 system capabilities are simply // unreported here: `undefined`, not `[]` (objectui#4656; a literal `[]` diff --git a/packages/permissions/src/usePermissions.ts b/packages/permissions/src/usePermissions.ts index 13dfc9b726..9c74b565f8 100644 --- a/packages/permissions/src/usePermissions.ts +++ b/packages/permissions/src/usePermissions.ts @@ -36,6 +36,8 @@ export function usePermissions(): PermissionContextValue & { getRowFilter: () => undefined, getObjectApiOperations: () => undefined, roles: [], + // [objectui#5683] No provider → identity unknown, defer to the server. + userId: null, // [objectui#4656] No provider mounted at all → no answer, not "holds // nothing". `undefined` matches MePermissionsProvider's own signal // for an unreported backend and keeps `hasCapabilities` fail-open. diff --git a/packages/plugin-form/src/DrawerForm.tsx b/packages/plugin-form/src/DrawerForm.tsx index 60ecb9cda3..a1d60e4e5f 100644 --- a/packages/plugin-form/src/DrawerForm.tsx +++ b/packages/plugin-form/src/DrawerForm.tsx @@ -49,6 +49,7 @@ import { import { deriveFieldGroupSections } from './fieldGroups'; import { sanitizeFormData } from './sanitize'; import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults'; +import { usePermissions } from '@object-ui/permissions'; import { useOccSave } from './occSave'; /** @@ -180,6 +181,7 @@ export const DrawerForm: React.FC = ({ className, }) => { const { fieldLabel, sectionLabel } = useSafeFieldLabel(); + const { userId: currentUserId } = usePermissions(); const { t } = useDiscardTranslation(); const previewMode = usePreviewMode(); const [objectSchema, setObjectSchema] = useState(null); @@ -256,7 +258,7 @@ export const DrawerForm: React.FC = ({ // Declared static defaults are this form's opening values (#4047) — // see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues)); + setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId })); setLoading(false); return; } diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 0207059024..20d4415465 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -325,7 +325,7 @@ export const ModalForm: React.FC = ({ // supplied initial values still win. See `schemaDefaults` for why // runtime defaults (`NOW()`, `current_user`, CEL envelopes) are left // to the server and why option-level `default` is not read here. - setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues)); + setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId: perms.userId })); setLoading(false); return; } diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 6feebfd6c2..ee9d2a8d9f 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -1012,9 +1012,13 @@ const SimpleObjectForm: React.FC = ({ // the create-mode `required` suppression (#4069), so seeding and validation // cannot disagree about which mode this form is in. const isCreateForm = isCreateFormMode(schema); + // [#5683] `currentUserId` lets the one client-resolvable token + // (`current_user`) pre-fill with the id the server would stamp anyway; + // null/unloaded seeds nothing and keeps the omit-and-let-the-engine-resolve + // contract above. const schemaDefaults = React.useMemo( - () => (isCreateForm ? schemaDefaultValues(objectSchema) : {}), - [objectSchema, isCreateForm], + () => (isCreateForm ? schemaDefaultValues(objectSchema, { currentUserId: perms.userId }) : {}), + [objectSchema, isCreateForm, perms.userId], ); const finalDefaultValues = { diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index b68bca4f70..4b3ade1ffa 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -29,6 +29,7 @@ import { cn } from '@object-ui/components'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults'; +import { usePermissions } from '@object-ui/permissions'; import { applyAutoColSpan, containerGridColsFor } from './autoLayout'; import { useOccSave } from './occSave'; @@ -116,6 +117,7 @@ export const SplitForm: React.FC = ({ className, }) => { const { fieldLabel } = useSafeFieldLabel(); + const { userId: currentUserId } = usePermissions(); const [objectSchema, setObjectSchema] = useState(null); const [formData, setFormData] = useState>({}); // OCC-guarded edit save + its conflict dialog (see occSave.tsx). @@ -164,7 +166,7 @@ export const SplitForm: React.FC = ({ // Declared static defaults are this form's opening values (#4047) — // see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues)); + setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId })); setLoading(false); return; } diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 238b99b8a2..4f173c02f3 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -19,6 +19,7 @@ import { cn } from '@object-ui/components'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { seedCreateValues, omitServerResolvedDefaults } from './schemaDefaults'; +import { usePermissions } from '@object-ui/permissions'; import { applyAutoColSpan, containerGridColsFor } from './autoLayout'; import { useOccSave } from './occSave'; @@ -191,6 +192,7 @@ export const TabbedForm: React.FC = ({ className, }) => { const { fieldLabel } = useSafeFieldLabel(); + const { userId: currentUserId } = usePermissions(); const [objectSchema, setObjectSchema] = useState(null); const [formData, setFormData] = useState>({}); // OCC-guarded edit save + its conflict dialog (see occSave.tsx). @@ -245,7 +247,7 @@ export const TabbedForm: React.FC = ({ // Declared static defaults are this form's opening values (#4047) — // see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues)); + setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId })); setLoading(false); return; } diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index eee5fcb5b5..9d6d1b0370 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -23,6 +23,7 @@ import { FormSectionContainer } from './FormSection'; import { SchemaRenderer, useSafeFieldLabel } from '@object-ui/react'; import { buildSectionFields as buildSectionFieldsShared } from './sectionFields'; import { seedCreateValues, omitServerResolvedDefaults, isCreateFormMode } from './schemaDefaults'; +import { usePermissions } from '@object-ui/permissions'; import { applyAutoColSpan, containerGridColsFor } from './autoLayout'; import { resolveSuccessNavigate, type SubmitBehavior } from './successBehavior'; import { resolveSubmitRedirect, submitRedirectScope } from './submitRedirect'; @@ -255,6 +256,7 @@ export const WizardForm: React.FC = ({ className, }) => { const { fieldLabel } = useSafeFieldLabel(); + const { userId: currentUserId } = usePermissions(); const { t } = useWizardTranslation(); const [objectSchema, setObjectSchema] = useState(null); const [formData, setFormData] = useState>({}); @@ -357,7 +359,7 @@ export const WizardForm: React.FC = ({ // Declared static defaults are this wizard's opening values (#4047) // — see `schemaDefaults` for the create-only boundary and for why // runtime defaults are left to the server. - setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues)); + setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId })); seededRef.current = true; } setLoading(false); @@ -594,7 +596,7 @@ export const WizardForm: React.FC = ({ // Back to a fresh step 1 for the next entry — "fresh" means the // same opening values the wizard had, defaults included (#4047), // not a blank object the first entry never started from. - setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues)); + setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId })); setCompletedSteps(new Set()); setCurrentStep(0); setResetNonce((n) => n + 1); @@ -649,7 +651,7 @@ export const WizardForm: React.FC = ({ if (schema.resetOnSuccess && schema.mode === 'create') { // Back to a fresh step 1 for the next entry — same opening values // as the first entry, defaults included (#4047). - setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues)); + setFormData(seedCreateValues(objectSchema, schema.initialData || schema.initialValues, { currentUserId })); setCompletedSteps(new Set()); setCurrentStep(0); setResetNonce((n) => n + 1); diff --git a/packages/plugin-form/src/currentUserDefault.test.tsx b/packages/plugin-form/src/currentUserDefault.test.tsx new file mode 100644 index 0000000000..491689c40b --- /dev/null +++ b/packages/plugin-form/src/currentUserDefault.test.tsx @@ -0,0 +1,165 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A CREATE form pre-fills the ONE runtime default the client can resolve + * exactly: `current_user` (#5683). + * + * The #4047/#4068 rule stands — runtime defaults are the server's to resolve, + * and seeding the literal token text would suppress that resolution. But the + * engine's `current_user` resolution IS "the acting user's id" + * (`ObjectQL.applyFieldDefaults` → `execCtx.userId`), and this session is that + * actor, so seeding `usePermissions().userId` previews the very value the + * server would stamp. This is #4069's promised "surface what the server WILL + * supply" follow-up. Live shape that motivated it: 报销流程's + * `applicant: { type: 'lookup', reference: 'sys_user', defaultValue: + * 'current_user' }` — spec-legal, engine-honoured, and yet the create form + * opened with 申请人 empty, reading as "the change did not work". + * + * Boundaries pinned here: + * + * 1. WITH a known user → seeded and SUBMITTED (the explicit id equals the + * engine's own resolution, by construction) + * 2. WITHOUT one (no provider / anonymous / role-based provider) → untouched: + * empty control, key omitted, the engine resolves at insert — the exact + * pre-#5683 contract, and why every older test in + * `createDefaults.test.tsx` passes unchanged + * 3. type gate — the spec allows the token on `user` and `lookup→sys_user` + * ONLY (`field.zod` #7127); a token smuggled onto any other field seeds + * nothing here, mirroring the validator's refusal + * 4. `NOW()` / CEL envelopes stay server-owned even with a known user — + * form-open time is not insert time, and the client cannot evaluate CEL + * 5. caller-supplied initial values outrank the seed, same as every other + * default + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { MePermissionsProvider } from '@object-ui/permissions'; +import { registerAllFields } from '@object-ui/fields'; +import { schemaDefaultValues, seedCreateValues } from './schemaDefaults'; +import { ObjectForm } from './ObjectForm'; + +registerAllFields(); + +const USER_ID = 'user-42'; + +/** The reported shape: a sys_user lookup defaulted to the acting user. */ +const OBJECT_SCHEMA = { + name: 'reimbursement_request', + fields: { + title: { type: 'text', label: 'Title' }, + applicant: { type: 'lookup', label: '申请人', reference: 'sys_user', defaultValue: 'current_user' }, + // objectui-types spelling of the reference key — must be honoured too. + reviewer: { type: 'lookup', label: 'Reviewer', reference_to: 'sys_user', defaultValue: 'current_user' }, + // The dedicated user field type is the token's other legal home. + owner_person: { type: 'user', label: 'Owner', defaultValue: 'current_user' }, + // Token on an ILLEGAL type: the engine's validator refuses this authoring; + // the seeding must not resolve it either. + supplier: { type: 'lookup', label: 'Supplier', reference: 'account', defaultValue: 'current_user' }, + // The other runtime token stays server-owned even when the user is known. + filed_at: { type: 'datetime', label: 'Filed at', defaultValue: 'NOW()' }, + }, +}; + +/** + * Authenticated `/me/permissions` payload for the acting user. The `*` object + * grant matters: an authenticated payload with NO entry for an object + * fail-closes `checkField` (#2926 ④) and the form would render zero fields. + */ +const ME_PERMISSIONS = { + authenticated: true, + userId: USER_ID, + tenantId: 't1', + roles: ['user'], + permissionSets: [], + objects: { '*': { allowCreate: true, allowRead: true, allowEdit: true } }, + fields: {}, +}; + +const makeDS = () => + ({ + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), + create: vi.fn().mockResolvedValue({ id: 'r1' }), + update: vi.fn().mockResolvedValue({ id: 'r1' }), + findOne: vi.fn().mockResolvedValue({ id: USER_ID, name: 'Current User' }), + query: vi.fn().mockResolvedValue({ data: [] }), + }) as any; + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => cleanup()); + +describe('schemaDefaultValues — current_user resolution (#5683)', () => { + it('seeds the acting user on the legal field shapes, and ONLY those', () => { + const seeded = schemaDefaultValues(OBJECT_SCHEMA, { currentUserId: USER_ID }); + expect(seeded.applicant).toBe(USER_ID); // lookup + reference + expect(seeded.reviewer).toBe(USER_ID); // lookup + reference_to + expect(seeded.owner_person).toBe(USER_ID); // type: user + expect('supplier' in seeded).toBe(false); // illegal type — validator territory + expect('filed_at' in seeded).toBe(false); // NOW() stays server-owned + expect('title' in seeded).toBe(false); + }); + + it('seeds nothing without a known user — the pre-#5683 contract', () => { + expect('applicant' in schemaDefaultValues(OBJECT_SCHEMA)).toBe(false); + expect('applicant' in schemaDefaultValues(OBJECT_SCHEMA, {})).toBe(false); + expect('applicant' in schemaDefaultValues(OBJECT_SCHEMA, { currentUserId: null })).toBe(false); + }); + + it('caller-supplied initial values outrank the seed', () => { + const seeded = seedCreateValues(OBJECT_SCHEMA, { applicant: 'someone-else' }, { currentUserId: USER_ID }); + expect(seeded.applicant).toBe('someone-else'); + expect(seeded.reviewer).toBe(USER_ID); + }); +}); + +describe('ObjectForm — current_user pre-fill on create (#5683)', () => { + const renderCreate = (ds: any) => + render( + + + , + ); + + it('submits the seeded acting-user id — the same value the engine would stamp', async () => { + const ds = makeDS(); + renderCreate(ds); + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + const title = document.body.querySelector('input[name="title"]') as HTMLInputElement; + fireEvent.change(title, { target: { value: 'Taxi' } }); + fireEvent.submit(document.body.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const payload = ds.create.mock.calls[0].at(-1); + expect(payload.applicant).toBe(USER_ID); + // The illegal-type token and the server-owned NOW() must not be invented. + expect(payload.supplier ?? undefined).toBeUndefined(); + expect(payload.filed_at ?? undefined).toBeUndefined(); + }); + + it('leaves the field empty and omitted without a permission provider', async () => { + const ds = makeDS(); + render( + , + ); + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + const title = document.body.querySelector('input[name="title"]') as HTMLInputElement; + fireEvent.change(title, { target: { value: 'Taxi' } }); + fireEvent.submit(document.body.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const payload = ds.create.mock.calls[0].at(-1); + // Key OMITTED — absence is what makes the engine resolve the token. + expect('applicant' in payload).toBe(false); + }); +}); diff --git a/packages/plugin-form/src/schemaDefaults.ts b/packages/plugin-form/src/schemaDefaults.ts index d876bc959b..6ff8b34cfb 100644 --- a/packages/plugin-form/src/schemaDefaults.ts +++ b/packages/plugin-form/src/schemaDefaults.ts @@ -60,6 +60,13 @@ * the engine resolves. `ObjectForm` had been seeding them verbatim — that is * fixed here along with the missing-seeding half. * + * ONE token is additionally RESOLVED (not seeded literally) when the caller + * threads a {@link SeedContext}: `current_user`, whose engine-side resolution + * is "the acting user's id" — a value this very session knows exactly. That is + * the "surface what the server WILL supply" follow-up the #4069 notes promised + * (#5683): the seeded id is the same one `applyFieldDefaults` would stamp, so + * submitting it explicitly and omitting it are equivalent by construction. + * * ## Create only * * An EDIT form shows a persisted row and must show it as the server holds it. @@ -69,6 +76,7 @@ */ import { isMissingForRequired, isRuntimeDefault } from '@object-ui/core'; +import { isCurrentUserDefaultToken } from '@objectstack/spec/data'; // Re-exported (not re-implemented) so this package's long-standing import site // keeps working while there is exactly ONE classifier in the workspace. It @@ -79,7 +87,25 @@ export { isRuntimeDefault }; /** An object schema as the data source serves it (`{ fields: { [name]: def } }`). */ interface ObjectSchemaLike { - fields?: Record; + fields?: Record< + string, + { defaultValue?: unknown; type?: unknown; reference?: unknown; reference_to?: unknown } | undefined + >; +} + +/** + * The session facts create-form seeding may draw on (#5683). Callers thread it + * from `usePermissions()`; every key is optional so existing call sites keep + * compiling and behaving unchanged until they opt in. + */ +export interface SeedContext { + /** + * The acting user's id (`usePermissions().userId`), or null/undefined when + * unknown — no provider, anonymous, or still loading. Unknown seeds nothing: + * the field stays empty and OMITTED from the payload, which is the case the + * engine's own `current_user` resolution handles at insert. + */ + currentUserId?: string | null; } /** @@ -170,17 +196,55 @@ export function isRequiredInForm( * Returns a fresh object (never shared), and `{}` for a missing/!object schema * so callers can spread it unconditionally. */ -export function schemaDefaultValues(objectSchema: ObjectSchemaLike | null | undefined): Record { +export function schemaDefaultValues( + objectSchema: ObjectSchemaLike | null | undefined, + ctx?: SeedContext, +): Record { const fields = objectSchema?.fields; if (!fields || typeof fields !== 'object') return {}; const defaults: Record = {}; for (const name of Object.keys(fields)) { - const dv = fields[name]?.defaultValue; - if (isSeedableDefault(dv)) defaults[name] = dv; + const f = fields[name]; + const dv = f?.defaultValue; + if (isSeedableDefault(dv)) { + defaults[name] = dv; + } else if (isCurrentUserSeedField(f) && ctx?.currentUserId) { + // #5683 — the ONE runtime token the client can resolve exactly. The + // engine's `current_user` resolution is "the acting user's id" + // (`ObjectQL.applyFieldDefaults` → `execCtx.userId`), and this session + // IS that actor, so seeding `usePermissions().userId` pre-fills the very + // value the server would have stamped — no second default contract, a + // preview of the same one. `NOW()` and CEL envelopes stay server-owned: + // form-open time is NOT insert time, and the client cannot evaluate CEL, + // so seeding either would submit a DIFFERENT value than the declaration + // resolves to. With no known user (`ctx` absent, provider-less, or + // anonymous) the field seeds nothing and the pre-#5683 contract holds: + // empty control, key omitted, server resolves. + defaults[name] = ctx.currentUserId; + } } return defaults; } +/** + * Is this field one the `current_user` token may legally default — and does it + * declare that token? + * + * The type gate mirrors the spec's own authoring rule (`field.zod` #7127: + * `current_user` is legal "on `user` or `lookup` with `reference: 'sys_user'` + * only"), so a token that somehow reached an illegal field type is left alone + * here exactly as the engine's validator would refuse it. `reference` is the + * ObjectStack schema spelling and `reference_to` the objectui-types one; both + * are honoured, same as `LookupField`'s own reader. + */ +function isCurrentUserSeedField( + f: { defaultValue?: unknown; type?: unknown; reference?: unknown; reference_to?: unknown } | undefined, +): boolean { + if (!f || !isCurrentUserDefaultToken(f.defaultValue)) return false; + if (f.type === 'user') return true; + return f.type === 'lookup' && (f.reference === 'sys_user' || f.reference_to === 'sys_user'); +} + /** * The initial values a CREATE form opens with: the schema's static defaults, * overlaid with whatever the caller supplied. @@ -193,8 +257,9 @@ export function schemaDefaultValues(objectSchema: ObjectSchemaLike | null | unde export function seedCreateValues( objectSchema: ObjectSchemaLike | null | undefined, initial?: Record | null, + ctx?: SeedContext, ): Record { - return { ...schemaDefaultValues(objectSchema), ...(initial ?? {}) }; + return { ...schemaDefaultValues(objectSchema, ctx), ...(initial ?? {}) }; } /** From 2fa9acb88431ce1840e0d4274ef06d8888e37774 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:54:23 +0800 Subject: [PATCH 2/2] changeset for #5683 Co-Authored-By: Claude Opus 5 --- .changeset/current-user-default-prefill-5683.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/current-user-default-prefill-5683.md diff --git a/.changeset/current-user-default-prefill-5683.md b/.changeset/current-user-default-prefill-5683.md new file mode 100644 index 0000000000..b519b0fb64 --- /dev/null +++ b/.changeset/current-user-default-prefill-5683.md @@ -0,0 +1,6 @@ +--- +'@object-ui/permissions': minor +'@object-ui/plugin-form': minor +--- + +Create forms pre-fill the `current_user` defaultValue token with the acting user (#5683). `PermissionContextValue` gains `userId` (from `/me/permissions`; `null` = unknown), and the create-form seeding resolves `defaultValue: 'current_user'` on `user` / `lookup→sys_user` fields to that id — the same value the engine stamps at insert, so the pre-fill is a preview of the server's own resolution, not a second default contract. Unknown user (no provider / anonymous / role-based provider) seeds nothing and keeps the omit-and-let-the-engine-resolve behavior. `NOW()` and CEL defaults stay server-owned.