diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx index 279025332..19ebbf467 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessReviewSourceDialog.tsx @@ -12,7 +12,6 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { formatError, sprintf } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { ActionDropdown, @@ -24,29 +23,23 @@ import { DialogContent, DialogFooter, DropdownItem, - Field, Input, - Option, - Select, ThirdPartyLogo, useDialogRef, - useToast, } from "@probo/ui"; import { type ReactNode, useMemo, useState } from "react"; -import { useMutation } from "react-relay"; import { Link } from "react-router"; import { graphql } from "relay-runtime"; -import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql"; import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.graphql"; -import type { AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation.graphql"; -import type { AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation.graphql"; -import { createAccessReviewSourceMutation } from "./accessReviewSourceMutations"; +import { APIKeyConnectorDialog } from "./_components/APIKeyConnectorDialog"; +import { ClientCredentialsConnectorDialog } from "./_components/ClientCredentialsConnectorDialog"; import { - isPostHogDeploymentSelected, - PostHogDeploymentField, -} from "./PostHogDeploymentField"; + DatadogConnectDialog, + ZendeskConnectDialog, +} from "./_components/OAuthExtraDialog"; +import { connectOAuthProvider } from "./_lib/connectorSettings"; export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql` fragment AddAccessReviewSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) { @@ -54,6 +47,7 @@ export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql` displayName oauthConfigured apiKeySupported + apiKeyManaged clientCredentialsSupported oauth2Scopes extraSettings { @@ -66,18 +60,6 @@ export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql` export type ProviderInfo = AddAccessReviewSourceDialogConnectorProviderInfoFragment$data[number]; -// DATADOG_SITES labels are technical identifiers (region code + hostname), -// intentionally not wrapped in __(). The dialog's prose strings are. -const DATADOG_SITES: { value: string; label: string }[] = [ - { value: "US1", label: "US1 (app.datadoghq.com)" }, - { value: "US3", label: "US3 (us3.datadoghq.com)" }, - { value: "US5", label: "US5 (us5.datadoghq.com)" }, - { value: "EU1", label: "EU1 (app.datadoghq.eu)" }, - { value: "AP1", label: "AP1 (ap1.datadoghq.com)" }, - { value: "AP2", label: "AP2 (ap2.datadoghq.com)" }, - { value: "US1-FED", label: "US1-FED (app.ddog-gov.com)" }, -]; - type Props = { children: ReactNode; organizationId: string; @@ -86,125 +68,6 @@ type Props = { existingSourceProviders: ReadonlyArray; }; -const createAPIKeyConnectorMutation = graphql` - mutation AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation( - $input: CreateAPIKeyConnectorInput! - ) { - createAPIKeyConnector(input: $input) { - connector { - id - provider - } - } - } -`; - -const createClientCredentialsConnectorMutation = graphql` - mutation AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation( - $input: CreateClientCredentialsConnectorInput! - ) { - createClientCredentialsConnector(input: $input) { - connector { - id - provider - } - } - } -`; - -function mapAPIKeyExtraSettingToField( - provider: string, - settingKey: string, -): string | null { - switch (provider) { - case "TALLY": - if (settingKey === "organizationId") return "tallyOrganizationId"; - break; - case "SENTRY": - if (settingKey === "organizationSlug") return "sentryOrganizationSlug"; - break; - case "SUPABASE": - if (settingKey === "organizationSlug") return "supabaseOrganizationSlug"; - break; - case "GITHUB": - if (settingKey === "organization") return "githubOrganization"; - break; - case "GRAFANA": - if (settingKey === "baseUrl") return "grafanaBaseUrl"; - break; - case "SIGNOZ": - if (settingKey === "baseUrl") return "signozBaseUrl"; - break; - case "ONE_PASSWORD": - if (settingKey === "scimBridgeUrl") return "onePasswordScimBridgeUrl"; - break; - case "METABASE": - if (settingKey === "instanceUrl") return "metabaseInstanceUrl"; - break; - case "POSTHOG": - if (settingKey === "region") return "posthogRegion"; - if (settingKey === "instanceUrl") return "posthogInstanceUrl"; - break; - case "OKTA": - if (settingKey === "domain") return "oktaDomain"; - break; - case "BETTER_STACK": - if (settingKey === "teamName") return "betterStackTeamName"; - break; - case "QOVERY": - if (settingKey === "organizationId") return "qoveryOrganizationId"; - break; - case "RENDER": - if (settingKey === "workspaceId") return "renderWorkspaceId"; - break; - case "NEON": - if (settingKey === "organizationId") return "neonOrganizationId"; - break; - case "SCALEWAY": - if (settingKey === "organizationId") return "scalewayOrganizationId"; - break; - case "CRISP": - if (settingKey === "websiteId") return "crispWebsiteId"; - break; - } - return null; -} - -function mapClientCredentialsExtraSettingToField( - provider: string, - settingKey: string, -): string | null { - switch (provider) { - case "ONE_PASSWORD": - if (settingKey === "accountId") return "onePasswordAccountId"; - if (settingKey === "region") return "onePasswordRegion"; - break; - } - return null; -} - -function hasRequiredExtraSettings( - settings: ReadonlyArray<{ readonly key: string; readonly required: boolean }>, - values: Record, -): boolean { - return settings - .filter(s => s.required) - .every(s => values[s.key]?.trim()); -} - -// Accepts either a bare subdomain ("acme") or a pasted host -// ("https://acme.zendesk.com/") and reduces it to the bare subdomain the -// backend expects as the `site` query param. -function cleanZendeskSubdomain(raw: string): string { - let value = raw.trim(); - value = value.replace(/^https?:\/\//i, ""); - // Drop any path, query, or fragment from a pasted URL/host so only the - // host label survives (e.g. "acme.zendesk.com/agent?x=1" -> "acme"). - value = value.replace(/[/?#].*$/, ""); - value = value.replace(/\.zendesk\.com$/i, ""); - return value.trim(); -} - export function AddAccessReviewSourceDialog({ children, organizationId, @@ -213,32 +76,14 @@ export function AddAccessReviewSourceDialog({ existingSourceProviders, }: Props) { const { __ } = useTranslate(); - const { toast } = useToast(); const dialogRef = useDialogRef(); - const apiKeyDialogRef = useDialogRef(); - const clientCredentialsDialogRef = useDialogRef(); - const datadogDialogRef = useDialogRef(); - const zendeskDialogRef = useDialogRef(); const [searchQuery, setSearchQuery] = useState(""); - const [activeProvider, setActiveProvider] = useState(null); - const [datadogSite, setDatadogSite] = useState("US1"); - const [datadogProvider, setDatadogProvider] = useState(null); - - const [zendeskSubdomain, setZendeskSubdomain] = useState(""); - const [zendeskProvider, setZendeskProvider] = useState(null); - - const [apiKeyValue, setApiKeyValue] = useState(""); - const [extraSettingValues, setExtraSettingValues] = useState>({}); - const [isConnectingAPIKey, setIsConnectingAPIKey] = useState(false); - - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [tokenUrl, setTokenUrl] = useState(""); - const [scope, setScope] = useState(""); - const [clientCredentialsExtraValues, setClientCredentialsExtraValues] = useState>({}); - const [isConnectingClientCredentials, setIsConnectingClientCredentials] = useState(false); + const [activeAPIKeyProvider, setActiveAPIKeyProvider] = useState(null); + const [activeClientCredsProvider, setActiveClientCredsProvider] = useState(null); + const [activeDatadogProvider, setActiveDatadogProvider] = useState(null); + const [activeZendeskProvider, setActiveZendeskProvider] = useState(null); const filteredProviders = useMemo(() => { const sorted = [...providerInfos].sort((a, b) => @@ -256,253 +101,6 @@ export function AddAccessReviewSourceDialog({ [existingSourceProviders], ); - const [createAccessReviewSource] - = useMutation( - createAccessReviewSourceMutation, - ); - const [createAPIKeyConnector] - = useMutation( - createAPIKeyConnectorMutation, - ); - const [createClientCredentialsConnector] - = useMutation( - createClientCredentialsConnectorMutation, - ); - - const connectOAuthProvider = ( - info: ProviderInfo, - extras?: Record, - ) => { - const baseURL = import.meta.env.VITE_API_URL || window.location.origin; - const url = new URL("/api/console/v1/connectors/initiate", baseURL); - url.searchParams.append("organization_id", organizationId); - url.searchParams.append("provider", info.provider); - for (const scope of info.oauth2Scopes) { - url.searchParams.append("scope", scope); - } - if (extras) { - for (const [k, v] of Object.entries(extras)) { - url.searchParams.append(k, v); - } - } - url.searchParams.append( - "continue", - `/organizations/${organizationId}/access-reviews/sources`, - ); - window.location.assign(url.toString()); - }; - - const openAPIKeyDialog = (info: ProviderInfo) => { - setActiveProvider(info); - setApiKeyValue(""); - setExtraSettingValues({}); - apiKeyDialogRef.current?.open(); - }; - - const openClientCredentialsDialog = (info: ProviderInfo) => { - setActiveProvider(info); - setClientId(""); - setClientSecret(""); - setTokenUrl(""); - setScope(""); - setClientCredentialsExtraValues({}); - clientCredentialsDialogRef.current?.open(); - }; - - const openDatadogDialog = (info: ProviderInfo) => { - setDatadogProvider(info); - setDatadogSite("US1"); - datadogDialogRef.current?.open(); - }; - - const openZendeskDialog = (info: ProviderInfo) => { - setZendeskProvider(info); - setZendeskSubdomain(""); - zendeskDialogRef.current?.open(); - }; - - const createSourceAfterConnector = ( - connectorId: string, - displayName: string, - onDone: () => void, - ) => { - createAccessReviewSource({ - variables: { - input: { - organizationId, - connectorId, - name: displayName, - csvData: null, - }, - connections: [connectionId], - }, - onCompleted(_, errors) { - onDone(); - if (errors?.length) { - toast({ - title: __("Error"), - description: formatError( - __("Failed to create access source"), - errors, - ), - variant: "error", - }); - return; - } - toast({ - title: __("Success"), - description: __("Access source created successfully."), - variant: "success", - }); - dialogRef.current?.close(); - }, - onError(error) { - onDone(); - toast({ - title: __("Error"), - description: formatError( - __("Failed to create access source"), - error, - ), - variant: "error", - }); - }, - }); - }; - - const connectAPIKeyProvider = () => { - if (!activeProvider || !apiKeyValue.trim()) { - return; - } - - const requiredSettings = activeProvider.extraSettings.filter(s => s.required); - if (!hasRequiredExtraSettings(requiredSettings, extraSettingValues)) { - return; - } - - setIsConnectingAPIKey(true); - - const extraFields: Record = {}; - for (const setting of activeProvider.extraSettings) { - const value = extraSettingValues[setting.key]?.trim(); - if (value) { - const fieldName = mapAPIKeyExtraSettingToField(activeProvider.provider, setting.key); - if (fieldName) { - extraFields[fieldName] = value; - } - } - } - - createAPIKeyConnector({ - variables: { - input: { - organizationId, - provider: activeProvider.provider, - apiKey: apiKeyValue.trim(), - ...extraFields, - }, - }, - onCompleted: (response) => { - const connectorId = response.createAPIKeyConnector.connector.id; - createSourceAfterConnector( - connectorId, - activeProvider.displayName, - () => { - setIsConnectingAPIKey(false); - setApiKeyValue(""); - setExtraSettingValues({}); - setActiveProvider(null); - apiKeyDialogRef.current?.close(); - }, - ); - }, - onError: () => { - setIsConnectingAPIKey(false); - toast({ - title: __("Connection failed"), - description: __("Failed to connect provider. Please check your API key and try again."), - variant: "error", - }); - }, - }); - }; - - const connectClientCredentialsProvider = () => { - if (!activeProvider || !clientId.trim() || !clientSecret.trim() || !tokenUrl.trim()) { - return; - } - - const requiredSettings = activeProvider.extraSettings.filter(s => s.required); - if (!hasRequiredExtraSettings(requiredSettings, clientCredentialsExtraValues)) { - return; - } - - setIsConnectingClientCredentials(true); - - const extraFields: Record = {}; - for (const setting of activeProvider.extraSettings) { - const value = clientCredentialsExtraValues[setting.key]?.trim(); - if (value) { - const fieldName = mapClientCredentialsExtraSettingToField( - activeProvider.provider, - setting.key, - ); - if (fieldName) { - extraFields[fieldName] = value; - } - } - } - - createClientCredentialsConnector({ - variables: { - input: { - organizationId, - provider: activeProvider.provider, - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - tokenUrl: tokenUrl.trim(), - scope: scope.trim() || null, - ...extraFields, - }, - }, - onCompleted: (response) => { - const connector = response.createClientCredentialsConnector?.connector; - if (!connector) { - setIsConnectingClientCredentials(false); - toast({ - title: __("Connection failed"), - description: __("Failed to connect provider. Please check your credentials and try again."), - variant: "error", - }); - return; - } - - createSourceAfterConnector( - connector.id, - activeProvider.displayName, - () => { - setIsConnectingClientCredentials(false); - setClientId(""); - setClientSecret(""); - setTokenUrl(""); - setScope(""); - setClientCredentialsExtraValues({}); - setActiveProvider(null); - clientCredentialsDialogRef.current?.close(); - }, - ); - }, - onError: () => { - setIsConnectingClientCredentials(false); - toast({ - title: __("Connection failed"), - description: __("Failed to connect provider. Please check your credentials and try again."), - variant: "error", - }); - }, - }); - }; - const renderProviderCard = (info: ProviderInfo) => { const isConnected = connectedProviders.has(info.provider); @@ -516,11 +114,11 @@ export function AddAccessReviewSourceDialog({ variant="secondary" onClick={() => { if (info.provider === "DATADOG") { - openDatadogDialog(info); + setActiveDatadogProvider(info); } else if (info.provider === "ZENDESK") { - openZendeskDialog(info); + setActiveZendeskProvider(info); } else { - connectOAuthProvider(info); + connectOAuthProvider(organizationId, info); } }} > @@ -528,13 +126,13 @@ export function AddAccessReviewSourceDialog({ ); } - if (info.apiKeySupported) { + if (info.apiKeySupported || info.apiKeyManaged) { return ( ); } @@ -542,7 +140,7 @@ export function AddAccessReviewSourceDialog({ return ( @@ -570,14 +168,14 @@ export function AddAccessReviewSourceDialog({ {info.apiKeySupported && ( openAPIKeyDialog(info)} + onSelect={() => setActiveAPIKeyProvider(info)} > {__("Connect with API Key")} )} {info.clientCredentialsSupported && ( openClientCredentialsDialog(info)} + onSelect={() => setActiveClientCredsProvider(info)} > {__("Connect with Client Credentials")} @@ -590,51 +188,6 @@ export function AddAccessReviewSourceDialog({ ); }; - // PostHog renders a dedicated deployment selector (Cloud region or - // self-hosted URL); every other provider falls back to generic fields. - const renderAPIKeyExtraSettings = () => { - if (!activeProvider) { - return null; - } - - if (activeProvider.provider === "POSTHOG") { - return ( - - ); - } - - return activeProvider.extraSettings.map((setting) => { - const value = extraSettingValues[setting.key] ?? ""; - return ( - ) => - setExtraSettingValues(prev => ({ ...prev, [setting.key]: e.target.value }))} - required={setting.required} - /> - ); - }); - }; - - // PostHog's extra settings are individually optional (region OR instance - // URL), so the generic required-field check can't gate it. - const postHogAPIKeyValid - = activeProvider?.provider !== "POSTHOG" - || isPostHogDeploymentSelected(extraSettingValues); - - const apiKeyExtraSettingsValid = activeProvider - ? hasRequiredExtraSettings(activeProvider.extraSettings, extraSettingValues) - : true; - - const clientCredentialsExtraSettingsValid = activeProvider - ? hasRequiredExtraSettings(activeProvider.extraSettings, clientCredentialsExtraValues) - : true; - return ( <> - -
{ - e.preventDefault(); - connectAPIKeyProvider(); - }} - > - -

- {sprintf( - __("Enter the API key for %s to connect it as an access source."), - activeProvider?.displayName ?? "", - )} -

- ) => setApiKeyValue(e.target.value)} - required - autoFocus - /> - {renderAPIKeyExtraSettings()} -
- - - -
-
+ setActiveAPIKeyProvider(null)} + onSuccess={() => dialogRef.current?.close()} + /> - -
{ - e.preventDefault(); - connectClientCredentialsProvider(); - }} - > - -

- {sprintf( - __("Enter the client credentials for %s to connect it as an access source."), - activeProvider?.displayName ?? "", - )} -

- ) => setClientId(e.target.value)} - required - autoFocus - /> - ) => setClientSecret(e.target.value)} - required - /> - ) => setTokenUrl(e.target.value)} - required - /> - ) => setScope(e.target.value)} - /> - {activeProvider?.extraSettings.map(setting => - setting.key === "region" - ? ( -
- - -
- ) - : ( - ) => - setClientCredentialsExtraValues(prev => ({ - ...prev, - [setting.key]: e.target.value, - }))} - required={setting.required} - /> - ), - )} -
- - - -
-
+ setActiveClientCredsProvider(null)} + onSuccess={() => dialogRef.current?.close()} + /> - -
{ - e.preventDefault(); - if (datadogProvider) { - connectOAuthProvider(datadogProvider, { site: datadogSite }); - } - }} - > - -

- {__("Select your Datadog site, then continue to authorize access.")} -

-
- - -
-
- - - -
-
+ setActiveDatadogProvider(null)} + /> - -
{ - e.preventDefault(); - if (zendeskProvider) { - const site = cleanZendeskSubdomain(zendeskSubdomain); - if (site) { - connectOAuthProvider(zendeskProvider, { site }); - } - } - }} - > - -

- {__("Enter your Zendesk subdomain, then continue to authorize access.")} -

- part of .zendesk.com")} - required - autoFocus - /> -
- - - -
-
+ setActiveZendeskProvider(null)} + /> ); } diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_components/APIKeyConnectorDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/APIKeyConnectorDialog.tsx new file mode 100644 index 000000000..16ec87da1 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/APIKeyConnectorDialog.tsx @@ -0,0 +1,399 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useEffect, useState } from "react"; +import { useMutation, useRelayEnvironment } from "react-relay"; +import { fetchQuery, graphql } from "relay-runtime"; + +import type { APIKeyConnectorDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/APIKeyConnectorDialogCreateAPIKeyConnectorMutation.graphql"; +import type { APIKeyConnectorDialogCrispVerificationCodeQuery } from "#/__generated__/core/APIKeyConnectorDialogCrispVerificationCodeQuery.graphql"; + +import { useCreateAccessReviewSource } from "../_hooks/useCreateAccessReviewSource"; +import { + buildExtraFields, + hasRequiredExtraSettings, + mapAPIKeyExtraSettingToField, +} from "../_lib/connectorSettings"; +import type { ProviderInfo } from "../AddAccessReviewSourceDialog"; +import { + isPostHogDeploymentSelected, + PostHogDeploymentField, +} from "../PostHogDeploymentField"; + +const createAPIKeyConnectorMutation = graphql` + mutation APIKeyConnectorDialogCreateAPIKeyConnectorMutation( + $input: CreateAPIKeyConnectorInput! + ) { + createAPIKeyConnector(input: $input) { + connector { + id + provider + } + } + } +`; + +// Crisp (a managed provider) proves website ownership before connecting: Probo +// derives a per-(organization, website) code the customer pastes into the plugin +// settings in their Crisp dashboard. The code depends on the typed Website ID, +// so it is fetched on demand rather than carried on ConnectorProviderInfo. +const crispVerificationCodeQuery = graphql` + query APIKeyConnectorDialogCrispVerificationCodeQuery( + $organizationId: ID! + $websiteId: String! + ) { + crispVerificationCode(organizationId: $organizationId, websiteId: $websiteId) + } +`; + +type CrispCodeState = { + websiteId: string; + status: "loading" | "ok" | "error"; + code: string; +}; + +type Props = { + provider: ProviderInfo | null; + organizationId: string; + connectionId: string; + onClose: () => void; + onSuccess: () => void; +}; + +export function APIKeyConnectorDialog({ + provider, + organizationId, + connectionId, + onClose, + onSuccess, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + const environment = useRelayEnvironment(); + + const [apiKeyValue, setApiKeyValue] = useState(""); + const [extraSettingValues, setExtraSettingValues] = useState>({}); + const [isConnectingAPIKey, setIsConnectingAPIKey] = useState(false); + const [crispCode, setCrispCode] = useState(null); + const [crispRetry, setCrispRetry] = useState(0); + + const [createAPIKeyConnector] + = useMutation( + createAPIKeyConnectorMutation, + ); + + const createSourceAfterConnector = useCreateAccessReviewSource({ + organizationId, + connectionId, + onSuccess, + }); + + // Opening is driven imperatively by the parent's active-provider state. Form + // state is reset when the dialog closes (onClose for a dismiss, the success + // callback otherwise), so opening only shows the dialog — no setState here. + useEffect(() => { + if (provider) { + dialogRef.current?.open(); + } + }, [provider]); + + const isCrispManaged + = provider?.provider === "CRISP" && !!provider.apiKeyManaged; + const crispWebsiteId = extraSettingValues.websiteId?.trim() ?? ""; + + // The fetch result is stored keyed by the Website ID it was minted for and + // read only while it still matches the current Website ID, so a slow response + // for a previous ID is ignored. crispVerificationCode is non-null only once a + // code has actually been minted (status "ok"); loading and error are distinct + // states so the UI shows progress or an actionable error, never a blank code. + const crispCodeState + = crispCode && crispCode.websiteId === crispWebsiteId ? crispCode : null; + const crispVerificationCode + = crispCodeState?.status === "ok" ? crispCodeState.code : null; + + // Fetch the ownership-verification code once a Website ID is entered, debounced + // so it is not recomputed on every keystroke. The cleanup cancels a superseded + // request so a late response for a previous Website ID cannot clobber the + // current one, and crispRetry lets the user re-trigger a fetch after a failure. + useEffect(() => { + if (!isCrispManaged || !crispWebsiteId) { + return; + } + + let cancelled = false; + + const handle = setTimeout(() => { + setCrispCode({ websiteId: crispWebsiteId, status: "loading", code: "" }); + + fetchQuery( + environment, + crispVerificationCodeQuery, + { organizationId, websiteId: crispWebsiteId }, + ) + .toPromise() + .then((res) => { + if (cancelled) { + return; + } + const code = res?.crispVerificationCode ?? ""; + setCrispCode({ + websiteId: crispWebsiteId, + status: code ? "ok" : "error", + code, + }); + }) + .catch(() => { + if (!cancelled) { + setCrispCode({ websiteId: crispWebsiteId, status: "error", code: "" }); + } + }); + }, 400); + + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [environment, isCrispManaged, crispWebsiteId, organizationId, crispRetry]); + + const connectAPIKeyProvider = () => { + // Managed providers (Model B, e.g. Crisp) supply no customer key: the + // server injects Probo's own credential, so only the extra settings + // are required. + if (!provider || (!provider.apiKeyManaged && !apiKeyValue.trim())) { + return; + } + + const requiredSettings = provider.extraSettings.filter(s => s.required); + if (!hasRequiredExtraSettings(requiredSettings, extraSettingValues)) { + return; + } + + setIsConnectingAPIKey(true); + + const extraFields = buildExtraFields( + provider, + extraSettingValues, + mapAPIKeyExtraSettingToField, + ); + + createAPIKeyConnector({ + variables: { + input: { + organizationId, + provider: provider.provider, + apiKey: provider.apiKeyManaged ? null : apiKeyValue.trim(), + ...extraFields, + }, + }, + onCompleted: (response) => { + const connectorId = response.createAPIKeyConnector.connector.id; + createSourceAfterConnector( + connectorId, + provider.displayName, + () => { + setIsConnectingAPIKey(false); + setApiKeyValue(""); + setExtraSettingValues({}); + setCrispCode(null); + dialogRef.current?.close(); + onClose(); + }, + ); + }, + onError: () => { + setIsConnectingAPIKey(false); + toast({ + title: __("Connection failed"), + description: __("Failed to connect provider. Please check your API key and try again."), + variant: "error", + }); + }, + }); + }; + + // PostHog renders a dedicated deployment selector (Cloud region or + // self-hosted URL); every other provider falls back to generic fields. + const renderAPIKeyExtraSettings = () => { + if (!provider) { + return null; + } + + if (provider.provider === "POSTHOG") { + return ( + + ); + } + + return provider.extraSettings.map((setting) => { + const value = extraSettingValues[setting.key] ?? ""; + return ( + ) => + setExtraSettingValues(prev => ({ ...prev, [setting.key]: e.target.value }))} + required={setting.required} + /> + ); + }); + }; + + // PostHog's extra settings are individually optional (region OR instance + // URL), so the generic required-field check can't gate it. + const postHogAPIKeyValid + = provider?.provider !== "POSTHOG" + || isPostHogDeploymentSelected(extraSettingValues); + + const apiKeyExtraSettingsValid = provider + ? hasRequiredExtraSettings(provider.extraSettings, extraSettingValues) + : true; + + return ( + { + // Reset on dismiss so the next open starts fresh (the imperative + // close() on success does not fire onClose, so success resets inline). + setApiKeyValue(""); + setExtraSettingValues({}); + setCrispCode(null); + setIsConnectingAPIKey(false); + onClose(); + }} + title={provider + ? sprintf(__("Connect %s"), provider.displayName) + : __("Connect provider")} + > +
{ + e.preventDefault(); + connectAPIKeyProvider(); + }} + > + +

+ {provider?.apiKeyManaged + ? sprintf( + __("Connect %s as an access source. Provide the details below to finish."), + provider?.displayName ?? "", + ) + : sprintf( + __("Enter the API key for %s to connect it as an access source."), + provider?.displayName ?? "", + )} +

+ {!provider?.apiKeyManaged && ( + ) => setApiKeyValue(e.target.value)} + required + autoFocus + /> + )} + {renderAPIKeyExtraSettings()} + {isCrispManaged && ( +
+ +

+ {__("Paste this code into the Probo plugin settings for this website in your Crisp dashboard, then click Connect. It proves you control this website.")} +

+ {!crispWebsiteId + ? ( +

+ {__("Enter your Website ID above to get your verification code.")} +

+ ) + : crispCodeState?.status === "ok" + ? ( +
+ + {crispCodeState.code} + + +
+ ) + : crispCodeState?.status === "error" + ? ( +
+

+ {__("Couldn't generate a verification code. Check the Website ID and try again.")} +

+ +
+ ) + : ( +

+ {__("Generating code...")} +

+ )} +
+ )} +
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_components/ClientCredentialsConnectorDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/ClientCredentialsConnectorDialog.tsx new file mode 100644 index 000000000..b82e01f26 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/ClientCredentialsConnectorDialog.tsx @@ -0,0 +1,281 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + Option, + Select, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useEffect, useState } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { ClientCredentialsConnectorDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/ClientCredentialsConnectorDialogCreateClientCredentialsConnectorMutation.graphql"; + +import { useCreateAccessReviewSource } from "../_hooks/useCreateAccessReviewSource"; +import { + buildExtraFields, + hasRequiredExtraSettings, + mapClientCredentialsExtraSettingToField, +} from "../_lib/connectorSettings"; +import type { ProviderInfo } from "../AddAccessReviewSourceDialog"; + +const createClientCredentialsConnectorMutation = graphql` + mutation ClientCredentialsConnectorDialogCreateClientCredentialsConnectorMutation( + $input: CreateClientCredentialsConnectorInput! + ) { + createClientCredentialsConnector(input: $input) { + connector { + id + provider + } + } + } +`; + +type Props = { + provider: ProviderInfo | null; + organizationId: string; + connectionId: string; + onClose: () => void; + onSuccess: () => void; +}; + +export function ClientCredentialsConnectorDialog({ + provider, + organizationId, + connectionId, + onClose, + onSuccess, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [tokenUrl, setTokenUrl] = useState(""); + const [scope, setScope] = useState(""); + const [clientCredentialsExtraValues, setClientCredentialsExtraValues] = useState>({}); + const [isConnectingClientCredentials, setIsConnectingClientCredentials] = useState(false); + + const [createClientCredentialsConnector] + = useMutation( + createClientCredentialsConnectorMutation, + ); + + const createSourceAfterConnector = useCreateAccessReviewSource({ + organizationId, + connectionId, + onSuccess, + }); + + // Opening is driven imperatively by the parent's active-provider state. Form + // state is reset when the dialog closes (onClose for a dismiss, the success + // callback otherwise), so opening only shows the dialog — no setState here. + useEffect(() => { + if (provider) { + dialogRef.current?.open(); + } + }, [provider]); + + const connectClientCredentialsProvider = () => { + if (!provider || !clientId.trim() || !clientSecret.trim() || !tokenUrl.trim()) { + return; + } + + const requiredSettings = provider.extraSettings.filter(s => s.required); + if (!hasRequiredExtraSettings(requiredSettings, clientCredentialsExtraValues)) { + return; + } + + setIsConnectingClientCredentials(true); + + const extraFields = buildExtraFields( + provider, + clientCredentialsExtraValues, + mapClientCredentialsExtraSettingToField, + ); + + createClientCredentialsConnector({ + variables: { + input: { + organizationId, + provider: provider.provider, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + tokenUrl: tokenUrl.trim(), + scope: scope.trim() || null, + ...extraFields, + }, + }, + onCompleted: (response) => { + const connector = response.createClientCredentialsConnector?.connector; + if (!connector) { + setIsConnectingClientCredentials(false); + toast({ + title: __("Connection failed"), + description: __("Failed to connect provider. Please check your credentials and try again."), + variant: "error", + }); + return; + } + + createSourceAfterConnector( + connector.id, + provider.displayName, + () => { + setIsConnectingClientCredentials(false); + setClientId(""); + setClientSecret(""); + setTokenUrl(""); + setScope(""); + setClientCredentialsExtraValues({}); + dialogRef.current?.close(); + onClose(); + }, + ); + }, + onError: () => { + setIsConnectingClientCredentials(false); + toast({ + title: __("Connection failed"), + description: __("Failed to connect provider. Please check your credentials and try again."), + variant: "error", + }); + }, + }); + }; + + const clientCredentialsExtraSettingsValid = provider + ? hasRequiredExtraSettings(provider.extraSettings, clientCredentialsExtraValues) + : true; + + return ( + { + // Reset on dismiss so the next open starts fresh (the imperative + // close() on success does not fire onClose, so success resets inline). + setClientId(""); + setClientSecret(""); + setTokenUrl(""); + setScope(""); + setClientCredentialsExtraValues({}); + setIsConnectingClientCredentials(false); + onClose(); + }} + title={provider + ? sprintf(__("Connect %s"), provider.displayName) + : __("Connect provider")} + > +
{ + e.preventDefault(); + connectClientCredentialsProvider(); + }} + > + +

+ {sprintf( + __("Enter the client credentials for %s to connect it as an access source."), + provider?.displayName ?? "", + )} +

+ ) => setClientId(e.target.value)} + required + autoFocus + /> + ) => setClientSecret(e.target.value)} + required + /> + ) => setTokenUrl(e.target.value)} + required + /> + ) => setScope(e.target.value)} + /> + {provider?.extraSettings.map(setting => + setting.key === "region" + ? ( +
+ + +
+ ) + : ( + ) => + setClientCredentialsExtraValues(prev => ({ + ...prev, + [setting.key]: e.target.value, + }))} + required={setting.required} + /> + ), + )} +
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_components/OAuthExtraDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/OAuthExtraDialog.tsx new file mode 100644 index 000000000..22d61dc05 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/OAuthExtraDialog.tsx @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { useTranslate } from "@probo/i18n"; +import { + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + Option, + Select, + useDialogRef, +} from "@probo/ui"; +import { useEffect, useState } from "react"; + +import { + cleanZendeskSubdomain, + connectOAuthProvider, + DATADOG_SITES, +} from "../_lib/connectorSettings"; +import type { ProviderInfo } from "../AddAccessReviewSourceDialog"; + +type OAuthExtraDialogProps = { + provider: ProviderInfo | null; + organizationId: string; + onClose: () => void; +}; + +export function DatadogConnectDialog({ + provider, + organizationId, + onClose, +}: OAuthExtraDialogProps) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [datadogSite, setDatadogSite] = useState("US1"); + + // Opening is driven imperatively by the parent's active-provider state; the + // form is reset on close so opening only shows the dialog (no setState here). + useEffect(() => { + if (provider) { + dialogRef.current?.open(); + } + }, [provider]); + + return ( + { + setDatadogSite("US1"); + onClose(); + }} + title={__("Connect Datadog")} + > +
{ + e.preventDefault(); + if (provider) { + connectOAuthProvider(organizationId, provider, { site: datadogSite }); + } + }} + > + +

+ {__("Select your Datadog site, then continue to authorize access.")} +

+
+ + +
+
+ + + +
+
+ ); +} + +export function ZendeskConnectDialog({ + provider, + organizationId, + onClose, +}: OAuthExtraDialogProps) { + const { __ } = useTranslate(); + const dialogRef = useDialogRef(); + const [zendeskSubdomain, setZendeskSubdomain] = useState(""); + + // Opening is driven imperatively by the parent's active-provider state; the + // form is reset on close so opening only shows the dialog (no setState here). + useEffect(() => { + if (provider) { + dialogRef.current?.open(); + } + }, [provider]); + + return ( + { + setZendeskSubdomain(""); + onClose(); + }} + title={__("Connect Zendesk")} + > +
{ + e.preventDefault(); + if (provider) { + const site = cleanZendeskSubdomain(zendeskSubdomain); + if (site) { + connectOAuthProvider(organizationId, provider, { site }); + } + } + }} + > + +

+ {__("Enter your Zendesk subdomain, then continue to authorize access.")} +

+ part of .zendesk.com")} + required + autoFocus + /> +
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_hooks/useCreateAccessReviewSource.ts b/apps/console/src/pages/organizations/access-reviews/dialogs/_hooks/useCreateAccessReviewSource.ts new file mode 100644 index 000000000..c97506ab4 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_hooks/useCreateAccessReviewSource.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { formatError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { useToast } from "@probo/ui"; +import { useMutation } from "react-relay"; + +import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql"; + +import { createAccessReviewSourceMutation } from "../accessReviewSourceMutations"; + +type UseCreateAccessReviewSourceParams = { + organizationId: string; + connectionId: string; + onSuccess: () => void; +}; + +// useCreateAccessReviewSource wraps the shared "after a connector is created, +// create the access source, toast the outcome, and close the main dialog on +// success" flow. It returns createSourceAfterConnector, which runs the source +// mutation and invokes onDone on both success and error (for the caller's own +// cleanup) before toasting; onSuccess is called to close the MAIN dialog. +export function useCreateAccessReviewSource({ + organizationId, + connectionId, + onSuccess, +}: UseCreateAccessReviewSourceParams) { + const { __ } = useTranslate(); + const { toast } = useToast(); + + const [createAccessReviewSource] + = useMutation( + createAccessReviewSourceMutation, + ); + + const createSourceAfterConnector = ( + connectorId: string, + displayName: string, + onDone: () => void, + ) => { + createAccessReviewSource({ + variables: { + input: { + organizationId, + connectorId, + name: displayName, + csvData: null, + }, + connections: [connectionId], + }, + onCompleted(_, errors) { + onDone(); + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to create access source"), + errors, + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Access source created successfully."), + variant: "success", + }); + onSuccess(); + }, + onError(error) { + onDone(); + toast({ + title: __("Error"), + description: formatError( + __("Failed to create access source"), + error, + ), + variant: "error", + }); + }, + }); + }; + + return createSourceAfterConnector; +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts b/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts new file mode 100644 index 000000000..a3b2428f3 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import type { ProviderInfo } from "../AddAccessReviewSourceDialog"; + +// DATADOG_SITES labels are technical identifiers (region code + hostname), +// intentionally not wrapped in __(). The dialog's prose strings are. +export const DATADOG_SITES: { value: string; label: string }[] = [ + { value: "US1", label: "US1 (app.datadoghq.com)" }, + { value: "US3", label: "US3 (us3.datadoghq.com)" }, + { value: "US5", label: "US5 (us5.datadoghq.com)" }, + { value: "EU1", label: "EU1 (app.datadoghq.eu)" }, + { value: "AP1", label: "AP1 (ap1.datadoghq.com)" }, + { value: "AP2", label: "AP2 (ap2.datadoghq.com)" }, + { value: "US1-FED", label: "US1-FED (app.ddog-gov.com)" }, +]; + +export function mapAPIKeyExtraSettingToField( + provider: string, + settingKey: string, +): string | null { + switch (provider) { + case "TALLY": + if (settingKey === "organizationId") return "tallyOrganizationId"; + break; + case "SENTRY": + if (settingKey === "organizationSlug") return "sentryOrganizationSlug"; + break; + case "SUPABASE": + if (settingKey === "organizationSlug") return "supabaseOrganizationSlug"; + break; + case "GITHUB": + if (settingKey === "organization") return "githubOrganization"; + break; + case "GRAFANA": + if (settingKey === "baseUrl") return "grafanaBaseUrl"; + break; + case "SIGNOZ": + if (settingKey === "baseUrl") return "signozBaseUrl"; + break; + case "ONE_PASSWORD": + if (settingKey === "scimBridgeUrl") return "onePasswordScimBridgeUrl"; + break; + case "METABASE": + if (settingKey === "instanceUrl") return "metabaseInstanceUrl"; + break; + case "POSTHOG": + if (settingKey === "region") return "posthogRegion"; + if (settingKey === "instanceUrl") return "posthogInstanceUrl"; + break; + case "OKTA": + if (settingKey === "domain") return "oktaDomain"; + break; + case "BETTER_STACK": + if (settingKey === "teamName") return "betterStackTeamName"; + break; + case "QOVERY": + if (settingKey === "organizationId") return "qoveryOrganizationId"; + break; + case "RENDER": + if (settingKey === "workspaceId") return "renderWorkspaceId"; + break; + case "NEON": + if (settingKey === "organizationId") return "neonOrganizationId"; + break; + case "SCALEWAY": + if (settingKey === "organizationId") return "scalewayOrganizationId"; + break; + case "CRISP": + if (settingKey === "websiteId") return "crispWebsiteId"; + break; + } + return null; +} + +export function mapClientCredentialsExtraSettingToField( + provider: string, + settingKey: string, +): string | null { + switch (provider) { + case "ONE_PASSWORD": + if (settingKey === "accountId") return "onePasswordAccountId"; + if (settingKey === "region") return "onePasswordRegion"; + break; + } + return null; +} + +export function hasRequiredExtraSettings( + settings: ReadonlyArray<{ readonly key: string; readonly required: boolean }>, + values: Record, +): boolean { + return settings + .filter(s => s.required) + .every(s => values[s.key]?.trim()); +} + +// buildExtraFields flattens a provider's extra settings into the input-field map +// the create mutations expect: each non-empty, trimmed value keyed by its +// provider-specific input field name (via mapFn), skipping settings that map to +// nothing. Shared by the API-key and client-credentials dialogs. +export function buildExtraFields( + provider: ProviderInfo, + values: Record, + mapFn: (provider: string, settingKey: string) => string | null, +): Record { + const extraFields: Record = {}; + for (const setting of provider.extraSettings) { + const value = values[setting.key]?.trim(); + if (!value) { + continue; + } + const fieldName = mapFn(provider.provider, setting.key); + if (fieldName) { + extraFields[fieldName] = value; + } + } + return extraFields; +} + +// Accepts either a bare subdomain ("acme") or a pasted host +// ("https://acme.zendesk.com/") and reduces it to the bare subdomain the +// backend expects as the `site` query param. +export function cleanZendeskSubdomain(raw: string): string { + let value = raw.trim(); + value = value.replace(/^https?:\/\//i, ""); + // Drop any path, query, or fragment from a pasted URL/host so only the + // host label survives (e.g. "acme.zendesk.com/agent?x=1" -> "acme"). + value = value.replace(/[/?#].*$/, ""); + value = value.replace(/\.zendesk\.com$/i, ""); + return value.trim(); +} + +// connectOAuthProvider builds the connector-initiate URL for an OAuth provider +// and navigates the browser to it, kicking off the authorization redirect. +export function connectOAuthProvider( + organizationId: string, + info: ProviderInfo, + extras?: Record, +) { + const baseURL = import.meta.env.VITE_API_URL || window.location.origin; + const url = new URL("/api/console/v1/connectors/initiate", baseURL); + url.searchParams.append("organization_id", organizationId); + url.searchParams.append("provider", info.provider); + for (const scope of info.oauth2Scopes) { + url.searchParams.append("scope", scope); + } + if (extras) { + for (const [k, v] of Object.entries(extras)) { + url.searchParams.append(k, v); + } + } + url.searchParams.append( + "continue", + `/organizations/${organizationId}/access-reviews/sources`, + ); + window.location.assign(url.toString()); +}