From ad9d4271d61dac4e7e8dd0da307227d2093d735c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 22:14:50 -0400 Subject: [PATCH] feat(psa): add Integrations page with connection management UI Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/index.ts | 1 + frontend/src/api/integrations.ts | 15 + frontend/src/pages/AccountSettingsPage.tsx | 19 +- .../src/pages/account/IntegrationsPage.tsx | 505 ++++++++++++++++++ frontend/src/router.tsx | 9 + frontend/src/types/index.ts | 1 + frontend/src/types/integrations.ts | 39 ++ 7 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 frontend/src/api/integrations.ts create mode 100644 frontend/src/pages/account/IntegrationsPage.tsx create mode 100644 frontend/src/types/integrations.ts diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 13ce8733..e957c82f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -22,3 +22,4 @@ export { assistantChatApi } from './assistantChat' export { flowTransferApi } from './flowTransfer' export { kbAcceleratorApi } from './kbAccelerator' export { scriptsApi } from './scripts' +export { integrationsApi } from './integrations' diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts new file mode 100644 index 00000000..f290979c --- /dev/null +++ b/frontend/src/api/integrations.ts @@ -0,0 +1,15 @@ +import { apiClient } from './client' +import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' + +export const integrationsApi = { + getConnection: () => + apiClient.get('/integrations/psa/connections').then(r => r.data), + createConnection: (data: PsaConnectionCreate) => + apiClient.post('/integrations/psa/connections', data).then(r => r.data), + updateConnection: (id: string, data: PsaConnectionUpdate) => + apiClient.put(`/integrations/psa/connections/${id}`, data).then(r => r.data), + deleteConnection: (id: string) => + apiClient.delete(`/integrations/psa/connections/${id}`), + testConnection: (id: string) => + apiClient.post(`/integrations/psa/connections/${id}/test`).then(r => r.data), +} diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index cbdd444f..3da03ff3 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react' +import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' import { accountsApi } from '@/api/accounts' import type { Account, AccountMember, AccountInvite } from '@/types' @@ -555,6 +555,23 @@ export function AccountSettingsPage() { )} + {/* Integrations Link (owners only) */} + {isAccountOwner && ( + +
+ +
+

Integrations

+

Connect your PSA to sync session documentation to tickets

