Skip to content

Commit f76c622

Browse files
fix(platform-objects): Setup → Users opens on the All Users list view (#21971)
Fixes #21960 Clause-②: no ## What changes Setup → Users now opens on the **All Users** list view (`all_users`). Before this, it opened **My Profile** (`me`): one row, the administrator, with a page size of 1. - `packages/platform-objects/src/apps/setup-nav.contributions.ts`: `nav_users` gains `viewName: 'all_users'`. That key is the one `ObjectNavItemSchema` already declares on an object navigation item ("Default list view to open"). No new key. - `packages/platform-objects/src/identity/sys-user.object.ts`: a comment only. The `me` view's comment said the Account App surfaces it. At `main` it does not (see the readers below), and the comment now names what does reach it. - `packages/platform-objects/src/apps/setup-users-nav-view.test.ts`: the pins. - `.changeset/21960-setup-users-all-users.md`: `@objectstack/platform-objects` patch. ⛔ No `listViews` reorder, no view removed, no `packages/spec` edit, no governed surface. Implemented by the os-dev run of session `session_017ErfyP2Rx7XWHJA27QjyUi` on branch `claude/issue-21960-setup-users-all-users`. ## The console honours `viewName` (measured, H1) Read in objectui at the commit `.objectui-sha` pins, `0abd4f9f8769fc4c19ad2f96707684876f74c09f`: - `packages/layout/src/NavigationRenderer.tsx`, `resolveHref`, case `object`. The precedence is `recordId`, then `filters`, then `viewName`. With `viewName` the href is `OBJECT/view/VIEWNAME`; without it, the bare `OBJECT` path. - `packages/app-shell/src/views/ObjectView.tsx`. The active view is the URL view id, then `?view=`, then a view marked `isDefault`, then `views[0]`. Only the object's primary `list` is marked `isDefault` (`buildViewTabs`), and `sys_user` declares none, so `views[0]` is the first declared list view: `me`. That is what the card observed. - `packages/core/src/utils/resolve-view-id.ts` matches the short name `all_users` against the qualified id `sys_user.all_users`. - "Defaults to "all"" in the spec's `viewName` describe does not hold for an object that declares list views. `all` is only the id of the fallback tab the console builds for an object with no list views. This is reported on the card; it is not edited here, because it is spec text. The served path keeps the key as authored. `applyNavContributions` (`packages/objectql/src/registry.ts`) pushes contributed items into a structured clone of the app, and `filterNav` (`packages/rest/src/meta-item-read-gate.ts`) pushes a passing leaf item unchanged. `nav_notifications` already ships `viewName: 'recent'` through the same contribution path. ## Who reads `me` (measured, H2) - **Navigation items: none.** The Account app's profile entry is `nav_account_profile`: `type: 'component'`, `componentRef: 'account:profile_card'`. No Account entry routes to `sys_user`. - **objectui at the pin: none by name.** `account:profile_card` is registered in `apps/console/src/registerAccountComponents.tsx` and renders `ProfilePage`, which reads the signed-in user from `useAuth()` and writes through `updateUser`. - **Pages: none.** `pages/sys-user.page.ts` names no list view. - **Tests: none.** - **Other references:** the four generated translation bundles carry the tab's label, and the spec's `ObjectNavItemSchema` docblock cites `sys_user.me` as an example of the `{current_user_id}` filter vocabulary. So `me` is reached only as a tab, and as the default tab when a route names no view. Triage's second pin ("the Account app's My Profile still opens on the caller's own row") is held by the profile component, and it is pinned there. ## Pins `setup-users-nav-view.test.ts`, 7 cases: - `nav_users` sits in `group_people_org` with `type: 'object'`, `objectName: 'sys_user'` and `viewName: 'all_users'`; - it carries neither `recordId` nor `filters`, both of which `resolveHref` honours ahead of `viewName`; - `sys_user.listViews.all_users` exists under that same name; - that view is not scoped to the caller: no `filter`, no `{current_user_id}` anywhere in it, and a page size above 1; - the contribution passes a full `NavigationContributionSchema.safeParse`, and the parsed item keeps `viewName: 'all_users'`; - the Account app's first entry is `nav_account_profile`, the `account:profile_card` component; - no Account entry routes to `sys_user`. **No dogfood door pin.** The framework half of the door is the served Setup nav, and both sites on that path pass the item through as authored (above). The data half is unchanged by this diff: `all_users` carries no filter. A door case would assert what the metadata pin already holds. ## Reverse verification On committed `6639eb4a52`: ```text node scripts/ablation-replace.mjs --file packages/platform-objects/src/apps/setup-nav.contributions.ts \ --anchor "viewName: 'all_users', " --delete -- \ pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2 src/apps/setup-users-nav-view.test.ts ``` - **Mutation landed:** anchor 1 → 0, blob `72a3adcc1066` → `7c70ade946a9`. - **Result: 4 failed | 3 passed (7).** The four cases that read `nav_users.viewName` went red. The `recordId` / `filters` case and both Account cases stayed green, as expected, because none of them reads `viewName`. - **Restored:** the blob after restore, `72a3adcc1066`, equals the HEAD blob, and `git diff HEAD` is empty. `git status --porcelain` was empty afterwards. - **Resolution path:** the test imports its subject by relative path from `src/`, not through the package `exports`, so no `dist/` sits between the mutation and the run. - **First attempt:** it was refused by the tool before anything ran, because its replacement text already appeared inside the anchor. It ran nothing and restored. ## Tests and gates, at `eb9b315b28` That is the merge of `origin/main` at `2a22177ae7`. `packages/spec` was rebuilt after the merge, then the whole workspace (`turbo run build --concurrency=2`, 72 tasks). - `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2`: **Test Files 61 passed (61), Tests 967 passed (967).** - `pnpm --filter @objectstack/platform-objects typecheck`: **exit 0**, with `check:test-typecheck: OK`. - The new test file is in the `tsconfig.test.json` program, counted with `--listFiles`. - The three errors that program reports are the ledgered `feature-gate-guard.test.ts` debt. - `node scripts/pm/dispatch-gates.mjs --commands` derived **63** commands, and all 63 exited 0. - The `--ran` reconciliation reads `63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`. That is a derived zero: every line carried its exit code. - **14 are new against the dispatch-time list:** `check-adr-0087-registration` (base and self-test), `check-empty-changeset` (base and self-test), `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. - **Artifact-roster block** (55 families, all run): 52 exited 0. - `check-closing-target-claim.mjs`, `check-partof-closing-keyword.mjs` and `check-single-claim-paths.mjs` answer NOT WIRED without a PR context. CI runs them with one. - `check-partof-closing-keyword.mjs` also exits 0 on this body when `PR_BODY` is set locally. The other two need the PR number. - **Symbol-anchor sweeps:** `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all exited 0. - **Prerequisite refusals, then reruns:** - `check:dual-build-cjs-loads`, `check:i18n`, `check:lean-entry-closure` and `check:published-readme-exports` first refused with exit 3 (no `dist/`). After the full build all four exited 0, and `check:i18n` reads "all bundles in sync". - `check:type-check-debt` first hit the runner's 420 s cap while it refreshed its closure. The rerun exited 0, with "1 ledger entr(ies) re-measured ... none above its recorded number". - **ESLint, narrowed to the three touched TypeScript files:** 3 files, 0 errors, 0 warnings. - The population is read from `eslint.config.mjs`: its `packages/**/*.{ts,tsx,mts,cts}` blocks match all three, and none was reported as ignored. - The count comes from `--format json`. - The config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. - The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **Other doors still open on `me`.** objectui's `AppHeader` links the object breadcrumb segment and the object switcher to the bare object path, and a bare path opens `views[0]`. So from a user's record page, the "Users" breadcrumb still lands on My Profile. This PR closes the Setup nav door, as dispatched. Changing which view `sys_user` opens by default is the declaration-order route, which the dispatch reserved. It is reported on the card. - **Sidebar highlight.** objectui's `resolveActiveNavItem` scores a `viewName` item only on its own view. So `nav_users` no longer lights up on a user's record page or on the other tabs, where the bare item used to claim every `sys_user` sub-route. Every `viewName` entry behaves this way today (`nav_notifications`, and the Account `mine` entries). This was read from source at the pin, not measured in a browser. - **Six other Setup entries land an admin on a caller-scoped first view.** `nav_api_keys`, `nav_sessions`, `nav_oauth_apps`, `nav_accounts` and `nav_user_preferences` each open a `mine` view first, and plugin-sharing's `nav_record_shares` opens `granted_to_me` first. Their page sizes are 50 to 100, so these are not one-row views. They are reported on the card, not fixed here. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d8ea5e commit f76c622

4 files changed

Lines changed: 154 additions & 6 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
Setup → Users now opens on the "All Users" list. Before this, the console opened `sys_user`'s first declared list view, "My Profile". That view is filtered to the caller with a page size of 1, so an administrator saw one row, themselves, and nothing said the rest of the organization was one tab away.
6+
7+
Clause-②: no
8+
9+
- The Setup app's `nav_users` entry now sets `viewName: 'all_users'`. The key is the one the spec already declares on an object navigation item, and the console honours it. No new key, no `listViews` reorder, and no view is removed.
10+
- "My Profile" (`me`) is still a tab on the Users page. The Account app's profile entry is unchanged: it is the `account:profile_card` component, which reads the signed-in user from the session, not this list view. The `me` view's code comment no longer says the Account app surfaces it.
11+
- ⛔ No schema, parse, export or accept-set change.

‎packages/platform-objects/src/apps/setup-nav.contributions.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,13 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
6868
group: 'group_people_org',
6969
priority: BASE_PRIORITY,
7070
items: [
71-
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' },
71+
// `viewName` names the tenant-wide list (#21960). With no view named,
72+
// the console opens the object's FIRST declared list view, and
73+
// `sys_user` declares `me` first — a one-row view of the caller — so an
74+
// administrator landed on themselves and read "this organization has
75+
// one user". `me` stays a tab in the view switcher; the Account app's
76+
// profile entry is the `account:profile_card` component, not that view.
77+
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', viewName: 'all_users', icon: 'user' },
7278
// The ACTIVE organization's record page (Members / Invitations / Teams
7379
// tabs with the better-auth row actions), rendered inside the app shell
7480
// (cloud ADR-0081 D3). `{current_org_id}` resolves from the session's active
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Setup → Users opens on the tenant-wide user list, and the Account app's
4+
// profile entry is not that list (#21960).
5+
//
6+
// The defect: `nav_users` named no view, and the console opens the FIRST
7+
// declared list view of an object when the route names none (objectui
8+
// `ObjectView`: URL view id, then `?view=`, then a view marked `isDefault`,
9+
// then `views[0]`). `sys_user` declares `me` first — `id = {current_user_id}`,
10+
// page size 1 — so an administrator's Users page showed one row, themselves.
11+
//
12+
// The repair uses the key the spec already declares on an object navigation
13+
// item, `viewName` ("Default list view to open"). The console honours it: its
14+
// `NavigationRenderer.resolveHref` emits `<object>/view/<viewName>`, and
15+
// `resolveViewId` matches the short name against the qualified view ids. Read
16+
// at the objectui commit `.objectui-sha` pins
17+
// (0abd4f9f8769fc4c19ad2f96707684876f74c09f). The same function gives
18+
// `recordId` and then `filters` precedence over `viewName`, which is why the
19+
// entry must carry neither.
20+
//
21+
// The profile half: the Account app's profile entry is the
22+
// `account:profile_card` component, which reads the signed-in user from the
23+
// session (objectui `ProfilePage`, `useAuth()`), not a `sys_user` list view.
24+
// No Account entry routes to `sys_user`, so the view order on `sys_user` is not
25+
// what puts a user on their own profile.
26+
import { describe, it, expect } from 'vitest';
27+
import { NavigationContributionSchema } from '@objectstack/spec/ui';
28+
29+
import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js';
30+
import { ACCOUNT_APP } from './account.app.js';
31+
import { SysUser } from '../identity/sys-user.object.js';
32+
33+
type NavItem = {
34+
id?: string;
35+
type?: string;
36+
objectName?: string;
37+
viewName?: string;
38+
recordId?: string;
39+
filters?: Record<string, unknown>;
40+
componentRef?: string;
41+
children?: NavItem[];
42+
};
43+
44+
type ListView = {
45+
name?: string;
46+
filter?: unknown;
47+
pagination?: { pageSize?: number };
48+
};
49+
50+
const usersContribution = () => {
51+
const found = SETUP_NAV_CONTRIBUTIONS.find((c) =>
52+
(c.items ?? []).some((i) => (i as NavItem).id === 'nav_users'),
53+
);
54+
expect(found, 'Setup lost its nav_users entry').toBeDefined();
55+
return found!;
56+
};
57+
58+
const usersEntry = (): NavItem =>
59+
(usersContribution().items ?? []).find((i) => (i as NavItem).id === 'nav_users') as NavItem;
60+
61+
const userListViews = (): Record<string, ListView> =>
62+
((SysUser as { listViews?: Record<string, ListView> }).listViews ?? {});
63+
64+
/** Every Account nav item, depth-first. */
65+
const accountItems = (): NavItem[] => {
66+
const out: NavItem[] = [];
67+
const walk = (items: NavItem[] = []) => {
68+
for (const item of items) {
69+
if (!item) continue;
70+
out.push(item);
71+
if (Array.isArray(item.children)) walk(item.children);
72+
}
73+
};
74+
walk((ACCOUNT_APP.navigation ?? []) as NavItem[]);
75+
return out;
76+
};
77+
78+
describe('Setup → Users opens on the tenant-wide user list (#21960)', () => {
79+
it('names the `all_users` list view of `sys_user`', () => {
80+
expect(usersContribution().group).toBe('group_people_org');
81+
expect(usersEntry()).toMatchObject({
82+
type: 'object',
83+
objectName: 'sys_user',
84+
viewName: 'all_users',
85+
});
86+
});
87+
88+
it('carries neither `recordId` nor `filters`, which the console honours ahead of `viewName`', () => {
89+
expect(usersEntry().recordId).toBeUndefined();
90+
expect(usersEntry().filters).toBeUndefined();
91+
});
92+
93+
it('names a view `sys_user` declares, under that same name', () => {
94+
const view = userListViews()[usersEntry().viewName ?? ''];
95+
expect(view, `sys_user declares no list view "${usersEntry().viewName}"`).toBeDefined();
96+
expect(view.name).toBe(usersEntry().viewName);
97+
});
98+
99+
it('names a view that is not scoped to the caller', () => {
100+
const view = userListViews()[usersEntry().viewName ?? ''];
101+
expect(view.filter).toBeUndefined();
102+
expect(JSON.stringify(view)).not.toContain('{current_user_id}');
103+
expect(view.pagination?.pageSize ?? 0).toBeGreaterThan(1);
104+
});
105+
106+
it('parses as a NavigationContribution, so the key reaches the served app', () => {
107+
const parsed = NavigationContributionSchema.safeParse(usersContribution());
108+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
109+
const item = (parsed.data?.items ?? []).find((i) => (i as NavItem).id === 'nav_users') as NavItem;
110+
expect(item.viewName).toBe('all_users');
111+
});
112+
});
113+
114+
describe('the Account app profile entry is not a `sys_user` list view (#21960)', () => {
115+
it('opens on the `account:profile_card` component, as its first entry', () => {
116+
const first = ((ACCOUNT_APP.navigation ?? []) as NavItem[])[0];
117+
expect(first).toMatchObject({
118+
id: 'nav_account_profile',
119+
type: 'component',
120+
componentRef: 'account:profile_card',
121+
});
122+
});
123+
124+
it('routes no entry to `sys_user`', () => {
125+
const toUser = accountItems().filter((i) => i.objectName === 'sys_user').map((i) => i.id);
126+
expect(toUser).toEqual([]);
127+
});
128+
});

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -601,11 +601,14 @@ export const SysUser = ObjectSchema.create({
601601
],
602602

603603
listViews: {
604-
// Self-service profile entry — surfaced by the Account App so every
605-
// authenticated user can view / edit their own basic profile (name,
606-
// email, avatar). Filtered to a single row (the caller) via the
607-
// `{current_user_id}` template variable; RLS additionally enforces
608-
// that non-admins cannot read other users' rows.
604+
// The caller's own row, as a tab of this object's view switcher.
605+
// Filtered to a single row via the `{current_user_id}` template
606+
// variable; RLS additionally enforces that non-admins cannot read other
607+
// users' rows. No navigation entry names this view: the Account App's
608+
// profile entry is the `account:profile_card` component (`account.app.ts`),
609+
// which reads the signed-in user from the session, not from this list.
610+
// Declared first, it is the tab the console opens when a route names no
611+
// view — so Setup's Users entry names `all_users` explicitly (#21960).
609612
me: {
610613
type: 'grid',
611614
name: 'me',

0 commit comments

Comments
 (0)