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 ( - - )} - > -
void handleSubmit(onSubmit)(e)}> - - - - ( - - )} - /> - - {sourceType === "OAUTH2" && ( - <> - - ( - - )} - /> - - - ( - - )} - /> - -
-

- {__("Need a new connection? Connect your provider and come back to continue creating this source.")} -

- -
- - )} - {sourceType === "CSV" && ( - <> - -

- {__("Paste CSV content with a header row. Supported columns: email, full_name, role, job_title, is_admin, active, external_id.")} -

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