From b39635916275f09e9cfea5bfc248c40d5f4be124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:52:13 +0200 Subject: [PATCH] Add access review frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add campaign and source management pages with detail views, bulk decision and flag controls, connector provider dialog with OAuth/API-key/client-credentials flows, vendor logos, shared helpers, and campaign lifecycle UX (start, complete, cancel). Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- .../iam/organizations/_components/Sidebar.tsx | 11 + .../access-reviews/AccessReviewLayout.tsx | 102 +++ .../AccessReviewLayoutLoader.tsx | 41 + .../CreateCsvAccessSourcePage.tsx | 184 ++++ .../CreateCsvAccessSourcePageLoader.tsx | 42 + .../_components/AccessSourceRow.tsx | 364 ++++++++ .../_components/EntryDecisionActions.tsx | 181 ++++ .../_components/EntryFlagSelect.tsx | 161 ++++ .../_components/accessReviewHelpers.tsx | 167 ++++ .../campaigns/AccessReviewCampaignsTab.tsx | 159 ++++ .../campaigns/CampaignDetailPage.tsx | 803 ++++++++++++++++++ .../campaigns/CampaignDetailPageLoader.tsx | 43 + .../dialogs/AddAccessSourceDialog.tsx | 689 +++++++++++++++ .../dialogs/AddCampaignScopeSourceDialog.tsx | 233 +++++ .../CreateAccessReviewCampaignDialog.tsx | 261 ++++++ .../dialogs/CreateAccessSourceDialog.tsx | 361 ++++++++ .../sources/AccessReviewSourcesTab.tsx | 280 ++++++ apps/console/src/routes.tsx | 2 + apps/console/src/routes/accessReviewRoutes.ts | 44 + packages/ui/src/Atoms/Icons/IconRobot.tsx | 29 + packages/ui/src/Atoms/Icons/index.tsx | 1 + packages/ui/src/Atoms/Select/Select.tsx | 17 + packages/ui/src/Atoms/Vendors/Brex.tsx | 16 + packages/ui/src/Atoms/Vendors/Cloudflare.tsx | 16 + packages/ui/src/Atoms/Vendors/DocuSign.tsx | 16 + packages/ui/src/Atoms/Vendors/Figma.tsx | 16 + packages/ui/src/Atoms/Vendors/GitHub.tsx | 16 + packages/ui/src/Atoms/Vendors/HubSpot.tsx | 16 + packages/ui/src/Atoms/Vendors/Intercom.tsx | 16 + packages/ui/src/Atoms/Vendors/Linear.tsx | 16 + packages/ui/src/Atoms/Vendors/Notion.tsx | 16 + packages/ui/src/Atoms/Vendors/OnePassword.tsx | 16 + packages/ui/src/Atoms/Vendors/OpenAI.tsx | 16 + packages/ui/src/Atoms/Vendors/Resend.tsx | 16 + packages/ui/src/Atoms/Vendors/Sentry.tsx | 16 + packages/ui/src/Atoms/Vendors/Supabase.tsx | 16 + packages/ui/src/Atoms/Vendors/Tally.tsx | 16 + packages/ui/src/Atoms/Vendors/VendorLogo.tsx | 80 ++ packages/ui/src/Atoms/Vendors/index.ts | 16 + .../ui/src/Molecules/Dialog/ConfirmDialog.tsx | 2 +- packages/ui/src/index.ts | 2 +- 41 files changed, 4513 insertions(+), 2 deletions(-) create mode 100644 apps/console/src/pages/organizations/access-reviews/AccessReviewLayout.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/AccessReviewLayoutLoader.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPageLoader.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignScopeSourceDialog.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx create mode 100644 apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx create mode 100644 apps/console/src/routes/accessReviewRoutes.ts create mode 100644 packages/ui/src/Atoms/Icons/IconRobot.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Brex.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Cloudflare.tsx create mode 100644 packages/ui/src/Atoms/Vendors/DocuSign.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Figma.tsx create mode 100644 packages/ui/src/Atoms/Vendors/GitHub.tsx create mode 100644 packages/ui/src/Atoms/Vendors/HubSpot.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Intercom.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Linear.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Notion.tsx create mode 100644 packages/ui/src/Atoms/Vendors/OnePassword.tsx create mode 100644 packages/ui/src/Atoms/Vendors/OpenAI.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Resend.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Sentry.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Supabase.tsx create mode 100644 packages/ui/src/Atoms/Vendors/Tally.tsx create mode 100644 packages/ui/src/Atoms/Vendors/VendorLogo.tsx diff --git a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx index 1a5c6291c..367ca0cd6 100644 --- a/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx +++ b/apps/console/src/pages/iam/organizations/_components/Sidebar.tsx @@ -22,6 +22,7 @@ import { IconFire3, IconGroup1, IconInboxEmpty, + IconKey, IconListStack, IconLock, IconMagnifyingGlass, @@ -66,6 +67,9 @@ const fragment = graphql` canListStatesOfApplicability: permission( action: "core:state-of-applicability:list" ) + canListAccessReviewCampaigns: permission( + action: "core:access-review-campaign:list" + ) } `; @@ -200,6 +204,13 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) { to={`${prefix}/snapshots`} /> )} + {organization.canListAccessReviewCampaigns && ( + + )} {organization.canGetTrustCenter && ( . +// +// 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 { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + IconFolder2, + IconKey, + PageHeader, + TabLink, + Tabs, +} from "@probo/ui"; +import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; +import { Outlet } from "react-router"; +import { graphql } from "relay-runtime"; + +import type { AccessReviewLayoutQuery } from "#/__generated__/core/AccessReviewLayoutQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +export const accessReviewLayoutQuery = graphql` + query AccessReviewLayoutQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + __typename + ... on Organization { + id + canCreateSource: permission(action: "core:access-source:create") + canCreateCampaign: permission(action: "core:access-review-campaign:create") + connectorProviderInfos { + provider + displayName + oauthConfigured + apiKeySupported + clientCredentialsSupported + extraSettings { + key + label + required + } + } + ...AccessReviewCampaignsTabFragment + ...AccessReviewSourcesTabFragment + } + } + } +`; + +export default function AccessReviewLayout({ + queryRef, +}: { + queryRef: PreloadedQuery; +}) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + + usePageTitle(__("Access Reviews")); + + const { organization } = usePreloadedQuery(accessReviewLayoutQuery, queryRef); + if (organization.__typename !== "Organization") { + throw new Error("Organization not found"); + } + + return ( +
+ + + + + + {__("Campaigns")} + + + + {__("Sources")} + + + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/AccessReviewLayoutLoader.tsx b/apps/console/src/pages/organizations/access-reviews/AccessReviewLayoutLoader.tsx new file mode 100644 index 000000000..2eef36dbe --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/AccessReviewLayoutLoader.tsx @@ -0,0 +1,41 @@ +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { AccessReviewLayoutQuery } from "#/__generated__/core/AccessReviewLayoutQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import AccessReviewLayout, { accessReviewLayoutQuery } from "./AccessReviewLayout"; + +export default function AccessReviewLayoutLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] = useQueryLoader(accessReviewLayoutQuery); + + useEffect(() => { + if (!queryRef) { + loadQuery({ organizationId }); + } + }, [loadQuery, organizationId]); + + if (!queryRef) return ; + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx new file mode 100644 index 000000000..f91b89b0a --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePage.tsx @@ -0,0 +1,184 @@ +// 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 { usePageTitle } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + Button, + Card, + Field, + PageHeader, + useToast, +} from "@probo/ui"; +import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay"; +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 { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { createAccessSourceMutation } from "./dialogs/CreateAccessSourceDialog"; + +export const createCsvAccessSourcePageQuery = graphql` + query CreateCsvAccessSourcePageQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + __typename + ... on Organization { + id + canCreateSource: permission(action: "core:access-source:create") + } + } + } +`; + +const csvSchema = z.object({ + name: z.string().min(1), + csvData: z.string().min(1), +}); + +export default function CreateCsvAccessSourcePage({ + queryRef, +}: { + queryRef: PreloadedQuery; +}) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const navigate = useNavigate(); + const organizationId = useOrganizationId(); + const { register, handleSubmit } + = useFormWithSchema(csvSchema, { + defaultValues: { + name: "", + csvData: "", + }, + }); + + usePageTitle(__("Add CSV Access Source")); + + const { organization } = usePreloadedQuery(createCsvAccessSourcePageQuery, queryRef); + if (organization.__typename !== "Organization") { + throw new Error("Organization not found"); + } + + const connectionId = ConnectionHandler.getConnectionID( + organization.id, + "AccessReviewSourcesTab_accessSources", + ); + + const [createAccessSource, isCreating] + = useMutation( + createAccessSourceMutation, + ); + + if (!organization.canCreateSource) { + return ( + +

+ {__("You do not have permission to create access sources.")} +

+
+ ); + } + + const onSubmit = (data: z.infer) => { + createAccessSource({ + variables: { + input: { + organizationId, + connectorId: null, + name: data.name, + csvData: data.csvData, + }, + connections: connectionId ? [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", + }); + void navigate(`/organizations/${organizationId}/access-reviews/sources`); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to create access source"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + return ( +
+ + + +
void handleSubmit(onSubmit)(e)} className="space-y-4"> + + + +

+ {__("Supported columns: email, full_name, role, job_title, is_admin, active, external_id.")} +

+ +
+ + +
+ +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader.tsx b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader.tsx new file mode 100644 index 000000000..ee5f4d188 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader.tsx @@ -0,0 +1,42 @@ +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import CreateCsvAccessSourcePage, { createCsvAccessSourcePageQuery } from "./CreateCsvAccessSourcePage"; + +export default function CreateCsvAccessSourcePageLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] + = useQueryLoader(createCsvAccessSourcePageQuery); + + useEffect(() => { + loadQuery({ organizationId }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx new file mode 100644 index 000000000..2e7befce0 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx @@ -0,0 +1,364 @@ +// 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 { formatDate, formatError, type GraphQLError, sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Badge, + Button, + DropdownItem, + IconTrashCan, + Input, + Option, + Select, + Td, + Tr, + useConfirm, + useToast, +} from "@probo/ui"; +import { Suspense, useState } from "react"; +import { useFragment, useLazyLoadQuery, useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { AccessSourceRowConfigureMutation } from "#/__generated__/core/AccessSourceRowConfigureMutation.graphql"; +import type { AccessSourceRowDeleteMutation } from "#/__generated__/core/AccessSourceRowDeleteMutation.graphql"; +import type { AccessSourceRowFragment$key } from "#/__generated__/core/AccessSourceRowFragment.graphql"; +import type { AccessSourceRowOrgsQuery } from "#/__generated__/core/AccessSourceRowOrgsQuery.graphql"; + +const fragment = graphql` + fragment AccessSourceRowFragment on AccessSource { + id + name + connectorId + connector { + provider + } + connectionStatus + selectedOrganization + needsConfiguration + createdAt + canDelete: permission(action: "core:access-source:delete") + } +`; + +export const deleteAccessSourceMutation = graphql` + mutation AccessSourceRowDeleteMutation( + $input: DeleteAccessSourceInput! + $connections: [ID!]! + ) { + deleteAccessSource(input: $input) { + deletedAccessSourceId @deleteEdge(connections: $connections) + } + } +`; + +const configureMutation = graphql` + mutation AccessSourceRowConfigureMutation( + $input: ConfigureAccessSourceInput! + ) { + configureAccessSource(input: $input) { + accessSource { + id + selectedOrganization + needsConfiguration + } + } + } +`; + +const orgsQuery = graphql` + query AccessSourceRowOrgsQuery($accessSourceId: ID!) { + node(id: $accessSourceId) @required(action: THROW) { + ... on AccessSource { + providerOrganizations { + slug + displayName + } + } + } + } +`; + +type Props = { + fKey: AccessSourceRowFragment$key; + connectionId: string; + organizationId: string; +}; + +function sourceLabel(connectorProvider: string | null | undefined): string { + if (!connectorProvider) { + return "CSV"; + } + + switch (connectorProvider) { + case "GOOGLE_WORKSPACE": + return "Google Workspace"; + case "LINEAR": + return "Linear"; + case "SLACK": + return "Slack"; + default: + return connectorProvider; + } +} + +export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) { + const { __ } = useTranslate(); + const confirm = useConfirm(); + const { toast } = useToast(); + + const accessSource = useFragment(fragment, fKey); + + const [deleteAccessSource] = useMutation(deleteAccessSourceMutation); + const [configure] = useMutation(configureMutation); + + const handleDelete = () => { + confirm( + () => { + deleteAccessSource({ + variables: { + input: { accessSourceId: accessSource.id }, + connections: [connectionId], + }, + onCompleted: (_response, errors) => { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to delete access source"), + errors as GraphQLError[], + ), + variant: "error", + }); + } + }, + onError: (error) => { + toast({ + title: __("Error"), + description: formatError( + __("Failed to delete access source"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }, + { + message: sprintf( + __("This will permanently delete \"%s\". This action cannot be undone."), + accessSource.name, + ), + }, + ); + }; + + const handleOrgChange = (slug: string) => { + configure({ + variables: { + input: { + accessSourceId: accessSource.id, + organizationSlug: slug, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to configure source"), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Organization updated."), + variant: "success", + }); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to configure source"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + const handleReconnect = () => { + const provider = accessSource.connector?.provider; + if (!provider || !accessSource.connectorId) 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("connector_id", accessSource.connectorId); + url.searchParams.append( + "continue", + `/organizations/${organizationId}/access-reviews/sources`, + ); + window.location.href = url.toString(); + }; + + const showOrgSelector = accessSource.needsConfiguration || accessSource.selectedOrganization; + + return ( + + {accessSource.name} + + + {sourceLabel(accessSource.connector?.provider ?? null)} + + + + {accessSource.connectionStatus === "CONNECTED" && ( + {__("Connected")} + )} + {accessSource.connectionStatus === "DISCONNECTED" && ( +
+ {__("Disconnected")} + +
+ )} + + + {showOrgSelector && ( + + } + > + + + )} + + + + + {accessSource.canDelete && ( + + + { + e.preventDefault(); + e.stopPropagation(); + handleDelete(); + }} + > + {__("Delete")} + + + + )} + + ); +} + +function InlineOrgSelect({ + accessSourceId, + selectedOrganization, + onSelect, +}: { + accessSourceId: string; + selectedOrganization: string; + onSelect: (slug: string) => void; +}) { + const { __ } = useTranslate(); + const data = useLazyLoadQuery( + orgsQuery, + { accessSourceId }, + { fetchPolicy: "store-or-network" }, + ); + + const orgs = data.node.providerOrganizations ?? []; + + if (orgs.length === 0) { + return ( + + ); + } + + return ( + + ); +} + +function ManualOrgInput({ + selectedOrganization, + onSubmit, +}: { + selectedOrganization: string; + onSubmit: (slug: string) => void; +}) { + const { __ } = useTranslate(); + const [value, setValue] = useState(selectedOrganization); + + const handleBlur = () => { + const trimmed = value.trim(); + if (trimmed && trimmed !== selectedOrganization) { + onSubmit(trimmed); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleBlur(); + } + }; + + return ( + setValue(e.target.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + className="max-w-40" + /> + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx b/apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx new file mode 100644 index 000000000..9af53c5bd --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/_components/EntryDecisionActions.tsx @@ -0,0 +1,181 @@ +// 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 { + Badge, + Button, + Dialog, + DialogContent, + DialogFooter, + Field, + IconPencil, + Option, + Select, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useState } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { AccessEntryDecision, EntryDecisionActionsMutation } from "#/__generated__/core/EntryDecisionActionsMutation.graphql"; + +import { decisionBadgeVariant, decisionLabel } from "./accessReviewHelpers"; + +const mutation = graphql` + mutation EntryDecisionActionsMutation( + $input: RecordAccessEntryDecisionInput! + ) { + recordAccessEntryDecision(input: $input) { + accessEntry { + id + decision + decisionNote + } + } + } +`; + +type Props = { + entryId: string; + decision: string; +}; + +export function EntryDecisionActions({ entryId, decision }: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const ref = useDialogRef(); + const [editing, setEditing] = useState(false); + const [pendingDecision, setPendingDecision] = useState(null); + const [note, setNote] = useState(""); + const [recordDecision, isRecording] + = useMutation(mutation); + + const submitDecision = (decisionValue: AccessEntryDecision, decisionNote?: string) => { + recordDecision({ + variables: { + input: { + accessEntryId: entryId, + decision: decisionValue, + decisionNote: decisionNote || null, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to record decision"), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + setPendingDecision(null); + setNote(""); + setEditing(false); + ref.current?.close(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to record decision"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + const openNoteDialog = (decisionValue: AccessEntryDecision) => { + setPendingDecision(decisionValue); + setNote(""); + ref.current?.open(); + }; + + const handleDecision = (value: string) => { + const decision = value as AccessEntryDecision; + if (decision === "APPROVED") { + submitDecision(decision); + } else { + openNoteDialog(decision); + } + }; + + // Already decided -- show badge with edit button + if (decision !== "PENDING" && !editing) { + return ( +
+ + {decisionLabel(__, decision)} + + +
+ ); + } + + return ( + <> + + + + +

+ {__("Please provide a reason for this decision.")} +

+ +
+ + + +
+ + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx b/apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx new file mode 100644 index 000000000..a1da1dc7e --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/_components/EntryFlagSelect.tsx @@ -0,0 +1,161 @@ +// 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 { Badge, Checkbox, useToast } from "@probo/ui"; +import * as Popover from "@radix-ui/react-popover"; +import { useRef, useState } from "react"; +import { useMutation } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { AccessEntryFlag, EntryFlagSelectMutation } from "#/__generated__/core/EntryFlagSelectMutation.graphql"; + +import { flagBadgeVariant, flagGroups, flagLabel } from "./accessReviewHelpers"; + +const mutation = graphql` + mutation EntryFlagSelectMutation($input: FlagAccessEntryInput!) { + flagAccessEntry(input: $input) { + accessEntry { + id + flags + flagReasons + } + } + } +`; + +type Props = { + entryId: string; + currentFlags: readonly AccessEntryFlag[]; +}; + +export function EntryFlagSelect({ entryId, currentFlags }: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const [open, setOpen] = useState(false); + const [localFlags, setLocalFlags] = useState([...currentFlags]); + const openedWithRef = useRef(currentFlags); + const [flagEntry] = useMutation(mutation); + + const toggleFlag = (flagValue: AccessEntryFlag) => { + setLocalFlags(prev => + prev.includes(flagValue) + ? prev.filter(f => f !== flagValue) + : [...prev, flagValue], + ); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + openedWithRef.current = currentFlags; + setLocalFlags([...currentFlags]); + } + + if (!nextOpen) { + // Submit only if flags changed since popover opened + const changed + = localFlags.length !== openedWithRef.current.length + || localFlags.some(f => !openedWithRef.current.includes(f)); + + if (changed) { + flagEntry({ + variables: { + input: { + accessEntryId: entryId, + flags: localFlags, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to flag entry"), + errors as GraphQLError[], + ), + variant: "error", + }); + } + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to flag entry"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + } + } + + setOpen(nextOpen); + }; + + const displayFlags = open ? localFlags : [...currentFlags]; + + return ( + + + + + + + {flagGroups.map(group => ( +
+
+ {__(group.label)} +
+ {group.flags.map(flag => ( + + ))} +
+ ))} +
+
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx b/apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx new file mode 100644 index 000000000..09895037d --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/_components/accessReviewHelpers.tsx @@ -0,0 +1,167 @@ +// 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. + +type BadgeVariant = "neutral" | "info" | "warning" | "success" | "danger"; + +export function statusBadgeVariant(status: string): BadgeVariant { + switch (status) { + case "DRAFT": + return "neutral"; + case "IN_PROGRESS": + return "info"; + case "PENDING_ACTIONS": + return "warning"; + case "COMPLETED": + return "success"; + case "FAILED": + case "CANCELLED": + return "danger"; + default: + return "neutral"; + } +} + +export function statusLabel( + __: (key: string) => string, + status: string, +): string { + switch (status) { + case "DRAFT": + return __("Draft"); + case "IN_PROGRESS": + return __("In progress"); + case "PENDING_ACTIONS": + return __("Pending actions"); + case "COMPLETED": + return __("Completed"); + case "FAILED": + return __("Failed"); + case "CANCELLED": + return __("Cancelled"); + default: + return status; + } +} + +export function decisionBadgeVariant(decision: string): BadgeVariant { + switch (decision) { + case "APPROVED": + return "success"; + case "REVOKE": + return "danger"; + case "DEFER": + return "warning"; + case "ESCALATE": + return "info"; + default: + return "neutral"; + } +} + +export function decisionLabel( + __: (key: string) => string, + decision: string, +): string { + switch (decision) { + case "PENDING": + return __("Pending"); + case "APPROVED": + return __("Approved"); + case "REVOKE": + return __("Revoked"); + case "DEFER": + return __("Modified"); + case "ESCALATE": + return __("Escalated"); + default: + return decision; + } +} + +export function flagBadgeVariant(flag: string): BadgeVariant { + switch (flag) { + case "ORPHANED": + case "TERMINATED_USER": + case "CONTRACTOR_EXPIRED": + return "danger"; + case "DORMANT": + case "EXCESSIVE": + case "SOD_CONFLICT": + case "PRIVILEGED_ACCESS": + case "ROLE_CREEP": + case "ROLE_MISMATCH": + return "warning"; + case "NO_BUSINESS_JUSTIFICATION": + case "OUT_OF_DEPARTMENT": + case "SHARED_ACCOUNT": + case "INACTIVE": + case "NEW": + return "info"; + default: + return "neutral"; + } +} + +export const flagGroups = [ + { + label: "Account", + flags: [ + { value: "ORPHANED" as const, label: "Orphan account" }, + { value: "DORMANT" as const, label: "Dormant" }, + { value: "TERMINATED_USER" as const, label: "Terminated user" }, + { value: "CONTRACTOR_EXPIRED" as const, label: "Contractor expired" }, + ], + }, + { + label: "Privileges", + flags: [ + { value: "EXCESSIVE" as const, label: "Excessive privileges" }, + { value: "SOD_CONFLICT" as const, label: "SoD conflict" }, + { value: "PRIVILEGED_ACCESS" as const, label: "Privileged access" }, + { value: "ROLE_CREEP" as const, label: "Role creep" }, + ], + }, + { + label: "Anomaly", + flags: [ + { value: "NO_BUSINESS_JUSTIFICATION" as const, label: "No justification" }, + { value: "OUT_OF_DEPARTMENT" as const, label: "Out of department" }, + { value: "SHARED_ACCOUNT" as const, label: "Shared account" }, + ], + }, +]; + +export function flagLabel(flag: string): string { + for (const group of flagGroups) { + for (const f of group.flags) { + if (f.value === flag) return f.label; + } + } + if (flag === "NONE") return "None"; + // Legacy flag values not shown in the grouped dropdown + if (flag === "INACTIVE") return "Inactive"; + if (flag === "ROLE_MISMATCH") return "Role mismatch"; + if (flag === "NEW") return "New"; + return flag; +} + +export function formatStatus(status: string): string { + return status.replace(/_/g, " "); +} + +export function NotAvailable() { + return ( + N/A + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx new file mode 100644 index 000000000..96b08421d --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab.tsx @@ -0,0 +1,159 @@ +// 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 { useTranslate } from "@probo/i18n"; +import { + Badge, + Button, + Card, + IconPlusLarge, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@probo/ui"; +import { graphql, usePaginationFragment } from "react-relay"; +import { useOutletContext } from "react-router"; + +import type { AccessReviewCampaignsTabFragment$key } from "#/__generated__/core/AccessReviewCampaignsTabFragment.graphql"; +import type { AccessReviewCampaignsTabPaginationQuery } from "#/__generated__/core/AccessReviewCampaignsTabPaginationQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { statusBadgeVariant, statusLabel } from "../_components/accessReviewHelpers"; +import { CreateAccessReviewCampaignDialog } from "../dialogs/CreateAccessReviewCampaignDialog"; + +const campaignsFragment = graphql` + fragment AccessReviewCampaignsTabFragment on Organization + @refetchable(queryName: "AccessReviewCampaignsTabPaginationQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 20 } + order: { + type: "AccessReviewCampaignOrder" + defaultValue: { direction: DESC, field: CREATED_AT } + } + after: { type: "CursorKey", defaultValue: null } + ) { + accessReviewCampaigns( + first: $first + after: $after + orderBy: $order + ) @connection(key: "AccessReviewCampaignsTab_accessReviewCampaigns") { + __id + edges { + node { + id + name + status + createdAt + startedAt + completedAt + } + } + } + } +`; + +export default function AccessReviewCampaignsTab() { + const { __, dateFormat } = useTranslate(); + const organizationId = useOrganizationId(); + const { organizationRef, canCreateCampaign } = useOutletContext<{ + organizationRef: AccessReviewCampaignsTabFragment$key; + canCreateCampaign: boolean; + }>(); + + const { + data: { accessReviewCampaigns }, + loadNext, + hasNext, + isLoadingNext, + } = usePaginationFragment< + AccessReviewCampaignsTabPaginationQuery, + AccessReviewCampaignsTabFragment$key + >(campaignsFragment, organizationRef); + + return ( +
+
+ {canCreateCampaign && ( + + + + )} +
+ + {accessReviewCampaigns.edges.length > 0 + ? ( + + + + + + + + + + + {accessReviewCampaigns.edges.map(edge => ( + + + + + + ))} + +
{__("Name")}{__("Status")}{__("Created at")}
{edge.node.name} + + {statusLabel(__, edge.node.status)} + + + {dateFormat(edge.node.createdAt)} +
+ + {hasNext && ( +
+ +
+ )} +
+ ) + : ( + +
+

+ {__("No access review campaigns yet. Create your first campaign to start reviewing access.")} +

+
+
+ )} +
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx new file mode 100644 index 000000000..5b9d69de0 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx @@ -0,0 +1,803 @@ +// 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 { formatDate, formatError, type GraphQLError, sprintf } from "@probo/helpers"; +import { useList } from "@probo/hooks"; +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Breadcrumb, + Button, + Card, + Checkbox, + Dialog, + DialogContent, + DialogFooter, + Field, + IconChevronDown, + IconChevronRight, + IconPlusLarge, + IconRobot, + Option, + Select, + Tbody, + Td, + Th, + Thead, + Tr, + useConfirm, + useDialogRef, + useToast, +} from "@probo/ui"; +import * as Popover from "@radix-ui/react-popover"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironment } from "react-relay"; +import { fetchQuery, graphql } from "relay-runtime"; + +import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql"; +import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql"; +import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql"; +import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql"; +import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { + decisionBadgeVariant, + decisionLabel, + flagBadgeVariant, + flagGroups, + flagLabel, + formatStatus, + NotAvailable, + statusBadgeVariant, + statusLabel, +} from "../_components/accessReviewHelpers"; +import { EntryDecisionActions } from "../_components/EntryDecisionActions"; +import { EntryFlagSelect } from "../_components/EntryFlagSelect"; +import { AddCampaignScopeSourceDialog } from "../dialogs/AddCampaignScopeSourceDialog"; + +const startCampaignMutation = graphql` + mutation CampaignDetailPageStartMutation( + $input: StartAccessReviewCampaignInput! + ) { + startAccessReviewCampaign(input: $input) { + accessReviewCampaign { + id + status + startedAt + } + } + } +`; + +const closeCampaignMutation = graphql` + mutation CampaignDetailPageCloseMutation( + $input: CloseAccessReviewCampaignInput! + ) { + closeAccessReviewCampaign(input: $input) { + accessReviewCampaign { + id + status + completedAt + } + } + } +`; + +const bulkDecisionMutation = graphql` + mutation CampaignDetailPageBulkDecisionMutation( + $input: RecordAccessEntryDecisionsInput! + ) { + recordAccessEntryDecisions(input: $input) { + accessEntries { + id + decision + decisionNote + } + } + } +`; + +const bulkFlagMutation = graphql` + mutation CampaignDetailPageBulkFlagMutation( + $input: FlagAccessEntryInput! + ) { + flagAccessEntry(input: $input) { + accessEntry { + id + flags + flagReasons + } + } + } +`; + +export const campaignDetailPageQuery = graphql` + query CampaignDetailPageQuery($campaignId: ID!) { + node(id: $campaignId) { + __typename + ... on AccessReviewCampaign { + id + name + status + createdAt + startedAt + completedAt + pendingEntryCount + scopeSources { + id + source { + id + } + name + fetchStatus + fetchedAccountsCount + entries(first: 500) { + edges { + node { + id + email + fullName + role + isAdmin + mfaStatus + accountType + lastLogin + decision + flags + } + } + pageInfo { + hasNextPage + } + } + } + } + } + } +`; + +type Props = { + queryRef: PreloadedQuery; +}; + +export default function CampaignDetailPage({ queryRef }: Props) { + const { __ } = useTranslate(); + const organizationId = useOrganizationId(); + const environment = useRelayEnvironment(); + const data = usePreloadedQuery(campaignDetailPageQuery, queryRef); + + if (data.node.__typename !== "AccessReviewCampaign") { + throw new Error("Campaign not found"); + } + + const campaign = data.node; + const { toast } = useToast(); + const isInProgress = campaign.status === "IN_PROGRESS"; + const isDraft = campaign.status === "DRAFT"; + const isPendingActions = campaign.status === "PENDING_ACTIONS"; + + const campaignIdRef = useRef(campaign.id); + + useEffect(() => { + campaignIdRef.current = campaign.id; + }, [campaign.id]); + + useEffect(() => { + if (!isInProgress) return; + const interval = setInterval(() => { + if (document.hidden) return; + fetchQuery( + environment, + campaignDetailPageQuery, + { campaignId: campaignIdRef.current }, + { fetchPolicy: "network-only" }, + ).subscribe({}); + }, 3000); + return () => clearInterval(interval); + }, [isInProgress, environment]); + const existingScopeSourceIds = useMemo( + () => campaign.scopeSources.flatMap(s => s.source?.id ? [s.source.id] : []), + [campaign.scopeSources], + ); + + const confirm = useConfirm(); + + const [startCampaign, isStarting] + = useMutation(startCampaignMutation); + + const [closeCampaign, isClosing] + = useMutation(closeCampaignMutation); + + const allDecided = campaign.scopeSources.length > 0 + && campaign.scopeSources.every(source => + source.entries + && source.entries.edges.length > 0 + && source.entries.edges.every(edge => edge.node.decision !== "PENDING") + && !source.entries.pageInfo.hasNextPage, + ); + + const handleStart = () => { + startCampaign({ + variables: { + input: { + accessReviewCampaignId: campaign.id, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to start campaign"), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Campaign started. Sources are being fetched."), + variant: "success", + }); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to start campaign"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + const handleComplete = () => { + confirm( + () => + new Promise((resolve) => { + closeCampaign({ + variables: { + input: { accessReviewCampaignId: campaign.id }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to complete campaign"), + errors as GraphQLError[], + ), + variant: "error", + }); + resolve(); + return; + } + toast({ + title: __("Success"), + description: __("Campaign completed successfully."), + variant: "success", + }); + resolve(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to complete campaign"), + error as GraphQLError, + ), + variant: "error", + }); + resolve(); + }, + }); + }), + { + message: __( + "Are you sure you want to complete this campaign? This action cannot be undone. All decisions will be finalized.", + ), + label: __("Complete"), + variant: "primary", + }, + ); + }; + + return ( +
+ + +
+

{campaign.name}

+ + {statusLabel(__, campaign.status)} + + {isPendingActions && ( + + )} +
+ +
+ {isDraft && ( +
+ + + + {campaign.scopeSources.length > 0 && ( + + )} +
+ )} + + {campaign.scopeSources.map(source => ( + + ))} + + {campaign.scopeSources.length === 0 && ( + +
+

+ {__("No sources configured for this campaign.")} +

+
+
+ )} +
+
+ ); +} + +type ScopeSource = NonNullable< + Extract< + CampaignDetailPageQuery["response"]["node"], + { readonly __typename: "AccessReviewCampaign" } + >["scopeSources"] +>[number]; + +function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; isPendingActions: boolean }) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const [expanded, setExpanded] = useState(false); + const { list: selection, toggle, clear, reset } = useList([]); + const [bulkPendingDecision, setBulkPendingDecision] = useState(null); + const [bulkNote, setBulkNote] = useState(""); + const bulkNoteRef = useDialogRef(); + + const [bulkDecide] + = useMutation(bulkDecisionMutation); + const [bulkFlag] + = useMutation(bulkFlagMutation); + + const entries = source.entries?.edges ?? []; + const entryIds = entries.map(edge => edge.node.id); + + const handleBulkDecision = (value: string) => { + const decision = value as AccessEntryDecision; + if (decision === "APPROVED") { + bulkDecide({ + variables: { + input: { + decisions: selection.map(id => ({ + accessEntryId: id, + decision: "APPROVED" as AccessEntryDecision, + })), + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to record decisions"), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Decisions recorded successfully."), + variant: "success", + }); + clear(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to record decisions"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + } else { + setBulkPendingDecision(decision); + setBulkNote(""); + bulkNoteRef.current?.open(); + } + }; + + const [bulkFlagSelection, setBulkFlagSelection] = useState([]); + const [bulkFlagOpen, setBulkFlagOpen] = useState(false); + const bulkFlagOpenedWithRef = useRef([]); + + const toggleBulkFlag = (flagValue: AccessEntryFlag) => { + setBulkFlagSelection(prev => + prev.includes(flagValue) + ? prev.filter(f => f !== flagValue) + : [...prev, flagValue], + ); + }; + + const handleBulkFlagOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + bulkFlagOpenedWithRef.current = []; + setBulkFlagSelection([]); + } + + if (!nextOpen && bulkFlagSelection.length > 0) { + let errorCount = 0; + let completedCount = 0; + const total = selection.length; + + for (const entryId of selection) { + bulkFlag({ + variables: { + input: { + accessEntryId: entryId, + flags: bulkFlagSelection, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + errorCount++; + } + completedCount++; + if (completedCount === total) { + if (errorCount > 0) { + toast({ + title: __("Error"), + description: sprintf(__("Failed to update flags for %d entries."), errorCount), + variant: "error", + }); + } else { + toast({ + title: __("Success"), + description: __("Flags updated for selected entries."), + variant: "success", + }); + } + clear(); + } + }, + onError() { + errorCount++; + completedCount++; + if (completedCount === total) { + toast({ + title: __("Error"), + description: sprintf(__("Failed to update flags for %d entries."), errorCount), + variant: "error", + }); + clear(); + } + }, + }); + } + } + + setBulkFlagOpen(nextOpen); + }; + + return ( + + + + {expanded && ( +
+ {entries.length === 0 + ? ( +
+ {__("No entries found for this source.")} +
+ ) + : ( +
+ + + + {isPendingActions && ( + + )} + + + + + + + + + + + + {entries.map(edge => ( + + {isPendingActions && ( + + )} + + + + + + + + + + ))} + +
+ 0} + onChange={() => selection.length === entryIds.length ? clear() : reset(entryIds)} + /> + {__("Name")}{__("Email")}{__("Role")}{__("Admin")}{__("MFA")}{__("Last login")}{__("Flag")}{__("Decision")}
+ toggle(edge.node.id)} + /> + + + {edge.node.accountType === "SERVICE_ACCOUNT" && ( + + )} + {edge.node.fullName || } + + {edge.node.email || }{edge.node.role || }{edge.node.isAdmin ? __("Yes") : __("No")} + {edge.node.mfaStatus === "UNKNOWN" + ? + : ( + + {formatStatus(edge.node.mfaStatus)} + + )} + + {edge.node.lastLogin + ? formatDate(edge.node.lastLogin) + : } + + {isPendingActions + ? ( + + ) + : edge.node.flags.length > 0 && ( +
+ {edge.node.flags.map(f => ( + + {flagLabel(f)} + + ))} +
+ )} +
+ {isPendingActions + ? ( + + ) + : edge.node.decision !== "PENDING" && ( + + {decisionLabel(__, edge.node.decision)} + + )} +
+
+ )} + + {selection.length > 0 && ( +
+ + {selection.length} + {" "} + {__("selected")} + + + + + + + + + + {flagGroups.map(group => ( +
+
+ {__(group.label)} +
+ {group.flags.map(flag => ( + + ))} +
+ ))} +
+
+
+
+ )} + + + +

+ {__("Please provide a reason for this decision.")} +

+ +
+ + + +
+ + {source.entries?.pageInfo.hasNextPage && ( +
+

+ {sprintf(__("Showing first %d entries. Use the CLI for the full list."), entries.length)} +

+
+ )} +
+ )} +
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPageLoader.tsx b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPageLoader.tsx new file mode 100644 index 000000000..860b06b1d --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPageLoader.tsx @@ -0,0 +1,43 @@ +// 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 { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; + +import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; + +import CampaignDetailPage, { campaignDetailPageQuery } from "./CampaignDetailPage"; + +export default function CampaignDetailPageLoader() { + const { campaignId } = useParams<{ campaignId: string }>(); + const [queryRef, loadQuery] = useQueryLoader(campaignDetailPageQuery); + + useEffect(() => { + if (campaignId) { + loadQuery({ campaignId }); + } + }, [loadQuery, campaignId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx new file mode 100644 index 000000000..e726dd539 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddAccessSourceDialog.tsx @@ -0,0 +1,689 @@ +// 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, sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Badge, + Breadcrumb, + Button, + Card, + Dialog, + DialogContent, + DialogFooter, + DropdownItem, + Field, + Input, + Option, + Select, + useDialogRef, + useToast, + VendorLogo, +} 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 { AccessReviewLayoutQuery$data } from "#/__generated__/core/AccessReviewLayoutQuery.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 { createAccessSourceMutation } from "./CreateAccessSourceDialog"; + +type OrganizationData = Extract< + AccessReviewLayoutQuery$data["organization"], + { readonly __typename: "Organization" } +>; + +export type ProviderInfo = OrganizationData["connectorProviderInfos"][number]; + +type Props = { + children: ReactNode; + organizationId: string; + connectionId: string; + providerInfos: ReadonlyArray; + existingSourceProviders: ReadonlyArray; +}; + +const createAPIKeyConnectorMutation = graphql` + mutation AddAccessSourceDialogCreateAPIKeyConnectorMutation( + $input: CreateAPIKeyConnectorInput! + ) { + createAPIKeyConnector(input: $input) { + connector { + id + provider + } + } + } +`; + +const createClientCredentialsConnectorMutation = graphql` + mutation AddAccessSourceDialogCreateClientCredentialsConnectorMutation( + $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 "ONE_PASSWORD": + if (settingKey === "scimBridgeUrl") return "onePasswordScimBridgeUrl"; + 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, +): boolean { + return settings + .filter(s => s.required) + .every(s => values[s.key]?.trim()); +} + +export function AddAccessSourceDialog({ + children, + organizationId, + connectionId, + providerInfos, + existingSourceProviders, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + const apiKeyDialogRef = useDialogRef(); + const clientCredentialsDialogRef = useDialogRef(); + + const [searchQuery, setSearchQuery] = useState(""); + const [activeProvider, setActiveProvider] = useState(null); + + const [apiKeyValue, setApiKeyValue] = useState(""); + const [extraSettingValues, setExtraSettingValues] = useState>({}); + 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>({}); + const [isConnectingClientCredentials, setIsConnectingClientCredentials] = useState(false); + + const filteredProviders = useMemo(() => { + const sorted = [...providerInfos].sort((a, b) => + a.displayName.localeCompare(b.displayName), + ); + if (!searchQuery.trim()) return sorted; + const q = searchQuery.toLowerCase(); + return sorted.filter( + info => info.displayName.toLowerCase().includes(q), + ); + }, [providerInfos, searchQuery]); + + const connectedProviders = useMemo( + () => new Set(existingSourceProviders), + [existingSourceProviders], + ); + + const [createAccessSource] + = useMutation( + createAccessSourceMutation, + ); + const [createAPIKeyConnector] + = useMutation( + createAPIKeyConnectorMutation, + ); + const [createClientCredentialsConnector] + = useMutation( + createClientCredentialsConnectorMutation, + ); + + const connectOAuthProvider = (provider: 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", provider); + 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 createSourceAfterConnector = ( + connectorId: string, + displayName: string, + onDone: () => void, + ) => { + createAccessSource({ + 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 as GraphQLError[], + ), + 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 as GraphQLError, + ), + 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 = {}; + 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 = {}; + 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); + + const hasSecondaryOptions = info.oauthConfigured + && (info.apiKeySupported || info.clientCredentialsSupported); + + const renderPrimaryButton = () => { + if (info.oauthConfigured) { + return ( + + ); + } + if (info.apiKeySupported) { + return ( + + ); + } + if (info.clientCredentialsSupported) { + return ( + + ); + } + return null; + }; + + return ( + + +
+

