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
16 changes: 16 additions & 0 deletions .changeset/28105-sys-user-role-off-setup-screens.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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<string, ListView> }).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<string, ListView> }).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<string, Record<string, unknown>>).role;
expect(role).toBeDefined();
expect(role!.type).toBe('text');
expect(role!.readonly).toBe(true);
expect(role!.group).toBe('Admin');
expect(role).not.toHaveProperty('options');
});
});

This file was deleted.

10 changes: 7 additions & 3 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** 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');
}
});
});
19 changes: 12 additions & 7 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand All @@ -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.
Expand Down Expand Up @@ -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'],
},
},

Expand Down Expand Up @@ -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: [
{
Expand Down
Loading