Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/23803-setup-nav-user-entries.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/ui/setup-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions docs/qa/platform-checklist/areas/identity-auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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" }
]
},
{
Expand Down
10 changes: 8 additions & 2 deletions docs/qa/platform-checklist/areas/platform-core.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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 <main>, no 'no actions configured' placeholder",
"Setup detail: confirm each settings URL entry (nav_settings_* → /apps/setup/system/settings/<namespace>) 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/<namespace>) 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/<pkg>/<pillar> 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)",
Expand Down Expand Up @@ -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"
}
]
},
Expand Down
10 changes: 7 additions & 3 deletions packages/cli/test/connect-agent-both-halves-wire.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading