Skip to content

Commit 1e01924

Browse files
fix(platform-objects): the Setup Users list and the user record highlights stop showing the legacy role scalar (#28810)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XD5Csq4vbvdCSTJt7KnCqYG)_ Fixes #28105 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Setup → 用户列表(`all_users` 视图)去掉「Platform Role」一列;用户记录页顶部的高亮条去掉「Platform Role」一格。字段 `sys_user.role` 本身不动:仍是只读文本、仍在 Admin 分组、数据 API 照旧可读。 - **为什么改**:这一列是 better-auth 的遗留字段,平台从不写它;admin 插件开着时它给每个人都盖 `user`,种子平台管理员也是 `user`(本地实测见下)。平台管理员身份来自 ADR-0068 D2(所有者邮箱或无组织的 `admin_full_access`),不来自这一列。按裁决 D1(Ruling-ref: 6557746678)撤掉这个误导的展示面;真实身份推导的一列另立 #28793。 - **风险与回滚**:低。纯展示面改动,无类型、存储、选项或 spec 变化;已存的用户列表个性化视图不受影响(它们存自己的列)。回滚即撤销本 PR。 - **先看**:`packages/platform-objects/src/pages/sys-user.page.ts`(`record:highlights` 的 `fields`,高亮其实住在记录页,不在对象的 `highlightFields`);`packages/platform-objects/src/identity/sys-user.object.ts`(`all_users.columns`);`packages/platform-objects/src/identity/sys-user-list-columns.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2 src/identity/sys-user-list-columns.test.ts src/pages/sys-user-record-highlights.test.ts`,预期 8/8 通过(main 上 4 红)。 - **需要你做的**:无,席位落地。 ## What changes - **`all_users` list view** (`sys-user.object.ts`): columns are now `name, email, phone_number, email_verified, banned, source, created_at`. `role` is gone; `banned` stays (#27863). The column comment says why. - **User record page highlights** (`pages/sys-user.page.ts`): the `record:highlights` chip strip of `sys_user_detail` is now `email, phone_number, email_verified`. The details grid's `hideFields` keeps `role` (its comment now says why: not the platform role, rather than "already promoted to highlights"), and no details section names it, so the record page draws `role` nowhere. The page docblock no longer lists "platform role" among the signals. - **Unchanged, per ruling D1:** the `sys_user.role` field (`Field.text`, readonly, `maxLength: 64`, group `Admin`, label and description), its storage, the better-auth `defaultRole` stamp, and the ADR-0092 import / create-user pass-through. No synchronisation of platform-admin standing into the scalar. No `packages/spec/**` or `ui/**` edit. - **Changeset** `.changeset/28105-sys-user-role-off-setup-screens.md`: `patch` on `@objectstack/platform-objects` (served view and page metadata change; no authorable value is refused, no key moves). ## Tests - **Pin renamed and moved with the ruling.** `identity/sys-user-list-status-role-columns.test.ts` (the #27863 pin, PR #28065) is now `identity/sys-user-list-columns.test.ts`, since its name named the Role column. It pins the new `all_users` columns, that no `sys_user` list view lists `role`, that the object `highlightFields` do not carry it, that every column is a declared field, and that `role` is still readonly `text` in group `Admin` with no `options` (ruling 2 guard). - **New page pin** `pages/sys-user-record-highlights.test.ts`: the header chip strip is exactly `email, phone_number, email_verified`; no `record:highlights` node anywhere on the page (tab children walked) carries `role`; no `record:details` section draws it and `hideFields` keeps it. - **Red first, on base `d9c4cdc4e`** (both files against base sources): 4 failed / 4 passed. Red: the `all_users` columns pin, "no list view shows role", the chip-strip pin, "no highlights node carries role". Green on base as well as head: object `highlightFields` (already `name, email, email_verified` on base), every column declared, the role-field guard, the details-section check. - **Green on head:** the two files 8/8; `@objectstack/platform-objects` full suite and typecheck green (numbers in Verification). The existing `pages/sys-user-details-tab.test.ts` pin of `hideFields` is unchanged and green. - No ablation: the change is declarative metadata, and the red-on-base run above is the pins' negative leg. ## Acceptance notes - **Mechanism (order assumption 1, confirmed).** The record highlight showing `role` was not the object-level `highlightFields` (`['name', 'email', 'email_verified']` on base) but the `record:highlights` node of the record page `sys_user_detail`. No compact layout exists (`compactLayout` is a retired key). The claim's file surface was amended to add the page file and its folder's tests. - **Other readers of `sys_user.role` (order assumption 2).** No other platform view, dashboard, report or example reads it as a column. The `role` columns in `sys_member`, `sys_invitation`, `sys_scim_projection_grant` and the related lists on the organization and user pages are membership / grant roles of other objects, not this scalar. Non-Setup readers left alone: the Console import panel's `role` column (`ui/packages/app-shell/src/views/IdentityImportPanels.tsx`, the ADR-0092 import pass-through), the expression-context `user.role` and its `${user.role === 'admin'}` example in `ui/packages/app-shell/src/providers/ExpressionProvider.tsx` (the ui finding the seat carries), and `packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts`, which reads the field label, not a list column. - **Still reads "Platform Role".** The field's label stays `Platform Role` (ruling 2: no field change), so the standard edit form and the data API keep that label. Noted, not changed. - **Saved personal list views** that copied `role` into their own column list keep it; this PR changes only the shipped `all_users` default. - No `content/docs/**` page pins the Users list columns (grep for the column list, `all_users` and `Platform Role` under `content/docs`: zero hits). ## Verification <details><summary>Live probe, stock showcase boot, base vs head</summary> Commands (each leg on a fresh boot, platform-objects `dist/` rebuilt for that leg; the base leg wrote the base blobs of the two source files over the head copies, then restored them with `git checkout HEAD --`, `git diff HEAD` empty and both blob hashes equal to HEAD before the head `dist/` was rebuilt): ``` pnpm dev -- --fresh -p PORT curl -c jar -H 'Origin: http://localhost:PORT' -H 'Content-Type: application/json' -X POST http://localhost:PORT/api/v1/auth/sign-in/email -d '{"email":"admin@objectos.ai","password":"admin123"}' curl -b jar -H 'Origin: http://localhost:PORT' http://localhost:PORT/api/v1/meta/object/sys_user # item.listViews.all_users.columns, item.highlightFields, item.fields.role curl -b jar -H 'Origin: http://localhost:PORT' http://localhost:PORT/api/v1/meta/page/sys_user_detail # every record:highlights node's fields curl -b jar -H 'Origin: http://localhost:PORT' 'http://localhost:PORT/api/v1/data/sys_user?fields=id,email,role&limit=2' ``` | read | base `d9c4cdc4e` (port 4625) | head (port 4615) | |---|---|---| | sign-in | 200 | 200 | | `all_users.columns` | `name, email, phone_number, email_verified, banned, role, source, created_at` | `name, email, phone_number, email_verified, banned, source, created_at` | | object `highlightFields` | `name, email, email_verified` | `name, email, email_verified` | | page `record:highlights` (header) | `email, phone_number, email_verified, role` | `email, phone_number, email_verified` | | page `record:highlights` (Security tab) | `two_factor_enabled` | `two_factor_enabled` | | `fields.role` | text, readonly, group Admin | text, readonly, group Admin, label Platform Role | Head data read: `admin@objectos.ai` (the showcase's seeded dev admin) answers `"role": "user"`; `auditor.demo@example.com` answers `"role": null`. That is the wrong value the ruling describes, and the field stays readable through the data API. </details> <details><summary>Gates, local subset, at head 280a84d (59 derived families, all exit 0)</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the real diff (merge base `c064d3a2`), each run with its exit code captured before any pipe, then reconciled: `dispatch-gates --ran: 59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED`. `check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone; after `git fetch --depth=1 origin` of its positive-control commit it passes 48 cases. Also at this head: `pnpm build` (117/117 tasks), `pnpm --filter @objectstack/platform-objects typecheck` (exit 0), `pnpm --filter @objectstack/platform-objects test` (108 files, 1503 tests passed), `pnpm exec eslint --no-inline-config packages/platform-objects` (exit 0). `test:repo` and the full farm are CI's. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5msFrPy8g6WzAxVUjG)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5ac1b6 commit 1e01924

6 files changed

Lines changed: 151 additions & 44 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`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.
11+
12+
- **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).
13+
- **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.
14+
- **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.
15+
16+
A list column that shows real platform-admin standing is tracked separately (#28793).
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Setup → Users: the default list shows whether a user is banned (#27863),
5+
* and does not show better-auth's legacy `role` scalar (#28105).
6+
*
7+
* `all_users` is the view Setup's Users entry opens (`viewName: 'all_users'`,
8+
* pinned in `setup-users-nav-view.test.ts`). #27863 added `banned` and `role`
9+
* beside the email. The `role` column is gone again (#28105, ruling D1): the
10+
* scalar is not the platform role. ObjectStack never writes it, and when the
11+
* better-auth admin plugin is on it stamps `user` on every user, the seeded
12+
* platform admin included. Platform-admin standing comes from ADR-0068 D2
13+
* (owner e-mail or an unscoped `admin_full_access` grant), not from this
14+
* column. The field itself stays: readonly text in its Admin group.
15+
*/
16+
17+
import { describe, expect, it } from 'vitest';
18+
import { SysUser } from './sys-user.object.js';
19+
20+
type ListView = { columns?: unknown[] };
21+
22+
const view = (name: string): ListView =>
23+
((SysUser as { listViews?: Record<string, ListView> }).listViews ?? {})[name] ?? {};
24+
25+
describe('sys_user all_users — list columns (#27863, #28105)', () => {
26+
it('pins the default list columns: banned stays, role is not one of them', () => {
27+
expect(view('all_users').columns).toEqual([
28+
'name', 'email', 'phone_number', 'email_verified', 'banned', 'source', 'created_at',
29+
]);
30+
});
31+
32+
it('no list view of sys_user shows the legacy role scalar', () => {
33+
const views = (SysUser as { listViews?: Record<string, ListView> }).listViews ?? {};
34+
for (const [name, v] of Object.entries(views)) expect(v.columns ?? [], name).not.toContain('role');
35+
});
36+
37+
it('the object highlight fields do not carry role', () => {
38+
expect(SysUser.highlightFields ?? []).not.toContain('role');
39+
});
40+
41+
it('every column is a declared sys_user field', () => {
42+
const fields = Object.keys(SysUser.fields ?? {});
43+
for (const c of view('all_users').columns ?? []) expect(fields, String(c)).toContain(c);
44+
});
45+
});
46+
47+
describe('sys_user.role — the field itself stays (#28105, ruling D1)', () => {
48+
it('is readonly text in its Admin group', () => {
49+
const role = (SysUser.fields as Record<string, Record<string, unknown>>).role;
50+
expect(role).toBeDefined();
51+
expect(role!.type).toBe('text');
52+
expect(role!.readonly).toBe(true);
53+
expect(role!.group).toBe('Admin');
54+
expect(role).not.toHaveProperty('options');
55+
});
56+
});

‎packages/platform-objects/src/identity/sys-user-list-status-role-columns.test.ts‎

Lines changed: 0 additions & 34 deletions
This file was deleted.

‎packages/platform-objects/src/identity/sys-user.object.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -816,9 +816,13 @@ export const SysUser = ObjectSchema.create({
816816
// has no feature gate, and on a deployment with 2FA off (the default)
817817
// it read a dead "No" on every row. The state is shown on the user's
818818
// own record page, in the Security tab, behind the `twoFactor` gate.
819-
// `banned` and `role` sit beside the email so a banned user or an
820-
// admin is spotted from the list, without opening each record.
821-
columns: ['name', 'email', 'phone_number', 'email_verified', 'banned', 'role', 'source', 'created_at'],
819+
// `banned` sits beside the email so a banned user is spotted from the
820+
// list, without opening each record (#27863). No `role` column
821+
// (#28105): better-auth's legacy scalar is not the platform role.
822+
// ObjectStack never writes it, and with the admin plugin on it reads
823+
// `user` for every user, a platform admin included. Platform-admin
824+
// standing is ADR-0068 D2's (owner e-mail or `admin_full_access`).
825+
columns: ['name', 'email', 'phone_number', 'email_verified', 'banned', 'source', 'created_at'],
822826
sort: [{ field: 'name', order: 'asc' }],
823827
pagination: { pageSize: 50 },
824828
},
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The user record page's highlight chips do not show better-auth's legacy
5+
* `role` scalar (#28105, ruling D1).
6+
*
7+
* The chip strip promoted `role` as "Platform Role", but the scalar is not the
8+
* platform role: ObjectStack never writes it, and when the better-auth admin
9+
* plugin is on it reads `user` for every user, the seeded platform admin
10+
* included. The details grid keeps hiding it, so the record page does not
11+
* present it anywhere; the field stays declared (see
12+
* `identity/sys-user-list-columns.test.ts`).
13+
*/
14+
15+
import { describe, expect, it } from 'vitest';
16+
import { SysUserDetailPage } from './sys-user.page.js';
17+
18+
type Rec = Record<string, unknown>;
19+
20+
/** Every component node of the page, nested tab children included. */
21+
function nodes(): Rec[] {
22+
const out: Rec[] = [];
23+
const walk = (list: unknown): void => {
24+
if (!Array.isArray(list)) return;
25+
for (const n of list as Rec[]) {
26+
out.push(n);
27+
walk(n.children);
28+
const items = (n.properties as Rec | undefined)?.items;
29+
if (Array.isArray(items)) for (const it of items as Rec[]) walk(it.children);
30+
}
31+
};
32+
for (const r of SysUserDetailPage.regions ?? []) walk((r as Rec).components);
33+
return out;
34+
}
35+
36+
const fieldsOf = (n: Rec): unknown[] => ((n.properties as Rec | undefined)?.fields as unknown[]) ?? [];
37+
38+
describe('sys_user_detail — highlight chips (#28105)', () => {
39+
it('pins the header chip strip: email, phone, verification', () => {
40+
const header = nodes().filter((n) => n.type === 'record:highlights')[0]!;
41+
expect(fieldsOf(header)).toEqual(['email', 'phone_number', 'email_verified']);
42+
});
43+
44+
it('no record:highlights node on the page carries role', () => {
45+
const strips = nodes().filter((n) => n.type === 'record:highlights');
46+
expect(strips.length).toBeGreaterThan(0);
47+
for (const s of strips) expect(fieldsOf(s)).not.toContain('role');
48+
});
49+
50+
it('no record:details section draws role either', () => {
51+
const details = nodes().filter((n) => n.type === 'record:details');
52+
expect(details.length).toBeGreaterThan(0);
53+
for (const d of details) {
54+
const props = d.properties as Rec;
55+
const shown = ((props.sections as { fields: string[] }[]) ?? []).flatMap((s) => s.fields);
56+
expect(shown).not.toContain('role');
57+
expect(props.hideFields).toContain('role');
58+
}
59+
});
60+
});

‎packages/platform-objects/src/pages/sys-user.page.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr
3030
* The Account App's "Profile" entry no longer routes here — it points at
3131
* the `account:profile_card` Console component for a settings-form-style
3232
* personal profile. This page therefore optimizes for the admin
33-
* use case: scanning a user's signals (email/verification/role),
33+
* use case: scanning a user's signals (email/verification),
3434
* reviewing related sessions/orgs/oauth/api-keys, and triggering
3535
* admin actions (ban / impersonate).
3636
*
@@ -46,10 +46,12 @@ const TWO_FACTOR_OFFERED = P`has(features.twoFactor) && features.twoFactor == tr
4646
* change_my_password / resend_verification_email / …`), so they still
4747
* appear in the header and its overflow menu.
4848
* - `record:highlights` promotes the signals worth scanning at the top:
49-
* email, phone, verification state, platform role. Highlight fields
50-
* are auto-dropped from the details grid below. 2FA state is not one
51-
* of them: it is drawn only in the Security tab, behind the
52-
* deployment's `twoFactor` gate (see {@link TWO_FACTOR_OFFERED}).
49+
* email, phone, verification state. Highlight fields are auto-dropped
50+
* from the details grid below. 2FA state is not one of them: it is
51+
* drawn only in the Security tab, behind the deployment's `twoFactor`
52+
* gate (see {@link TWO_FACTOR_OFFERED}). Nor is better-auth's legacy
53+
* `role` scalar (#28105): it is not the platform role, and the page
54+
* does not draw it anywhere.
5355
* - The FIRST tab is the details grid: a `record:details` that
5456
* re-groups remaining fields into sections (locale on Identity, the ban
5557
* state and reason in Account Status) and hides admin-internal columns.
@@ -175,7 +177,10 @@ export const SysUserDetailPage: Page = {
175177
// No `two_factor_enabled` (#25287): a highlight field has no
176178
// per-field gate, and on a deployment with 2FA off the chip read a
177179
// dead "No". The state is drawn in the Security tab instead.
178-
fields: ['email', 'phone_number', 'email_verified', 'role'],
180+
// No `role` (#28105): better-auth's legacy scalar read as the
181+
// "Platform Role" chip, but ObjectStack never writes it and with
182+
// the admin plugin on it reads `user` for a platform admin too.
183+
fields: ['email', 'phone_number', 'email_verified'],
179184
},
180185
},
181186

@@ -215,7 +220,7 @@ export const SysUserDetailPage: Page = {
215220
'phone_number',
216221
'email_verified',
217222
'two_factor_enabled', // not a highlight: drawn in the Security tab, behind the 2FA gate
218-
'role',
223+
'role', // not a highlight either: the legacy better-auth scalar, not the platform role (#28105)
219224
],
220225
sections: [
221226
{

0 commit comments

Comments
 (0)