diff --git a/.changeset/10583-masked-cell-copy.md b/.changeset/10583-masked-cell-copy.md new file mode 100644 index 0000000000..9449426ec1 --- /dev/null +++ b/.changeset/10583-masked-cell-copy.md @@ -0,0 +1,71 @@ +--- +'@object-ui/types': minor +'@object-ui/components': patch +'@object-ui/plugin-grid': patch +--- + +Once the grid has its object schema, a masked grid field's raw value is withheld from copy, tooltip, inline edit, the client export and the mobile card, and a masked field is refused as a grouping key (objectui#10583). + +`@object-ui/fields` draws `password` and `secret` cells as `••••••`. In `object-grid` the +cell drew the mask, but the raw value still left through other paths: + +- `data-table`'s Ctrl+C / Cmd+C handler copied `String(row[accessorKey])` for every + focused cell. +- The cell's `title` tooltip carried the raw value, so a hover showed it and the DOM + held it. +- Inline edit, for a view-typed password column, seeded its editor with the raw value. +- The grid's client export wrote it: the CSV per column, the JSON as whole records. +- The mobile card printed the first column, and fields named like an amount or a + stage, raw. +- Grouping by a masked field printed its raw value as each group's label. + +This is the grid face of the disclosure the detail page closed in objectui#8440. + +- **`@object-ui/types`: new declared key `TableColumn.masked?: boolean`**, mirrored as a + typed `z.boolean()` on `TableColumnSchema` and refused on the static `table` column + (`StaticTableColumn`), like the other rich-only keys. Additive. +- **`@object-ui/components`: `data-table` obeys the flag on four paths.** On a `masked` + column: + - Ctrl+C / Cmd+C writes nothing to the clipboard. It still blocks the browser's own + copy, which would put a selected mask on the clipboard as bullets. + - The cell has no `title` tooltip. + - The built-in CSV export leaves the column out. It is left out, not blanked: a + column of empty strings would claim the records hold nothing. + - The column never enters inline edit, by Enter, click or double-click, and its cell + no longer takes the row's click as an edit. + + Columns without the flag copy, show, export and edit exactly as before. +- **`@object-ui/plugin-grid`: `ObjectGrid` sets the flag** at its column emit seam, from + `isMaskedFieldType()` (objectui#8686). It reads the column's type and, once the object + schema has loaded, the object-declared type as a narrow-only union, the same shape as + the detail page's `isMaskedDetailFieldType`. So a view that authors `type: 'password'` + masks a field the object declares as text from first paint, and a view that authors + `type: 'text'` over a `secret` field keeps the refusal once the schema has loaded. Once the object schema has loaded, the same rule leaves every masked + field of the grid's object out of the grid's client export (CSV and JSON, used when the + data source has no server export) and draws a masked field through its cell on the + mobile card. Once the object schema has loaded, it also refuses a masked field as a + grouping key: the entry is ignored, the other grouping levels + still apply, and a console warning names the field. Masking the group label was not + enough, because the groups would still show which records share a credential, in its + raw order. Unmasked columns, files and groupings are unchanged. + +**Not covered.** + +- The flag withholds; it does not draw. The mask comes from the producer's `cell` + renderer, and `data-table` draws a column with no `cell` as its value. +- The table's client-side search and sort still run over the raw values + (objectui#10657, which folded objectui#10658). +- A masked column's width is still sized from the raw value's length (objectui#10657, + which folded objectui#10658). +- On the host-fetched path (rows handed down as `data`, as `ListView` and `ObjectView` + do), the grid's guards and the cell's own mask depend on the object schema, which the + grid fetches after first paint. Until it arrives, and for good if that read fails (the + grid swallows the failure and keeps its heuristic column types), an untyped view column + over a `password` / `secret` field draws and hands out the raw value (objectui#10657, which folded objectui#10706). +- The server-streamed export (`exportDownload`) sends the masked columns as before and + relies on the server's masking. +- The client JSON export writes an expanded lookup record whole, so a credential field + of the related object is not pruned. The same holds for the table's CSV export of a + lookup column. +- The related list and `object-data-table` produce `data-table` columns too, and they do + not set the flag yet (objectui#10657). diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx index b3baf2c874..64e8e0da9b 100644 --- a/content/docs/components/complex/data-table.mdx +++ b/content/docs/components/complex/data-table.mdx @@ -21,6 +21,7 @@ interface TableColumn { accessorKey: string; // Data property key width?: string; // Column width (e.g., '100px', '20%') sortable?: boolean; // Enable sorting for this column + masked?: boolean; // Withhold the raw value (see Masked columns) } interface DataTableSchema { @@ -132,6 +133,50 @@ a sibling field — a `dependsOn` lookup — should read `pendingRow`, so a pare edited in the same row re-scopes the child before anything is saved; `row` stays the place to read what the data source last returned. +## Masked columns + +A column with `masked: true` holds a credential. The table withholds its raw value on +four paths: + +- Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard. +- The cell carries no `title` tooltip. +- The table's CSV export leaves the column out. +- The column never enters inline edit, on any trigger, because every editor would + start from the raw value. + +Every other column copies, shows, exports and edits exactly as before. + +What the flag does **not** cover: + +- The table's client-side search and sort still run over the raw values, so where + they run on the client they can reveal whether a masked value matches or how it + orders (objectui#10657, which folded objectui#10658). +- A masked column's width is still sized from the raw value's length (objectui#10657, + which folded objectui#10658). +- The CSV export of a lookup column whose value is an expanded record writes that + record whole, so a credential field of the related object is not pruned. + +The flag withholds; it does not draw. What a reader sees in the cell comes from the +producer's `cell` renderer, and the table draws a column with no `cell` as its value. +You rarely set the flag by hand: `object-grid` sets it for every column whose field +type is drawn as a mask (`password`, `secret`, or a type registered with the mask), +from `isMaskedFieldType()` in `@object-ui/fields`. It reads both the view's column +`type` and, once the object schema has loaded, the object's field type, so a view that +shows a `secret` field as `text` keeps the flag once that schema has loaded. Once the object schema has loaded, the same rule leaves every masked +field of the grid's object out of `object-grid`'s own client export (CSV and JSON), +draws those fields through their `cell` on its mobile card, and refuses them as +grouping keys (the entry is ignored with a console warning). What `object-grid` does +not cover: on the host-fetched path (rows handed down as `data`, as `ListView` and +`ObjectView` do), the grid's guards and the cell's own mask depend on the object +schema, which the grid fetches after first paint, so until it arrives, and for good +if that read fails, an untyped view column over a `password` / `secret` field draws +and hands out the raw value (objectui#10657, which folded objectui#10706). The server-streamed export (`exportDownload`) sends the masked +columns as before and relies on the server's masking, and the client JSON export +writes an expanded lookup record whole, so a credential field of the related object +is not pruned. Other producers of +`data-table` columns (the related list, `object-data-table`) do not set the flag yet +(objectui#10657). + ## Examples ### Product Inventory diff --git a/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx new file mode 100644 index 0000000000..3a1e227032 --- /dev/null +++ b/packages/components/src/renderers/complex/__tests__/data-table-masked-column-10583.test.tsx @@ -0,0 +1,211 @@ +/** + * 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. + */ + +/** + * `TableColumn.masked` — the table withholds a masked column's raw value on the + * paths it owns (objectui#10583). + * + * The producer (`ObjectGrid`, from `isMaskedFieldType()`) draws the mask + * through `cell` and sets the flag; this table cannot import + * `@object-ui/fields`, so it obeys the flag on every path IT owns that put the + * raw value somewhere else: + * + * 1. Ctrl+C / Cmd+C on a focused cell — wrote `String(row[accessorKey])`; + * 2. the cell wrapper's `title` tooltip — carried the raw value into the DOM; + * 3. the toolbar's CSV export — wrote every column's raw value; + * 4. inline edit — `startEdit` seeded the editor with the raw row value, so + * any editor (built-in input or a host's `renderCellEditor`) drew it. + * + * Each path is pinned three ways in ONE file, so an absence can never pass by + * never running: the masked column refuses; an ordinary column in the same + * table behaves as before; and the SAME column with the flag ABSENT hands the + * value out, which proves the flag — not the test's shape — is what refuses. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import { TableColumnSchema } from '@object-ui/types/zod'; +import '../data-table'; + +const RAW = 'RAW-KEY-10583'; +const MASK = '••••••'; +const CONTROL = 'Ada'; +const ROWS = [{ id: '1', name: CONTROL, key: RAW }]; + +let writeText: ReturnType; +let createObjectURL: ReturnType; + +beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + createObjectURL = vi.fn(() => 'blob:objectui-10583'); + Object.defineProperty(window.URL, 'createObjectURL', { configurable: true, value: createObjectURL }); + Object.defineProperty(window.URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +/** `masked` undefined ⇒ the key is ABSENT from the column, not `false`. */ +function renderTable(masked: boolean | undefined, extra: Record = {}) { + const DataTable = ComponentRegistry.get('data-table') as any; + if (!DataTable) throw new Error('data-table not registered'); + return render( + MASK, ...(masked === undefined ? {} : { masked }) }, + ], + pagination: false, + searchable: false, + exportable: true, + ...extra, + }} + />, + ); +} + +/** The body cell under the header `label`. */ +function cellUnder(label: string): HTMLElement { + const headers = Array.from(document.querySelectorAll('thead th')); + const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label); + expect(index, `CONTROL: a "${label}" header rendered`).toBeGreaterThanOrEqual(0); + const cell = document.querySelector('tbody tr')?.children[index] as HTMLElement | undefined; + expect(cell?.tagName, `CONTROL: the "${label}" body cell exists`).toBe('TD'); + return cell!; +} + +const payloads = () => writeText.mock.calls.map((call) => call[0]); + +async function exportedCsv(): Promise { + fireEvent.click(screen.getByRole('button', { name: /Export CSV/ })); + expect(createObjectURL, 'CONTROL: the export produced a file').toHaveBeenCalledTimes(1); + return (createObjectURL.mock.calls[0]![0] as Blob).text(); +} + +describe('data-table — `masked: true` withholds the raw value (objectui#10583)', () => { + for (const [chord, init] of [ + ['Ctrl+C', { key: 'c', ctrlKey: true }], + ['Cmd+C', { key: 'c', metaKey: true }], + ] as const) { + it(`${chord} on a masked cell writes nothing; the ordinary cell copies its value`, () => { + renderTable(true); + fireEvent.keyDown(cellUnder('Name'), init); + expect(payloads(), 'CONTROL: the ordinary cell copies').toEqual([CONTROL]); + writeText.mockClear(); + + const masked = cellUnder('Key'); + expect(masked.textContent, 'CONTROL: the producer drew the mask').toContain(MASK); + fireEvent.keyDown(masked, init); + expect(payloads(), 'the masked cell writes nothing').toEqual([]); + }); + } + + it('draws no `title` tooltip on a masked cell — the raw value is nowhere in the DOM', () => { + renderTable(true); + expect( + cellUnder('Name').querySelector('[title]')?.getAttribute('title'), + 'CONTROL: an ordinary cell keeps its tooltip', + ).toBe(CONTROL); + expect(cellUnder('Key').querySelector('[title]'), 'the masked cell carries no tooltip').toBeNull(); + expect(document.body.innerHTML).not.toContain(RAW); + }); + + it('omits a masked column from the CSV export, header and all', async () => { + renderTable(true); + const csv = await exportedCsv(); + expect(csv, 'CONTROL: the ordinary column is exported').toContain(`"${CONTROL}"`); + expect(csv.split('\n')[0], 'the header row names only the ordinary column').toBe('Name'); + expect(csv).not.toContain(RAW); + }); +}); + +describe('data-table — the flag ABSENT behaves exactly as before (objectui#10583 control)', () => { + it('Ctrl+C copies the raw value, the tooltip carries it, the export writes it', async () => { + renderTable(undefined); + fireEvent.keyDown(cellUnder('Key'), { key: 'c', ctrlKey: true }); + expect(payloads()).toEqual([RAW]); + expect(cellUnder('Key').querySelector('[title]')?.getAttribute('title')).toBe(RAW); + const csv = await exportedCsv(); + expect(csv.split('\n')[0]).toBe('Name,Key'); + expect(csv).toContain(`"${RAW}"`); + }); + + it('`masked: false` is the same as absent', () => { + renderTable(false); + fireEvent.keyDown(cellUnder('Key'), { key: 'c', ctrlKey: true }); + expect(payloads()).toEqual([RAW]); + }); +}); + +describe('`masked` is declared on the rich column mirror (objectui#10583)', () => { + it('SURVIVES the zod parse — a non-strict object would otherwise strip it silently', () => { + const parsed = TableColumnSchema.safeParse({ header: 'Key', accessorKey: 'key', masked: true }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.masked).toBe(true); + // A value of the wrong type is refused by name, not coerced. + const wrong = TableColumnSchema.safeParse({ header: 'Key', accessorKey: 'key', masked: 'yes' }); + expect(wrong.success).toBe(false); + if (!wrong.success) expect(wrong.error.issues.map((i) => String(i.path[0]))).toContain('masked'); + }); +}); + +describe('data-table — a masked column never enters edit mode (objectui#10583)', () => { + /** Every editor this table can draw is an input or a textarea. */ + const rawInAnEditor = () => + Array.from(document.querySelectorAll('input, textarea')).some((el) => + el.value.includes(RAW), + ); + + it('single-click mode — click and Enter open no editor; the click reaches the row like a read-only cell', () => { + const onRowClick = vi.fn(); + renderTable(true, { editable: true, singleClickEdit: true, onRowClick }); + const masked = cellUnder('Key'); + expect(masked.textContent, 'CONTROL: the producer drew the mask').toContain(MASK); + expect(masked.className, 'no edit cursor on a masked cell').not.toContain('cursor-text'); + + fireEvent.click(masked); + fireEvent.keyDown(masked, { key: 'Enter' }); + expect(masked.querySelector('input, textarea'), 'no editor in the masked cell').toBeNull(); + expect(rawInAnEditor(), 'no editor holds the raw value').toBe(false); + expect(document.body.innerHTML).not.toContain(RAW); + expect(onRowClick, 'the click is not swallowed by an edit that never opens').toHaveBeenCalledTimes(1); + + // CONTROL — the ordinary column in the SAME table does open its editor. + fireEvent.click(cellUnder('Name')); + expect(cellUnder('Name').querySelector('input'), 'CONTROL: the ordinary cell edits').not.toBeNull(); + }); + + it('double-click mode — double-click and Enter open no editor; the ordinary cell still edits', () => { + renderTable(true, { editable: true }); + const masked = cellUnder('Key'); + fireEvent.doubleClick(masked); + fireEvent.keyDown(masked, { key: 'Enter' }); + expect(masked.querySelector('input, textarea')).toBeNull(); + expect(rawInAnEditor()).toBe(false); + expect(document.body.innerHTML).not.toContain(RAW); + + fireEvent.doubleClick(cellUnder('Name')); + expect(cellUnder('Name').querySelector('input'), 'CONTROL: the ordinary cell edits').not.toBeNull(); + }); + + it('CONTROL — the same column with the flag ABSENT edits exactly as before', () => { + renderTable(undefined, { editable: true, singleClickEdit: true }); + fireEvent.click(cellUnder('Key')); + expect(rawInAnEditor(), 'without the flag the editor is seeded with the stored value').toBe(true); + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index e871009b32..1d87e138c9 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -1416,10 +1416,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { }; const handleExport = () => { + // A MASKED column is OMITTED from the export (objectui#10583), header and + // all: its cells draw a mask, so the file must not carry the raw value. + // Omitted rather than blanked — a column of empty strings would assert + // the records hold nothing, and a re-import of it would write that. + const exportColumns = columns.filter((col) => !col.masked); const csvContent = [ - columns.map(col => col.header).join(','), + exportColumns.map(col => col.header).join(','), ...sortedData.map(row => - columns.map(col => JSON.stringify(row[col.accessorKey] || '')).join(',') + exportColumns.map(col => JSON.stringify(row[col.accessorKey] || '')).join(',') ) ].join('\n'); @@ -1571,6 +1576,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const column = columns.find(col => col.accessorKey === columnKey); if (column?.editable === false) return; + // A MASKED column never enters edit mode, on any trigger (objectui#10583): + // the editor is seeded with the RAW row value below, so every editor — + // the built-in inputs and a host's `renderCellEditor` alike — would draw + // the credential the cell's mask hides. This is the one door Enter, click + // and double-click all pass through. + if (column?.masked) return; editingCellRef.current = { rowIndex, columnKey }; setEditingCell({ rowIndex, columnKey }); @@ -1799,6 +1810,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // Copy cell value with Ctrl+C / Cmd+C if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !editingCell) { e.preventDefault(); + // A MASKED column copies NOTHING (objectui#10583) — the producer drew a + // mask, so the keyboard must not hand out what the cell hides. The + // detail page's house shape (objectui#8440, option A): no copy at all, + // ⛔ not the bullets — which is also why `preventDefault()` above stays: + // measured in Chromium, letting the default run copies a selected mask + // as `••••••`, the payload that ruling refused. Unmasked cells are + // untouched below. + if (columns.find((col) => col.accessorKey === columnKey)?.masked) return; const globalIdx = (effectivePage - 1) * pageSize + rowIndex; const row = sortedData[manualPagination ? rowIndex : globalIdx]; if (row) { @@ -2412,7 +2431,11 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const hasPendingChange = rowChanges[col.accessorKey] !== undefined; const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue; const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === col.accessorKey; - const isEditable = editable && col.editable !== false; + // A masked column reads as NOT editable here too + // (objectui#10583), so its cell neither shows the + // edit cursor nor swallows the row's click — + // `startEdit` would refuse it anyway. + const isEditable = editable && col.editable !== false && !col.masked; const isFrozen = frozenColumns > 0 && colIndex < frozenColumns; const frozenOffset = isFrozen ? measuredStickyLefts?.[(selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + colIndex] @@ -2677,7 +2700,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { ? 'w-full whitespace-normal break-words' : 'truncate w-full' } - title={!isFit && cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined} + // No tooltip on a MASKED column (objectui#10583): + // the title carried the raw value, so a hover + // showed what the cell's mask hides. + title={!isFit && !col.masked && cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined} > {typeof col.cell === 'function' ? col.cell(cellValue, row) diff --git a/packages/plugin-grid/README.md b/packages/plugin-grid/README.md index 619cac6c84..fd73940e6b 100644 --- a/packages/plugin-grid/README.md +++ b/packages/plugin-grid/README.md @@ -378,6 +378,33 @@ a render function used to be written for. A genuinely custom cell **renderer** i a component-layer concern: `VirtualGridColumn.cell` on `VirtualGrid`, a React prop, not an authoring key. +Once the grid has loaded its object schema, a `password` or `secret` field draws a +mask (`••••••`), and the grid withholds its raw value on these paths: Ctrl+C / Cmd+C on the cell copies nothing, the cell has no +tooltip, it never enters inline edit, the table's CSV export and the grid's own +client export (CSV and JSON, used when the data source has no server export) leave +it out, the mobile card draws it through its cell, and it cannot be a grouping key +(a grouping entry on it is ignored with a console warning). The grid decides this +with `isMaskedFieldType()` from `@object-ui/fields` and passes it to the table as the +column's `masked` flag (see the data table's "Masked columns"). A column `type` +authored over such a field (`type: 'text'` on a `secret` field) cannot lift the +refusal, though such a cell then draws the value as the text it was told to be. + +Not covered: + +- The table's client-side search and sort still run over the raw values, and a + masked column's width is still sized from the raw value's length (objectui#10657, + which folded objectui#10658). +- On the host-fetched path (rows handed down as `data`, as `ListView` and `ObjectView` + do), the grid's guards and the cell's own mask depend on the object schema, which + the grid fetches after first paint. Until it arrives, and for good if that read fails + (the grid swallows the failure and keeps its heuristic column types), an untyped view + column over a `password` / `secret` field draws and hands out the raw value + (objectui#10657, which folded objectui#10706). +- The server-streamed export (`exportDownload`) sends the masked columns as before + and relies on the server's masking. +- The client JSON export writes an expanded lookup record whole, so a credential + field of the related object is not pruned. + ### Selectable Grid ```typescript diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 73261ef054..5a641f7282 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -33,6 +33,7 @@ import { createSafeTranslation } from '@object-ui/i18n'; // what dropped a `format`-hinted column's renderer, and one shared owner is // what stops a seventh site picking a convention of its own. import { resolveGridCellRendering, gridCellRendererForFixedKey, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution'; +import { isMaskedGridColumn } from './maskedColumn'; import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue } from '@object-ui/fields'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the @@ -2658,8 +2659,48 @@ export const ObjectGrid: React.FC = ({ }; }, [schema.grouping, schema.columns, schema.objectName, objectSchema, translateOptions, t]); + // objectui#10583 — a MASKED field is REFUSED as a grouping key, loudly, once + // `objectSchema` has loaded (until then an untyped column's object-declared + // type is unknown: the host-fetched window, objectui#10657, which folded objectui#10706). + // Grouping by it printed the raw value as each group's label. Masking the + // label would not be enough: the buckets would still show which records + // share a credential, ordered by its raw value. So the entry is dropped (the + // other entries still group, as `usableGroupingFields` does for an unusable + // one) and the drop is reported through the grid's warning channel. The rule + // is the column flag's: `isMaskedGridColumn` over the view column's type and + // the object-declared type. + const groupingFieldsRaw = schema.grouping?.fields; + const maskedGroupingSignature = React.useMemo(() => { + const cols: ReadonlyArray = normalizeColumns(schema.columns) ?? []; + const columnTypeOf = (field: string) => + cols.find((c): c is ListColumn => typeof c === 'object' && c !== null && c.field === field)?.type; + return JSON.stringify( + usableGroupingFields(groupingFieldsRaw) + .map((gf) => gf.field) + .filter((field) => isMaskedGridColumn(columnTypeOf(field), objectSchema?.fields?.[field]?.type)), + ); + }, [groupingFieldsRaw, schema.columns, objectSchema]); + // Keyed on the authored array and the signature STRING, never on a memo's + // identity (AGENTS.md #10). Read only when something was refused: the + // unmasked path below hands `useGroupedData` the authored config itself. + const unmaskedGroupingFields = React.useMemo(() => { + const masked: string[] = JSON.parse(maskedGroupingSignature); + return usableGroupingFields(groupingFieldsRaw).filter((gf) => !masked.includes(gf.field)); + }, [groupingFieldsRaw, maskedGroupingSignature]); + useEffect(() => { + const masked: string[] = JSON.parse(maskedGroupingSignature); + if (masked.length === 0) return; + console.warn( + `[ObjectUI] ObjectGrid grouping: ${schema.objectName ?? 'object-grid'} groups by the masked ` + + `field(s) ${masked.join(', ')}. A masked field cannot be a grouping key: its group labels would ` + + 'show the raw value, and its groups would show which records share it. The entry was ignored.', + ); + }, [maskedGroupingSignature, schema.objectName]); + const { groups, isGrouped, toggleGroup } = useGroupedData( - schema.grouping, + maskedGroupingSignature === '[]' || !schema.grouping + ? schema.grouping + : { ...schema.grouping, fields: unmaskedGroupingFields }, data, schema.aggregations, groupValueFormatter, @@ -3664,8 +3705,17 @@ export const ObjectGrid: React.FC = ({ : str; }; + // objectui#10583 — a MASKED field leaves neither file. The same rule that + // stamps `TableColumn.masked` (`isMaskedGridColumn`, the narrow-only union + // of the column's type and the object-declared type), asked per KEY + // because the JSON branch writes whole records, including fields that are + // not columns. + const columnTypeByKey = new Map(generateColumns().map((c) => [c.accessorKey, c.type])); + const isMaskedKey = (key: string) => + isMaskedGridColumn(columnTypeByKey.get(key), objectSchema?.fields?.[key]?.type); + if (format === 'csv') { - const cols = generateColumns().filter((c) => c.accessorKey !== '_actions'); + const cols = generateColumns().filter((c) => c.accessorKey !== '_actions' && !isMaskedKey(c.accessorKey)); const fields = cols.map((c) => c.accessorKey); const headers = cols.map((c) => c.header); const rows: string[] = []; @@ -3677,7 +3727,10 @@ export const ObjectGrid: React.FC = ({ }); downloadFile(new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8;' }), fileNameFor('csv')); } else if (format === 'json') { - downloadFile(new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }), fileNameFor('json')); + const unmasked = exportData.map((record) => + Object.fromEntries(Object.entries(record).filter(([key]) => !isMaskedKey(key))), + ); + downloadFile(new Blob([JSON.stringify(unmasked, null, 2)], { type: 'application/json' }), fileNameFor('json')); } setShowExport(false); }, [data, schema.exportOptions, schema.operations?.export, effectiveApiOps, schema.objectName, objectName, objectSchema, generateColumns, dataSource, hasInlineData, schemaFilter, schemaSort]); @@ -3823,7 +3876,26 @@ export const ObjectGrid: React.FC = ({ // producer's types have not held in practice. Destructuring a null below // would throw where the pre-#6004 code passed it through. if (!col) return col; - const { type: producerType, ...rest } = col; + const { type: producerType, ...draft } = col; + // ⭐ THE MASKED FLAG (objectui#10583) — stamped HERE, before the fold, + // because the fold is exactly what erases the answer: `password` and + // `secret` are not `TableColumnType` members, so `normalizeTableColumnType` + // drops them and `data-table` could never tell a masked column from a + // text one. It also must not ask the question itself — it cannot import + // `@object-ui/fields` — so this producer asks `isMaskedFieldType()` (via + // `isMaskedGridColumn`) and the table obeys the flag: no Ctrl+C / Cmd+C + // copy, no `title` tooltip, no column in its CSV export, no inline edit. + // + // Every path that writes `type` is covered for the same reason the fold + // is: all four `generateColumns()` literals and the enrichment map above + // pass through this pass. The object-declared type is read beside the + // producer's for the narrow-only union — path A forwards a VIEW-authored + // type ahead of the object's, and `type: 'text'` over a `secret` column + // must keep the flag. Written only when true, so every unmasked column + // reaches the table byte-identical to before. + const rest = isMaskedGridColumn(producerType, objectSchema?.fields?.[col.accessorKey]?.type) + ? { ...draft, masked: true } + : draft; if (producerType == null) return rest; const normalized = normalizeTableColumnType(producerType); if (normalized === undefined) return rest; @@ -5281,7 +5353,16 @@ export const ObjectGrid: React.FC = ({ return 'border-l-gray-300'; }; + // objectui#10583 — a MASKED column (the rule that stamps `TableColumn.masked`) + // is drawn only through its own `cell`, which draws the mask. The branches + // below pick amount / stage / date / percent by the field's NAME and print + // the raw value, so a masked column is never classified; it lands in the + // `col.cell` branch, and the title row routes it through `cell` as well. + const isMaskedCardColumn = (key: string) => + isMaskedGridColumn(colMap.get(key)?.type, objectSchema?.fields?.[key]?.type); + const classify = (key: string): 'amount' | 'stage' | 'date' | 'percent' | 'other' => { + if (isMaskedCardColumn(key)) return 'other'; const k = key.toLowerCase(); if (amountKeys.some(p => k.includes(p))) return 'amount'; if (stageKeys.some(p => k.includes(p))) return 'stage'; @@ -5341,7 +5422,9 @@ export const ObjectGrid: React.FC = ({ {/* Title row - Name as bold prominent title */} {titleCol && (
- {coerceToSafeValue(row[titleCol.accessorKey]) ?? '—'} + {isMaskedCardColumn(titleCol.accessorKey) + ? titleCol.cell?.(row[titleCol.accessorKey], row) + : (coerceToSafeValue(row[titleCol.accessorKey]) ?? '—')}
)} diff --git a/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx new file mode 100644 index 0000000000..145157ffcf --- /dev/null +++ b/packages/plugin-grid/src/__tests__/maskedCellCopyRefusal-10583.test.tsx @@ -0,0 +1,195 @@ +/** + * 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 MASKED grid cell withholds its raw value from the keyboard copy and the + * tooltip (objectui#10583). The other grid surfaces are pinned in + * `maskedColumnSurfaces-10583.test.tsx`. + * + * ## The defect + * + * `@object-ui/fields` draws `password` and `secret` cells as `••••••`. The + * grid drew the mask, and `data-table`'s Ctrl+C / Cmd+C handler copied + * `String(row[columnKey])` for EVERY focused cell anyway — so the keyboard + * handed the raw credential to `navigator.clipboard.writeText`, silently. The + * same cell's wrapper also carried the raw value as its `title` tooltip, so a + * hover showed it and the DOM held it. + * + * The detail page closed the same class on its own surface (objectui#8440, + * maintainer ruling 2026-09-08, option A: no copy affordance on masked field + * types; copying the bullets was refused as a second silent wrong answer). The + * grid follows that house shape: + * + * - `ObjectGrid` (the producer) asks `isMaskedFieldType()` from + * `@object-ui/fields` (objectui#8686) and stamps `masked: true` on the column; + * - `data-table` (which cannot import `@object-ui/fields`) obeys the flag — + * nothing is written to the clipboard, and no raw `title` is drawn. + * + * ## Narrow-only, like the detail page + * + * The flag is the UNION of the view-authored type and the object-declared type + * (`isMaskedDetailFieldType`'s shape, objectui#3355): a view authoring + * `type: 'text'` over a `secret` column never restores the copy. + * + * ## Every case carries a CONTROL in the same mounted tree + * + * The pin is absence-shaped ("the spy was not called"), which also passes when + * the cell never rendered or the event never landed. So each case first fires + * the SAME keystroke on an ordinary `text` cell and requires the spy to + * receive that cell's value, and only then requires silence from the masked + * cell. Absence is counted AT THE SPY. + * + * All three column-emit shapes are exercised (configured `ListColumn[]`, a + * string array, and the object-schema default), because the flag is stamped at + * the one emit seam every one of them passes through. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const RAW_PASSWORD = 'RAW-PASSWORD-10583'; +const RAW_SECRET = 'RAW-SECRET-10583'; +const RAW_VAULT = 'RAW-VAULT-10583'; +const CONTROL_VALUE = 'Row one'; +const MASK = '••••••'; + +const ROWS = [ + { id: 'r1', name: CONTROL_VALUE, api_key: RAW_PASSWORD, token: RAW_SECRET, vault_key: RAW_VAULT }, +]; + +const FIELDS = { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + api_key: { type: 'password', label: 'API Key' }, + token: { type: 'secret', label: 'Token' }, + vault_key: { type: 'secret', label: 'Vault Key' }, +}; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: ROWS, total: ROWS.length, hasMore: false, pageSize: 50 })), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: FIELDS })), + } as any; +} + +let writeText: ReturnType; +const ORIGINAL_INNER_WIDTH = window.innerWidth; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => { + // Desktop: the sub-768px layout is the card list, which has no data-table. + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 }); + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); +}); + +afterEach(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: ORIGINAL_INNER_WIDTH }); + cleanup(); +}); + +function renderGrid(columns: unknown) { + const ds = makeDataSource(); + return render( + + + + + , + ); +} + +/** The body cell under the header whose text is `label`, in the control row. */ +function cellUnder(label: string): HTMLElement { + const row = screen.getByText(CONTROL_VALUE).closest('tr'); + expect(row, 'CONTROL: the data row rendered').not.toBeNull(); + const table = row!.closest('table')!; + const headers = Array.from(table.querySelectorAll('thead th')); + const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label); + expect(index, `CONTROL: a "${label}" column header rendered`).toBeGreaterThanOrEqual(0); + const cell = row!.children[index] as HTMLElement | undefined; + expect(cell?.tagName, `CONTROL: the "${label}" body cell exists`).toBe('TD'); + return cell!; +} + +const payloads = () => writeText.mock.calls.map((call) => call[0]); + +const COPY_KEYS = [ + { chord: 'Ctrl+C', init: { key: 'c', ctrlKey: true } }, + { chord: 'Cmd+C', init: { key: 'c', metaKey: true } }, +]; + +const SHAPES: Array<{ shape: string; columns: unknown; authoredTextOverSecret: boolean }> = [ + { + shape: 'configured ListColumn[]', + columns: [ + { field: 'name', label: 'Name' }, + { field: 'api_key', label: 'API Key' }, + { field: 'token', label: 'Token' }, + // The narrow-only case: a PRESENTATION override over a `secret` column. + { field: 'vault_key', label: 'Vault Key', type: 'text' }, + ], + authoredTextOverSecret: true, + }, + { shape: 'string array', columns: ['name', 'api_key', 'token'], authoredTextOverSecret: false }, + { shape: 'object-schema default (no columns)', columns: undefined, authoredTextOverSecret: false }, +]; + +describe('ObjectGrid — a masked cell writes nothing to the clipboard (objectui#10583)', () => { + for (const { shape, columns, authoredTextOverSecret } of SHAPES) { + for (const { chord, init } of COPY_KEYS) { + it(`${shape} — ${chord} on a password / secret cell copies nothing; the text control copies its value`, async () => { + renderGrid(columns); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + + // CONTROL — the SAME chord on an ordinary text cell reaches the spy, + // in this same mounted tree. + fireEvent.keyDown(cellUnder('Name'), init); + expect(payloads(), 'CONTROL: the text cell copies its own value').toEqual([CONTROL_VALUE]); + writeText.mockClear(); + + for (const [label, raw] of [['API Key', RAW_PASSWORD], ['Token', RAW_SECRET]] as const) { + const cell = cellUnder(label); + // CONTROL — the masked cell rendered, and rendered the MASK. + expect(cell.textContent, `CONTROL: the "${label}" cell drew the mask`).toContain(MASK); + fireEvent.keyDown(cell, init); + expect(payloads(), `${label}: ${chord} must write nothing`).toEqual([]); + // Not in the DOM at all — text OR attribute (the `title` tooltip). + expect(document.body.innerHTML, `${label}: the raw value is nowhere in the DOM`).not.toContain(raw); + } + + if (authoredTextOverSecret) { + // Narrow-only: `type: 'text'` authored over a `secret` column never + // restores the copy. + fireEvent.keyDown(cellUnder('Vault Key'), init); + expect(payloads(), 'Vault Key (text over secret): the copy stays refused').toEqual([]); + } + }); + } + } +}); diff --git a/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx new file mode 100644 index 0000000000..f1a8bc7abf --- /dev/null +++ b/packages/plugin-grid/src/__tests__/maskedColumnSurfaces-10583.test.tsx @@ -0,0 +1,348 @@ +/** + * 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. + */ + +/** + * The OTHER grid surfaces a masked field's raw value reached (objectui#10583, + * round 2). `maskedCellCopyRefusal-10583.test.tsx` pins the keyboard copy and + * the tooltip; this file pins the three paths the contract review named: + * + * 1. INLINE EDIT — the editor was seeded with the raw row value. Reachable + * through the flag's own union: a view authoring `type: 'password'` over an + * object `text` field (or over no field def at all) is masked, while the + * object-level `isFieldInlineEditable` gate reads only the object's type. + * 2. THE GRID'S CLIENT EXPORT — the `exportOptions` menu's fallback, taken + * when the data source has no `exportDownload`: CSV wrote every column's + * raw value and JSON wrote whole records. + * 3. THE MOBILE CARD — the title row printed the first column raw, and the + * amount / stage branches, chosen by the field's NAME, printed it too. + * 4. GROUPING (round 3) — a grouping entry on a masked field printed the raw + * value as the group label. The entry is now REFUSED (dropped, with a + * warning), not masked: masked labels would still bucket the records that + * share a credential, in its raw order. + * + * Each case carries a control in the same mounted tree (an ordinary field + * edits, exports, draws), and the export pins carry a grid with no masked + * field whose files are byte-identical to what the fallback always wrote. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const MASK = '••••••'; +const CONTROL_VALUE = 'Row one'; +const RAW = { + password: 'RAW-PASSWORD-10583', + secret: 'RAW-SECRET-10583', + vault: 'RAW-VAULT-10583', + pin: 'RAW-PIN-10583', + hidden: 'RAW-HIDDEN-10583', + secretValue: 'RAW-SECRET-VALUE-10583', + status: 'RAW-STATUS-10583', +}; + +const ORIGINAL_INNER_WIDTH = window.innerWidth; +const setWidth = (px: number) => + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: px }); + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +beforeEach(() => setWidth(1280)); + +afterEach(() => { + setWidth(ORIGINAL_INNER_WIDTH); + vi.restoreAllMocks(); + cleanup(); +}); + +function makeDataSource(rows: Record[], fields: Record) { + // ⚠️ No `exportDownload`: that is what sends the grid's export to its client fallback. + return { + find: vi.fn(async () => ({ data: rows, total: rows.length, hasMore: false, pageSize: 50 })), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields })), + } as any; +} + +function renderGrid(schema: Record, ds?: any) { + const grid = ; + return render( + + {ds ? {grid} : grid} + , + ); +} + +/** The body cell under the header whose text is `label`, in the control row. */ +function cellUnder(label: string): HTMLElement { + const row = screen.getByText(CONTROL_VALUE).closest('tr'); + expect(row, 'CONTROL: the data row rendered').not.toBeNull(); + const headers = Array.from(row!.closest('table')!.querySelectorAll('thead th')); + const index = headers.findIndex((th) => (th.textContent ?? '').trim() === label); + expect(index, `CONTROL: a "${label}" column header rendered`).toBeGreaterThanOrEqual(0); + return row!.children[index] as HTMLElement; +} + +const rawInAnEditor = (raw: string) => + Array.from(document.querySelectorAll('input, textarea')).some((el) => + el.value.includes(raw), + ); + +/* ── 1. inline edit ──────────────────────────────────────────────────────── */ + +describe('ObjectGrid — a masked column opens no inline editor (objectui#10583)', () => { + const ROWS = [{ id: 'r1', name: CONTROL_VALUE, pin: RAW.pin }]; + // The view authors `type: 'password'` over a field the object declares `text`. + const COLUMNS = [ + { field: 'name', label: 'Name' }, + { field: 'pin', label: 'PIN', type: 'password' }, + ]; + + const cases = [ + { + name: 'over an object `text` field', + render: () => + renderGrid( + { objectName: 'masked_edit_probe', columns: COLUMNS, editable: true, singleClickEdit: true }, + makeDataSource(ROWS, { id: { type: 'text' }, name: { type: 'text', label: 'Name' }, pin: { type: 'text', label: 'PIN' } }), + ), + }, + { + name: 'over no field def (inline rows)', + render: () => + renderGrid({ data: { provider: 'value', items: ROWS }, columns: COLUMNS, editable: true, singleClickEdit: true }), + }, + ]; + + for (const c of cases) { + it(`view \`type: 'password'\` ${c.name} — click and Enter open no editor; the text control edits`, async () => { + c.render(); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + + const masked = cellUnder('PIN'); + expect(masked.textContent, 'CONTROL: the authored password type drew the mask').toContain(MASK); + fireEvent.click(masked); + fireEvent.keyDown(masked, { key: 'Enter' }); + expect(masked.querySelector('input, textarea'), 'no editor in the masked cell').toBeNull(); + expect(rawInAnEditor(RAW.pin), 'no editor holds the raw value').toBe(false); + expect(document.body.innerHTML, 'the raw value is nowhere in the DOM').not.toContain(RAW.pin); + + // CONTROL — the text column in the same editable grid opens its editor. + // Held by reference: once it edits, its text lives in an input value. + const control = cellUnder('Name'); + fireEvent.click(control); + await waitFor(() => + expect(control.querySelector('input, textarea'), 'CONTROL: the text cell edits').not.toBeNull(), + ); + }); + } +}); + +/* ── 2. the grid's client export ─────────────────────────────────────────── */ + +describe("ObjectGrid — the client export leaves every masked field out (objectui#10583)", () => { + let blobs: Blob[]; + + beforeEach(() => { + blobs = []; + const createObjectURL = vi.fn((blob: Blob) => { + blobs.push(blob); + return 'blob:objectui-10583'; + }); + for (const target of new Set([globalThis.URL, window.URL])) { + Object.defineProperty(target, 'createObjectURL', { configurable: true, value: createObjectURL }); + Object.defineProperty(target, 'revokeObjectURL', { configurable: true, value: vi.fn() }); + } + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + }); + + async function exportAs(format: 'CSV' | 'JSON'): Promise { + const toolbarExport = screen + .getAllByRole('button', { name: /export/i }) + .find((el) => !/export as/i.test(el.textContent ?? '')); + expect(toolbarExport, 'CONTROL: the export menu button rendered').toBeDefined(); + fireEvent.click(toolbarExport!); + fireEvent.click(await screen.findByRole('button', { name: new RegExp(`export as ${format}`, 'i') })); + await waitFor(() => expect(blobs.length, `CONTROL: the ${format} export produced a file`).toBeGreaterThan(0)); + return blobs.pop()!.text(); + } + + const MASKED_ROWS = [ + { + id: 'r1', + name: CONTROL_VALUE, + notes: 'plain note', + api_key: RAW.password, + token: RAW.secret, + vault_key: RAW.vault, + pin: RAW.pin, + hidden_secret: RAW.hidden, + }, + ]; + const MASKED_FIELDS = { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + notes: { type: 'text', label: 'Notes' }, + api_key: { type: 'password', label: 'API Key' }, + token: { type: 'secret', label: 'Token' }, + vault_key: { type: 'secret', label: 'Vault Key' }, + pin: { type: 'text', label: 'PIN' }, + // Not a column: only the JSON branch (whole records) could carry it. + hidden_secret: { type: 'secret', label: 'Hidden' }, + }; + const MASKED_COLUMNS = [ + { field: 'name', label: 'Name' }, + { field: 'notes', label: 'Notes' }, + { field: 'api_key', label: 'API Key' }, + { field: 'token', label: 'Token' }, + { field: 'vault_key', label: 'Vault Key', type: 'text' }, + { field: 'pin', label: 'PIN', type: 'password' }, + ]; + + function renderExportGrid(rows: Record[], fields: Record, columns: unknown[]) { + renderGrid( + { objectName: 'masked_export_probe', columns, exportOptions: { formats: ['csv', 'json'] } }, + makeDataSource(rows, fields), + ); + } + + it('CSV — no masked column, header and all; the ordinary columns are written', async () => { + renderExportGrid(MASKED_ROWS, MASKED_FIELDS, MASKED_COLUMNS); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + const csv = await exportAs('CSV'); + expect(csv.split('\n')[0], 'only the ordinary columns').toBe('Name,Notes'); + expect(csv, 'CONTROL: the ordinary values are written').toContain(`${CONTROL_VALUE},plain note`); + for (const raw of Object.values(RAW)) expect(csv).not.toContain(raw); + }); + + it('JSON — no masked field in any record, column or not; the other fields are written', async () => { + renderExportGrid(MASKED_ROWS, MASKED_FIELDS, MASKED_COLUMNS); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + const json = JSON.parse(await exportAs('JSON')); + expect(json, 'CONTROL: the unmasked fields are written').toEqual([{ id: 'r1', name: CONTROL_VALUE, notes: 'plain note' }]); + }); + + it('CONTROL — a grid with no masked field exports byte-for-byte what the fallback always wrote', async () => { + const rows = [{ id: 'r1', name: CONTROL_VALUE, notes: 'plain note' }]; + renderExportGrid( + rows, + { id: { type: 'text' }, name: { type: 'text', label: 'Name' }, notes: { type: 'text', label: 'Notes' } }, + [{ field: 'name', label: 'Name' }, { field: 'notes', label: 'Notes' }], + ); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE)).not.toBeNull()); + expect(await exportAs('CSV')).toBe(`Name,Notes\n${CONTROL_VALUE},plain note`); + expect(await exportAs('JSON')).toBe(JSON.stringify(rows, null, 2)); + }); +}); + +/* ── 3. the mobile card ──────────────────────────────────────────────────── */ + +describe('ObjectGrid — the mobile card draws a masked field through its cell (objectui#10583)', () => { + it('below 768px — a masked TITLE column and masked fields named like amount / stage draw the mask, never the value', async () => { + setWidth(375); + const rows = [ + { id: 'r1', api_key: RAW.password, name: CONTROL_VALUE, secret_value: RAW.secretValue, token_status: RAW.status }, + ]; + renderGrid( + { + objectName: 'masked_card_probe', + // `api_key` FIRST — the card's title row. `secret_value` would classify + // as an amount and `token_status` as a stage, by name. + columns: [ + { field: 'api_key', label: 'API Key' }, + { field: 'name', label: 'Name' }, + { field: 'secret_value', label: 'Secret Value' }, + { field: 'token_status', label: 'Token Status' }, + ], + }, + makeDataSource(rows, { + id: { type: 'text' }, + api_key: { type: 'password', label: 'API Key' }, + name: { type: 'text', label: 'Name' }, + secret_value: { type: 'secret', label: 'Secret Value' }, + token_status: { type: 'secret', label: 'Token Status' }, + }), + ); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE), 'CONTROL: the card drew the ordinary field').not.toBeNull()); + expect(document.querySelector('table'), 'CONTROL: this is the card layout, not the table').toBeNull(); + + const html = document.body.innerHTML; + for (const raw of [RAW.password, RAW.secretValue, RAW.status]) { + expect(html, `${raw} is nowhere in the card`).not.toContain(raw); + } + const masks = (document.body.textContent ?? '').split(MASK).length - 1; + expect(masks, 'the title and both named fields draw the mask').toBe(3); + }); +}); + +/* ── 4. grouping ─────────────────────────────────────────────────────────── */ + +describe('ObjectGrid — a masked field is refused as a grouping key (objectui#10583)', () => { + const RAW_GROUP = 'RAW-GROUP-10643'; + // Two records SHARE the credential: a masked-label grouping would still say so. + const ROWS = [ + { id: 'r1', name: CONTROL_VALUE, category: 'Alpha', api_key: RAW_GROUP }, + { id: 'r2', name: 'Row two', category: 'Beta', api_key: RAW_GROUP }, + { id: 'r3', name: 'Row three', category: 'Alpha', api_key: 'RAW-OTHER-10643' }, + ]; + const FIELDS = { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + category: { type: 'text', label: 'Category' }, + api_key: { type: 'password', label: 'API Key' }, + }; + const COLUMNS = [ + { field: 'name', label: 'Name' }, + { field: 'category', label: 'Category' }, + { field: 'api_key', label: 'API Key' }, + ]; + const groupRows = () => Array.from(document.querySelectorAll('[data-testid^="group-row-"]')); + + function renderGrouped(fields: Array<{ field: string }>) { + renderGrid( + { objectName: 'masked_group_probe', columns: COLUMNS, grouping: { fields } }, + makeDataSource(ROWS, FIELDS), + ); + } + + it('CONTROL — grouping by a text field draws its values as group labels', async () => { + renderGrouped([{ field: 'category' }]); + await waitFor(() => expect(groupRows().length, 'CONTROL: the grid grouped').toBe(2)); + const labels = groupRows().map((g) => g.textContent ?? ''); + expect(labels.some((l) => l.includes('Alpha')) && labels.some((l) => l.includes('Beta'))).toBe(true); + }); + + it('grouping by a password field is refused: no group, no raw label, and a warning names the field', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderGrouped([{ field: 'api_key' }]); + await waitFor(() => expect(screen.queryByText(CONTROL_VALUE), 'CONTROL: the rows rendered').not.toBeNull()); + expect(groupRows(), 'no group was built on the masked field').toHaveLength(0); + expect(document.body.innerHTML).not.toContain(RAW_GROUP); + expect(document.body.innerHTML).not.toContain('RAW-OTHER-10643'); + expect( + warn.mock.calls.some((call) => String(call[0]).includes('ObjectGrid grouping') && String(call[0]).includes('api_key')), + 'the refusal is reported, naming the field', + ).toBe(true); + }); + + it('a masked level is dropped and the other levels still group', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderGrouped([{ field: 'category' }, { field: 'api_key' }]); + await waitFor(() => expect(groupRows().length, 'the category level still groups').toBe(2)); + expect(document.body.innerHTML).not.toContain(RAW_GROUP); + }); +}); diff --git a/packages/plugin-grid/src/maskedColumn.ts b/packages/plugin-grid/src/maskedColumn.ts new file mode 100644 index 0000000000..624977140c --- /dev/null +++ b/packages/plugin-grid/src/maskedColumn.ts @@ -0,0 +1,52 @@ +/** + * 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. + */ + +import { isMaskedFieldType } from '@object-ui/fields'; + +/** + * Is this grid column's cell drawn as a MASK, given the type the column + * carries and the type the object schema declares for its field? + * (objectui#10583) + * + * `ObjectGrid` stamps `TableColumn.masked` from this answer, and `data-table` + * obeys the flag: no Ctrl+C / Cmd+C copy, no `title` tooltip, no column in its + * CSV export, no inline edit. `ObjectGrid` also asks it directly where it + * handles values itself: its client export (CSV and JSON) leaves masked fields + * out, its mobile card draws them through `cell`, and its grouping refuses them + * as keys. `@object-ui/components` + * cannot import `@object-ui/fields`, which is why the answer is computed HERE, + * on the producer side, and handed across as a flag. + * + * ⚠️ Every one of those paths asks with the object-declared type, which the grid + * reads from the object schema it fetches after first paint. On the host-fetched + * path (rows handed down as `data`) an untyped view column is therefore + * unmasked until that schema arrives, and for good if the read fails + * (objectui#10657, which folded objectui#10706). + * + * The rule itself is NOT restated here — it is `isMaskedFieldType()` from + * `@object-ui/fields`, the one authority for "is this field type's cell drawn + * as a mask" (objectui#8686). A type the fields package masks, declared + * (`password`, `secret`) or registered at runtime, sets the flag with no edit + * to this file. The detail page asks the same authority through the same shape + * (`isMaskedDetailFieldType` in `@object-ui/plugin-detail`, objectui#8440). + * + * **Narrow-only**: the answer is the UNION of the two types, so an authored + * column `type` can add the refusal but never withdraw it (objectui#3355). A + * view authoring `type: 'text'` over an object column declared `secret` keeps + * the flag: a PRESENTATION override has no business widening access to a + * credential. The declared cost is the mirror case — an object `text` column + * a view authors as `password` is masked AND flagged, which is the answer its + * cell already gives. + */ +export function isMaskedGridColumn(columnType: unknown, objectFieldType: unknown): boolean { + return isMaskedType(columnType) || isMaskedType(objectFieldType); +} + +function isMaskedType(fieldType: unknown): boolean { + return typeof fieldType === 'string' && isMaskedFieldType(fieldType); +} diff --git a/packages/types/src/__tests__/static-table-narrow-surface.test.ts b/packages/types/src/__tests__/static-table-narrow-surface.test.ts index c3f1e1537d..34bdbb2f2a 100644 --- a/packages/types/src/__tests__/static-table-narrow-surface.test.ts +++ b/packages/types/src/__tests__/static-table-narrow-surface.test.ts @@ -62,12 +62,13 @@ type Equal = type Expect = T; // Side 3, type level: the narrow shape declares exactly the rich shape's key -// set — five live, fifteen tombstoned, none invented, none forgotten. If a +// set — five live, the rest tombstoned, none invented, none forgotten. If a // key is ever added to `TableColumn` without a deliberate decision on the // static side (live or tombstone), this line goes red. (`headerIcon` was the // first key to arrive through that gate — added rich by objectui#6424, // tombstoned here — then #6425's three declared field-meta overrides, -// `fitContent` (objectui#6424's second key), and now `wrap`, objectui#6650.) +// `fitContent` (objectui#6424's second key), `wrap` (objectui#6650), and now +// `masked`, objectui#10583.) type _SameKeySet = Expect>; // The DECLARATION itself, read directly off the interface — no object literal @@ -94,15 +95,16 @@ const LIVE_COLUMN = { width: 120, }; -/** The fifteen keys the narrow surface refuses, with the value an author - * would plausibly write for each: nine the #5474 split retired, plus the six - * that joined the RICH shape later and are tombstoned here under the lockstep +/** The keys the narrow surface refuses, with the value an author would + * plausibly write for each: nine the #5474 split retired, plus those that + * joined the RICH shape later and are tombstoned here under the lockstep * rule — `headerIcon` and `fitContent` (objectui#6424's two keys), the three * field-meta overrides objectui#6425 declared (`format` / `options` / - * `currency`), and `wrap` (objectui#6650). The static renderer reads none of - * them: its measured read set is the five live keys, it has no auto-width - * pass for `fitContent` to opt out of, and no truncation for `wrap` to - * switch off. */ + * `currency`), `wrap` (objectui#6650) and `masked` (objectui#10583). The + * static renderer reads none of them: its measured read set is the five live + * keys, it has no auto-width pass for `fitContent` to opt out of, no + * truncation for `wrap` to switch off, and no copy, tooltip or export for + * `masked` to withhold. */ const RETIRED_COLUMN_KEYS: Record = { minWidth: 80, align: 'right', @@ -119,6 +121,7 @@ const RETIRED_COLUMN_KEYS: Record = { options: [{ value: 'tech', label: 'Technology' }], currency: 'EUR', wrap: true, + masked: true, }; /** Every key the rich `TableColumn` interface declares. `satisfies` keeps the @@ -146,6 +149,7 @@ const RICH_COLUMN_KEYS = [ 'options', 'currency', 'wrap', + 'masked', ] as const satisfies readonly (keyof TableColumn)[]; type _RichKeyListExhaustive = Expect>; @@ -305,7 +309,7 @@ describe('the tombstone refusal reaches the author with its remediation text (ob } }); - it('the SCOPE BOUNDARY of #6105 is closed — the later eight answer with their guidance too (objectui#6931)', () => { + it('the SCOPE BOUNDARY of #6105 is closed — every later arrival answers with its guidance too (objectui#6931)', () => { // This assertion is the #6105 scope-boundary pin, FLIPPED deliberately. // It used to assert the opposite: that these seven — the five rich-shape // arrivals tombstoned here under the lockstep rule (#6424 / #6425) and the @@ -322,7 +326,7 @@ describe('the tombstone refusal reaches the author with its remediation text (ob expect(StaticTableColumnSchema.safeParse(LIVE_COLUMN).success).toBe(true); expect(TableZod.safeParse(STATIC_TABLE).success).toBe(true); - for (const key of ['headerIcon', 'fitContent', 'format', 'options', 'currency', 'wrap'] as const) { + for (const key of ['headerIcon', 'fitContent', 'format', 'options', 'currency', 'wrap', 'masked'] as const) { const result = StaticTableColumnSchema.safeParse({ header: 'Amount', accessorKey: 'amount', diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index a8934d1c74..d8f09e0f9b 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -702,6 +702,53 @@ export interface TableColumn { * this slot and `data-table` reads it here — declared, forwarded, rendered. */ wrap?: boolean; + /** + * The column holds a MASKED value — a credential whose cell is drawn as a + * mask. `data-table` withholds the raw value on four paths of its own: + * Ctrl+C / Cmd+C on one of its cells writes nothing to the clipboard, the + * cell carries no `title` tooltip, the table's CSV export leaves the column + * out, and the column never enters inline edit (on any trigger), because + * every editor is seeded with the raw value. Absent or `false` leaves every + * one of those paths exactly as it was. + * + * ⚠️ What the flag does NOT cover: the table's client-side search and sort + * still run over the raw values, so where they run on the client they can + * answer questions about a masked value, and the column's auto width is + * still sized from the raw value's length (objectui#10657, which folded + * objectui#10658). + * + * ⚠️ The flag withholds; it does not DRAW. The mask a reader sees comes from + * the PRODUCER's {@link TableColumn.cell} renderer. The table draws a column + * with no `cell` as its value, flag or not. + * + * ⭐ The producer decides, and the rule is not restated here: `ObjectGrid` + * stamps this flag from `isMaskedFieldType()` in `@object-ui/fields` + * (objectui#8686), the one authority for "is this field type's cell drawn as + * a mask", reading the view-authored type and, once the grid's object schema + * has loaded, the object-declared type as a narrow-only UNION — a view + * authoring `type: 'text'` over a `secret` column keeps the flag once that + * schema has loaded. Once the grid's object schema has loaded, the same + * rule also leaves every masked field of the grid's object out of the grid's + * own client export, draws those fields through `cell` on its mobile card, + * and refuses them as grouping keys. `@object-ui/components` cannot import + * `@object-ui/fields`, so the table obeys the flag instead of asking the + * question itself. Not covered there: on the host-fetched path (rows handed + * down as `data`, as `ListView` and `ObjectView` do), the grid's guards and + * the cell's own mask depend on the object schema, which the grid fetches + * after first paint, so until it arrives, and for good if that read fails, + * an untyped view column over a `password` / `secret` field draws and hands + * out the raw value (objectui#10657, which folded objectui#10706); the server-streamed export (`exportDownload`) sends the masked columns as + * before and relies on the server's masking; the client JSON export, and + * this table's CSV export of a lookup column, write an expanded lookup + * record whole, so a credential field of the related object is not pruned; + * and other producers of these columns (`RelatedList`, `ObjectDataTable`) + * do not set the flag yet (objectui#10657). + * + * Declared by objectui#10583: the grid drew the mask while the table's + * keyboard copy wrote `String(row[accessorKey])` for every cell — the grid + * face of the disclosure objectui#8440 closed on the detail page. + */ + masked?: boolean; } /** @@ -761,6 +808,14 @@ export interface StaticTableColumn { * @deprecated Not part of the static `table` renderer's contract. */ wrap?: never; + /** + * NOT on the static `table` surface (objectui#10583) — declared on the rich + * {@link TableColumn} only, where `data-table` reads it. The static renderer + * has no keyboard copy, tooltip, export or inline edit for it to withhold. + * Use `data-table` for the interactive set. + * @deprecated Not part of the static `table` renderer's contract. + */ + masked?: never; /** * RETIRED from the static `table` surface (objectui#5474, ADR-0049) — the * static renderer never read it; a right-aligned column authored here was diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 2d1974dffa..7ca87ba27c 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -280,6 +280,16 @@ export const TableColumnSchema = z.object({ // declaration refuses. The `.describe()` text is the spec's own wording // for `ListColumn.wrap`, so the two authoring surfaces read alike. wrap: z.boolean().optional().describe('Allow text wrapping'), + // objectui#10583. Serializable metadata, so the mirror TYPES it — + // `z.boolean()`, like `fitContent` and `wrap`. Without this line the + // non-strict object would silently STRIP an authored `masked`, and the + // table would copy, tooltip, export and edit the raw value again: the same + // second de-facto contract #6424 closed for `headerIcon`. The producer that + // sets it is `ObjectGrid`, from `isMaskedFieldType()` (`@object-ui/fields`). + masked: z + .boolean() + .optional() + .describe('Masked column: the table withholds the raw value from Ctrl+C / Cmd+C copy, the cell title tooltip, its CSV export and inline edit. Client-side search, sort and the column auto width still read the raw value. It withholds only: the producer\'s cell renderer draws the mask, and the table draws a column with no cell as its value'), }); /** @@ -306,10 +316,11 @@ export const TableColumnSchema = z.object({ * `success`, same issue `path`, same issue `code` (`invalid_type`); only the * message differs. * - * The six later arrivals below (`headerIcon` / `fitContent`, objectui#6424; - * `format` / `options` / `currency`, objectui#6425; `wrap`, objectui#6650) - * were outside #6105's reviewed scope and carried the bare spelling until - * objectui#6931 converted them here (`wrap` was born converted). That + * The later arrivals below (`headerIcon` / `fitContent`, objectui#6424; + * `format` / `options` / `currency`, objectui#6425; `wrap`, objectui#6650; + * `masked`, objectui#10583) were outside #6105's reviewed scope; the first + * five carried the bare spelling until objectui#6931 converted them here + * (`wrap` and `masked` were born converted). That * mattered because a half-converted shape teaches worse than a uniform one: * an author reading guidance on nine keys and zod's generic on five learns the * message means something, then has it withheld. @@ -335,6 +346,7 @@ export const StaticTableColumnSchema = z.object({ options: retirementTombstone('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'), currency: retirementTombstone('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'), wrap: retirementTombstone('NOT on the static table surface (objectui#6650) — declared on the rich TableColumn only; use data-table'), + masked: retirementTombstone('NOT on the static table surface (objectui#10583) — declared on the rich TableColumn only; use data-table'), }); /**