Skip to content

Commit 3271a08

Browse files
fix(console): a shared non-chat record renders as read-only rows, not raw JSON (#26989)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8N4q2fqunhzMgpV5LUU8Vc6)_ Fixes #26767 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:分享链接页 `/s/:token` 打开非对话记录时,不再把整条记录 `JSON.stringify` 成代码块,改为逐字段的只读列表(字段名转成可读标签,日期、数字、布尔、数组、嵌套对象按值的形状格式化)。 - 为什么改:外部访客看到的是字段 API 名和原始 JSON,而文档说分享链接展示的是「记录的字段,只读」。 - 风险与回滚:只动 console 一页的展示层;不改 resolve 请求、请求头和密码流程,不多取任何字段。回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/pages/SharedRecordFields.tsx`(值的格式化分支)、`SharedRecordPage.tsx`(唯一替换点)、`SharedRecordPage-26767.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/pages/SharedRecordPage-26767.test.tsx`,预期 6 passed。 - 需要你做的:无,席位落地。 ## What changes - `SharedRecordPage` no longer prints `JSON.stringify(data.record, null, 2)` in a pre block for an object other than `ai_conversations`. It renders a new presentational component beside it, `SharedRecordFields`. - `SharedRecordFields` renders one read-only row (a definition list) per key the resolve answer's `record` carries. ⛔ It adds no key and fetches nothing. - **Label:** the record key through `humanizeLabel` (`@object-ui/core`): `annual_revenue` reads "Annual Revenue". - **Value, by JSON shape:** an ISO date string through `formatDate`, an ISO date-time through `formatDateTime`, a number through `formatDisplayNumber` (the console's one date-display and number-display paths, `@object-ui/core`, in the `useDisplayLocale()` locale); a boolean as `common.yes` / `common.no`; an array as its items joined with commas; an object as `Key: value` pairs; null and the empty string as a dash. Nested values are spelled out two levels deep, then elided. - **System fields** are shown, never hidden. The record detail view's notion of a framework-managed column, `isSystemManagedField` (`@object-ui/types`), only orders them after the business fields, in a second list. - A record with no keys left reads `common.noData`. - The "Some fields are hidden by the owner" line, the `ai_conversations` transcript, the resolve request, its headers and the password flow are unchanged. - Changeset: `@object-ui/console` patch (the package is published, not `private`). - No i18n row is added: every word the list prints comes from an existing `common.*` row. ## Why Triage ruled (comment 6448388065): "Render the shared record read-only through the record renderer. The fields shown are exactly the fields the share read returns. ⛔ The client never widens them." **Which branch of the dispatch order was measured: branch 3.** The record renderer needs field labels and types, and none reach this page: 1. The resolve answer is `{ record, link, redactFields }` on both producers (`packages/runtime/src/domains/share-links.ts` and `packages/plugins/plugin-sharing/src/share-link-routes.ts`): no object schema. 2. The only door to the schema, `GET /api/v1/meta/object/:name`, refuses an anonymous caller: the meta domain's anonymous deny exempts only the `book` and `doc` public reads (`isPublicAudienceRead`). This page sends no bearer token, so its visitor is anonymous by design. 3. A signed-in visitor without read on the object gets the ADR-0106 D1 masked schema, which removes every unreadable field whole: no labels for exactly those fields. So the page renders a labelled list from the payload alone, and the missing labels and types are reported as a server-side finding (below). ## Tests `ui/apps/console/src/pages/SharedRecordPage-26767.test.tsx`, six cases against a stub that answers resolve like both producers (enveloped, redacted fields already stripped, anonymous visitor): - an `account`-like record renders labelled, formatted rows (`1,250,000`, `Yes`, a formatted date, a dash for null); no pre block, no JSON brace; one row per key carried, none added; system fields ordered after the business fields; - a nested value reads `enterprise, emea` and `City: Paris; Zip: 75001`, never `[object Object]`; - a redacted field stays absent and the hidden-fields line still shows; - a record with no fields left reads "No data"; - `ai_conversations` still renders the transcript and no field list; - under zh, Yes / No and the hidden-fields line come from the zh pack (guarded as real zh rows). Ablation (one-shot, through `scripts/ablation-replace.mjs`, restores itself): the `SharedRecordFields` call replaced by the old pre block with the JSON dump → the new file goes **4 failed / 2 passed** (the four rendering cases red; the redaction and `ai_conversations` cases are invariants and stay green); blob restored to HEAD `79f88a6a`, `git diff HEAD` empty. ## Acceptance notes - **Out of scope, reported for the seat to file (class a):** the share resolve payload carries no field labels or field types, so an anonymous visitor sees humanized keys instead of authored labels, picklist values instead of their labels, a currency amount as a bare number and a lookup as its id. The fix is server-side (the resolve answer carrying a projection of the readable fields' labels and types), which this card rules out. - The page header still reads "Shared OBJECT" (`sharedRecord.sharedObject`) with the object's API name, for the same reason: no object label reaches the page. - The key prettifier: `humanizeFieldKey`, the documented KEY fallback, is private to `@object-ui/plugin-dashboard` (not on its public face, and that package is another seat's). `humanizeLabel` is the published one. On every legal field name (the spec's snake_case name pattern) the two return the same string; they differ only on camelCase and kebab-case, which a field name cannot carry. - The value-shape inference reads only the JSON shape: a string that happens to look like an ISO date is shown as a date. ## Verification All on head `a20d2d84`, after the closure build `pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build` (lock VERDICT command-exit 0). <details><summary>Tests and typecheck</summary> - `pnpm --dir ui exec vitest run` over `SharedRecordPage-26767.test.tsx`, `SharedRecordPage-11649.test.tsx`, `shared-record-shape.test.ts`, `consolePagesLocale-secondPass-13460.test.tsx` → Test Files 4 passed (4), Tests 32 passed (32). - `pnpm --filter @object-ui/console run type-check` → VERDICT command-exit 0. - eslint (`--no-inline-config`) on the three changed source files → 0 errors, 3 warnings, all on unchanged lines of `SharedRecordPage.tsx`. </details> <details><summary>Derived gates: 33 of 33 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 33 families on this diff; each was run and its exit recorded before any pipe; `--ran` reconciles 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN. Families: adr-0087-registration (+self-test), changeset-no-major, closing-keyword-parity (+self-test), comment-mask-corpus, empty-changeset (+self-test), sdui-manifest (+self-test), release-rehearsal-clone self-test, release-pending-publish self-test, `vitest run scripts/` (under the lock), spec duration-unit-keys, changeset-gate-self-tests, closed-ceremony-text, cross-package-test-inputs, dispatcher-error-vocabulary, driver-memory-census, engine-double-contract, gitlink-declared, nul-bytes, pm-changeset-deadline-census, published-files, query-options-erasure, refd-timer-probe, test-source-alias, tier-file-adoption, type-check-coverage, type-check-debt, undeclared-dep-imports, watch-hint-literal, where-matcher. `dispatch-gates --tier`: "Model tier — no path-derived mandate: the surface hits none of the 4 declared glob(s)". Changed lines 305 of 3000. </details> Repo-scale runs (`pnpm lint`, the Test UI shards, Build & E2E UI) are CI's. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the three changed files reports 0 errors (`pnpm lint` itself is CI's run). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the os-dev contract's Chinese summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbW2ZDLp6XiysrTN4QP --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbW2ZDLp6XiysrTN4QP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6e0faa commit 3271a08

4 files changed

Lines changed: 313 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): a share link to a non-chat record shows the record's fields as read-only rows, not raw JSON
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Before.** The share-link page `/s/:token` printed `JSON.stringify(record)` in a code block for every object except `ai_conversations`: field API names, unformatted values and nested objects as JSON.
11+
- **Now.** The page renders one read-only row per key the resolve answer carries. The label is the key humanized (`annual_revenue` reads "Annual Revenue"). The value is formatted by its JSON shape: ISO dates and date-times in the visitor's locale, numbers grouped, booleans as Yes / No, lists and nested objects as short text, and an empty value as a dash. System fields such as `id` and `created_at` are shown after the business fields. A record with no fields left reads "No data".
12+
- **Unchanged.** The page shows exactly the fields the server returned: redacted fields stay absent and the "Some fields are hidden by the owner" line still renders. The `ai_conversations` transcript, the resolve request, its headers and the password flow do not change.
13+
- **Not yet.** Field labels and field types are not in the resolve answer, and an anonymous visitor cannot read the object's metadata, so authored labels, picklist labels, currency and lookup names do not show yet.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* SharedRecordFields — the read-only field list a share link to a non-chat
5+
* record renders (objectstack#26767), in place of the raw JSON dump it showed.
6+
*
7+
* What it draws from is exactly what the resolve answer carries: the record's
8+
* own keys, after the server stripped the link's redacted fields. ⛔ It never
9+
* adds a key the record does not carry, and never fetches anything to widen it.
10+
*
11+
* Why no field labels or field types. The resolve answer is `{ record, link,
12+
* redactFields }` on both producers — no object schema. The one door to that
13+
* schema, `GET /meta/object/:name`, refuses an anonymous caller, and this page's
14+
* visitor is anonymous by design (its requests carry no bearer token). So the
15+
* label is the record key humanized, and the value is formatted by its JSON
16+
* shape: an ISO date or date-time string through the console's one date-display
17+
* path, a number through its one number-display path, a boolean as Yes / No,
18+
* and an array or object as short text — never raw JSON.
19+
*
20+
* System fields are shown, not hidden: the record detail view's notion of a
21+
* framework-managed column (`isSystemManagedField`, `@object-ui/types`) only
22+
* orders them after the business fields, the way the detail view keeps
23+
* provenance out of the leading rows.
24+
*/
25+
26+
import { formatDate, formatDateTime, formatDisplayNumber, humanizeLabel } from '@object-ui/core';
27+
import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n';
28+
import { isSystemManagedField } from '@object-ui/types';
29+
30+
/** An empty value's face — the date-display path's own placeholder. */
31+
const EMPTY = '—';
32+
33+
/** How deep a nested value is spelled out before it is elided. */
34+
const MAX_DEPTH = 2;
35+
36+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
37+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
38+
39+
type Translate = ReturnType<typeof useObjectTranslation>['t'];
40+
41+
function formatString(value: string, locale: string): string {
42+
if (value === '') return EMPTY;
43+
if (DATE_ONLY.test(value)) {
44+
const text = formatDate(value, undefined, { locale });
45+
return text === EMPTY ? value : text;
46+
}
47+
if (DATE_TIME.test(value)) {
48+
const text = formatDateTime(value, { locale });
49+
return text === EMPTY ? value : text;
50+
}
51+
return value;
52+
}
53+
54+
/** One value as display text, by its JSON shape. */
55+
function formatValue(value: unknown, locale: string, t: Translate, depth = 0): string {
56+
if (value === null || value === undefined) return EMPTY;
57+
if (typeof value === 'string') return formatString(value, locale);
58+
if (typeof value === 'number') return Number.isFinite(value) ? formatDisplayNumber(value, { locale }) : String(value);
59+
if (typeof value === 'boolean') {
60+
return value ? t('common.yes', { defaultValue: 'Yes' }) : t('common.no', { defaultValue: 'No' });
61+
}
62+
if (Array.isArray(value)) {
63+
if (value.length === 0) return EMPTY;
64+
if (depth >= MAX_DEPTH) return '…';
65+
return value.map((item) => formatValue(item, locale, t, depth + 1)).join(', ');
66+
}
67+
if (typeof value === 'object') {
68+
const entries = Object.entries(value as Record<string, unknown>);
69+
if (entries.length === 0) return EMPTY;
70+
if (depth >= MAX_DEPTH) return '…';
71+
return entries.map(([k, v]) => `${humanizeLabel(k)}: ${formatValue(v, locale, t, depth + 1)}`).join('; ');
72+
}
73+
return String(value);
74+
}
75+
76+
export interface SharedRecordFieldsProps {
77+
/** The record as the resolve answer carried it, redacted fields already stripped. */
78+
record: Record<string, unknown>;
79+
}
80+
81+
export function SharedRecordFields({ record }: SharedRecordFieldsProps) {
82+
const { t } = useObjectTranslation();
83+
const locale = useDisplayLocale();
84+
const keys = Object.keys(record);
85+
86+
if (keys.length === 0) {
87+
return <p className="text-sm text-muted-foreground">{t('common.noData', { defaultValue: 'No data' })}</p>;
88+
}
89+
90+
const business = keys.filter((key) => !isSystemManagedField(key));
91+
const system = keys.filter((key) => isSystemManagedField(key));
92+
93+
const rows = (group: string[]) =>
94+
group.map((key) => (
95+
<div key={key} className="grid grid-cols-1 gap-1 py-2 sm:grid-cols-[minmax(8rem,1fr)_2fr] sm:gap-4">
96+
<dt className="text-xs font-medium text-muted-foreground">{humanizeLabel(key)}</dt>
97+
<dd className="min-w-0 whitespace-pre-wrap break-words text-sm text-foreground">
98+
{formatValue(record[key], locale, t)}
99+
</dd>
100+
</div>
101+
));
102+
103+
return (
104+
<div className="rounded-md border bg-card px-4">
105+
{business.length > 0 && <dl className="divide-y">{rows(business)}</dl>}
106+
{system.length > 0 && (
107+
<dl data-testid="shared-record-system-fields" className={business.length > 0 ? 'divide-y border-t' : 'divide-y'}>
108+
{rows(system)}
109+
</dl>
110+
)}
111+
</div>
112+
);
113+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26767 — a share link to any object other than `ai_conversations`
5+
* renders the shared record read-only, as labelled rows, and never as the raw
6+
* `JSON.stringify(record)` dump it used to print in a `pre` block.
7+
*
8+
* The stub answers `/share-links/:token/resolve` the way both producers do: the
9+
* enveloped `{ success, data: { record, link, redactFields } }`, with the
10+
* redacted fields already stripped from `record`. Every visitor is anonymous.
11+
*/
12+
13+
import '@testing-library/jest-dom/vitest';
14+
import { afterEach, describe, expect, it, vi } from 'vitest';
15+
import { cleanup, render, screen, within } from '@testing-library/react';
16+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
17+
import { formatDate } from '@object-ui/core';
18+
import { I18nProvider } from '@object-ui/i18n';
19+
import { builtInLocales } from '@object-ui/i18n/locales';
20+
import SharedRecordPage from './SharedRecordPage';
21+
22+
const TOKEN = 'tok_26767';
23+
24+
/** An `account`-like record as resolve hands it over, `ssn` already redacted. */
25+
const ACCOUNT = {
26+
id: 'acc_1',
27+
name: 'Acme Corporation',
28+
annual_revenue: 1250000,
29+
is_active: true,
30+
founded_on: '2026-03-15',
31+
tags: ['enterprise', 'emea'],
32+
billing_address: { city: 'Paris', zip: '75001' },
33+
parent_id: null,
34+
created_at: '2026-01-02T03:04:05.000Z',
35+
};
36+
37+
const json = (status: number, body: unknown) =>
38+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
39+
40+
function shareServer(objectName: string, record: Record<string, unknown>, redactFields: string[]) {
41+
vi.stubGlobal(
42+
'fetch',
43+
vi.fn(async (input: string | URL | Request) => {
44+
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
45+
const { pathname } = new URL(raw, 'http://localhost');
46+
if (pathname === `/api/v1/share-links/${TOKEN}/resolve`) {
47+
return json(200, {
48+
success: true,
49+
data: {
50+
record,
51+
link: {
52+
id: 'sl_1',
53+
token: TOKEN,
54+
object_name: objectName,
55+
record_id: String(record.id),
56+
permission: 'view',
57+
audience: 'anyone',
58+
},
59+
redactFields,
60+
},
61+
});
62+
}
63+
if (pathname === `/api/v1/share-links/${TOKEN}/messages`) return json(200, { success: true, data: [] });
64+
return new Response(null, { status: 404 });
65+
}),
66+
);
67+
}
68+
69+
function renderLink(language?: 'zh') {
70+
const routed = (
71+
<MemoryRouter initialEntries={[`/s/${TOKEN}`]}>
72+
<Routes>
73+
<Route path="/s/:token" element={<SharedRecordPage />} />
74+
</Routes>
75+
</MemoryRouter>
76+
);
77+
return render(
78+
language ? (
79+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
80+
{routed}
81+
</I18nProvider>
82+
) : (
83+
routed
84+
),
85+
);
86+
}
87+
88+
/** The value cell beside the row whose label reads `label`. */
89+
function valueOf(label: string): string {
90+
const term = screen.getByText(label, { selector: 'dt' });
91+
const value = term.parentElement?.querySelector('dd');
92+
expect(value).not.toBeNull();
93+
return value!.textContent ?? '';
94+
}
95+
96+
afterEach(() => {
97+
cleanup();
98+
vi.unstubAllGlobals();
99+
});
100+
101+
describe('SharedRecordPage — a shared non-chat record renders as read-only rows (objectstack#26767)', () => {
102+
it('renders each record key as a labelled, formatted row, and no pre block or JSON', async () => {
103+
shareServer('account', ACCOUNT, ['ssn']);
104+
const { container } = renderLink();
105+
106+
expect(await screen.findByText('Shared account')).toBeInTheDocument();
107+
108+
expect(container.querySelector('pre')).toBeNull();
109+
expect(container.textContent).not.toContain('"name"');
110+
expect(container.textContent).not.toContain('{');
111+
112+
expect(valueOf('Name')).toBe('Acme Corporation');
113+
expect(valueOf('Annual Revenue')).toBe('1,250,000');
114+
expect(valueOf('Is Active')).toBe('Yes');
115+
expect(valueOf('Founded On')).toBe(formatDate('2026-03-15', undefined, { locale: 'en' }));
116+
expect(valueOf('Founded On')).not.toBe('2026-03-15');
117+
expect(valueOf('Parent Id')).toBe('—');
118+
expect(valueOf('Created At')).not.toContain('T03:04:05');
119+
120+
// Exactly the keys the record carried: no row is added, none is dropped.
121+
expect(container.querySelectorAll('dt')).toHaveLength(Object.keys(ACCOUNT).length);
122+
// System fields are shown, ordered after the business fields.
123+
const system = screen.getByTestId('shared-record-system-fields');
124+
expect(within(system).getByText('Id', { selector: 'dt' })).toBeInTheDocument();
125+
expect(within(system).getByText('Created At', { selector: 'dt' })).toBeInTheDocument();
126+
expect(within(system).queryByText('Name', { selector: 'dt' })).toBeNull();
127+
});
128+
129+
it('a nested value renders as text, never as [object Object] or JSON', async () => {
130+
shareServer('account', ACCOUNT, []);
131+
const { container } = renderLink();
132+
133+
await screen.findByText('Shared account');
134+
expect(valueOf('Tags')).toBe('enterprise, emea');
135+
expect(valueOf('Billing Address')).toBe('City: Paris; Zip: 75001');
136+
expect(container.textContent).not.toContain('[object Object]');
137+
});
138+
139+
it('a redacted field stays absent, and the hidden-fields line still shows', async () => {
140+
shareServer('account', ACCOUNT, ['ssn']);
141+
renderLink();
142+
143+
await screen.findByText('Shared account');
144+
expect(screen.queryByText('Ssn', { selector: 'dt' })).toBeNull();
145+
expect(screen.getByText('Some fields are hidden by the owner: ssn')).toBeInTheDocument();
146+
});
147+
148+
it('a record with no fields left says so instead of an empty box', async () => {
149+
shareServer('account', {}, ['name']);
150+
const { container } = renderLink();
151+
152+
await screen.findByText('Shared account');
153+
expect(screen.getByText('No data')).toBeInTheDocument();
154+
expect(container.querySelector('dt')).toBeNull();
155+
});
156+
157+
it('the ai_conversations branch is unchanged: the transcript, not a field list', async () => {
158+
shareServer('ai_conversations', { id: 'conv_1', title: 'Quarterly plan' }, []);
159+
const { container } = renderLink();
160+
161+
expect(await screen.findByText('Quarterly plan')).toBeInTheDocument();
162+
expect(await screen.findByText('This conversation has no messages yet.')).toBeInTheDocument();
163+
expect(container.querySelector('dt')).toBeNull();
164+
expect(container.querySelector('pre')).toBeNull();
165+
});
166+
167+
it('under zh the value words come from the zh pack', async () => {
168+
const { zh, en } = builtInLocales;
169+
// Guard: these are real zh rows, not English copies.
170+
expect(zh.common.yes).not.toBe(en.common.yes);
171+
expect(zh.sharedRecord.redactedFields).not.toBe(en.sharedRecord.redactedFields);
172+
173+
shareServer('account', { id: 'acc_1', is_active: true, is_vip: false }, ['ssn']);
174+
renderLink('zh');
175+
176+
expect(await screen.findByText('分享的 account')).toBeInTheDocument();
177+
expect(valueOf('Is Active')).toBe(zh.common.yes);
178+
expect(valueOf('Is Vip')).toBe(zh.common.no);
179+
expect(screen.getByText(zh.sharedRecord.redactedFields.replace('{{fields}}', 'ssn'))).toBeInTheDocument();
180+
});
181+
});

‎ui/apps/console/src/pages/SharedRecordPage.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
* a read-only transcript with the SAME `ChatbotEnhanced` renderer the live
1414
* chat uses (in `readOnly` mode — no composer), so assistant tool calls show
1515
* as proper proposed-plan / draft cards instead of a raw tool-result JSON
16-
* dump. Other object kinds fall back to a generic JSON preview.
16+
* dump. Every other object renders its fields read-only through
17+
* `SharedRecordFields`: the record's own keys, labelled and formatted, and
18+
* never the raw JSON (objectstack#26767).
1719
*
1820
* A link's password travels in the `X-Share-Password` request header, never in
1921
* the request URL (objectui#11649) — see `SHARE_PASSWORD_HEADER`. A `401` is
@@ -49,6 +51,7 @@ import {
4951
type ResolvedShare,
5052
type ResolveErrorBody,
5153
} from './shared-record-shape';
54+
import { SharedRecordFields } from './SharedRecordFields';
5255

5356
/**
5457
* Why the link did not open. The server's own message is shown as it came;
@@ -333,7 +336,7 @@ export default function SharedRecordPage() {
333336
);
334337
}
335338

336-
// Generic fallback: dump the JSON record.
339+
// Every other object: the record's fields, read-only.
337340
return (
338341
<div className="mx-auto flex h-svh max-w-3xl flex-col bg-background">
339342
<header className="border-b px-4 py-3">
@@ -342,9 +345,7 @@ export default function SharedRecordPage() {
342345
</h1>
343346
</header>
344347
<main className="flex-1 overflow-y-auto p-4">
345-
<pre className="overflow-x-auto rounded-md border bg-muted/30 p-3 text-[12px]">
346-
{JSON.stringify(data.record, null, 2)}
347-
</pre>
348+
<SharedRecordFields record={data.record} />
348349
{data.redactedFields && data.redactedFields.length > 0 && (
349350
<p className="mt-2 text-[11px] text-muted-foreground">
350351
{t('sharedRecord.redactedFields', {

0 commit comments

Comments
 (0)