Read OAuth2 scopes from GraphQL on the connector frontend
Each component that initiates an OAuth2 flow now reads its scopes from the colocated Relay fragment instead of a hardcoded TypeScript map. The five live call sites pass scopes to the backend via the new ?scope= query parameter. Drop the dead CreateAccessSourceDialog React component and rename the file to accessSourceMutations.ts since only the mutation export was used. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { GoogleWorkspaceConnector } from "./GoogleWorkspaceConnector";
|
|||||||
|
|
||||||
const connectorListFragment = graphql`
|
const connectorListFragment = graphql`
|
||||||
fragment ConnectorListFragment on Organization {
|
fragment ConnectorListFragment on Organization {
|
||||||
|
googleWorkspaceOAuth2Scopes
|
||||||
scimConfiguration {
|
scimConfiguration {
|
||||||
...GoogleWorkspaceConnectorFragment
|
...GoogleWorkspaceConnectorFragment
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,10 @@ export function ConnectorList(props: { fKey: ConnectorListFragment$key }) {
|
|||||||
"Connect your identity provider to automatically sync users to your organization. Once connected, you don't need to configure SCIM manually.",
|
"Connect your identity provider to automatically sync users to your organization. Once connected, you don't need to configure SCIM manually.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<GoogleWorkspaceConnector fKey={data.scimConfiguration ?? null} />
|
<GoogleWorkspaceConnector
|
||||||
|
fKey={data.scimConfiguration ?? null}
|
||||||
|
oauth2Scopes={data.googleWorkspaceOAuth2Scopes}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ const updateSCIMBridgeMutation = graphql`
|
|||||||
|
|
||||||
export function GoogleWorkspaceConnector(props: {
|
export function GoogleWorkspaceConnector(props: {
|
||||||
fKey: GoogleWorkspaceConnectorFragment$key | null;
|
fKey: GoogleWorkspaceConnectorFragment$key | null;
|
||||||
|
oauth2Scopes: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
const { fKey } = props;
|
const { fKey, oauth2Scopes } = props;
|
||||||
const data = useFragment<GoogleWorkspaceConnectorFragment$key>(googleWorkspaceConnectorFragment, fKey);
|
const data = useFragment<GoogleWorkspaceConnectorFragment$key>(googleWorkspaceConnectorFragment, fKey);
|
||||||
const bridge = data?.bridge;
|
const bridge = data?.bridge;
|
||||||
const connector = bridge?.connector;
|
const connector = bridge?.connector;
|
||||||
@@ -112,6 +113,9 @@ export function GoogleWorkspaceConnector(props: {
|
|||||||
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
|
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
|
||||||
url.searchParams.append("organization_id", organizationId);
|
url.searchParams.append("organization_id", organizationId);
|
||||||
url.searchParams.append("provider", "GOOGLE_WORKSPACE");
|
url.searchParams.append("provider", "GOOGLE_WORKSPACE");
|
||||||
|
for (const scope of oauth2Scopes) {
|
||||||
|
url.searchParams.append("scope", scope);
|
||||||
|
}
|
||||||
const continueUrl = `/organizations/${organizationId}/settings/scim`;
|
const continueUrl = `/organizations/${organizationId}/settings/scim`;
|
||||||
url.searchParams.append("continue", continueUrl);
|
url.searchParams.append("continue", continueUrl);
|
||||||
window.location.href = url.toString();
|
window.location.href = url.toString();
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ import { Link, useNavigate } from "react-router";
|
|||||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
|
||||||
import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql";
|
import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { createAccessSourceMutation } from "./dialogs/CreateAccessSourceDialog";
|
import { createAccessSourceMutation } from "./dialogs/accessSourceMutations";
|
||||||
|
|
||||||
export const createCsvAccessSourcePageQuery = graphql`
|
export const createCsvAccessSourcePageQuery = graphql`
|
||||||
query CreateCsvAccessSourcePageQuery($organizationId: ID!) {
|
query CreateCsvAccessSourcePageQuery($organizationId: ID!) {
|
||||||
@@ -81,7 +81,7 @@ export default function CreateCsvAccessSourcePage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [createAccessSource, isCreating]
|
const [createAccessSource, isCreating]
|
||||||
= useMutation<CreateAccessSourceDialogMutation>(
|
= useMutation<accessSourceMutationsCreateMutation>(
|
||||||
createAccessSourceMutation,
|
createAccessSourceMutation,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const fragment = graphql`
|
|||||||
connector {
|
connector {
|
||||||
provider
|
provider
|
||||||
}
|
}
|
||||||
|
oauth2Scopes
|
||||||
connectionStatus
|
connectionStatus
|
||||||
selectedOrganization
|
selectedOrganization
|
||||||
needsConfiguration
|
needsConfiguration
|
||||||
@@ -213,6 +214,9 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
|
|||||||
url.searchParams.append("organization_id", organizationId);
|
url.searchParams.append("organization_id", organizationId);
|
||||||
url.searchParams.append("provider", provider);
|
url.searchParams.append("provider", provider);
|
||||||
url.searchParams.append("connector_id", accessSource.connectorId);
|
url.searchParams.append("connector_id", accessSource.connectorId);
|
||||||
|
for (const scope of accessSource.oauth2Scopes) {
|
||||||
|
url.searchParams.append("scope", scope);
|
||||||
|
}
|
||||||
url.searchParams.append(
|
url.searchParams.append(
|
||||||
"continue",
|
"continue",
|
||||||
`/organizations/${organizationId}/access-reviews/sources`,
|
`/organizations/${organizationId}/access-reviews/sources`,
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ import { graphql } from "relay-runtime";
|
|||||||
import type { AddAccessSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
|
import type { AddAccessSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
|
||||||
import type { AddAccessSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateAPIKeyConnectorMutation.graphql";
|
import type { AddAccessSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateAPIKeyConnectorMutation.graphql";
|
||||||
import type { AddAccessSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateClientCredentialsConnectorMutation.graphql";
|
import type { AddAccessSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateClientCredentialsConnectorMutation.graphql";
|
||||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
|
||||||
|
|
||||||
import { createAccessSourceMutation } from "./CreateAccessSourceDialog";
|
import { createAccessSourceMutation } from "./accessSourceMutations";
|
||||||
|
|
||||||
export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
|
export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
|
||||||
fragment AddAccessSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
|
fragment AddAccessSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
|
||||||
@@ -51,6 +51,7 @@ export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
|
|||||||
oauthConfigured
|
oauthConfigured
|
||||||
apiKeySupported
|
apiKeySupported
|
||||||
clientCredentialsSupported
|
clientCredentialsSupported
|
||||||
|
oauth2Scopes
|
||||||
extraSettings {
|
extraSettings {
|
||||||
key
|
key
|
||||||
label
|
label
|
||||||
@@ -185,7 +186,7 @@ export function AddAccessSourceDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [createAccessSource]
|
const [createAccessSource]
|
||||||
= useMutation<CreateAccessSourceDialogMutation>(
|
= useMutation<accessSourceMutationsCreateMutation>(
|
||||||
createAccessSourceMutation,
|
createAccessSourceMutation,
|
||||||
);
|
);
|
||||||
const [createAPIKeyConnector]
|
const [createAPIKeyConnector]
|
||||||
@@ -197,11 +198,14 @@ export function AddAccessSourceDialog({
|
|||||||
createClientCredentialsConnectorMutation,
|
createClientCredentialsConnectorMutation,
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectOAuthProvider = (provider: string) => {
|
const connectOAuthProvider = (info: ProviderInfo) => {
|
||||||
const baseURL = import.meta.env.VITE_API_URL || window.location.origin;
|
const baseURL = import.meta.env.VITE_API_URL || window.location.origin;
|
||||||
const url = new URL("/api/console/v1/connectors/initiate", baseURL);
|
const url = new URL("/api/console/v1/connectors/initiate", baseURL);
|
||||||
url.searchParams.append("organization_id", organizationId);
|
url.searchParams.append("organization_id", organizationId);
|
||||||
url.searchParams.append("provider", provider);
|
url.searchParams.append("provider", info.provider);
|
||||||
|
for (const scope of info.oauth2Scopes) {
|
||||||
|
url.searchParams.append("scope", scope);
|
||||||
|
}
|
||||||
url.searchParams.append(
|
url.searchParams.append(
|
||||||
"continue",
|
"continue",
|
||||||
`/organizations/${organizationId}/access-reviews/sources`,
|
`/organizations/${organizationId}/access-reviews/sources`,
|
||||||
@@ -419,7 +423,7 @@ export function AddAccessSourceDialog({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => connectOAuthProvider(info.provider)}
|
onClick={() => connectOAuthProvider(info)}
|
||||||
>
|
>
|
||||||
{__("Connect")}
|
{__("Connect")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.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, type GraphQLError } from "@probo/helpers";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
Field,
|
|
||||||
Option,
|
|
||||||
Select,
|
|
||||||
useDialogRef,
|
|
||||||
useToast,
|
|
||||||
} from "@probo/ui";
|
|
||||||
import { type ReactNode, useEffect, useMemo } from "react";
|
|
||||||
import { Controller, useWatch } from "react-hook-form";
|
|
||||||
import { graphql, useMutation } from "react-relay";
|
|
||||||
import { useSearchParams } from "react-router";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
|
||||||
|
|
||||||
export const createAccessSourceMutation = graphql`
|
|
||||||
mutation CreateAccessSourceDialogMutation(
|
|
||||||
$input: CreateAccessSourceInput!
|
|
||||||
$connections: [ID!]!
|
|
||||||
) {
|
|
||||||
createAccessSource(input: $input) {
|
|
||||||
accessSourceEdge @prependEdge(connections: $connections) {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
createdAt
|
|
||||||
...AccessSourceRowFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: ReactNode;
|
|
||||||
organizationId: string;
|
|
||||||
connectionId: string;
|
|
||||||
connectors: ReadonlyArray<{
|
|
||||||
readonly id: string;
|
|
||||||
readonly provider: "GOOGLE_WORKSPACE" | "LINEAR" | "SLACK";
|
|
||||||
readonly createdAt: string;
|
|
||||||
}>;
|
|
||||||
preselectedConnectorId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
sourceType: z.enum(["CSV", "OAUTH2"]),
|
|
||||||
provider: z.enum(["GOOGLE_WORKSPACE", "LINEAR", "SLACK"]).optional(),
|
|
||||||
connectorId: z.string().optional(),
|
|
||||||
csvData: z.string().optional(),
|
|
||||||
}).superRefine((data, ctx) => {
|
|
||||||
if (data.sourceType === "CSV" && !data.csvData?.trim()) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["csvData"],
|
|
||||||
message: "CSV data is required for CSV sources.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.sourceType === "OAUTH2") {
|
|
||||||
if (!data.provider) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["provider"],
|
|
||||||
message: "Provider is required for OAuth2 sources.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!data.connectorId) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["connectorId"],
|
|
||||||
message: "Connector is required for OAuth2 sources.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function providerLabel(provider: "GOOGLE_WORKSPACE" | "LINEAR" | "SLACK") {
|
|
||||||
switch (provider) {
|
|
||||||
case "GOOGLE_WORKSPACE":
|
|
||||||
return "Google Workspace";
|
|
||||||
case "LINEAR":
|
|
||||||
return "Linear";
|
|
||||||
case "SLACK":
|
|
||||||
return "Slack";
|
|
||||||
default:
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateAccessSourceDialog({
|
|
||||||
children,
|
|
||||||
organizationId,
|
|
||||||
connectionId,
|
|
||||||
connectors,
|
|
||||||
preselectedConnectorId,
|
|
||||||
}: Props) {
|
|
||||||
const { __ } = useTranslate();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const preselectedConnector = useMemo(
|
|
||||||
() => connectors.find(connector => connector.id === preselectedConnectorId),
|
|
||||||
[connectors, preselectedConnectorId],
|
|
||||||
);
|
|
||||||
const { control, register, handleSubmit, reset, setValue }
|
|
||||||
= useFormWithSchema(
|
|
||||||
schema,
|
|
||||||
{
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
sourceType: preselectedConnector ? "OAUTH2" : "CSV",
|
|
||||||
provider: preselectedConnector?.provider ?? "GOOGLE_WORKSPACE",
|
|
||||||
connectorId: preselectedConnector?.id,
|
|
||||||
csvData: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const sourceType = useWatch({ control, name: "sourceType" });
|
|
||||||
const provider = useWatch({ control, name: "provider" });
|
|
||||||
const connectorId = useWatch({ control, name: "connectorId" });
|
|
||||||
const ref = useDialogRef();
|
|
||||||
|
|
||||||
const providerConnectors = useMemo(
|
|
||||||
() => connectors,
|
|
||||||
[connectors],
|
|
||||||
);
|
|
||||||
const selectableConnectors = useMemo(
|
|
||||||
() =>
|
|
||||||
providerConnectors.filter(
|
|
||||||
connector => !provider || connector.provider === provider,
|
|
||||||
),
|
|
||||||
[provider, providerConnectors],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!provider) {
|
|
||||||
setValue("connectorId", undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
connectorId
|
|
||||||
&& !selectableConnectors.some(connector => connector.id === connectorId)
|
|
||||||
) {
|
|
||||||
setValue("connectorId", undefined);
|
|
||||||
}
|
|
||||||
}, [provider, connectorId, selectableConnectors, setValue]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!preselectedConnector) return;
|
|
||||||
setValue("sourceType", "OAUTH2");
|
|
||||||
setValue("provider", preselectedConnector.provider);
|
|
||||||
setValue("connectorId", preselectedConnector.id);
|
|
||||||
}, [preselectedConnector, setValue]);
|
|
||||||
|
|
||||||
const [createAccessSource, isCreating]
|
|
||||||
= useMutation<CreateAccessSourceDialogMutation>(
|
|
||||||
createAccessSourceMutation,
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearConnectorQueryParam = () => {
|
|
||||||
if (!searchParams.get("connector_id")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSearchParams((params) => {
|
|
||||||
params.delete("connector_id");
|
|
||||||
return params;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const startOAuthConnection = () => {
|
|
||||||
if (!provider) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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", provider);
|
|
||||||
url.searchParams.append("continue", `/organizations/${organizationId}/access-reviews`);
|
|
||||||
window.location.href = url.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
|
||||||
const isOAuth = data.sourceType === "OAUTH2";
|
|
||||||
createAccessSource({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
organizationId,
|
|
||||||
connectorId: isOAuth ? data.connectorId : null,
|
|
||||||
name: data.name,
|
|
||||||
csvData: isOAuth ? null : (data.csvData || null),
|
|
||||||
},
|
|
||||||
connections: [connectionId],
|
|
||||||
},
|
|
||||||
onCompleted(_, errors) {
|
|
||||||
if (errors?.length) {
|
|
||||||
toast({
|
|
||||||
title: __("Error"),
|
|
||||||
description: formatError(
|
|
||||||
__("Failed to create access source"),
|
|
||||||
errors as GraphQLError[],
|
|
||||||
),
|
|
||||||
variant: "error",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast({
|
|
||||||
title: __("Success"),
|
|
||||||
description: __("Access source created successfully."),
|
|
||||||
variant: "success",
|
|
||||||
});
|
|
||||||
clearConnectorQueryParam();
|
|
||||||
reset();
|
|
||||||
ref.current?.close();
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
toast({
|
|
||||||
title: __("Error"),
|
|
||||||
description: formatError(
|
|
||||||
__("Failed to create access source"),
|
|
||||||
error as GraphQLError,
|
|
||||||
),
|
|
||||||
variant: "error",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
ref={ref}
|
|
||||||
trigger={children}
|
|
||||||
title={(
|
|
||||||
<Breadcrumb
|
|
||||||
items={[
|
|
||||||
__("Access Reviews"),
|
|
||||||
__("New Access Source"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
|
||||||
<DialogContent padded className="space-y-4">
|
|
||||||
<Field
|
|
||||||
label={__("Name")}
|
|
||||||
{...register("name")}
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Field label={__("Source type")}>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="sourceType"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<Option value="CSV">{__("CSV Upload")}</Option>
|
|
||||||
<Option value="OAUTH2">{__("OAuth2 Connector")}</Option>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{sourceType === "OAUTH2" && (
|
|
||||||
<>
|
|
||||||
<Field label={__("Provider")}>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="provider"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
value={field.value}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
setValue("connectorId", undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Option value="GOOGLE_WORKSPACE">{__("Google Workspace")}</Option>
|
|
||||||
<Option value="LINEAR">{__("Linear")}</Option>
|
|
||||||
<Option value="SLACK">{__("Slack")}</Option>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label={__("Connector")}>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="connectorId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
value={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
>
|
|
||||||
{selectableConnectors.map(connector => (
|
|
||||||
<Option key={connector.id} value={connector.id}>
|
|
||||||
{`${providerLabel(connector.provider)} (${new Date(connector.createdAt).toLocaleDateString(undefined, { month: "short", year: "numeric" })})`}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="text-txt-secondary text-sm">
|
|
||||||
{__("Need a new connection? Connect your provider and come back to continue creating this source.")}
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={startOAuthConnection}
|
|
||||||
disabled={!provider}
|
|
||||||
>
|
|
||||||
{__("Connect")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{sourceType === "CSV" && (
|
|
||||||
<>
|
|
||||||
<Field
|
|
||||||
label={__("CSV Data")}
|
|
||||||
{...register("csvData")}
|
|
||||||
type="textarea"
|
|
||||||
placeholder="email,full_name,role,job_title,is_admin,active,external_id"
|
|
||||||
/>
|
|
||||||
<p className="text-txt-secondary text-sm">
|
|
||||||
{__("Paste CSV content with a header row. Supported columns: email, full_name, role, job_title, is_admin, active, external_id.")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button disabled={isCreating} type="submit">
|
|
||||||
{__("Create")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
export const createAccessSourceMutation = graphql`
|
||||||
|
mutation accessSourceMutationsCreateMutation(
|
||||||
|
$input: CreateAccessSourceInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
createAccessSource(input: $input) {
|
||||||
|
accessSourceEdge @prependEdge(connections: $connections) {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
createdAt
|
||||||
|
...AccessSourceRowFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -34,12 +34,12 @@ import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/Ac
|
|||||||
import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql";
|
import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql";
|
||||||
import type { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.graphql";
|
import type { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.graphql";
|
||||||
import type { AddAccessSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
|
import type { AddAccessSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
|
||||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { AccessSourceRow } from "../_components/AccessSourceRow";
|
import { AccessSourceRow } from "../_components/AccessSourceRow";
|
||||||
import { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessSourceDialog";
|
import { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessSourceDialog";
|
||||||
import { createAccessSourceMutation } from "../dialogs/CreateAccessSourceDialog";
|
import { createAccessSourceMutation } from "../dialogs/accessSourceMutations";
|
||||||
|
|
||||||
export const accessReviewSourcesTabQuery = graphql`
|
export const accessReviewSourcesTabQuery = graphql`
|
||||||
query AccessReviewSourcesTabQuery($organizationId: ID!) {
|
query AccessReviewSourcesTabQuery($organizationId: ID!) {
|
||||||
@@ -131,7 +131,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [createAccessSource, isCreatingSource]
|
const [createAccessSource, isCreatingSource]
|
||||||
= useMutation<CreateAccessSourceDialogMutation>(
|
= useMutation<accessSourceMutationsCreateMutation>(
|
||||||
createAccessSourceMutation,
|
createAccessSourceMutation,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
|
|||||||
const fragment = graphql`
|
const fragment = graphql`
|
||||||
fragment CompliancePageSlackSectionFragment on Organization {
|
fragment CompliancePageSlackSectionFragment on Organization {
|
||||||
canConnectSlack: permission(action: "core:connector:initiate")
|
canConnectSlack: permission(action: "core:connector:initiate")
|
||||||
|
slackOAuth2Scopes
|
||||||
slackConnections(first: 100) {
|
slackConnections(first: 100) {
|
||||||
__id
|
__id
|
||||||
edges {
|
edges {
|
||||||
@@ -144,7 +145,9 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" asChild>
|
<Button variant="secondary" asChild>
|
||||||
<a href={getSlackConnectionUrl(organizationId)}>{__("Connect")}</a>
|
<a href={getSlackConnectionUrl(organizationId, organization.slackOAuth2Scopes)}>
|
||||||
|
{__("Connect")}
|
||||||
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -153,13 +156,15 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSlackConnectionUrl(organizationId: string): string {
|
function getSlackConnectionUrl(organizationId: string, scopes: readonly string[]): string {
|
||||||
const baseUrl = import.meta.env.VITE_API_URL || window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_URL || window.location.origin;
|
||||||
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
|
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
|
||||||
url.searchParams.append("organization_id", organizationId);
|
url.searchParams.append("organization_id", organizationId);
|
||||||
url.searchParams.append("provider", "SLACK");
|
url.searchParams.append("provider", "SLACK");
|
||||||
|
for (const scope of scopes) {
|
||||||
|
url.searchParams.append("scope", scope);
|
||||||
|
}
|
||||||
const redirectUrl = `/organizations/${organizationId}/compliance-page`;
|
const redirectUrl = `/organizations/${organizationId}/compliance-page`;
|
||||||
url.searchParams.append("continue", redirectUrl);
|
url.searchParams.append("continue", redirectUrl);
|
||||||
const finalUrl = url.toString();
|
return url.toString();
|
||||||
return finalUrl;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user