Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -480,6 +508,7 @@ export interface SourceFieldUnsupportedConfig {
export type SourceFieldConfig =
| SourceFieldInputConfig
| SourceFieldOauthConfig
| SourceFieldOauthAccountSelectConfig
| SourceFieldSelectConfig
| SourceFieldSwitchGroupConfig
| SourceFieldUnsupportedConfig;
Expand Down Expand Up @@ -2253,6 +2282,35 @@ export class PostHogAPIClient {
return (await response.json()) as Record<string, SourceConfig>;
}

/**
* 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<IntegrationAccount[]> {
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,
Expand Down
167 changes: 167 additions & 0 deletions packages/ui/src/features/inbox/components/DynamicSourceSetup.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -219,6 +234,7 @@ export function DynamicSourceSetup({
values={values}
setValue={setValue}
providerName={title.replace(/^Connect\s+/i, "")}
sourceType={sourceType}
/>
))}
{hasUnsupportedField && (
Expand Down Expand Up @@ -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];
Expand All @@ -285,6 +303,7 @@ function SourceField({
values={values}
setValue={setValue}
providerName={providerName}
sourceType={sourceType}
/>
))}
</Flex>
Expand All @@ -302,6 +321,18 @@ function SourceField({
);
}

if (field.type === "oauth-account-select") {
return (
<AccountSelectField
field={field}
value={values[field.name]}
setValue={setValue}
sourceType={sourceType}
integrationId={values[field.integrationField]}
/>
);
}

if (field.type === "select") {
const selected = (values[field.name] as string) ?? field.defaultValue ?? "";
const option = field.options.find((o) => o.value === selected);
Expand All @@ -328,6 +359,7 @@ function SourceField({
values={values}
setValue={setValue}
providerName={providerName}
sourceType={sourceType}
/>
))}
</Flex>
Expand Down Expand Up @@ -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<IntegrationAccount[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);

const hasIntegration =
integrationId !== undefined &&
integrationId !== "" &&
integrationId !== false;
Comment thread
Gilbert09 marked this conversation as resolved.

// 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 (
<Flex direction="column" gap="1">
<Text className="text-gray-12 text-sm">{field.label}</Text>
<TextField.Root
placeholder={field.placeholder || field.label}
value={typeof value === "string" ? value : ""}
onChange={(e) => setValue(field.name, e.target.value)}
/>
{field.caption && (
<Text className="text-[13px] text-gray-11">{field.caption}</Text>
)}
</Flex>
);
}

return (
<Flex direction="column" gap="1">
<Text className="text-gray-12 text-sm">{field.label}</Text>
<TextField.Root
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, "");
setOpen(true);
}}
onFocus={() => setOpen(true)}
/>
{open && (loading || accounts.length > 0) && (
<Box className="max-h-48 overflow-y-auto rounded-(--radius-2) border border-border bg-(--color-panel-solid)">
{loading ? (
<Text className="block px-2 py-1 text-[13px] text-gray-11">
Loading…
</Text>
) : (
accounts.map((account) => (
<button
key={account.value}
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.display_name);
setOpen(false);
Comment thread
Gilbert09 marked this conversation as resolved.
}}
>
{account.display_name}
</button>
))
)}
</Box>
)}
{field.caption && (
<Text className="text-[13px] text-gray-11">{field.caption}</Text>
)}
</Flex>
);
}

function SetupFormContainer({
title,
children,
Expand Down
Loading