Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/approvals-nav-points-at-inbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/platform-objects': minor
'@objectstack/plugin-approvals': minor
---

Point the account app's **Approvals** navigation entry at the Approvals Inbox component, and contribute an **Approvals Inbox** entry to Setup (#7234).

The entry point has not moved — the account menu still shows **Approvals** with the same
label and icon in every locale. Its destination has. It used to open the raw
`sys_approval_request` grid, which is an admin/diagnostic view of the engine's own table
and cannot show an approver a single decision button: every action on that object is gated
on `record.viewer.can_act || record.viewer.can_override`, and the `viewer` block is
attached only by the approvals REST path, never by the generic data API the object route
reads. The result was a correct-looking list of rows nobody could act on. The entry is now
`{ type: 'component', componentRef: 'approvals:inbox' }`, so it opens the full inbox —
decision actions, business vocabulary, node progress and the request drawer.

- **Account app**: `nav_account_approvals` becomes a component entry gated by
`requiresService: 'approvals'`, so it disappears where `plugin-approvals` is not
installed (the previous `requiresObject` gate does not apply to a component entry).
- **Setup**: `plugin-approvals` contributes a new **Approvals Inbox** entry at the top of
**Setup → Approvals**, above the three raw tables, which stay exactly as they were —
admin-gated by `manage_platform_settings` and now unambiguously the diagnostic surface.
Labels ship in all four locales (zh-CN 审批中心).
- `sys_approval_request` is no longer surfaced raw to end users anywhere.
- **Docs**: the approver's queue is documented as the Approvals Inbox, with a snippet for
mounting it in any business app — one navigation entry naming the component-registry key
`approvals:inbox`, never a console path.

Reaching the inbox end to end in the browser additionally requires the console pin bump,
tracked separately.
60 changes: 57 additions & 3 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,24 @@ and nothing can move it, so the run parks forever.

### The approver finds it in their queue

Requests land in **Setup → Approvals → Requests**, whose `my_pending` view
filters to `status = pending` and `pending_approvers` containing the current
user. Programmatically:
The approver's surface is the **Approvals Inbox** — in the stock console, the
**Approvals** entry in the account menu. It lists what is waiting on the current
user (`status = pending`, `pending_approvers` containing them) and it is the only
surface that carries the **decision actions**: approve, reject, return, reassign,
request info, delegate, remind, override.

<Callout type="info">
**Why the raw `sys_approval_request` table is not that surface.** Every decision
action on the object is gated on `record.viewer.can_act ||
record.viewer.can_override`, and the `viewer` block is computed and attached only
by the approvals REST path (`/api/v1/approvals/*`) — never by the generic data
API a plain object view reads. Browse the table directly and you get a correct,
completely inert list: the rows are there, the buttons are not. The table stays
available under **Setup → Approvals → Requests** for admins and diagnostics,
which is what it is good for.
</Callout>

Programmatically the same queue is:

