From 6adc30007980523e61662eb7ea39e4f83cd88cba Mon Sep 17 00:00:00 2001 From: Inakitajes Date: Mon, 31 Aug 2026 22:22:29 +0100 Subject: [PATCH 1/2] chore(openspec): add unify-zendesk-action-permissions proposal --- .../.openspec.yaml | 2 + .../design.md | 166 ++++++++++++++++++ .../proposal.md | 31 ++++ .../specs/zendesk-action-permissions/spec.md | 125 +++++++++++++ .../unify-zendesk-action-permissions/tasks.md | 48 +++++ 5 files changed, 372 insertions(+) create mode 100644 openspec/changes/unify-zendesk-action-permissions/.openspec.yaml create mode 100644 openspec/changes/unify-zendesk-action-permissions/design.md create mode 100644 openspec/changes/unify-zendesk-action-permissions/proposal.md create mode 100644 openspec/changes/unify-zendesk-action-permissions/specs/zendesk-action-permissions/spec.md create mode 100644 openspec/changes/unify-zendesk-action-permissions/tasks.md diff --git a/openspec/changes/unify-zendesk-action-permissions/.openspec.yaml b/openspec/changes/unify-zendesk-action-permissions/.openspec.yaml new file mode 100644 index 00000000..ecf3b45d --- /dev/null +++ b/openspec/changes/unify-zendesk-action-permissions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-31 diff --git a/openspec/changes/unify-zendesk-action-permissions/design.md b/openspec/changes/unify-zendesk-action-permissions/design.md new file mode 100644 index 00000000..4242c1e9 --- /dev/null +++ b/openspec/changes/unify-zendesk-action-permissions/design.md @@ -0,0 +1,166 @@ +## Context + +See `proposal.md` for motivation and `specs/zendesk-action-permissions/spec.md` for the behavioral contract. + +Zendesk configuration is encrypted JSON containing credentials, five legacy boolean permission flags, and optionally the generic `mcpToolPermissions` map. The embedded Zendesk MCP server currently uses the booleans to filter `tools/list` and to reject disabled operations. Runtime configuration separately expands `mcpToolPermissions` into exact OpenCode tool permissions. + +OpenCode 1.18.18 evaluates MCP approval policy by the sanitized MCP tool name. MCP tools provide the permission pattern `*` and no argument metadata, so `create_ticket(publicComment: true)` and `create_ticket(publicComment: false)` cannot receive different policies. The connector also cannot initiate an OpenCode permission request after inspecting arguments; MCP elicitation is not enabled by the pinned OpenCode runtime. + +Permission events do carry the session, message, and call identifiers. Arche transforms an OpenCode tool call ID into the corresponding tool-part ID and retains tool inputs in the per-session chat store, which provides a separate path for building an approval preview. + +## Goals / Non-Goals + +**Goals:** + +- Make one canonical action-policy map the source of truth for Zendesk permissions. +- Preserve one Zendesk API request when an update contains both field changes and a comment. +- Keep `deny` enforceable at the connector boundary and use OpenCode only for the interactive distinction between `ask` and `allow`. +- Migrate legacy settings conservatively and support a staged rollout while old and new application versions share connector records. +- Show a deterministic, connector-specific approval preview before a Zendesk permission response can be submitted. + +**Non-Goals:** + +- Add argument-aware permissions to OpenCode or a general approval broker inside the connector gateway. +- Enable MCP elicitation or replace OpenCode's existing `once`, `always`, and `reject` responses. +- Change generic tool-permission behavior for non-Zendesk connectors. +- Add Zendesk role, requester, organization, brand, or field-level authorization. +- Distinguish reading public comments from reading internal notes; listing ticket comments remains one independently configurable read action. + +## Decisions + +### 1. Store canonical policies separately and derive compatibility projections + +Add a versioned `zendeskActionPermissions` object to encrypted Zendesk configuration. Its keys are the complete supported action names and its values use the existing connector permission action type: `deny`, `ask`, or `allow`. + +```text +zendeskActionPermissions +├── search_tickets +├── get_ticket +├── list_ticket_comments +├── create_ticket_public +├── create_ticket_internal +├── update_ticket_fields +├── update_ticket_with_public_comment +└── update_ticket_with_internal_note +``` + +This object is the only authoritative state after migration. The legacy boolean `permissions` object and legacy entries in `mcpToolPermissions` remain temporarily as derived compatibility projections, not independently editable policy. Keeping a distinct key avoids changing the type of an existing field while old and new web versions can read the same connector record. + +The Zendesk settings API accepts and returns the canonical map. During the compatibility release it also continues accepting the old boolean request shape, normalizing it into canonical actions. Saving canonical policies dual-writes a conservative legacy projection: + +- Existing read tool policies project one-to-one. +- Legacy `create_ticket` receives the most restrictive public/internal create policy. +- Legacy `update_ticket` receives the most restrictive field/public/internal update policy. +- A legacy boolean gate remains enabled only when none of the actions it covers is denied; the projected old tool policy handles `ask` and may over-prompt a less restrictive variant. + +This guarantees an older runtime can be more restrictive, but cannot bypass a canonical `ask` or `deny`. A later contract change may remove the projections after mixed-version and rollback support is no longer required. + +Alternative considered: make `mcpToolPermissions` itself canonical. This minimizes storage changes but leaves legacy and new tool names mixed in one generic map, makes connector-side validation ambiguous, and lets the generic settings endpoint become a second representation. A connector-specific versioned map provides a clear migration boundary. + +Alternative considered: change the five existing booleans directly to action strings. This is rejected because old code requires booleans and would treat the shared record as invalid during a blue-green deployment. + +### 2. Normalize every read path with a restrictive merge + +A single Zendesk normalization function produces a complete canonical map for settings reads, MCP inventory, tool execution, and managed runtime generation. It uses the canonical map when present and otherwise migrates legacy values in memory. + +For a legacy record, each replacement action combines all applicable values and chooses the most restrictive result using: + +```text +allow < ask < deny +``` + +The mappings are: + +```text +search_tickets ← allowRead + search_tickets policy +get_ticket ← allowRead + get_ticket policy +list_ticket_comments ← allowRead + list_ticket_comments policy +create_ticket_public ← allowCreateTickets + allowPublicComments + create_ticket policy +create_ticket_internal ← allowCreateTickets + allowInternalComments + create_ticket policy +update_ticket_fields ← allowUpdateTickets + update_ticket policy +update_ticket_with_public_comment ← allowUpdateTickets + allowPublicComments + update_ticket policy +update_ticket_with_internal_note ← allowUpdateTickets + allowInternalComments + update_ticket policy +``` + +A false boolean contributes `deny`; a true or missing boolean contributes `allow`; a missing tool policy contributes `allow`. A complete record with no permission fields therefore retains the current full-access default. + +Normalization occurs before validation and does not require the user to open settings. A successful canonical save records the versioned map and refreshes its compatibility projections atomically with the encrypted config update. + +Alternative considered: migrate only when settings are saved. This is rejected because workspaces and gateway requests may use a connector before its settings are opened, which could temporarily discard an existing restriction. + +### 3. Replace composite writes with visibility-specific tools + +Keep the three existing read tool names. Replace `create_ticket` and `update_ticket` in the new inventory with: + +- `create_ticket_public` +- `create_ticket_internal` +- `update_ticket_fields` +- `update_ticket_with_public_comment` +- `update_ticket_with_internal_note` + +The creation schemas omit `publicComment`; visibility comes from the tool identity. `update_ticket_fields` omits both `comment` and `publicComment`. Each visibility-specific update requires `comment`, omits `publicComment`, and retains optional subject, priority, status, type, and assignee fields so a comment and field changes remain one Zendesk update. + +Tool definitions carry their canonical action key. `tools/list` filters out `deny`; execution resolves the definition and performs the same deny check before argument validation or network I/O. `ask` and `allow` are both executable at this layer because only the managed OpenCode runtime can conduct the approval exchange. + +Alternative considered: retain one tool and instruct the model to ask the user before public communication. Prompt instructions are not an authorization boundary and can be skipped, so this is rejected. + +Alternative considered: separate comments into `add_public_comment` and `add_internal_note` tools that cannot update fields. This is simpler but changes the current atomic update behavior and can leave a ticket partially changed if a second request fails. + +### 4. Derive managed runtime permissions from canonical Zendesk policies + +The MCP configuration builder uses a connector-type policy adapter. For Zendesk it receives the normalized canonical map; other connectors continue reading generic `mcpToolPermissions` unchanged. Agent connector-tool remapping then expands every canonical action into the exact sanitized MCP tool name and OpenCode action. + +Denied Zendesk actions are still included in the generated exact policy map as `deny`, even though the MCP server omits them. This preserves defense in depth if tool discovery is stale. `ask` and `allow` map directly to OpenCode. Because each public/internal variant has a different tool name, OpenCode's session-wide `always` response remains scoped to that atomic action. + +The generic tool-permissions UI is not rendered inside Zendesk settings. During the transition, the generic tool-permissions endpoint either delegates Zendesk updates to the canonical adapter or rejects Zendesk writes with an explicit instruction to use the Zendesk settings endpoint; it must never persist an independent effective Zendesk policy. + +Alternative considered: implement approvals in the connector gateway. That requires durable suspended requests, session routing, timeout handling, and a second approval event protocol, duplicating behavior OpenCode already provides. + +### 5. Correlate permission requests with tool parts for previews + +Keep OpenCode permission transport unchanged. Enrich a visible Zendesk permission in Arche by joining: + +```text +permission.sessionId → ChatStore.messages[sessionId] +permission.messageId → assistant message +permission.callId → tool part id +``` + +The transformed tool part contains the tool name and validated model-produced input. A Zendesk-specific preview formatter whitelists fields from the known action schema and produces: + +- connector display name and human-readable action; +- public or internal visibility from the atomic tool name; +- ticket ID for updates; +- subject and comment body when present; +- optional status, priority, type, assignee, and tags when present. + +The formatter does not render arbitrary metadata or connector configuration. React text rendering remains escaped, long comments are contained in a scrollable/pre-wrapped region, and preview values are not added to audit metadata or logs. + +Permission and tool-part events can arrive in either order, so preview selection is reactive. Permission hydration also hydrates the referenced session messages, including delegated child sessions. For a recognized Zendesk action, response controls remain disabled while its referenced tool input is unavailable; the UI shows a loading or retrieval error rather than allowing a blind approval. Generic permission cards retain their existing fallback. + +Alternative considered: use permission-event metadata. OpenCode emits empty metadata for MCP tools, and changing the embedded MCP server cannot populate the pre-execution OpenCode request. + +### 6. Use one domain-oriented Zendesk editor + +Replace boolean switches and the nested generic tool section with segmented `Deny`/`Ask`/`Allow` controls grouped as ticket reading, ticket updates, public communication, and internal communication. The editor loads and saves the complete canonical map through the Zendesk settings route. + +There is no longer a cross-field rule requiring ticket creation plus one enabled comment type: public and internal creation are independently valid actions. Saving uses the existing authenticated, CSRF-protected, ownership-checked, encrypted update and audit path, adds the canonical policy values to the existing Zendesk-settings audit event, and emits the workspace-config-changed signal used by generic tool policies. + +## Risks / Trade-offs + +- [More MCP tools increase prompt size and tool-choice surface] → Keep descriptions concise, encode visibility in names, and test that each action produces the intended payload. +- [An older runtime cannot express different policies for variants of one composite tool] → Dual-write the most restrictive aggregate legacy tool policy, accepting temporary over-restriction rather than under-enforcement. +- [`ask` is not enforceable for a client that bypasses managed OpenCode and directly calls the MCP gateway] → Keep `deny` connector-enforced, document managed-workspace approval as the trust boundary, and do not present `ask` as a gateway-level guarantee. +- [Permission events may precede or outlive their tool part] → Correlate by stable IDs, hydrate referenced sessions, disable Zendesk approval responses until the preview resolves, and retain explicit retry/error UI. +- [Session-wide approval can authorize later calls] → Atomic tool names limit the grant to one visibility and operation class; keep the existing button copy explicit. +- [Canonical and compatibility fields can drift] → Centralize normalization and projection, update both in one encrypted-config write, and never read compatibility fields once a valid canonical version is present except for rollback projection checks. +- [Retiring composite names can interrupt an in-flight workspace] → Treat runtime regeneration/restart as the activation boundary and do not mutate already-running OpenCode config in place. + +## Migration Plan + +1. **Expand release:** add canonical parsing, restrictive in-memory migration, dual-write compatibility projections, settings API support for both request shapes, and tests. Keep the existing UI and composite tools active during this release so every running web version understands the new config key before it is written by users. +2. **Activation release:** switch the Zendesk UI to canonical action policies, publish the atomic tool inventory, derive runtime permissions from the canonical adapter, add approval previews, and stop advertising composite tools in newly generated runtime configurations. +3. Regenerate or restart affected workspace runtimes through the existing config-change lifecycle; already-running runtimes continue with their previous tool inventory until that boundary. +4. Retain legacy booleans, composite policy projections, and legacy-input parsing for at least one rollback window. Remove them only in a separately reviewed contract change after no deployed version depends on them. + +Rollback from the activation release restores the previous UI and composite tools. The compatibility projection remains readable by the older version and is intentionally at least as restrictive as the canonical policies, so rollback may temporarily hide or over-prompt an action but SHALL NOT silently grant broader access. Connector credentials and the canonical map remain intact. diff --git a/openspec/changes/unify-zendesk-action-permissions/proposal.md b/openspec/changes/unify-zendesk-action-permissions/proposal.md new file mode 100644 index 00000000..58d25dbb --- /dev/null +++ b/openspec/changes/unify-zendesk-action-permissions/proposal.md @@ -0,0 +1,31 @@ +## Why + +Zendesk currently has two overlapping permission models: boolean ticket/comment limits enforced by the connector and `deny`/`ask`/`allow` policies applied to MCP tools. This prevents users from requiring approval specifically for public comments or internal notes and makes the settings difficult to reason about. + +## What Changes + +- Replace Zendesk's boolean permission experience with one `deny`/`ask`/`allow` policy model covering ticket reads, ticket-field updates, public communication, and internal communication. +- Model public and internal Zendesk writes as distinct atomic MCP actions so OpenCode can apply a different policy to each action. +- Enforce `deny` as a connector-side hard boundary while routing `ask` through OpenCode's existing approval flow and executing `allow` without prompting. +- Present Zendesk permissions through one domain-oriented settings surface instead of independent connector-limit and generic tool-policy controls that can conflict. +- Show the proposed Zendesk operation and relevant arguments when approval is required, so users can review the ticket, visibility, content, and field changes before responding. +- Migrate existing boolean permissions and stored Zendesk tool policies to equivalent action policies, preserving the effective restriction whenever old settings conflict. +- **BREAKING**: Replace the composite `create_ticket` and `update_ticket` MCP write tools with visibility-specific and field-update actions; stored policies are migrated to the new tool names. + +## Capabilities + +### New Capabilities + +- `zendesk-action-permissions`: Defines Zendesk's action-level `deny`/`ask`/`allow` policies, approval behavior and previews, atomic MCP operations, and compatibility migration. + +### Modified Capabilities + +None. + +## Impact + +- Zendesk connector configuration types, parsing, validation, defaults, and encrypted-config migration. +- Zendesk MCP tool inventory, schemas, execution guards, and tests. +- Connector tool-policy storage and runtime OpenCode permission generation. +- Zendesk settings UI, approval-card/tool-call presentation, settings APIs, and audit metadata. +- Existing workspaces require regenerated runtime configuration to receive the migrated tool names and policies; no new external dependency is expected. diff --git a/openspec/changes/unify-zendesk-action-permissions/specs/zendesk-action-permissions/spec.md b/openspec/changes/unify-zendesk-action-permissions/specs/zendesk-action-permissions/spec.md new file mode 100644 index 00000000..89c19864 --- /dev/null +++ b/openspec/changes/unify-zendesk-action-permissions/specs/zendesk-action-permissions/spec.md @@ -0,0 +1,125 @@ +## Purpose + +Defines a single action-level permission model for Zendesk so ticket access, public communication, and internal communication can each be denied, approved interactively, or allowed automatically. + +## ADDED Requirements + +### Requirement: Configurable Zendesk action policies +The system SHALL assign exactly one `deny`, `ask`, or `allow` policy to each of the following Zendesk actions: search tickets, read ticket details, list ticket comments, create a ticket with a public comment, create a ticket with an internal note, update ticket fields without a comment, update a ticket with a public comment, and update a ticket with an internal note. + +#### Scenario: Configure different public and internal policies +- **WHEN** a user sets public ticket comments to `ask` and internal ticket notes to `allow` +- **THEN** the system stores and applies those policies independently to the corresponding Zendesk actions + +#### Scenario: Configure every ticket-access action +- **WHEN** a user selects a policy for a Zendesk read, create, or update action +- **THEN** the selected action accepts `deny`, `ask`, and `allow` with the same meaning used by connector tool permissions + +### Requirement: Atomic Zendesk write actions +The system SHALL expose visibility-specific Zendesk MCP write actions named `create_ticket_public`, `create_ticket_internal`, `update_ticket_fields`, `update_ticket_with_public_comment`, and `update_ticket_with_internal_note`. A visibility-specific update SHALL require a comment and MAY include ticket-field changes in the same Zendesk request, while `update_ticket_fields` SHALL NOT accept a comment. + +#### Scenario: Create a public ticket +- **WHEN** an agent needs to create a ticket whose initial comment is public +- **THEN** it invokes `create_ticket_public`, and the resulting Zendesk request marks the initial comment as public + +#### Scenario: Create a ticket with an internal note +- **WHEN** an agent needs to create a ticket whose initial comment is private to Zendesk agents +- **THEN** it invokes `create_ticket_internal`, and the resulting Zendesk request marks the initial comment as internal + +#### Scenario: Update fields and add a comment atomically +- **WHEN** an agent invokes a visibility-specific update with a comment and ticket-field changes +- **THEN** the system sends the comment and field changes in one Zendesk ticket update request with the visibility declared by the action name + +#### Scenario: Update fields without communication +- **WHEN** an agent invokes `update_ticket_fields` +- **THEN** the tool updates only supplied ticket fields and rejects comment input + +### Requirement: Denied actions are hard connector boundaries +The system SHALL omit an action with a `deny` policy from the Zendesk MCP tool inventory and SHALL reject a direct invocation of that action before sending any request to Zendesk. + +#### Scenario: Denied action is not advertised +- **WHEN** a client lists tools for a Zendesk connector with a denied action +- **THEN** the denied action is absent while non-denied actions remain available + +#### Scenario: Denied action is invoked directly +- **WHEN** a client directly invokes a denied Zendesk action despite its absence from the inventory +- **THEN** the connector returns an explicit `operation_not_allowed` error and sends no Zendesk request + +### Requirement: Managed workspaces honor ask and allow +For managed workspace execution, the system SHALL translate each non-denied Zendesk action policy into the matching OpenCode permission: `ask` SHALL pause the action for a user response, and `allow` SHALL execute it without a permission prompt. + +#### Scenario: Allow action executes directly +- **WHEN** an agent invokes a Zendesk action configured as `allow` +- **THEN** the managed workspace executes the action without creating a permission request + +#### Scenario: Ask action waits for approval +- **WHEN** an agent invokes a Zendesk action configured as `ask` +- **THEN** the managed workspace creates a pending approval and sends no Zendesk request until the user approves the action + +#### Scenario: User rejects an ask action +- **WHEN** the user rejects a pending Zendesk action +- **THEN** the action does not execute and no Zendesk request is sent + +#### Scenario: User allows an action once +- **WHEN** the user selects `Allow once` for a pending Zendesk action +- **THEN** only that invocation is approved by the response + +#### Scenario: User allows an action for the session +- **WHEN** the user selects `Allow for this session` for a pending Zendesk action +- **THEN** later invocations of that same atomic action in the current OpenCode session may execute without another prompt while differently named Zendesk actions retain their own policies + +### Requirement: Zendesk approval previews +The system SHALL present a pending Zendesk approval with the connector identity, human-readable action, and the proposed operation arguments available for review. The preview SHALL identify public versus internal visibility and SHALL show the subject, ticket identifier, comment body, and changed ticket fields when those values apply to the action. + +#### Scenario: Review a pending public comment +- **WHEN** a public-comment action requires approval +- **THEN** the approval identifies the action as public and displays the target ticket and proposed comment before the user responds + +#### Scenario: Review a pending ticket creation +- **WHEN** a ticket-creation action requires approval +- **THEN** the approval identifies the initial comment visibility and displays the proposed subject, comment, and optional ticket fields before the user responds + +### Requirement: Single Zendesk permission settings surface +The system SHALL provide one domain-oriented Zendesk settings surface for the action policies and SHALL NOT present a second independently editable generic tool-permission policy for the same Zendesk connector. + +#### Scenario: Edit Zendesk permissions +- **WHEN** a user opens Zendesk connector settings +- **THEN** every configurable Zendesk action is shown with a `Deny`, `Ask`, and `Allow` selector reflecting the single persisted policy state + +#### Scenario: Save Zendesk permissions +- **WHEN** a user saves valid Zendesk action policies +- **THEN** the settings API persists the complete policy state, records the sensitive configuration change in the audit log, and signals that workspace runtime configuration must be refreshed + +### Requirement: Legacy Zendesk permissions migrate without becoming less restrictive +The system SHALL normalize legacy Zendesk boolean permissions and legacy `create_ticket` or `update_ticket` tool policies into the new action-policy model. For each new action, the migrated policy SHALL be the most restrictive applicable legacy value using `deny` as more restrictive than `ask`, and `ask` as more restrictive than `allow`. + +#### Scenario: Migrate disabled public comments +- **WHEN** a legacy connector allows ticket updates but disables public comments +- **THEN** its public create and public update actions migrate to `deny` regardless of a less restrictive legacy create or update tool policy + +#### Scenario: Migrate an ask update policy +- **WHEN** a legacy connector allows ticket updates and internal comments and configures `update_ticket` as `ask` +- **THEN** `update_ticket_fields` and `update_ticket_with_internal_note` migrate to `ask` + +#### Scenario: Migrate a denied update policy +- **WHEN** a legacy connector allows ticket updates but configures `update_ticket` as `deny` +- **THEN** every new update action migrates to `deny` + +#### Scenario: Load a connector without explicit legacy permissions +- **WHEN** an existing or newly created Zendesk connector has no explicit permission configuration +- **THEN** all Zendesk actions default to `allow` to preserve the existing full-access default + +#### Scenario: Use migrated settings before an explicit save +- **WHEN** a legacy Zendesk connector is loaded for tool inventory or managed workspace configuration before the user opens or saves its settings +- **THEN** the system applies the normalized action policies in memory so legacy restrictions remain effective + +### Requirement: Composite Zendesk write tools are retired +The system SHALL stop advertising the legacy `create_ticket` and `update_ticket` MCP tools after action-policy migration and SHALL migrate their stored policy intent to the replacement actions. + +#### Scenario: List tools after migration +- **WHEN** a migrated Zendesk connector lists its MCP tools +- **THEN** `create_ticket` and `update_ticket` are absent and their applicable atomic replacement actions are present according to policy + +#### Scenario: Regenerate managed workspace configuration +- **WHEN** runtime configuration is regenerated for a workspace using a migrated Zendesk connector +- **THEN** permission entries target the replacement action names and no generated permission entry targets the retired composite names diff --git a/openspec/changes/unify-zendesk-action-permissions/tasks.md b/openspec/changes/unify-zendesk-action-permissions/tasks.md new file mode 100644 index 00000000..6e4b5893 --- /dev/null +++ b/openspec/changes/unify-zendesk-action-permissions/tasks.md @@ -0,0 +1,48 @@ +## 1. Compatibility Data Model + +- [ ] 1.1 Run `pnpm test` from `apps/web/` before implementation and record whether the existing suite passes as the baseline +- [ ] 1.2 Add the complete versioned Zendesk action-policy type, keys, default-allow value, and strict parser, and verify unit tests accept only complete `deny`/`ask`/`allow` maps with known actions +- [ ] 1.3 Implement restrictive in-memory normalization from legacy booleans and stored read/create/update tool policies, and verify table-driven tests cover every replacement action, missing settings, and `allow < ask < deny` conflict resolution +- [ ] 1.4 Implement the conservative legacy boolean and composite-tool policy projection from canonical actions, and verify tests prove an older runtime is never granted broader access than the canonical map +- [ ] 1.5 Update Zendesk config parsing and validation to expose canonical policies while preserving credentials and legacy compatibility fields, and verify existing connector validation tests plus canonical and legacy fixtures pass +- [ ] 1.6 Extend the Zendesk settings API to read canonical policies, accept both legacy and canonical request shapes during expansion, dual-write the compatibility projection in one encrypted update, and verify route tests cover authentication, validation, audit metadata, and round trips for both shapes +- [ ] 1.7 Prevent the generic tool-permissions endpoint from creating independent Zendesk policy state by delegating to the canonical adapter or returning an explicit unsupported-write response, and verify route tests cannot create a conflicting effective Zendesk policy + +## 2. Atomic Zendesk MCP Actions + +- [ ] 2.1 Define `create_ticket_public`, `create_ticket_internal`, `update_ticket_fields`, `update_ticket_with_public_comment`, and `update_ticket_with_internal_note` schemas, and verify tool-inventory tests assert required fields, omitted `publicComment`, and the absence of legacy composite tools in the activated inventory +- [ ] 2.2 Implement public and internal creation payloads whose visibility comes from the tool identity, and verify unit tests assert the exact Zendesk request body for each action +- [ ] 2.3 Implement field-only and visibility-specific update payloads while preserving one-request field-plus-comment updates, and verify tests cover comment rejection for `update_ticket_fields`, required comments for visibility-specific tools, optional fields, and empty-update errors +- [ ] 2.4 Filter denied actions from `tools/list` and reject direct denied invocations before network I/O, and verify MCP handler and tool tests cover `deny`, `ask`, and `allow` inventory/execution behavior +- [ ] 2.5 Update connector MCP route integration fixtures for the atomic actions, and verify `pnpm test -- tests/connectors-mcp-route.test.ts src/lib/connectors/mcp/__tests__/zendesk-handler.test.ts` passes from `apps/web/` + +## 3. Managed Runtime Policy Generation + +- [ ] 3.1 Add a connector-type policy adapter that supplies normalized canonical Zendesk actions while leaving other connectors on generic `mcpToolPermissions`, and verify MCP-config unit tests cover legacy normalization and canonical precedence +- [ ] 3.2 Expand canonical Zendesk actions into exact sanitized OpenCode permissions for every agent with the connector enabled, and verify transform tests map `deny`, `ask`, and `allow` independently for public, internal, field-update, and read actions +- [ ] 3.3 Keep explicit `deny` entries in generated runtime policy while excluding retired composite names, and verify runtime artifact and desktop workspace-host tests inspect the resulting tool and permission maps +- [ ] 3.4 Verify a session-level approval for one atomic Zendesk action does not authorize differently named actions using permission-flow tests against the generated configuration + +## 4. Unified Zendesk Settings Experience + +- [ ] 4.1 Replace boolean switches with grouped three-way `Deny`/`Ask`/`Allow` selectors backed by the complete canonical action map, and verify component tests cover loading, editing, disabled states, and labels for all eight actions +- [ ] 4.2 Remove the independently editable generic tool-permissions section from Zendesk settings and remove the old create/comment cross-field constraint, and verify the dialog accepts all-denied creation actions without showing duplicate policy controls +- [ ] 4.3 Save the full canonical map through the Zendesk settings API, emit the workspace-config-changed signal after success, and verify UI tests cover success, validation failure, network failure, and unchanged credential preservation +- [ ] 4.4 Update Zendesk settings response/request types and connector error copy without changing other connector dialogs, and verify the connector component and route test suites pass + +## 5. Zendesk Approval Previews + +- [ ] 5.1 Correlate pending permissions with per-session messages by session, message, and call IDs, and verify selector/reducer tests handle permission-first and tool-part-first event ordering +- [ ] 5.2 Hydrate messages referenced by pending permissions, including delegated child sessions, and verify reconnect tests resolve previews without grafting child permission cards into the parent transcript +- [ ] 5.3 Add a Zendesk preview formatter that recognizes atomic tool names and whitelists connector name, visibility, ticket ID, subject, comment, and supported changed fields, and verify unit tests exclude unknown metadata and credentials +- [ ] 5.4 Render the formatted preview in the approval card with escaped, contained comment text and disable responses while required Zendesk input is unresolved, and verify component tests cover loading, retrieval failure, public/internal labels, creation, and update previews +- [ ] 5.5 Exercise `Allow once`, `Allow for this session`, and `Reject` from a previewed Zendesk permission, and verify interaction tests send the existing response values and never submit while the preview is unavailable + +## 6. Rollout and Verification + +- [ ] 6.1 Prepare the compatibility data-model and API work as an independently deployable expand release, and verify legacy UI requests, legacy connector records, and rollback projections pass before enabling the activation work +- [ ] 6.2 After the expand release is available across the fleet, prepare the atomic tools, runtime mapping, unified UI, and previews as the activation release, and verify a regenerated workspace advertises only policy-eligible atomic Zendesk writes +- [ ] 6.3 Run `pnpm test` from `apps/web/` and verify the complete Vitest suite passes +- [ ] 6.4 Run `pnpm lint` and `pnpm build` from `apps/web/` and verify both commands pass +- [ ] 6.5 Run `bash scripts/check-podman-images.sh` from the repository root and treat any image-build failure as blocking +- [ ] 6.6 Verify rollback documentation and fixtures retain legacy booleans, composite policy projections, and legacy-input parsing for the agreed rollback window without re-advertising composite tools in activated runtime configuration From 91dc2880d8283a02031a3c5934cc5c6a3d10be7d Mon Sep 17 00:00:00 2001 From: Inakitajes Date: Mon, 31 Aug 2026 23:56:17 +0100 Subject: [PATCH 2/2] feat(zendesk): unify action permission handling - add canonical deny, ask, and allow policy mapping - add atomic tool-permission and Zendesk settings routes - update permission cards and connector settings UI - cover routes, MCP configuration, and permission flows with tests --- .../tool-permissions/__tests__/route.test.ts | 49 +++ .../connectors/[id]/tool-permissions/route.ts | 43 ++ .../zendesk-settings/__tests__/route.test.ts | 206 +++++++--- .../connectors/[id]/zendesk-settings/route.ts | 126 ++++-- ...zendesk-connector-settings-dialog.test.tsx | 264 +++++++----- .../zendesk-connector-settings-dialog.tsx | 290 +++++++------ .../__tests__/permission-card.test.tsx | 170 ++++++++ .../__tests__/sessions-panel.test.tsx | 4 +- .../src/components/workspace/chat-panel.tsx | 5 + .../workspace/chat-panel/messages.tsx | 4 + .../workspace/chat-panel/permission-card.tsx | 131 +++++- .../components/workspace/workspace-shell.tsx | 1 + ...kspace-permission-messages-effect.test.tsx | 103 +++++ .../hooks/workspace/use-workspace-composed.ts | 20 + .../hooks/workspace/use-workspace-effects.ts | 1 + .../workspace/use-workspace-event-bus.ts | 8 +- ...se-workspace-permission-messages-effect.ts | 40 ++ .../src/hooks/workspace/workspace-types.ts | 4 + .../zendesk-action-permissions.test.ts | 274 +++++++++++++ .../__tests__/zendesk-config.test.ts | 104 ++--- .../zendesk-permission-preview.test.ts | 102 +++++ .../__tests__/zendesk-tools-extended.test.ts | 120 +++--- .../__tests__/zendesk-tools.test.ts | 324 ++++++++++++--- .../connectors/zendesk-action-permissions.ts | 248 +++++++++++ apps/web/src/lib/connectors/zendesk-config.ts | 43 +- .../connectors/zendesk-permission-preview.ts | 142 +++++++ apps/web/src/lib/connectors/zendesk-tools.ts | 384 ++++++++++++------ apps/web/src/lib/connectors/zendesk-types.ts | 48 ++- apps/web/src/lib/connectors/zendesk.ts | 13 +- .../__tests__/permission-tool-parts.test.ts | 109 +++++ .../src/lib/opencode/permission-tool-parts.ts | 61 +++ .../__tests__/agent-config-transforms.test.ts | 40 ++ .../lib/spawner/__tests__/mcp-config.test.ts | 98 ++++- apps/web/src/lib/spawner/mcp-config.ts | 17 +- apps/web/tests/connectors-mcp-route.test.ts | 55 +-- .../connectors-zendesk-settings-route.test.ts | 76 +++- apps/web/tests/connectors.test.ts | 5 +- .../unify-zendesk-action-permissions/tasks.md | 62 +-- 38 files changed, 3062 insertions(+), 732 deletions(-) create mode 100644 apps/web/src/components/workspace/__tests__/permission-card.test.tsx create mode 100644 apps/web/src/hooks/workspace/__tests__/use-workspace-permission-messages-effect.test.tsx create mode 100644 apps/web/src/hooks/workspace/use-workspace-permission-messages-effect.ts create mode 100644 apps/web/src/lib/connectors/__tests__/zendesk-action-permissions.test.ts create mode 100644 apps/web/src/lib/connectors/__tests__/zendesk-permission-preview.test.ts create mode 100644 apps/web/src/lib/connectors/zendesk-action-permissions.ts create mode 100644 apps/web/src/lib/connectors/zendesk-permission-preview.ts create mode 100644 apps/web/src/lib/opencode/__tests__/permission-tool-parts.test.ts create mode 100644 apps/web/src/lib/opencode/permission-tool-parts.ts diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/__tests__/route.test.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/__tests__/route.test.ts index b0bd789d..187925b6 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/__tests__/route.test.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/__tests__/route.test.ts @@ -288,4 +288,53 @@ describe('/api/u/[slug]/connectors/[id]/tool-permissions', () => { expect(res.status).toBe(404) await expect(res.json()).resolves.toEqual({ error: 'connector_not_found' }) }) + + describe('zendesk connectors', () => { + const ZENDESK_CONNECTOR = { id: 'c1', type: 'zendesk', config: 'encrypted', enabled: true } + + beforeEach(() => { + mocks.connectorService.findByIdAndUserId.mockResolvedValue(ZENDESK_CONNECTOR) + mocks.decryptConfig.mockReturnValue({ + subdomain: 'test', + email: 'a@b.com', + apiToken: 'tok', + permissions: { + allowRead: true, + allowCreateTickets: true, + allowUpdateTickets: true, + allowPublicComments: false, + allowInternalComments: true, + }, + }) + }) + + it('GET projects the normalized canonical actions instead of stored tool permissions', async () => { + const res = await GET(makeGetRequest(), params()) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.policyConfigured).toBe(true) + expect(body.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'create_ticket_public', permission: 'deny' }), + expect.objectContaining({ name: 'create_ticket_internal', permission: 'allow' }), + expect.objectContaining({ name: 'search_tickets', permission: 'allow' }), + ]) + ) + expect(body.tools).toHaveLength(8) + }) + + it('PATCH rejects writes so no independent Zendesk policy state can be created', async () => { + const res = await PATCH( + makePatchRequest({ permissions: { create_ticket_public: 'allow' } }), + params(), + ) + const body = await res.json() + + expect(res.status).toBe(409) + expect(body.error).toBe('unsupported_connector') + expect(mocks.encryptConfig).not.toHaveBeenCalled() + expect(mocks.connectorService.updateManyByIdAndUserId).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/route.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/route.ts index 3958d32c..1ed59aa4 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/route.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/tool-permissions/route.ts @@ -15,6 +15,13 @@ import { } from '@/lib/connectors/tool-permissions' import type { ConnectorType } from '@/lib/connectors/types' import { validateConnectorConfig, validateConnectorType } from '@/lib/connectors/validators' +import { + normalizeZendeskActionPermissions, +} from '@/lib/connectors/zendesk' +import { + ZENDESK_ACTION_KEYS, + type ZendeskActionName, +} from '@/lib/connectors/zendesk-types' import { requireCapability } from '@/lib/runtime/require-capability' import { withAuth } from '@/lib/runtime/with-auth' import { connectorService, userService } from '@/lib/services' @@ -45,6 +52,28 @@ function fallbackToolsFromStoredPermissions( })) } +function toZendeskActionTitle(name: string): string { + const formatted = name.replace(/_/g, ' ').trim() + return formatted ? formatted.charAt(0).toUpperCase() + formatted.slice(1) : name +} + +// Zendesk policies are canonical action state, not generic tool-permission +// state: the read path projects the normalized actions so the response can +// never disagree with Zendesk settings, and writes are rejected so a second, +// independently editable policy surface cannot exist. +function buildZendeskToolPermissionsResponse( + config: Record +): ConnectorToolPermissionsResponse { + const actions = normalizeZendeskActionPermissions(config) + const tools: ConnectorToolPermissionEntry[] = ZENDESK_ACTION_KEYS.map((action: ZendeskActionName) => ({ + name: action, + title: toZendeskActionTitle(action), + permission: actions[action], + })) + + return { tools, policyConfigured: true } +} + async function buildToolPermissionsResponse(input: { connectorType: ConnectorType config: Record @@ -135,6 +164,10 @@ export const GET = withAuth< const context = await getConnectorContext(slug, id) if (!context.ok) return context.response + if (context.connectorType === 'zendesk') { + return NextResponse.json(buildZendeskToolPermissionsResponse(context.config)) + } + return NextResponse.json( await buildToolPermissionsResponse({ connectorType: context.connectorType, @@ -153,6 +186,16 @@ export const PATCH = withAuth< const context = await getConnectorContext(slug, id) if (!context.ok) return context.response + if (context.connectorType === 'zendesk') { + return NextResponse.json( + { + error: 'unsupported_connector', + message: 'Zendesk tool permissions are managed through Zendesk settings.', + }, + { status: 409 }, + ) + } + let body: UpdateConnectorToolPermissionsRequest try { body = await request.json() diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/__tests__/route.test.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/__tests__/route.test.ts index 3e9e3d96..e5603811 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/__tests__/route.test.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/__tests__/route.test.ts @@ -11,9 +11,6 @@ const mocks = vi.hoisted(() => ({ auditEvent: vi.fn(), decryptConfig: vi.fn(), encryptConfig: vi.fn(), - parseZendeskConnectorConfig: vi.fn(), - parseZendeskConnectorPermissions: vi.fn(), - getZendeskConnectorPermissionsConstraintMessage: vi.fn(() => null), connectorService: { findByIdAndUserId: vi.fn(), updateManyByIdAndUserId: vi.fn(), @@ -35,17 +32,13 @@ vi.mock('@/lib/connectors/crypto', () => ({ decryptConfig: mocks.decryptConfig, encryptConfig: mocks.encryptConfig, })) -vi.mock('@/lib/connectors/zendesk', () => ({ - parseZendeskConnectorConfig: mocks.parseZendeskConnectorConfig, - parseZendeskConnectorPermissions: mocks.parseZendeskConnectorPermissions, - getZendeskConnectorPermissionsConstraintMessage: mocks.getZendeskConnectorPermissionsConstraintMessage, -})) vi.mock('@/lib/services', () => ({ connectorService: mocks.connectorService, userService: mocks.userService, })) import { GET, PATCH } from '../route' +import { DEFAULT_ZENDESK_ACTION_PERMISSIONS } from '@/lib/connectors/zendesk-types' const SESSION = { user: { id: 'u1', email: 'admin@test.com', slug: 'admin', role: 'ADMIN' }, @@ -54,13 +47,16 @@ const SESSION = { const CONNECTOR = { id: 'c1', type: 'zendesk', config: 'encrypted', enabled: true } -const PARSED_CONFIG = { - ok: true as const, - value: { - subdomain: 'test', - email: 'a@b.com', - apiToken: 'tok', - permissions: { tickets: { read: true, write: false } }, +const LEGACY_CONFIG = { + subdomain: 'test', + email: 'a@b.com', + apiToken: 'tok', + permissions: { + allowRead: true, + allowCreateTickets: true, + allowUpdateTickets: true, + allowPublicComments: true, + allowInternalComments: true, }, } @@ -86,14 +82,39 @@ describe('GET /api/u/[slug]/connectors/[id]/zendesk-settings', () => { mocks.getSession.mockResolvedValue(SESSION) mocks.userService.findIdBySlug.mockResolvedValue({ id: 'u1' }) mocks.connectorService.findByIdAndUserId.mockResolvedValue(CONNECTOR) - mocks.decryptConfig.mockReturnValue({ subdomain: 'test' }) - mocks.parseZendeskConnectorConfig.mockReturnValue(PARSED_CONFIG) + mocks.decryptConfig.mockReturnValue({ ...LEGACY_CONFIG }) + }) + + it('returns legacy permissions and normalized canonical actions', async () => { + const res = await GET(makeGetRequest(), params()) + const body = await res.json() + expect(body.permissions).toEqual(LEGACY_CONFIG.permissions) + expect(body.zendeskActionPermissions).toEqual({ + version: 1, + actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS, + }) + }) + + it('normalizes disabled legacy booleans in memory without a save', async () => { + mocks.decryptConfig.mockReturnValue({ + ...LEGACY_CONFIG, + permissions: { ...LEGACY_CONFIG.permissions, allowPublicComments: false }, + }) + const res = await GET(makeGetRequest(), params()) + const body = await res.json() + expect(body.zendeskActionPermissions.actions.create_ticket_public).toBe('deny') + expect(body.zendeskActionPermissions.actions.create_ticket_internal).toBe('allow') }) - it('returns permissions on success', async () => { + it('returns stored canonical actions when present', async () => { + const actions = { ...DEFAULT_ZENDESK_ACTION_PERMISSIONS, update_ticket_fields: 'ask' as const } + mocks.decryptConfig.mockReturnValue({ + ...LEGACY_CONFIG, + zendeskActionPermissions: { version: 1, actions }, + }) const res = await GET(makeGetRequest(), params()) const body = await res.json() - expect(body.permissions).toEqual({ tickets: { read: true, write: false } }) + expect(body.zendeskActionPermissions).toEqual({ version: 1, actions }) }) it('returns 404 when user not found', async () => { @@ -121,7 +142,7 @@ describe('GET /api/u/[slug]/connectors/[id]/zendesk-settings', () => { }) it('returns 500 when config parsing fails', async () => { - mocks.parseZendeskConnectorConfig.mockReturnValue({ ok: false, missing: ['subdomain'] }) + mocks.decryptConfig.mockReturnValue({ subdomain: 'test' }) const res = await GET(makeGetRequest(), params()) expect(res.status).toBe(500) }) @@ -133,81 +154,156 @@ describe('PATCH /api/u/[slug]/connectors/[id]/zendesk-settings', () => { mocks.getSession.mockResolvedValue(SESSION) mocks.userService.findIdBySlug.mockResolvedValue({ id: 'u1' }) mocks.connectorService.findByIdAndUserId.mockResolvedValue(CONNECTOR) - mocks.decryptConfig.mockReturnValue({ subdomain: 'test' }) - mocks.parseZendeskConnectorConfig.mockReturnValue(PARSED_CONFIG) - mocks.parseZendeskConnectorPermissions.mockReturnValue({ - ok: true, - value: { tickets: { read: true, write: true } }, - }) - mocks.getZendeskConnectorPermissionsConstraintMessage.mockReturnValue(null) + mocks.decryptConfig.mockReturnValue({ ...LEGACY_CONFIG }) mocks.encryptConfig.mockReturnValue('new-encrypted') mocks.connectorService.updateManyByIdAndUserId.mockResolvedValue({ count: 1 }) }) - it('updates permissions and audits', async () => { + it('persists canonical actions and dual-writes the legacy projection in one update', async () => { + const actions = { + ...DEFAULT_ZENDESK_ACTION_PERMISSIONS, + create_ticket_public: 'deny' as const, + update_ticket_with_internal_note: 'ask' as const, + } const res = await PATCH( - makePatchRequest({ permissions: { tickets: { read: true, write: true } } }), + makePatchRequest({ zendeskActionPermissions: { version: 1, actions } }), params(), ) + expect(res.status).toBe(200) const body = await res.json() - expect(body.permissions).toEqual({ tickets: { read: true, write: true } }) + expect(body.zendeskActionPermissions).toEqual({ version: 1, actions }) + + const written = mocks.encryptConfig.mock.calls[0][0] as Record + expect(written.zendeskActionPermissions).toEqual({ version: 1, actions }) + expect(written.permissions).toEqual({ + allowRead: true, + allowCreateTickets: false, + allowUpdateTickets: true, + allowPublicComments: false, + allowInternalComments: true, + }) + expect(written.mcpToolPermissions).toEqual({ + search_tickets: 'allow', + get_ticket: 'allow', + list_ticket_comments: 'allow', + create_ticket: 'deny', + update_ticket: 'ask', + }) + expect(mocks.connectorService.updateManyByIdAndUserId).toHaveBeenCalledTimes(1) expect(mocks.auditEvent).toHaveBeenCalledWith( - expect.objectContaining({ action: 'connector.zendesk_settings_updated' }), + expect.objectContaining({ + action: 'connector.zendesk_settings_updated', + metadata: expect.objectContaining({ connectorId: 'c1', zendeskActionPermissions: { version: 1, actions } }), + }), ) }) - it('returns 400 for invalid JSON', async () => { - const req = new NextRequest('http://localhost/api/u/admin/connectors/c1/zendesk-settings', { - method: 'PATCH', - body: 'bad json', - headers: { 'Content-Type': 'application/json', Origin: 'http://localhost' }, + it('normalizes a legacy boolean request into canonical actions', async () => { + const res = await PATCH( + makePatchRequest({ + permissions: { + allowRead: true, + allowCreateTickets: true, + allowUpdateTickets: true, + allowPublicComments: false, + allowInternalComments: true, + }, + }), + params(), + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.zendeskActionPermissions.actions.create_ticket_public).toBe('deny') + expect(body.zendeskActionPermissions.actions.create_ticket_internal).toBe('allow') + + const written = mocks.encryptConfig.mock.calls[0][0] as Record + expect(written.zendeskActionPermissions).toEqual(body.zendeskActionPermissions) + }) + + it('preserves unrelated stored tool-permission entries when projecting', async () => { + mocks.decryptConfig.mockReturnValue({ + ...LEGACY_CONFIG, + mcpToolPermissions: { custom_entry: 'deny' }, }) - const res = await PATCH(req, params()) + await PATCH( + makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), + params(), + ) + const written = mocks.encryptConfig.mock.calls[0][0] as Record + expect(written.mcpToolPermissions).toEqual( + expect.objectContaining({ custom_entry: 'deny', create_ticket: 'allow', update_ticket: 'allow' }) + ) + }) + + it('preserves credentials in the updated config', async () => { + await PATCH( + makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), + params(), + ) + const written = mocks.encryptConfig.mock.calls[0][0] as Record + expect(written.subdomain).toBe('test') + expect(written.email).toBe('a@b.com') + expect(written.apiToken).toBe('tok') + }) + + it('returns 400 for an invalid canonical payload', async () => { + const res = await PATCH( + makePatchRequest({ zendeskActionPermissions: { version: 1, actions: { search_tickets: 'allow' } } }), + params(), + ) expect(res.status).toBe(400) }) - it('returns 400 when permissions validation fails', async () => { - mocks.parseZendeskConnectorPermissions.mockReturnValue({ - ok: false, - message: 'invalid field', - }) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + it('returns 400 for an invalid legacy permissions payload', async () => { + const res = await PATCH( + makePatchRequest({ permissions: { allowRead: 'yes' } }), + params(), + ) expect(res.status).toBe(400) }) - it('returns 400 when constraint message exists', async () => { - mocks.getZendeskConnectorPermissionsConstraintMessage.mockReturnValue('At least one must be enabled') - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + it('returns 400 when neither shape is provided', async () => { + const res = await PATCH(makePatchRequest({}), params()) + expect(res.status).toBe(400) + }) + + it('returns 400 for invalid JSON', async () => { + const req = new NextRequest('http://localhost/api/u/admin/connectors/c1/zendesk-settings', { + method: 'PATCH', + body: 'bad json', + headers: { 'Content-Type': 'application/json', Origin: 'http://localhost' }, + }) + const res = await PATCH(req, params()) expect(res.status).toBe(400) }) it('returns 404 when update affects 0 rows', async () => { mocks.connectorService.updateManyByIdAndUserId.mockResolvedValue({ count: 0 }) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + const res = await PATCH(makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), params()) expect(res.status).toBe(404) }) it('returns 400 when encryption fails', async () => { mocks.encryptConfig.mockImplementation(() => { throw new Error('too large') }) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + const res = await PATCH(makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), params()) expect(res.status).toBe(400) }) it('returns 404 when user is not found', async () => { mocks.userService.findIdBySlug.mockResolvedValue(null) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + const res = await PATCH(makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), params()) expect(res.status).toBe(404) }) it('returns 404 when connector is not found', async () => { mocks.connectorService.findByIdAndUserId.mockResolvedValue(null) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + const res = await PATCH(makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), params()) expect(res.status).toBe(404) }) it('returns 400 when connector is not zendesk', async () => { mocks.connectorService.findByIdAndUserId.mockResolvedValue({ ...CONNECTOR, type: 'linear' }) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + const res = await PATCH(makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), params()) expect(res.status).toBe(400) }) @@ -218,13 +314,7 @@ describe('PATCH /api/u/[slug]/connectors/[id]/zendesk-settings', () => { it('returns 500 when decryption fails', async () => { mocks.decryptConfig.mockImplementation(() => { throw new Error('bad') }) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) - expect(res.status).toBe(500) - }) - - it('returns 500 when existing config parsing fails', async () => { - mocks.parseZendeskConnectorConfig.mockReturnValue({ ok: false, message: 'invalid config' }) - const res = await PATCH(makePatchRequest({ permissions: {} }), params()) + const res = await PATCH(makePatchRequest({ zendeskActionPermissions: { version: 1, actions: DEFAULT_ZENDESK_ACTION_PERMISSIONS } }), params()) expect(res.status).toBe(500) }) }) diff --git a/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/route.ts b/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/route.ts index db7700de..ba89d76d 100644 --- a/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/route.ts +++ b/apps/web/src/app/api/u/[slug]/connectors/[id]/zendesk-settings/route.ts @@ -3,21 +3,38 @@ import { NextRequest, NextResponse } from 'next/server' import { auditEvent } from '@/lib/auth' import { decryptConfig, encryptConfig } from '@/lib/connectors/crypto' import { - getZendeskConnectorPermissionsConstraintMessage, + getStoredConnectorToolPermissions, +} from '@/lib/connectors/tool-permissions' +import { + buildLegacyProjectionFromActionPermissions, + mergeLegacyToolPermissions, + normalizeZendeskActionPermissions, + parseZendeskActionPermissionsConfig, parseZendeskConnectorConfig, parseZendeskConnectorPermissions, - type ZendeskConnectorPermissions, + type ZendeskActionPermissions, } from '@/lib/connectors/zendesk' +import { + ZENDESK_ACTION_PERMISSIONS_CONFIG_KEY, + ZENDESK_ACTION_PERMISSIONS_VERSION, +} from '@/lib/connectors/zendesk-types' import { requireCapability } from '@/lib/runtime/require-capability' import { withAuth } from '@/lib/runtime/with-auth' import { connectorService, userService } from '@/lib/services' +type ZendeskActionPermissionsPayload = { + version: typeof ZENDESK_ACTION_PERMISSIONS_VERSION + actions: ZendeskActionPermissions +} + type ZendeskConnectorSettingsResponse = { - permissions: ZendeskConnectorPermissions + permissions: Record + zendeskActionPermissions: ZendeskActionPermissionsPayload } type UpdateZendeskConnectorSettingsRequest = { permissions?: unknown + zendeskActionPermissions?: unknown } function isObjectRecord(value: unknown): value is Record { @@ -66,9 +83,43 @@ export const GET = withAuth< ) } - return NextResponse.json({ permissions: parsedConfig.value.permissions }) + return NextResponse.json({ + permissions: parsedConfig.value.permissions, + zendeskActionPermissions: { + version: ZENDESK_ACTION_PERMISSIONS_VERSION, + actions: normalizeZendeskActionPermissions(config), + }, + }) }) +function buildUpdatedConfig(input: { + config: Record + parsedConfig: Extract, { ok: true }>['value'] + actions: ZendeskActionPermissions +}): { + config: Record + permissions: Record +} { + const projection = buildLegacyProjectionFromActionPermissions(input.actions) + + return { + config: { + ...input.config, + ...input.parsedConfig, + permissions: projection.permissions, + mcpToolPermissions: mergeLegacyToolPermissions( + getStoredConnectorToolPermissions(input.config), + projection.legacyToolPermissions + ), + [ZENDESK_ACTION_PERMISSIONS_CONFIG_KEY]: { + version: ZENDESK_ACTION_PERMISSIONS_VERSION, + actions: input.actions, + }, + }, + permissions: projection.permissions, + } +} + export const PATCH = withAuth< ZendeskConnectorSettingsResponse | { error: string; message?: string }, { slug: string; id: string } @@ -108,20 +159,30 @@ export const PATCH = withAuth< ) } - const parsedPermissions = parseZendeskConnectorPermissions(body.permissions, { requireAll: true }) - if (!parsedPermissions.ok) { - return NextResponse.json( - { error: 'invalid_permissions', message: parsedPermissions.message }, - { status: 400 } - ) - } - - const permissionsMessage = getZendeskConnectorPermissionsConstraintMessage( - parsedPermissions.value - ) - if (permissionsMessage) { + let update: + | { kind: 'canonical'; actions: ZendeskActionPermissions } + | { kind: 'legacy'; permissions: Record } + if (body.zendeskActionPermissions !== undefined) { + const parsedActions = parseZendeskActionPermissionsConfig(body.zendeskActionPermissions) + if (!parsedActions.ok) { + return NextResponse.json( + { error: 'invalid_permissions', message: parsedActions.message }, + { status: 400 } + ) + } + update = { kind: 'canonical', actions: parsedActions.value.actions } + } else if (body.permissions !== undefined) { + const parsedPermissions = parseZendeskConnectorPermissions(body.permissions, { requireAll: true }) + if (!parsedPermissions.ok) { + return NextResponse.json( + { error: 'invalid_permissions', message: parsedPermissions.message }, + { status: 400 } + ) + } + update = { kind: 'legacy', permissions: parsedPermissions.value } + } else { return NextResponse.json( - { error: 'invalid_permissions', message: permissionsMessage }, + { error: 'invalid_permissions', message: 'permissions or zendeskActionPermissions is required' }, { status: 400 } ) } @@ -147,15 +208,20 @@ export const PATCH = withAuth< ) } - const updatedConfig = { - ...config, - ...parsedConfig.value, - permissions: parsedPermissions.value, - } + // A legacy boolean request is normalized into canonical actions before it is + // persisted, so both request shapes converge on the same stored state. + const actions = update.kind === 'canonical' + ? update.actions + : normalizeZendeskActionPermissions({ + ...config, + permissions: update.permissions, + }) + + const updated = buildUpdatedConfig({ config, parsedConfig: parsedConfig.value, actions }) let encryptedConfig: string try { - encryptedConfig = encryptConfig(updatedConfig) + encryptedConfig = encryptConfig(updated.config) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to encrypt config' return NextResponse.json({ error: 'invalid_config', message }, { status: 400 }) @@ -173,9 +239,19 @@ export const PATCH = withAuth< action: 'connector.zendesk_settings_updated', metadata: { connectorId: id, - permissions: parsedPermissions.value, + permissions: updated.permissions, + zendeskActionPermissions: { + version: ZENDESK_ACTION_PERMISSIONS_VERSION, + actions, + }, }, }) - return NextResponse.json({ permissions: parsedPermissions.value }) + return NextResponse.json({ + permissions: updated.permissions, + zendeskActionPermissions: { + version: ZENDESK_ACTION_PERMISSIONS_VERSION, + actions, + }, + }) }) diff --git a/apps/web/src/components/connectors/__tests__/zendesk-connector-settings-dialog.test.tsx b/apps/web/src/components/connectors/__tests__/zendesk-connector-settings-dialog.test.tsx index ce77fbe8..a7277468 100644 --- a/apps/web/src/components/connectors/__tests__/zendesk-connector-settings-dialog.test.tsx +++ b/apps/web/src/components/connectors/__tests__/zendesk-connector-settings-dialog.test.tsx @@ -4,22 +4,49 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ZendeskConnectorSettingsDialog } from '@/components/connectors/zendesk-connector-settings-dialog' - -function getPermissionSwitch(label: string): HTMLButtonElement { +import { + DEFAULT_ZENDESK_ACTION_PERMISSIONS, + ZENDESK_ACTION_KEYS, + type ZendeskActionName, + type ZendeskActionPermissions, + type ZendeskActionPolicy, +} from '@/lib/connectors/zendesk-types' + +const mocks = vi.hoisted(() => ({ + notifyWorkspaceConfigChanged: vi.fn(), +})) + +vi.mock('@/lib/runtime/config-status-events', () => ({ + notifyWorkspaceConfigChanged: mocks.notifyWorkspaceConfigChanged, +})) + +function getActionButtons(label: string): HTMLButtonElement[] { const labelElement = screen.getByText(label) const field = labelElement.parentElement?.parentElement - const switchElement = field?.querySelector('[role="switch"]') + const buttons = Array.from(field?.querySelectorAll('button') ?? []) + + if (buttons.length !== 3) { + throw new Error(`Policy selector not found for ${label}`) + } - if (!(switchElement instanceof HTMLButtonElement)) { - throw new Error(`Switch not found for ${label}`) + return buttons as HTMLButtonElement[] +} + +function settingsResponse(actions: ZendeskActionPermissions) { + return { + permissions: {}, + zendeskActionPermissions: { version: 1, actions }, } +} - return switchElement +function actionsWith(overrides: Partial>): ZendeskActionPermissions { + return { ...DEFAULT_ZENDESK_ACTION_PERMISSIONS, ...overrides } } describe('ZendeskConnectorSettingsDialog', () => { beforeEach(() => { vi.restoreAllMocks() + mocks.notifyWorkspaceConfigChanged.mockClear() }) afterEach(() => { @@ -80,8 +107,13 @@ describe('ZendeskConnectorSettingsDialog', () => { const saveButton = screen.getByRole('button', { name: 'Save settings' }) as HTMLButtonElement expect(saveButton.disabled).toBe(true) - for (const switchElement of screen.getAllByRole('switch')) { - expect((switchElement as HTMLButtonElement).disabled).toBe(true) + for (const selectorLabel of [ + 'Search tickets', + 'Create tickets with a public comment', + ]) { + for (const button of getActionButtons(selectorLabel)) { + expect(button.disabled).toBe(true) + } } fireEvent.click(saveButton) @@ -91,26 +123,11 @@ describe('ZendeskConnectorSettingsDialog', () => { }) }) - it('prevents enabling ticket creation without an allowed comment type', async () => { + it('renders a Deny/Ask/Allow selector for all eight actions without a generic tool section', async () => { const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, - json: async () => ({ - permissions: { - allowRead: true, - allowCreateTickets: false, - allowUpdateTickets: true, - allowPublicComments: false, - allowInternalComments: false, - }, - }), - }).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - tools: [], - policyConfigured: false, - }), + json: async () => settingsResponse(DEFAULT_ZENDESK_ACTION_PERMISSIONS), }) - vi.stubGlobal('fetch', fetchMock) render( @@ -124,63 +141,73 @@ describe('ZendeskConnectorSettingsDialog', () => { ) await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(1) }) - expect(screen.getByText('Enable public comments or internal notes before allowing ticket creation.')).toBeTruthy() + const labels = [ + 'Search tickets', + 'Read ticket details', + 'List ticket comments', + 'Update ticket fields', + 'Create tickets with a public comment', + 'Update tickets with a public comment', + 'Create tickets with an internal note', + 'Update tickets with an internal note', + ] + for (const label of labels) { + const [deny, ask, allow] = getActionButtons(label) + expect(deny.textContent).toBe('Deny') + expect(ask.textContent).toBe('Ask') + expect(allow.textContent).toBe('Allow') + } + expect(screen.queryByText('Tool permissions')).toBeNull() + }) - const createTicketsSwitch = getPermissionSwitch('Create tickets') - expect(createTicketsSwitch.disabled).toBe(true) + it('accepts all-denied creation actions without cross-field constraints', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => settingsResponse(DEFAULT_ZENDESK_ACTION_PERMISSIONS), + }) + vi.stubGlobal('fetch', fetchMock) - fireEvent.click(getPermissionSwitch('Public comments')) + render( + + ) - expect(getPermissionSwitch('Public comments').getAttribute('aria-checked')).toBe('true') - expect(getPermissionSwitch('Create tickets').disabled).toBe(false) + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1) + }) - fireEvent.click(getPermissionSwitch('Create tickets')) + fireEvent.click(getActionButtons('Create tickets with a public comment')[0]) + fireEvent.click(getActionButtons('Create tickets with an internal note')[0]) + fireEvent.click(getActionButtons('Update tickets with a public comment')[0]) + fireEvent.click(getActionButtons('Update tickets with an internal note')[0]) - expect(getPermissionSwitch('Create tickets').getAttribute('aria-checked')).toBe('true') - expect(getPermissionSwitch('Public comments').disabled).toBe(true) - expect( - screen.getByText( - 'Ticket creation needs at least one comment option. Disable ticket creation first to turn off the last enabled comment type.' - ) - ).toBeTruthy() + const saveButton = screen.getByRole('button', { name: 'Save settings' }) as HTMLButtonElement + expect(saveButton.disabled).toBe(false) + expect(screen.queryByText(/Ticket creation requires/)).toBeNull() }) - it('saves loaded permissions and closes the dialog', async () => { + it('loads, edits, saves the complete canonical map, and closes the dialog', async () => { const onOpenChange = vi.fn() + const loaded = actionsWith({ + create_ticket_public: 'ask', + update_ticket_fields: 'deny', + }) const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, - json: async () => ({ - permissions: { - allowRead: true, - allowCreateTickets: false, - allowUpdateTickets: true, - allowPublicComments: true, - allowInternalComments: false, - }, - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - tools: [], - policyConfigured: false, - }), + json: async () => settingsResponse(loaded), }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ - permissions: { - allowRead: false, - allowCreateTickets: false, - allowUpdateTickets: true, - allowPublicComments: true, - allowInternalComments: false, - }, - }), + json: async () => settingsResponse(loaded), }) vi.stubGlobal('fetch', fetchMock) @@ -196,27 +223,30 @@ describe('ZendeskConnectorSettingsDialog', () => { ) await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(1) }) - fireEvent.click(getPermissionSwitch('Read tickets')) + fireEvent.click(getActionButtons('List ticket comments')[0]) + fireEvent.click(screen.getByRole('button', { name: 'Save settings' })) await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock).toHaveBeenCalledTimes(2) }) - const [, patchRequest] = fetchMock.mock.calls[2] as [string, RequestInit] + const [, patchRequest] = fetchMock.mock.calls[1] as [string, RequestInit] expect(patchRequest.method).toBe('PATCH') expect(JSON.parse(String(patchRequest.body))).toEqual({ - permissions: { - allowRead: false, - allowCreateTickets: false, - allowUpdateTickets: true, - allowPublicComments: true, - allowInternalComments: false, + zendeskActionPermissions: { + version: 1, + actions: actionsWith({ + create_ticket_public: 'ask', + update_ticket_fields: 'deny', + list_ticket_comments: 'deny', + }), }, }) expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mocks.notifyWorkspaceConfigChanged).toHaveBeenCalledOnce() }) it('shows save errors without closing the dialog', async () => { @@ -224,22 +254,7 @@ describe('ZendeskConnectorSettingsDialog', () => { const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, - json: async () => ({ - permissions: { - allowRead: true, - allowCreateTickets: false, - allowUpdateTickets: true, - allowPublicComments: true, - allowInternalComments: false, - }, - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - tools: [], - policyConfigured: false, - }), + json: async () => settingsResponse(DEFAULT_ZENDESK_ACTION_PERMISSIONS), }) .mockResolvedValueOnce({ ok: false, @@ -259,12 +274,75 @@ describe('ZendeskConnectorSettingsDialog', () => { ) await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(1) }) + fireEvent.click(getActionButtons('Search tickets')[0]) fireEvent.click(screen.getByRole('button', { name: 'Save settings' })) expect(await screen.findByText('Failed to save connector changes.')).toBeTruthy() expect(onOpenChange).not.toHaveBeenCalled() + expect(mocks.notifyWorkspaceConfigChanged).not.toHaveBeenCalled() + }) + + it('preserves loaded policies across every action when saving unchanged state', async () => { + const onOpenChange = vi.fn() + const loaded = actionsWith( + Object.fromEntries(ZENDESK_ACTION_KEYS.map((key, index) => [key, (['deny', 'ask', 'allow'] as const)[index % 3]])) as + Partial> + ) + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => settingsResponse(loaded), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => settingsResponse(loaded), + }) + + vi.stubGlobal('fetch', fetchMock) + + render( + + ) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + for (const [action, policy] of Object.entries(loaded)) { + const policyIndex = policy === 'deny' ? 0 : policy === 'ask' ? 1 : 2 + const buttons = getActionButtons( + { + search_tickets: 'Search tickets', + get_ticket: 'Read ticket details', + list_ticket_comments: 'List ticket comments', + create_ticket_public: 'Create tickets with a public comment', + create_ticket_internal: 'Create tickets with an internal note', + update_ticket_fields: 'Update ticket fields', + update_ticket_with_public_comment: 'Update tickets with a public comment', + update_ticket_with_internal_note: 'Update tickets with an internal note', + }[action as ZendeskActionName] + ) + expect(buttons[policyIndex].className).toContain('bg-primary') + } + + fireEvent.click(screen.getByRole('button', { name: 'Save settings' })) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + const [, patchRequest] = fetchMock.mock.calls[1] as [string, RequestInit] + expect(JSON.parse(String(patchRequest.body))).toEqual({ + zendeskActionPermissions: { version: 1, actions: loaded }, + }) + expect(onOpenChange).toHaveBeenCalledWith(false) }) }) diff --git a/apps/web/src/components/connectors/zendesk-connector-settings-dialog.tsx b/apps/web/src/components/connectors/zendesk-connector-settings-dialog.tsx index 642c6d8b..74909289 100644 --- a/apps/web/src/components/connectors/zendesk-connector-settings-dialog.tsx +++ b/apps/web/src/components/connectors/zendesk-connector-settings-dialog.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from 'react' import { SpinnerGap } from '@phosphor-icons/react' import { getConnectorErrorMessage } from '@/components/connectors/error-messages' -import { ConnectorToolPermissionsSection } from '@/components/connectors/connector-tool-permissions-section' import { Button } from '@/components/ui/button' import { Dialog, @@ -13,14 +12,14 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { Switch } from '@/components/ui/switch' import { - getZendeskConnectorPermissionsConstraintMessage, -} from '@/lib/connectors/zendesk' -import { - DEFAULT_ZENDESK_CONNECTOR_PERMISSIONS, - type ZendeskConnectorPermissions, + DEFAULT_ZENDESK_ACTION_PERMISSIONS, + type ZendeskActionName, + type ZendeskActionPermissions, + type ZendeskActionPolicy, } from '@/lib/connectors/zendesk-types' +import { notifyWorkspaceConfigChanged } from '@/lib/runtime/config-status-events' +import { cn } from '@/lib/utils' type ZendeskConnectorSettingsDialogProps = { open: boolean @@ -31,25 +30,134 @@ type ZendeskConnectorSettingsDialogProps = { } type ZendeskSettingsResponse = { - permissions: ZendeskConnectorPermissions + permissions: Record + zendeskActionPermissions: { + version: number + actions: ZendeskActionPermissions + } +} + +const ACTION_POLICY_LABELS: Record = { + deny: 'Deny', + ask: 'Ask', + allow: 'Allow', } -type PermissionFieldProps = { - checked: boolean +const ZENDESK_ACTION_GROUPS: Array<{ + title: string + description: string + actions: Array<{ name: ZendeskActionName; label: string; description: string }> +}> = [ + { + title: 'Ticket reading', + description: 'Control whether the agent can inspect tickets and their comments.', + actions: [ + { + name: 'search_tickets', + label: 'Search tickets', + description: 'Search tickets with Zendesk search queries.', + }, + { + name: 'get_ticket', + label: 'Read ticket details', + description: 'Fetch a single ticket by ID.', + }, + { + name: 'list_ticket_comments', + label: 'List ticket comments', + description: 'Read the comments on a ticket, public and internal.', + }, + ], + }, + { + title: 'Ticket updates', + description: 'Change ticket fields without adding a comment.', + actions: [ + { + name: 'update_ticket_fields', + label: 'Update ticket fields', + description: 'Change subject, status, priority, type, or assignee without a comment.', + }, + ], + }, + { + title: 'Public communication', + description: 'Public comments can notify the requester by email.', + actions: [ + { + name: 'create_ticket_public', + label: 'Create tickets with a public comment', + description: 'Open a new ticket whose initial comment is public.', + }, + { + name: 'update_ticket_with_public_comment', + label: 'Update tickets with a public comment', + description: 'Add a public comment and optionally change fields in one update.', + }, + ], + }, + { + title: 'Internal communication', + description: 'Internal notes stay visible only to Zendesk agents.', + actions: [ + { + name: 'create_ticket_internal', + label: 'Create tickets with an internal note', + description: 'Open a new ticket whose initial comment is internal.', + }, + { + name: 'update_ticket_with_internal_note', + label: 'Update tickets with an internal note', + description: 'Add an internal note and optionally change fields in one update.', + }, + ], + }, +] + +type ActionPolicySelectorProps = { + action: ZendeskActionName description: string disabled: boolean label: string - onCheckedChange: (checked: boolean) => void + value: ZendeskActionPolicy + onChange: (policy: ZendeskActionPolicy) => void } -function PermissionField({ checked, description, disabled, label, onCheckedChange }: PermissionFieldProps) { +function ActionPolicySelector({ action, description, disabled, label, value, onChange }: ActionPolicySelectorProps) { + const labelId = `${action}-policy-label` + return ( -
-
-

