diff --git a/addons/internal/connector-ui/src/PermissionFields.jsx b/addons/internal/connector-ui/src/PermissionFields.jsx
index 32d243ba27..c2ff3092bc 100644
--- a/addons/internal/connector-ui/src/PermissionFields.jsx
+++ b/addons/internal/connector-ui/src/PermissionFields.jsx
@@ -10,6 +10,7 @@ import { Field, Hint, Notice, Switch, TextInput } from './controls'
import Icon from '../../host-compat/src/components/Icon'
import { MAX_CHANNELS, MAX_CHANNEL_ID, MAX_CHANNEL_LABEL, agentHas, agentHeld, channelsProblem, copyFromAgent, grantableFor, hasNoCaps, presetBlock, roleHas, setCap, setGrant, unpairedSensitive, usualFor, withAddedCaps, withCopiedCaps } from './connector'
import PairingFields from './PairingFields'
+import ApprovalOptions from './ApprovalFields'
export const PERMISSION_TEXT = {
'perm.title': 'What senders may do',
@@ -68,14 +69,14 @@ export const PERMISSION_TEXT = {
'perm.channelProblem.label': 'A name has at most {{max}} characters.',
'perm.channelProblem.repeat': 'A channel is listed twice.',
'perm.channelProblem.count': 'At most {{max}} channels.',
- 'perm.triggersNeed': 'These settings apply only while "Propose triggers" is on above.',
+ 'perm.triggersNeed': 'Turn on Propose triggers to use this.',
}
export function permissionT(t) {
return (key, opts = {}) => t(key, { defaultValue: PERMISSION_TEXT[key], ...opts })
}
-function Cap({ cap, on, onChange, s, lacks }) {
+function Cap({ cap, on, onChange, s, lacks, children, effect }) {
// A permission the agent does not have cannot be switched on; one that is
// already on can still be switched off.
const blocked = lacks && !on
@@ -86,6 +87,8 @@ function Cap({ cap, on, onChange, s, lacks }) {
{cap.sensitive &&
{s('perm.sensitive')}}
{cap.description}{cap.sensitive ? ` ${s('perm.sensitiveHelp')}` : ''}
+ {effect &&
{effect}
}
+ {children}
{lacks &&
{s('perm.agentLacks')}
}
)
@@ -303,7 +306,10 @@ export default function PermissionFields({ form, setForm, kind, capabilities, s,
)}
- {caps.map((c) => setForm(setCap(form, c.name, v))} />)}
+ {caps.map((c) => setForm(setCap(form, c.name, v))}>
+ {c.name === 'triggers.propose' && form.approval && }
+ )}
diff --git a/addons/internal/connector-ui/src/connector.css b/addons/internal/connector-ui/src/connector.css
index 850b6b13ea..30bd3b2a53 100644
--- a/addons/internal/connector-ui/src/connector.css
+++ b/addons/internal/connector-ui/src/connector.css
@@ -199,3 +199,11 @@ fieldset.cn-group {
.cn-confirm__list { margin: 0; padding-left: var(--dk-space-5); font-size: var(--fs-small); line-height: var(--lh-small); }
.cn-cap__lacks { margin: 2px 0 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-muted); }
.cn-cap[data-lacks="true"] .cn-cap__title { color: var(--color-text-muted); }
+
+/* The approval of proposed triggers, nested under the permission Propose triggers. */
+.cn-cap__effect { margin: 2px 0 0; font-size: var(--fs-small); line-height: var(--lh-small); color: var(--color-text-secondary); }
+.cn-option { margin: var(--dk-space-2) 0 0 var(--dk-space-3); padding: 0 0 0 var(--dk-space-3); border: 0; border-left: 2px solid var(--color-border-subtle); min-width: 0; display: flex; flex-direction: column; gap: var(--dk-space-2); }
+.cn-option[data-off="true"] { opacity: 0.6; }
+.cn-option__title { margin: 0; font-size: var(--fs-small); line-height: var(--lh-small); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); }
+.cn-option__limits summary { cursor: pointer; font-size: var(--fs-small); color: var(--color-text-secondary); }
+.cn-option__limits[open] summary { margin-bottom: var(--dk-space-2); }
diff --git a/core/http/react-ui/e2e/connector-permissions.spec.js b/core/http/react-ui/e2e/connector-permissions.spec.js
index 84e29dc237..af1ca95248 100644
--- a/core/http/react-ui/e2e/connector-permissions.spec.js
+++ b/core/http/react-ui/e2e/connector-permissions.spec.js
@@ -319,13 +319,41 @@ test.describe('Connector permissions', () => {
await shot(page, 'perm-5-access')
})
- test('trigger approval says it applies only while triggers are allowed', async ({ page }) => {
- await api(page, [SET]).install()
+ test('the approval of proposed triggers sits under Propose triggers, shows its choice in the row and is greyed while the permission is off', async ({ page }) => {
+ const a = api(page, [{ ...LEGACY, caps: ['memory.write'], approval: { mode: 'auto_readonly' } }])
+ await a.install()
await page.goto(PAGE)
await page.getByRole('button', { name: 'Edit' }).click()
- await expect(page.getByTestId('connector-approval-off')).toBeVisible()
- await page.getByTestId('connector-cap-switch-triggers.propose').click()
- await expect(page.getByTestId('connector-approval-off')).toHaveCount(0)
+ const row = page.getByTestId('connector-cap-triggers.propose')
+ // One place only: nothing at the bottom of the page.
+ await expect(page.getByTestId('connector-approval')).toHaveCount(1)
+ await expect(row.getByTestId('connector-approval')).toBeVisible()
+ await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: at once if read-only')
+ // Permission off: greyed, with the reason, and the radios cannot be used.
+ await expect(row.getByTestId('connector-approval-off')).toHaveText('Turn on Propose triggers to use this.')
+ await expect(row.getByRole('radio').first()).toBeDisabled()
+ await expect(row.getByTestId('connector-approval')).toHaveAccessibleDescription('Turn on Propose triggers to use this.')
+ await shot(page, 'perm-9-approval-greyed')
+ await row.getByTestId('connector-cap-switch-triggers.propose').click()
+ await expect(row.getByTestId('connector-approval-off')).toHaveCount(0)
+ await expect(row.getByRole('radio', { name: /Ask me/ })).toBeEnabled()
+ // The three modes change the line of the row.
+ await row.getByRole('radio', { name: /Ask me/ }).check()
+ await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: asks first')
+ await shot(page, 'perm-10-approval-ask')
+ await row.getByRole('radio', { name: /Set it up at once/ }).check()
+ await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: set up at once')
+ await shot(page, 'perm-11-approval-auto')
+ await row.getByRole('radio', { name: /At once when the runs only read/ }).check()
+ await expect(row.getByTestId('connector-cap-effect-triggers.propose')).toHaveText('Proposals: at once if read-only')
+ await shot(page, 'perm-12-approval-readonly')
+ // The limits are in a closed disclosure and are sent as before.
+ const limits = row.getByTestId('connector-approval-limits')
+ await expect(limits).not.toHaveAttribute('open', '')
+ await limits.getByText('Limits', { exact: true }).click()
+ await limits.getByTestId('connector-approval-max_pending').fill('3')
+ await page.getByRole('button', { name: 'Save' }).click()
+ await expect.poll(() => a.s.calls.find(c => c.method === 'PUT')?.body.approval).toEqual({ mode: 'auto_readonly', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
})
test('an older host that sends no capability list shows no permission section', async ({ page }) => {
diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md
index 24393261d3..bd572a3133 100644
--- a/docs/content/features/chats-connectors.md
+++ b/docs/content/features/chats-connectors.md
@@ -666,7 +666,9 @@ trigger stays in Studio. Nothing runs before the owner confirms.
### Approval modes
The owner chooses, per binding, what happens to a proposal. The choice is on the settings
-page of the connector add-on, or in the `approval` field of the binding
+page of the connector add-on, nested under the permission **Propose triggers** (greyed while
+that permission is off, with its limits in a closed **Limits** list, and the current mode in
+the line of the row), or in the `approval` field of the binding
(`PUT /api/chats/connectors/{id}`), and is the same for every connector.
| `mode` | Behavior |