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

View File

@@ -0,0 +1,29 @@
// 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 type { IconProps } from "./type";
export function IconRobot({ size = 24, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" className={className} xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M13 3V5H17C18.6569 5 20 6.34315 20 8V19C20 20.6569 18.6569 22 17 22H7C5.34315 22 4 20.6569 4 19V8C4 6.34315 5.34315 5 7 5H11V3H13ZM7 7C6.44772 7 6 7.44772 6 8V19C6 19.5523 6.44772 20 7 20H17C17.5523 20 18 19.5523 18 19V8C18 7.44772 17.5523 7 17 7H7Z" />
<circle cx="9.5" cy="12" r="1.5" />
<circle cx="14.5" cy="12" r="1.5" />
<path d="M9 16H15V17.5H9V16Z" />
<path d="M12 1C12.5523 1 13 1.44772 13 2V3H11V2C11 1.44772 11.4477 1 12 1Z" />
<path d="M2 11C2 10.4477 2.44772 10 3 10V14C2.44772 14 2 13.5523 2 13V11Z" />
<path d="M21 10C21.5523 10 22 10.4477 22 11V13C22 13.5523 21.5523 14 21 14V10Z" />
</svg>
);
}

View File

@@ -96,6 +96,7 @@ export { IconChevronUp } from "./IconChevronUp";
export { IconBlock } from "./IconBlock";
export { IconChevronDown } from "./IconChevronDown";
export { IconPageCross } from "./IconPageCross";
export { IconRobot } from "./IconRobot";
export { IconRotateCw } from "./IconRotateCw";
export { IconPin } from "./IconPin";
export { IconMinusLarge } from "./IconMinusLarge";

View File