{info.displayName}

+
+ {isConnected + ? ( + + {__("Connected")} + + ) + : ( +
+ {renderPrimaryButton()} + {hasSecondaryOptions && ( + + {info.apiKeySupported && ( + openAPIKeyDialog(info)} + > + {__("Connect with API Key")} + + )} + {info.clientCredentialsSupported && ( + openClientCredentialsDialog(info)} + > + {__("Connect with Client Credentials")} + + )} + + )} +
+ )} +
+ ); + }; + + const apiKeyExtraSettingsValid = activeProvider + ? hasRequiredExtraSettings(activeProvider.extraSettings, extraSettingValues) + : true; + + const clientCredentialsExtraSettingsValid = activeProvider + ? hasRequiredExtraSettings(activeProvider.extraSettings, clientCredentialsExtraValues) + : true; + + return ( + <> + + )} + > + + setSearchQuery(e.target.value)} + /> + +
+ {filteredProviders.map(info => renderProviderCard(info))} + + {(!searchQuery.trim() || "csv".includes(searchQuery.toLowerCase())) && ( + +
+

{__("CSV")}

+

+ {__("Upload CSV data directly as an access source.")} +

+
+ +
+ )} +
+
+ +
+ + +
{ + e.preventDefault(); + connectAPIKeyProvider(); + }} + > + +

