diff --git a/.changeset/21972-caller-scoped-views-not-first.md b/.changeset/21972-caller-scoped-views-not-first.md new file mode 100644 index 00000000000..fc2859c9ab7 --- /dev/null +++ b/.changeset/21972-caller-scoped-views-not-first.md @@ -0,0 +1,13 @@ +--- +"@objectstack/platform-objects": patch +--- + +Setup's identity pages open on the tenant-wide list, not on the administrator's own rows. Before this, Setup → API Keys, Sessions, OAuth Applications, Identity Links and User Preferences opened each object's first declared list view, which was the caller-scoped "My …" view (`user_id = {current_user_id}`), so an administrator saw only their own keys, sessions, applications, links and preferences. + +Clause-②: no + +- On `sys_api_key`, `sys_session`, `sys_oauth_application`, `sys_account`, `sys_user_preference` and `sys_user`, the unscoped "All" view (`all_keys`, `all_sessions`, `all_apps`, `all_links`, `all_preferences`, `all_users`) is now declared first, and the caller-scoped view (`mine`, `me`) second. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens the "All" view. No view is added, removed or changed. +- The Setup entries `nav_api_keys`, `nav_sessions`, `nav_oauth_apps`, `nav_accounts` and `nav_user_preferences` now name that view with `viewName`, as `nav_users` already did. The Account app's Linked Accounts entry (`nav_account_linked`) now names `mine`, like the other Account entries, so neither app depends on the declared order. +- The "My …" views are still tabs on each page. The declared order decides which view opens, not which rows a caller may read: row-level security still scopes a member's rows. +- The generated translation bundles follow the new view order. No translated text changed. +- ⛔ No schema, parse, export or accept-set change. diff --git a/.changeset/21972-record-shares-all-first.md b/.changeset/21972-record-shares-all-first.md new file mode 100644 index 00000000000..b1bef852c37 --- /dev/null +++ b/.changeset/21972-record-shares-all-first.md @@ -0,0 +1,12 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +Setup → Record Shares opens on every share, not on the shares granted to the administrator. Before this, the entry named no view, and `sys_record_share` declared the caller-scoped "Granted to Me" view (`recipient_id = {current_user_id}`) first. + +Clause-②: no + +- `sys_record_share` now declares its unscoped "All" view (`all_shares`) first. "Granted to Me" and "Granted by Me" follow it, still as tabs. No view is added, removed or changed. +- The Setup entry `nav_record_shares` now names `all_shares` with `viewName`, so it does not depend on the declared order. +- The generated translation bundles follow the new view order. No translated text changed. +- ⛔ No schema, parse, export or accept-set change. diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index 87334cef729..8f078a2b376 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -154,10 +154,14 @@ export const ACCOUNT_APP: App = { expanded: true, children: [ { + // Names `mine` like every other self-service entry here (#21972): + // `sys_account` no longer declares its caller-scoped view first, + // so an entry naming no view would open the `all_links` tab. id: 'nav_account_linked', type: 'object', label: 'Linked Accounts', objectName: 'sys_account', + viewName: 'mine', icon: 'link-2', requiresObject: 'sys_account', }, 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 new file mode 100644 index 00000000000..baf25e109db --- /dev/null +++ b/packages/platform-objects/src/apps/caller-scoped-first-list-view.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A caller-scoped list view is never an object's FIRST, and every entry that +// wants one names it (#21972 — the family #21960 opened with Setup → Users). +// +// The mechanism: when a route names no view, the console opens the object's +// first declared list view (objectui `ObjectView`: the URL view id, then +// `?view=`, then a view marked `isDefault`, then `views[0]`; these objects mark +// none). So a `{current_user_id}`-filtered view declared first is what an +// administrator lands on from a Setup entry that names no view — their own +// rows, on the page meant for administering everyone's — and what every +// bare-object door opens (the record page's object breadcrumb, the object +// switcher). +// +// Both pins are DERIVED from this package's navigation, never from a hand list +// of objects: the population is every `type: 'object'` entry of +// `SETUP_NAV_CONTRIBUTIONS` and of `ACCOUNT_APP`, and the object each names, +// resolved from this package's own exports. +// +// (a) every object such an entry names declares a first list view that is +// not caller-scoped; +// (b) every such entry whose object declares a caller-scoped view names a +// view (`viewName`) the object declares under that name — and a Setup +// entry names one that is not caller-scoped. +// +// "Caller-scoped" is read off the view itself: `{current_user_id}` anywhere in +// it (the `${current_user_id}` spelling contains it too). That token is +// presentation scope, not access: which rows a caller may read is RLS's +// decision, so the declared order decides which view opens and nothing else. +// +// Reach, stated so a green run is not read wider than it is: +// - Setup entries a PLUGIN contributes at runtime (`nav_record_shares` from +// `@objectstack/plugin-sharing`, `nav_approval_requests` from +// `@objectstack/plugin-approvals`, …) are not in this population: they are +// not visible from here, and this package cannot import the plugins (they +// depend on it). +// - An object an entry names that this package does not declare cannot be +// judged by (a). (b) therefore requires its entry to name a view, and the +// set is pinned exactly below so it cannot grow unnoticed. +// - An object that declares ONLY caller-scoped list views cannot meet (a) +// without a new view, which is not this file's to add. That set is pinned +// exactly too, and (b) holds every entry naming it to a named view. +import { describe, it, expect } from 'vitest'; +import { AppSchema, NavigationContributionSchema } from '@objectstack/spec/ui'; + +import * as PlatformObjects from '../index.js'; +import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; +import { ACCOUNT_APP } from './account.app.js'; + +type NavItem = { + id?: string; + type?: string; + objectName?: string; + viewName?: string; + children?: NavItem[]; +}; + +type ListView = { name?: string }; + +type ObjectDef = { + name: string; + fields: Record; + listViews?: Record; +}; + +type Entry = { + id: string; + surface: 'setup' | 'account'; + objectName: string; + viewName?: string; +}; + +const CALLER_TOKEN = '{current_user_id}'; + +const isCallerScoped = (view: unknown): boolean => + JSON.stringify(view ?? {}).includes(CALLER_TOKEN); + +/** Every `type: 'object'` nav item under `items`, depth-first. */ +function objectEntries(items: unknown[] | undefined, surface: Entry['surface']): Entry[] { + const out: Entry[] = []; + const walk = (list: unknown[] | undefined) => { + for (const raw of list ?? []) { + const item = raw as NavItem; + if (!item) continue; + if (item.type === 'object') { + // Thrown, never skipped: an entry this walk cannot read is an entry + // neither pin judges. + if (typeof item.id !== 'string' || typeof item.objectName !== 'string') { + throw new Error(`a ${surface} object entry without an id or objectName: ${JSON.stringify(item)}`); + } + out.push({ id: item.id, surface, objectName: item.objectName, viewName: item.viewName }); + } + if (Array.isArray(item.children)) walk(item.children); + } + }; + walk(items); + return out; +} + +const ENTRIES: Entry[] = [ + ...SETUP_NAV_CONTRIBUTIONS.flatMap((c) => objectEntries(c.items as unknown[], 'setup')), + ...objectEntries(ACCOUNT_APP.navigation as unknown[], 'account'), +]; + +/** This package's objects by name. Two different definitions under one name is a failure, not a pick. */ +const CATALOGUE: Map = (() => { + const byName = new Map(); + for (const value of Object.values(PlatformObjects)) { + const def = value as unknown as ObjectDef; + if (!def || typeof def !== 'object' || typeof def.name !== 'string') continue; + if (!def.fields || typeof def.fields !== 'object') continue; + const seen = byName.get(def.name); + if (seen && seen !== def) throw new Error(`two different object definitions export the name ${def.name}`); + byName.set(def.name, def); + } + return byName; +})(); + +const listViewsOf = (name: string): Record => CATALOGUE.get(name)?.listViews ?? {}; + +const NAMED_OBJECTS = [...new Set(ENTRIES.map((e) => e.objectName))].sort(); +const RESOLVED = NAMED_OBJECTS.filter((name) => CATALOGUE.has(name)); +const UNRESOLVED = NAMED_OBJECTS.filter((name) => !CATALOGUE.has(name)); + +/** Resolved objects whose every declared list view is caller-scoped. */ +const ONLY_CALLER_SCOPED = RESOLVED.filter((name) => { + const views = Object.values(listViewsOf(name)); + return views.length > 0 && views.every(isCallerScoped); +}); + +/** The objects (a) judges: resolved, and declaring no list view or at least one unscoped one. */ +const JUDGED_BY_A = RESOLVED.filter((name) => !ONLY_CALLER_SCOPED.includes(name)); + +/** The entries (b) judges: the object declares a caller-scoped view, or cannot be read from here. */ +const JUDGED_BY_B = ENTRIES.filter( + (e) => !CATALOGUE.has(e.objectName) || Object.values(listViewsOf(e.objectName)).some(isCallerScoped), +); + +describe('the population is derived from Setup and Account navigation (#21972)', () => { + it('reaches object entries in both apps', () => { + expect(ENTRIES.filter((e) => e.surface === 'setup').length).toBeGreaterThan(0); + expect(ENTRIES.filter((e) => e.surface === 'account').length).toBeGreaterThan(0); + }); + + // 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']) { + 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); + } + }); + + 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`. + expect(UNRESOLVED).toEqual(['sys_inbox_message']); + }); + + it('finds exactly these named objects declaring only caller-scoped list views', () => { + // `sys_member` declares `mine` alone; only the Account app names it, with + // `viewName: 'mine'`. Meeting (a) would take a new unscoped view, which is + // a decision this card reported rather than made. + expect(ONLY_CALLER_SCOPED).toEqual(['sys_member']); + }); +}); + +describe('(a) a named object declares a first list view that is not caller-scoped (#21972)', () => { + it.each(JUDGED_BY_A)('%s', (name) => { + const [firstName, firstView] = Object.entries(listViewsOf(name))[0] ?? []; + expect( + isCallerScoped(firstView), + `${name} declares the caller-scoped list view "${firstName}" first, so a route naming no view opens the caller's own rows`, + ).toBe(false); + }); +}); + +describe('(b) an entry whose object declares a caller-scoped view names its view (#21972)', () => { + it.each(JUDGED_BY_B.map((e) => [`${e.surface} ${e.id} → ${e.objectName}`, e] as const))('%s', (_label, entry) => { + expect(entry.viewName, `${entry.id} names no view, so it opens whatever ${entry.objectName} declares first`).toBeTypeOf( + 'string', + ); + if (!CATALOGUE.has(entry.objectName)) return; + const view = listViewsOf(entry.objectName)[entry.viewName!]; + expect(view, `${entry.objectName} declares no list view "${entry.viewName}"`).toBeDefined(); + expect(view.name).toBe(entry.viewName); + if (entry.surface === 'setup') { + expect(isCallerScoped(view), `Setup's ${entry.id} names the caller-scoped view "${entry.viewName}"`).toBe(false); + } + }); +}); + +describe('the named views reach the served apps (#21972)', () => { + it('every Setup contribution parses, keeping each object entry its `viewName`', () => { + for (const contribution of SETUP_NAV_CONTRIBUTIONS) { + const parsed = NavigationContributionSchema.safeParse(contribution); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + const kept = objectEntries(parsed.data?.items as unknown[], 'setup'); + expect(kept).toEqual(objectEntries(contribution.items as unknown[], 'setup')); + } + }); + + it('the Account app parses, keeping each object entry its `viewName`', () => { + const parsed = AppSchema.safeParse(ACCOUNT_APP); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + const kept = objectEntries(parsed.data?.navigation as unknown[], 'account'); + expect(kept).toEqual(objectEntries(ACCOUNT_APP.navigation as unknown[], 'account')); + }); +}); diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 72a3adcc106..17f1725c3ca 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -70,10 +70,11 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ items: [ // `viewName` names the tenant-wide list (#21960). With no view named, // the console opens the object's FIRST declared list view, and - // `sys_user` declares `me` first — a one-row view of the caller — so an - // administrator landed on themselves and read "this organization has - // one user". `me` stays a tab in the view switcher; the Account app's - // profile entry is the `account:profile_card` component, not that view. + // `sys_user` used to declare `me` first — a one-row view of the caller — + // so an administrator landed on themselves and read "this organization + // has one user". Every object entry here names its unscoped view the + // same way (#21972), so no entry depends on the declared order; `me` + // stays a tab in the view switcher. { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', viewName: 'all_users', icon: 'user' }, // The ACTIVE organization's record page (Members / Invitations / Teams // tabs with the better-auth row actions), rendered inside the app shell @@ -105,7 +106,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // and Sharing Rules / Record Shares by @objectstack/plugin-sharing // (ADR-0029 K2). Only API Keys (sys_api_key, an identity object owned by // plugin-auth) remains a platform-objects base entry here. - { id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', icon: 'key', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', viewName: 'all_keys', icon: 'key', requiredPermissions: ['manage_platform_settings'] }, ], }, // group_approvals is contributed by @objectstack/plugin-approvals, which owns @@ -142,7 +143,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ items: [ // Audit Logs (sys_audit_log) is contributed by @objectstack/plugin-audit // which now owns it (ADR-0029 K2). - { id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', icon: 'monitor' }, + { id: 'nav_sessions', type: 'object', label: 'Sessions', objectName: 'sys_session', viewName: 'all_sessions', icon: 'monitor' }, { id: 'nav_notifications', type: 'object', label: 'Notification Events', objectName: 'sys_notification', viewName: 'recent', icon: 'bell', requiresObject: 'sys_notification' }, ], }, @@ -151,7 +152,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ group: 'group_advanced', priority: BASE_PRIORITY, items: [ - { id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', icon: 'app-window' }, + { id: 'nav_oauth_apps', type: 'object', label: 'OAuth Applications', objectName: 'sys_oauth_application', viewName: 'all_apps', icon: 'app-window' }, // No `nav_jwks` here (#7544). `sys_jwks` is the environment's JWT SIGNING // KEY store (`private_key` — private key material), and it declares // `enable.apiEnabled: false` / `apiMethods: []`, so the generic data API @@ -182,8 +183,8 @@ 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', icon: 'link-2' }, - { id: 'nav_user_preferences', type: 'object', label: 'User Preferences', objectName: 'sys_user_preference', icon: 'sliders' }, + { 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' }, ], }, ]; diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 0d214ad760d..27893e57892 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -122,12 +122,12 @@ export const enObjects: NonNullable = { } }, _views: { - me: { - label: "My Profile" - }, all_users: { label: "All Users" }, + me: { + label: "My Profile" + }, unverified: { label: "Unverified" }, @@ -376,12 +376,12 @@ export const enObjects: NonNullable = { } }, _views: { - mine: { - label: "My Sessions" - }, all_sessions: { label: "All" }, + mine: { + label: "My Sessions" + }, revoked: { label: "Revoked" } @@ -460,14 +460,14 @@ export const enObjects: NonNullable = { } }, _views: { + all_links: { + label: "All" + }, mine: { label: "My Links" }, by_provider: { label: "By Provider" - }, - all_links: { - label: "All" } }, _actions: { @@ -1022,6 +1022,9 @@ export const enObjects: NonNullable = { } }, _views: { + all_keys: { + label: "All" + }, mine: { label: "My Keys" }, @@ -1030,9 +1033,6 @@ export const enObjects: NonNullable = { }, revoked: { label: "Revoked" - }, - all_keys: { - label: "All" } }, _actions: { @@ -1225,14 +1225,14 @@ export const enObjects: NonNullable = { } }, _views: { + all_preferences: { + label: "All" + }, mine: { label: "My Preferences" }, by_user: { label: "By User" - }, - all_preferences: { - label: "All" } } }, @@ -1386,6 +1386,9 @@ export const enObjects: NonNullable = { } }, _views: { + all_apps: { + label: "All" + }, mine: { label: "My Applications" }, @@ -1394,9 +1397,6 @@ export const enObjects: NonNullable = { }, disabled_apps: { label: "Disabled" - }, - all_apps: { - label: "All" } }, _actions: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index c5e6f49414d..f1d2a51e005 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -122,12 +122,12 @@ export const esESObjects: NonNullable = { } }, _views: { - me: { - label: "Mi perfil" - }, all_users: { label: "Todos los usuarios" }, + me: { + label: "Mi perfil" + }, unverified: { label: "No verificados" }, @@ -376,12 +376,12 @@ export const esESObjects: NonNullable = { } }, _views: { - mine: { - label: "Mis sesiones" - }, all_sessions: { label: "Todas" }, + mine: { + label: "Mis sesiones" + }, revoked: { label: "Revocadas" } @@ -460,14 +460,14 @@ export const esESObjects: NonNullable = { } }, _views: { + all_links: { + label: "Todos" + }, mine: { label: "Mis enlaces" }, by_provider: { label: "Por proveedor" - }, - all_links: { - label: "Todos" } }, _actions: { @@ -1022,6 +1022,9 @@ export const esESObjects: NonNullable = { } }, _views: { + all_keys: { + label: "Todas" + }, mine: { label: "Mis claves" }, @@ -1030,9 +1033,6 @@ export const esESObjects: NonNullable = { }, revoked: { label: "Revocado" - }, - all_keys: { - label: "Todas" } }, _actions: { @@ -1225,14 +1225,14 @@ export const esESObjects: NonNullable = { } }, _views: { + all_preferences: { + label: "Todas" + }, mine: { label: "Mis preferencias" }, by_user: { label: "Por usuario" - }, - all_preferences: { - label: "Todas" } } }, @@ -1386,6 +1386,9 @@ export const esESObjects: NonNullable = { } }, _views: { + all_apps: { + label: "Todas" + }, mine: { label: "Mis aplicaciones" }, @@ -1394,9 +1397,6 @@ export const esESObjects: NonNullable = { }, disabled_apps: { label: "Deshabilitado" - }, - all_apps: { - label: "Todas" } }, _actions: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 0022b513d0a..b6ad2c71b62 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -122,12 +122,12 @@ export const jaJPObjects: NonNullable = { } }, _views: { - me: { - label: "マイプロフィール" - }, all_users: { label: "すべてのユーザー" }, + me: { + label: "マイプロフィール" + }, unverified: { label: "未確認" }, @@ -376,12 +376,12 @@ export const jaJPObjects: NonNullable = { } }, _views: { - mine: { - label: "自分のセッション" - }, all_sessions: { label: "すべて" }, + mine: { + label: "自分のセッション" + }, revoked: { label: "取り消し済み" } @@ -460,14 +460,14 @@ export const jaJPObjects: NonNullable = { } }, _views: { + all_links: { + label: "すべて" + }, mine: { label: "自分の連携" }, by_provider: { label: "プロバイダー別" - }, - all_links: { - label: "すべて" } }, _actions: { @@ -1022,6 +1022,9 @@ export const jaJPObjects: NonNullable = { } }, _views: { + all_keys: { + label: "すべて" + }, mine: { label: "自分のキー" }, @@ -1030,9 +1033,6 @@ export const jaJPObjects: NonNullable = { }, revoked: { label: "失効済み" - }, - all_keys: { - label: "すべて" } }, _actions: { @@ -1225,14 +1225,14 @@ export const jaJPObjects: NonNullable = { } }, _views: { + all_preferences: { + label: "すべて" + }, mine: { label: "自分の設定" }, by_user: { label: "ユーザー別" - }, - all_preferences: { - label: "すべて" } } }, @@ -1386,6 +1386,9 @@ export const jaJPObjects: NonNullable = { } }, _views: { + all_apps: { + label: "すべて" + }, mine: { label: "自分のアプリケーション" }, @@ -1394,9 +1397,6 @@ export const jaJPObjects: NonNullable = { }, disabled_apps: { label: "無効" - }, - all_apps: { - label: "すべて" } }, _actions: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 6dbf1a1f3fe..873863ae94c 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -122,12 +122,12 @@ export const zhCNObjects: NonNullable = { } }, _views: { - me: { - label: "我的资料" - }, all_users: { label: "全部用户" }, + me: { + label: "我的资料" + }, unverified: { label: "未验证" }, @@ -376,12 +376,12 @@ export const zhCNObjects: NonNullable = { } }, _views: { - mine: { - label: "我的会话" - }, all_sessions: { label: "全部" }, + mine: { + label: "我的会话" + }, revoked: { label: "已撤销" } @@ -460,14 +460,14 @@ export const zhCNObjects: NonNullable = { } }, _views: { + all_links: { + label: "全部" + }, mine: { label: "我的链接" }, by_provider: { label: "按提供方" - }, - all_links: { - label: "全部" } }, _actions: { @@ -1022,6 +1022,9 @@ export const zhCNObjects: NonNullable = { } }, _views: { + all_keys: { + label: "全部" + }, mine: { label: "我的密钥" }, @@ -1030,9 +1033,6 @@ export const zhCNObjects: NonNullable = { }, revoked: { label: "已撤销" - }, - all_keys: { - label: "全部" } }, _actions: { @@ -1225,14 +1225,14 @@ export const zhCNObjects: NonNullable = { } }, _views: { + all_preferences: { + label: "全部" + }, mine: { label: "我的偏好" }, by_user: { label: "按用户" - }, - all_preferences: { - label: "全部" } } }, @@ -1386,6 +1386,9 @@ export const zhCNObjects: NonNullable = { } }, _views: { + all_apps: { + label: "全部" + }, mine: { label: "我的应用" }, @@ -1394,9 +1397,6 @@ export const zhCNObjects: NonNullable = { }, disabled_apps: { label: "已禁用" - }, - all_apps: { - label: "全部" } }, _actions: { diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 55baacb20f1..3398eaf68f8 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -78,6 +78,19 @@ 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`. + all_links: { + type: 'grid', + name: 'all_links', + label: 'All', + data: { provider: 'object', object: 'sys_account' }, + columns: ['provider_id', 'user_id', 'account_id', 'created_at', 'updated_at'], + sort: [{ field: 'created_at', order: 'desc' }], + pagination: { pageSize: 100 }, + }, mine: { type: 'grid', name: 'mine', @@ -98,15 +111,6 @@ export const SysAccount = ObjectSchema.create({ grouping: { fields: [{ field: 'provider_id', order: 'asc', collapsed: false }] }, pagination: { pageSize: 100 }, }, - all_links: { - type: 'grid', - name: 'all_links', - label: 'All', - data: { provider: 'object', object: 'sys_account' }, - columns: ['provider_id', 'user_id', 'account_id', 'created_at', 'updated_at'], - sort: [{ field: 'created_at', order: 'desc' }], - pagination: { pageSize: 100 }, - }, }, fields: { diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index e889bb45f2e..628b42328f0 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -119,6 +119,19 @@ export const SysApiKey = 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_api_keys` names this view; the Account app's entry + // names `mine`. + all_keys: { + type: 'grid', + name: 'all_keys', + label: 'All', + data: { provider: 'object', object: 'sys_api_key' }, + columns: ['name', 'prefix', 'user_id', 'active_organization_id', 'expires_at', 'last_used_at', 'revoked'], + sort: [{ field: 'created_at', order: 'desc' }], + pagination: { pageSize: 50 }, + }, mine: { type: 'grid', name: 'mine', @@ -154,15 +167,6 @@ export const SysApiKey = ObjectSchema.create({ sort: [{ field: 'updated_at', order: 'desc' }], pagination: { pageSize: 50 }, }, - all_keys: { - type: 'grid', - name: 'all_keys', - label: 'All', - data: { provider: 'object', object: 'sys_api_key' }, - columns: ['name', 'prefix', 'user_id', 'active_organization_id', 'expires_at', 'last_used_at', 'revoked'], - sort: [{ field: 'created_at', order: 'desc' }], - pagination: { pageSize: 50 }, - }, }, fields: { diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index ed473ffa0e8..1ff2c3f3558 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -208,6 +208,19 @@ export const SysOauthApplication = 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_oauth_apps` names this view; the Account app's entry + // names `mine`. + all_apps: { + type: 'grid', + name: 'all_apps', + label: 'All', + data: { provider: 'object', object: 'sys_oauth_application' }, + columns: ['name', 'client_id', 'type', 'disabled', 'created_at'], + sort: [{ field: 'name', order: 'asc' }], + pagination: { pageSize: 50 }, + }, mine: { type: 'grid', name: 'mine', @@ -216,8 +229,8 @@ export const SysOauthApplication = ObjectSchema.create({ columns: ['name', 'client_id', 'type', 'disabled', 'created_at'], // Self-service Account view — scope to the signed-in user's own // registrations so they don't see other developers' apps. Admins - // get the unfiltered `active` / `disabled_apps` / `all_apps` views - // via the Setup → OAuth Applications nav. + // get the unfiltered `all_apps` / `active` / `disabled_apps` views + // via the Setup → OAuth Applications nav, which names `all_apps`. filter: [{ field: 'user_id', operator: 'equals', value: '{current_user_id}' }], sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, @@ -242,15 +255,6 @@ export const SysOauthApplication = ObjectSchema.create({ sort: [{ field: 'updated_at', order: 'desc' }], pagination: { pageSize: 50 }, }, - all_apps: { - type: 'grid', - name: 'all_apps', - label: 'All', - data: { provider: 'object', object: 'sys_oauth_application' }, - columns: ['name', 'client_id', 'type', 'disabled', 'created_at'], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, }, fields: { diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index a6153c2b1d3..4e46fda0773 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -111,6 +111,20 @@ export const SysSession = ObjectSchema.create({ // the fields would be written and still readable nowhere, which is the same // declared-≠-enforced gap one layer up. 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_sessions` names this view; the Account app's entry + // names `mine`. + all_sessions: { + type: 'grid', + name: 'all_sessions', + label: 'All', + data: { provider: 'object', object: 'sys_session' }, + columns: ['user_id', 'ip_address', 'active_organization_id', 'created_at', 'expires_at'], + filter: [{ field: 'revoked_at', operator: 'is_null' }], + sort: [{ field: 'created_at', order: 'desc' }], + pagination: { pageSize: 50 }, + }, mine: { type: 'grid', name: 'mine', @@ -124,16 +138,6 @@ export const SysSession = ObjectSchema.create({ sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, - all_sessions: { - type: 'grid', - name: 'all_sessions', - label: 'All', - data: { provider: 'object', object: 'sys_session' }, - columns: ['user_id', 'ip_address', 'active_organization_id', 'created_at', 'expires_at'], - filter: [{ field: 'revoked_at', operator: 'is_null' }], - sort: [{ field: 'created_at', order: 'desc' }], - pagination: { pageSize: 50 }, - }, revoked: { type: 'grid', name: 'revoked', 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 49874eceea5..c5ef66133c9 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -34,6 +34,18 @@ export const SysUserPreference = ObjectSchema.create({ highlightFields: ['user_id', 'key'], 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. + all_preferences: { + type: 'grid', + name: 'all_preferences', + label: 'All', + data: { provider: 'object', object: 'sys_user_preference' }, + columns: ['user_id', 'key', 'created_at', 'updated_at'], + sort: [{ field: 'updated_at', order: 'desc' }], + pagination: { pageSize: 100 }, + }, mine: { type: 'grid', name: 'mine', @@ -54,15 +66,6 @@ export const SysUserPreference = ObjectSchema.create({ grouping: { fields: [{ field: 'user_id', order: 'asc', collapsed: true }] }, pagination: { pageSize: 200 }, }, - all_preferences: { - type: 'grid', - name: 'all_preferences', - label: 'All', - data: { provider: 'object', object: 'sys_user_preference' }, - columns: ['user_id', 'key', 'created_at', 'updated_at'], - sort: [{ field: 'updated_at', order: 'desc' }], - pagination: { pageSize: 100 }, - }, }, fields: { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 6a3362a2f05..a8d755bf535 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -601,14 +601,28 @@ export const SysUser = ObjectSchema.create({ ], listViews: { + // [#21972] Declared FIRST on purpose: a route that names no view opens the + // first declared list view — Setup's Users entry, which names this view + // explicitly (#21960), and the bare-object doors (the record page's + // object breadcrumb, the object switcher), which name none. So the + // caller-scoped `me` is never first. + all_users: { + type: 'grid', + name: 'all_users', + label: 'All Users', + data: { provider: 'object', object: 'sys_user' }, + columns: ['name', 'email', 'phone_number', 'email_verified', 'source', 'two_factor_enabled', 'created_at'], + sort: [{ field: 'name', order: 'asc' }], + pagination: { pageSize: 50 }, + }, // The caller's own row, as a tab of this object's view switcher. // Filtered to a single row via the `{current_user_id}` template - // variable; RLS additionally enforces that non-admins cannot read other - // users' rows. No navigation entry names this view: the Account App's - // profile entry is the `account:profile_card` component (`account.app.ts`), - // which reads the signed-in user from the session, not from this list. - // Declared first, it is the tab the console opens when a route names no - // view — so Setup's Users entry names `all_users` explicitly (#21960). + // variable — presentation scope only; which other users' rows a caller + // may read is RLS's decision (`member_default` admits the caller's own + // row and their organization's users). No navigation entry names this + // view: the Account App's profile entry is the `account:profile_card` + // component (`account.app.ts`), which reads the signed-in user from the + // session, not from this list. me: { type: 'grid', name: 'me', @@ -619,15 +633,6 @@ export const SysUser = ObjectSchema.create({ sort: [{ field: 'name', order: 'asc' }], pagination: { pageSize: 1 }, }, - all_users: { - type: 'grid', - name: 'all_users', - label: 'All Users', - data: { provider: 'object', object: 'sys_user' }, - columns: ['name', 'email', 'phone_number', 'email_verified', 'source', 'two_factor_enabled', 'created_at'], - sort: [{ field: 'name', order: 'asc' }], - pagination: { pageSize: 50 }, - }, unverified: { type: 'grid', name: 'unverified', diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index c7151e3a7f1..a8a16f177ef 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -44,6 +44,19 @@ export const SysRecordShare = ObjectSchema.create({ highlightFields: ['object_name', 'record_id', 'recipient_id', 'access_level', 'source'], listViews: { + // [#21972] Declared FIRST on purpose: a route that names no view opens the + // first declared list view, so a caller-scoped view (`granted_to_me`, + // `granted_by_me`) is never first. Setup's `nav_record_shares` names this + // view. + all_shares: { + type: 'grid', + name: 'all_shares', + label: 'All', + data: { provider: 'object', object: 'sys_record_share' }, + columns: ['object_name', 'record_id', 'recipient_type', 'recipient_id', 'access_level', 'source', 'created_at'], + sort: [{ field: 'created_at', order: 'desc' }], + pagination: { pageSize: 100 }, + }, granted_to_me: { type: 'grid', name: 'granted_to_me', @@ -99,15 +112,6 @@ export const SysRecordShare = ObjectSchema.create({ sort: [{ field: 'source_id', order: 'asc' }, { field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, - all_shares: { - type: 'grid', - name: 'all_shares', - label: 'All', - data: { provider: 'object', object: 'sys_record_share' }, - columns: ['object_name', 'record_id', 'recipient_type', 'recipient_id', 'access_level', 'source', 'created_at'], - sort: [{ field: 'created_at', order: 'desc' }], - pagination: { pageSize: 100 }, - }, }, fields: { diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index ce6e41ebaf8..97da4ec4ee1 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -588,7 +588,7 @@ export class SharingServicePlugin implements Plugin { priority: 200, items: [ { id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', viewName: 'all_shares', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] }, ], }, ], diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index 94b4d9dcb8b..d7b0eb5e929 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -84,6 +84,9 @@ export const enObjects: NonNullable = { } }, _views: { + all_shares: { + label: "All" + }, granted_to_me: { label: "Granted to Me" }, @@ -98,9 +101,6 @@ export const enObjects: NonNullable = { }, rule_grants: { label: "Rule Grants" - }, - all_shares: { - label: "All" } } }, diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index 9b93bcb7513..a5b48a9ec22 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -84,6 +84,9 @@ export const esESObjects: NonNullable = { } }, _views: { + all_shares: { + label: "Todas" + }, granted_to_me: { label: "Concedidos a mí" }, @@ -98,9 +101,6 @@ export const esESObjects: NonNullable = { }, rule_grants: { label: "Concesiones por regla" - }, - all_shares: { - label: "Todas" } } }, diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index d5611312f0a..14aabe67067 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -84,6 +84,9 @@ export const jaJPObjects: NonNullable = { } }, _views: { + all_shares: { + label: "すべて" + }, granted_to_me: { label: "自分への付与" }, @@ -98,9 +101,6 @@ export const jaJPObjects: NonNullable = { }, rule_grants: { label: "ルール付与" - }, - all_shares: { - label: "すべて" } } }, diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 7e53a72d10d..d716fd31a4b 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -84,6 +84,9 @@ export const zhCNObjects: NonNullable = { } }, _views: { + all_shares: { + label: "全部" + }, granted_to_me: { label: "授予我的" }, @@ -98,9 +101,6 @@ export const zhCNObjects: NonNullable = { }, rule_grants: { label: "规则授权" - }, - all_shares: { - label: "全部" } } },