diff --git a/.changeset/20142-console-page-nav-entries.md b/.changeset/20142-console-page-nav-entries.md new file mode 100644 index 00000000000..b7535988f7c --- /dev/null +++ b/.changeset/20142-console-page-nav-entries.md @@ -0,0 +1,19 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/plugin-audit': patch +--- + +fix(platform-objects,plugin-audit): Setup and Studio navigation entries for the console's Audit Log and Integrations & APIs pages (#20142) + +The console retired its System Hub card wall and its Developer Hub, which had been the only in-app links to several pages, and registered each page under a component-registry key instead. Framework navigation reaches a console page only through a `type: 'component'` item that names such a key, and no item named them, so each page was reachable only by a typed URL. Two entries now name the pages whose capability ships in the open framework: + +| Entry | App / group | `componentRef` | Contributed by | Gate | +| --- | --- | --- | --- | --- | +| `nav_audit_log_browser` ("Audit Log Browser") | Setup / Diagnostics, directly under Audit Logs | `audit:log` | `@objectstack/plugin-audit` | none: it lives and dies with the plugin that owns `sys_audit_log` | +| `nav_integrations` ("Integrations & APIs") | Studio / Developer, after Public Forms | `developer:integrations` | `@objectstack/platform-objects` | none beyond Studio's own `studio.access` | + +**Two audit entries, on purpose.** The existing Audit Logs entry (the `sys_audit_log` object view) stays. It carries the named list views, search, and the actor and tenant rendered as resolved lookups. The new page adds one filterable table whose detail drawer pretty-prints a change's before and after JSON, where the record page shows `old_value` / `new_value` as raw text. Neither surface replaces the other. + +**No entry for the console's AI Approvals page (`ai:approvals`) here.** Under ADR-0029 D7, each capability plugin contributes its own navigation entries into a Setup slot, and the Setup shell does not enumerate capability objects. The AI pending-action queue belongs to the AI capability, whose provider (`@objectstack/service-ai`) ships in Cloud/Enterprise, not in the open framework. Its entry is therefore that capability's to contribute. + +The keys are the ones the console registers at the objectui commit this release's console is built from. Labels ship in all four locales (en, zh-CN, ja-JP, es-ES), with their source hashes recorded. Nothing is removed or renamed, and there is nothing to migrate. diff --git a/content/docs/ui/setup-app.mdx b/content/docs/ui/setup-app.mdx index 7671f751b6e..9247cf072a3 100644 --- a/content/docs/ui/setup-app.mdx +++ b/content/docs/ui/setup-app.mdx @@ -53,7 +53,7 @@ anchors are: | **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` | | **Approvals** (`group_approvals`) | Approvals Inbox (the `approvals:inbox` component) · Requests · Action History · Delegations (OOO) — `plugin-approvals` | | **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 — `plugin-audit` | +| **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` | diff --git a/packages/cli/test/console-page-nav-entries.pin.test.ts b/packages/cli/test/console-page-nav-entries.pin.test.ts new file mode 100644 index 00000000000..cf6c39eee3e --- /dev/null +++ b/packages/cli/test/console-page-nav-entries.pin.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #20142 — the Integrations & APIs page, one of the console pages that lost +// their only in-app link when objectui#10520 retired the Developer Hub, read +// back OVER THE WIRE from the composed Studio app. +// +// --------------------------------------------------------------------------- +// What this pins +// --------------------------------------------------------------------------- +// The card's acceptance: "each entry resolves to its registered page (a +// `componentRef` that is registered)". For the entry +// `@objectstack/platform-objects` declares — `nav_integrations` → +// `developer:integrations`, last in Studio's `group_developer` — this file +// reads it from the served body of `GET /api/v1/meta/:type/:name`, after the +// real `SchemaRegistry` registration and the real per-request filter, so a +// regression anywhere between the declaration and the wire turns it red. +// +// The other two pages are pinned where they belong: +// - `nav_audit_log_browser` → `audit:log` is contributed by +// `@objectstack/plugin-audit`; its ref is pinned beside it in +// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`. +// Importing that plugin from this package's tests would resolve to its +// `dist/`, which `check:test-source-alias` refuses for a new import. +// - `ai:approvals` is not this repository's entry: ADR-0029 D7 has each +// capability plugin contribute its own navigation, and the `ai` +// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise. +// +// "Registered" is judged against the keys MEASURED at the commit +// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d): +// `registerDeveloperComponents.tsx` registers `developer:integrations` and +// `registerSystemComponents.tsx` registers `audit:log`. objectui pins its +// registration half in +// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`. +// +// It lives in `packages/cli/test/` for the reason its sibling +// `connect-agent-both-halves-wire.pin.test.ts` states: `cli` is the one +// workspace package that depends on every piece at once — the app shells +// (`@objectstack/platform-objects`), the registry (`@objectstack/objectql`) and +// the per-request filter (`@objectstack/rest`). + +import { describe, expect, it, vi } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { STUDIO_APP } from '@objectstack/platform-objects/apps'; +import { RestServer } from '@objectstack/rest'; + +type AnyRec = Record; + +/** The registry keys the pinned console registers for the pages this repo links. */ +const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations']; + +/** + * The real registration of the Studio shell. `structuredClone` because + * `registerItem` writes the `_lock` envelope onto what it is handed + * (ADR-0010 §3.7). + */ +function composedRegistry(): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as AnyRec).logLevel = 'silent'; + registry.registerApp(structuredClone(STUDIO_APP), '@objectstack/platform-objects'); + return registry; +} + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: AnyRec = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: unknown) => { res.body = b; return res; }); + res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +/** A `RestServer` serving the composed Studio app to a caller holding `studio.access`. */ +function serve() { + const registry = composedRegistry(); + const protocol: AnyRec = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn(async ({ type }: AnyRec) => { + const t = String(type ?? ''); + return t === 'app' || t === 'apps' ? registry.getAllApps() : []; + }), + getMetaItem: vi.fn(async ({ name }: AnyRec) => { + const item = registry.getApp(String(name)); + return item ? { type: 'app', name, item } : undefined; + }), + findData: vi.fn().mockResolvedValue([]), + }; + const rest: AnyRec = new RestServer( + createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any, + ); + rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: ['studio.access'] }); + rest.registerRoutes(); + return rest; +} + +/** `GET /api/v1/meta/apps/:name`, answered as `{ statusCode, body }`. */ +async function getApp(rest: AnyRec, name: string) { + const route = rest.getRoutes().find( + (r: AnyRec) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name', + ); + if (!route) throw new Error('meta/:type/:name route not registered'); + const res = makeRes(); + await route.handler( + { method: 'GET', params: { type: 'apps', name }, query: {}, body: {}, headers: {} }, res, + ); + return res; +} + +/** The served children of one nav group, or `undefined` when the group is not served. */ +function group(app: AnyRec | undefined, groupId: string): AnyRec[] | undefined { + const found = ((app?.navigation ?? []) as AnyRec[]).find((g) => g?.id === groupId); + return found ? ((found.children ?? []) as AnyRec[]) : undefined; +} + +const ids = (items: AnyRec[] | undefined) => (items ?? []).map((i) => String(i?.id)); + +describe('#20142 — the Integrations & APIs nav entry, served from the composed Studio app', () => { + it('developer:integrations is served last in Studio\'s Developer group', async () => { + const studio = await getApp(serve(), 'studio'); + expect(studio.statusCode).toBe(200); + const developer = group(studio.body?.item, 'group_developer'); + expect(ids(developer)).toEqual([ + 'nav_api_console', 'nav_flow_runs', 'nav_public_forms', 'nav_integrations', + ]); + expect(developer?.at(-1)).toMatchObject({ type: 'component', componentRef: 'developer:integrations' }); + }); + + it('the served ref is a key the pinned console registers', async () => { + const studio = await getApp(serve(), 'studio'); + const served = ((studio.body?.item?.navigation ?? []) as AnyRec[]) + .flatMap((g) => (g?.children ?? []) as AnyRec[]) + .filter((i) => i?.id === 'nav_integrations'); + expect(served).toHaveLength(1); + expect(MEASURED_CONSOLE_KEYS).toContain(served[0].componentRef); + }); +}); diff --git a/packages/platform-objects/src/apps/console-page-nav-entries.test.ts b/packages/platform-objects/src/apps/console-page-nav-entries.test.ts new file mode 100644 index 00000000000..b4014ed4116 --- /dev/null +++ b/packages/platform-objects/src/apps/console-page-nav-entries.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #20142 — the framework half of the console pages' navigation contract, for +// the entry this package declares. +// +// objectui#10520 (PR objectui#10576) retired the console's System Hub card wall +// and its Developer Hub, which had been the only in-app links to three pages, +// and registered each page under a component-registry key instead. Framework +// navigation reaches a console page only through a `type: 'component'` item +// whose `componentRef` is such a key, so until an entry names the key the page +// is reachable by typed URL alone. Of the three: +// - `developer:integrations` is Studio's, declared here and pinned below; +// - `audit:log` is contributed by `@objectstack/plugin-audit` (it lives and +// dies with that plugin) and is pinned beside it in +// `packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts`; +// - `ai:approvals` is NOT this repository's to contribute: ADR-0029 D7 has +// each capability plugin contribute its own entries, and the `ai` +// capability's owner is `@objectstack/service-ai` in Cloud/Enterprise. +// +// Why a pin rather than a code comment: `componentRef` is a free string, and +// `validate-nav-target-refs` deliberately does not resolve component refs (the +// registry is not visible to the linter). A misspelt key parses, merges and +// ships, and the console renders "Component not registered" in its place. +// Each repo pins its own half of the seam: objectui pins the registration in +// `apps/console/src/__tests__/orphanedPageComponentRefs-10520.test.tsx`, this +// file pins the ref the entry names. +// +// The keys below were MEASURED, not recalled: read from objectui at the commit +// objectstack's `.objectui-sha` pins (dd3f7e1be3561d63267d7162f3fc0ac52e72834d), +// where `registerSystemComponents.tsx` registers `audit:log` and +// `registerDeveloperComponents.tsx` registers `developer:integrations`, both +// imported for their side effect by `apps/console/src/main.tsx`. A pin bump +// that renames one of them must change this list in the same PR. + +import { describe, it, expect } from 'vitest'; +import { AppSchema } from '@objectstack/spec/ui'; + +import { STUDIO_APP } from './studio.app.js'; + +/** The registry keys the pinned console registers for the pages this repo links. */ +const MEASURED_CONSOLE_KEYS = ['audit:log', 'developer:integrations'] as const; + +type NavItem = { + id?: string; + type?: string; + label?: string; + componentRef?: string; + requiresService?: string; + requiredPermissions?: string[]; + children?: NavItem[]; +}; + +/** The children of one Studio group, by id. */ +const studioGroup = (id: string): NavItem[] => { + const group = ((STUDIO_APP.navigation ?? []) as NavItem[]).find((g) => g.id === id); + expect(group, `Studio lost its ${id} group`).toBeDefined(); + return group?.children ?? []; +}; + +describe('the Integrations & APIs entry targets the console page (#20142)', () => { + const entry = (): NavItem => { + const found = studioGroup('group_developer').find((i) => i.id === 'nav_integrations'); + expect(found, 'Studio lost its nav_integrations entry').toBeDefined(); + return found!; + }; + + it('routes to the `developer:integrations` registry key, last in group_developer', () => { + expect(entry()).toMatchObject({ + type: 'component', + componentRef: 'developer:integrations', + label: 'Integrations & APIs', + }); + expect(studioGroup('group_developer').map((i) => i.id)).toEqual([ + 'nav_api_console', + 'nav_flow_runs', + 'nav_public_forms', + 'nav_integrations', + ]); + }); + + it('carries no gate beyond Studio\'s own, like its neighbours', () => { + expect(entry().requiresService).toBeUndefined(); + expect(entry().requiredPermissions).toBeUndefined(); + }); + + it('the Studio app still satisfies AppSchema', () => { + expect(() => AppSchema.parse(STUDIO_APP)).not.toThrow(); + }); +}); + +describe('each ref names a key the pinned console registers (#20142)', () => { + it('the ref this package declares is among the measured keys', () => { + const ref = studioGroup('group_developer').find((i) => i.id === 'nav_integrations')?.componentRef; + expect(MEASURED_CONSOLE_KEYS as readonly (string | undefined)[]).toContain(ref); + }); + + it('every measured key is a registry KEY, never a console path', () => { + // objectui#2763's boundary: navigation names a key and the console owns the + // URL it resolves to (`developer:integrations` → + // `/apps/APP/component/developer/integrations`). + for (const key of MEASURED_CONSOLE_KEYS) { + expect(key).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/); + expect(key).not.toContain('/'); + } + }); +}); diff --git a/packages/platform-objects/src/apps/studio.app.ts b/packages/platform-objects/src/apps/studio.app.ts index 042219fb3a9..4c842ed5bf2 100644 --- a/packages/platform-objects/src/apps/studio.app.ts +++ b/packages/platform-objects/src/apps/studio.app.ts @@ -298,9 +298,9 @@ export const STUDIO_APP: App = { }, { // Developer — first-party developer tooling surfaces hosted by the - // console (API console, flow run inspector, public forms registry). - // Registered as built-in components in the console's - // ComponentRegistry under the `developer:*` namespace. + // console (API console, flow run inspector, public forms registry, + // integrations & APIs). Registered as built-in components in the + // console's ComponentRegistry under the `developer:*` namespace. id: 'group_developer', type: 'group', label: 'Developer', @@ -327,6 +327,20 @@ export const STUDIO_APP: App = { componentRef: 'developer:public-forms', icon: 'file-text', }, + { + // #20142 — the console's Integrations & APIs page: the environment's + // REST base URL, per-object endpoints and an `x-api-key` cURL + // sample. Its only in-app link was a card on the console's Developer + // Hub, which objectui#10520 retired once all four of the hub's + // destinations were registry keys; the console registers this one + // in `registerDeveloperComponents.tsx`. No gate beyond Studio's own + // `studio.access`, like its neighbours. + id: 'nav_integrations', + type: 'component', + label: 'Integrations & APIs', + componentRef: 'developer:integrations', + icon: 'plug-zap', + }, ], }, { diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index da6690b9347..8ee72b2ac65 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -135,6 +135,8 @@ export const en: TranslationData = { // Diagnostics nav_sessions: { label: 'Sessions' }, nav_audit_logs: { label: 'Audit Logs' }, + // The console's Audit Log page, beside the object view (#20142). + nav_audit_log_browser: { label: 'Audit Log Browser' }, nav_notifications: { label: 'Notifications' }, // Integrations — every entry here is contributed at RUNTIME by the @@ -191,6 +193,7 @@ export const en: TranslationData = { nav_api_console: { label: 'API Console' }, nav_flow_runs: { label: 'Flow Runs' }, nav_public_forms: { label: 'Public Forms' }, + nav_integrations: { label: 'Integrations & APIs' }, group_integration: { label: 'Integration' }, nav_email_templates: { label: 'Email Templates' }, }, 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 0fd32ead688..bb48dc87b67 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 @@ -117,6 +117,7 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': '700dd03639b82a6a', + 'apps.setup.navigation.nav_audit_log_browser.label': 'f6932710da7ec071', 'apps.setup.navigation.nav_notifications.label': 'e0fdb90b1d31078d', 'apps.setup.navigation.nav_webhooks.label': '8fdd81bc76459a6a', 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', @@ -152,6 +153,7 @@ export const esESSourceHashes: Readonly> = { 'apps.studio.navigation.nav_api_console.label': 'd90e508f1921e8d3', 'apps.studio.navigation.nav_flow_runs.label': '0bdee1504c505862', 'apps.studio.navigation.nav_public_forms.label': '4592e97959994e86', + 'apps.studio.navigation.nav_integrations.label': 'edbd97d7f401a5b8', 'apps.studio.navigation.group_integration.label': 'a1f2ddfb5c00c83a', 'apps.studio.navigation.nav_email_templates.label': '3463bce831c833d5', 'dashboards.system_overview.label': '574b2a4b7fb7f979', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index c5826733294..16de6d4e247 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -94,6 +94,7 @@ export const esES: TranslationData = { nav_sessions: { label: 'Sesiones' }, nav_audit_logs: { label: 'Registros de Auditoría' }, + nav_audit_log_browser: { label: 'Explorador de registros de auditoría' }, nav_notifications: { label: 'Notificaciones' }, // Integrations — contributed at RUNTIME by the owning capability @@ -137,6 +138,7 @@ export const esES: TranslationData = { nav_api_console: { label: 'Consola de API' }, nav_flow_runs: { label: 'Ejecuciones de flujo' }, nav_public_forms: { label: 'Formularios públicos' }, + nav_integrations: { label: 'Integraciones y API' }, group_integration: { label: 'Integración' }, nav_email_templates: { label: 'Plantillas de correo' }, }, 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 5018e10a630..d3063192df3 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 @@ -117,6 +117,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': '700dd03639b82a6a', + 'apps.setup.navigation.nav_audit_log_browser.label': 'f6932710da7ec071', 'apps.setup.navigation.nav_notifications.label': 'e0fdb90b1d31078d', 'apps.setup.navigation.nav_webhooks.label': '8fdd81bc76459a6a', 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', @@ -152,6 +153,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.studio.navigation.nav_api_console.label': 'd90e508f1921e8d3', 'apps.studio.navigation.nav_flow_runs.label': '0bdee1504c505862', 'apps.studio.navigation.nav_public_forms.label': '4592e97959994e86', + 'apps.studio.navigation.nav_integrations.label': 'edbd97d7f401a5b8', 'apps.studio.navigation.group_integration.label': 'a1f2ddfb5c00c83a', 'apps.studio.navigation.nav_email_templates.label': '3463bce831c833d5', 'dashboards.system_overview.label': '574b2a4b7fb7f979', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 788e23be437..07eb8248660 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -94,6 +94,7 @@ export const jaJP: TranslationData = { nav_sessions: { label: 'セッション' }, nav_audit_logs: { label: '監査ログ' }, + nav_audit_log_browser: { label: '監査ログブラウザー' }, nav_notifications: { label: '通知' }, // Integrations — contributed at RUNTIME by the owning capability @@ -137,6 +138,7 @@ export const jaJP: TranslationData = { nav_api_console: { label: 'API コンソール' }, nav_flow_runs: { label: 'フロー実行履歴' }, nav_public_forms: { label: '公開フォーム' }, + nav_integrations: { label: '連携と API' }, group_integration: { label: '連携' }, nav_email_templates: { label: 'メールテンプレート' }, }, 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 330d0122220..5229f05412a 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 @@ -117,6 +117,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': '700dd03639b82a6a', + 'apps.setup.navigation.nav_audit_log_browser.label': 'f6932710da7ec071', 'apps.setup.navigation.nav_notifications.label': 'e0fdb90b1d31078d', 'apps.setup.navigation.nav_webhooks.label': '8fdd81bc76459a6a', 'apps.setup.navigation.nav_http_deliveries.label': '340f81dc6ea64987', @@ -152,6 +153,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.studio.navigation.nav_api_console.label': 'd90e508f1921e8d3', 'apps.studio.navigation.nav_flow_runs.label': '0bdee1504c505862', 'apps.studio.navigation.nav_public_forms.label': '4592e97959994e86', + 'apps.studio.navigation.nav_integrations.label': 'edbd97d7f401a5b8', 'apps.studio.navigation.group_integration.label': 'a1f2ddfb5c00c83a', 'apps.studio.navigation.nav_email_templates.label': '3463bce831c833d5', 'dashboards.system_overview.label': '574b2a4b7fb7f979', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 4ee68d9bb2f..ba01a9eb04f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -101,6 +101,7 @@ export const zhCN: TranslationData = { nav_sessions: { label: '会话' }, nav_audit_logs: { label: '审计日志' }, + nav_audit_log_browser: { label: '审计日志浏览器' }, nav_notifications: { label: '通知' }, // Integrations — contributed at RUNTIME by the owning capability @@ -147,6 +148,7 @@ export const zhCN: TranslationData = { nav_api_console: { label: 'API 控制台' }, nav_flow_runs: { label: '流程运行记录' }, nav_public_forms: { label: '公开表单' }, + nav_integrations: { label: '集成与 API' }, group_integration: { label: '集成' }, nav_email_templates: { label: '邮件模板' }, }, diff --git a/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts b/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts new file mode 100644 index 00000000000..d3b1ec9a3ab --- /dev/null +++ b/packages/plugins/plugin-audit/src/audit-nav-contribution.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #20142 — the Audit plugin's Setup navigation contribution carries BOTH doors +// onto `sys_audit_log`: the object view and the console's Audit Log page. +// +// The page is registered by the console under the `audit:log` component key +// (objectui `apps/console/src/registerSystemComponents.tsx`, measured at the +// commit objectstack's `.objectui-sha` pins, dd3f7e1be356). Its only in-app +// link was the System Hub card wall, which objectui#10520 retired; without an +// entry naming the key the page is reachable by typed URL alone. objectui pins +// the registration half (`orphanedPageComponentRefs-10520.test.tsx`); this file +// pins the half this repo owns — the ref the entry names. +// +// Why here and not beside #20142's Studio entry in +// `@objectstack/platform-objects`: the page reads `sys_audit_log`, which this +// plugin owns (ADR-0029 K2), so its entry lives and dies with this plugin the +// way `nav_audit_logs` does — and needs no item gate for the same reason. + +import { describe, it, expect } from 'vitest'; +import { NavigationContributionSchema } from '@objectstack/spec/ui'; +import { AuditPlugin } from './audit-plugin.js'; + +type NavItem = { + id?: string; + type?: string; + label?: string; + objectName?: string; + componentRef?: string; + requiresService?: string; + requiresObject?: string; +}; + +/** Run the plugin's init() and return the manifest it registered. */ +async function registeredManifest(): Promise { + const registered: any[] = []; + const ctx: any = { + logger: { info() {}, warn() {}, error() {}, debug() {} }, + getService: (name: string) => + name === 'manifest' ? { register: (m: unknown) => registered.push(m) } : undefined, + registerService() {}, + hook() {}, + }; + await new AuditPlugin().init(ctx); + expect(registered).toHaveLength(1); + return registered[0]; +} + +describe('AuditPlugin — Setup navigation contribution (#20142)', () => { + it('contributes the object view AND the console page into group_diagnostics, in that order', async () => { + const manifest = await registeredManifest(); + expect(manifest.navigationContributions).toHaveLength(1); + const [contribution] = manifest.navigationContributions; + expect(contribution).toMatchObject({ app: 'setup', group: 'group_diagnostics' }); + // Array order IS rendered order (`applyNavContributions` appends `items` + // verbatim), so the page sits directly under the object view it adds to. + expect((contribution.items as NavItem[]).map((i) => i.id)).toEqual([ + 'nav_audit_logs', + 'nav_audit_log_browser', + ]); + expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow(); + }); + + it('the page entry routes to the `audit:log` registry key', async () => { + const manifest = await registeredManifest(); + const page = (manifest.navigationContributions[0].items as NavItem[]) + .find((i) => i.id === 'nav_audit_log_browser'); + expect(page).toMatchObject({ + type: 'component', + componentRef: 'audit:log', + label: 'Audit Log Browser', + }); + // A registry KEY, never a console path (objectui#2763). + expect(page?.componentRef).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/); + // A component item has no object route to gate, and a contribution exists + // only while this plugin is installed — which is exactly when the page's + // `sys_audit_log` reads can answer. No gate of either kind. + expect(page?.objectName).toBeUndefined(); + expect(page?.requiresObject).toBeUndefined(); + expect(page?.requiresService).toBeUndefined(); + }); + + it('keeps the object view entry unchanged beside it', async () => { + const manifest = await registeredManifest(); + const objectView = (manifest.navigationContributions[0].items as NavItem[]) + .find((i) => i.id === 'nav_audit_logs'); + expect(objectView).toMatchObject({ + type: 'object', + objectName: 'sys_audit_log', + label: 'Audit Logs', + }); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 170ca0a79a8..9750312e04b 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -95,8 +95,22 @@ export class AuditPlugin implements Plugin { defaultDatasource: 'cloud', namespace: 'sys', objects: [SysAuditLog, SysActivity, SysComment], - // ADR-0029 D7 — contribute the Audit Logs entry into the Setup app's - // `group_diagnostics` slot. The plugin owns sys_audit_log (K2). + // ADR-0029 D7 — contribute the Audit Logs entries into the Setup app's + // `group_diagnostics` slot. The plugin owns sys_audit_log (K2), so both + // doors onto it live and die with this plugin and need no item gate. + // + // #20142 — the two entries are two different surfaces, and neither is a + // superset of the other: + // - `nav_audit_logs` is the object view: `sys_audit_log`'s named list + // views (`record_views` lists the `read` rows, an action the page's + // filter did not offer at the console pin this entry was measured + // against), searchable, with the actor and tenant rendered as + // resolved lookups. + // - `nav_audit_log_browser` is the console's Audit Log page (the + // `audit:log` registry key, `registerSystemComponents.tsx`): one + // filterable table whose detail drawer pretty-prints a change's + // before and after JSON, where the record page shows `old_value` / + // `new_value` as raw textarea text. navigationContributions: [ { app: 'setup', @@ -104,6 +118,7 @@ export class AuditPlugin implements Plugin { priority: 100, items: [ { id: 'nav_audit_logs', type: 'object', label: 'Audit Logs', objectName: 'sys_audit_log', icon: 'scroll-text' }, + { id: 'nav_audit_log_browser', type: 'component', label: 'Audit Log Browser', componentRef: 'audit:log', icon: 'file-diff' }, ], }, ],