From 2abfd4b4c676e88fd812558818c63ee908baeaf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:49:36 +0000 Subject: [PATCH 1/5] feat(platform-objects): User Preferences and Identity Links leave Setup's menu; the User page gains a Preferences tab Setup's nav_accounts (sys_account) and nav_user_preferences (sys_user_preference) entries are removed. Both stores hold rows that belong to one user; an administrator reaches them from the User record page: the existing Linked Accounts tab and a new Preferences tab, a related list on user_id whose columns are the all_preferences view's minus user_id. The two ids join the Setup nav tombstone, their labels and recorded source hashes leave the four bundles, and the caller-scoped-first-view control holds its (a) pin for sys_user_preference by name now that no nav entry names it. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .../caller-scoped-first-list-view.test.ts | 22 ++- .../apps/setup-nav-per-user-stores.test.ts | 144 ++++++++++++++++++ .../src/apps/setup-nav.contributions.ts | 14 +- .../src/apps/translations/en.ts | 8 +- .../apps/translations/es-ES.source-hashes.ts | 2 - .../src/apps/translations/es-ES.ts | 2 - .../apps/translations/ja-JP.source-hashes.ts | 2 - .../src/apps/translations/ja-JP.ts | 2 - .../setup-nav-dead-key-tombstone.test.ts | 24 ++- .../apps/translations/zh-CN.source-hashes.ts | 2 - .../src/apps/translations/zh-CN.ts | 2 - .../src/pages/sys-user.page.ts | 40 ++++- 12 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts diff --git a/packages/platform-objects/src/apps/caller-scoped-first-list-view.test.ts b/packages/platform-objects/src/apps/caller-scoped-first-list-view.test.ts index baf25e109db..20124052105 100644 --- a/packages/platform-objects/src/apps/caller-scoped-first-list-view.test.ts +++ b/packages/platform-objects/src/apps/caller-scoped-first-list-view.test.ts @@ -145,12 +145,32 @@ describe('the population is derived from Setup and Account navigation (#21972)', // Non-vacuity control, not the population: the objects this card reordered // must still be judged by both pins, or a green run says nothing about them. it('judges every object whose caller-scoped first view this card moved', () => { - for (const name of ['sys_user', 'sys_api_key', 'sys_session', 'sys_oauth_application', 'sys_account', 'sys_user_preference']) { + for (const name of ['sys_user', 'sys_api_key', 'sys_session', 'sys_oauth_application', 'sys_account']) { expect(JUDGED_BY_A, `(a) no longer judges ${name}`).toContain(name); expect(JUDGED_BY_B.map((e) => e.objectName), `(b) no longer judges an entry naming ${name}`).toContain(name); } }); + // The sixth object #21972 reordered left this population with Setup's "User + // Preferences" entry (#23803): no nav entry of either app names it now, so + // neither pin above reaches it, and (b) has nothing to judge. (a)'s concern + // does not leave with the entry — a preference row opened from the User + // record page's Preferences tab still has an object breadcrumb, a door that + // names no view — so (a) is held for it here, by name. Should an entry name + // it again, the first assertion goes red: put it back in the list above. + it('holds (a) for sys_user_preference, which no nav entry names any more', () => { + expect(NAMED_OBJECTS, 'an entry names sys_user_preference again — judge it in the loop above').not.toContain( + 'sys_user_preference', + ); + expect(CATALOGUE.has('sys_user_preference'), 'control: the object is read from this package').toBe(true); + const [firstName, firstView] = Object.entries(listViewsOf('sys_user_preference'))[0] ?? []; + expect(firstName).toBeTypeOf('string'); + expect( + isCallerScoped(firstView), + `sys_user_preference declares the caller-scoped list view "${firstName}" first`, + ).toBe(false); + }); + it('cannot read exactly these named objects from this package', () => { // `sys_inbox_message` is `@objectstack/service-messaging`'s; the Account // app's Notifications entry names it, and names `mine`. diff --git a/packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts b/packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts new file mode 100644 index 00000000000..1a4211c1b1c --- /dev/null +++ b/packages/platform-objects/src/apps/setup-nav-per-user-stores.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #23803 item 3 — "User Preferences" (`sys_user_preference`) and "Identity +// Links" (`sys_account`) are not Setup menu entries; each is reached from the +// User record page, from the user its rows belong to. +// +// Both halves are pinned together because each is only correct with the other: +// the menu entries could leave only because the User page lists the same rows, +// per user, with nothing new shown. +// +// (1) no Setup entry this package contributes targets either object — by +// `objectName`, or by a URL into the object's route; +// (2) the User page carries exactly one related list over each object, keyed +// to the page's record by `user_id`, and the block contract accepts it; +// (3) the Preferences list shows no column the object's `all_preferences` +// view (the grid the menu entry opened) did not show, and authors no +// `add` picker and no `actions` — so it exposes nothing new; +// (4) the Linked Accounts list already served "Identity links", and the one +// row action that grid offered, `unlink_account`, is a `list_item` +// action, which a related list's host offers on its rows. +// +// Reach, stated so a green run is not read wider than it is: (1) reads +// `SETUP_NAV_CONTRIBUTIONS` only. The other Setup contributors are capability +// plugins that depend on this package and cannot be imported from here; on the +// commit this landed on, none of them names either object (a grep of every +// `app: 'setup'` contributor for `objectName:`). Whether the console renders +// the tabs is objectui's half and is not judged here. + +import { describe, it, expect } from 'vitest'; +import { PageSchema, RecordRelatedListProps } from '@objectstack/spec/ui'; + +import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; +import { SysUserDetailPage } from '../pages/sys-user.page.js'; +import { SysUserPreference } from '../identity/sys-user-preference.object.js'; +import { SysAccount } from '../identity/sys-account.object.js'; + +const PER_USER_STORES = ['sys_user_preference', 'sys_account'] as const; + +type NavItem = { id?: string; objectName?: string; url?: string; children?: NavItem[] }; +type Rec = Record; + +/** Every Setup nav item this package contributes, depth-first. */ +function setupNavItems(): NavItem[] { + const out: NavItem[] = []; + const walk = (items: unknown[] | undefined) => { + for (const raw of items ?? []) { + const item = raw as NavItem; + if (!item) continue; + out.push(item); + if (Array.isArray(item.children)) walk(item.children); + } + }; + for (const contribution of SETUP_NAV_CONTRIBUTIONS) walk(contribution.items as unknown[]); + return out; +} + +/** Every `record:related_list` block's properties over `objectName`, anywhere on the page. */ +function relatedListsOf(root: unknown, objectName: string): Rec[] { + const found: Rec[] = []; + const walk = (n: unknown): void => { + if (Array.isArray(n)) { n.forEach(walk); return; } + if (!n || typeof n !== 'object') return; + const node = n as Rec & { type?: unknown; properties?: Rec }; + if (node.type === 'record:related_list' && node.properties?.objectName === objectName) found.push(node.properties); + Object.values(node).forEach(walk); + }; + walk(root); + return found; +} + +/** The `tabs` item whose body holds the related list over `objectName`. */ +function tabOf(objectName: string): Rec | undefined { + const items = (((SysUserDetailPage.slots as Rec).tabs as Rec).properties as Rec).items as Rec[]; + return items.find((item) => relatedListsOf(item.children, objectName).length > 0); +} + +describe('(1) Setup has no menu entry over a per-user store (#23803)', () => { + const items = setupNavItems(); + + it('control: the walk reads the contributions (Users is an entry here)', () => { + expect(items.some((i) => i.id === 'nav_users' && i.objectName === 'sys_user')).toBe(true); + }); + + it.each(PER_USER_STORES)('no entry targets %s', (objectName) => { + const hits = items.filter( + (i) => i.objectName === objectName || (typeof i.url === 'string' && i.url.includes(objectName)), + ); + expect(hits.map((i) => i.id)).toEqual([]); + }); +}); + +describe('(2) the User page lists each per-user store, keyed to its record (#23803)', () => { + it('the page parses through PageSchema', () => { + const parsed = PageSchema.safeParse(SysUserDetailPage); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it.each(PER_USER_STORES)('%s: one related list on user_id, accepted by the block contract', (objectName) => { + const lists = relatedListsOf(SysUserDetailPage, objectName); + expect(lists).toHaveLength(1); + expect(lists[0].relationshipField).toBe('user_id'); + const parsed = RecordRelatedListProps.safeParse(lists[0]); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true); + }); + + it('each list is its own tab, labelled in every locale the page carries', () => { + expect((tabOf('sys_user_preference')?.label as Rec)).toEqual({ + en: 'Preferences', + 'zh-CN': '偏好设置', + 'ja-JP': '設定', + 'es-ES': 'Preferencias', + }); + expect((tabOf('sys_account')?.label as Rec)?.en).toBe('Linked Accounts'); + expect(Object.keys((tabOf('sys_account')?.label as Rec) ?? {}).sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']); + }); +}); + +describe('(3) the Preferences list exposes nothing the old grid did not (#23803)', () => { + const list = relatedListsOf(SysUserDetailPage, 'sys_user_preference')[0] ?? {}; + const grid = SysUserPreference.listViews?.all_preferences as { columns?: unknown[] } | undefined; + + it('shows a subset of the all_preferences columns, without the page record itself', () => { + const gridColumns = (grid?.columns ?? []) as string[]; + expect(gridColumns, 'control: the grid the menu entry opened still declares its columns').toContain('key'); + const columns = list.columns as string[]; + expect(columns.length).toBeGreaterThan(0); + expect(columns.filter((c) => !gridColumns.includes(c)), 'columns all_preferences never showed').toEqual([]); + expect(columns).not.toContain('user_id'); + expect(columns).not.toContain('value'); + }); + + it('authors no add picker and no actions', () => { + expect(list).not.toHaveProperty('add'); + expect(list).not.toHaveProperty('actions'); + }); +}); + +describe('(4) Linked Accounts already serves "Identity links" (#23803)', () => { + it('the grid\'s one row action, unlink_account, is offered on list rows', () => { + const unlink = (SysAccount.actions ?? []).find((a) => a.name === 'unlink_account'); + expect(unlink, 'sys_account lost unlink_account').toBeDefined(); + expect(unlink?.locations).toContain('list_item'); + }); +}); diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 17f1725c3ca..ff80d367f5d 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -183,8 +183,18 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // nav entry for them can only ever render "failed to load". They're // reachable by id (get) when needed; no browse menu. (Re-adding requires // enabling `list` on the object — a security decision.) - { id: 'nav_accounts', type: 'object', label: 'Identity Links', objectName: 'sys_account', viewName: 'all_links', icon: 'link-2' }, - { id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', viewName: 'all_preferences', icon: 'sliders' }, + // + // No `nav_accounts` ("Identity Links", `sys_account`) and no + // `nav_user_preferences` ("User Preferences", `sys_user_preference`) + // either (#23803), for a different reason: both objects list fine, but + // each row belongs to one user and is written for that user — identity + // links by better-auth at sign-in, preferences by the console as the + // user works (`ui.recent`, `ui.favorites`, …). A menu entry listing them + // across every user is not a page an administrator works from. Each is + // reached from the user it belongs to instead: the User record page's + // "Linked Accounts" and "Preferences" tabs (`pages/sys-user.page.ts`), + // and the search, which finds the user. The objects, their list views and + // their routes are unchanged; only the two menu entries left. ], }, ]; diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 6d68e68febe..2b9e19356d1 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -155,12 +155,12 @@ export const en: TranslationData = { // no browse entry for them (#2266). Nor `nav_jwks` — `sys_jwks` is // `apiEnabled: false`, so its list 404s for EVERY persona and the entry // was removed (#7544). Nor `nav_metadata` — that entry moved to Studio - // as `nav_metadata_directory`. Re-adding a label here means re-adding - // the nav item first; the tombstone in + // as `nav_metadata_directory`. Nor `nav_accounts` / + // `nav_user_preferences` — both left the menu for the User record + // page's tabs (#23803). Re-adding a label here means re-adding the nav + // item first; the tombstone in // `setup-nav-dead-key-tombstone.test.ts` states the whole rule (#6660). nav_oauth_apps: { label: 'OAuth Applications' }, - nav_accounts: { label: 'Identity Links' }, - nav_user_preferences: { label: 'User Preferences' }, }, }, studio: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 9f763fbb114..6bd21bfdd61 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -123,8 +123,6 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', 'apps.setup.navigation.nav_datasources.label': '2f0648ce23158e6f', 'apps.setup.navigation.nav_oauth_apps.label': '1b26cce61a4c10eb', - 'apps.setup.navigation.nav_accounts.label': '0a7369b6e0a9006d', - 'apps.setup.navigation.nav_user_preferences.label': '933760250cddbb1e', 'apps.studio.label': '5aaaaea2294ee893', 'apps.studio.description': 'c026a5795933995d', 'apps.studio.navigation.group_overview.label': 'eebdbe1cc7b59cdf', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index e5f300a5e38..9e0356c68a9 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -104,8 +104,6 @@ export const esES: TranslationData = { nav_datasources: { label: 'Fuentes de datos' }, nav_oauth_apps: { label: 'Aplicaciones OAuth' }, - nav_accounts: { label: 'Enlaces de Identidad' }, - nav_user_preferences: { label: 'Preferencias de Usuario' }, }, }, studio: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index 14a7015babb..e50eb81108e 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -123,8 +123,6 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', 'apps.setup.navigation.nav_datasources.label': '2f0648ce23158e6f', 'apps.setup.navigation.nav_oauth_apps.label': '1b26cce61a4c10eb', - 'apps.setup.navigation.nav_accounts.label': '0a7369b6e0a9006d', - 'apps.setup.navigation.nav_user_preferences.label': '933760250cddbb1e', 'apps.studio.label': '5aaaaea2294ee893', 'apps.studio.description': 'c026a5795933995d', 'apps.studio.navigation.group_overview.label': 'eebdbe1cc7b59cdf', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 5acbe5e69b9..9d669e2d06c 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -104,8 +104,6 @@ export const jaJP: TranslationData = { nav_datasources: { label: 'データソース' }, nav_oauth_apps: { label: 'OAuth アプリケーション' }, - nav_accounts: { label: 'ID 連携' }, - nav_user_preferences: { label: 'ユーザー設定' }, }, }, studio: { diff --git a/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts b/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts index d1a74755ee3..ff788994038 100644 --- a/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts +++ b/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts @@ -21,11 +21,12 @@ // deliberately NOT built (#6659's triage): it is a separate maintainer-facing // call, not a prerequisite for labelling the ids it cannot see. // -// This file makes no general claim. It pins exactly five ids that were checked +// This file makes no general claim. It pins exactly seven ids that were checked // ONE BY ONE against a repo-wide grep — `id: ''` returned zero hits for -// each of them on `61282f906` (`nav_jwks` on `68a1edb`), against a control -// probe (`nav_webhooks`) that returned five — and each of which has a recorded -// reason to be gone: +// each of the first five on `61282f906` (`nav_jwks` on `68a1edb`), against a +// control probe (`nav_webhooks`) that returned five — and each of which has a +// recorded reason to be gone. The last two rows were measured differently, +// and say so: // // nav_approval_processes the process engine was retired in favour of the // approval flow node (#1408, ADR-0019 P4/P5) @@ -45,11 +46,22 @@ // prune it (#7544) // nav_metadata moved to Studio as `nav_metadata_directory` when // the Studio app was split out (482eb67cc) +// nav_accounts "Identity Links" (`sys_account`) and "User +// nav_user_preferences Preferences" (`sys_user_preference`): per-user +// stores, reached from the User record page's +// Linked Accounts and Preferences tabs instead +// (#23803). The objects still list; this is a menu +// decision, not an API one. The grep answers zero +// for `nav_user_preferences` but not for +// `nav_accounts`, which is also some OTHER app's CRM +// entry id (example apps, docs, test fixtures) — so +// for that one the grep is no evidence, and the +// assertions below are the whole claim. // // --------------------------------------------------------------------------- // What to do when this test goes red // --------------------------------------------------------------------------- -// It goes red on exactly one event: one of the five ids comes back. That is not +// It goes red on exactly one event: one of the seven ids comes back. That is not // automatically wrong — re-adding `nav_verifications`, `nav_device_codes` or // `nav_jwks` is a deliberate security decision (each requires enabling the read // on the object first — for `sys_jwks`, opening a data-API read path onto @@ -70,10 +82,12 @@ const LOCALES = { en, 'zh-CN': zhCN, 'ja-JP': jaJP, 'es-ES': esES } as const; /** Removed Setup nav ids. Delete a line here only together with its nav item. */ const DEAD_SETUP_NAV_IDS = [ + 'nav_accounts', 'nav_approval_processes', 'nav_device_codes', 'nav_jwks', 'nav_metadata', + 'nav_user_preferences', 'nav_verifications', ] as const; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index b2ddf8c3661..9a9234f26e9 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -123,8 +123,6 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', 'apps.setup.navigation.nav_datasources.label': '2f0648ce23158e6f', 'apps.setup.navigation.nav_oauth_apps.label': '1b26cce61a4c10eb', - 'apps.setup.navigation.nav_accounts.label': '0a7369b6e0a9006d', - 'apps.setup.navigation.nav_user_preferences.label': '933760250cddbb1e', 'apps.studio.label': '5aaaaea2294ee893', 'apps.studio.description': 'c026a5795933995d', 'apps.studio.navigation.group_overview.label': 'eebdbe1cc7b59cdf', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 98ed30cb272..1e8aa4b9c1d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -114,8 +114,6 @@ export const zhCN: TranslationData = { nav_datasources: { label: '数据源' }, nav_oauth_apps: { label: 'OAuth 应用' }, - nav_accounts: { label: '身份链接' }, - nav_user_preferences: { label: '用户偏好' }, }, }, studio: { diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 2f11030404f..c231575d4d9 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -36,11 +36,14 @@ import type { Page } from '@objectstack/spec/ui'; * - The other `tabs` items are **explicitly curated** to the related lists * that matter on a user profile (Positions / Permission Sets / Business * Units / Sessions / Linked Accounts / Organizations / OAuth Apps / API - * Keys) plus a Security tab. Without this override, the synthesizer - * auto-generates a tab per object that has a FK to sys_user - * (sys_position.created_by, sys_email.updated_by, sys_user_preference, + * Keys / Preferences) plus a Security tab. Without this override, the + * synthesizer auto-generates a tab per object that has a FK to sys_user + * (sys_position.created_by, sys_email.updated_by, * sys_email_template.created_by, …) producing dozens of noisy * "查看全部" cards on every profile. + * - Linked Accounts (`sys_account`) and Preferences (`sys_user_preference`) + * are where an administrator reaches those two per-user stores: Setup + * has no menu entry listing them across users (#23803). * - `discussion: []` removes the Chatter feed — it has no business * on a personal profile. */ @@ -521,6 +524,37 @@ export const SysUserDetailPage: Page = { }, ], }, + // ── Preferences ─────────────────────────────────────────── + // [#23803] This user's `sys_user_preference` rows — the console's + // per-user UI state (`ui.recent`, `ui.favorites`, `ui.list.` + // …). Setup's "User Preferences" menu entry, which listed every + // user's rows, left the menu; this tab is where an administrator + // reaches them now, from the user they belong to. + // + // The columns are the object's `all_preferences` view (the grid that + // entry opened) minus `user_id`, which is the page's own record. + // Nothing is shown here that view did not show. No `add` and no + // `actions`: New / Edit / Delete are the host's, decided by the + // object's `managedBy: 'system-data'` and the caller's grant, the + // same verdict the grid's buttons read. + { + label: { en: 'Preferences', 'zh-CN': '偏好设置', 'ja-JP': '設定', 'es-ES': 'Preferencias' }, + icon: 'sliders', + children: [ + { + type: 'record:related_list', + properties: { + objectName: 'sys_user_preference', + relationshipField: 'user_id', + columns: ['key', 'created_at', 'updated_at'], + sort: [{ field: 'updated_at', order: 'desc' }], + limit: 25, + showViewAll: true, + title: { en: 'Preferences', 'zh-CN': '偏好设置', 'ja-JP': '設定', 'es-ES': 'Preferencias' }, + }, + }, + ], + }, ], }, }, From 8f20415f1999a1fdfc7a13c7c0c3d75da0400a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:08:49 +0000 Subject: [PATCH 2/5] docs(platform-objects): re-point the citations that named Setup's two removed entries The identity-auth.linked-accounts-social checklist item cited setup-nav.contributions.ts#nav_accounts, which no longer resolves (check:platform-checklist: ABSENT SYMBOL). It now cites SysUserDetailPage, whose Linked Accounts tab is the admin surface over sys_account; revision 4, provenance only, no clause change. The sys_account and sys_user_preference list-view comments stop saying a Setup entry names their unscoped view. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- docs/qa/platform-checklist/areas/identity-auth.json | 7 ++++--- .../platform-objects/src/identity/sys-account.object.ts | 5 +++-- .../src/identity/sys-user-preference.object.ts | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 0f245846dc2..47125348234 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -1116,7 +1116,7 @@ "title": "Linked accounts: link a social/OIDC identity through the redirect round-trip → a sys_account row appears in mine-view; unlink removes it; provider-less boot degrades honestly", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "mixed", "personas": ["a signed-in user linking a second identity", "the same user after unlinking"], @@ -1182,14 +1182,15 @@ "packages/platform-objects/src/identity/sys-account.object.ts#user_id (unlink_account → /api/v1/auth/unlink-account accountId=row id; mine view user_id={current_user_id} vs all_links; apiMethods ['get','list'])", "packages/plugins/plugin-auth/src/auth-route-ledger.ts#AUTH_ROUTE_LEDGER (POST link-social=auth.accounts.linkSocial, GET list-accounts=auth.accounts.list, POST unlink-account=auth.accounts.unlink — re-pointed #18104: linkSocial is a CLIENT METHOD name, carried in this file only inside a dotted string value)", "packages/platform-objects/src/apps/account.app.ts#nav_account_linked (nav_account_linked → 'Linked Accounts' in the Account app, objectName sys_account — the user's surface this item drives)", - "packages/platform-objects/src/apps/setup-nav.contributions.ts#nav_accounts (nav_accounts → 'Identity Links', objectName sys_account — the Setup app's admin entry over the same object, not the user's surface)", + "packages/platform-objects/src/pages/sys-user.page.ts#SysUserDetailPage (its Linked Accounts tab — a record:related_list over sys_account on user_id — is the admin's surface over the same object since #23803 removed Setup's nav_accounts 'Identity Links' entry; not the user's surface)", "packages/plugins/plugin-auth/src/auth-manager.ts#applyConfigPatch (the public config patch the local-provider fixture registers through; oidcProviders)", "#21845 (follow-up run: link / mine-view / unlink / read-only clauses passed against a local OIDC provider through the authenticated link door)" ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: social/OIDC account linking round-trip → sys_account mine-view row, unlink removal, provider-less honest degradation; blocked(fixture) pending a configured IdP (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-09-23", "change": "the 'issuer stamped' requirement is withdrawn from both the step and the first acceptance clause's verify: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so nothing stamps it and the old verify failed a healthy system. The reason is kept — the step now says (provider_id, account_id) is the whole identity and names the retirement's migration entry in packages/spec — and the verify states that an issuer-less row is the expected shape", "ref": "#19217" }, - { "revision": 3, "date": "2026-10-05", "change": "blocked lifted: run #21845 passed the link, mine-view, unlink and read-only clauses against a local authorization-code + PKCE OIDC provider registered through the auth manager's applyConfigPatch from a scratch app plugin ('auth:configure' fires too early); the provider recipe is written into fixtures.requires, not committed as code, and the missing-IdP gap is kept as a CLOSED-for-runs knownGap. Step 2 now links through the authenticated POST /api/v1/auth/link-social and navigates to the URL it returns — the old step drove the sign-in door, which signs in rather than links. The step, the provider-less clause and its negative no longer name a specific sys_account action: they describe the link affordance, so the clause indices stay stable while the action itself is handled on its own card. The user's nav label is 'Linked Accounts' in the Account app (nav_account_linked); 'Identity Links' is the Setup app's admin entry (card #21851 entry 3)", "ref": "#21845" } + { "revision": 3, "date": "2026-10-05", "change": "blocked lifted: run #21845 passed the link, mine-view, unlink and read-only clauses against a local authorization-code + PKCE OIDC provider registered through the auth manager's applyConfigPatch from a scratch app plugin ('auth:configure' fires too early); the provider recipe is written into fixtures.requires, not committed as code, and the missing-IdP gap is kept as a CLOSED-for-runs knownGap. Step 2 now links through the authenticated POST /api/v1/auth/link-social and navigates to the URL it returns — the old step drove the sign-in door, which signs in rather than links. The step, the provider-less clause and its negative no longer name a specific sys_account action: they describe the link affordance, so the clause indices stay stable while the action itself is handled on its own card. The user's nav label is 'Linked Accounts' in the Account app (nav_account_linked); 'Identity Links' is the Setup app's admin entry (card #21851 entry 3)", "ref": "#21845" }, + { "revision": 4, "date": "2026-10-16", "change": "PROVENANCE, no clause change: #23803 removed Setup's nav_accounts ('Identity Links') entry, so the source anchor that cited it no longer resolved. Re-pointed it at SysUserDetailPage, whose Linked Accounts tab (a related list over sys_account keyed on user_id) is where an administrator reaches a user's identity links now. Steps, acceptance clauses, oracles and negatives are unchanged: they drive the user's Account-app surface, which did not move", "ref": "#23803" } ] }, { diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index a4343c57787..83c54a3109e 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -80,8 +80,9 @@ export const SysAccount = ObjectSchema.create({ listViews: { // [#21972] Declared FIRST on purpose: a route that names no view opens the // first declared list view, so a caller-scoped view (`mine`) is never - // first. Setup's `nav_accounts` names this view; the Account app's - // `nav_account_linked` names `mine`. + // first. The Account app's `nav_account_linked` names `mine`; Setup's + // "Identity Links" entry, which named this view, left the menu (#23803) + // for the User record page's Linked Accounts tab. all_links: { type: 'grid', name: 'all_links', diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index c5ef66133c9..f20aebd1efe 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -36,7 +36,9 @@ export const SysUserPreference = ObjectSchema.create({ listViews: { // [#21972] Declared FIRST on purpose: a route that names no view opens the // first declared list view, so a caller-scoped view (`mine`) is never - // first. Setup's `nav_user_preferences` names this view. + // first. No nav entry names this view any more (Setup's "User + // Preferences" left the menu, #23803); the bare-object routes — a + // preference record's object breadcrumb — still open it. all_preferences: { type: 'grid', name: 'all_preferences', From 25338a12c68b458aeab7ec963fb87c2a4dfae46f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:09:12 +0000 Subject: [PATCH 3/5] chore(changeset): platform-objects patch for Setup's two per-user menu entries Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .changeset/23803-setup-nav-user-entries.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/23803-setup-nav-user-entries.md diff --git a/.changeset/23803-setup-nav-user-entries.md b/.changeset/23803-setup-nav-user-entries.md new file mode 100644 index 00000000000..48e1b64025d --- /dev/null +++ b/.changeset/23803-setup-nav-user-entries.md @@ -0,0 +1,13 @@ +--- +'@objectstack/platform-objects': patch +--- + +feat(platform-objects): Setup's menu no longer lists "User Preferences" or "Identity Links". You reach both from the user they belong to: open a user in Setup › Users, and the user's page has a "Preferences" tab and its existing "Linked Accounts" tab. + +Clause-②: no + +Both lists held rows that belong to one user each: identity links are written when a user signs in with a provider, and preferences are what the console remembers as a user works (recent items, favourites, list settings). A menu entry listing them for every user at once was not a page an administrator works from. + +- **Where they are now.** On a user's page, "Preferences" lists that user's preference rows (key, created, updated) and "Linked Accounts" lists their identity links, with the same Unlink action. New, Edit and Delete on the Preferences tab follow the same rules as the old list did. Search finds the user, and the tabs are on the user's page. +- **What did not change.** The two objects, their list views and their addresses are unchanged, so a bookmarked list still opens, and a preference row's breadcrumb still leads to the full list. +- **Nothing to migrate.** No data moves and no setting changes. From 6c64115e99218f96c68d533def66cd625f9b16d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:53:29 +0000 Subject: [PATCH 4/5] test(cli): the Connect-an-Agent wire pin counts the 32-id Setup tree the admin is served Setup no longer carries nav_accounts or nav_user_preferences, so the folded tree is 8 groups (group_approvals collapses) + 23 platform-objects entries + the MCP entry = 32, measured from the built packages. Both length pins move from 34 to 32, the count comment says 23 contributed entries, and the first case asserts the two removed ids are absent. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- .../test/connect-agent-both-halves-wire.pin.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts index 37458a9ba7e..69822dd9542 100644 --- a/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts +++ b/packages/cli/test/connect-agent-both-halves-wire.pin.test.ts @@ -254,8 +254,12 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( .toContain('nav_connect_agent'); // The Setup tree the platform admin is served: 9 authored groups less // `group_approvals`, which nothing contributes into and the filter - // collapses, plus 25 contributed entries plus the MCP one. - expect(allowedIds).toHaveLength(34); + // collapses, plus 23 contributed entries plus the MCP one. + expect(allowedIds).toHaveLength(32); + // The two per-user stores are reached from the User record page, not + // from Setup's menu (#23803). + expect(allowedIds).not.toContain('nav_accounts'); + expect(allowedIds).not.toContain('nav_user_preferences'); }); it('the Account half widened NOTHING on Setup — the admin tree is unchanged', async () => { @@ -273,7 +277,7 @@ describe('#17647 — Connect-an-Agent visibility: BOTH halves, over the wire', ( expect(withAccountHalf.statusCode).toBe(200); expect(withoutAccountHalf.statusCode).toBe(200); expect(navIds(withAccountHalf.body?.item)).toEqual(navIds(withoutAccountHalf.body?.item)); - expect(navIds(withAccountHalf.body?.item)).toHaveLength(34); + expect(navIds(withAccountHalf.body?.item)).toHaveLength(32); // The mechanism behind the invariance, asserted rather than assumed: // the bundle aims exactly one contribution at `setup` and one at From 770f1d617422208c27e886e3afb30cfc9a227a27 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:34:40 +0000 Subject: [PATCH 5/5] test(dogfood),docs: the composed entry-views pin, the Setup docs table and the nav-render checklist step stop naming Setup's two removed entries platform-app-object-entry-views: sys_user_preference leaves the non-vacuity list (no merged entry names it any more) and a new case holds rule (a) for it by name, read from the composition or the identity barrel, the same shape as caller-scoped-first-list-view.test.ts in platform-objects. content/docs/ui/setup-app.mdx: the Advanced row lists OAuth Applications only. platform-core.builtin-apps-nav-render: the Setup detail step drives the User page's Preferences and Linked Accounts tabs instead of two Setup entries; revision 6. The Setup destination count is left for the card's last lander. Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude --- content/docs/ui/setup-app.mdx | 2 +- .../areas/platform-core.json | 10 +++++++-- .../platform-app-object-entry-views.test.ts | 21 ++++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index 9247cf072a3..9e1cfc126a9 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -55,7 +55,7 @@ anchors are: | **Configuration** (`group_configuration`) | All Settings · Localization · Company · Branding · Authentication · Email · File Storage · AI & Embedder · Knowledge · Feature Flags — `platform-objects` | | **Diagnostics** (`group_diagnostics`) | Sessions · Notification Events — `platform-objects`; Audit Logs · Audit Log Browser — `plugin-audit` | | **Integrations** (`group_integrations`) | `plugin-webhooks` | -| **Advanced** (`group_advanced`) | OAuth Applications · Identity Links · User Preferences — `platform-objects` | +| **Advanced** (`group_advanced`) | OAuth Applications — `platform-objects` | The exact rendered menu depends on which capability plugins are loaded. A few notable entries: diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 603e1511a91..234457b180a 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -509,7 +509,7 @@ "title": "The built-in apps a stock boot REGISTERS (Setup / Account) render every merged-nav destination; app-level and entry-level gates are enforced, not errored — and Studio's deliberate absence is asserted, never hunted", "since": "v15", "status": "active", - "revision": 5, + "revision": 6, "priority": "P0", "surface": "browser", "personas": [ @@ -532,7 +532,7 @@ "as admin, GET /api/v1/meta/app?id=setup and extract every merged nav destination — Setup is a SHELL of empty group anchors (packages/platform-objects/src/apps/setup.app.ts: group_overview/apps/people_org/access_control/approvals/configuration/diagnostics/integrations/advanced) filled by SETUP_NAV_CONTRIBUTIONS (setup-nav.contributions.ts) plus capability-plugin contributions (plugin-security Positions/Permission Sets, plugin-sharing Sharing Rules/Record Shares, plugin-approvals, plugin-webhooks)", "GET /api/v1/meta/app with NO query first and record the names actually served — a stock boot answers exactly showcase_app, setup, account. Then GET /api/v1/meta/app?id=account (account.app.ts — Profile + Inbox/Security/Developer groups). ⚠️ GET /api/v1/meta/app?id=studio answers 200 {\"items\":[]} on every stock boot and that is the expected result (knownGap): record the absence as an assertion and move on — do NOT install @objectstack/studio to make the step runnable", "as admin, hand-walk each destination in each app: navigate, wait for render, SCREENSHOT FIRST, then read the DOM — assert no pageerror, no 'failed to load', no blank
, no 'no actions configured' placeholder", - "Setup detail: confirm each settings URL entry (nav_settings_* → /apps/setup/system/settings/) opens the settings namespace form (localization/company/branding/auth/mail/storage/ai/knowledge/feature_flags), and Users / Organization / Business Units / Teams / Sessions / OAuth Applications / Identity Links / User Preferences render", + "Setup detail: confirm each settings URL entry (nav_settings_* → /apps/setup/system/settings/) opens the settings namespace form (localization/company/branding/auth/mail/storage/ai/knowledge/feature_flags), and Users / Organization / Business Units / Teams / Sessions / OAuth Applications render. User preferences and identity links are not Setup destinations: open a user from Users and confirm the user page's Preferences and Linked Accounts tabs render that user's rows", "Studio detail is NOT walked from here — the app is not registered on a stock boot (knownGap), so there is no merged nav to render. Its metadata:resource lists and component surfaces (App Builder, All Metadata Types, Packages, API Console, Flow Runs, Public Forms) are reached through the console surface at /_console/studio// and are covered by area studio-authoring. What THIS item asserts about Studio is the absence itself, per clause 6", "Account detail: confirm Profile (account:profile_card), Notifications (sys_inbox_message/mine), Approvals (sys_approval_request/my_pending), Linked Accounts (sys_account), Active Sessions (sys_session/mine), API Keys (sys_api_key/mine), OAuth Applications (sys_oauth_application/mine) and Connect an Agent (nav_connect_agent) each render — 8 Account destinations and 45 Setup destinations for the admin, re-measured 2026-10-01 (GET /api/v1/meta/app leaves; #9453 recorded 43 and 7). Recount from the payload each run", "confirm the gated entries resolve to ABSENT-not-erroring for the admin: nav_organizations (requiresService org-scoping) is absent in single-org mode; nav_jwks Signing Keys is ABSENT for EVERY persona, admin included — the entry was removed in #7544 because sys_jwks declares apiEnabled:false, so its list answers OBJECT_API_DISABLED (404) for all callers and no permission gate could prune it; SSO Providers is absent unless OS_SSO_ENABLED (knownGap)", @@ -618,6 +618,12 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21782 parked on #22017 (row 10). The marketplace catalog content was blocked(environment): egress to the catalog was denied. Disposition: recorded knownGap. The existing knownGap now names the environment the leg needs (a boot that reaches DEFAULT_CLOUD_URL or a reachable OS_CLOUD_URL) and that no CI job drives it. No clause changed", "ref": "#22017" + }, + { + "revision": 6, + "date": "2026-10-16", + "change": "the Setup detail step listed Identity Links and User Preferences among Setup's rendered destinations; #23803 removed both entries from Setup (nav_accounts, nav_user_preferences), and an administrator reaches those rows from the User record page's Linked Accounts and Preferences tabs. The step now drives those two tabs from a user's page instead. The destination count in the Account-detail step is left as it is: it is #23803 item 4's, re-measured by whichever of that card's changes lands last. No acceptance clause changed", + "ref": "#23803" } ] }, diff --git a/packages/qa/dogfood/test/platform-app-object-entry-views.test.ts b/packages/qa/dogfood/test/platform-app-object-entry-views.test.ts index 1479ae42a12..4b685c8f13e 100644 --- a/packages/qa/dogfood/test/platform-app-object-entry-views.test.ts +++ b/packages/qa/dogfood/test/platform-app-object-entry-views.test.ts @@ -339,13 +339,32 @@ describe('the merged Setup and Account apps are booted whole', () => { 'sys_session', 'sys_oauth_application', 'sys_account', - 'sys_user_preference', ]) { expect(JUDGED_BY_A, `(a) no longer judges ${name}`).toContain(name); expect(JUDGED_BY_B.map((e) => e.objectName), `(b) no longer judges an entry naming ${name}`).toContain(name); } }); + // One object this family reordered left the population with Setup's + // "User Preferences" entry (#23803): no merged entry of either app names it + // now, so neither rule reaches it, and (b) has nothing to judge. (a)'s + // concern does not leave with the entry — a preference row opened from the + // User record page's Preferences tab still has an object breadcrumb, a door + // that names no view — so (a) is held for it here, by name, read from the + // same two sources the catalogue reads. Should a merged entry name it again, + // the first assertion goes red: put it back in the list above. + it('holds (a) for sys_user_preference, which no merged entry names any more', () => { + expect(NAMED_OBJECTS, 'a merged entry names sys_user_preference again — judge it in the loop above').not.toContain( + 'sys_user_preference', + ); + const def = + (engine.registry.getObject('sys_user_preference') as ObjectDef | undefined) ?? IDENTITY.get('sys_user_preference'); + expect(def, 'control: neither the composition nor the identity barrel declares sys_user_preference').toBeDefined(); + const [first, view] = Object.entries(def?.listViews ?? {})[0] ?? []; + expect(first).toBeTypeOf('string'); + expect(isCallerScoped(view), `sys_user_preference declares the caller-scoped list view "${first}" first`).toBe(false); + }); + it('every object an entry names is declared by the composition or by the identity barrel', () => { // Remedy for a red here: add the plugin that registers the object to the // roster above (and to check-app-nav-i18n's), never a fallback.