+ {sprintf( + __("Enter the API key for %s to connect it as an access source."), + activeProvider?.displayName ?? "", + )} +

+ ) => setApiKeyValue(e.target.value)} + required + autoFocus + /> + {activeProvider?.extraSettings.map(setting => ( + ) => + setExtraSettingValues(prev => ({ + ...prev, + [setting.key]: e.target.value, + }))} + required={setting.required} + /> + ))} +
+ + + +
+
+ + +
{ + e.preventDefault(); + connectClientCredentialsProvider(); + }} + > + +

+ {sprintf( + __("Enter the client credentials for %s to connect it as an access source."), + activeProvider?.displayName ?? "", + )} +

+ ) => setClientId(e.target.value)} + required + autoFocus + /> + ) => setClientSecret(e.target.value)} + required + /> + ) => setTokenUrl(e.target.value)} + required + /> + ) => setScope(e.target.value)} + /> + {activeProvider?.extraSettings.map(setting => + setting.key === "region" + ? ( +
+ + +
+ ) + : ( + ) => + setClientCredentialsExtraValues(prev => ({ + ...prev, + [setting.key]: e.target.value, + }))} + required={setting.required} + /> + ), + )} +
+ + + +
+
+ + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignScopeSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignScopeSourceDialog.tsx new file mode 100644 index 000000000..dc1b0cfdc --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/AddCampaignScopeSourceDialog.tsx @@ -0,0 +1,233 @@ +// 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, + Option, + Select, + useDialogRef, + useToast, +} from "@probo/ui"; +import { type ReactNode, Suspense, useState } from "react"; +import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; + +import type { AddCampaignScopeSourceDialogMutation } from "#/__generated__/core/AddCampaignScopeSourceDialogMutation.graphql"; +import type { AddCampaignScopeSourceDialogSourcesQuery } from "#/__generated__/core/AddCampaignScopeSourceDialogSourcesQuery.graphql"; + +const addScopeMutation = graphql` + mutation AddCampaignScopeSourceDialogMutation( + $input: AddAccessReviewCampaignScopeSourceInput! + ) { + addAccessReviewCampaignScopeSource(input: $input) { + accessReviewCampaign { + id + scopeSources { + id + name + fetchStatus + fetchedAccountsCount + entries(first: 50) { + edges { + node { + id + email + fullName + role + isAdmin + mfaStatus + lastLogin + decision + flags + } + } + pageInfo { + hasNextPage + } + } + } + } + } + } +`; + +const sourcesQuery = graphql` + query AddCampaignScopeSourceDialogSourcesQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + ... on Organization { + accessSources(first: 100) { + edges { + node { + id + name + } + } + } + } + } + } +`; + +type Props = { + children: ReactNode; + organizationId: string; + campaignId: string; + existingScopeSourceIds: string[]; +}; + +export function AddCampaignScopeSourceDialog({ + children, + organizationId, + campaignId, + existingScopeSourceIds, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const ref = useDialogRef(); + const [selectedSourceId, setSelectedSourceId] = useState(""); + + const [addScopeSource, isAdding] + = useMutation(addScopeMutation); + + const onSubmit = () => { + if (!selectedSourceId) return; + + addScopeSource({ + variables: { + input: { + accessReviewCampaignId: campaignId, + accessSourceId: selectedSourceId, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to add source"), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Source added to campaign."), + variant: "success", + }); + setSelectedSourceId(""); + ref.current?.close(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to add source"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + return ( + + } + > + + + } + > + + + + + + + + ); +} + +function SourceSelect({ + organizationId, + existingScopeSourceIds, + value, + onChange, +}: { + organizationId: string; + existingScopeSourceIds: string[]; + value: string; + onChange: (value: string) => void; +}) { + const { __ } = useTranslate(); + const data + = useLazyLoadQuery( + sourcesQuery, + { organizationId }, + { fetchPolicy: "network-only" }, + ); + + const sources + = data?.organization?.accessSources?.edges + ?.map(edge => edge.node) + .filter( + (node): node is NonNullable => + node !== null && !existingScopeSourceIds.includes(node.id), + ) ?? []; + + if (sources.length === 0) { + return ( +

