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 b0bd789d3..187925b6a 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 3958d32cc..1ed59aa45 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 3e9e3d96b..e5603811b 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 db7700de7..ba89d76da 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 ce77fbe8e..a72774683 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 642c6d8b3..749092890 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} +