@@ -15,9 +15,11 @@
import * as ScrollArea from "@radix-ui/react-scroll-area";
import {
Content,
Group,
Icon,
Item,
ItemText,
Label,
Portal,
Root,
Trigger,
@@ -224,3 +226,18 @@ export function Option({ children, ...props }: ComponentProps<typeof Item>) {
</Item>
);
}
export function SelectGroup({ children, ...props }: ComponentProps<typeof Group>) {
return <Group {...props}>{children}</Group>;
}
export function SelectLabel({ children, ...props }: ComponentProps<typeof Label>) {
return (
<Label
{...props}
className="px-[10px] py-1 text-xs font-semibold text-txt-tertiary uppercase tracking-wider"
>
{children}
</Label>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Brex(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.69 2.319a3.868 3.868 0 0 0-3.108 1.547l-.759 1.007a1.658 1.658 0 0 1-1.313.656H0V21.68h5.296a3.87 3.87 0 0 0 3.108-1.547l.759-1.006a1.656 1.656 0 0 1 1.313-.657H24V2.319h-5.31Zm1.108 11.949h-5.66a3.87 3.87 0 0 0-3.108 1.547l-.759 1.007a1.658 1.658 0 0 1-1.313.656H4.202V9.731h5.661a3.868 3.868 0 0 0 3.107-1.547l.759-1.006a1.658 1.658 0 0 1 1.313-.657h4.771l-.015 7.747Z"
fill="#000000"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Cloudflare(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"
fill="#F38020"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function DocuSign(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M9.517 3.31h4.966v6.621h3.31L12 16.552 6.207 9.931h3.31V3.31zM0 19.034h24v1.655H0v-1.655z"
fill="#FFCD00"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Figma(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15.852 8.981h-4.588V0h4.588c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.491-4.49 4.491zM12.735 7.51h3.117c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0 1.471H8.148c-2.476 0-4.49-2.014-4.49-4.49S5.672 0 8.148 0h4.588v8.981zm-4.587-7.51c-1.665 0-3.019 1.355-3.019 3.019s1.354 3.02 3.019 3.02h3.117V1.471H8.148zm4.587 15.019H8.148c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h4.588v8.98zM8.148 8.981c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h3.117V8.981H8.148zM8.172 24c-2.489 0-4.515-2.014-4.515-4.49s2.014-4.49 4.49-4.49h4.588v4.441c0 2.503-2.047 4.539-4.563 4.539zm-.024-7.51a3.023 3.023 0 0 0-3.019 3.019c0 1.665 1.365 3.019 3.044 3.019 1.705 0 3.093-1.376 3.093-3.068v-2.97H8.148zm7.704 0h-.098c-2.476 0-4.49-2.014-4.49-4.49s2.014-4.49 4.49-4.49h.098c2.476 0 4.49 2.014 4.49 4.49s-2.014 4.49-4.49 4.49zm-.097-7.509c-1.665 0-3.019 1.355-3.019 3.019s1.355 3.019 3.019 3.019h.098c1.665 0 3.019-1.355 3.019-3.019s-1.355-3.019-3.019-3.019h-.098z"
fill="#F24E1E"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function GitHub(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
fill="#181717"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function HubSpot(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.164 7.93V5.084a2.198 2.198 0 001.267-1.978v-.067A2.2 2.2 0 0017.238.845h-.067a2.2 2.2 0 00-2.193 2.193v.067a2.196 2.196 0 001.252 1.973l.013.006v2.852a6.22 6.22 0 00-2.969 1.31l.012-.01-7.828-6.095A2.497 2.497 0 104.3 4.656l-.012.006 7.697 5.991a6.176 6.176 0 00-1.038 3.446c0 1.343.425 2.588 1.147 3.607l-.013-.02-2.342 2.343a1.968 1.968 0 00-.58-.095h-.002a2.033 2.033 0 102.033 2.033 1.978 1.978 0 00-.1-.595l.005.014 2.317-2.317a6.247 6.247 0 104.782-11.134l-.036-.005zm-.964 9.378a3.206 3.206 0 113.215-3.207v.002a3.206 3.206 0 01-3.207 3.207z"
fill="#FF7A59"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Intercom(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M21 0H3C1.343 0 0 1.343 0 3v18c0 1.658 1.343 3 3 3h18c1.658 0 3-1.342 3-3V3c0-1.657-1.342-3-3-3zm-5.801 4.399c0-.44.36-.8.802-.8.44 0 .8.36.8.8v10.688c0 .442-.36.801-.8.801-.443 0-.802-.359-.802-.801V4.399zM11.2 3.994c0-.44.357-.799.8-.799s.8.359.8.799v11.602c0 .44-.357.8-.8.8s-.8-.36-.8-.8V3.994zm-4 .405c0-.44.359-.8.799-.8.443 0 .802.36.802.8v10.688c0 .442-.36.801-.802.801-.44 0-.799-.359-.799-.801V4.399zM3.199 6c0-.442.36-.8.802-.8.44 0 .799.358.799.8v7.195c0 .441-.359.8-.799.8-.443 0-.802-.36-.802-.8V6zM20.52 18.202c-.123.105-3.086 2.593-8.52 2.593-5.433 0-8.397-2.486-8.521-2.593-.335-.288-.375-.792-.086-1.128.285-.334.79-.375 1.125-.09.047.041 2.693 2.211 7.481 2.211 4.848 0 7.456-2.186 7.479-2.207.334-.289.839-.25 1.128.086.289.336.25.84-.086 1.128zm.281-5.007c0 .441-.36.8-.801.8-.441 0-.801-.36-.801-.8V6c0-.442.361-.8.801-.8.441 0 .801.357.801.8v7.195z"
fill="#6AFDEF"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Linear(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"
fill="#5E6AD2"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Notion(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z"
fill="#000000"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function OnePassword(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 .007C5.373.007 0 5.376 0 11.999c0 6.624 5.373 11.994 12 11.994S24 18.623 24 12C24 5.376 18.627.007 12 .007Zm-.895 4.857h1.788c.484 0 .729.002.914.096a.86.86 0 0 1 .377.377c.094.185.095.428.095.912v6.016c0 .12 0 .182-.015.238a.427.427 0 0 1-.067.137.923.923 0 0 1-.174.162l-.695.564c-.113.092-.17.138-.191.194a.216.216 0 0 0 0 .15c.02.055.078.101.191.193l.695.565c.094.076.14.115.174.162.03.042.053.087.067.137a.936.936 0 0 1 .015.238v2.746c0 .484-.001.727-.095.912a.86.86 0 0 1-.377.377c-.185.094-.43.096-.914.096h-1.788c-.484 0-.726-.002-.912-.096a.86.86 0 0 1-.377-.377c-.094-.185-.095-.428-.095-.912v-6.016c0-.12 0-.182.015-.238a.437.437 0 0 1 .067-.139c.034-.047.08-.083.174-.16l.695-.564c.113-.092.17-.138.191-.194a.216.216 0 0 0 0-.15c-.02-.055-.078-.101-.191-.193l-.695-.565a.92.92 0 0 1-.174-.162.437.437 0 0 1-.067-.139.92.92 0 0 1-.015-.236V6.25c0-.484.001-.727.095-.912a.86.86 0 0 1 .377-.377c.186-.094.428-.096.912-.096z"
fill="#3B66BC"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function OpenAI(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
fill="#412991"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Resend(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M14.679 0c4.648 0 7.413 2.765 7.413 6.434s-2.765 6.434-7.413 6.434H12.33L24 24h-8.245l-8.88-8.44c-.636-.588-.93-1.273-.93-1.86 0-.831.587-1.565 1.713-1.883l4.574-1.224c1.737-.465 2.936-1.81 2.936-3.572 0-2.153-1.761-3.4-3.939-3.4H0V0z"
fill="#000000"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Sentry(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z"
fill="#362D59"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Supabase(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M11.9 1.036c-.015-.986-1.26-1.41-1.874-.637L.764 12.05C-.33 13.427.65 15.455 2.409 15.455h9.579l.113 7.51c.014.985 1.259 1.408 1.873.636l9.262-11.653c1.093-1.375.113-3.403-1.645-3.403h-9.642z"
fill="#3FCF8E"
/>
</svg>
);
}

View File

@@ -0,0 +1,16 @@
import type { ComponentProps } from "react";
export function Tally(props: ComponentProps<"svg">) {
return (
<svg
viewBox="0 0 128 124"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M79.9335 17.1445C79.9335 24.2262 77.3191 33.969 73.4647 47.0615L70.7069 57.1173L79.4337 51.4172C96.022 39.7688 106.04 35.397 112.67 35.397C120.212 35.397 128 40.7864 128 51.5052C128 65.1402 113.443 69.0136 85.3379 69.4912L74.8298 69.9849L83.1169 76.5821C102.791 91.747 109.612 99.1764 109.612 108.59C109.612 115.999 102.727 123.715 94.1897 123.715C81.3632 123.715 75.5655 110.387 67.7502 87.7575L63.7662 77.922L60.0183 87.7575C51.2591 113.46 44.5174 123.479 33.343 123.479C24.334 123.479 17.9208 115.289 17.9208 108.354C17.9208 97.9942 27.3369 89.3825 44.6517 76.5821L52.9388 69.9849L42.6666 69.4912C12.8171 69.2501 0 64.957 0 51.2688C0 40.55 8.02804 35.1605 15.4268 35.1605C24.3617 35.1605 34.1064 41.1875 48.5708 51.4172L57.2976 57.1173L54.5398 47.0615C50.3616 33.2597 48.3996 23.1344 48.3996 17.1445C48.3996 7.76783 53.6838 0 64.0023 0C74.5567 1.03096e-07 79.9335 7.76783 79.9335 17.1445Z"
fill="#000000"
/>
</svg>
);
}

View File

@@ -0,0 +1,80 @@
// 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 type { ComponentProps, FC } from "react";
import { Brex } from "./Brex";
import { Cloudflare } from "./Cloudflare";
import { DocuSign } from "./DocuSign";
import { Figma } from "./Figma";
import { GitHub } from "./GitHub";
import { Google } from "./Google";
import { HubSpot } from "./HubSpot";
import { Intercom } from "./Intercom";
import { Linear } from "./Linear";
import { Microsoft } from "./Microsoft";
import { Notion } from "./Notion";
import { OnePassword } from "./OnePassword";
import { OpenAI } from "./OpenAI";
import { Resend } from "./Resend";
import { Sentry } from "./Sentry";
import { Slack } from "./Slack";
import { Supabase } from "./Supabase";
import { Tally } from "./Tally";
const vendors: Record<string, FC<ComponentProps<"svg">>> = {
BREX: Brex,
CLOUDFLARE: Cloudflare,
DOCUSIGN: DocuSign,
FIGMA: Figma,
GITHUB: GitHub,
GOOGLE: Google,
GOOGLE_WORKSPACE: Google,
HUBSPOT: HubSpot,
INTERCOM: Intercom,
LINEAR: Linear,
MICROSOFT: Microsoft,
NOTION: Notion,
ONE_PASSWORD: OnePassword,
ONEPASSWORD: OnePassword,
OPENAI: OpenAI,
RESEND: Resend,
SENTRY: Sentry,
SLACK: Slack,
SUPABASE: Supabase,
TALLY: Tally,
};
type VendorLogoProps = ComponentProps<"svg"> & {
/** The vendor/brand name (case-insensitive, supports enum values like GOOGLE_WORKSPACE). */
vendor: string;
/** When true, renders the SVG in monochrome, adapting to the current theme. */
tint?: boolean;
};
export function VendorLogo({ vendor, tint, ...props }: VendorLogoProps) {
const Component = vendors[vendor.toUpperCase()];
if (!Component) return null;
if (tint) {
return (
<Component
{...props}
className={["grayscale brightness-0 dark:invert", props.className].filter(Boolean).join(" ")}
/>
);
}
return <Component {...props} />;
}

View File

@@ -1,3 +1,19 @@
export { Brex } from "./Brex";
export { Cloudflare } from "./Cloudflare";
export { DocuSign } from "./DocuSign";
export { Figma } from "./Figma";
export { GitHub } from "./GitHub";
export { Google } from "./Google";
export { HubSpot } from "./HubSpot";
export { Intercom } from "./Intercom";
export { Linear } from "./Linear";
export { Microsoft } from "./Microsoft";
export { Notion } from "./Notion";
export { OnePassword } from "./OnePassword";
export { OpenAI } from "./OpenAI";
export { Resend } from "./Resend";
export { Sentry } from "./Sentry";
export { Slack } from "./Slack";
export { Supabase } from "./Supabase";
export { Tally } from "./Tally";
export { VendorLogo } from "./VendorLogo";

View File

@@ -35,7 +35,7 @@ type State = {
message: string | null;
variant?: ComponentProps<typeof Button>["variant"];
label?: string;
onConfirm: () => Promise<unknown>;
onConfirm: () => void | Promise<unknown>;
};
const useConfirmStore = create(

View File

@@ -40,7 +40,7 @@ export { Avatar } from "./Atoms/Avatar/Avatar";
export { Field } from "./Molecules/Field/Field";
export { Input } from "./Atoms/Input/Input";
export { Textarea } from "./Atoms/Textarea/Textarea";
export { Option, Select } from "./Atoms/Select/Select";
export { Option, Select, SelectGroup, SelectLabel } from "./Atoms/Select/Select";
export { Label } from "./Atoms/Label/Label";
export { PropertyRow } from "./Atoms/PropertyRow/PropertyRow";
export { Table, Tbody, Td, Th, Thead, Tr, TrButton } from "./Atoms/Table/Table";