Skip to content

Commit 96fb7b5

Browse files
fix(console): refuse an internal form's write up front when the caller holds no grant (#29407)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbDSw9FNbgkETV4DyaGwPTDJ)_ Fixes #29187 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:成员打开一个自己没有新建权限的表单链接时,页面一加载就直接显示「您没有权限保存这条记录。」,不再画出可填写的表单;编辑表单(带记录 ID)同理按编辑权限判断。两个登录后的表单地址行为一致,匿名公开表单不变。 - **为什么改**:以前只读成员能把整张表单填完,点提交才被拒,填的内容白费。 - **风险与回滚**:风险低,只影响没有对应权限的人;有权限的人看到的表单完全不变。回滚即还原这一个 PR。 - **先看**:`ui/apps/console/src/components/FormPage.tsx` 新增的拒绝分支;`ui/apps/console/src/App.tsx` 中 `/forms/:name` 移入权限布局的那一处。 - **两分钟验证**:用只有读权限的成员打开 `/_console/forms/showcase_project.form`,应只看到一行红色的无权限提示,没有任何输入框;管理员打开同一链接仍是完整表单。 - **需要你做的**:无,席位落地。 ## What changes - **One mechanism, named: `/forms/NAME` moves under `ConsolePermissionsScope`.** The app-less internal form route was a sibling of the console's permissions layout, under `ProtectedRoute` alone, while its in-app twin `/apps/APP/forms/NAME` sat under it. It is now a child of the same pathless layout route as `/home` and `/apps/APP/*`, so both form URLs read the one `/auth/me/permissions` answer the console already holds. The URL is unchanged, deep links keep working, and a hop from Home or an app keeps the layout, so the answer is not asked again. `/f/SLUG` stays outside it (anonymous, no answer, no request). - **`FormPage` refuses up front.** Once the form has loaded (it needs the form view to know its object), internal mode asks the affordance map's form row through `resolveAffordance` from `@object-ui/core`, the predicate the app's own create and edit forms read: `createFormFields` (the create grant) for a create form, `editFormFields` (the update grant) for an edit form (`?recordId=`), each intersected with the object's effective API operations. Refused, the page draws the submit-time refusal's own sentence (`form.noPermissionToSave`, the one #29019's PR #29184 uses) in a `role="alert"` banner and no field, no Submit. With the grant, nothing changes. No new i18n key, no metadata key, no server change. - With no permissions provider above it (the public route, a bare harness), `usePermissions()` reads every grant open, so `/f/SLUG` and every existing `FormPage` test behave as before. ## Tests - `FormPage.upfrontWriteRefusal-29187.test.tsx` (8 cases, real `MePermissionsProvider` fed the served payload shape): create without the create grant is refused with no textbox and no POST, on `/forms/NAME` and `/apps/APP/forms/NAME`; create with it renders the form; the update grant alone does not open a create form; an edit form without the update grant is refused even with create; with it the form renders prefilled; `/f/SLUG` draws its fields both under a no-create answer and with no answer at all. - `FormPage.routePermissions-29187.test.tsx` (4 cases, real `App` route tree and `ConsolePermissionsScope`): `/forms/NAME` renders under the answer, asked once; the loading screen shows before it; `/home` then a form link asks `/auth/me/permissions` once in total; `/f/SLUG` has no permission context and makes no request. - Ablation: replacing `mode === 'internal' &&` in the new guard with a never-true test (`node scripts/ablation-replace.mjs`, restored to `git diff HEAD` empty) reds exactly the four refusal cases; the four grant and public cases stay green. ## Acceptance notes - **Requests on a cold `/forms/NAME` deep link.** The permission check costs one request, `/auth/me/permissions`. The same layout also holds `LauncherAppsProvider`, so a cold deep link now asks `/me/apps` too, as `/home` does: the form page's header launcher now narrows the apps it offers the way Home's does, where before it listed every app. A hop from Home or an app asks neither again. - **The policy half reads the default bucket.** `FormPage`'s object payload keeps only `name`, `label`, `fields` and `fieldGroups`, so `resolveAffordance` is given no `objectSchema` and an object-level `userActions` opt-out or a managed bucket does not refuse here; the grant and the effective API operations do. The card asked for the grant; widening the payload is a separate change. - The refusal state carries no button of its own; both routes keep the console header for navigation. ## Verification Live on a fresh showcase (`pnpm dev -- --fresh`, source Console on Vite), a member created through `auth/admin/create-user`, holding only the `everyone` baseline: `showcase_project` read only, `showcase_task` read + create, no edit. Before = `main` at `5780fa49`, after = this branch. | Case | Before | After | |:--|:--|:--| | member, `/forms/showcase_project.form` | full form, 8 inputs, Submit | refusal sentence, 0 inputs, no Submit | | member, `/apps/showcase_app/forms/showcase_project.form` | full form, Submit | refusal sentence, 0 inputs, no Submit | | member, `/forms/showcase_task.form` (create allowed) | form | form, unchanged | | member, `/forms/showcase_task.form?recordId=ID` (no edit) | edit form, Submit | refusal sentence, 0 inputs | | admin, same edit link | edit form | edit form, unchanged | | anonymous, `/f/contact-us` | form, Submit | form, unchanged, 0 permission requests | Screenshots for every row (`before-*.png`, `after-*.png`) are in the project folder `issue-29187/`. <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 33 commands for this diff; each ran locally and exited 0, reconciled with `--ran` (the list is in the os-dev report on the card). Also: `pnpm --filter @object-ui/console run type-check` exit 0; eslint on the four touched files, 0 errors; the FormPage, InternalFormRoute and `App.*` console suites plus `affordanceGrantMap-12082.test.tsx` (the census that refuses a CRUD grant read outside the map), 51 files / 474 tests green. All of the above reran green on the merged head `c9f31cf0` (main with #29322 and #29302): 33/33 derived gates exit 0 (`--ran`: 33 run, 0 NOT-MEASURED), the console suites 53 files / 484 tests, `vitest run scripts/` 169 files. The last merge of main (`e8cea601`) touched only `ui/apps/console/src/main.tsx`, `vite.config.ts` and `tsconfig.node.json` in this area; on it the console type-check (after rebuilding the `@object-ui/mobile` dist that merge moved) and the two new files plus `App.homePermissions-12700` (17 tests) are green. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSZFcoEaXNQkQx4KJp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1d03901 commit 96fb7b5

5 files changed

Lines changed: 485 additions & 13 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): an internal form refuses a write the caller holds no grant for before it draws a field
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.