+
+
+ + + )} + {/* Feedback Link (all users) */} (null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Form state + const [mode, setMode] = useState<'view' | 'setup' | 'edit'>('setup') + const [form, setForm] = useState(emptyForm) + const [isSaving, setIsSaving] = useState(false) + const [formError, setFormError] = useState(null) + + // Test state + const [isTesting, setIsTesting] = useState(false) + const [testResult, setTestResult] = useState(null) + + // Delete state + const [isDeleting, setIsDeleting] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + useEffect(() => { + loadConnection() + }, []) + + const loadConnection = async () => { + setIsLoading(true) + setError(null) + try { + const data = await integrationsApi.getConnection() + setConnection(data) + setMode(data ? 'view' : 'setup') + } catch (err) { + // 404 means no connection exists — that's fine + const axiosErr = err as { response?: { status?: number } } + if (axiosErr.response?.status === 404) { + setConnection(null) + setMode('setup') + } else { + setError('Failed to load integration settings') + console.error(err) + } + } finally { + setIsLoading(false) + } + } + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + setIsSaving(true) + setFormError(null) + try { + const payload: PsaConnectionCreate = { + provider: 'connectwise', + ...form, + } + const created = await integrationsApi.createConnection(payload) + setConnection(created) + setMode('view') + setForm(emptyForm) + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setFormError(axiosErr.response?.data?.detail || 'Failed to create connection. Please check your credentials and try again.') + console.error(err) + } finally { + setIsSaving(false) + } + } + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault() + if (!connection) return + setIsSaving(true) + setFormError(null) + try { + const update: PsaConnectionUpdate = {} + if (form.display_name && form.display_name !== connection.display_name) update.display_name = form.display_name + if (form.site_url && form.site_url !== connection.site_url) update.site_url = form.site_url + if (form.company_id && form.company_id !== connection.company_id) update.company_id = form.company_id + if (form.public_key) update.public_key = form.public_key + if (form.private_key) update.private_key = form.private_key + if (form.client_id) update.client_id = form.client_id + + const updated = await integrationsApi.updateConnection(connection.id, update) + setConnection(updated) + setMode('view') + setForm(emptyForm) + } catch (err) { + const axiosErr = err as { response?: { data?: { detail?: string } } } + setFormError(axiosErr.response?.data?.detail || 'Failed to update connection.') + console.error(err) + } finally { + setIsSaving(false) + } + } + + const handleTest = async () => { + if (!connection) return + setIsTesting(true) + setTestResult(null) + try { + const result = await integrationsApi.testConnection(connection.id) + setTestResult(result) + } catch (err) { + setTestResult({ success: false, message: 'Connection test failed. Check your credentials.', server_version: null }) + console.error(err) + } finally { + setIsTesting(false) + } + } + + const handleDelete = async () => { + if (!connection) return + setIsDeleting(true) + try { + await integrationsApi.deleteConnection(connection.id) + setConnection(null) + setMode('setup') + setForm(emptyForm) + setShowDeleteConfirm(false) + setTestResult(null) + } catch (err) { + console.error('Failed to delete connection:', err) + } finally { + setIsDeleting(false) + } + } + + const startEdit = () => { + if (!connection) return + setForm({ + display_name: connection.display_name, + site_url: connection.site_url, + company_id: connection.company_id, + public_key: '', + private_key: '', + client_id: '', + }) + setFormError(null) + setTestResult(null) + setMode('edit') + } + + const cancelEdit = () => { + setMode('view') + setForm(emptyForm) + setFormError(null) + } + + if (isLoading) { + return ( + <> + +
+ +
+ + ) + } + + if (error) { + return ( + <> + +
+
+ + {error} +
+
+ + ) + } + + return ( + <> + +
+
+
+ +

Integrations

+
+

+ Connect your PSA to post session documentation directly to tickets. +

+
+ +
+ {/* Setup / Edit Form */} + {(mode === 'setup' || mode === 'edit') && ( +
+
+ +

+ {mode === 'setup' ? 'Connect to ConnectWise PSA' : 'Edit Connection'} +

+
+ +
+
+ + setForm({ ...form, display_name: e.target.value })} + placeholder="My ConnectWise Instance" + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, site_url: e.target.value })} + placeholder="na.myconnectwise.net" + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, company_id: e.target.value })} + placeholder="mycompany" + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, public_key: e.target.value })} + placeholder={mode === 'edit' && connection ? `Current: ${connection.public_key_hint}` : 'Enter public key'} + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, private_key: e.target.value })} + placeholder={mode === 'edit' && connection ? `Current: ${connection.private_key_hint}` : 'Enter private key'} + required={mode === 'setup'} + className="mt-1" + /> +
+ +
+ + setForm({ ...form, client_id: e.target.value })} + placeholder="ConnectWise Developer Client ID" + required={mode === 'setup'} + className="mt-1" + /> +
+ + {formError && ( +
+ + {formError} +
+ )} + +
+ + + {mode === 'edit' && ( + + )} +
+
+
+ )} + + {/* Connected View */} + {mode === 'view' && connection && ( +
+ {/* Status Card */} +
+
+
+ + ConnectWise + +

{connection.display_name}

+
+
+ + + {connection.is_active ? 'Connected' : 'Not validated'} + +
+
+ +
+
+

Site URL

+

{connection.site_url}

+
+
+

Company ID

+

{connection.company_id}

+
+
+

Public Key

+

{connection.public_key_hint}

+
+
+

Private Key

+

{connection.private_key_hint}

+
+
+

Last Validated

+

+ {connection.last_validated_at ? formatRelativeTime(connection.last_validated_at) : 'Never'} +

+
+
+
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + {testResult.message} + {testResult.server_version && ( + + v{testResult.server_version} + + )} +
+ )} + + {/* Action Buttons */} +
+ + + + + {showDeleteConfirm ? ( +
+ Disconnect? + + +
+ ) : ( + + )} +
+
+ )} +
+
+ + ) +} + +export default IntegrationsPage diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 6bc9b177..b43743bf 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -68,6 +68,7 @@ const ProfileSettingsPage = lazy(() => import('@/pages/account/ProfileSettingsPa const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage')) const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage')) const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage')) +const IntegrationsPage = lazy(() => import('@/pages/account/IntegrationsPage')) /** Wraps a lazy-loaded page with Suspense + ErrorBoundary */ function page(Component: React.LazyExoticComponent) { @@ -224,6 +225,14 @@ export const router = sentryCreateBrowserRouter([ ), }, { path: 'target-lists', element: page(TargetListsPage) }, + { + path: 'integrations', + element: ( + + {page(IntegrationsPage)} + + ), + }, ], }, ], diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6ea816d7..7628007f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -89,3 +89,4 @@ export type { } from './kbAccelerator' export * from './scripts' +export * from './integrations' diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts new file mode 100644 index 00000000..95b7c03b --- /dev/null +++ b/frontend/src/types/integrations.ts @@ -0,0 +1,39 @@ +export interface PsaConnectionResponse { + id: string + account_id: string + provider: string + display_name: string + site_url: string + company_id: string + is_active: boolean + last_validated_at: string | null + created_at: string + updated_at: string + public_key_hint: string + private_key_hint: string +} + +export interface PsaConnectionCreate { + provider: string + display_name: string + site_url: string + company_id: string + public_key: string + private_key: string + client_id: string +} + +export interface PsaConnectionUpdate { + display_name?: string + site_url?: string + company_id?: string + public_key?: string + private_key?: string + client_id?: string +} + +export interface PsaConnectionTestResponse { + success: boolean + message: string + server_version: string | null +}