Add access review frontend
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>
This commit is contained in:
@@ -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 && (
|
||||
<SidebarItem
|
||||
label={__("Access Reviews")}
|
||||
icon={IconKey}
|
||||
to={`${prefix}/access-reviews`}
|
||||
/>
|
||||
)}
|
||||
{organization.canGetTrustCenter && (
|
||||
<SidebarItem
|
||||
label={__("Compliance Page")}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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<AccessReviewLayoutQuery>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
usePageTitle(__("Access Reviews"));
|
||||
|
||||
const { organization } = usePreloadedQuery(accessReviewLayoutQuery, queryRef);
|
||||
if (organization.__typename !== "Organization") {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Access Reviews")}
|
||||
description={__(
|
||||
"Review and manage user access across your organization's systems and applications.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Tabs>
|
||||
<TabLink to={`/organizations/${organizationId}/access-reviews`} end>
|
||||
<IconKey className="size-4" />
|
||||
{__("Campaigns")}
|
||||
</TabLink>
|
||||
<TabLink to={`/organizations/${organizationId}/access-reviews/sources`}>
|
||||
<IconFolder2 className="size-4" />
|
||||
{__("Sources")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet context={{
|
||||
organizationRef: organization,
|
||||
canCreateSource: organization.canCreateSource,
|
||||
canCreateCampaign: organization.canCreateCampaign,
|
||||
connectorProviderInfos: organization.connectorProviderInfos,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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>(accessReviewLayoutQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery({ organizationId });
|
||||
}
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) return <PageSkeleton />;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<AccessReviewLayout queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { 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<CreateCsvAccessSourcePageQuery>;
|
||||
}) {
|
||||
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<CreateAccessSourceDialogMutation>(
|
||||
createAccessSourceMutation,
|
||||
);
|
||||
|
||||
if (!organization.canCreateSource) {
|
||||
return (
|
||||
<Card padded>
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("You do not have permission to create access sources.")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const onSubmit = (data: z.infer<typeof csvSchema>) => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Add CSV access source")}
|
||||
description={__(
|
||||
"Paste CSV content with a header row. This source will be saved and available in Access Reviews.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Card padded>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)} className="space-y-4">
|
||||
<Field
|
||||
label={__("Name")}
|
||||
{...register("name")}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={__("CSV Data")}
|
||||
{...register("csvData")}
|
||||
type="textarea"
|
||||
placeholder="email,full_name,role,job_title,is_admin,active,external_id"
|
||||
required
|
||||
/>
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("Supported columns: email, full_name, role, job_title, is_admin, active, external_id.")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to={`/organizations/${organizationId}/access-reviews/sources`}>
|
||||
{__("Back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button disabled={isCreating} type="submit">
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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>(createCsvAccessSourcePageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ organizationId });
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<CreateCsvAccessSourcePage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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<AccessSourceRowDeleteMutation>(deleteAccessSourceMutation);
|
||||
const [configure] = useMutation<AccessSourceRowConfigureMutation>(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 (
|
||||
<Tr>
|
||||
<Td>{accessSource.name}</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral" size="sm">
|
||||
{sourceLabel(accessSource.connector?.provider ?? null)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{accessSource.connectionStatus === "CONNECTED" && (
|
||||
<Badge variant="success" size="sm">{__("Connected")}</Badge>
|
||||
)}
|
||||
{accessSource.connectionStatus === "DISCONNECTED" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="danger" size="sm">{__("Disconnected")}</Badge>
|
||||
<Button variant="secondary" onClick={handleReconnect}>
|
||||
{__("Reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{showOrgSelector && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Select variant="editor" disabled placeholder={__("Loading...")} />
|
||||
}
|
||||
>
|
||||
<InlineOrgSelect
|
||||
accessSourceId={accessSource.id}
|
||||
selectedOrganization={accessSource.selectedOrganization ?? ""}
|
||||
onSelect={handleOrgChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<time dateTime={accessSource.createdAt}>
|
||||
{formatDate(accessSource.createdAt)}
|
||||
</time>
|
||||
</Td>
|
||||
{accessSource.canDelete && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineOrgSelect({
|
||||
accessSourceId,
|
||||
selectedOrganization,
|
||||
onSelect,
|
||||
}: {
|
||||
accessSourceId: string;
|
||||
selectedOrganization: string;
|
||||
onSelect: (slug: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data = useLazyLoadQuery<AccessSourceRowOrgsQuery>(
|
||||
orgsQuery,
|
||||
{ accessSourceId },
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
const orgs = data.node.providerOrganizations ?? [];
|
||||
|
||||
if (orgs.length === 0) {
|
||||
return (
|
||||
<ManualOrgInput
|
||||
selectedOrganization={selectedOrganization}
|
||||
onSubmit={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
variant="editor"
|
||||
placeholder={__("Select organization")}
|
||||
value={selectedOrganization}
|
||||
onValueChange={onSelect}
|
||||
>
|
||||
{orgs.map(org => (
|
||||
<Option key={org.slug} value={org.slug}>
|
||||
{org.displayName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Input
|
||||
placeholder={__("org-slug")}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="max-w-40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
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<AccessEntryDecision | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [recordDecision, isRecording]
|
||||
= useMutation<EntryDecisionActionsMutation>(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 (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={decisionBadgeVariant(decision)}>
|
||||
{decisionLabel(__, decision)}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
className="text-txt-tertiary hover:text-txt-primary cursor-pointer"
|
||||
onClick={() => setEditing(true)}
|
||||
title={__("Change decision")}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
variant="editor"
|
||||
placeholder={__("Decide...")}
|
||||
onValueChange={handleDecision}
|
||||
disabled={isRecording}
|
||||
>
|
||||
<Option value="APPROVED">{__("Approve")}</Option>
|
||||
<Option value="REVOKE">{__("Revoke")}</Option>
|
||||
<Option value="DEFER">{__("Modify")}</Option>
|
||||
<Option value="ESCALATE">{__("Escalate")}</Option>
|
||||
</Select>
|
||||
|
||||
<Dialog ref={ref} title={__("Decision note")}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Please provide a reason for this decision.")}
|
||||
</p>
|
||||
<Field
|
||||
label={__("Note")}
|
||||
type="textarea"
|
||||
value={note}
|
||||
onValueChange={setNote}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={isRecording || !note.trim()}
|
||||
onClick={() => {
|
||||
if (pendingDecision) {
|
||||
submitDecision(pendingDecision, note);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{__("Confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { 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<AccessEntryFlag[]>([...currentFlags]);
|
||||
const openedWithRef = useRef<readonly AccessEntryFlag[]>(currentFlags);
|
||||
const [flagEntry] = useMutation<EntryFlagSelectMutation>(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 (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-sm cursor-pointer"
|
||||
>
|
||||
{displayFlags.length === 0
|
||||
? (
|
||||
<span className="text-txt-tertiary">--</span>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayFlags.map(f => (
|
||||
<Badge key={f} variant={flagBadgeVariant(f)} size="sm">
|
||||
{flagLabel(f)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={5}
|
||||
className="z-100 w-64 rounded-[10px] bg-level-1 p-2 shadow-mid animate-in fade-in slide-in-from-top-2"
|
||||
>
|
||||
{flagGroups.map(group => (
|
||||
<div key={group.label} className="mb-2 last:mb-0">
|
||||
<div className="px-2 py-1 text-xs font-semibold text-txt-tertiary uppercase tracking-wider">
|
||||
{__(group.label)}
|
||||
</div>
|
||||
{group.flags.map(flag => (
|
||||
<label
|
||||
key={flag.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-tertiary-hover"
|
||||
>
|
||||
<Checkbox
|
||||
checked={localFlags.includes(flag.value)}
|
||||
onChange={() => toggleFlag(flag.value)}
|
||||
/>
|
||||
<span className="text-sm text-txt-primary">{__(flag.label)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
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 (
|
||||
<span className="text-xs text-txt-tertiary">N/A</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
{canCreateCampaign && (
|
||||
<CreateAccessReviewCampaignDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={accessReviewCampaigns.__id}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("New campaign")}
|
||||
</Button>
|
||||
</CreateAccessReviewCampaignDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accessReviewCampaigns.edges.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Status")}</Th>
|
||||
<Th>{__("Created at")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{accessReviewCampaigns.edges.map(edge => (
|
||||
<Tr
|
||||
key={edge.node.id}
|
||||
to={`/organizations/${organizationId}/access-reviews/campaigns/${edge.node.id}`}
|
||||
>
|
||||
<Td>{edge.node.name}</Td>
|
||||
<Td>
|
||||
<Badge variant={statusBadgeVariant(edge.node.status)}>
|
||||
{statusLabel(__, edge.node.status)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{dateFormat(edge.node.createdAt)}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
{hasNext && (
|
||||
<div className="p-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => loadNext(20)}
|
||||
disabled={isLoadingNext}
|
||||
>
|
||||
{isLoadingNext
|
||||
? __("Loading...")
|
||||
: __("Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-txt-tertiary">
|
||||
{__("No access review campaigns yet. Create your first campaign to start reviewing access.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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<CampaignDetailPageQuery>;
|
||||
};
|
||||
|
||||
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<CampaignDetailPageQuery>(
|
||||
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<CampaignDetailPageStartMutation>(startCampaignMutation);
|
||||
|
||||
const [closeCampaign, isClosing]
|
||||
= useMutation<CampaignDetailPageCloseMutation>(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<void>((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 (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
label: __("Access Reviews"),
|
||||
to: `/organizations/${organizationId}/access-reviews`,
|
||||
},
|
||||
{ label: campaign.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold">{campaign.name}</h1>
|
||||
<Badge variant={statusBadgeVariant(campaign.status)}>
|
||||
{statusLabel(__, campaign.status)}
|
||||
</Badge>
|
||||
{isPendingActions && (
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!allDecided || isClosing}
|
||||
>
|
||||
{isClosing ? __("Completing...") : __("Complete campaign")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isDraft && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<AddCampaignScopeSourceDialog
|
||||
organizationId={organizationId}
|
||||
campaignId={campaign.id}
|
||||
existingScopeSourceIds={existingScopeSourceIds}
|
||||
>
|
||||
<Button icon={IconPlusLarge} variant="secondary">
|
||||
{__("Add source")}
|
||||
</Button>
|
||||
</AddCampaignScopeSourceDialog>
|
||||
{campaign.scopeSources.length > 0 && (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
>
|
||||
{isStarting ? __("Starting...") : __("Start campaign")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{campaign.scopeSources.map(source => (
|
||||
<ScopeSourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
isPendingActions={isPendingActions}
|
||||
/>
|
||||
))}
|
||||
|
||||
{campaign.scopeSources.length === 0 && (
|
||||
<Card padded>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-txt-tertiary">
|
||||
{__("No sources configured for this campaign.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>([]);
|
||||
const [bulkPendingDecision, setBulkPendingDecision] = useState<AccessEntryDecision | null>(null);
|
||||
const [bulkNote, setBulkNote] = useState("");
|
||||
const bulkNoteRef = useDialogRef();
|
||||
|
||||
const [bulkDecide]
|
||||
= useMutation<CampaignDetailPageBulkDecisionMutation>(bulkDecisionMutation);
|
||||
const [bulkFlag]
|
||||
= useMutation<CampaignDetailPageBulkFlagMutation>(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<AccessEntryFlag[]>([]);
|
||||
const [bulkFlagOpen, setBulkFlagOpen] = useState(false);
|
||||
const bulkFlagOpenedWithRef = useRef<AccessEntryFlag[]>([]);
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-4 text-left hover:bg-bg-subtle transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expanded
|
||||
? <IconChevronDown className="size-4 text-txt-tertiary" />
|
||||
: <IconChevronRight className="size-4 text-txt-tertiary" />}
|
||||
<span className="font-medium">{source.name}</span>
|
||||
<Badge variant="neutral">
|
||||
{source.fetchedAccountsCount}
|
||||
{" "}
|
||||
{__("accounts")}
|
||||
</Badge>
|
||||
<Badge variant={source.fetchStatus === "SUCCESS" ? "success" : "info"}>
|
||||
{formatStatus(source.fetchStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t">
|
||||
{entries.length === 0
|
||||
? (
|
||||
<div className="px-4 py-6 text-center text-txt-tertiary">
|
||||
{__("No entries found for this source.")}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className="w-full text-left">
|
||||
<Thead>
|
||||
<Tr>
|
||||
{isPendingActions && (
|
||||
<Th className="w-12">
|
||||
<Checkbox
|
||||
checked={selection.length === entryIds.length && entryIds.length > 0}
|
||||
onChange={() => selection.length === entryIds.length ? clear() : reset(entryIds)}
|
||||
/>
|
||||
</Th>
|
||||
)}
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Email")}</Th>
|
||||
<Th>{__("Role")}</Th>
|
||||
<Th>{__("Admin")}</Th>
|
||||
<Th>{__("MFA")}</Th>
|
||||
<Th>{__("Last login")}</Th>
|
||||
<Th>{__("Flag")}</Th>
|
||||
<Th>{__("Decision")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{entries.map(edge => (
|
||||
<Tr key={edge.node.id}>
|
||||
{isPendingActions && (
|
||||
<Td noLink>
|
||||
<Checkbox
|
||||
checked={selection.includes(edge.node.id)}
|
||||
onChange={() => toggle(edge.node.id)}
|
||||
/>
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{edge.node.accountType === "SERVICE_ACCOUNT" && (
|
||||
<IconRobot size={16} className="text-txt-tertiary shrink-0" />
|
||||
)}
|
||||
{edge.node.fullName || <NotAvailable />}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>{edge.node.email || <NotAvailable />}</Td>
|
||||
<Td>{edge.node.role || <NotAvailable />}</Td>
|
||||
<Td>{edge.node.isAdmin ? __("Yes") : __("No")}</Td>
|
||||
<Td>
|
||||
{edge.node.mfaStatus === "UNKNOWN"
|
||||
? <NotAvailable />
|
||||
: (
|
||||
<Badge variant={edge.node.mfaStatus === "ENABLED" ? "success" : "neutral"}>
|
||||
{formatStatus(edge.node.mfaStatus)}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{edge.node.lastLogin
|
||||
? formatDate(edge.node.lastLogin)
|
||||
: <NotAvailable />}
|
||||
</Td>
|
||||
<Td>
|
||||
{isPendingActions
|
||||
? (
|
||||
<EntryFlagSelect
|
||||
entryId={edge.node.id}
|
||||
currentFlags={edge.node.flags}
|
||||
/>
|
||||
)
|
||||
: edge.node.flags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{edge.node.flags.map(f => (
|
||||
<Badge key={f} variant={flagBadgeVariant(f)}>
|
||||
{flagLabel(f)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{isPendingActions
|
||||
? (
|
||||
<EntryDecisionActions
|
||||
entryId={edge.node.id}
|
||||
decision={edge.node.decision}
|
||||
/>
|
||||
)
|
||||
: edge.node.decision !== "PENDING" && (
|
||||
<Badge variant={decisionBadgeVariant(edge.node.decision)}>
|
||||
{decisionLabel(__, edge.node.decision)}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selection.length > 0 && (
|
||||
<div className="flex items-center gap-4 p-4 border-t">
|
||||
<span className="text-sm text-txt-secondary">
|
||||
{selection.length}
|
||||
{" "}
|
||||
{__("selected")}
|
||||
</span>
|
||||
<Button variant="secondary" onClick={clear}>
|
||||
{__("Clear")}
|
||||
</Button>
|
||||
<Select
|
||||
variant="editor"
|
||||
placeholder={__("Set decision...")}
|
||||
onValueChange={handleBulkDecision}
|
||||
>
|
||||
<Option value="APPROVED">{__("Approve")}</Option>
|
||||
<Option value="REVOKE">{__("Revoke")}</Option>
|
||||
<Option value="DEFER">{__("Modify")}</Option>
|
||||
<Option value="ESCALATE">{__("Escalate")}</Option>
|
||||
</Select>
|
||||
<Popover.Root open={bulkFlagOpen} onOpenChange={handleBulkFlagOpenChange}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button variant="secondary">
|
||||
{bulkFlagSelection.length > 0
|
||||
? `${bulkFlagSelection.length} ${__("flags")}`
|
||||
: __("Set flags...")}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={5}
|
||||
className="z-100 w-64 rounded-[10px] bg-level-1 p-2 shadow-mid animate-in fade-in slide-in-from-top-2"
|
||||
>
|
||||
{flagGroups.map(group => (
|
||||
<div key={group.label} className="mb-2 last:mb-0">
|
||||
<div className="px-2 py-1 text-xs font-semibold text-txt-tertiary uppercase tracking-wider">
|
||||
{__(group.label)}
|
||||
</div>
|
||||
{group.flags.map(flag => (
|
||||
<label
|
||||
key={flag.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-tertiary-hover"
|
||||
>
|
||||
<Checkbox
|
||||
checked={bulkFlagSelection.includes(flag.value)}
|
||||
onChange={() => toggleBulkFlag(flag.value)}
|
||||
/>
|
||||
<span className="text-sm text-txt-primary">{__(flag.label)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog ref={bulkNoteRef} title={__("Decision note")}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Please provide a reason for this decision.")}
|
||||
</p>
|
||||
<Field
|
||||
label={__("Note")}
|
||||
type="textarea"
|
||||
value={bulkNote}
|
||||
onValueChange={setBulkNote}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={!bulkNote.trim()}
|
||||
onClick={() => {
|
||||
if (bulkPendingDecision) {
|
||||
bulkDecide({
|
||||
variables: {
|
||||
input: {
|
||||
decisions: selection.map(id => ({
|
||||
accessEntryId: id,
|
||||
decision: bulkPendingDecision,
|
||||
decisionNote: bulkNote,
|
||||
})),
|
||||
},
|
||||
},
|
||||
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();
|
||||
setBulkPendingDecision(null);
|
||||
setBulkNote("");
|
||||
bulkNoteRef.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to record decisions"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{__("Confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
|
||||
{source.entries?.pageInfo.hasNextPage && (
|
||||
<div className="p-4 border-t text-center">
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{sprintf(__("Showing first %d entries. Use the CLI for the full list."), entries.length)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { 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>(campaignDetailPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (campaignId) {
|
||||
loadQuery({ campaignId });
|
||||
}
|
||||
}, [loadQuery, campaignId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<CampaignDetailPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError, 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<ProviderInfo>;
|
||||
existingSourceProviders: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
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<string, string>,
|
||||
): 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<ProviderInfo | null>(null);
|
||||
|
||||
const [apiKeyValue, setApiKeyValue] = useState("");
|
||||
const [extraSettingValues, setExtraSettingValues] = useState<Record<string, string>>({});
|
||||
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<Record<string, string>>({});
|
||||
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<CreateAccessSourceDialogMutation>(
|
||||
createAccessSourceMutation,
|
||||
);
|
||||
const [createAPIKeyConnector]
|
||||
= useMutation<AddAccessSourceDialogCreateAPIKeyConnectorMutation>(
|
||||
createAPIKeyConnectorMutation,
|
||||
);
|
||||
const [createClientCredentialsConnector]
|
||||
= useMutation<AddAccessSourceDialogCreateClientCredentialsConnectorMutation>(
|
||||
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<string, string> = {};
|
||||
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<string, string> = {};
|
||||
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 (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => connectOAuthProvider(info.provider)}
|
||||
>
|
||||
{__("Connect")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (info.apiKeySupported) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openAPIKeyDialog(info)}
|
||||
>
|
||||
{__("API Key")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (info.clientCredentialsSupported) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openClientCredentialsDialog(info)}
|
||||
>
|
||||
{__("Client Credentials")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={info.provider} padded className="flex items-center gap-3">
|
||||
<VendorLogo vendor={info.provider} tint className="size-6 shrink-0" />
|
||||
<div className="mr-auto">
|
||||
<h3 className="font-medium">{info.displayName}</h3>
|
||||
</div>
|
||||
{isConnected
|
||||
? (
|
||||
<Badge variant="success" size="md">
|
||||
{__("Connected")}
|
||||
</Badge>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-2">
|
||||
{renderPrimaryButton()}
|
||||
{hasSecondaryOptions && (
|
||||
<ActionDropdown variant="secondary">
|
||||
{info.apiKeySupported && (
|
||||
<DropdownItem
|
||||
onSelect={() => openAPIKeyDialog(info)}
|
||||
>
|
||||
{__("Connect with API Key")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{info.clientCredentialsSupported && (
|
||||
<DropdownItem
|
||||
onSelect={() => openClientCredentialsDialog(info)}
|
||||
>
|
||||
{__("Connect with Client Credentials")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const apiKeyExtraSettingsValid = activeProvider
|
||||
? hasRequiredExtraSettings(activeProvider.extraSettings, extraSettingValues)
|
||||
: true;
|
||||
|
||||
const clientCredentialsExtraSettingsValid = activeProvider
|
||||
? hasRequiredExtraSettings(activeProvider.extraSettings, clientCredentialsExtraValues)
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
__("Access Reviews"),
|
||||
__("Add Source"),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Input
|
||||
placeholder={__("Search providers...")}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredProviders.map(info => renderProviderCard(info))}
|
||||
|
||||
{(!searchQuery.trim() || "csv".includes(searchQuery.toLowerCase())) && (
|
||||
<Card padded className="flex items-center gap-3">
|
||||
<div className="mr-auto">
|
||||
<h3 className="font-medium">{__("CSV")}</h3>
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Upload CSV data directly as an access source.")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
asChild
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
<Link to={`/organizations/${organizationId}/access-reviews/sources/new/csv`}>
|
||||
{__("Open")}
|
||||
</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter exitLabel={__("Close")} />
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
ref={apiKeyDialogRef}
|
||||
title={activeProvider
|
||||
? sprintf(__("Connect %s"), activeProvider.displayName)
|
||||
: __("Connect provider")}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
connectAPIKeyProvider();
|
||||
}}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{sprintf(
|
||||
__("Enter the API key for %s to connect it as an access source."),
|
||||
activeProvider?.displayName ?? "",
|
||||
)}
|
||||
</p>
|
||||
<Field
|
||||
label={__("API Key")}
|
||||
type="password"
|
||||
value={apiKeyValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setApiKeyValue(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{activeProvider?.extraSettings.map(setting => (
|
||||
<Field
|
||||
key={setting.key}
|
||||
label={__(setting.label)}
|
||||
value={extraSettingValues[setting.key] ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setExtraSettingValues(prev => ({
|
||||
...prev,
|
||||
[setting.key]: e.target.value,
|
||||
}))}
|
||||
required={setting.required}
|
||||
/>
|
||||
))}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isConnectingAPIKey
|
||||
|| !apiKeyValue.trim()
|
||||
|| !apiKeyExtraSettingsValid
|
||||
}
|
||||
>
|
||||
{isConnectingAPIKey ? __("Connecting...") : __("Connect")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
ref={clientCredentialsDialogRef}
|
||||
title={activeProvider
|
||||
? sprintf(__("Connect %s"), activeProvider.displayName)
|
||||
: __("Connect provider")}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
connectClientCredentialsProvider();
|
||||
}}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{sprintf(
|
||||
__("Enter the client credentials for %s to connect it as an access source."),
|
||||
activeProvider?.displayName ?? "",
|
||||
)}
|
||||
</p>
|
||||
<Field
|
||||
label={__("Client ID")}
|
||||
value={clientId}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientId(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Field
|
||||
label={__("Client Secret")}
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientSecret(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={__("Token URL")}
|
||||
value={tokenUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={__("Scope")}
|
||||
value={scope}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setScope(e.target.value)}
|
||||
/>
|
||||
{activeProvider?.extraSettings.map(setting =>
|
||||
setting.key === "region"
|
||||
? (
|
||||
<div key={setting.key} className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{__(setting.label)}</label>
|
||||
<Select
|
||||
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
||||
onValueChange={(val: string) =>
|
||||
setClientCredentialsExtraValues(prev => ({
|
||||
...prev,
|
||||
[setting.key]: val,
|
||||
}))}
|
||||
placeholder={__("Select a region")}
|
||||
>
|
||||
<Option value="US">United States (US)</Option>
|
||||
<Option value="CA">Canada (CA)</Option>
|
||||
<Option value="EU">Europe (EU)</Option>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Field
|
||||
key={setting.key}
|
||||
label={__(setting.label)}
|
||||
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setClientCredentialsExtraValues(prev => ({
|
||||
...prev,
|
||||
[setting.key]: e.target.value,
|
||||
}))}
|
||||
required={setting.required}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isConnectingClientCredentials
|
||||
|| !clientId.trim()
|
||||
|| !clientSecret.trim()
|
||||
|| !tokenUrl.trim()
|
||||
|| !clientCredentialsExtraSettingsValid
|
||||
}
|
||||
>
|
||||
{isConnectingClientCredentials ? __("Connecting...") : __("Connect")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
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<string>("");
|
||||
|
||||
const [addScopeSource, isAdding]
|
||||
= useMutation<AddCampaignScopeSourceDialogMutation>(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 (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
trigger={children}
|
||||
title={
|
||||
<Breadcrumb items={[__("Campaign"), __("Add Source")]} />
|
||||
}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Select disabled placeholder={__("Loading...")} />
|
||||
}
|
||||
>
|
||||
<SourceSelect
|
||||
organizationId={organizationId}
|
||||
existingScopeSourceIds={existingScopeSourceIds}
|
||||
value={selectedSourceId}
|
||||
onChange={setSelectedSourceId}
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={isAdding || !selectedSourceId}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{__("Add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSelect({
|
||||
organizationId,
|
||||
existingScopeSourceIds,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
organizationId: string;
|
||||
existingScopeSourceIds: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data
|
||||
= useLazyLoadQuery<AddCampaignScopeSourceDialogSourcesQuery>(
|
||||
sourcesQuery,
|
||||
{ organizationId },
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
|
||||
const sources
|
||||
= data?.organization?.accessSources?.edges
|
||||
?.map(edge => edge.node)
|
||||
.filter(
|
||||
(node): node is NonNullable<typeof node> =>
|
||||
node !== null && !existingScopeSourceIds.includes(node.id),
|
||||
) ?? [];
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("All available sources are already added to this campaign.")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={__("Select a source")}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
{sources.map(source => (
|
||||
<Option key={source.id} value={source.id}>
|
||||
{source.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
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<string[]>([]);
|
||||
const { register, handleSubmit, reset, formState } = useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [createCampaign, isCreating]
|
||||
= useMutation<CreateAccessReviewCampaignDialogMutation>(
|
||||
createCampaignMutation,
|
||||
);
|
||||
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSelectedSourceIds(prev =>
|
||||
prev.includes(sourceId)
|
||||
? prev.filter(id => id !== sourceId)
|
||||
: [...prev, sourceId],
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
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 (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
trigger={children}
|
||||
onClose={handleClose}
|
||||
title={(
|
||||
<Breadcrumb
|
||||
items={[__("Access Reviews"), __("New Campaign")]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Field
|
||||
label={__("Name")}
|
||||
{...register("name")}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={__("Description")}
|
||||
{...register("description")}
|
||||
type="textarea"
|
||||
/>
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="text-sm text-txt-tertiary">
|
||||
{__("Loading sources...")}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<SourceSelector
|
||||
organizationId={organizationId}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={toggleSource}
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button disabled={isCreating || formState.isSubmitting} type="submit">
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSelector({
|
||||
organizationId,
|
||||
selectedSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
organizationId: string;
|
||||
selectedSourceIds: string[];
|
||||
onToggle: (sourceId: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data = useLazyLoadQuery<CreateAccessReviewCampaignDialogSourcesQuery>(
|
||||
sourcesQuery,
|
||||
{ organizationId },
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
|
||||
const sources
|
||||
= data?.organization?.accessSources?.edges
|
||||
?.map(edge => edge.node)
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null) ?? [];
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-txt-tertiary">
|
||||
{__("No sources available. Add sources in the Sources tab first.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium mb-2">{__("Sources")}</legend>
|
||||
<div className="space-y-2">
|
||||
{sources.map(source => (
|
||||
<label
|
||||
key={source.id}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSourceIds.includes(source.id)}
|
||||
onChange={() => onToggle(source.id)}
|
||||
/>
|
||||
<span className="text-sm">{source.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
Option,
|
||||
Select,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, useEffect, useMemo } from "react";
|
||||
import { Controller, useWatch } from "react-hook-form";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
|
||||
export const createAccessSourceMutation = graphql`
|
||||
mutation CreateAccessSourceDialogMutation(
|
||||
$input: CreateAccessSourceInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createAccessSource(input: $input) {
|
||||
accessSourceEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
...AccessSourceRowFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
connectors: ReadonlyArray<{
|
||||
readonly id: string;
|
||||
readonly provider: "GOOGLE_WORKSPACE" | "LINEAR" | "SLACK";
|
||||
readonly createdAt: string;
|
||||
}>;
|
||||
preselectedConnectorId: string | null;
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1),
|
||||
sourceType: z.enum(["CSV", "OAUTH2"]),
|
||||
provider: z.enum(["GOOGLE_WORKSPACE", "LINEAR", "SLACK"]).optional(),
|
||||
connectorId: z.string().optional(),
|
||||
csvData: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.sourceType === "CSV" && !data.csvData?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["csvData"],
|
||||
message: "CSV data is required for CSV sources.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.sourceType === "OAUTH2") {
|
||||
if (!data.provider) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["provider"],
|
||||
message: "Provider is required for OAuth2 sources.",
|
||||
});
|
||||
}
|
||||
if (!data.connectorId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["connectorId"],
|
||||
message: "Connector is required for OAuth2 sources.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function providerLabel(provider: "GOOGLE_WORKSPACE" | "LINEAR" | "SLACK") {
|
||||
switch (provider) {
|
||||
case "GOOGLE_WORKSPACE":
|
||||
return "Google Workspace";
|
||||
case "LINEAR":
|
||||
return "Linear";
|
||||
case "SLACK":
|
||||
return "Slack";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateAccessSourceDialog({
|
||||
children,
|
||||
organizationId,
|
||||
connectionId,
|
||||
connectors,
|
||||
preselectedConnectorId,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const preselectedConnector = useMemo(
|
||||
() => connectors.find(connector => connector.id === preselectedConnectorId),
|
||||
[connectors, preselectedConnectorId],
|
||||
);
|
||||
const { control, register, handleSubmit, reset, setValue }
|
||||
= useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
name: "",
|
||||
sourceType: preselectedConnector ? "OAUTH2" : "CSV",
|
||||
provider: preselectedConnector?.provider ?? "GOOGLE_WORKSPACE",
|
||||
connectorId: preselectedConnector?.id,
|
||||
csvData: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
const sourceType = useWatch({ control, name: "sourceType" });
|
||||
const provider = useWatch({ control, name: "provider" });
|
||||
const connectorId = useWatch({ control, name: "connectorId" });
|
||||
const ref = useDialogRef();
|
||||
|
||||
const providerConnectors = useMemo(
|
||||
() => connectors,
|
||||
[connectors],
|
||||
);
|
||||
const selectableConnectors = useMemo(
|
||||
() =>
|
||||
providerConnectors.filter(
|
||||
connector => !provider || connector.provider === provider,
|
||||
),
|
||||
[provider, providerConnectors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider) {
|
||||
setValue("connectorId", undefined);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
connectorId
|
||||
&& !selectableConnectors.some(connector => connector.id === connectorId)
|
||||
) {
|
||||
setValue("connectorId", undefined);
|
||||
}
|
||||
}, [provider, connectorId, selectableConnectors, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preselectedConnector) return;
|
||||
setValue("sourceType", "OAUTH2");
|
||||
setValue("provider", preselectedConnector.provider);
|
||||
setValue("connectorId", preselectedConnector.id);
|
||||
}, [preselectedConnector, setValue]);
|
||||
|
||||
const [createAccessSource, isCreating]
|
||||
= useMutation<CreateAccessSourceDialogMutation>(
|
||||
createAccessSourceMutation,
|
||||
);
|
||||
|
||||
const clearConnectorQueryParam = () => {
|
||||
if (!searchParams.get("connector_id")) {
|
||||
return;
|
||||
}
|
||||
setSearchParams((params) => {
|
||||
params.delete("connector_id");
|
||||
return params;
|
||||
});
|
||||
};
|
||||
|
||||
const startOAuthConnection = () => {
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL || window.location.origin;
|
||||
const url = new URL("/api/console/v1/connectors/initiate", baseURL);
|
||||
url.searchParams.append("organization_id", organizationId);
|
||||
url.searchParams.append("provider", provider);
|
||||
url.searchParams.append("continue", `/organizations/${organizationId}/access-reviews`);
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const isOAuth = data.sourceType === "OAUTH2";
|
||||
createAccessSource({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
connectorId: isOAuth ? data.connectorId : null,
|
||||
name: data.name,
|
||||
csvData: isOAuth ? null : (data.csvData || null),
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Access source created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
clearConnectorQueryParam();
|
||||
reset();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
trigger={children}
|
||||
title={(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
__("Access Reviews"),
|
||||
__("New Access Source"),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Field
|
||||
label={__("Name")}
|
||||
{...register("name")}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
<Field label={__("Source type")}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sourceType"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Option value="CSV">{__("CSV Upload")}</Option>
|
||||
<Option value="OAUTH2">{__("OAuth2 Connector")}</Option>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{sourceType === "OAUTH2" && (
|
||||
<>
|
||||
<Field label={__("Provider")}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
setValue("connectorId", undefined);
|
||||
}}
|
||||
>
|
||||
<Option value="GOOGLE_WORKSPACE">{__("Google Workspace")}</Option>
|
||||
<Option value="LINEAR">{__("Linear")}</Option>
|
||||
<Option value="SLACK">{__("Slack")}</Option>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={__("Connector")}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="connectorId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
{selectableConnectors.map(connector => (
|
||||
<Option key={connector.id} value={connector.id}>
|
||||
{`${providerLabel(connector.provider)} (${new Date(connector.createdAt).toLocaleDateString(undefined, { month: "short", year: "numeric" })})`}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("Need a new connection? Connect your provider and come back to continue creating this source.")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={startOAuthConnection}
|
||||
disabled={!provider}
|
||||
>
|
||||
{__("Connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sourceType === "CSV" && (
|
||||
<>
|
||||
<Field
|
||||
label={__("CSV Data")}
|
||||
{...register("csvData")}
|
||||
type="textarea"
|
||||
placeholder="email,full_name,role,job_title,is_admin,active,external_id"
|
||||
/>
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("Paste CSV content with a header row. Supported columns: email, full_name, role, job_title, is_admin, active, external_id.")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button disabled={isCreating} type="submit">
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
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<string | null>(null);
|
||||
const { organizationRef, canCreateSource, connectorProviderInfos } = useOutletContext<{
|
||||
organizationRef: AccessReviewSourcesTabFragment$key;
|
||||
canCreateSource: boolean;
|
||||
connectorProviderInfos: ReadonlyArray<ProviderInfo>;
|
||||
}>();
|
||||
|
||||
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<typeof p> => p != null),
|
||||
[accessSources.edges],
|
||||
);
|
||||
|
||||
const [createAccessSource, isCreatingSource]
|
||||
= useMutation<CreateAccessSourceDialogMutation>(
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
{canCreateSource && (
|
||||
<AddAccessSourceDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={accessSources.__id}
|
||||
providerInfos={connectorProviderInfos}
|
||||
existingSourceProviders={existingSourceProviders}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("Add source")}
|
||||
</Button>
|
||||
</AddAccessSourceDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accessSources && accessSources.edges.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Source")}</Th>
|
||||
<Th>{__("Status")}</Th>
|
||||
<Th>{__("Organization")}</Th>
|
||||
<Th>{__("Created at")}</Th>
|
||||
<Th className="w-12"></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{accessSources.edges.map(edge => (
|
||||
<AccessSourceRow
|
||||
key={edge.node.id}
|
||||
fKey={edge.node}
|
||||
connectionId={accessSources.__id}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
{hasNext && (
|
||||
<div className="p-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => loadNext(50)}
|
||||
disabled={isLoadingNext}
|
||||
>
|
||||
{isLoadingNext
|
||||
? __("Loading...")
|
||||
: __("Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-txt-tertiary">
|
||||
{__("No access sources configured yet. Add your first source to start reviewing access.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
44
apps/console/src/routes/accessReviewRoutes.ts
Normal file
44
apps/console/src/routes/accessReviewRoutes.ts
Normal file
@@ -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[];
|
||||
Reference in New Issue
Block a user