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:
Aurélien Sibiril
2026-04-07 14:53:43 +02:00
parent 247cae14fa
commit 614b4a2384
9 changed files with 72 additions and 379 deletions

View File

@@ -21,6 +21,7 @@ import { GoogleWorkspaceConnector } from "./GoogleWorkspaceConnector";
const connectorListFragment = graphql`
fragment ConnectorListFragment on Organization {
googleWorkspaceOAuth2Scopes
scimConfiguration {
...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.",
)}
</p>
<GoogleWorkspaceConnector fKey={data.scimConfiguration ?? null} />
<GoogleWorkspaceConnector
fKey={data.scimConfiguration ?? null}
oauth2Scopes={data.googleWorkspaceOAuth2Scopes}
/>
</div>
);
}

View File

@@ -74,8 +74,9 @@ const updateSCIMBridgeMutation = graphql`
export function GoogleWorkspaceConnector(props: {
fKey: GoogleWorkspaceConnectorFragment$key | null;
oauth2Scopes: readonly string[];
}) {
const { fKey } = props;
const { fKey, oauth2Scopes } = props;
const data = useFragment<GoogleWorkspaceConnectorFragment$key>(googleWorkspaceConnectorFragment, fKey);
const bridge = data?.bridge;
const connector = bridge?.connector;
@@ -112,6 +113,9 @@ export function GoogleWorkspaceConnector(props: {
const url = new URL("/api/console/v1/connectors/initiate", baseUrl);
url.searchParams.append("organization_id", organizationId);
url.searchParams.append("provider", "GOOGLE_WORKSPACE");
for (const scope of oauth2Scopes) {
url.searchParams.append("scope", scope);
}
const continueUrl = `/organizations/${organizationId}/settings/scim`;
url.searchParams.append("continue", continueUrl);
window.location.href = url.toString();

View File

@@ -27,12 +27,12 @@ import { Link, useNavigate } from "react-router";
import { ConnectionHandler, graphql } from "relay-runtime";
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 { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { createAccessSourceMutation } from "./dialogs/CreateAccessSourceDialog";
import { createAccessSourceMutation } from "./dialogs/accessSourceMutations";
export const createCsvAccessSourcePageQuery = graphql`
query CreateCsvAccessSourcePageQuery($organizationId: ID!) {
@@ -81,7 +81,7 @@ export default function CreateCsvAccessSourcePage({
);
const [createAccessSource, isCreating]
= useMutation<CreateAccessSourceDialogMutation>(
= useMutation<accessSourceMutationsCreateMutation>(
createAccessSourceMutation,
);

View File

@@ -45,6 +45,7 @@ const fragment = graphql`
connector {
provider
}
oauth2Scopes
connectionStatus
selectedOrganization
needsConfiguration
@@ -213,6 +214,9 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
url.searchParams.append("organization_id", organizationId);
url.searchParams.append("provider", provider);
url.searchParams.append("connector_id", accessSource.connectorId);
for (const scope of accessSource.oauth2Scopes) {
url.searchParams.append("scope", scope);
}
url.searchParams.append(
"continue",
`/organizations/${organizationId}/access-reviews/sources`,

View File

@@ -40,9 +40,9 @@ import { graphql } from "relay-runtime";
import type { AddAccessSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
import type { AddAccessSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateAPIKeyConnectorMutation.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`
fragment AddAccessSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
@@ -51,6 +51,7 @@ export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
oauthConfigured
apiKeySupported
clientCredentialsSupported
oauth2Scopes
extraSettings {
key
label
@@ -185,7 +186,7 @@ export function AddAccessSourceDialog({
);
const [createAccessSource]
= useMutation<CreateAccessSourceDialogMutation>(
= useMutation<accessSourceMutationsCreateMutation>(
createAccessSourceMutation,
);
const [createAPIKeyConnector]
@@ -197,11 +198,14 @@ export function AddAccessSourceDialog({
createClientCredentialsConnectorMutation,
);
const connectOAuthProvider = (provider: string) => {
const connectOAuthProvider = (info: ProviderInfo) => {
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("provider", info.provider);
for (const scope of info.oauth2Scopes) {
url.searchParams.append("scope", scope);
}
url.searchParams.append(
"continue",
`/organizations/${organizationId}/access-reviews/sources`,
@@ -419,7 +423,7 @@ export function AddAccessSourceDialog({
return (
<Button
variant="secondary"
onClick={() => connectOAuthProvider(info.provider)}
onClick={() => connectOAuthProvider(info)}
>
{__("Connect")}
</Button>

View File

@@ -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>
);
}

View File

@@ -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
}
}
}
}
`;

View File

@@ -34,12 +34,12 @@ import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/Ac
import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql";
import type { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.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 { AccessSourceRow } from "../_components/AccessSourceRow";
import { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessSourceDialog";
import { createAccessSourceMutation } from "../dialogs/CreateAccessSourceDialog";
import { createAccessSourceMutation } from "../dialogs/accessSourceMutations";
export const accessReviewSourcesTabQuery = graphql`
query AccessReviewSourcesTabQuery($organizationId: ID!) {
@@ -131,7 +131,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
);
const [createAccessSource, isCreatingSource]
= useMutation<CreateAccessSourceDialogMutation>(
= useMutation<accessSourceMutationsCreateMutation>(
createAccessSourceMutation,
);

View File

@@ -25,6 +25,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
const fragment = graphql`
fragment CompliancePageSlackSectionFragment on Organization {
canConnectSlack: permission(action: "core:connector:initiate")
slackOAuth2Scopes
slackConnections(first: 100) {
__id
edges {
@@ -144,7 +145,9 @@ export function CompliancePageSlackSection(props: { fragmentRef: CompliancePageS
</p>
</div>
<Button variant="secondary" asChild>
<a href={getSlackConnectionUrl(organizationId)}>{__("Connect")}</a>
<a href={getSlackConnectionUrl(organizationId, organization.slackOAuth2Scopes)}>
{__("Connect")}
</a>
</Button>
</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 url = new URL("/api/console/v1/connectors/initiate", baseUrl);
url.searchParams.append("organization_id", organizationId);
url.searchParams.append("provider", "SLACK");
for (const scope of scopes) {
url.searchParams.append("scope", scope);
}
const redirectUrl = `/organizations/${organizationId}/compliance-page`;
url.searchParams.append("continue", redirectUrl);
const finalUrl = url.toString();
return finalUrl;
return url.toString();
}