diff --git a/.changeset/29187-form-upfront-write-refusal.md b/.changeset/29187-form-upfront-write-refusal.md new file mode 100644 index 00000000000..1fb05cecc49 --- /dev/null +++ b/.changeset/29187-form-upfront-write-refusal.md @@ -0,0 +1,12 @@ +--- +'@object-ui/console': patch +--- + +fix(console): an internal form refuses a write the caller holds no grant for before it draws a field + +Clause-②: no +Metadata-protocol: none + +A member who may read an object but not create it could open a form link (`/forms/NAME` or `/apps/APP/forms/NAME`), type the whole record, and learn only on Submit that the server refuses it, losing the typing. The internal form page now asks the caller's grant as soon as the form has loaded: a create form the create grant, an edit form (`?recordId=`) the update grant, through the console's affordance map (`createFormFields`, `editFormFields`). Refused, the page draws the submit-time refusal's own localized sentence ("You don't have permission to save this record.") and no field. With the grant, the form is unchanged. + +The app-less `/forms/NAME` route now sits under the same permissions layout as `/home` and `/apps/APP/*`, so both form URLs read the one `/auth/me/permissions` answer the console already holds, and a hop from Home or an app asks nothing again. The URL is unchanged. The anonymous `/f/SLUG` route is unchanged. No metadata key and no server change. diff --git a/ui/apps/console/src/App.tsx b/ui/apps/console/src/App.tsx index 5f009fe0ca9..a64ee41c433 100644 --- a/ui/apps/console/src/App.tsx +++ b/ui/apps/console/src/App.tsx @@ -311,19 +311,6 @@ export function App() { * layer too late for a capability whose declared meaning is * "Enter the Studio metadata-design surfaces". */} {studioRoutes} - {/* Internal authed form — same renderer as `/f/:slug`, different - * submit path, and (objectui#4109) rendered INSIDE the console - * shell instead of as a bare page. The route stays exactly where - * it is: per the 2026-08-10 ruling on objectstack#7245 the missing - * chrome was the defect, not the navigation, so deep links keep - * working. `InternalFormRoute` owns the chrome + the "land on the - * created record" target; the public path above stays chrome-less - * on purpose (an anonymous visitor has no console to be in). */} - - - - } /> {/* Package documentation (ADR-0046): a platform-level portal * lists every installed `doc` (grouped by package), and one * viewer route renders any item; cross-references between docs @@ -367,6 +354,25 @@ export function App() { }> } /> } /> + {/* Internal authed form — same renderer as `/f/:slug`, different + * submit path, and (objectui#4109) rendered INSIDE the console + * shell instead of as a bare page. The URL is unchanged: per the + * 2026-08-10 ruling on objectstack#7245 the missing chrome was + * the defect, not the navigation, so deep links keep working. + * `InternalFormRoute` owns the chrome + the "land on the created + * record" target; the public `/f/:slug` above stays chrome-less + * and outside this layout on purpose (an anonymous visitor has + * no console to be in, and no permissions to ask about). + * + * Under this layout rather than beside it (objectstack#29187): + * `FormPage` refuses a form whose write the caller holds no + * grant for BEFORE drawing a field, and it reads that grant + * from this layout's `/auth/me/permissions` answer — the one + * its in-app twin `/apps/:appName/forms/:name` already reads. + * One answer for both URLs, so one form never behaves + * differently by URL; a hop from `/home` or an app keeps the + * layout, so the answer is not asked again. */} + } /> {/* Dev-only: ADR-0001 master-detail subform verification harness. */} ({ + /** Every URL the console's authenticated fetcher was asked for. */ + calls: [] as string[], +})); + +const METADATA: MetadataContextValue = { + apps: [{ name: 'crm', label: 'CRM', active: true, navigation: [] }] as never, + objects: [], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready', +} as unknown as MetadataContextValue; + +/** A member who may read `qar8_fsb` and not create it. */ +function permissionsAnswer(): MePermissionsAuthenticatedResponse { + return { + authenticated: true, + userId: 'u_member', + tenantId: null, + positions: [], + hasAnyQueue: false, + permissionSets: ['member'], + systemPermissions: [], + objects: { qar8_fsb: { allowRead: true } }, + fields: {}, + } as MePermissionsAuthenticatedResponse; +} + +function respond(input: unknown): Promise { + const url = String(input); + const body = url.includes('/auth/me/permissions') ? permissionsAnswer() : {}; + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => body, + } as unknown as Response); +} + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + AuthProvider: ({ children }: { children?: ReactNode }) => <>{children}, + useAuth: () => ({ user: { id: 'u_member', name: 'Member', email: 'member@example.com' }, isAuthenticated: true, isLoading: false }), + createAuthenticatedFetch: () => (input: unknown) => { + net.calls.push(String(input)); + return respond(input); + }, +})); + +vi.mock('../components/ProtectedRoute', () => ({ + ProtectedRoute: ({ children }: { children?: ReactNode }) => ( + {children} + ), +})); + +/** What a form route sees of the caller's grants on the form's object. */ +function GrantProbe({ testId }: { testId: string }) { + const ctx = useContext(PermCtx); + const { isLoaded, can } = usePermissions(); + const verdict = ctx === null ? 'no-perm-ctx' : isLoaded ? `create:${String(can('qar8_fsb', 'create'))}` : 'not-loaded'; + return
{verdict}
; +} + +/** Stands where Home's page renders, with a way to follow a form link. */ +function HomeProbe() { + const navigate = useNavigate(); + return ( + + ); +} + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + ConsoleShell: ({ children }: { children?: ReactNode }) => <>{children}, + ConsoleToaster: () => null, + LoadingScreen: () =>
, + DefaultHomePage: () => , + DefaultAppContent: () => null, + BuilderLanding: () => null, + StudioDesignSurface: () => null, + getProductName: () => 'ObjectOS', + getFaviconUrl: () => '', +})); + +vi.mock('../../../../packages/app-shell/src/layout/AppHeader', () => ({ AppHeader: () => null })); +vi.mock('../../../../packages/app-shell/src/chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../../../packages/app-shell/src/hooks/useAiSurface', async (importOriginal) => ({ + ...(await importOriginal>()), + useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }), +})); + +vi.mock('../LocalizationFetchProvider', () => ({ + LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); +vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null })); +vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null })); +vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null })); +vi.mock('../components/FormPage', () => ({ FormPage: () => })); +vi.mock('../components/InternalFormRoute', () => ({ + InternalFormRoute: () => , + InAppFormRoute: () => , +})); +vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null })); +vi.mock('../components/FaviconSync', () => ({ FaviconSync: () => null })); +vi.mock('../pages/SharedRecordPage', () => ({ default: () => null })); +vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: () => null })); +vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: () => null })); +vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: () => null })); +vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: () => null })); +vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: () => null })); +vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: () => null })); +vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: () => null })); +vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: () => null })); +vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: () => null })); +vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: () => null })); +vi.mock('../dev/DevLists', () => ({ DevLists: () => null })); +vi.mock('../dev/DevModal', () => ({ DevModal: () => null })); +vi.mock('../dev/DevLookup', () => ({ DevLookup: () => null })); +vi.mock('../dev/DevRowActions', () => ({ DevRowActions: () => null })); + +import { App } from '../App'; + +const permissionsCalls = () => net.calls.filter((u) => u.includes('/auth/me/permissions')); + +function renderAt(url: string) { + window.history.pushState({}, '', url); + return render(); +} + +beforeEach(() => { + net.calls = []; + localStorage.clear(); + sessionStorage.clear(); +}); +afterEach(() => { + cleanup(); + window.history.pushState({}, '', '/'); +}); + +describe('the form routes and the permissions answer (#29187)', () => { + it('/forms/NAME renders under the permissions answer, asked once', async () => { + renderAt('/forms/qar8_fsb.form'); + await waitFor(() => expect(screen.getByTestId('internal-form')).toHaveTextContent('create:false')); + expect(permissionsCalls()).toHaveLength(1); + }); + + it('/forms/NAME waits on the answer the way /home and /apps do: the loading screen first, no form', async () => { + renderAt('/forms/qar8_fsb.form'); + expect(screen.getByTestId('loading-screen')).toBeInTheDocument(); + expect(screen.queryByTestId('internal-form')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId('internal-form')).toBeInTheDocument()); + }); + + it('/home then a form link keeps the one answer: /auth/me/permissions asked ONCE in total', async () => { + renderAt('/home'); + await waitFor(() => expect(screen.getByRole('button', { name: 'open form' })).toBeInTheDocument()); + await act(async () => { + screen.getByRole('button', { name: 'open form' }).click(); + }); + await waitFor(() => expect(screen.getByTestId('internal-form')).toHaveTextContent('create:false')); + expect(permissionsCalls()).toHaveLength(1); + }); + + it('/f/SLUG stays outside it: no permission context and no /auth/me/permissions request', async () => { + renderAt('/f/feedback'); + await waitFor(() => expect(screen.getByTestId('public-form')).toHaveTextContent('no-perm-ctx')); + expect(permissionsCalls()).toHaveLength(0); + }); +}); diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index 0c59879d2a4..00217b07bf6 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -126,10 +126,12 @@ import { isMissingForRequired, isRuntimeDefault, isServerOwnedValue, + resolveAffordance, resolveFieldRuleState, type FieldRuleFaults, type FieldRulePredicate, } from '@object-ui/core'; +import { usePermissions } from '@object-ui/permissions'; import { omitServerResolvedDefaults, optionListDefault, @@ -2547,6 +2549,15 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { */ const hostApiFetch = useContext(SchemaRendererContext)?.apiFetch; const channel = mode === 'internal' ? hostApiFetch : undefined; + /** + * The caller's permissions, for the up-front write refusal below + * (objectstack-ai/objectstack#29187): the `/auth/me/permissions` answer the + * console's permissions layout holds over BOTH internal routes — + * `/forms/:name` and `/apps/:appName/forms/:name` (`App.tsx`). With no + * provider above (the public `/f/:slug`, a bare test harness) every grant + * reads open, so nothing changes there and the server still enforces. + */ + const perms = usePermissions(); const [loading, setLoading] = useState(true); /** One string, or one line per reason a server refusal marked on no row. */ @@ -3116,6 +3127,36 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { } if (!loaded) return null; + // A write this caller holds no grant for is refused BEFORE a field is drawn + // (objectstack-ai/objectstack#29187). The server refuses it at Submit anyway, + // and since #29019 says so in a sentence; but a create form offered to a + // member who may only read the object let them type the whole record first, + // and lose it. The verdict is the affordance map's form row, the one the + // app's own create and edit forms read (`createFormFields`, `editFormFields`): + // a create form asks the create grant, an edit form (`?recordId=`) the update + // grant, each intersected with the object's effective API operations. The sentence + // is the submit-time refusal's own (`refusalText`), so the two read alike. + // Internal mode only: `/f/:slug` is anonymous and has no grants to read. + if ( + mode === 'internal' && + !resolveAffordance(isCreateForm ? 'createFormFields' : 'editFormFields', { + objectName: loaded.object, + perms, + }).allowed + ) { + return ( +
+
+ {t('form.noPermissionToSave')} +
+
+ ); + } + // `created-record` reaches this branch only on the fallback path above (no // id in the response, or nowhere to land) — a successful redirect navigates // away instead. It carries no author-supplied title/message, so it renders diff --git a/ui/apps/console/src/components/FormPage.upfrontWriteRefusal-29187.test.tsx b/ui/apps/console/src/components/FormPage.upfrontWriteRefusal-29187.test.tsx new file mode 100644 index 00000000000..096ba049a11 --- /dev/null +++ b/ui/apps/console/src/components/FormPage.upfrontWriteRefusal-29187.test.tsx @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#29187 — an internal form whose write the caller + * holds no grant for is refused up front, not at Submit. + * + * A member with read but no create right opened a form link, typed the whole + * record, and only on Submit learned they may not create (#29019 made that + * refusal read well; the typing was still lost). The page now asks the + * affordance map's form row against the console's `/auth/me/permissions` + * answer as soon as the form has loaded: a create form the create grant, an + * edit form (`?recordId=`) the update grant. Refused, it draws the submit-time + * refusal's own sentence and NO field, so there is nothing to type and lose. + * + * What is real: `FormPage`, the real `MePermissionsProvider` fed the served + * payload shape (`initialPermissions`), the affordance resolver and the i18n + * pack. The network is a stubbed `fetch` answering the view, object and + * record reads; a POST would be counted, and none is ever made here. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions'; +import { createI18n } from '@object-ui/i18n'; +import { FormPage } from './FormPage'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const OBJECT = 'qar8_fsb'; +const REFUSAL = "You don't have permission to save this record."; + +const FIELDS = { + name: { type: 'text', label: 'Name' }, + email: { type: 'text', label: 'Email' }, +}; + +let posts = 0; + +function answer(status: number, body: unknown): Response { + const text = JSON.stringify(body); + return { + ok: status < 400, + status, + statusText: status < 400 ? 'OK' : 'Refused', + json: async () => JSON.parse(text), + text: async () => text, + } as unknown as Response; +} + +beforeEach(() => { + posts = 0; + createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + const view = { + name: `${OBJECT}.form`, + object: OBJECT, + viewKind: 'form', + label: 'Feedback', + config: { type: 'simple', sections: [{ fields: ['name', 'email'] }] }, + }; + const publicPayload = { + slug: 'feedback', + object: OBJECT, + label: 'Feedback', + form: { type: 'simple', sections: [{ fields: ['name', 'email'] }] }, + objectSchema: { name: OBJECT, fields: FIELDS }, + }; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const u = String(url); + if (init?.method && init.method !== 'GET') { + posts += 1; + return answer(403, { error: 'denied', code: 'PERMISSION_DENIED' }); + } + if (u.includes('/meta/view/')) return answer(200, view); + if (u.includes('/meta/object/')) return answer(200, { name: OBJECT, label: 'Feedback', fields: FIELDS }); + if (u.includes(`/data/${OBJECT}/r1`)) { + return answer(200, { object: OBJECT, id: 'r1', record: { id: 'r1', name: 'Ada', email: 'ada@example.com' } }); + } + if (u.includes('/forms/feedback')) return answer(200, publicPayload); + throw new Error(`unstubbed fetch: ${u}`); + }), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** The served `/auth/me/permissions` answer, granting `grants` on the form's object. */ +function permissions(grants: Record): MePermissionsAuthenticatedResponse { + return { + authenticated: true, + userId: 'u_member', + tenantId: null, + positions: [], + hasAnyQueue: false, + permissionSets: ['member'], + systemPermissions: [], + objects: { [OBJECT]: { allowRead: true, ...grants } }, + fields: {}, + } as MePermissionsAuthenticatedResponse; +} + +/** An internal route, mounted under the permissions answer the console holds over it. */ +function renderInternal(url: string, grants: Record) { + return render( + + + + } /> + } /> + + + , + ); +} + +/** Refused: the sentence, and not one field of the form. */ +async function expectRefusedUpFront() { + const banner = await screen.findByTestId('form-write-refused'); + expect(banner).toHaveTextContent(REFUSAL); + expect(banner).toHaveAttribute('role', 'alert'); + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); + expect(screen.queryAllByRole('textbox')).toHaveLength(0); + expect(screen.queryByRole('button', { name: /Submit/ })).not.toBeInTheDocument(); + expect(posts).toBe(0); +} + +/** Offered: the form, unchanged. */ +async function expectFormOffered() { + expect(await screen.findByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Submit/ })).toBeInTheDocument(); + expect(screen.queryByTestId('form-write-refused')).not.toBeInTheDocument(); +} + +describe('internal form: a write the caller holds no grant for is refused before a field is drawn (#29187)', () => { + it('a create form without the create grant: the refusal, no fields, on /forms/NAME', async () => { + renderInternal(`/forms/${OBJECT}.form`, {}); + await expectRefusedUpFront(); + }); + + it('the same form on /apps/APP/forms/NAME reads the same: refused', async () => { + renderInternal(`/apps/crm/forms/${OBJECT}.form`, {}); + await expectRefusedUpFront(); + }); + + it('a create form with the create grant: the form, unchanged', async () => { + renderInternal(`/forms/${OBJECT}.form`, { allowCreate: true }); + await expectFormOffered(); + }); + + it('a create form asks the CREATE grant: the update grant alone does not open it', async () => { + renderInternal(`/forms/${OBJECT}.form`, { allowEdit: true }); + await expectRefusedUpFront(); + }); + + it('an edit form without the update grant: refused, even with the create grant', async () => { + renderInternal(`/forms/${OBJECT}.form?recordId=r1`, { allowCreate: true }); + await expectRefusedUpFront(); + }); + + it('an edit form with the update grant: the form, prefilled from the record', async () => { + renderInternal(`/forms/${OBJECT}.form?recordId=r1`, { allowEdit: true }); + await expectFormOffered(); + await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Ada')); + }); +}); + +describe('/f/SLUG is unchanged (#29187)', () => { + it('the anonymous form draws its fields even under an answer that grants no create', async () => { + render( + + + + } /> + + + , + ); + await expectFormOffered(); + }); + + it('and with no permissions answer above it at all, as App.tsx mounts it', async () => { + render( + + + } /> + + , + ); + await expectFormOffered(); + }); +});