From cb280f0a761a6f13fa34565595fbc2a916d90fef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 20:10:41 +0000 Subject: [PATCH] fix(platform-objects): drop the legacy role scalar from the Setup Users list and the user record highlights The all_users list view and the sys_user_detail highlight chips showed better-auth's sys_user.role as the platform role. ObjectStack never writes that scalar; platform-admin standing comes from ADR-0068 D2. The field itself stays readonly text in its Admin group. The #27863 list pin is renamed to sys-user-list-columns.test.ts and pins the new columns, role's absence from every list view and the object highlight fields, and the field's shape. A new page pin checks no record:highlights node or details section draws role. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5msFrPy8g6WzAxVUjG --- .../28105-sys-user-role-off-setup-screens.md | 16 +++++ .../identity/sys-user-list-columns.test.ts | 56 +++++++++++++++++ .../sys-user-list-status-role-columns.test.ts | 34 ----------- .../src/identity/sys-user.object.ts | 10 +++- .../pages/sys-user-record-highlights.test.ts | 60 +++++++++++++++++++ .../src/pages/sys-user.page.ts | 19 +++--- 6 files changed, 151 insertions(+), 44 deletions(-) create mode 100644 .changeset/28105-sys-user-role-off-setup-screens.md create mode 100644 packages/platform-objects/src/identity/sys-user-list-columns.test.ts delete mode 100644 packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts create mode 100644 packages/platform-objects/src/pages/sys-user-record-highlights.test.ts diff --git a/.changeset/28105-sys-user-role-off-setup-screens.md b/.changeset/28105-sys-user-role-off-setup-screens.md new file mode 100644 index 00000000000..13f05ce6d91 --- /dev/null +++ b/.changeset/28105-sys-user-role-off-setup-screens.md @@ -0,0 +1,16 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): the Setup Users list and the user record's highlight chips stop showing better-auth's legacy `role` scalar as the platform role (#28105) + +Clause-②: no +Metadata-protocol: none + +`sys_user.role` is the column better-auth's admin plugin needs. ObjectStack never writes it: when the plugin is off it is empty, and when it is on the plugin stamps `user` on every new user, the seeded platform admin included. Platform-admin standing comes from ADR-0068 D2 (the `OS_PLATFORM_OWNER_EMAIL` list, or an unscoped `admin_full_access` grant under the `single` posture), not from this column. So a "Platform Role: user" cell on a platform admin was wrong, and it read as authoritative to people and to agents. + +- **Users list.** The `all_users` view drops the `role` column. Its columns are now name, email, phone, email verified, banned, identity source and created. `banned` stays (#27863). +- **User record page.** The `record:highlights` chip strip on `sys_user_detail` drops `role`. It shows email, phone and email verification. The details grid still hides `role`, so the record page does not draw it. +- **The field is unchanged.** `sys_user.role` stays a readonly text field in its Admin group, with the same label, storage and description, readable through the data API. Nothing synchronises platform-admin standing into it, and the better-auth `defaultRole` stamp and the import / create-user pass-through are untouched. + +A list column that shows real platform-admin standing is tracked separately (#28793). diff --git a/packages/platform-objects/src/identity/sys-user-list-columns.test.ts b/packages/platform-objects/src/identity/sys-user-list-columns.test.ts new file mode 100644 index 00000000000..5808dc46842 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-user-list-columns.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Setup → Users: the default list shows whether a user is banned (#27863), + * and does not show better-auth's legacy `role` scalar (#28105). + * + * `all_users` is the view Setup's Users entry opens (`viewName: 'all_users'`, + * pinned in `setup-users-nav-view.test.ts`). #27863 added `banned` and `role` + * beside the email. The `role` column is gone again (#28105, ruling D1): the + * scalar is not the platform role. ObjectStack never writes it, and when the + * better-auth admin plugin is on it stamps `user` on every user, the seeded + * platform admin included. Platform-admin standing comes from ADR-0068 D2 + * (owner e-mail or an unscoped `admin_full_access` grant), not from this + * column. The field itself stays: readonly text in its Admin group. + */ + +import { describe, expect, it } from 'vitest'; +import { SysUser } from './sys-user.object.js'; + +type ListView = { columns?: unknown[] }; + +const view = (name: string): ListView => + ((SysUser as { listViews?: Record }).listViews ?? {})[name] ?? {}; + +describe('sys_user all_users — list columns (#27863, #28105)', () => { + it('pins the default list columns: banned stays, role is not one of them', () => { + expect(view('all_users').columns).toEqual([ + 'name', 'email', 'phone_number', 'email_verified', 'banned', 'source', 'created_at', + ]); + }); + + it('no list view of sys_user shows the legacy role scalar', () => { + const views = (SysUser as { listViews?: Record }).listViews ?? {}; + for (const [name, v] of Object.entries(views)) expect(v.columns ?? [], name).not.toContain('role'); + }); + + it('the object highlight fields do not carry role', () => { + expect(SysUser.highlightFields ?? []).not.toContain('role'); + }); + + it('every column is a declared sys_user field', () => { + const fields = Object.keys(SysUser.fields ?? {}); + for (const c of view('all_users').columns ?? []) expect(fields, String(c)).toContain(c); + }); +}); + +describe('sys_user.role — the field itself stays (#28105, ruling D1)', () => { + it('is readonly text in its Admin group', () => { + const role = (SysUser.fields as Record>).role; + expect(role).toBeDefined(); + expect(role!.type).toBe('text'); + expect(role!.readonly).toBe(true); + expect(role!.group).toBe('Admin'); + expect(role).not.toHaveProperty('options'); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts b/packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts deleted file mode 100644 index 6de520e8a52..00000000000 --- a/packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Setup → Users: the default list shows whether a user is banned and what role - * they hold, so a banned user or an admin is spotted without opening each - * record (#27863). - * - * `all_users` is the view Setup's Users entry opens (`viewName: 'all_users'`, - * pinned in `setup-users-nav-view.test.ts`). Its columns used to be name, - * email, phone, email verified, identity source and created: nothing on the - * list said a user was banned. The `banned` view keeps its own filter; this - * pin is about the default list. - */ - -import { describe, expect, it } from 'vitest'; -import { SysUser } from './sys-user.object.js'; - -type ListView = { columns?: unknown[] }; - -const view = (name: string): ListView => - ((SysUser as { listViews?: Record }).listViews ?? {})[name] ?? {}; - -describe('sys_user all_users — Status and Role columns (#27863)', () => { - it('pins the default list columns', () => { - expect(view('all_users').columns).toEqual([ - 'name', 'email', 'phone_number', 'email_verified', 'banned', 'role', 'source', 'created_at', - ]); - }); - - it('every column is a declared sys_user field', () => { - const fields = Object.keys(SysUser.fields ?? {}); - for (const c of view('all_users').columns ?? []) expect(fields, String(c)).toContain(c); - }); -}); diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index cb763e5de39..10c4492a1d9 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -816,9 +816,13 @@ export const SysUser = ObjectSchema.create({ // has no feature gate, and on a deployment with 2FA off (the default) // it read a dead "No" on every row. The state is shown on the user's // own record page, in the Security tab, behind the `twoFactor` gate. - // `banned` and `role` sit beside the email so a banned user or an - // admin is spotted from the list, without opening each record. - columns: ['name', 'email', 'phone_number', 'email_verified', 'banned', 'role', 'source', 'created_at'], + // `banned` sits beside the email so a banned user is spotted from the + // list, without opening each record (#27863). No `role` column + // (#28105): better-auth's legacy scalar is not the platform role. + // ObjectStack never writes it, and with the admin plugin on it reads + // `user` for every user, a platform admin included. Platform-admin + // standing is ADR-0068 D2's (owner e-mail or `admin_full_access`). + columns: ['name', 'email', 'phone_number', 'email_verified', 'banned', 'source', 'created_at'], sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 50 }, }, diff --git a/packages/platform-objects/src/pages/sys-user-record-highlights.test.ts b/packages/platform-objects/src/pages/sys-user-record-highlights.test.ts new file mode 100644 index 00000000000..8905c0ca292 --- /dev/null +++ b/packages/platform-objects/src/pages/sys-user-record-highlights.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The user record page's highlight chips do not show better-auth's legacy + * `role` scalar (#28105, ruling D1). + * + * The chip strip promoted `role` as "Platform Role", but the scalar is not the + * platform role: ObjectStack never writes it, and when the better-auth admin + * plugin is on it reads `user` for every user, the seeded platform admin + * included. The details grid keeps hiding it, so the record page does not + * present it anywhere; the field stays declared (see + * `identity/sys-user-list-columns.test.ts`). + */ + +import { describe, expect, it } from 'vitest'; +import { SysUserDetailPage } from './sys-user.page.js'; + +type Rec = Record; + +/** Every component node of the page, nested tab children included. */ +function nodes(): Rec[] { + const out: Rec[] = []; + const walk = (list: unknown): void => { + if (!Array.isArray(list)) return; + for (const n of list as Rec[]) { + out.push(n); + walk(n.children); + const items = (n.properties as Rec | undefined)?.items; + if (Array.isArray(items)) for (const it of items as Rec[]) walk(it.children); + } + }; + for (const r of SysUserDetailPage.regions ?? []) walk((r as Rec).components); + return out; +} + +const fieldsOf = (n: Rec): unknown[] => ((n.properties as Rec | undefined)?.fields as unknown[]) ?? []; + +describe('sys_user_detail — highlight chips (#28105)', () => { + it('pins the header chip strip: email, phone, verification', () => { + const header = nodes().filter((n) => n.type === 'record:highlights')[0]!; + expect(fieldsOf(header)).toEqual(['email', 'phone_number', 'email_verified']); + }); + + it('no record:highlights node on the page carries role', () => { + const strips = nodes().filter((n) => n.type === 'record:highlights'); + expect(strips.length).toBeGreaterThan(0); + for (const s of strips) expect(fieldsOf(s)).not.toContain('role'); + }); + + it('no record:details section draws role either', () => { + const details = nodes().filter((n) => n.type === 'record:details'); + expect(details.length).toBeGreaterThan(0); + for (const d of details) { + const props = d.properties as Rec; + const shown = ((props.sections as { fields: string[] }[]) ?? []).flatMap((s) => s.fields); + expect(shown).not.toContain('role'); + expect(props.hideFields).toContain('role'); + } + }); +}); diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 592626ada00..17101336c81 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -30,7 +30,7 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr * The Account App's "Profile" entry no longer routes here — it points at * the `account:profile_card` Console component for a settings-form-style * personal profile. This page therefore optimizes for the admin - * use case: scanning a user's signals (email/verification/role), + * use case: scanning a user's signals (email/verification), * reviewing related sessions/orgs/oauth/api-keys, and triggering * admin actions (ban / impersonate). * @@ -46,10 +46,12 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr * change_my_password / resend_verification_email / …`), so they still * appear in the header and its overflow menu. * - `record:highlights` promotes the signals worth scanning at the top: - * email, phone, verification state, platform role. Highlight fields - * are auto-dropped from the details grid below. 2FA state is not one - * of them: it is drawn only in the Security tab, behind the - * deployment's `twoFactor` gate (see {@link TWO_FACTOR_OFFERED}). + * email, phone, verification state. Highlight fields are auto-dropped + * from the details grid below. 2FA state is not one of them: it is + * drawn only in the Security tab, behind the deployment's `twoFactor` + * gate (see {@link TWO_FACTOR_OFFERED}). Nor is better-auth's legacy + * `role` scalar (#28105): it is not the platform role, and the page + * does not draw it anywhere. * - The FIRST tab is the details grid: a `record:details` that * re-groups remaining fields into sections (locale on Identity, the ban * state and reason in Account Status) and hides admin-internal columns. @@ -175,7 +177,10 @@ export const SysUserDetailPage: Page = { // No `two_factor_enabled` (#25287): a highlight field has no // per-field gate, and on a deployment with 2FA off the chip read a // dead "No". The state is drawn in the Security tab instead. - fields: ['email', 'phone_number', 'email_verified', 'role'], + // No `role` (#28105): better-auth's legacy scalar read as the + // "Platform Role" chip, but ObjectStack never writes it and with + // the admin plugin on it reads `user` for a platform admin too. + fields: ['email', 'phone_number', 'email_verified'], }, }, @@ -215,7 +220,7 @@ export const SysUserDetailPage: Page = { 'phone_number', 'email_verified', 'two_factor_enabled', // not a highlight: drawn in the Security tab, behind the 2FA gate - 'role', + 'role', // not a highlight either: the legacy better-auth scalar, not the platform role (#28105) ], sections: [ {