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
|
||||
// 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<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({
|
||||
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<ProviderInfo | null>(null);
|
||||
|
||||
const [datadogSite, setDatadogSite] = useState<string>("US1");
|
||||
const [datadogProvider, setDatadogProvider] = useState<ProviderInfo | null>(null);
|
||||
|
||||
const [zendeskSubdomain, setZendeskSubdomain] = useState<string>("");
|
||||
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 [activeAPIKeyProvider, setActiveAPIKeyProvider] = useState<ProviderInfo | null>(null);
|
||||
const [activeClientCredsProvider, setActiveClientCredsProvider] = useState<ProviderInfo | null>(null);
|
||||
const [activeDatadogProvider, setActiveDatadogProvider] = useState<ProviderInfo | null>(null);
|
||||
const [activeZendeskProvider, setActiveZendeskProvider] = useState<ProviderInfo | null>(null);
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
const sorted = [...providerInfos].sort((a, b) =>
|
||||
@@ -256,253 +101,6 @@ export function AddAccessReviewSourceDialog({
|
||||
[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 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({
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (info.apiKeySupported) {
|
||||
if (info.apiKeySupported || info.apiKeyManaged) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openAPIKeyDialog(info)}
|
||||
onClick={() => setActiveAPIKeyProvider(info)}
|
||||
>
|
||||
{__("API Key")}
|
||||
{info.apiKeyManaged ? __("Connect") : __("API Key")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -542,7 +140,7 @@ export function AddAccessReviewSourceDialog({
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openClientCredentialsDialog(info)}
|
||||
onClick={() => setActiveClientCredsProvider(info)}
|
||||
>
|
||||
{__("Client Credentials")}
|
||||
</Button>
|
||||
@@ -570,14 +168,14 @@ export function AddAccessReviewSourceDialog({
|
||||
<ActionDropdown variant="secondary">
|
||||
{info.apiKeySupported && (
|
||||
<DropdownItem
|
||||
onSelect={() => openAPIKeyDialog(info)}
|
||||
onSelect={() => setActiveAPIKeyProvider(info)}
|
||||
>
|
||||
{__("Connect with API Key")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{info.clientCredentialsSupported && (
|
||||
<DropdownItem
|
||||
onSelect={() => openClientCredentialsDialog(info)}
|
||||
onSelect={() => setActiveClientCredsProvider(info)}
|
||||
>
|
||||
{__("Connect with Client Credentials")}
|
||||
</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 (
|
||||
<>
|
||||
<Dialog
|
||||
@@ -683,217 +236,33 @@ export function AddAccessReviewSourceDialog({
|
||||
<DialogFooter exitLabel={__("Close")} />
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
ref={apiKeyDialogRef}
|
||||
title={activeProvider
|
||||
? sprintf(__("Connect %s"), activeProvider.displayName)
|
||||
: __("Connect provider")}
|
||||
>
|
||||
<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>
|
||||
<APIKeyConnectorDialog
|
||||
provider={activeAPIKeyProvider}
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
onClose={() => setActiveAPIKeyProvider(null)}
|
||||
onSuccess={() => dialogRef.current?.close()}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
ref={clientCredentialsDialogRef}
|
||||
title={activeProvider
|
||||
? sprintf(__("Connect %s"), activeProvider.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."),
|
||||
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>
|
||||
<ClientCredentialsConnectorDialog
|
||||
provider={activeClientCredsProvider}
|
||||
organizationId={organizationId}
|
||||
connectionId={connectionId}
|
||||
onClose={() => setActiveClientCredsProvider(null)}
|
||||
onSuccess={() => dialogRef.current?.close()}
|
||||
/>
|
||||
|
||||
<Dialog ref={datadogDialogRef} title={__("Connect Datadog")}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
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>
|
||||
<DatadogConnectDialog
|
||||
provider={activeDatadogProvider}
|
||||
organizationId={organizationId}
|
||||
onClose={() => setActiveDatadogProvider(null)}
|
||||
/>
|
||||
|
||||
<Dialog ref={zendeskDialogRef} title={__("Connect Zendesk")}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
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>
|
||||
<ZendeskConnectDialog
|
||||
provider={activeZendeskProvider}
|
||||
organizationId={organizationId}
|
||||
onClose={() => setActiveZendeskProvider(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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