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/curly-badgers-column.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@opensaas/stack-ui': minor
---

Replace the admin UI's hardcoded `password`/`createdAt`/`updatedAt` default-column exclusion with curation driven by each field's declared `ui.listView.defaultColumn` (issue #1018). The list view, related-list tables, and the `ListTable` standalone component now share one implementation (`computeDefaultColumns`) instead of three independent name/type-matching copies, and a list's structural `createdAt`/`updatedAt` timestamp columns are identified from its own timestamp configuration rather than by name.

**Behavior change:** an application field literally named (or typed) `password`, `createdAt`, or `updatedAt` that does NOT declare `ui.listView.defaultColumn: false` — and isn't your list's actual auto-timestamp column — is no longer hidden from default admin columns purely by name/type match. Real password fields (built with `password()`) and real system timestamps are unaffected; they're excluded via the declared flag instead.

`ListTable` gains an optional `fields?: Record<string, SerializableFieldConfig>` prop to supply this curation metadata; without it (as before), every `fieldTypes` column shows absent an explicit `columns` list.
5 changes: 5 additions & 0 deletions .changeset/curly-badgers-credential.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@opensaas/stack-auth': patch
---

Read-denied credential fields (ADR-0036) now also declare `ui.listView.defaultColumn: false`, so they're curated out of the admin's default table columns instead of rendering as permanently empty columns.
13 changes: 13 additions & 0 deletions .changeset/curly-badgers-curate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@opensaas/stack-core': minor
---

Add `ui.listView.defaultColumn` to field config — a declared, presentation-only flag (default `true`) controlling whether a field belongs in a list/related-list table's default column set. Naming a field explicitly in `ui.listView.initialColumns` or a relationship's `ui.itemView.columns` always shows it regardless of this flag.

```typescript
fields: {
internalScore: integer({ ui: { listView: { defaultColumn: false } } }),
}
```

`password()` now sets this flag to `false` by default instead of the admin UI matching on field type — a password field can opt back into default columns with `ui: { listView: { defaultColumn: true } } }`.
1 change: 1 addition & 0 deletions docs/content/reference/config-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,7 @@ UI-specific configuration passed to field components.
- `component?: React.Component` - Custom field component
- `fieldType?: string` - Reference to globally registered field type
- `valueForClientSerialization?: (args) => unknown` - Transform value before sending to browser
- `listView?: { defaultColumn?: boolean }` - Whether this field belongs in a list/related-list table's default column set (default `true`). Naming the field explicitly in `ui.listView.initialColumns` or a relationship's `ui.itemView.columns` always shows it regardless of this flag. **Presentation only** — it does not affect who can read the field; use `access.read` for that.

---

Expand Down
1 change: 1 addition & 0 deletions docs/content/reference/fields-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,7 @@ password(options?: {
2. **Idempotent**: Already-hashed passwords are not re-hashed
3. **Secure Output**: Query results return `HashedPassword` instances with a `compare()` method
4. **No Exposure**: Only sends `{ isSet: boolean }` to client (not the hash)
5. **Excluded from default admin columns**: sets `ui.listView.defaultColumn: false` by default (a presentation default, not an access control — see [`ui.listView`](/docs/reference/config-api#ui)); override with `ui: { listView: { defaultColumn: true } }` to show it anyway

#### Options

Expand Down
12 changes: 11 additions & 1 deletion packages/auth/src/config/derive-auth-lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,17 @@ function withCredentialAccess(
field: FieldConfig,
): FieldConfig {
if (!registry[modelKey]?.has(fieldKey)) return field
return { ...field, access: DENY_READ }
return {
...field,
access: DENY_READ,
// Curated out of the admin's default table columns too (issue #1018),
// via the same declared flag as everything else — a read-denied column
// would otherwise render permanently empty rather than simply absent.
ui: {
...field.ui,
listView: { ...field.ui?.listView, defaultColumn: false },
},
}
}

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/auth/tests/derive-auth-lists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,27 @@ describe('deriveAuthLists - credential fields ship read-denied (ADR-0036, issue
}
})

it('also curates read-denied credential fields out of the admin default columns (issue #1018)', async () => {
const { lists } = deriveAuthLists(defaultModels)

const denied: Array<[string, string]> = [
['Session', 'token'],
['Verification', 'value'],
['Account', 'password'],
['Account', 'accessToken'],
['Account', 'refreshToken'],
['Account', 'idToken'],
]

for (const [listKey, fieldKey] of denied) {
const field = lists[listKey].fields[fieldKey]
expect(field.ui?.listView?.defaultColumn).toBe(false)
}

// A non-credential field is left with no ui.listView declaration at all.
expect(lists.Session.fields.ipAddress.ui?.listView).toBeUndefined()
})

it('leaves identifying fields open — session/account metadata and every User field', () => {
const { lists } = deriveAuthLists(defaultModels)

Expand Down Expand Up @@ -557,6 +578,7 @@ describe('deriveAuthLists - credential fields on plugin tables (issue #1014)', (
expect(field.access?.read).toBeTypeOf('function')
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal read-access call fixture
expect(await field.access!.read!({} as any)).toBe(false)
expect(field.ui?.listView?.defaultColumn).toBe(false)
}
})

Expand All @@ -578,6 +600,7 @@ describe('deriveAuthLists - credential fields on plugin tables (issue #1014)', (
expect(field.access?.read).toBeTypeOf('function')
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal read-access call fixture
expect(await field.access!.read!({} as any)).toBe(false)
expect(field.ui?.listView?.defaultColumn).toBe(false)
}
})

Expand Down
38 changes: 34 additions & 4 deletions packages/core/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,32 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
* ```
*/
description?: string
/**
* Whether this field belongs in a list/related-list table's DEFAULT
* column set (issue #1018) — the columns shown when nothing explicitly
* names them (`ui.listView.initialColumns` on the list, or a
* relationship's own `ui.itemView.columns`). Naming the field explicitly
* in either of those always shows it regardless of this flag; it governs
* only what appears absent an explicit column list.
*
* This is a PRESENTATION default, not an access control — a field can be
* read-denied and still default to `true` here (it simply renders empty
* for a viewer who can't read it), and setting this to `false` hides a
* column without restricting who can read the underlying value. The
* field-level `access.read` deny remains the only real boundary.
*
* @default true
*
* @example Hide an internal field from default table views without denying read access
* ```typescript
* fields: {
* internalScore: integer({ ui: { listView: { defaultColumn: false } } }),
* }
* ```
*/
listView?: {
defaultColumn?: boolean
}
/**
* Transform field value before sending to client (browser)
* Useful for sensitive fields (e.g., passwords) or complex data structures
Expand Down Expand Up @@ -1018,8 +1044,10 @@ export type RelationshipItemViewConfig = {
* The related list's fields to show as Relationship-table columns, in order.
*
* When omitted, the columns default to the related list's own column
* curation (`ui.listView.initialColumns`, else all non-system fields) minus
* the back-reference field that points at the parent record.
* curation (`ui.listView.initialColumns`, else every field whose own
* `ui.listView.defaultColumn` declaration holds — see {@link
* BaseFieldConfig.ui}) minus the back-reference field that points at the
* parent record.
*
* @example
* ```typescript
Expand Down Expand Up @@ -2277,14 +2305,16 @@ export interface BulkAction {
* `ui.listView`.
*
* When omitted, the admin UI falls back to its existing defaults: every
* non-system field is shown as a column and no default sort is applied.
* field whose own `ui.listView.defaultColumn` declaration holds is shown as
* a column (see {@link BaseFieldConfig.ui}) and no default sort is applied.
*/
export type ListViewUIConfig = {
/**
* The fields to show as columns in the list table, in order.
*
* Drives both the column **selection** and their **order**. When omitted,
* all non-system fields are shown (current default behaviour).
* every field whose own `ui.listView.defaultColumn` declaration holds is
* shown (current default behaviour).
*
* @example
* ```typescript
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,13 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
},
ui: {
...options?.ui,
// Excluded from default admin table columns (issue #1018) — declared
// via the flag rather than matched by field type/name, so an app can
// still opt a real password field back in with `ui.listView.defaultColumn: true`.
listView: {
defaultColumn: false,
...options?.ui?.listView,
},
valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
},
hooks: {
Expand Down
21 changes: 21 additions & 0 deletions packages/core/tests/field-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,27 @@ describe('Field Types', () => {
)
})
})

describe('ui.listView.defaultColumn (issue #1018)', () => {
test('is excluded from default admin table columns by default', () => {
const field = password()

expect(field.ui?.listView?.defaultColumn).toBe(false)
})

test('can be opted back into default columns explicitly', () => {
const field = password({ ui: { listView: { defaultColumn: true } } })

expect(field.ui?.listView?.defaultColumn).toBe(true)
})

test('preserves other caller-supplied ui options', () => {
const field = password({ ui: { description: 'Account password' } })

expect(field.ui?.description).toBe('Account password')
expect(field.ui?.listView?.defaultColumn).toBe(false)
})
})
})

describe('select field', () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/ui/src/components/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ListViewClient } from './ListViewClient.js'
import type { SerializedBulkAction } from './BulkActions.js'
import { formatListName } from '../lib/utils.js'
import { serializeFieldConfigs } from '../lib/serializeFieldConfig.js'
import { withStructuralTimestampDefaults } from '../lib/defaultColumns.js'
import { jsonSafeClone } from '../lib/jsonSafeClone.js'
import { PageHeader } from './PageHeader.js'
import { Button } from '../primitives/button.js'
Expand Down Expand Up @@ -339,6 +340,13 @@ export async function ListView({
listKey,
)

// Fold in the structural createdAt/updatedAt exclusion (issue #1018) before
// crossing the server/client boundary, so `ListViewClient`'s fallback (used
// when no explicit `columns` is configured) curates off the same declared
// `ui.listView.defaultColumn` flag as everything else — no timestamp-aware
// logic needed on the client.
const displayFields = withStructuralTimestampDefaults(listConfig.fields, listConfig, config.db)

return (
<div className="p-8">
<PageHeader
Expand All @@ -357,12 +365,12 @@ export async function ListView({
<ListViewClient
items={serializedItems || []}
fieldTypes={Object.fromEntries(
Object.entries(listConfig.fields).map(([key, field]) => [
Object.entries(displayFields).map(([key, field]) => [
key,
(field as { type: string }).type,
]),
)}
fields={serializeFieldConfigs(listConfig.fields)}
fields={serializeFieldConfigs(displayFields)}
relationshipRefs={relationshipRefs}
columns={columns}
initialSort={activeSort}
Expand Down
25 changes: 18 additions & 7 deletions packages/ui/src/components/ListViewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { BulkActions, type SerializedBulkAction } from './BulkActions.js'
import { useRowSelection, getPageCheckboxState } from '../lib/useRowSelection.js'
import { useBulkStatus } from '../lib/useBulkStatus.js'
import type { SerializableFieldConfig } from '../lib/serializeFieldConfig.js'
import { isDefaultColumnField } from '../lib/defaultColumns.js'
import type { ServerActionInput } from '../server/types.js'
import type { FilterFieldSuggestion } from '@opensaas/stack-core'

Expand Down Expand Up @@ -69,9 +70,13 @@ export interface ListViewClientProps {
fieldTypes: Record<string, string>
/**
* Serialised per-field config, keyed by field name. Drives Cell resolution
* (per-field override, select option variants, relationship ref). Optional so
* callers that only have `fieldTypes` still render via the field-type
* registry; a minimal config is synthesised from `fieldTypes` when absent.
* (per-field override, select option variants, relationship ref) and, absent
* an explicit `columns` list, default-column curation (issue #1018, see
* `isDefaultColumnField`). Optional, and may cover only a subset of
* `fieldTypes`' keys — `columnField` below synthesises a fallback per-column
* for cell rendering, and a column missing an entry here has no
* `ui.listView.defaultColumn` declaration to curate by, so it defaults to
* shown, the same as when `fields` is omitted entirely.
*/
fields?: Record<string, SerializableFieldConfig>
relationshipRefs: Record<string, string>
Expand Down Expand Up @@ -183,11 +188,17 @@ export function ListViewClient({
selection.togglePage(pageIds)
}

// Absent an explicit `columns` list, curate off each field's own declared
// `ui.listView.defaultColumn` (issue #1018) — the same declaration
// `deriveItemView.ts`'s server-side layout helper reads, no field
// name/type matching here. The column set itself still comes from
// `fieldTypes` (not `Object.keys(fields)`): `fields` may cover only a
// subset of columns (`columnField` above synthesises a fallback for any
// column missing from it), and a column absent from `fields` has no
// declaration to curate by, so it defaults to shown — same as when
// `fields` is omitted entirely.
const displayColumns =
columns ||
Object.keys(fieldTypes).filter(
(key) => fieldTypes[key] !== 'password' && !['createdAt', 'updatedAt'].includes(key),
)
columns || Object.keys(fieldTypes).filter((key) => isDefaultColumnField(fields?.[key]))

// Items are already sorted by the server via orderBy; no in-memory sort needed.

Expand Down
23 changes: 19 additions & 4 deletions packages/ui/src/components/standalone/ListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { EmptyState } from '../EmptyState.js'
import { CellRenderer } from '../cells/CellRenderer.js'
import type { SerializableFieldConfig } from '../../lib/serializeFieldConfig.js'
import { isDefaultColumnField } from '../../lib/defaultColumns.js'

/**
* Per-part `classNames` slots for `ListTable` (issue #709). Each slot is merged
Expand Down Expand Up @@ -59,6 +60,17 @@ export interface ListTableProps {
* neutral badge, same as before this option existed.
*/
fieldOptions?: Record<string, Array<SelectOption>>
/**
* Serialised per-field config, keyed by field name (issue #1018), used only
* for default-column curation — not for cell rendering, which always
* synthesises from `fieldTypes`/`relationshipRefs`/`fieldOptions` (see
* `getFieldConfig`). When supplied, an explicit `columns` list absent, a
* column is excluded only if its entry here declares
* `ui.listView.defaultColumn: false` (see `isDefaultColumnField`); a column
* missing an entry — including every column when `fields` is omitted
* entirely — has nothing to curate by, so it defaults to shown.
*/
fields?: Record<string, SerializableFieldConfig>
basePath?: string
columns?: string[]
onRowClick?: (item: Record<string, unknown>) => void
Expand Down Expand Up @@ -90,6 +102,7 @@ export function ListTable({
fieldTypes,
relationshipRefs,
fieldOptions,
fields,
basePath = '/admin',
columns,
onRowClick,
Expand Down Expand Up @@ -117,11 +130,13 @@ export function ListTable({
options: fieldOptions?.[fieldName],
})

// Absent an explicit `columns` list, curate off each field's own declared
// `ui.listView.defaultColumn` (issue #1018). The column set comes from
// `fieldTypes` (not `Object.keys(fields)`), since `fields` may cover only
// a subset of columns — one missing from it has no declaration to curate
// by, so it defaults to shown, same as when `fields` is omitted entirely.
const displayColumns =
columns ||
Object.keys(fieldTypes).filter(
(key) => fieldTypes[key] !== 'password' && !['createdAt', 'updatedAt'].includes(key),
)
columns || Object.keys(fieldTypes).filter((key) => isDefaultColumnField(fields?.[key]))

const sortedItems = [...items]
if (sortBy && sortable) {
Expand Down
Loading
Loading