```bash
curl -b cookies.txt \
Expand Down Expand Up @@ -353,6 +368,45 @@ service and drive `remind()`. Reminder, escalation, return, and reassignment
*do* publish notification topics — the initial request doesn't.
</Callout>

### Mount the approvals inbox in your app

Approvals are cross-cutting: people approve expenses inside the expenses app,
not by leaving for a platform app. Add one navigation entry to any app you ship
and that app gets the full inbox:

```ts
// my-app.app.ts
navigation: [
// …
{
id: 'nav_approvals',
type: 'component',
label: 'Approvals',
componentRef: 'approvals:inbox',
icon: 'check-circle',
// Hide the entry where the approvals plugin is not installed. Enforced
// server-side: a gated-off entry never reaches the browser.
requiresService: 'approvals',
},
],
```

`componentRef` names a **component-registry key**, not a URL. The console owns
the route it resolves to, so `approvals:inbox` keeps working if that route ever
changes — never hand-write a console path into app metadata.

The inbox is per-user, not per-app: it shows the signed-in user everything
waiting on them across every object, because "what is waiting on me" is a
property of the person and not of the app they happen to have open. Filtering it
down to one app's objects is not an option today.

<Callout type="info">
The platform's own **Account** app mounts it exactly this way, and
**Setup → Approvals → Approvals Inbox** mounts the same component for admins
above the raw `sys_approval_request` / `sys_approval_action` /
`sys_approval_delegation` tables.
</Callout>

### The decision

```bash
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/setup-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ anchors are:
| **Apps** (`group_apps`) | Packages — `platform-objects`; marketplace — `cloud-connection` (capability plugin) |
| **People & Organization** (`group_people_org`) | Users · Organization · Business Units · Teams · Organizations · Invitations — `platform-objects` |
| **Access Control** (`group_access_control`) | Positions / Permission Sets — `plugin-security`; Sharing Rules / Record Shares — `plugin-sharing`; API Keys — `platform-objects` |
| **Approvals** (`group_approvals`) | `plugin-approvals` |
| **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` |
| **Integrations** (`group_integrations`) | `plugin-webhooks` |
Expand Down
121 changes: 121 additions & 0 deletions packages/platform-objects/src/apps/account-approvals-nav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The account app's Approvals entry points at the Approvals Inbox COMPONENT,
// not at the raw `sys_approval_request` grid (#7234, entry C of #7213).
//
// Why this needs a pin rather than a code comment: the two shapes are one
// keyword apart and both parse. `type: 'object'` renders a perfectly healthy
// list of the engine's own table — which is exactly the defect. Every one of
// `sys_approval_request`'s eight decision actions is gated on
// `record.viewer.can_act || record.viewer.can_override`, and the `viewer` block
// is attached ONLY by the approvals REST path (`approval-service.ts`,
// `attachViewers()`), never by the generic data API the object route uses. So
// the object destination is a read-only wall of rows an approver cannot act on,
// and it LOOKS fine. A regression back to it would show up in no other test.
//
// The gate half is verified where it is implemented, not re-implemented here:
// `packages/rest/src/meta-app-area-nav-gate.test.ts` pins that `filterNav`
// strips a `type: 'component'` item whose `requiresService` names an absent
// service — variant-agnostic, which is what makes `requiresService` usable on a
// component item at all.
import { describe, it, expect } from 'vitest';
import { AppSchema } from '@objectstack/spec/ui';

import { ACCOUNT_APP } from './account.app.js';

type NavItem = {
id?: string;
type?: string;
label?: string;
objectName?: string;
viewName?: string;
componentRef?: string;
requiresObject?: string;
requiresService?: string;
children?: NavItem[];
};

/** Every nav item in the account app, depth-first. */
const allNavItems = (): NavItem[] => {
const out: NavItem[] = [];
const walk = (items: NavItem[] = []) => {
for (const item of items) {
if (!item) continue;
out.push(item);
if (Array.isArray(item.children)) walk(item.children);
}
};
walk((ACCOUNT_APP.navigation ?? []) as NavItem[]);
return out;
};

const approvalsEntry = (): NavItem => {
const item = allNavItems().find((i) => i.id === 'nav_account_approvals');
expect(item, 'the account app lost its nav_account_approvals entry').toBeDefined();
return item as NavItem;
};

describe("the account app's Approvals entry targets the inbox component (#7234)", () => {
it('routes to the `approvals:inbox` registry key', () => {
expect(approvalsEntry()).toMatchObject({
type: 'component',
componentRef: 'approvals:inbox',
});
});

it('addresses a registry KEY, never a console path', () => {
// objectui#2763's boundary: nav metadata names a component-registry key and
// the console owns the URL it resolves to (`approvals:inbox` →
// `/apps/{app}/component/approvals/inbox`, objectui#4071). A `/`-shaped ref
// here would be this package reaching across that line and would silently
// survive every schema check — `componentRef` is a free string, and
// `validate-nav-target-refs` deliberately does not resolve component refs
// (an unregistered ref is a NON-rule there: the registry is not visible to
// the linter).
const ref = approvalsEntry().componentRef ?? '';
expect(ref).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/);
expect(ref).not.toContain('/');
});

it('no longer routes to the raw sys_approval_request table', () => {
const entry = approvalsEntry();
expect(entry.objectName).toBeUndefined();
expect(entry.viewName).toBeUndefined();
// `requiresObject` is the object-route gate; a component item has no object
// route to gate, so leaving it behind would be a dead key that reads as one.
expect(entry.requiresObject).toBeUndefined();
});

it('gates on the approvals SERVICE, which is what backs the inbox', () => {
// `plugin-approvals` does `ctx.registerService('approvals', …)` in start().
// Without a gate the entry would render a dead component route on any
// deployment that does not install the plugin — the "dead-quiet entry"
// failure. `requiresService` is stripped server-side by `filterAppForUser`
// (ADR-0057 D10), so a gated-off entry never reaches the browser.
expect(approvalsEntry().requiresService).toBe('approvals');
});

it('keeps its label and icon — the entry point does not move', () => {
// Only the DESTINATION changes. The label is load-bearing beyond this file:
// `plugin-approvals`' approval-status-vocabulary.test.ts ties the
// `my_pending` view label to this entry's per-locale label (#7232/#7271), so
// rewording it here turns that cross-package parity case red.
expect(approvalsEntry()).toMatchObject({ label: 'Approvals', icon: 'check-circle' });
});

it('surfaces sys_approval_request raw NOWHERE in the account app', () => {
// The acceptance criterion in its own words: the engine-owned table is an
// admin/diagnostic surface reachable only under Setup, behind
// `group_approvals`' `manage_platform_settings` gate. The account app
// deliberately declares no `requiredPermissions` (every authenticated user
// needs it for sessions / API keys), so anything it lists is listed for
// everyone — which is why this is asserted over the whole tree and not just
// over the one entry above.
const raw = allNavItems().filter((i) => i.objectName === 'sys_approval_request');
expect(raw.map((i) => i.id)).toEqual([]);
});

it('the whole app still satisfies AppSchema with the component item', () => {
expect(() => AppSchema.parse(ACCOUNT_APP)).not.toThrow();
});
});
30 changes: 26 additions & 4 deletions packages/platform-objects/src/apps/account.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,35 @@ export const ACCOUNT_APP: App = {
requiresObject: 'sys_inbox_message',
},
{
// #7234 — the DESTINATION is the Approvals Inbox component, not the
// raw `sys_approval_request` grid. The engine-owned table has no
// decision actions for an end user: all eight are gated on
// `record.viewer.can_act || record.viewer.can_override`, and the
// `viewer` block is attached ONLY on the approvals REST path
// (`approval-service.ts`, `attachViewers()`), never on the generic
// data API. So the object route could only ever render a read-only
// list of rows an approver cannot act on. The inbox component calls
// the approvals REST path and therefore carries the decision
// actions, business vocabulary, node progress and drawer.
//
// The ref is the component-registry KEY, never a console path — that
// indirection is the whole contract (objectui#2763; the key is
// registered by objectui#4071).
id: 'nav_account_approvals',
type: 'object',
type: 'component',
label: 'Approvals',
objectName: 'sys_approval_request',
viewName: 'my_pending',
componentRef: 'approvals:inbox',
icon: 'check-circle',
requiresObject: 'sys_approval_request',
// `requiresObject` cannot gate a component item — it names the
// object the entry ROUTES to, and this one routes to a component.
// The capability that must be present is the approvals SERVICE
// (`ctx.registerService('approvals', …)` in plugin-approvals), which
// is also what backs the REST path the inbox reads. Same shape as
// `nav_account_memberships` below, and enforced server-side by
// `filterAppForUser`/`filterNav` (ADR-0057 D10) — without it the
// entry would render a dead component route wherever
// plugin-approvals is not installed.
requiresService: 'approvals',
},
{
id: 'nav_account_memberships',
Expand Down
4 changes: 3 additions & 1 deletion packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export const en: TranslationData = {
nav_api_keys: { label: 'API Keys' },
nav_connect_agent: { label: 'Connect an Agent' },

// Approvals
// Approvals. `nav_approvals_inbox` is the working surface (#7234); the
// three below it are the engine's raw tables, kept as the admin view.
nav_approvals_inbox: { label: 'Approvals Inbox' },
nav_approval_requests: { label: 'Requests' },
nav_approval_actions: { label: 'Action History' },
nav_approval_delegations: { label: 'Delegations (OOO)' },
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const esES: TranslationData = {
nav_api_keys: { label: 'Claves API' },
nav_connect_agent: { label: 'Conectar un agente' },

nav_approvals_inbox: { label: 'Centro de aprobaciones' },
nav_approval_requests: { label: 'Solicitudes' },
nav_approval_actions: { label: 'Historial de Acciones' },
nav_approval_delegations: { label: 'Delegaciones (ausencia)' },
Expand Down
1 change: 1 addition & 0 deletions packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const jaJP: TranslationData = {
nav_api_keys: { label: 'API キー' },
nav_connect_agent: { label: 'エージェントを接続' },

nav_approvals_inbox: { label: '承認センター' },
nav_approval_requests: { label: 'リクエスト' },
nav_approval_actions: { label: 'アクション履歴' },
nav_approval_delegations: { label: '委任 (不在時)' },
Expand Down
3 changes: 3 additions & 0 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export const zhCN: TranslationData = {
nav_api_keys: { label: 'API 密钥' },
nav_connect_agent: { label: '连接智能体' },

// 审批中心 = the Approvals Inbox component (#7234) — the surface with
// decision actions. The three below are the engine's raw tables.
nav_approvals_inbox: { label: '审批中心' },
nav_approval_requests: { label: '审批申请' },
nav_approval_actions: { label: '审批历史' },
// `审批委派` matches sys_approval_delegation's object label; `(外出)`
Expand Down
11 changes: 11 additions & 0 deletions packages/plugins/plugin-approvals/src/approvals-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,23 @@ export class ApprovalsServicePlugin implements Plugin {
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
// ships their menu too; when the plugin isn't installed the slot is empty.
//
// #7234 — order inside the slot is ARRAY ORDER: `applyNavContributions`
// does `group.children.push(...c.items)` per contribution (sorted by
// `priority`), so the inbox being first in this array is what puts it
// above the raw tables. Do not reorder without meaning to.
//
// The inbox entry is the working surface (decision actions, node
// progress, drawer); the three object entries below stay as the
// admin/diagnostic view of the engine's own tables — reachable only here,
// behind `group_approvals`' `manage_platform_settings` gate.
navigationContributions: [
{
app: 'setup',
group: 'group_approvals',
priority: 100,
items: [
{ id: 'nav_approvals_inbox', type: 'component', label: 'Approvals Inbox', componentRef: 'approvals:inbox', icon: 'list-checks' },
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
{ id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
{ id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
Expand Down
41 changes: 37 additions & 4 deletions packages/plugins/plugin-approvals/src/nav-contribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,46 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
expect(manifest.navigationContributions).toHaveLength(1);
const contribution = manifest.navigationContributions[0];
expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([

// ORDER IS THE ASSERTION, not a by-product (#7234). `applyNavContributions`
// appends `c.items` into the group verbatim, so this array's order IS the
// rendered order — the inbox must come first. A set-shaped assertion (the
// `.sort()` this case used to open with) cannot see that, and putting the
// working surface below three raw tables is most of the defect #7213
// reported.
expect(contribution.items.map((i: any) => i.id)).toEqual([
'nav_approvals_inbox',
'nav_approval_requests',
'nav_approval_actions',
'nav_approval_delegations',
]);

// The inbox entry addresses the component REGISTRY KEY, never a console
// path (objectui#2763) — the console resolves `approvals:inbox` to
// `component/approvals/inbox` on its side (objectui#4071).
const [inbox, ...rawTables] = contribution.items as any[];
expect(inbox).toMatchObject({
id: 'nav_approvals_inbox',
type: 'component',
componentRef: 'approvals:inbox',
});
// It carries no object gate: `requiresObject` names the object an entry
// routes to, and this one routes to a component. It needs none — a
// navigation CONTRIBUTION only exists while its plugin is installed, which
// is the same condition that makes the inbox's REST path answer.
expect(inbox.objectName).toBeUndefined();
expect(inbox.requiresObject).toBeUndefined();

// The raw engine tables stay, unchanged, as the admin/diagnostic view.
expect(rawTables.map((i: any) => i.objectName)).toEqual([
'sys_approval_request',
'sys_approval_action',
'sys_approval_delegation',
'sys_approval_request',
]);
// Each entry is gated so the slot stays empty when the plugin is absent.
for (const item of contribution.items) {
// Each object entry is gated so the slot degrades cleanly when an object is
// not registered.
for (const item of rawTables) {
expect(item.type).toBe('object');
expect(item.requiresObject).toBe(item.objectName);
}
});
Expand Down
Loading