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
9 changes: 9 additions & 0 deletions .changeset/silent-jars-refuse.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/ui/src/components/ListViewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/components/cells/PasswordCell.tsx
Original file line number Diff line number Diff line change
@@ -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 <span data-slot="cell-password">••••••••</span>
}
1 change: 1 addition & 0 deletions packages/ui/src/components/cells/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/cells/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +48,7 @@ const cellComponentRegistry: Record<string, CellComponent> = {
select: SelectCell,
timestamp: TimestampCell,
relationship: RelationshipCell,
password: PasswordCell,
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/src/components/standalone/ListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 2 additions & 7 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export {
SelectCell,
TimestampCell,
RelationshipCell,
PasswordCell,
AvatarLabelCell,
CellRenderer,
cellComponentRegistry,
Expand Down Expand Up @@ -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'
Expand Down
13 changes: 8 additions & 5 deletions packages/ui/src/lib/deriveItemView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
}

Expand Down
24 changes: 0 additions & 24 deletions packages/ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
7 changes: 7 additions & 0 deletions packages/ui/tests/components/CellRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<CellRenderer value="raw-hash-value" field={field} fieldName="password" />)
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(<CellRenderer value="raw value" field={field} fieldName="f" />)
Expand Down
14 changes: 14 additions & 0 deletions packages/ui/tests/components/Cells.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -154,3 +155,16 @@ describe('TextCell', () => {
expect(screen.getByText('-')).toBeInTheDocument()
})
})

describe('PasswordCell', () => {
it('renders a fixed mask regardless of the raw value', () => {
render(<PasswordCell value="hunter2" field={{ type: 'password' }} fieldName="password" />)
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(<PasswordCell value={null} field={{ type: 'password' }} fieldName="password" />)
expect(screen.getByText('••••••••')).toBeInTheDocument()
})
})
17 changes: 17 additions & 0 deletions packages/ui/tests/components/ListTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ListTable items={items} fieldTypes={{ username: 'text', secret: 'password' }} />)

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(<ListTable items={items} fieldTypes={{ password: 'text' }} />)

expect(screen.getByText('Password')).toBeInTheDocument()
})

it('should exclude createdAt and updatedAt by default', () => {
const items = [
{
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/tests/components/ListViewClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ListViewClient
{...defaultProps}
items={items}
fieldTypes={{ username: 'text', secret: 'password' }}
/>,
)

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(<ListViewClient {...defaultProps} items={items} fieldTypes={{ password: 'text' }} />)

expect(screen.getByText('Password')).toBeInTheDocument()
})
})

describe('edit links', () => {
it('should link to correct edit page', () => {
render(<ListViewClient {...defaultProps} />)
Expand Down
28 changes: 28 additions & 0 deletions packages/ui/tests/lib/deriveItemView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})
})
Loading