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'])
+ })
})