Split access-review source dialog into components
AddAccessReviewSourceDialog had grown past a thousand lines, mixing provider search, the API-key, client-credentials and OAuth connect flows, and their shared mutation logic in one file. Extract the per-protocol dialogs into _components, the shared create-source flow into a _hooks hook, and the settings mappers and OAuth redirect helper into _lib. Each connect dialog now owns its own Relay operation. The API-key dialog carries the Crisp managed-connector flow: it fetches the verification code for the entered Website ID and distinguishes loading, ready and error states, so a failed fetch shows an actionable error with a retry instead of a permanent spinner and a superseded response cannot overwrite the current code. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -12,7 +12,6 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { formatError, sprintf } from "@probo/helpers";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
@@ -24,29 +23,23 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
Field,
|
|
||||||
Input,
|
Input,
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
ThirdPartyLogo,
|
ThirdPartyLogo,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
useToast,
|
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode, useMemo, useState } from "react";
|
import { type ReactNode, useMemo, useState } from "react";
|
||||||
import { useMutation } from "react-relay";
|
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql";
|
|
||||||
import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.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 {
|
import {
|
||||||
isPostHogDeploymentSelected,
|
DatadogConnectDialog,
|
||||||
PostHogDeploymentField,
|
ZendeskConnectDialog,
|
||||||
} from "./PostHogDeploymentField";
|
} from "./_components/OAuthExtraDialog";
|
||||||
|
import { connectOAuthProvider } from "./_lib/connectorSettings";
|
||||||
|
|
||||||
export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql`
|
export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql`
|
||||||
fragment AddAccessReviewSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
|
fragment AddAccessReviewSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
|
||||||
@@ -54,6 +47,7 @@ export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql`
|
|||||||
displayName
|
displayName
|
||||||
oauthConfigured
|
oauthConfigured
|
||||||
apiKeySupported
|
apiKeySupported
|
||||||
|
apiKeyManaged
|
||||||
clientCredentialsSupported
|
clientCredentialsSupported
|
||||||
oauth2Scopes
|
oauth2Scopes
|
||||||
extraSettings {
|
extraSettings {
|
||||||
@@ -66,18 +60,6 @@ export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql`
|
|||||||
|
|
||||||
export type ProviderInfo = AddAccessReviewSourceDialogConnectorProviderInfoFragment$data[number];
|
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 = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -86,125 +68,6 @@ type Props = {
|
|||||||
existingSourceProviders: ReadonlyArray<string>;
|
existingSourceProviders: ReadonlyArray<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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<string, string>,
|
|
||||||
): 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({
|
export function AddAccessReviewSourceDialog({
|
||||||
children,
|
children,
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -213,32 +76,14 @@ export function AddAccessReviewSourceDialog({
|
|||||||
existingSourceProviders,
|
existingSourceProviders,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
|
||||||
const dialogRef = useDialogRef();
|
const dialogRef = useDialogRef();
|
||||||
const apiKeyDialogRef = useDialogRef();
|
|
||||||
const clientCredentialsDialogRef = useDialogRef();
|
|
||||||
const datadogDialogRef = useDialogRef();
|
|
||||||
const zendeskDialogRef = useDialogRef();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeProvider, setActiveProvider] = useState<ProviderInfo | null>(null);
|
|
||||||
|
|
||||||
const [datadogSite, setDatadogSite] = useState<string>("US1");
|
const [activeAPIKeyProvider, setActiveAPIKeyProvider] = useState<ProviderInfo | null>(null);
|
||||||
const [datadogProvider, setDatadogProvider] = useState<ProviderInfo | null>(null);
|
const [activeClientCredsProvider, setActiveClientCredsProvider] = useState<ProviderInfo | null>(null);
|
||||||
|
const [activeDatadogProvider, setActiveDatadogProvider] = useState<ProviderInfo | null>(null);
|
||||||
const [zendeskSubdomain, setZendeskSubdomain] = useState<string>("");
|
const [activeZendeskProvider, setActiveZendeskProvider] = useState<ProviderInfo | null>(null);
|
||||||
const [zendeskProvider, setZendeskProvider] = useState<ProviderInfo | null>(null);
|
|
||||||
|
|
||||||
const [apiKeyValue, setApiKeyValue] = useState("");
|
|
||||||
const [extraSettingValues, setExtraSettingValues] = useState<Record<string, string>>({});
|
|
||||||
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<Record<string, string>>({});
|
|
||||||
const [isConnectingClientCredentials, setIsConnectingClientCredentials] = useState(false);
|
|
||||||
|
|
||||||
const filteredProviders = useMemo(() => {
|
const filteredProviders = useMemo(() => {
|
||||||
const sorted = [...providerInfos].sort((a, b) =>
|
const sorted = [...providerInfos].sort((a, b) =>
|
||||||
@@ -256,253 +101,6 @@ export function AddAccessReviewSourceDialog({
|
|||||||
[existingSourceProviders],
|
[existingSourceProviders],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [createAccessReviewSource]
|
|
||||||
= useMutation<accessReviewSourceMutationsCreateMutation>(
|
|
||||||
createAccessReviewSourceMutation,
|
|
||||||
);
|
|
||||||
const [createAPIKeyConnector]
|
|
||||||
= useMutation<AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation>(
|
|
||||||
createAPIKeyConnectorMutation,
|
|
||||||
);
|
|
||||||
const [createClientCredentialsConnector]
|
|
||||||
= useMutation<AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation>(
|
|
||||||
createClientCredentialsConnectorMutation,
|
|
||||||
);
|
|
||||||
|
|
||||||
const connectOAuthProvider = (
|
|
||||||
info: ProviderInfo,
|
|
||||||
extras?: Record<string, string>,
|
|
||||||
) => {
|
|
||||||
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<string, string> = {};
|
|
||||||
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<string, string> = {};
|
|
||||||
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 renderProviderCard = (info: ProviderInfo) => {
|
||||||
const isConnected = connectedProviders.has(info.provider);
|
const isConnected = connectedProviders.has(info.provider);
|
||||||
|
|
||||||
@@ -516,11 +114,11 @@ export function AddAccessReviewSourceDialog({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (info.provider === "DATADOG") {
|
if (info.provider === "DATADOG") {
|
||||||
openDatadogDialog(info);
|
setActiveDatadogProvider(info);
|
||||||
} else if (info.provider === "ZENDESK") {
|
} else if (info.provider === "ZENDESK") {
|
||||||
openZendeskDialog(info);
|
setActiveZendeskProvider(info);
|
||||||
} else {
|
} else {
|
||||||
connectOAuthProvider(info);
|
connectOAuthProvider(organizationId, info);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -528,13 +126,13 @@ export function AddAccessReviewSourceDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (info.apiKeySupported) {
|
if (info.apiKeySupported || info.apiKeyManaged) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => openAPIKeyDialog(info)}
|
onClick={() => setActiveAPIKeyProvider(info)}
|
||||||
>
|
>
|
||||||
{__("API Key")}
|
{info.apiKeyManaged ? __("Connect") : __("API Key")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -542,7 +140,7 @@ export function AddAccessReviewSourceDialog({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => openClientCredentialsDialog(info)}
|
onClick={() => setActiveClientCredsProvider(info)}
|
||||||
>
|
>
|
||||||
{__("Client Credentials")}
|
{__("Client Credentials")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -570,14 +168,14 @@ export function AddAccessReviewSourceDialog({
|
|||||||
<ActionDropdown variant="secondary">
|
<ActionDropdown variant="secondary">
|
||||||
{info.apiKeySupported && (
|
{info.apiKeySupported && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onSelect={() => openAPIKeyDialog(info)}
|
onSelect={() => setActiveAPIKeyProvider(info)}
|
||||||
>
|
>
|
||||||
{__("Connect with API Key")}
|
{__("Connect with API Key")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
{info.clientCredentialsSupported && (
|
{info.clientCredentialsSupported && (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
onSelect={() => openClientCredentialsDialog(info)}
|
onSelect={() => setActiveClientCredsProvider(info)}
|
||||||
>
|
>
|
||||||
{__("Connect with Client Credentials")}
|
{__("Connect with Client Credentials")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
@@ -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 (
|
|
||||||
<PostHogDeploymentField
|
|
||||||
values={extraSettingValues}
|
|
||||||
onChange={setExtraSettingValues}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return activeProvider.extraSettings.map((setting) => {
|
|
||||||
const value = extraSettingValues[setting.key] ?? "";
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={setting.key}
|
|
||||||
label={__(setting.label)}
|
|
||||||
value={value}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -683,217 +236,33 @@ export function AddAccessReviewSourceDialog({
|
|||||||
<DialogFooter exitLabel={__("Close")} />
|
<DialogFooter exitLabel={__("Close")} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<APIKeyConnectorDialog
|
||||||
ref={apiKeyDialogRef}
|
provider={activeAPIKeyProvider}
|
||||||
title={activeProvider
|
organizationId={organizationId}
|
||||||
? sprintf(__("Connect %s"), activeProvider.displayName)
|
connectionId={connectionId}
|
||||||
: __("Connect provider")}
|
onClose={() => setActiveAPIKeyProvider(null)}
|
||||||
>
|
onSuccess={() => dialogRef.current?.close()}
|
||||||
<form
|
/>
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
connectAPIKeyProvider();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent padded className="space-y-4">
|
|
||||||
<p className="text-txt-secondary text-sm">
|
|
||||||
{sprintf(
|
|
||||||
__("Enter the API key for %s to connect it as an access source."),
|
|
||||||
activeProvider?.displayName ?? "",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<Field
|
|
||||||
label={__("API Key")}
|
|
||||||
type="password"
|
|
||||||
value={apiKeyValue}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setApiKeyValue(e.target.value)}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{renderAPIKeyExtraSettings()}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
isConnectingAPIKey
|
|
||||||
|| !apiKeyValue.trim()
|
|
||||||
|| !apiKeyExtraSettingsValid
|
|
||||||
|| !postHogAPIKeyValid
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isConnectingAPIKey ? __("Connecting...") : __("Connect")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
<ClientCredentialsConnectorDialog
|
||||||
ref={clientCredentialsDialogRef}
|
provider={activeClientCredsProvider}
|
||||||
title={activeProvider
|
organizationId={organizationId}
|
||||||
? sprintf(__("Connect %s"), activeProvider.displayName)
|
connectionId={connectionId}
|
||||||
: __("Connect provider")}
|
onClose={() => setActiveClientCredsProvider(null)}
|
||||||
>
|
onSuccess={() => dialogRef.current?.close()}
|
||||||
<form
|
/>
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
connectClientCredentialsProvider();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent padded className="space-y-4">
|
|
||||||
<p className="text-txt-secondary text-sm">
|
|
||||||
{sprintf(
|
|
||||||
__("Enter the client credentials for %s to connect it as an access source."),
|
|
||||||
activeProvider?.displayName ?? "",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<Field
|
|
||||||
label={__("Client ID")}
|
|
||||||
value={clientId}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientId(e.target.value)}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={__("Client Secret")}
|
|
||||||
type="password"
|
|
||||||
value={clientSecret}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientSecret(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={__("Token URL")}
|
|
||||||
value={tokenUrl}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={__("Scope")}
|
|
||||||
value={scope}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setScope(e.target.value)}
|
|
||||||
/>
|
|
||||||
{activeProvider?.extraSettings.map(setting =>
|
|
||||||
setting.key === "region"
|
|
||||||
? (
|
|
||||||
<div key={setting.key} className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium">{__(setting.label)}</label>
|
|
||||||
<Select
|
|
||||||
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
|
||||||
onValueChange={(val: string) =>
|
|
||||||
setClientCredentialsExtraValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
[setting.key]: val,
|
|
||||||
}))}
|
|
||||||
placeholder={__("Select a region")}
|
|
||||||
>
|
|
||||||
<Option value="US">United States (US)</Option>
|
|
||||||
<Option value="CA">Canada (CA)</Option>
|
|
||||||
<Option value="EU">Europe (EU)</Option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<Field
|
|
||||||
key={setting.key}
|
|
||||||
label={__(setting.label)}
|
|
||||||
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
setClientCredentialsExtraValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
[setting.key]: e.target.value,
|
|
||||||
}))}
|
|
||||||
required={setting.required}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
isConnectingClientCredentials
|
|
||||||
|| !clientId.trim()
|
|
||||||
|| !clientSecret.trim()
|
|
||||||
|| !tokenUrl.trim()
|
|
||||||
|| !clientCredentialsExtraSettingsValid
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isConnectingClientCredentials ? __("Connecting...") : __("Connect")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog ref={datadogDialogRef} title={__("Connect Datadog")}>
|
<DatadogConnectDialog
|
||||||
<form
|
provider={activeDatadogProvider}
|
||||||
onSubmit={(e) => {
|
organizationId={organizationId}
|
||||||
e.preventDefault();
|
onClose={() => setActiveDatadogProvider(null)}
|
||||||
if (datadogProvider) {
|
/>
|
||||||
connectOAuthProvider(datadogProvider, { site: datadogSite });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent padded className="space-y-4">
|
|
||||||
<p className="text-txt-secondary text-sm">
|
|
||||||
{__("Select your Datadog site, then continue to authorize access.")}
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-sm font-medium">{__("Datadog site")}</label>
|
|
||||||
<Select
|
|
||||||
value={datadogSite}
|
|
||||||
onValueChange={setDatadogSite}
|
|
||||||
placeholder={__("Select a site")}
|
|
||||||
>
|
|
||||||
{DATADOG_SITES.map(s => (
|
|
||||||
<Option key={s.value} value={s.value}>
|
|
||||||
{s.label}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit">{__("Continue")}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog ref={zendeskDialogRef} title={__("Connect Zendesk")}>
|
<ZendeskConnectDialog
|
||||||
<form
|
provider={activeZendeskProvider}
|
||||||
onSubmit={(e) => {
|
organizationId={organizationId}
|
||||||
e.preventDefault();
|
onClose={() => setActiveZendeskProvider(null)}
|
||||||
if (zendeskProvider) {
|
/>
|
||||||
const site = cleanZendeskSubdomain(zendeskSubdomain);
|
|
||||||
if (site) {
|
|
||||||
connectOAuthProvider(zendeskProvider, { site });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent padded className="space-y-4">
|
|
||||||
<p className="text-txt-secondary text-sm">
|
|
||||||
{__("Enter your Zendesk subdomain, then continue to authorize access.")}
|
|
||||||
</p>
|
|
||||||
<Field
|
|
||||||
label={__("Zendesk subdomain")}
|
|
||||||
placeholder={__("acme")}
|
|
||||||
value={zendeskSubdomain}
|
|
||||||
onValueChange={setZendeskSubdomain}
|
|
||||||
help={__("The <subdomain> part of <subdomain>.zendesk.com")}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!cleanZendeskSubdomain(zendeskSubdomain)}
|
|
||||||
>
|
|
||||||
{__("Continue")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<Record<string, string>>({});
|
||||||
|
const [isConnectingAPIKey, setIsConnectingAPIKey] = useState(false);
|
||||||
|
const [crispCode, setCrispCode] = useState<CrispCodeState | null>(null);
|
||||||
|
const [crispRetry, setCrispRetry] = useState(0);
|
||||||
|
|
||||||
|
const [createAPIKeyConnector]
|
||||||
|
= useMutation<APIKeyConnectorDialogCreateAPIKeyConnectorMutation>(
|
||||||
|
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<APIKeyConnectorDialogCrispVerificationCodeQuery>(
|
||||||
|
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 (
|
||||||
|
<PostHogDeploymentField
|
||||||
|
values={extraSettingValues}
|
||||||
|
onChange={setExtraSettingValues}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.extraSettings.map((setting) => {
|
||||||
|
const value = extraSettingValues[setting.key] ?? "";
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={setting.key}
|
||||||
|
label={__(setting.label)}
|
||||||
|
value={value}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
onClose={() => {
|
||||||
|
// 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")}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
connectAPIKeyProvider();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent padded className="space-y-4">
|
||||||
|
<p className="text-txt-secondary text-sm">
|
||||||
|
{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 ?? "",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{!provider?.apiKeyManaged && (
|
||||||
|
<Field
|
||||||
|
label={__("API Key")}
|
||||||
|
type="password"
|
||||||
|
value={apiKeyValue}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setApiKeyValue(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderAPIKeyExtraSettings()}
|
||||||
|
{isCrispManaged && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">
|
||||||
|
{__("Verification code")}
|
||||||
|
</label>
|
||||||
|
<p className="text-txt-tertiary text-sm">
|
||||||
|
{__("Paste this code into the Probo plugin settings for this website in your Crisp dashboard, then click Connect. It proves you control this website.")}
|
||||||
|
</p>
|
||||||
|
{!crispWebsiteId
|
||||||
|
? (
|
||||||
|
<p className="text-txt-tertiary text-sm">
|
||||||
|
{__("Enter your Website ID above to get your verification code.")}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: crispCodeState?.status === "ok"
|
||||||
|
? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="rounded border border-border-solid bg-subtle px-2 py-1 font-mono text-sm">
|
||||||
|
{crispCodeState.code}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(crispCodeState.code);
|
||||||
|
toast({
|
||||||
|
title: __("Copied to clipboard"),
|
||||||
|
description: __("Verification code"),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{__("Copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: crispCodeState?.status === "error"
|
||||||
|
? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-txt-danger text-sm">
|
||||||
|
{__("Couldn't generate a verification code. Check the Website ID and try again.")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setCrispRetry(n => n + 1)}
|
||||||
|
>
|
||||||
|
{__("Retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<p className="text-txt-tertiary text-sm">
|
||||||
|
{__("Generating code...")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isConnectingAPIKey
|
||||||
|
|| (!provider?.apiKeyManaged && !apiKeyValue.trim())
|
||||||
|
|| !apiKeyExtraSettingsValid
|
||||||
|
|| !postHogAPIKeyValid
|
||||||
|
|| (isCrispManaged && !crispVerificationCode)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isConnectingAPIKey ? __("Connecting...") : __("Connect")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<Record<string, string>>({});
|
||||||
|
const [isConnectingClientCredentials, setIsConnectingClientCredentials] = useState(false);
|
||||||
|
|
||||||
|
const [createClientCredentialsConnector]
|
||||||
|
= useMutation<ClientCredentialsConnectorDialogCreateClientCredentialsConnectorMutation>(
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
onClose={() => {
|
||||||
|
// 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")}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
connectClientCredentialsProvider();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent padded className="space-y-4">
|
||||||
|
<p className="text-txt-secondary text-sm">
|
||||||
|
{sprintf(
|
||||||
|
__("Enter the client credentials for %s to connect it as an access source."),
|
||||||
|
provider?.displayName ?? "",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Field
|
||||||
|
label={__("Client ID")}
|
||||||
|
value={clientId}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientId(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={__("Client Secret")}
|
||||||
|
type="password"
|
||||||
|
value={clientSecret}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientSecret(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={__("Token URL")}
|
||||||
|
value={tokenUrl}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label={__("Scope")}
|
||||||
|
value={scope}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setScope(e.target.value)}
|
||||||
|
/>
|
||||||
|
{provider?.extraSettings.map(setting =>
|
||||||
|
setting.key === "region"
|
||||||
|
? (
|
||||||
|
<div key={setting.key} className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">{__(setting.label)}</label>
|
||||||
|
<Select
|
||||||
|
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
||||||
|
onValueChange={(val: string) =>
|
||||||
|
setClientCredentialsExtraValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[setting.key]: val,
|
||||||
|
}))}
|
||||||
|
placeholder={__("Select a region")}
|
||||||
|
>
|
||||||
|
<Option value="US">United States (US)</Option>
|
||||||
|
<Option value="CA">Canada (CA)</Option>
|
||||||
|
<Option value="EU">Europe (EU)</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Field
|
||||||
|
key={setting.key}
|
||||||
|
label={__(setting.label)}
|
||||||
|
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setClientCredentialsExtraValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[setting.key]: e.target.value,
|
||||||
|
}))}
|
||||||
|
required={setting.required}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
isConnectingClientCredentials
|
||||||
|
|| !clientId.trim()
|
||||||
|
|| !clientSecret.trim()
|
||||||
|
|| !tokenUrl.trim()
|
||||||
|
|| !clientCredentialsExtraSettingsValid
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isConnectingClientCredentials ? __("Connecting...") : __("Connect")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<string>("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 (
|
||||||
|
<Dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
onClose={() => {
|
||||||
|
setDatadogSite("US1");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
title={__("Connect Datadog")}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (provider) {
|
||||||
|
connectOAuthProvider(organizationId, provider, { site: datadogSite });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent padded className="space-y-4">
|
||||||
|
<p className="text-txt-secondary text-sm">
|
||||||
|
{__("Select your Datadog site, then continue to authorize access.")}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-sm font-medium">{__("Datadog site")}</label>
|
||||||
|
<Select
|
||||||
|
value={datadogSite}
|
||||||
|
onValueChange={setDatadogSite}
|
||||||
|
placeholder={__("Select a site")}
|
||||||
|
>
|
||||||
|
{DATADOG_SITES.map(s => (
|
||||||
|
<Option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">{__("Continue")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZendeskConnectDialog({
|
||||||
|
provider,
|
||||||
|
organizationId,
|
||||||
|
onClose,
|
||||||
|
}: OAuthExtraDialogProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const dialogRef = useDialogRef();
|
||||||
|
const [zendeskSubdomain, setZendeskSubdomain] = useState<string>("");
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
onClose={() => {
|
||||||
|
setZendeskSubdomain("");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
title={__("Connect Zendesk")}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (provider) {
|
||||||
|
const site = cleanZendeskSubdomain(zendeskSubdomain);
|
||||||
|
if (site) {
|
||||||
|
connectOAuthProvider(organizationId, provider, { site });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent padded className="space-y-4">
|
||||||
|
<p className="text-txt-secondary text-sm">
|
||||||
|
{__("Enter your Zendesk subdomain, then continue to authorize access.")}
|
||||||
|
</p>
|
||||||
|
<Field
|
||||||
|
label={__("Zendesk subdomain")}
|
||||||
|
placeholder={__("acme")}
|
||||||
|
value={zendeskSubdomain}
|
||||||
|
onValueChange={setZendeskSubdomain}
|
||||||
|
help={__("The <subdomain> part of <subdomain>.zendesk.com")}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!cleanZendeskSubdomain(zendeskSubdomain)}
|
||||||
|
>
|
||||||
|
{__("Continue")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<accessReviewSourceMutationsCreateMutation>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<string, string>,
|
||||||
|
): 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<string, string>,
|
||||||
|
mapFn: (provider: string, settingKey: string) => string | null,
|
||||||
|
): Record<string, string> {
|
||||||
|
const extraFields: Record<string, string> = {};
|
||||||
|
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<string, string>,
|
||||||
|
) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user