‎ui/apps/console/src/App.tsx‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -311,19 +311,6 @@ export function App() {
311311
* layer too late for a capability whose declared meaning is
312312
* "Enter the Studio metadata-design surfaces". */}
313313
{studioRoutes}
314-
{/* Internal authed form — same renderer as `/f/:slug`, different
315-
* submit path, and (objectui#4109) rendered INSIDE the console
316-
* shell instead of as a bare page. The route stays exactly where
317-
* it is: per the 2026-08-10 ruling on objectstack#7245 the missing
318-
* chrome was the defect, not the navigation, so deep links keep
319-
* working. `InternalFormRoute` owns the chrome + the "land on the
320-
* created record" target; the public path above stays chrome-less
321-
* on purpose (an anonymous visitor has no console to be in). */}
322-
<Route path="/forms/:name" element={
323-
<ProtectedRoute>
324-
<InternalFormRoute />
325-
</ProtectedRoute>
326-
} />
327314
{/* Package documentation (ADR-0046): a platform-level portal
328315
* lists every installed `doc` (grouped by package), and one
329316
* viewer route renders any item; cross-references between docs
@@ -367,6 +354,25 @@ export function App() {
367354
}>
368355
<Route path="/home" element={<HomeRoute />} />
369356
<Route path="/apps/:appName/*" element={<AppContent />} />
357+
{/* Internal authed form — same renderer as `/f/:slug`, different
358+
* submit path, and (objectui#4109) rendered INSIDE the console
359+
* shell instead of as a bare page. The URL is unchanged: per the
360+
* 2026-08-10 ruling on objectstack#7245 the missing chrome was
361+
* the defect, not the navigation, so deep links keep working.
362+
* `InternalFormRoute` owns the chrome + the "land on the created
363+
* record" target; the public `/f/:slug` above stays chrome-less
364+
* and outside this layout on purpose (an anonymous visitor has
365+
* no console to be in, and no permissions to ask about).
366+
*
367+
* Under this layout rather than beside it (objectstack#29187):
368+
* `FormPage` refuses a form whose write the caller holds no
369+
* grant for BEFORE drawing a field, and it reads that grant
370+
* from this layout's `/auth/me/permissions` answer — the one
371+
* its in-app twin `/apps/:appName/forms/:name` already reads.
372+
* One answer for both URLs, so one form never behaves
373+
* differently by URL; a hop from `/home` or an app keeps the
374+
* layout, so the answer is not asked again. */}
375+
<Route path="/forms/:name" element={<InternalFormRoute />} />
370376
</Route>
371377
{/* Dev-only: ADR-0001 master-detail subform verification harness. */}
372378
<Route path="/dev/master-detail" element={
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#29187 — where the form routes sit in `App`'s
5+
* route tree, which decides what `FormPage`'s up-front write refusal reads.
6+
*
7+
* The app-less `/forms/:name` used to sit beside the console's permissions
8+
* layout, under `ProtectedRoute` alone, while its in-app twin
9+
* `/apps/:appName/forms/:name` sat under it. The refusal reads the caller's
10+
* grants from that layout's `/auth/me/permissions` answer, so on the app-less
11+
* URL it would have had none and the same form would behave differently by
12+
* URL. `/forms/:name` is now a child of the same pathless layout route as
13+
* `/home` and `/apps/:appName/*`: one answer, asked once per session load,
14+
* held across a hop between them. `/f/:slug` stays outside it: anonymous,
15+
* no answer, no request.
16+
*
17+
* What is real: `App`'s route tree, `ConsolePermissionsScope` and the
18+
* `MePermissionsProvider` it mounts. The network is the console's
19+
* authenticated fetcher, mocked. The probes stand where `InternalFormRoute`,
20+
* the public `FormPage` and Home's page render, and report what they see.
21+
* `ProtectedRoute` is reduced to the metadata context it provides.
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
26+
import { render, screen, cleanup, waitFor, act } from '@testing-library/react';
27+
import { useContext, type ReactNode } from 'react';
28+
import { useNavigate } from 'react-router-dom';
29+
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
30+
import { usePermissions, type MePermissionsAuthenticatedResponse } from '@object-ui/permissions';
31+
// Not on the package's entry: the context object itself, read by its module id
32+
// (`@object-ui/permissions` resolves to `packages/permissions/src`, so this is
33+
// the same module instance the provider writes).
34+
import { PermCtx } from '../../../../packages/permissions/src/PermissionContext';
35+
36+
const net = vi.hoisted(() => ({
37+
/** Every URL the console's authenticated fetcher was asked for. */
38+
calls: [] as string[],
39+
}));
40+
41+
const METADATA: MetadataContextValue = {
42+
apps: [{ name: 'crm', label: 'CRM', active: true, navigation: [] }] as never,
43+
objects: [],
44+
dashboards: [],
45+
reports: [],
46+
pages: [],
47+
loading: false,
48+
error: null,
49+
refresh: async () => {},
50+
invalidate: () => {},
51+
ensureType: async () => [],
52+
getItem: async () => null,
53+
getItemsByType: () => [],
54+
getTypeStatus: () => 'ready',
55+
} as unknown as MetadataContextValue;
56+
57+
/** A member who may read `qar8_fsb` and not create it. */
58+
function permissionsAnswer(): MePermissionsAuthenticatedResponse {
59+
return {
60+
authenticated: true,
61+
userId: 'u_member',
62+
tenantId: null,
63+
positions: [],
64+
hasAnyQueue: false,
65+
permissionSets: ['member'],
66+
systemPermissions: [],
67+
objects: { qar8_fsb: { allowRead: true } },
68+
fields: {},
69+
} as MePermissionsAuthenticatedResponse;
70+
}
71+
72+
function respond(input: unknown): Promise<Response> {
73+
const url = String(input);
74+
const body = url.includes('/auth/me/permissions') ? permissionsAnswer() : {};
75+
return Promise.resolve({
76+
ok: true,
77+
status: 200,
78+
headers: { get: () => null },
79+
json: async () => body,
80+
} as unknown as Response);
81+
}
82+
83+
vi.mock('@object-ui/auth', async (importOriginal) => ({
84+
...(await importOriginal<Record<string, unknown>>()),
85+
AuthProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
86+
useAuth: () => ({ user: { id: 'u_member', name: 'Member', email: 'member@example.com' }, isAuthenticated: true, isLoading: false }),
87+
createAuthenticatedFetch: () => (input: unknown) => {
88+
net.calls.push(String(input));
89+
return respond(input);
90+
},
91+
}));
92+
93+
vi.mock('../components/ProtectedRoute', () => ({
94+
ProtectedRoute: ({ children }: { children?: ReactNode }) => (
95+
<MetadataCtx.Provider value={METADATA}>{children}</MetadataCtx.Provider>
96+
),
97+
}));
98+
99+
/** What a form route sees of the caller's grants on the form's object. */
100+
function GrantProbe({ testId }: { testId: string }) {
101+
const ctx = useContext(PermCtx);
102+
const { isLoaded, can } = usePermissions();
103+
const verdict = ctx === null ? 'no-perm-ctx' : isLoaded ? `create:${String(can('qar8_fsb', 'create'))}` : 'not-loaded';
104+
return <div data-testid={testId}>{verdict}</div>;
105+
}
106+
107+
/** Stands where Home's page renders, with a way to follow a form link. */
108+
function HomeProbe() {
109+
const navigate = useNavigate();
110+
return (
111+
<button type="button" onClick={() => navigate('/forms/qar8_fsb.form')}>
112+
open form
113+
</button>
114+
);
115+
}
116+
117+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
118+
...(await importOriginal<Record<string, unknown>>()),
119+
ConsoleShell: ({ children }: { children?: ReactNode }) => <>{children}</>,
120+
ConsoleToaster: () => null,
121+
LoadingScreen: () => <div data-testid="loading-screen" />,
122+
DefaultHomePage: () => <HomeProbe />,
123+
DefaultAppContent: () => null,
124+
BuilderLanding: () => null,
125+
StudioDesignSurface: () => null,
126+
getProductName: () => 'ObjectOS',
127+
getFaviconUrl: () => '',
128+
}));
129+
130+
vi.mock('../../../../packages/app-shell/src/layout/AppHeader', () => ({ AppHeader: () => null }));
131+
vi.mock('../../../../packages/app-shell/src/chrome/CommandPalette', () => ({ CommandPalette: () => null }));
132+
vi.mock('../../../../packages/app-shell/src/hooks/useAiSurface', async (importOriginal) => ({
133+
...(await importOriginal<Record<string, unknown>>()),
134+
useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }),
135+
}));
136+
137+
vi.mock('../LocalizationFetchProvider', () => ({
138+
LocalizationFetchProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
139+
}));
140+
vi.mock('../pages/settings/WorkspaceTimezonePrompt', () => ({ WorkspaceTimezonePrompt: () => null }));
141+
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: () => null }));
142+
vi.mock('../components/SetupRoute', () => ({ SetupRoute: () => null }));
143+
vi.mock('../components/FormPage', () => ({ FormPage: () => <GrantProbe testId="public-form" /> }));
144+
vi.mock('../components/InternalFormRoute', () => ({
145+
InternalFormRoute: () => <GrantProbe testId="internal-form" />,
146+
InAppFormRoute: () => <GrantProbe testId="in-app-form" />,
147+
}));
148+
vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null }));
149+
vi.mock('../components/FaviconSync', () => ({ FaviconSync: () => null }));
150+
vi.mock('../pages/SharedRecordPage', () => ({ default: () => null }));
151+
vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: () => null }));
152+
vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: () => null }));
153+
vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: () => null }));
154+
vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: () => null }));
155+
vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: () => null }));
156+
vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: () => null }));
157+
vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: () => null }));
158+
vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: () => null }));
159+
vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: () => null }));
160+
vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: () => null }));
161+
vi.mock('../dev/DevLists', () => ({ DevLists: () => null }));
162+
vi.mock('../dev/DevModal', () => ({ DevModal: () => null }));
163+
vi.mock('../dev/DevLookup', () => ({ DevLookup: () => null }));
164+
vi.mock('../dev/DevRowActions', () => ({ DevRowActions: () => null }));
165+
166+
import { App } from '../App';
167+
168+
const permissionsCalls = () => net.calls.filter((u) => u.includes('/auth/me/permissions'));
169+
170+
function renderAt(url: string) {
171+
window.history.pushState({}, '', url);
172+
return render(<App />);
173+
}
174+
175+
beforeEach(() => {
176+
net.calls = [];
177+
localStorage.clear();
178+
sessionStorage.clear();
179+
});
180+
afterEach(() => {
181+
cleanup();
182+
window.history.pushState({}, '', '/');
183+
});
184+
185+
describe('the form routes and the permissions answer (#29187)', () => {
186+
it('/forms/NAME renders under the permissions answer, asked once', async () => {
187+
renderAt('/forms/qar8_fsb.form');
188+
await waitFor(() => expect(screen.getByTestId('internal-form')).toHaveTextContent('create:false'));
189+
expect(permissionsCalls()).toHaveLength(1);
190+
});
191+
192+
it('/forms/NAME waits on the answer the way /home and /apps do: the loading screen first, no form', async () => {
193+
renderAt('/forms/qar8_fsb.form');
194+
expect(screen.getByTestId('loading-screen')).toBeInTheDocument();
195+
expect(screen.queryByTestId('internal-form')).not.toBeInTheDocument();
196+
await waitFor(() => expect(screen.getByTestId('internal-form')).toBeInTheDocument());
197+
});
198+
199+
it('/home then a form link keeps the one answer: /auth/me/permissions asked ONCE in total', async () => {
200+
renderAt('/home');
201+
await waitFor(() => expect(screen.getByRole('button', { name: 'open form' })).toBeInTheDocument());
202+
await act(async () => {
203+
screen.getByRole('button', { name: 'open form' }).click();
204+
});
205+
await waitFor(() => expect(screen.getByTestId('internal-form')).toHaveTextContent('create:false'));
206+
expect(permissionsCalls()).toHaveLength(1);
207+
});
208+
209+
it('/f/SLUG stays outside it: no permission context and no /auth/me/permissions request', async () => {
210+
renderAt('/f/feedback');
211+
await waitFor(() => expect(screen.getByTestId('public-form')).toHaveTextContent('no-perm-ctx'));
212+
expect(permissionsCalls()).toHaveLength(0);
213+
});
214+
});

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,12 @@ import {
126126
isMissingForRequired,
127127
isRuntimeDefault,
128128
isServerOwnedValue,
129+
resolveAffordance,
129130
resolveFieldRuleState,
130131
type FieldRuleFaults,
131132
type FieldRulePredicate,
132133
} from '@object-ui/core';
134+
import { usePermissions } from '@object-ui/permissions';
133135
import {
134136
omitServerResolvedDefaults,
135137
optionListDefault,
@@ -2547,6 +2549,15 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
25472549
*/
25482550
const hostApiFetch = useContext(SchemaRendererContext)?.apiFetch;
25492551
const channel = mode === 'internal' ? hostApiFetch : undefined;
2552+
/**
2553+
* The caller's permissions, for the up-front write refusal below
2554+
* (objectstack-ai/objectstack#29187): the `/auth/me/permissions` answer the
2555+
* console's permissions layout holds over BOTH internal routes —
2556+
* `/forms/:name` and `/apps/:appName/forms/:name` (`App.tsx`). With no
2557+
* provider above (the public `/f/:slug`, a bare test harness) every grant
2558+
* reads open, so nothing changes there and the server still enforces.
2559+
*/
2560+
const perms = usePermissions();
25502561

25512562
const [loading, setLoading] = useState(true);
25522563
/** 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) {
31163127
}
31173128
if (!loaded) return null;
31183129

3130+
// A write this caller holds no grant for is refused BEFORE a field is drawn
3131+
// (objectstack-ai/objectstack#29187). The server refuses it at Submit anyway,
3132+
// and since #29019 says so in a sentence; but a create form offered to a
3133+
// member who may only read the object let them type the whole record first,
3134+
// and lose it. The verdict is the affordance map's form row, the one the
3135+
// app's own create and edit forms read (`createFormFields`, `editFormFields`):
3136+
// a create form asks the create grant, an edit form (`?recordId=`) the update
3137+
// grant, each intersected with the object's effective API operations. The sentence
3138+
// is the submit-time refusal's own (`refusalText`), so the two read alike.
3139+
// Internal mode only: `/f/:slug` is anonymous and has no grants to read.
3140+
if (
3141+
mode === 'internal' &&
3142+
!resolveAffordance(isCreateForm ? 'createFormFields' : 'editFormFields', {
3143+
objectName: loaded.object,
3144+
perms,
3145+
}).allowed
3146+
) {
3147+
return (
3148+
<div className="mx-auto max-w-2xl p-6">
3149+
<div
3150+
role="alert"
3151+
className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
3152+
data-testid="form-write-refused"
3153+
>
3154+
{t('form.noPermissionToSave')}
3155+
</div>
3156+
</div>
3157+
);
3158+
}
3159+
31193160
// `created-record` reaches this branch only on the fallback path above (no
31203161
// id in the response, or nowhere to land) — a successful redirect navigates
31213162
// away instead. It carries no author-supplied title/message, so it renders

0 commit comments

Comments
 (0)