diff --git a/.changeset/silent-jars-refuse.md b/.changeset/silent-jars-refuse.md new file mode 100644 index 00000000..d7a9f0c0 --- /dev/null +++ b/.changeset/silent-jars-refuse.md @@ -0,0 +1,9 @@ +--- +'@opensaas/stack-ui': minor +--- + +Password columns are now identified by field type, not field name, across the list view, standalone `ListTable`, and item-view Relationship tables. A field declared `secret: password()` is now excluded from default columns even though it isn't named `password`; a field merely named `password` (e.g. `password: text()`) is no longer excluded unless it is actually a `password()` field. + +A `password` Cell is now registered in the cell registry, so a password-typed column shown via an explicit `columns` prop renders a fixed `••••••••` mask instead of the raw value. + +BREAKING (shipped as minor — pre-1.0 packages ship breaking changes as minor): the unused `getFieldDisplayValue` export has been removed from `@opensaas/stack-ui`. It had no callers in the rendering path — Cells render each field type directly — so nothing in this package depended on it; a consumer importing it directly should port to a project-local formatter. diff --git a/packages/ui/src/components/ListViewClient.tsx b/packages/ui/src/components/ListViewClient.tsx index 6f33efa2..5fb4784e 100644 --- a/packages/ui/src/components/ListViewClient.tsx +++ b/packages/ui/src/components/ListViewClient.tsx @@ -185,7 +185,9 @@ export function ListViewClient({ const displayColumns = columns || - Object.keys(fieldTypes).filter((key) => !['password', 'createdAt', 'updatedAt'].includes(key)) + Object.keys(fieldTypes).filter( + (key) => fieldTypes[key] !== 'password' && !['createdAt', 'updatedAt'].includes(key), + ) // Items are already sorted by the server via orderBy; no in-memory sort needed. diff --git a/packages/ui/src/components/cells/PasswordCell.tsx b/packages/ui/src/components/cells/PasswordCell.tsx new file mode 100644 index 00000000..df28bec0 --- /dev/null +++ b/packages/ui/src/components/cells/PasswordCell.tsx @@ -0,0 +1,13 @@ +'use client' + +import * as React from 'react' +import type { CellComponentProps } from './registry.js' + +/** + * Renders a fixed mask for a `password()` column regardless of `value` — a + * password column must never render the underlying value, so this Cell does + * not read it at all. + */ +export function PasswordCell(_props: CellComponentProps) { + return •••••••• +} diff --git a/packages/ui/src/components/cells/index.ts b/packages/ui/src/components/cells/index.ts index 51dac1e3..9b2cdccf 100644 --- a/packages/ui/src/components/cells/index.ts +++ b/packages/ui/src/components/cells/index.ts @@ -5,6 +5,7 @@ export { CheckboxCell } from './CheckboxCell.js' export { SelectCell } from './SelectCell.js' export { TimestampCell } from './TimestampCell.js' export { RelationshipCell } from './RelationshipCell.js' +export { PasswordCell } from './PasswordCell.js' export { AvatarLabelCell } from './AvatarLabelCell.js' export { CellRenderer } from './CellRenderer.js' diff --git a/packages/ui/src/components/cells/registry.ts b/packages/ui/src/components/cells/registry.ts index d7f0e0b5..fe2c0d05 100644 --- a/packages/ui/src/components/cells/registry.ts +++ b/packages/ui/src/components/cells/registry.ts @@ -6,6 +6,7 @@ import { CheckboxCell } from './CheckboxCell.js' import { SelectCell } from './SelectCell.js' import { TimestampCell } from './TimestampCell.js' import { RelationshipCell } from './RelationshipCell.js' +import { PasswordCell } from './PasswordCell.js' /** * Props every Cell component receives — the list-table rendering of one @@ -47,6 +48,7 @@ const cellComponentRegistry: Record = { select: SelectCell, timestamp: TimestampCell, relationship: RelationshipCell, + password: PasswordCell, } /** diff --git a/packages/ui/src/components/standalone/ListTable.tsx b/packages/ui/src/components/standalone/ListTable.tsx index 8ac6765a..7e964cbc 100644 --- a/packages/ui/src/components/standalone/ListTable.tsx +++ b/packages/ui/src/components/standalone/ListTable.tsx @@ -119,7 +119,9 @@ export function ListTable({ const displayColumns = columns || - Object.keys(fieldTypes).filter((key) => !['password', 'createdAt', 'updatedAt'].includes(key)) + Object.keys(fieldTypes).filter( + (key) => fieldTypes[key] !== 'password' && !['createdAt', 'updatedAt'].includes(key), + ) const sortedItems = [...items] if (sortBy && sortable) { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3db9af59..21305608 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -68,6 +68,7 @@ export { SelectCell, TimestampCell, RelationshipCell, + PasswordCell, AvatarLabelCell, CellRenderer, cellComponentRegistry, @@ -148,13 +149,7 @@ export type { } from './components/standalone/index.js' // Utility functions -export { - cn, - formatListName, - formatFieldName, - getFieldDisplayValue, - isNumericField, -} from './lib/utils.js' +export { cn, formatListName, formatFieldName, isNumericField } from './lib/utils.js' // Relationship-options read primitive (re-exported from @opensaas/stack-core) export { getRelationshipOptions } from './lib/getRelationshipOptions.js' diff --git a/packages/ui/src/lib/deriveItemView.ts b/packages/ui/src/lib/deriveItemView.ts index 1be969b6..6462311d 100644 --- a/packages/ui/src/lib/deriveItemView.ts +++ b/packages/ui/src/lib/deriveItemView.ts @@ -80,11 +80,11 @@ export interface ItemViewLayout { } /** - * Related-list columns that are never shown by default, mirroring the list - * view's own default curation (`ListViewClient`): system timestamp columns and - * password fields. + * System timestamp columns never shown by default, mirroring the list view's + * own default curation (`ListViewClient`). Password fields are excluded + * separately, by type rather than name (see {@link defaultColumnsFor}). */ -const DEFAULT_EXCLUDED_COLUMNS = new Set(['password', 'createdAt', 'updatedAt']) +const DEFAULT_EXCLUDED_COLUMNS = new Set(['createdAt', 'updatedAt']) function readStringArray(value: unknown): string[] | undefined { return Array.isArray(value) && value.every((entry) => typeof entry === 'string') @@ -154,7 +154,10 @@ function defaultColumnsFor( if (!relatedListConfig) return [] const curated = relatedListConfig.ui?.listView?.initialColumns ?? - Object.keys(relatedListConfig.fields).filter((key) => !DEFAULT_EXCLUDED_COLUMNS.has(key)) + Object.keys(relatedListConfig.fields).filter( + (key) => + relatedListConfig.fields[key]?.type !== 'password' && !DEFAULT_EXCLUDED_COLUMNS.has(key), + ) return curated.filter((column) => column !== backReferenceField) } diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index ed2f758d..1999032c 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -61,27 +61,3 @@ export function isSortableColumn(field: SerializableFieldConfig | undefined): bo if (field.type === 'relationship') return field.many === true return true } - -/** - * Get the display value for a scalar field. - * - * Relationship fields are not handled here — their label is resolved via the - * shared label seam (`getItemLabel`) by the component that has access to the - * related list's config (see `ListView.tsx`), not derived from the raw value. - */ -export function getFieldDisplayValue(value: unknown, fieldType: string): string { - if (value === null || value === undefined) { - return '-' - } - - switch (fieldType) { - case 'checkbox': - return value ? 'Yes' : 'No' - case 'timestamp': - return new Date(value as string | number | Date).toLocaleString() - case 'password': - return '••••••••' - default: - return String(value) - } -} diff --git a/packages/ui/tests/components/CellRenderer.test.tsx b/packages/ui/tests/components/CellRenderer.test.tsx index 24c37abc..0492f7c5 100644 --- a/packages/ui/tests/components/CellRenderer.test.tsx +++ b/packages/ui/tests/components/CellRenderer.test.tsx @@ -75,6 +75,13 @@ describe('CellRenderer resolution priority', () => { expect(cell).toHaveTextContent('3') }) + it('resolves a password-typed field to the masked Cell, ignoring the raw value', () => { + const field: SerializableFieldConfig = { type: 'password' } + render() + expect(screen.getByText('••••••••')).toBeInTheDocument() + expect(screen.queryByText('raw-hash-value')).not.toBeInTheDocument() + }) + it('4. unknown / third-party types without a registered Cell fall back to plain text', () => { const field: SerializableFieldConfig = { type: 'someUnregisteredThirdPartyType' } render() diff --git a/packages/ui/tests/components/Cells.test.tsx b/packages/ui/tests/components/Cells.test.tsx index c2d5e39c..dc0ee4c7 100644 --- a/packages/ui/tests/components/Cells.test.tsx +++ b/packages/ui/tests/components/Cells.test.tsx @@ -6,6 +6,7 @@ import { TimestampCell } from '../../src/components/cells/TimestampCell.js' import { CheckboxCell } from '../../src/components/cells/CheckboxCell.js' import { RelationshipCell } from '../../src/components/cells/RelationshipCell.js' import { TextCell } from '../../src/components/cells/TextCell.js' +import { PasswordCell } from '../../src/components/cells/PasswordCell.js' import type { SerializableFieldConfig } from '../../src/lib/serializeFieldConfig.js' const statusField: SerializableFieldConfig = { @@ -154,3 +155,16 @@ describe('TextCell', () => { expect(screen.getByText('-')).toBeInTheDocument() }) }) + +describe('PasswordCell', () => { + it('renders a fixed mask regardless of the raw value', () => { + render() + const cell = screen.getByText('••••••••') + expect(cell).toHaveAttribute('data-slot', 'cell-password') + }) + + it('does not depend on any serialisation of value — an empty/null value still masks', () => { + render() + expect(screen.getByText('••••••••')).toBeInTheDocument() + }) +}) diff --git a/packages/ui/tests/components/ListTable.test.tsx b/packages/ui/tests/components/ListTable.test.tsx index 673ddd9f..c4a3acb2 100644 --- a/packages/ui/tests/components/ListTable.test.tsx +++ b/packages/ui/tests/components/ListTable.test.tsx @@ -449,6 +449,23 @@ describe('ListTable', () => { expect(screen.queryByText('Password')).not.toBeInTheDocument() }) + it('should exclude a password-typed field regardless of its name', () => { + const items = [{ id: '1', username: 'john', secret: 'hash...' }] + + render() + + expect(screen.getByText('Username')).toBeInTheDocument() + expect(screen.queryByText('Secret')).not.toBeInTheDocument() + }) + + it('should not exclude a field merely named password if it is not password-typed', () => { + const items = [{ id: '1', password: 'plain text field' }] + + render() + + expect(screen.getByText('Password')).toBeInTheDocument() + }) + it('should exclude createdAt and updatedAt by default', () => { const items = [ { diff --git a/packages/ui/tests/components/ListViewClient.test.tsx b/packages/ui/tests/components/ListViewClient.test.tsx index 50c5b702..da53961d 100644 --- a/packages/ui/tests/components/ListViewClient.test.tsx +++ b/packages/ui/tests/components/ListViewClient.test.tsx @@ -505,6 +505,31 @@ describe('ListViewClient', () => { }) }) + describe('column filtering', () => { + it('should exclude password-typed columns by default regardless of field name', () => { + const items = [{ id: '1', username: 'john', secret: 'hash...' }] + + render( + , + ) + + expect(screen.getByText('Username')).toBeInTheDocument() + expect(screen.queryByText('Secret')).not.toBeInTheDocument() + }) + + it('should not exclude a field merely named password if it is not password-typed', () => { + const items = [{ id: '1', password: 'plain text field' }] + + render() + + expect(screen.getByText('Password')).toBeInTheDocument() + }) + }) + describe('edit links', () => { it('should link to correct edit page', () => { render() diff --git a/packages/ui/tests/lib/deriveItemView.test.ts b/packages/ui/tests/lib/deriveItemView.test.ts index 2987f2e9..f2ac085e 100644 --- a/packages/ui/tests/lib/deriveItemView.test.ts +++ b/packages/ui/tests/lib/deriveItemView.test.ts @@ -355,4 +355,32 @@ describe('deriveItemViewLayout', () => { expect(section.backReferenceField).toBeUndefined() expect(section.columns).toEqual(['name']) }) + + it('excludes a password-typed related-list column by type, regardless of its name', () => { + const config = makeConfig({ + Team: { + fields: { members: { type: 'relationship', ref: 'Member', many: true } }, + }, + Member: { + fields: { name: { type: 'text' }, secret: { type: 'password' } }, + }, + }) + + const [section] = deriveItemViewLayout(config, 'Team').sections + expect(section.columns).toEqual(['name']) + }) + + it('does not exclude a related-list column merely named password if it is not password-typed', () => { + const config = makeConfig({ + Team: { + fields: { members: { type: 'relationship', ref: 'Member', many: true } }, + }, + Member: { + fields: { name: { type: 'text' }, password: { type: 'text' } }, + }, + }) + + const [section] = deriveItemViewLayout(config, 'Team').sections + expect(section.columns).toEqual(['name', 'password']) + }) })