Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/8686-masked-field-type-authority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-detail': patch
---

feat(fields): `isMaskedFieldType()` and `MASKED_FIELD_TYPES` answer "is this field type's cell drawn as a mask?"

**New public API on `@object-ui/fields`:** `MASKED_FIELD_TYPES` (a `ReadonlySet<string>`,
today `password` and `secret`) and `isMaskedFieldType(fieldType)`. They are the read-side
twin of `INLINE_EXCLUDED_FIELD_TYPES` / `isInlineExcludedFieldType()`. Additive; nothing
existing changes shape.

Until now the fact lived only as two entries in `getCellRenderer`'s standard table, and
nothing outside the package could ask it. So the detail page's copy refusal
(objectui#8440) kept its own two-member list: a masked type added to the fields package
would render masked and stay one click from the clipboard there.

- The standard table's masked entries are now built from `MASKED_FIELD_TYPES`, so the
set and the drawn mask are one fact.
- `isMaskedFieldType()` reads the LIVE cell registry. A type registered with the mask
(`registerFieldRenderer('api_token', getCellRenderer('password'))`) answers `true`. A
shipped mask replaced at runtime with one of this package's own renderers answers
`false`, since none of them is the mask; the cell then draws what that renderer draws
(for `TextCellRenderer`, the value). A shipped mask
replaced with a host component the package cannot inspect keeps the declared answer
(`true`), on the side that withholds the value.
- It matches raw spellings only, as `getCellRenderer` does: `field:password` renders in
the clear and is not masked.

`@object-ui/plugin-detail`: `isMaskedDetailFieldType` now asks `isMaskedFieldType()`
instead of keeping its own list. It stays the narrow-only union of the view's and the
object's type (objectui#3355). Behaviour changes only where the two used to disagree:
a masked type registered at runtime now refuses the copy affordance, and a shipped mask
that a host replaced with a package text renderer offers it again.
28 changes: 28 additions & 0 deletions packages/fields/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,34 @@ every type draws — reconcile their tables against this reading so a newly
registered type fails them by name instead of slipping past a frozen
population (objectui#8734).

### Asking whether a cell is masked

`isMaskedFieldType(type)` answers whether a cell of that field type is drawn
as a mask (`••••••`) instead of its value. It is the read-side twin of
`isInlineExcludedFieldType()`, and a consumer that acts on a cell's value asks
it rather than keeping its own list. The detail page's copy affordance in
`@object-ui/plugin-detail` is one such consumer: it refuses to copy a masked
row (objectui#8686). `MASKED_FIELD_TYPES` is the declared set behind it,
`password` and `secret`. The standard cell of each member draws the mask, and
the predicate answers `true` for it. Like `listCellRendererTypes()`, the
answer is a live reading of the cell registry taken when you call it, and,
with nothing overridden at runtime, it agrees with what `getCellRenderer`
resolves for every type that function lists. To add a masked type, register
the package's own mask under it with
`registerFieldRenderer('api_token', getCellRenderer('password'))`, while
`password` still resolves to the shipped mask. The predicate then answers
`true` with no change to the declared set, and the detail page refuses to copy
that row. Overriding a declared type with one of this package's own renderers
makes the predicate answer `false`, since none of them is the mask, and the
cell draws what that renderer draws: after
`registerFieldRenderer('password', TextCellRenderer)` the cell shows the value
and the detail row copies again. Overriding a declared type with a component
of your own keeps the declared answer, `true`, because the package cannot
tell whether an opaque component hides the value, so it errs toward
withholding it. Your own component under an undeclared type answers `false`.
Spellings are matched raw, the way `getCellRenderer` looks them up:
`field:password` renders in the clear and is not masked.

### File uploads in line-item grids

`GridField` (the master-detail line-items grid) supports `type: 'file'` columns:
Expand Down
183 changes: 183 additions & 0 deletions packages/fields/src/__tests__/isMaskedFieldType-8686.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* 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.
*/

/**
* `isMaskedFieldType()` is THE authority for "is this field type's cell drawn
* as a mask?" (objectui#8686), and `MASKED_FIELD_TYPES` is the set behind it.
*
* Before this card the fact had no queryable home: the mask lived as two table
* entries inside `getCellRenderer`'s standard map, so every consumer that had
* to honour it (the detail page's copy refusal, objectui#8440) kept its own
* list. The ruling on objectui#8686: the predicate reads the LIVE renderer
* registration where it can, falling back to the declared set.
*
* Three families of pins:
*
* - DECLARED — every member of the set answers `true` AND its cell really
* draws the mask, with a lit non-masked control. The set is tied to what
* the cell draws, not only to itself.
* - CENSUS — over every type `getCellRenderer` resolves to a renderer of its
* own, the predicate agrees with the resolver: `true` exactly where the
* resolved renderer is the mask. A future edit that grows the table with a
* second masked entry outside the set, or answers from a list again, goes
* red by name.
* - RUNTIME — the override behaviour the ruling asked to be decided:
* registering the mask under a NEW type masks it; replacing a declared
* type's mask with one of this package's renderers unmasks it; replacing it
* with a host component this package cannot read keeps the declared answer.
*
* ⚠️ `registerFieldRenderer` has no inverse and the RUNTIME cases mutate the
* registry, so this file is a `.tsx`: it lands in the `dom` project, which keeps
* `isolate: true` (the `unit` project shares one module graph across files).
* The pristine-state families are declared first and run first, and every
* override case restores the mask it replaced.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import * as React from 'react';
import {
getCellRenderer,
isMaskedFieldType,
listCellRendererTypes,
MASKED_FIELD_TYPES,
registerFieldRenderer,
TextCellRenderer,
type CellRendererProps,
} from '../index';

afterEach(() => cleanup());

const MASK_TEXT = '••••••';
const RAW = 'objectui-8686-raw-credential';

/**
* The mask renderer, captured through the public resolver BEFORE anything in
* this file touches the registry. It is not exported, and does not need to be:
* `getCellRenderer('password')` is how a host reaches it too.
*/
const MASK_RENDERER = getCellRenderer('password');

function renderCell(type: string, value: unknown): HTMLElement {
const Renderer = getCellRenderer(type);
const field = { type, name: type } as unknown as CellRendererProps['field'];
return render(<Renderer value={value} field={field} />).container;
}

describe('objectui#8686 — DECLARED: the set, the predicate and the drawn mask are one fact', () => {
it('the declared set is non-empty, so every loop below runs', () => {
expect(MASKED_FIELD_TYPES.size, 'an empty set would make the loops vacuous').toBeGreaterThan(0);
});

for (const type of MASKED_FIELD_TYPES) {
it(`\`${type}\` is masked: the predicate says so AND the cell draws the mask`, () => {
expect(isMaskedFieldType(type), `${type} is a declared masked type`).toBe(true);
const cell = renderCell(type, RAW);
expect(cell.textContent, `${type}: the cell drew the mask`).toBe(MASK_TEXT);
expect(cell.innerHTML, `${type}: the stored value never reaches the DOM`).not.toContain(RAW);
});
}

it('LIT CONTROL — `text` is not masked, and its cell draws the value', () => {
expect(MASKED_FIELD_TYPES.has('text'), 'control: `text` is not declared masked').toBe(false);
expect(isMaskedFieldType('text'), '`text` is not masked').toBe(false);
const cell = renderCell('text', RAW);
expect(cell.textContent, 'the same probe value IS drawn by a non-masked cell').toContain(RAW);
});

it('no type, and spellings the cell path does not resolve, are not masked', () => {
expect(isMaskedFieldType(undefined)).toBe(false);
expect(isMaskedFieldType('')).toBe(false);
expect(isMaskedFieldType('objectui-8686-never-registered')).toBe(false);
// The form-alias spelling renders in the clear: `getCellRenderer` is an
// exact-key lookup and does not resolve `field:` aliases.
expect(getCellRenderer('field:password'), 'control: the alias spelling falls to text').toBe(
TextCellRenderer,
);
expect(isMaskedFieldType('field:password'), 'so it is not masked either').toBe(false);
});
});

describe('objectui#8686 — CENSUS: the predicate agrees with the resolver on every registered type', () => {
it('`true` exactly where the resolved renderer is the mask, over the live registry reading', () => {
const types = listCellRendererTypes();
// Lit halves: the population holds every declared member and at least one
// type that is not masked, so the agreement below compares both answers.
for (const t of MASKED_FIELD_TYPES) expect(types, `${t} is in the census`).toContain(t);
expect(types, 'a non-masked control type is in the census').toContain('text');

const disagreeing = types.filter(
(t) => isMaskedFieldType(t) !== (getCellRenderer(t) === MASK_RENDERER),
);
expect(disagreeing, 'types where the predicate and the drawn cell disagree').toEqual([]);

const masked = types.filter((t) => isMaskedFieldType(t));
expect(
[...masked].sort(),
'with nothing registered at runtime, the masked types are exactly the declared set',
).toEqual([...MASKED_FIELD_TYPES].sort());
});
});

describe('objectui#8686 — RUNTIME: the live registration is read, the declared set is the fallback', () => {
it('registering THE mask under a NEW type masks it, with no declared-set edit', () => {
const type = 'objectui_8686_api_token';
// Control leg — attributes the answer below to the registration.
expect(isMaskedFieldType(type), 'control: an unregistered type is not masked').toBe(false);
expect(renderCell(type, RAW).textContent, 'control: it draws the value').toContain(RAW);
cleanup();

registerFieldRenderer(type, getCellRenderer('password'));

expect(MASKED_FIELD_TYPES.has(type), 'the declared set was not edited').toBe(false);
expect(isMaskedFieldType(type), 'the live registration is read').toBe(true);
expect(renderCell(type, RAW).textContent, 'and the cell draws the mask').toBe(MASK_TEXT);
});

it("replacing a declared type's mask with one of this package's renderers UNMASKS it", () => {
const type = 'password';
expect(isMaskedFieldType(type), 'control: masked before the override').toBe(true);
try {
registerFieldRenderer(type, TextCellRenderer);
// The override took effect: the cell now shows the value.
expect(renderCell(type, RAW).textContent, 'the overridden cell draws the value').toContain(
RAW,
);
expect(isMaskedFieldType(type), 'the predicate follows the cell').toBe(false);
} finally {
registerFieldRenderer(type, MASK_RENDERER);
}
expect(isMaskedFieldType(type), 'restored: masked again').toBe(true);
});

it("replacing a declared type's mask with a HOST component keeps the declared answer", () => {
const type = 'secret';
// A host's own mask: this package cannot tell it from a component that
// prints the value, so it answers with the declared set, on the side that
// withholds the value.
const HostMask: React.FC<CellRendererProps> = () => <span>[hidden by host]</span>;
expect(isMaskedFieldType(type), 'control: masked before the override').toBe(true);
try {
registerFieldRenderer(type, HostMask);
expect(getCellRenderer(type), 'the override took effect').toBe(HostMask);
expect(isMaskedFieldType(type), 'an unreadable override falls back to the declared set').toBe(
true,
);
} finally {
registerFieldRenderer(type, MASK_RENDERER);
}
});

it('a HOST component under an undeclared type is not masked: the set is the only fallback', () => {
const type = 'objectui_8686_host_only';
const HostCell: React.FC<CellRendererProps> = () => <span>host</span>;
registerFieldRenderer(type, HostCell);
expect(getCellRenderer(type), 'control: the host component is registered').toBe(HostCell);
expect(isMaskedFieldType(type)).toBe(false);
});
});
70 changes: 68 additions & 2 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3276,6 +3276,70 @@ function MaskedCellRenderer({ value }: CellRendererProps): React.ReactElement {
return <span>••••••</span>;
}

/**
* The field types this package DECLARES masked: their standard cell is
* {@link MaskedCellRenderer}, which draws `••••••` in place of the value
* (objectui#8686). The read-side twin of the credential entry in
* `INLINE_EXCLUDED_FIELD_TYPES`, which lives in `FieldEditWidget`.
*
* ⭐ This set BUILDS the table's masked entries — `buildStandardCellRendererMap`
* spreads one {@link MaskedCellRenderer} entry per member — so adding a type
* here masks its cell AND makes {@link isMaskedFieldType} answer `true` for it,
* in one edit. There is no second list to keep in step.
*
* ⚠️ Membership answers "what ships masked", ⛔ not "what is masked right now":
* `registerFieldRenderer` can add or replace a masked type at runtime, and a
* set cannot see that. Consumers deciding what to do with a cell's value ask
* {@link isMaskedFieldType}.
*
* RAW spellings, deliberately: the cell path does not resolve form aliases
* (`getCellRenderer` is an exact-key lookup), so `field:password` renders in
* the clear and is not a member.
*
* Owned here for now. The objectui#8686 ruling made the protocol the first
* place to look: if `@objectstack/spec` comes to declare which field types are
* credentials, this set derives from that declaration instead of listing types.
*/
export const MASKED_FIELD_TYPES: ReadonlySet<string> = new Set<string>(['password', 'secret']);

/**
* Is a cell of this field type drawn as a mask instead of as its value? The
* one authority for that question (objectui#8686): ask it rather than keep a
* list of types. The read-side twin of `isInlineExcludedFieldType()`.
*
* A LIVE reading of the cell registry, taken at call time, in the order
* {@link getCellRenderer} resolves: the runtime registry first, then the
* standard table.
*
* 1. The type resolves to THE mask ({@link MaskedCellRenderer}) → `true`,
* declared or not. That is how a host adds a masked type:
* `registerFieldRenderer('api_token', getCellRenderer('password'))`.
* 2. Otherwise, a type outside {@link MASKED_FIELD_TYPES} → `false`.
* 3. A declared type whose mask a host REPLACED at runtime
* (`registerFieldRenderer('password', X)`) → the override is read:
* - X is one of this package's own cell renderers → `false`. None of them
* is the mask, so the predicate answers `false` and the cell draws what
* X draws (for `TextCellRenderer`, the value).
* - X is the host's own component → `true`, the declared answer. Nothing
* here can tell whether an opaque component masks. Answering `false`
* would offer a credential to anything that trusts this predicate
* (the detail page's copy affordance, objectui#8440) while a custom mask
* hides it on screen. So an unreadable override falls back to the
* declared set, on the side that withholds.
*
* RAW spelling, no alias resolution, for the reason {@link MASKED_FIELD_TYPES}
* states. Side-effect free, like `isInlineExcludedFieldType()`: unlike
* {@link getCellRenderer}, it never reports a retired spelling.
*/
export function isMaskedFieldType(fieldType: string | undefined): boolean {
if (!fieldType) return false;
const standardMap = buildStandardCellRendererMap();
const live = fieldRegistry.has(fieldType) ? fieldRegistry.get(fieldType) : standardMap[fieldType];
if (live === MaskedCellRenderer) return true;
if (!MASKED_FIELD_TYPES.has(fieldType)) return false;
return !Object.values(standardMap).some((standard) => standard === live);
}

/**
* `vector` / `grid` cell renderers: the placeholder literal for a value that is
* stored, and the shared affordance for none (objectui#8678). Before this, both
Expand Down Expand Up @@ -3374,8 +3438,10 @@ function buildStandardCellRendererMap(): Record<string, React.FC<CellRendererPro
summary: FormulaCellRenderer,
auto_number: TextCellRenderer,
user: UserCellRenderer,
password: MaskedCellRenderer,
secret: MaskedCellRenderer,
// `password` / `secret` — spread from THE declared set, so the table that
// draws the mask and the set `isMaskedFieldType` falls back to are one
// fact (objectui#8686).
...Object.fromEntries([...MASKED_FIELD_TYPES].map((type) => [type, MaskedCellRenderer])),
location: LocationCellRenderer,
geolocation: LocationCellRenderer,
address: AddressCellRenderer,
Expand Down
Loading
Loading