From 614b4a23849bc3965372bafbdfcbe54d641b8986 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?=
<81782+aureliensibiril@users.noreply.github.com>
Date: Tue, 7 Apr 2026 14:53:43 +0200
Subject: [PATCH] Read OAuth2 scopes from GraphQL on the connector frontend
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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>
---
.../settings/_components/ConnectorList.tsx | 6 +-
.../_components/GoogleWorkspaceConnector.tsx | 6 +-
.../CreateCsvAccessSourcePage.tsx | 6 +-
.../_components/AccessSourceRow.tsx | 4 +
.../dialogs/AddAccessSourceDialog.tsx | 16 +-
.../dialogs/CreateAccessSourceDialog.tsx | 361 ------------------
.../dialogs/accessSourceMutations.ts | 33 ++
.../sources/AccessReviewSourcesTab.tsx | 6 +-
.../CompliancePageSlackSection.tsx | 13 +-
9 files changed, 72 insertions(+), 379 deletions(-)
delete mode 100644 apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx
create mode 100644 apps/console/src/pages/organizations/access-reviews/dialogs/accessSourceMutations.ts
diff --git a/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx b/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx
index 624301d05..54b41797b 100644
--- a/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx
+++ b/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx
@@ -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.",
)}
-
+
);
}
diff --git a/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx b/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx
index f9f0aaa42..daf6ebf7d 100644
--- a/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx
+++ b/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx
@@ -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, 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();
diff --git a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx
index f91b89b0a..df29ef7e3 100644
--- a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx
+++ b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx
@@ -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(
+ = useMutation(
createAccessSourceMutation,
);
diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx
index 2e7befce0..ae4199797 100644
--- a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx
+++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx
@@ -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`,
diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx
index b12c10b61..9fa7a4f80 100644
--- a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx
+++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx
@@ -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(
+ = useMutation(
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 (
diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx
deleted file mode 100644
index dfcb82fa3..000000000
--- a/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx
+++ /dev/null
@@ -1,361 +0,0 @@
-// Copyright (c) 2026 Probo Inc .
-//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
-//
-// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
-
-import { formatError, 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(
- 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) => {
- 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 (
-
- )}
- >
-
-
- );
-}
diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/accessSourceMutations.ts b/apps/console/src/pages/organizations/access-reviews/dialogs/accessSourceMutations.ts
new file mode 100644
index 000000000..932bb1e35
--- /dev/null
+++ b/apps/console/src/pages/organizations/access-reviews/dialogs/accessSourceMutations.ts
@@ -0,0 +1,33 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+import { 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
+ }
+ }
+ }
+ }
+`;
diff --git a/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx
index a15c8270e..1f81d45cb 100644
--- a/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx
+++ b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx
@@ -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(
+ = useMutation(
createAccessSourceMutation,
);
diff --git a/apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageSlackSection.tsx b/apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageSlackSection.tsx
index 4ab9aba6e..990421e3c 100644
--- a/apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageSlackSection.tsx
+++ b/apps/console/src/pages/organizations/compliance-page/overview/_components/CompliancePageSlackSection.tsx
@@ -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
)}
@@ -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();
}