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:
Aurélien Sibiril
2026-04-02 11:52:13 +02:00
parent 244b6390cb
commit b396359162
41 changed files with 4513 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
{

View 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[];