From edf692ffcf18d01e107c57e07d632830028afdbd Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Mon, 13 Jul 2026 17:19:28 +0100 Subject: [PATCH 1/2] feat(data-warehouse): render oauth-account-select fields in DynamicSourceSetup Add support for the backend's `oauth-account-select` field type (a picker whose options are the accounts/resources a connected OAuth integration exposes, e.g. GitHub repositories) to the inbox's generic source-setup renderer, plus a `getOauthAccounts` api-client method. The picker reads its integration id from the sibling `integrationField` in the (flat) form values, fetches options from the oauth_accounts endpoint with debounced server-side search, and falls back to free text until an integration is connected. The backend uses the integration's stored token; the client only passes the id. This is the Code-app consumer for the field type. Routing GitHub's inbox setup through DynamicSourceSetup (repository field uses this picker, removing the bespoke GitHubSetup) is a follow-up, gated on the posthog config swap and best verified with the app running. --- packages/api-client/src/posthog-client.ts | 58 +++++++ .../inbox/components/DynamicSourceSetup.tsx | 149 ++++++++++++++++++ 2 files changed, 207 insertions(+) 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..b2a80442a8 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,123 @@ 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; + + 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} + { + setQuery(e.target.value); + setValue(field.name, e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + /> + {open && (loading || accounts.length > 0) && ( + + {loading ? ( + + Loading… + + ) : ( + accounts.map((account) => ( + + )) + )} + + )} + {field.caption && ( + {field.caption} + )} + + ); +} + function SetupFormContainer({ title, children, From 8ab28f2d2da71f36dc7d777322e801afb262652f Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Mon, 13 Jul 2026 17:39:24 +0100 Subject: [PATCH 2/2] fix(data-warehouse): decouple account-select label from submitted value Reset the oauth-account-select selection when the backing integration changes, so an account (or fallback text) chosen for one integration can't survive into another and be submitted as an account that was never selected for the active integration. Also stop treating typed text as a committed value: typing only filters the list, and picking an option shows the account's human-readable name while submitting its opaque value. Editing the text after a selection clears the committed value rather than silently mutating the underlying account id. Generated-By: PostHog Code Task-Id: d1804989-7d93-42d8-ba74-a5d496495c31 --- .../inbox/components/DynamicSourceSetup.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx index b2a80442a8..afe1e5c36d 100644 --- a/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx @@ -548,6 +548,18 @@ function AccountSelectField({ 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; @@ -596,8 +608,12 @@ function AccountSelectField({ placeholder={field.placeholder || field.label} value={query} onChange={(e) => { + // 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, e.target.value); + setValue(field.name, ""); setOpen(true); }} onFocus={() => setOpen(true)} @@ -615,8 +631,10 @@ function AccountSelectField({ type="button" className="block w-full px-2 py-1 text-left text-gray-12 text-sm hover:bg-(--gray-3)" onClick={() => { + // Commit the account's opaque value to the form, but show the + // human-readable name in the input. setValue(field.name, account.value); - setQuery(account.value); + setQuery(account.display_name); setOpen(false); }} >