From 7f3f0545837c1982d86532469d5d0d614b6919a2 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sun, 15 Mar 2026 00:15:04 -0400 Subject: [PATCH] fix(psa): move CW clientId to server config, remove from user input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientId is a product-level GUID registered at developer.connectwise.com, not per-MSP. Moved to settings.CW_CLIENT_ID env var. MSPs now only provide site URL, company ID, public key, and private key. Also added newline handling note to post_note() — CW Developer Guide states \n is unsupported in JSON bodies. Needs sandbox testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/integrations.py | 13 ++++++++----- backend/app/core/config.py | 10 ++++++++++ backend/app/schemas/psa_connection.py | 3 +-- .../app/services/psa/connectwise/provider.py | 4 ++++ backend/app/services/psa/registry.py | 3 ++- .../src/pages/account/IntegrationsPage.tsx | 19 +------------------ frontend/src/types/integrations.ts | 2 -- 7 files changed, 26 insertions(+), 28 deletions(-) diff --git a/backend/app/api/endpoints/integrations.py b/backend/app/api/endpoints/integrations.py index b260fccb..f4a5cd7c 100644 --- a/backend/app/api/endpoints/integrations.py +++ b/backend/app/api/endpoints/integrations.py @@ -28,6 +28,7 @@ from app.schemas.psa_connection import ( PsaMemberResponse, AutoMatchResult, ) +from app.core.config import settings from app.services.psa.encryption import ( decrypt_credentials, encrypt_credentials, @@ -130,6 +131,9 @@ async def create_connection( if not current_user.account_id: raise HTTPException(status.HTTP_400_BAD_REQUEST, "No account associated with user") + if not settings.CW_CLIENT_ID: + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "ConnectWise integration is not configured on this server") + # Check for existing connection existing = await _get_connection(current_user.account_id, db) if existing: @@ -145,7 +149,7 @@ async def create_connection( company_id=data.company_id, public_key=data.public_key, private_key=data.private_key, - client_id=data.client_id, + client_id=settings.CW_CLIENT_ID, ) if not test_result.success: raise HTTPException( @@ -156,7 +160,6 @@ async def create_connection( credentials = { "public_key": data.public_key, "private_key": data.private_key, - "client_id": data.client_id, } conn = PsaConnection( @@ -189,7 +192,7 @@ async def update_connection( creds = decrypt_credentials(conn.credentials_encrypted) # Track whether credential fields changed - cred_fields = {"public_key", "private_key", "client_id"} + cred_fields = {"public_key", "private_key"} cred_changed = False # Apply updates @@ -213,7 +216,7 @@ async def update_connection( company_id=company_id_val, public_key=creds["public_key"], private_key=creds["private_key"], - client_id=creds["client_id"], + client_id=settings.CW_CLIENT_ID or "", ) if not test_result.success: raise HTTPException( @@ -263,7 +266,7 @@ async def test_connection( company_id=conn.company_id, public_key=creds["public_key"], private_key=creds["private_key"], - client_id=creds["client_id"], + client_id=settings.CW_CLIENT_ID or "", ) if result.success: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0cd64e94..ee0ea9db 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -119,6 +119,16 @@ class Settings(BaseSettings): """Check if any AI provider is configured.""" return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None + # ConnectWise PSA Integration + # CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com + # All MSP customers share this single clientId — it identifies ResolutionFlow as the integration + CW_CLIENT_ID: Optional[str] = None + + @property + def cw_enabled(self) -> bool: + """Check if ConnectWise integration is configured.""" + return self.CW_CLIENT_ID is not None + # Monitoring SENTRY_DSN: Optional[str] = None diff --git a/backend/app/schemas/psa_connection.py b/backend/app/schemas/psa_connection.py index 5231a201..d9dfeeb4 100644 --- a/backend/app/schemas/psa_connection.py +++ b/backend/app/schemas/psa_connection.py @@ -12,7 +12,7 @@ class PsaConnectionCreate(BaseModel): company_id: str = Field(min_length=1, max_length=100) public_key: str = Field(min_length=1) private_key: str = Field(min_length=1) - client_id: str = Field(min_length=1) + # Note: client_id is NOT per-MSP — it's a product-level GUID from settings.CW_CLIENT_ID class PsaConnectionUpdate(BaseModel): @@ -21,7 +21,6 @@ class PsaConnectionUpdate(BaseModel): company_id: str | None = None public_key: str | None = None private_key: str | None = None - client_id: str | None = None class PsaConnectionResponse(BaseModel): diff --git a/backend/app/services/psa/connectwise/provider.py b/backend/app/services/psa/connectwise/provider.py index 3159e9ba..d84ef73b 100644 --- a/backend/app/services/psa/connectwise/provider.py +++ b/backend/app/services/psa/connectwise/provider.py @@ -199,6 +199,10 @@ class ConnectWiseProvider(PSAProvider): note_flags = flags.get(note_type, flags[NoteType.INTERNAL_ANALYSIS]) + # NOTE: CW Developer Guide states \n is "Not Supported" in JSON bodies + # and may be collapsed to a single space. CW does support markdown in ticket + # notes (see PSA-Markdown.md). This needs sandbox testing — if newlines are + # lost, consider using double-space line breaks or HTML
tags instead. body: dict = { "text": text, **note_flags, diff --git a/backend/app/services/psa/registry.py b/backend/app/services/psa/registry.py index 7f4428ac..ff84c3cc 100644 --- a/backend/app/services/psa/registry.py +++ b/backend/app/services/psa/registry.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.psa_connection import PsaConnection from app.services.psa.base import PSAProvider +from app.core.config import settings from app.services.psa.encryption import decrypt_credentials from app.services.psa.exceptions import PSAConnectionError @@ -40,7 +41,7 @@ async def get_provider_for_account( company_id=connection.company_id, public_key=creds["public_key"], private_key=creds["private_key"], - client_id=creds["client_id"], + client_id=settings.CW_CLIENT_ID or "", ) return ConnectWiseProvider(client) diff --git a/frontend/src/pages/account/IntegrationsPage.tsx b/frontend/src/pages/account/IntegrationsPage.tsx index 15076550..ae9a55ac 100644 --- a/frontend/src/pages/account/IntegrationsPage.tsx +++ b/frontend/src/pages/account/IntegrationsPage.tsx @@ -28,7 +28,6 @@ interface ConnectionForm { company_id: string public_key: string private_key: string - client_id: string } const emptyForm: ConnectionForm = { @@ -37,7 +36,6 @@ const emptyForm: ConnectionForm = { company_id: '', public_key: '', private_key: '', - client_id: '', } type Tab = 'connection' | 'member-mapping' | 'post-history' @@ -122,7 +120,7 @@ export function IntegrationsPage() { 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 + // client_id is server-side (settings.CW_CLIENT_ID), not per-account const updated = await integrationsApi.updateConnection(connection.id, update) setConnection(updated) @@ -177,7 +175,6 @@ export function IntegrationsPage() { company_id: connection.company_id, public_key: '', private_key: '', - client_id: '', }) setFormError(null) setTestResult(null) @@ -336,20 +333,6 @@ export function IntegrationsPage() { /> -
- - setForm({ ...form, client_id: e.target.value })} - placeholder="ConnectWise Developer Client ID" - required={mode === 'setup'} - className="mt-1" - /> -
- {formError && (
diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index b80da91f..13e4d3ae 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -20,7 +20,6 @@ export interface PsaConnectionCreate { company_id: string public_key: string private_key: string - client_id: string } export interface PsaConnectionUpdate { @@ -29,7 +28,6 @@ export interface PsaConnectionUpdate { company_id?: string public_key?: string private_key?: string - client_id?: string } export interface PsaConnectionTestResponse {