fix(psa): move CW clientId to server config, remove from user input
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) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ from app.schemas.psa_connection import (
|
|||||||
PsaMemberResponse,
|
PsaMemberResponse,
|
||||||
AutoMatchResult,
|
AutoMatchResult,
|
||||||
)
|
)
|
||||||
|
from app.core.config import settings
|
||||||
from app.services.psa.encryption import (
|
from app.services.psa.encryption import (
|
||||||
decrypt_credentials,
|
decrypt_credentials,
|
||||||
encrypt_credentials,
|
encrypt_credentials,
|
||||||
@@ -130,6 +131,9 @@ async def create_connection(
|
|||||||
if not current_user.account_id:
|
if not current_user.account_id:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "No account associated with user")
|
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
|
# Check for existing connection
|
||||||
existing = await _get_connection(current_user.account_id, db)
|
existing = await _get_connection(current_user.account_id, db)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -145,7 +149,7 @@ async def create_connection(
|
|||||||
company_id=data.company_id,
|
company_id=data.company_id,
|
||||||
public_key=data.public_key,
|
public_key=data.public_key,
|
||||||
private_key=data.private_key,
|
private_key=data.private_key,
|
||||||
client_id=data.client_id,
|
client_id=settings.CW_CLIENT_ID,
|
||||||
)
|
)
|
||||||
if not test_result.success:
|
if not test_result.success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -156,7 +160,6 @@ async def create_connection(
|
|||||||
credentials = {
|
credentials = {
|
||||||
"public_key": data.public_key,
|
"public_key": data.public_key,
|
||||||
"private_key": data.private_key,
|
"private_key": data.private_key,
|
||||||
"client_id": data.client_id,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
conn = PsaConnection(
|
conn = PsaConnection(
|
||||||
@@ -189,7 +192,7 @@ async def update_connection(
|
|||||||
creds = decrypt_credentials(conn.credentials_encrypted)
|
creds = decrypt_credentials(conn.credentials_encrypted)
|
||||||
|
|
||||||
# Track whether credential fields changed
|
# Track whether credential fields changed
|
||||||
cred_fields = {"public_key", "private_key", "client_id"}
|
cred_fields = {"public_key", "private_key"}
|
||||||
cred_changed = False
|
cred_changed = False
|
||||||
|
|
||||||
# Apply updates
|
# Apply updates
|
||||||
@@ -213,7 +216,7 @@ async def update_connection(
|
|||||||
company_id=company_id_val,
|
company_id=company_id_val,
|
||||||
public_key=creds["public_key"],
|
public_key=creds["public_key"],
|
||||||
private_key=creds["private_key"],
|
private_key=creds["private_key"],
|
||||||
client_id=creds["client_id"],
|
client_id=settings.CW_CLIENT_ID or "",
|
||||||
)
|
)
|
||||||
if not test_result.success:
|
if not test_result.success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -263,7 +266,7 @@ async def test_connection(
|
|||||||
company_id=conn.company_id,
|
company_id=conn.company_id,
|
||||||
public_key=creds["public_key"],
|
public_key=creds["public_key"],
|
||||||
private_key=creds["private_key"],
|
private_key=creds["private_key"],
|
||||||
client_id=creds["client_id"],
|
client_id=settings.CW_CLIENT_ID or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ class Settings(BaseSettings):
|
|||||||
"""Check if any AI provider is configured."""
|
"""Check if any AI provider is configured."""
|
||||||
return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None
|
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
|
# Monitoring
|
||||||
SENTRY_DSN: Optional[str] = None
|
SENTRY_DSN: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class PsaConnectionCreate(BaseModel):
|
|||||||
company_id: str = Field(min_length=1, max_length=100)
|
company_id: str = Field(min_length=1, max_length=100)
|
||||||
public_key: str = Field(min_length=1)
|
public_key: str = Field(min_length=1)
|
||||||
private_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):
|
class PsaConnectionUpdate(BaseModel):
|
||||||
@@ -21,7 +21,6 @@ class PsaConnectionUpdate(BaseModel):
|
|||||||
company_id: str | None = None
|
company_id: str | None = None
|
||||||
public_key: str | None = None
|
public_key: str | None = None
|
||||||
private_key: str | None = None
|
private_key: str | None = None
|
||||||
client_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PsaConnectionResponse(BaseModel):
|
class PsaConnectionResponse(BaseModel):
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ class ConnectWiseProvider(PSAProvider):
|
|||||||
|
|
||||||
note_flags = flags.get(note_type, flags[NoteType.INTERNAL_ANALYSIS])
|
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 <br> tags instead.
|
||||||
body: dict = {
|
body: dict = {
|
||||||
"text": text,
|
"text": text,
|
||||||
**note_flags,
|
**note_flags,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.models.psa_connection import PsaConnection
|
from app.models.psa_connection import PsaConnection
|
||||||
from app.services.psa.base import PSAProvider
|
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.encryption import decrypt_credentials
|
||||||
from app.services.psa.exceptions import PSAConnectionError
|
from app.services.psa.exceptions import PSAConnectionError
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ async def get_provider_for_account(
|
|||||||
company_id=connection.company_id,
|
company_id=connection.company_id,
|
||||||
public_key=creds["public_key"],
|
public_key=creds["public_key"],
|
||||||
private_key=creds["private_key"],
|
private_key=creds["private_key"],
|
||||||
client_id=creds["client_id"],
|
client_id=settings.CW_CLIENT_ID or "",
|
||||||
)
|
)
|
||||||
return ConnectWiseProvider(client)
|
return ConnectWiseProvider(client)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ interface ConnectionForm {
|
|||||||
company_id: string
|
company_id: string
|
||||||
public_key: string
|
public_key: string
|
||||||
private_key: string
|
private_key: string
|
||||||
client_id: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: ConnectionForm = {
|
const emptyForm: ConnectionForm = {
|
||||||
@@ -37,7 +36,6 @@ const emptyForm: ConnectionForm = {
|
|||||||
company_id: '',
|
company_id: '',
|
||||||
public_key: '',
|
public_key: '',
|
||||||
private_key: '',
|
private_key: '',
|
||||||
client_id: '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'connection' | 'member-mapping' | 'post-history'
|
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.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.public_key) update.public_key = form.public_key
|
||||||
if (form.private_key) update.private_key = form.private_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)
|
const updated = await integrationsApi.updateConnection(connection.id, update)
|
||||||
setConnection(updated)
|
setConnection(updated)
|
||||||
@@ -177,7 +175,6 @@ export function IntegrationsPage() {
|
|||||||
company_id: connection.company_id,
|
company_id: connection.company_id,
|
||||||
public_key: '',
|
public_key: '',
|
||||||
private_key: '',
|
private_key: '',
|
||||||
client_id: '',
|
|
||||||
})
|
})
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
@@ -336,20 +333,6 @@ export function IntegrationsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
||||||
Client ID
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={form.client_id}
|
|
||||||
onChange={(e) => setForm({ ...form, client_id: e.target.value })}
|
|
||||||
placeholder="ConnectWise Developer Client ID"
|
|
||||||
required={mode === 'setup'}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formError && (
|
{formError && (
|
||||||
<div className="flex items-center gap-2 rounded-md border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
<div className="flex items-center gap-2 rounded-md border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface PsaConnectionCreate {
|
|||||||
company_id: string
|
company_id: string
|
||||||
public_key: string
|
public_key: string
|
||||||
private_key: string
|
private_key: string
|
||||||
client_id: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PsaConnectionUpdate {
|
export interface PsaConnectionUpdate {
|
||||||
@@ -29,7 +28,6 @@ export interface PsaConnectionUpdate {
|
|||||||
company_id?: string
|
company_id?: string
|
||||||
public_key?: string
|
public_key?: string
|
||||||
private_key?: string
|
private_key?: string
|
||||||
client_id?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PsaConnectionTestResponse {
|
export interface PsaConnectionTestResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user