{label}

-

{description}

+
+
+
+

{label}

+

{description}

+
+ +
+ {(['deny', 'ask', 'allow'] as const).map((policy) => ( + + ))} +
-
) } @@ -61,27 +169,16 @@ export function ZendeskConnectorSettingsDialog({ connectorName, onOpenChange, }: ZendeskConnectorSettingsDialogProps) { - const [permissions, setPermissions] = useState(DEFAULT_ZENDESK_CONNECTOR_PERMISSIONS) + const [actions, setActions] = useState(DEFAULT_ZENDESK_ACTION_PERMISSIONS) const [hasLoadedSettings, setHasLoadedSettings] = useState(false) const [isSaving, setIsSaving] = useState(false) const [error, setError] = useState(null) const isLoading = open && Boolean(connectorId) && !hasLoadedSettings && error === null - - const hasCommentVisibility = permissions.allowPublicComments || permissions.allowInternalComments - const permissionsConstraintMessage = getZendeskConnectorPermissionsConstraintMessage(permissions) - const canEditPermissions = hasLoadedSettings && !isLoading && !isSaving - const createTicketsDisabled = - !canEditPermissions || (!hasCommentVisibility && !permissions.allowCreateTickets) - const internalCommentsDisabled = - !canEditPermissions || - (permissions.allowCreateTickets && permissions.allowInternalComments && !permissions.allowPublicComments) - const publicCommentsDisabled = - !canEditPermissions || - (permissions.allowCreateTickets && permissions.allowPublicComments && !permissions.allowInternalComments) + const canEditActions = hasLoadedSettings && !isLoading && !isSaving function resetDialogState() { - setPermissions(DEFAULT_ZENDESK_CONNECTOR_PERMISSIONS) + setActions(DEFAULT_ZENDESK_ACTION_PERMISSIONS) setHasLoadedSettings(false) setError(null) setIsSaving(false) @@ -112,12 +209,12 @@ export function ZendeskConnectorSettingsDialog({ if (cancelled) return - if (!response.ok || !data?.permissions) { + if (!response.ok || !data?.zendeskActionPermissions?.actions) { setError(getConnectorErrorMessage(data, 'load_settings_failed')) return } - setPermissions(data.permissions) + setActions(data.zendeskActionPermissions.actions) setHasLoadedSettings(true) setError(null) } catch { @@ -134,15 +231,15 @@ export function ZendeskConnectorSettingsDialog({ } }, [connectorId, open, slug]) - function updatePermission(key: K, value: boolean) { - setPermissions((current) => ({ + function updateAction(action: ZendeskActionName, policy: ZendeskActionPolicy) { + setActions((current) => ({ ...current, - [key]: value, + [action]: policy, })) } async function handleSave() { - if (!connectorId || !hasLoadedSettings || isLoading || isSaving || permissionsConstraintMessage) { + if (!connectorId || !hasLoadedSettings || isLoading || isSaving) { return } @@ -153,18 +250,24 @@ export function ZendeskConnectorSettingsDialog({ const response = await fetch(`/api/u/${slug}/connectors/${connectorId}/zendesk-settings`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ permissions }), + body: JSON.stringify({ + zendeskActionPermissions: { + version: 1, + actions, + }, + }), }) const data = (await response.json().catch(() => null)) as | (ZendeskSettingsResponse & { error?: string; message?: string }) | null - if (!response.ok || !data?.permissions) { + if (!response.ok || !data?.zendeskActionPermissions?.actions) { setError(getConnectorErrorMessage(data, 'save_failed')) return } - setPermissions(data.permissions) + setActions(data.zendeskActionPermissions.actions) + notifyWorkspaceConfigChanged() handleDialogOpenChange(false) } catch { setError(getConnectorErrorMessage(null, 'network_error')) @@ -179,7 +282,8 @@ export function ZendeskConnectorSettingsDialog({ Zendesk settings - Restrict what {connectorName ?? 'this connector'} can do. These limits are enforced by Arche before any Zendesk request is sent. + Restrict what {connectorName ?? 'this connector'} can do. Deny is enforced by Arche before + any Zendesk request is sent, and Ask requires approval in the workspace before the action runs. @@ -197,91 +301,37 @@ export function ZendeskConnectorSettingsDialog({

) : null} - {permissionsConstraintMessage ? ( -

- {permissionsConstraintMessage} -

- ) : null} - -
-
-

Ticket access

-

- Control whether the agent can inspect tickets or perform write operations. -

-
- -
- updatePermission('allowRead', checked)} - /> - updatePermission('allowCreateTickets', checked)} - /> - updatePermission('allowUpdateTickets', checked)} - /> - - {!hasCommentVisibility && !permissions.allowCreateTickets ? ( -

- Enable public comments or internal notes before allowing ticket creation. -

- ) : null} -
-
- -
-
-

Comment visibility

-

- Apply these limits to both ticket creation and updates. Requests outside this policy fail explicitly. -

-
- -
- updatePermission('allowInternalComments', checked)} - /> - updatePermission('allowPublicComments', checked)} - /> - - {permissions.allowCreateTickets && permissions.allowPublicComments !== permissions.allowInternalComments ? ( -

- Ticket creation needs at least one comment option. Disable ticket creation first to turn off the last enabled comment type. -

- ) : null} -
-
- - + {!isLoading + ? ZENDESK_ACTION_GROUPS.map((group) => ( +
+
+

{group.title}

+

{group.description}

+
+ +
+ {group.actions.map((action) => ( + updateAction(action.name, policy)} + /> + ))} +
+
+ )) + : null}
- {subtitle ? ( + {subtitle && !isZendeskAction ? (

{subtitle}

@@ -71,12 +188,14 @@ export function PermissionCard({ onAnswerPermission, permission }: PermissionCar
+ {previewState ? : null} +