+ {__("All available sources are already added to this campaign.")} +

+ ); + } + + return ( + + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx new file mode 100644 index 000000000..cafb1c362 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessReviewCampaignDialog.tsx @@ -0,0 +1,261 @@ +// 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, + Checkbox, + Dialog, + DialogContent, + DialogFooter, + Field, + useDialogRef, + useToast, +} from "@probo/ui"; +import { type ReactNode, Suspense, useState } from "react"; +import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; +import { z } from "zod"; + +import type { CreateAccessReviewCampaignDialogMutation } from "#/__generated__/core/CreateAccessReviewCampaignDialogMutation.graphql"; +import type { CreateAccessReviewCampaignDialogSourcesQuery } from "#/__generated__/core/CreateAccessReviewCampaignDialogSourcesQuery.graphql"; +import { useFormWithSchema } from "#/hooks/useFormWithSchema"; + +const createCampaignMutation = graphql` + mutation CreateAccessReviewCampaignDialogMutation( + $input: CreateAccessReviewCampaignInput! + $connections: [ID!]! + ) { + createAccessReviewCampaign(input: $input) { + accessReviewCampaignEdge @prependEdge(connections: $connections) { + node { + id + name + status + createdAt + } + } + } + } +`; + +const sourcesQuery = graphql` + query CreateAccessReviewCampaignDialogSourcesQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + ... on Organization { + accessSources(first: 500) { + edges { + node { + id + name + } + } + } + } + } + } +`; + +const schema = z.object({ + name: z.string().min(1), + description: z.string().optional(), +}); + +type Props = { + children: ReactNode; + organizationId: string; + connectionId: string; +}; + +export function CreateAccessReviewCampaignDialog({ + children, + organizationId, + connectionId, +}: Props) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const ref = useDialogRef(); + const [selectedSourceIds, setSelectedSourceIds] = useState([]); + const { register, handleSubmit, reset, formState } = useFormWithSchema( + schema, + { + defaultValues: { + name: "", + description: "", + }, + }, + ); + + const [createCampaign, isCreating] + = useMutation( + createCampaignMutation, + ); + + const toggleSource = (sourceId: string) => { + setSelectedSourceIds(prev => + prev.includes(sourceId) + ? prev.filter(id => id !== sourceId) + : [...prev, sourceId], + ); + }; + + const onSubmit = (data: z.infer) => { + createCampaign({ + variables: { + input: { + organizationId, + name: data.name, + description: data.description || null, + accessSourceIds: + selectedSourceIds.length > 0 ? selectedSourceIds : null, + }, + connections: [connectionId], + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to create campaign"), + errors as GraphQLError[], + ), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Campaign created successfully."), + variant: "success", + }); + reset(); + setSelectedSourceIds([]); + ref.current?.close(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to create campaign"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }; + + const handleClose = () => { + reset(); + setSelectedSourceIds([]); + }; + + return ( + + )} + > +
void handleSubmit(onSubmit)(e)}> + + + + + {__("Loading sources...")} + + )} + > + + + + + + +
+
+ ); +} + +function SourceSelector({ + organizationId, + selectedSourceIds, + onToggle, +}: { + organizationId: string; + selectedSourceIds: string[]; + onToggle: (sourceId: string) => void; +}) { + const { __ } = useTranslate(); + const data = useLazyLoadQuery( + sourcesQuery, + { organizationId }, + { fetchPolicy: "network-only" }, + ); + + const sources + = data?.organization?.accessSources?.edges + ?.map(edge => edge.node) + .filter((node): node is NonNullable => node !== null) ?? []; + + if (sources.length === 0) { + return ( +
+ {__("No sources available. Add sources in the Sources tab first.")} +
+ ); + } + + return ( +
+ {__("Sources")} +
+ {sources.map(source => ( + + ))} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx new file mode 100644 index 000000000..dfcb82fa3 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/CreateAccessSourceDialog.tsx @@ -0,0 +1,361 @@ +// 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/sources/AccessReviewSourcesTab.tsx b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx new file mode 100644 index 000000000..b29368f52 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/sources/AccessReviewSourcesTab.tsx @@ -0,0 +1,280 @@ +// 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 { + Button, + Card, + IconPlusLarge, + Table, + Tbody, + Th, + Thead, + Tr, + useToast, +} from "@probo/ui"; +import { useEffect, useMemo, useRef } from "react"; +import { graphql, useMutation, usePaginationFragment } from "react-relay"; +import { useOutletContext, useSearchParams } from "react-router"; + +import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/AccessReviewSourcesTabFragment.graphql"; +import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql"; +import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { AccessSourceRow } from "../_components/AccessSourceRow"; +import { AddAccessSourceDialog, type ProviderInfo } from "../dialogs/AddAccessSourceDialog"; +import { createAccessSourceMutation } from "../dialogs/CreateAccessSourceDialog"; + +const sourcesFragment = graphql` + fragment AccessReviewSourcesTabFragment on Organization + @refetchable(queryName: "AccessReviewSourcesTabPaginationQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { + type: "AccessSourceOrder" + defaultValue: { direction: DESC, field: CREATED_AT } + } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + accessSources( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + ) @connection(key: "AccessReviewSourcesTab_accessSources") { + __id + edges { + node { + id + name + connectorId + connector { + provider + } + ...AccessSourceRowFragment + } + } + } + } +`; + +export default function AccessReviewSourcesTab() { + const { __ } = useTranslate(); + const { toast } = useToast(); + const organizationId = useOrganizationId(); + const [searchParams, setSearchParams] = useSearchParams(); + const processedConnectorIdRef = useRef(null); + const { organizationRef, canCreateSource, connectorProviderInfos } = useOutletContext<{ + organizationRef: AccessReviewSourcesTabFragment$key; + canCreateSource: boolean; + connectorProviderInfos: ReadonlyArray; + }>(); + + const { + data: { accessSources }, + loadNext, + hasNext, + isLoadingNext, + } = usePaginationFragment< + AccessReviewSourcesTabPaginationQuery, + AccessReviewSourcesTabFragment$key + >(sourcesFragment, organizationRef); + + const existingSourceProviders = useMemo( + () => + accessSources.edges + .map(edge => edge.node.connector?.provider) + .filter((p): p is NonNullable => p != null), + [accessSources.edges], + ); + + const [createAccessSource, isCreatingSource] + = useMutation( + createAccessSourceMutation, + ); + + // Handle OAuth callback: after the provider redirects back with connector_id, + // automatically create the access source for that connector. + const callbackConnectorId = searchParams.get("connector_id"); + const callbackProvider = searchParams.get("provider"); + const hasSourceForCallback = !!callbackConnectorId + && accessSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId); + + useEffect(() => { + if (!callbackConnectorId) return; + + if (hasSourceForCallback) { + setSearchParams((params) => { + params.delete("connector_id"); + params.delete("provider"); + return params; + }, { replace: true }); + return; + } + + if (processedConnectorIdRef.current === callbackConnectorId || isCreatingSource) { + return; + } + processedConnectorIdRef.current = callbackConnectorId; + + const providerInfo = callbackProvider + ? connectorProviderInfos.find(p => p.provider === callbackProvider) + : null; + const sourceName = providerInfo?.displayName ?? callbackProvider ?? "Source"; + + createAccessSource({ + variables: { + input: { + organizationId, + connectorId: callbackConnectorId, + name: sourceName, + csvData: null, + }, + connections: [accessSources.__id], + }, + onCompleted(_, errors) { + if (errors?.length) { + processedConnectorIdRef.current = null; + setSearchParams((params) => { + params.delete("connector_id"); + params.delete("provider"); + return params; + }, { replace: true }); + 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", + }); + setSearchParams((params) => { + params.delete("connector_id"); + params.delete("provider"); + return params; + }, { replace: true }); + }, + onError(error) { + processedConnectorIdRef.current = null; + setSearchParams((params) => { + params.delete("connector_id"); + params.delete("provider"); + return params; + }, { replace: true }); + toast({ + title: __("Error"), + description: formatError( + __("Failed to create access source"), + error as GraphQLError, + ), + variant: "error", + }); + }, + }); + }, [ + __, + callbackConnectorId, + callbackProvider, + connectorProviderInfos, + createAccessSource, + hasSourceForCallback, + isCreatingSource, + organizationId, + accessSources.__id, + setSearchParams, + toast, + ]); + + return ( +
+
+ {canCreateSource && ( + + + + )} +
+ + {accessSources && accessSources.edges.length > 0 + ? ( + + + + + + + + + + + + + + {accessSources.edges.map(edge => ( + + ))} + +
{__("Name")}{__("Source")}{__("Status")}{__("Organization")}{__("Created at")}
+ + {hasNext && ( +
+ +
+ )} +
+ ) + : ( + +
+

+ {__("No access sources configured yet. Add your first source to start reviewing access.")} +

+
+
+ )} +
+ ); +} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 32e56eb8f..eae747e4d 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -31,6 +31,7 @@ import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading import { peopleRoutes } from "./pages/iam/organizations/people/routes"; import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes"; import { CurrentUser } from "./providers/CurrentUser"; +import { accessReviewRoutes } from "./routes/accessReviewRoutes"; import { assetRoutes } from "./routes/assetRoutes"; import { auditRoutes } from "./routes/auditRoutes"; import { contextRoutes } from "./routes/contextRoutes"; @@ -288,6 +289,7 @@ const routes = [ ...rightsRequestRoutes, ...processingActivityRoutes, ...statesOfApplicabilityRoutes, + ...accessReviewRoutes, ...compliancePageRoutes, ...snapshotsRoutes, { diff --git a/apps/console/src/routes/accessReviewRoutes.ts b/apps/console/src/routes/accessReviewRoutes.ts new file mode 100644 index 000000000..8afd15c36 --- /dev/null +++ b/apps/console/src/routes/accessReviewRoutes.ts @@ -0,0 +1,44 @@ +import { lazy } from "@probo/react-lazy"; +import type { AppRoute } from "@probo/routes"; + +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; + +export const accessReviewRoutes = [ + { + path: "access-reviews", + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/access-reviews/AccessReviewLayoutLoader"), + ), + children: [ + { + index: true, + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab"), + ), + }, + { + path: "sources", + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/access-reviews/sources/AccessReviewSourcesTab"), + ), + }, + ], + }, + { + path: "access-reviews/campaigns/:campaignId", + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/access-reviews/campaigns/CampaignDetailPageLoader"), + ), + }, + { + path: "access-reviews/sources/new/csv", + Fallback: PageSkeleton, + Component: lazy( + () => import("#/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader"), + ), + }, +] satisfies AppRoute[]; diff --git a/packages/ui/src/Atoms/Icons/IconRobot.tsx b/packages/ui/src/Atoms/Icons/IconRobot.tsx new file mode 100644 index 000000000..557c3438c --- /dev/null +++ b/packages/ui/src/Atoms/Icons/IconRobot.tsx @@ -0,0 +1,29 @@ +// 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 type { IconProps } from "./type"; + +export function IconRobot({ size = 24, className }: IconProps) { + return ( + + + + + + + + + + ); +} diff --git a/packages/ui/src/Atoms/Icons/index.tsx b/packages/ui/src/Atoms/Icons/index.tsx index dae74781f..3f9124b5f 100644 --- a/packages/ui/src/Atoms/Icons/index.tsx +++ b/packages/ui/src/Atoms/Icons/index.tsx @@ -96,6 +96,7 @@ export { IconChevronUp } from "./IconChevronUp"; export { IconBlock } from "./IconBlock"; export { IconChevronDown } from "./IconChevronDown"; export { IconPageCross } from "./IconPageCross"; +export { IconRobot } from "./IconRobot"; export { IconRotateCw } from "./IconRotateCw"; export { IconPin } from "./IconPin"; export { IconMinusLarge } from "./IconMinusLarge"; diff --git a/packages/ui/src/Atoms/Select/Select.tsx b/packages/ui/src/Atoms/Select/Select.tsx index b19e97341..d79ec6f93 100644 --- a/packages/ui/src/Atoms/Select/Select.tsx +++ b/packages/ui/src/Atoms/Select/Select.tsx @@ -15,9 +15,11 @@ import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Content, + Group, Icon, Item, ItemText, + Label, Portal, Root, Trigger, @@ -224,3 +226,18 @@ export function Option({ children, ...props }: ComponentProps) { ); } + +export function SelectGroup({ children, ...props }: ComponentProps) { + return {children}; +} + +export function SelectLabel({ children, ...props }: ComponentProps) { + return ( + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Brex.tsx b/packages/ui/src/Atoms/Vendors/Brex.tsx new file mode 100644 index 000000000..f0e1655d7 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Brex.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Brex(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Cloudflare.tsx b/packages/ui/src/Atoms/Vendors/Cloudflare.tsx new file mode 100644 index 000000000..b486436fe --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Cloudflare.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Cloudflare(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/DocuSign.tsx b/packages/ui/src/Atoms/Vendors/DocuSign.tsx new file mode 100644 index 000000000..56728aaa3 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/DocuSign.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function DocuSign(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Figma.tsx b/packages/ui/src/Atoms/Vendors/Figma.tsx new file mode 100644 index 000000000..736076089 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Figma.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Figma(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/GitHub.tsx b/packages/ui/src/Atoms/Vendors/GitHub.tsx new file mode 100644 index 000000000..141ee99b9 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/GitHub.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function GitHub(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/HubSpot.tsx b/packages/ui/src/Atoms/Vendors/HubSpot.tsx new file mode 100644 index 000000000..3e45221d3 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/HubSpot.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function HubSpot(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Intercom.tsx b/packages/ui/src/Atoms/Vendors/Intercom.tsx new file mode 100644 index 000000000..dcaccae73 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Intercom.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Intercom(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Linear.tsx b/packages/ui/src/Atoms/Vendors/Linear.tsx new file mode 100644 index 000000000..734f973a6 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Linear.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Linear(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Notion.tsx b/packages/ui/src/Atoms/Vendors/Notion.tsx new file mode 100644 index 000000000..14f9f920a --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Notion.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Notion(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/OnePassword.tsx b/packages/ui/src/Atoms/Vendors/OnePassword.tsx new file mode 100644 index 000000000..caa1c0fc6 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/OnePassword.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function OnePassword(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/OpenAI.tsx b/packages/ui/src/Atoms/Vendors/OpenAI.tsx new file mode 100644 index 000000000..a4c46dbdb --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/OpenAI.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function OpenAI(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Resend.tsx b/packages/ui/src/Atoms/Vendors/Resend.tsx new file mode 100644 index 000000000..9dc7dc0c1 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Resend.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Resend(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Sentry.tsx b/packages/ui/src/Atoms/Vendors/Sentry.tsx new file mode 100644 index 000000000..0384e0f30 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Sentry.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Sentry(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Supabase.tsx b/packages/ui/src/Atoms/Vendors/Supabase.tsx new file mode 100644 index 000000000..1db13b89d --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Supabase.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Supabase(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/Tally.tsx b/packages/ui/src/Atoms/Vendors/Tally.tsx new file mode 100644 index 000000000..99241fd33 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Tally.tsx @@ -0,0 +1,16 @@ +import type { ComponentProps } from "react"; + +export function Tally(props: ComponentProps<"svg">) { + return ( + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/VendorLogo.tsx b/packages/ui/src/Atoms/Vendors/VendorLogo.tsx new file mode 100644 index 000000000..ceeeb3852 --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/VendorLogo.tsx @@ -0,0 +1,80 @@ +// 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 type { ComponentProps, FC } from "react"; + +import { Brex } from "./Brex"; +import { Cloudflare } from "./Cloudflare"; +import { DocuSign } from "./DocuSign"; +import { Figma } from "./Figma"; +import { GitHub } from "./GitHub"; +import { Google } from "./Google"; +import { HubSpot } from "./HubSpot"; +import { Intercom } from "./Intercom"; +import { Linear } from "./Linear"; +import { Microsoft } from "./Microsoft"; +import { Notion } from "./Notion"; +import { OnePassword } from "./OnePassword"; +import { OpenAI } from "./OpenAI"; +import { Resend } from "./Resend"; +import { Sentry } from "./Sentry"; +import { Slack } from "./Slack"; +import { Supabase } from "./Supabase"; +import { Tally } from "./Tally"; + +const vendors: Record>> = { + BREX: Brex, + CLOUDFLARE: Cloudflare, + DOCUSIGN: DocuSign, + FIGMA: Figma, + GITHUB: GitHub, + GOOGLE: Google, + GOOGLE_WORKSPACE: Google, + HUBSPOT: HubSpot, + INTERCOM: Intercom, + LINEAR: Linear, + MICROSOFT: Microsoft, + NOTION: Notion, + ONE_PASSWORD: OnePassword, + ONEPASSWORD: OnePassword, + OPENAI: OpenAI, + RESEND: Resend, + SENTRY: Sentry, + SLACK: Slack, + SUPABASE: Supabase, + TALLY: Tally, +}; + +type VendorLogoProps = ComponentProps<"svg"> & { + /** The vendor/brand name (case-insensitive, supports enum values like GOOGLE_WORKSPACE). */ + vendor: string; + /** When true, renders the SVG in monochrome, adapting to the current theme. */ + tint?: boolean; +}; + +export function VendorLogo({ vendor, tint, ...props }: VendorLogoProps) { + const Component = vendors[vendor.toUpperCase()]; + if (!Component) return null; + + if (tint) { + return ( + + ); + } + + return ; +} diff --git a/packages/ui/src/Atoms/Vendors/index.ts b/packages/ui/src/Atoms/Vendors/index.ts index 5b9a21224..bad051ada 100644 --- a/packages/ui/src/Atoms/Vendors/index.ts +++ b/packages/ui/src/Atoms/Vendors/index.ts @@ -1,3 +1,19 @@ +export { Brex } from "./Brex"; +export { Cloudflare } from "./Cloudflare"; +export { DocuSign } from "./DocuSign"; +export { Figma } from "./Figma"; +export { GitHub } from "./GitHub"; export { Google } from "./Google"; +export { HubSpot } from "./HubSpot"; +export { Intercom } from "./Intercom"; +export { Linear } from "./Linear"; export { Microsoft } from "./Microsoft"; +export { Notion } from "./Notion"; +export { OnePassword } from "./OnePassword"; +export { OpenAI } from "./OpenAI"; +export { Resend } from "./Resend"; +export { Sentry } from "./Sentry"; export { Slack } from "./Slack"; +export { Supabase } from "./Supabase"; +export { Tally } from "./Tally"; +export { VendorLogo } from "./VendorLogo"; diff --git a/packages/ui/src/Molecules/Dialog/ConfirmDialog.tsx b/packages/ui/src/Molecules/Dialog/ConfirmDialog.tsx index 0eccd9456..84a56a65b 100644 --- a/packages/ui/src/Molecules/Dialog/ConfirmDialog.tsx +++ b/packages/ui/src/Molecules/Dialog/ConfirmDialog.tsx @@ -35,7 +35,7 @@ type State = { message: string | null; variant?: ComponentProps["variant"]; label?: string; - onConfirm: () => Promise; + onConfirm: () => void | Promise; }; const useConfirmStore = create( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 54aeac4f7..8ee4b4205 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -40,7 +40,7 @@ export { Avatar } from "./Atoms/Avatar/Avatar"; export { Field } from "./Molecules/Field/Field"; export { Input } from "./Atoms/Input/Input"; export { Textarea } from "./Atoms/Textarea/Textarea"; -export { Option, Select } from "./Atoms/Select/Select"; +export { Option, Select, SelectGroup, SelectLabel } from "./Atoms/Select/Select"; export { Label } from "./Atoms/Label/Label"; export { PropertyRow } from "./Atoms/PropertyRow/PropertyRow"; export { Table, Tbody, Td, Th, Thead, Tr, TrButton } from "./Atoms/Table/Table";