diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 18a2aa6da6..9790adcbfb 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -446,6 +446,34 @@ export interface SourceFieldOauthConfig { requiredScopes?: string; } +/** + * A picker whose options are the accounts/resources a connected OAuth integration exposes (loaded + * from the `oauth_accounts` endpoint using the integration's server-side token). Used e.g. for a + * GitHub repository or an ad account. + */ +export interface SourceFieldOauthAccountSelectConfig { + type: "oauth-account-select"; + name: string; + label: string; + /** Name of the sibling OAuth id field this selector reads its integration id from. */ + integrationField: string; + /** Integration kind used to validate the connected integration, e.g. "github". */ + integrationKind: string; + placeholder?: string; + caption?: string; + required?: boolean; +} + +/** A selectable account/resource an OAuth integration exposes (shared `IntegrationAccount` shape). */ +export interface IntegrationAccount { + value: string; + display_name: string; + is_primary: boolean; + badges: string[]; + group: string | null; + secondary_text: string | null; +} + export interface SourceFieldSelectConfigOption { label: string; value: string; @@ -480,6 +508,7 @@ export interface SourceFieldUnsupportedConfig { export type SourceFieldConfig = | SourceFieldInputConfig | SourceFieldOauthConfig + | SourceFieldOauthAccountSelectConfig | SourceFieldSelectConfig | SourceFieldSwitchGroupConfig | SourceFieldUnsupportedConfig; @@ -2253,6 +2282,35 @@ export class PostHogAPIClient { return (await response.json()) as Record; } + /** + * List the accounts/resources a connected OAuth integration exposes for a source type (e.g. the + * repositories a GitHub integration can access), for an `oauth-account-select` field. The backend + * uses the integration's stored token; the client only passes the integration id. Pass `search` + * to filter server-side for large lists. + */ + async getOauthAccounts( + projectId: number, + sourceType: string, + integrationId: number | string, + search?: string, + ): Promise { + const url = new URL( + `${this.api.baseUrl}/api/environments/${projectId}/external_data_sources/oauth_accounts/`, + ); + url.searchParams.set("source_type", sourceType); + url.searchParams.set("integration_id", String(integrationId)); + if (search?.trim()) { + url.searchParams.set("search", search.trim()); + } + const path = `/api/environments/${projectId}/external_data_sources/oauth_accounts/`; + const response = await this.api.fetcher.fetch({ method: "get", url, path }); + if (!response.ok) { + throw new Error(`Failed to fetch accounts: ${response.statusText}`); + } + const data = (await response.json()) as { accounts?: IntegrationAccount[] }; + return data.accounts ?? []; + } + async updateExternalDataSchema( projectId: number, schemaId: string, diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx index 7a28467066..afe1e5c36d 100644 --- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -1,7 +1,9 @@ import type { + IntegrationAccount, SourceConfig, SourceFieldConfig, SourceFieldInputConfig, + SourceFieldOauthAccountSelectConfig, SourceFieldOauthConfig, } from "@posthog/api-client/posthog-client"; import { useHostTRPC } from "@posthog/host-router/react"; @@ -98,6 +100,14 @@ function missingRequiredFields( if (field.required && !values[field.name]) { missing.push(field.name); } + } else if (field.type === "oauth-account-select") { + const value = values[field.name]; + if ( + field.required && + (typeof value !== "string" || value.trim().length === 0) + ) { + missing.push(field.name); + } } else if (isInputField(field) && field.required) { const value = values[field.name]; if (typeof value !== "string" || value.trim().length === 0) { @@ -134,6 +144,11 @@ function buildPayload( } else if (field.type === "oauth") { const value = values[field.name]; if (value !== undefined && value !== "") out[field.name] = value; + } else if (field.type === "oauth-account-select") { + const value = values[field.name]; + if (typeof value === "string" && value.trim() !== "") { + out[field.name] = value.trim(); + } } else if (isInputField(field)) { const value = values[field.name]; if (typeof value === "string") out[field.name] = value.trim(); @@ -219,6 +234,7 @@ export function DynamicSourceSetup({ values={values} setValue={setValue} providerName={title.replace(/^Connect\s+/i, "")} + sourceType={sourceType} /> ))} {hasUnsupportedField && ( @@ -257,11 +273,13 @@ function SourceField({ values, setValue, providerName, + sourceType, }: { field: SourceFieldConfig; values: FieldValues; setValue: (name: string, value: FieldValue) => void; providerName: string; + sourceType: string; }) { if (field.type === "switch-group") { const enabled = !!values[field.name]; @@ -285,6 +303,7 @@ function SourceField({ values={values} setValue={setValue} providerName={providerName} + sourceType={sourceType} /> ))} @@ -302,6 +321,18 @@ function SourceField({ ); } + if (field.type === "oauth-account-select") { + return ( + + ); + } + if (field.type === "select") { const selected = (values[field.name] as string) ?? field.defaultValue ?? ""; const option = field.options.find((o) => o.value === selected); @@ -328,6 +359,7 @@ function SourceField({ values={values} setValue={setValue} providerName={providerName} + sourceType={sourceType} /> ))} @@ -484,6 +516,141 @@ function OAuthSourceField({ ); } +/** + * Renders an `oauth-account-select` field: a searchable picker whose options are the accounts/ + * resources a connected OAuth integration exposes (e.g. GitHub repositories), fetched from the + * backend using the integration's server-side token (the client only passes the integration id). + * Search is server-side (debounced) so large lists work. Falls back to a free-text input until a + * valid integration id is present in the form. + */ +function AccountSelectField({ + field, + value, + setValue, + sourceType, + integrationId, +}: { + field: SourceFieldOauthAccountSelectConfig; + value: FieldValue | undefined; + setValue: (name: string, value: FieldValue) => void; + sourceType: string; + integrationId: FieldValue | undefined; +}) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + const client = useAuthenticatedClient(); + const [query, setQuery] = useState(typeof value === "string" ? value : ""); + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + const hasIntegration = + integrationId !== undefined && + integrationId !== "" && + integrationId !== false; + + // Reset any prior selection when the backing integration changes. An account + // chosen (or fallback text typed) for one integration must not survive into + // another — otherwise the form could submit an account that was never + // selected for the active integration. + const prevIntegrationId = useRef(integrationId); + useEffect(() => { + if (prevIntegrationId.current === integrationId) return; + prevIntegrationId.current = integrationId; + setQuery(""); + setValue(field.name, ""); + }, [integrationId, field.name, setValue]); + + useEffect(() => { + if (!projectId || !client || !hasIntegration) return; + let cancelled = false; + const handle = setTimeout(async () => { + setLoading(true); + try { + const results = await client.getOauthAccounts( + projectId, + sourceType, + integrationId as number | string, + query, + ); + if (!cancelled) setAccounts(results); + } catch { + if (!cancelled) setAccounts([]); + } finally { + if (!cancelled) setLoading(false); + } + }, 300); + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [projectId, client, sourceType, integrationId, hasIntegration, query]); + + if (!hasIntegration) { + return ( + + {field.label} + setValue(field.name, e.target.value)} + /> + {field.caption && ( + {field.caption} + )} + + ); + } + + return ( + + {field.label} + { + // Typing only filters the list; it does not commit a value. The + // submitted account is set solely by picking an option, so editing + // the text after a selection clears it rather than silently mutating + // the underlying (possibly opaque) account id. + setQuery(e.target.value); + setValue(field.name, ""); + setOpen(true); + }} + onFocus={() => setOpen(true)} + /> + {open && (loading || accounts.length > 0) && ( + + {loading ? ( + + Loading… + + ) : ( + accounts.map((account) => ( + + )) + )} + + )} + {field.caption && ( + {field.caption} + )} + + ); +} + function SetupFormContainer({ title, children,