Add access review frontend
Add campaign and source management pages with detail views, bulk decision and flag controls, connector provider dialog with OAuth/API-key/client-credentials flows, vendor logos, shared helpers, and campaign lifecycle UX (start, complete, cancel). Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
IconFire3,
|
||||
IconGroup1,
|
||||
IconInboxEmpty,
|
||||
IconKey,
|
||||
IconListStack,
|
||||
IconLock,
|
||||
IconMagnifyingGlass,
|
||||
@@ -66,6 +67,9 @@ const fragment = graphql`
|
||||
canListStatesOfApplicability: permission(
|
||||
action: "core:state-of-applicability:list"
|
||||
)
|
||||
canListAccessReviewCampaigns: permission(
|
||||
action: "core:access-review-campaign:list"
|
||||
)
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -200,6 +204,13 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
|
||||
to={`${prefix}/snapshots`}
|
||||
/>
|
||||
)}
|
||||
{organization.canListAccessReviewCampaigns && (
|
||||
<SidebarItem
|
||||
label={__("Access Reviews")}
|
||||
icon={IconKey}
|
||||
to={`${prefix}/access-reviews`}
|
||||
/>
|
||||
)}
|
||||
{organization.canGetTrustCenter && (
|
||||
<SidebarItem
|
||||
label={__("Compliance Page")}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
IconFolder2,
|
||||
IconKey,
|
||||
PageHeader,
|
||||
TabLink,
|
||||
Tabs,
|
||||
} from "@probo/ui";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { Outlet } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { AccessReviewLayoutQuery } from "#/__generated__/core/AccessReviewLayoutQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
export const accessReviewLayoutQuery = graphql`
|
||||
query AccessReviewLayoutQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
__typename
|
||||
... on Organization {
|
||||
id
|
||||
canCreateSource: permission(action: "core:access-source:create")
|
||||
canCreateCampaign: permission(action: "core:access-review-campaign:create")
|
||||
connectorProviderInfos {
|
||||
provider
|
||||
displayName
|
||||
oauthConfigured
|
||||
apiKeySupported
|
||||
clientCredentialsSupported
|
||||
extraSettings {
|
||||
key
|
||||
label
|
||||
required
|
||||
}
|
||||
}
|
||||
...AccessReviewCampaignsTabFragment
|
||||
...AccessReviewSourcesTabFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AccessReviewLayout({
|
||||
queryRef,
|
||||
}: {
|
||||
queryRef: PreloadedQuery<AccessReviewLayoutQuery>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
usePageTitle(__("Access Reviews"));
|
||||
|
||||
const { organization } = usePreloadedQuery(accessReviewLayoutQuery, queryRef);
|
||||
if (organization.__typename !== "Organization") {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Access Reviews")}
|
||||
description={__(
|
||||
"Review and manage user access across your organization's systems and applications.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Tabs>
|
||||
<TabLink to={`/organizations/${organizationId}/access-reviews`} end>
|
||||
<IconKey className="size-4" />
|
||||
{__("Campaigns")}
|
||||
</TabLink>
|
||||
<TabLink to={`/organizations/${organizationId}/access-reviews/sources`}>
|
||||
<IconFolder2 className="size-4" />
|
||||
{__("Sources")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet context={{
|
||||
organizationRef: organization,
|
||||
canCreateSource: organization.canCreateSource,
|
||||
canCreateCampaign: organization.canCreateCampaign,
|
||||
connectorProviderInfos: organization.connectorProviderInfos,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { AccessReviewLayoutQuery } from "#/__generated__/core/AccessReviewLayoutQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import AccessReviewLayout, { accessReviewLayoutQuery } from "./AccessReviewLayout";
|
||||
|
||||
export default function AccessReviewLayoutLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<AccessReviewLayoutQuery>(accessReviewLayoutQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery({ organizationId });
|
||||
}
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) return <PageSkeleton />;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<AccessReviewLayout queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Field,
|
||||
PageHeader,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
||||
import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { createAccessSourceMutation } from "./dialogs/CreateAccessSourceDialog";
|
||||
|
||||
export const createCsvAccessSourcePageQuery = graphql`
|
||||
query CreateCsvAccessSourcePageQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
__typename
|
||||
... on Organization {
|
||||
id
|
||||
canCreateSource: permission(action: "core:access-source:create")
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const csvSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
csvData: z.string().min(1),
|
||||
});
|
||||
|
||||
export default function CreateCsvAccessSourcePage({
|
||||
queryRef,
|
||||
}: {
|
||||
queryRef: PreloadedQuery<CreateCsvAccessSourcePageQuery>;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { register, handleSubmit }
|
||||
= useFormWithSchema(csvSchema, {
|
||||
defaultValues: {
|
||||
name: "",
|
||||
csvData: "",
|
||||
},
|
||||
});
|
||||
|
||||
usePageTitle(__("Add CSV Access Source"));
|
||||
|
||||
const { organization } = usePreloadedQuery(createCsvAccessSourcePageQuery, queryRef);
|
||||
if (organization.__typename !== "Organization") {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organization.id,
|
||||
"AccessReviewSourcesTab_accessSources",
|
||||
);
|
||||
|
||||
const [createAccessSource, isCreating]
|
||||
= useMutation<CreateAccessSourceDialogMutation>(
|
||||
createAccessSourceMutation,
|
||||
);
|
||||
|
||||
if (!organization.canCreateSource) {
|
||||
return (
|
||||
<Card padded>
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("You do not have permission to create access sources.")}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const onSubmit = (data: z.infer<typeof csvSchema>) => {
|
||||
createAccessSource({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
connectorId: null,
|
||||
name: data.name,
|
||||
csvData: data.csvData,
|
||||
},
|
||||
connections: connectionId ? [connectionId] : [],
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Access source created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
void navigate(`/organizations/${organizationId}/access-reviews/sources`);
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Add CSV access source")}
|
||||
description={__(
|
||||
"Paste CSV content with a header row. This source will be saved and available in Access Reviews.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<Card padded>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)} className="space-y-4">
|
||||
<Field
|
||||
label={__("Name")}
|
||||
{...register("name")}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={__("CSV Data")}
|
||||
{...register("csvData")}
|
||||
type="textarea"
|
||||
placeholder="email,full_name,role,job_title,is_admin,active,external_id"
|
||||
required
|
||||
/>
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("Supported columns: email, full_name, role, job_title, is_admin, active, external_id.")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to={`/organizations/${organizationId}/access-reviews/sources`}>
|
||||
{__("Back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button disabled={isCreating} type="submit">
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import CreateCsvAccessSourcePage, { createCsvAccessSourcePageQuery } from "./CreateCsvAccessSourcePage";
|
||||
|
||||
export default function CreateCsvAccessSourcePageLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<CreateCsvAccessSourcePageQuery>(createCsvAccessSourcePageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ organizationId });
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<CreateCsvAccessSourcePage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatDate, formatError, type GraphQLError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Badge,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconTrashCan,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Td,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useFragment, useLazyLoadQuery, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { AccessSourceRowConfigureMutation } from "#/__generated__/core/AccessSourceRowConfigureMutation.graphql";
|
||||
import type { AccessSourceRowDeleteMutation } from "#/__generated__/core/AccessSourceRowDeleteMutation.graphql";
|
||||
import type { AccessSourceRowFragment$key } from "#/__generated__/core/AccessSourceRowFragment.graphql";
|
||||
import type { AccessSourceRowOrgsQuery } from "#/__generated__/core/AccessSourceRowOrgsQuery.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment AccessSourceRowFragment on AccessSource {
|
||||
id
|
||||
name
|
||||
connectorId
|
||||
connector {
|
||||
provider
|
||||
}
|
||||
connectionStatus
|
||||
selectedOrganization
|
||||
needsConfiguration
|
||||
createdAt
|
||||
canDelete: permission(action: "core:access-source:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
export const deleteAccessSourceMutation = graphql`
|
||||
mutation AccessSourceRowDeleteMutation(
|
||||
$input: DeleteAccessSourceInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteAccessSource(input: $input) {
|
||||
deletedAccessSourceId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const configureMutation = graphql`
|
||||
mutation AccessSourceRowConfigureMutation(
|
||||
$input: ConfigureAccessSourceInput!
|
||||
) {
|
||||
configureAccessSource(input: $input) {
|
||||
accessSource {
|
||||
id
|
||||
selectedOrganization
|
||||
needsConfiguration
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const orgsQuery = graphql`
|
||||
query AccessSourceRowOrgsQuery($accessSourceId: ID!) {
|
||||
node(id: $accessSourceId) @required(action: THROW) {
|
||||
... on AccessSource {
|
||||
providerOrganizations {
|
||||
slug
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
fKey: AccessSourceRowFragment$key;
|
||||
connectionId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
function sourceLabel(connectorProvider: string | null | undefined): string {
|
||||
if (!connectorProvider) {
|
||||
return "CSV";
|
||||
}
|
||||
|
||||
switch (connectorProvider) {
|
||||
case "GOOGLE_WORKSPACE":
|
||||
return "Google Workspace";
|
||||
case "LINEAR":
|
||||
return "Linear";
|
||||
case "SLACK":
|
||||
return "Slack";
|
||||
default:
|
||||
return connectorProvider;
|
||||
}
|
||||
}
|
||||
|
||||
export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
|
||||
const accessSource = useFragment(fragment, fKey);
|
||||
|
||||
const [deleteAccessSource] = useMutation<AccessSourceRowDeleteMutation>(deleteAccessSourceMutation);
|
||||
const [configure] = useMutation<AccessSourceRowConfigureMutation>(configureMutation);
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() => {
|
||||
deleteAccessSource({
|
||||
variables: {
|
||||
input: { accessSourceId: accessSource.id },
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: (_response, errors) => {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete access source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete access source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
message: sprintf(
|
||||
__("This will permanently delete \"%s\". This action cannot be undone."),
|
||||
accessSource.name,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleOrgChange = (slug: string) => {
|
||||
configure({
|
||||
variables: {
|
||||
input: {
|
||||
accessSourceId: accessSource.id,
|
||||
organizationSlug: slug,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to configure source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Organization updated."),
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to configure source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
const provider = accessSource.connector?.provider;
|
||||
if (!provider || !accessSource.connectorId) return;
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL || window.location.origin;
|
||||
const url = new URL("/api/console/v1/connectors/initiate", baseURL);
|
||||
url.searchParams.append("organization_id", organizationId);
|
||||
url.searchParams.append("provider", provider);
|
||||
url.searchParams.append("connector_id", accessSource.connectorId);
|
||||
url.searchParams.append(
|
||||
"continue",
|
||||
`/organizations/${organizationId}/access-reviews/sources`,
|
||||
);
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
const showOrgSelector = accessSource.needsConfiguration || accessSource.selectedOrganization;
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{accessSource.name}</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral" size="sm">
|
||||
{sourceLabel(accessSource.connector?.provider ?? null)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{accessSource.connectionStatus === "CONNECTED" && (
|
||||
<Badge variant="success" size="sm">{__("Connected")}</Badge>
|
||||
)}
|
||||
{accessSource.connectionStatus === "DISCONNECTED" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="danger" size="sm">{__("Disconnected")}</Badge>
|
||||
<Button variant="secondary" onClick={handleReconnect}>
|
||||
{__("Reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{showOrgSelector && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Select variant="editor" disabled placeholder={__("Loading...")} />
|
||||
}
|
||||
>
|
||||
<InlineOrgSelect
|
||||
accessSourceId={accessSource.id}
|
||||
selectedOrganization={accessSource.selectedOrganization ?? ""}
|
||||
onSelect={handleOrgChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<time dateTime={accessSource.createdAt}>
|
||||
{formatDate(accessSource.createdAt)}
|
||||
</time>
|
||||
</Td>
|
||||
{accessSource.canDelete && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
variant="danger"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineOrgSelect({
|
||||
accessSourceId,
|
||||
selectedOrganization,
|
||||
onSelect,
|
||||
}: {
|
||||
accessSourceId: string;
|
||||
selectedOrganization: string;
|
||||
onSelect: (slug: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data = useLazyLoadQuery<AccessSourceRowOrgsQuery>(
|
||||
orgsQuery,
|
||||
{ accessSourceId },
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
const orgs = data.node.providerOrganizations ?? [];
|
||||
|
||||
if (orgs.length === 0) {
|
||||
return (
|
||||
<ManualOrgInput
|
||||
selectedOrganization={selectedOrganization}
|
||||
onSubmit={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
variant="editor"
|
||||
placeholder={__("Select organization")}
|
||||
value={selectedOrganization}
|
||||
onValueChange={onSelect}
|
||||
>
|
||||
{orgs.map(org => (
|
||||
<Option key={org.slug} value={org.slug}>
|
||||
{org.displayName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualOrgInput({
|
||||
selectedOrganization,
|
||||
onSubmit,
|
||||
}: {
|
||||
selectedOrganization: string;
|
||||
onSubmit: (slug: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const [value, setValue] = useState(selectedOrganization);
|
||||
|
||||
const handleBlur = () => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed && trimmed !== selectedOrganization) {
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleBlur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder={__("org-slug")}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="max-w-40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
IconPencil,
|
||||
Option,
|
||||
Select,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { AccessEntryDecision, EntryDecisionActionsMutation } from "#/__generated__/core/EntryDecisionActionsMutation.graphql";
|
||||
|
||||
import { decisionBadgeVariant, decisionLabel } from "./accessReviewHelpers";
|
||||
|
||||
const mutation = graphql`
|
||||
mutation EntryDecisionActionsMutation(
|
||||
$input: RecordAccessEntryDecisionInput!
|
||||
) {
|
||||
recordAccessEntryDecision(input: $input) {
|
||||
accessEntry {
|
||||
id
|
||||
decision
|
||||
decisionNote
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
entryId: string;
|
||||
decision: string;
|
||||
};
|
||||
|
||||
export function EntryDecisionActions({ entryId, decision }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const ref = useDialogRef();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pendingDecision, setPendingDecision] = useState<AccessEntryDecision | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [recordDecision, isRecording]
|
||||
= useMutation<EntryDecisionActionsMutation>(mutation);
|
||||
|
||||
const submitDecision = (decisionValue: AccessEntryDecision, decisionNote?: string) => {
|
||||
recordDecision({
|
||||
variables: {
|
||||
input: {
|
||||
accessEntryId: entryId,
|
||||
decision: decisionValue,
|
||||
decisionNote: decisionNote || null,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to record decision"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPendingDecision(null);
|
||||
setNote("");
|
||||
setEditing(false);
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to record decision"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openNoteDialog = (decisionValue: AccessEntryDecision) => {
|
||||
setPendingDecision(decisionValue);
|
||||
setNote("");
|
||||
ref.current?.open();
|
||||
};
|
||||
|
||||
const handleDecision = (value: string) => {
|
||||
const decision = value as AccessEntryDecision;
|
||||
if (decision === "APPROVED") {
|
||||
submitDecision(decision);
|
||||
} else {
|
||||
openNoteDialog(decision);
|
||||
}
|
||||
};
|
||||
|
||||
// Already decided -- show badge with edit button
|
||||
if (decision !== "PENDING" && !editing) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant={decisionBadgeVariant(decision)}>
|
||||
{decisionLabel(__, decision)}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
className="text-txt-tertiary hover:text-txt-primary cursor-pointer"
|
||||
onClick={() => setEditing(true)}
|
||||
title={__("Change decision")}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
variant="editor"
|
||||
placeholder={__("Decide...")}
|
||||
onValueChange={handleDecision}
|
||||
disabled={isRecording}
|
||||
>
|
||||
<Option value="APPROVED">{__("Approve")}</Option>
|
||||
<Option value="REVOKE">{__("Revoke")}</Option>
|
||||
<Option value="DEFER">{__("Modify")}</Option>
|
||||
<Option value="ESCALATE">{__("Escalate")}</Option>
|
||||
</Select>
|
||||
|
||||
<Dialog ref={ref} title={__("Decision note")}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Please provide a reason for this decision.")}
|
||||
</p>
|
||||
<Field
|
||||
label={__("Note")}
|
||||
type="textarea"
|
||||
value={note}
|
||||
onValueChange={setNote}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={isRecording || !note.trim()}
|
||||
onClick={() => {
|
||||
if (pendingDecision) {
|
||||
submitDecision(pendingDecision, note);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{__("Confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Checkbox, useToast } from "@probo/ui";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { AccessEntryFlag, EntryFlagSelectMutation } from "#/__generated__/core/EntryFlagSelectMutation.graphql";
|
||||
|
||||
import { flagBadgeVariant, flagGroups, flagLabel } from "./accessReviewHelpers";
|
||||
|
||||
const mutation = graphql`
|
||||
mutation EntryFlagSelectMutation($input: FlagAccessEntryInput!) {
|
||||
flagAccessEntry(input: $input) {
|
||||
accessEntry {
|
||||
id
|
||||
flags
|
||||
flagReasons
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
entryId: string;
|
||||
currentFlags: readonly AccessEntryFlag[];
|
||||
};
|
||||
|
||||
export function EntryFlagSelect({ entryId, currentFlags }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localFlags, setLocalFlags] = useState<AccessEntryFlag[]>([...currentFlags]);
|
||||
const openedWithRef = useRef<readonly AccessEntryFlag[]>(currentFlags);
|
||||
const [flagEntry] = useMutation<EntryFlagSelectMutation>(mutation);
|
||||
|
||||
const toggleFlag = (flagValue: AccessEntryFlag) => {
|
||||
setLocalFlags(prev =>
|
||||
prev.includes(flagValue)
|
||||
? prev.filter(f => f !== flagValue)
|
||||
: [...prev, flagValue],
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
openedWithRef.current = currentFlags;
|
||||
setLocalFlags([...currentFlags]);
|
||||
}
|
||||
|
||||
if (!nextOpen) {
|
||||
// Submit only if flags changed since popover opened
|
||||
const changed
|
||||
= localFlags.length !== openedWithRef.current.length
|
||||
|| localFlags.some(f => !openedWithRef.current.includes(f));
|
||||
|
||||
if (changed) {
|
||||
flagEntry({
|
||||
variables: {
|
||||
input: {
|
||||
accessEntryId: entryId,
|
||||
flags: localFlags,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to flag entry"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to flag entry"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
|
||||
const displayFlags = open ? localFlags : [...currentFlags];
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-sm cursor-pointer"
|
||||
>
|
||||
{displayFlags.length === 0
|
||||
? (
|
||||
<span className="text-txt-tertiary">--</span>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayFlags.map(f => (
|
||||
<Badge key={f} variant={flagBadgeVariant(f)} size="sm">
|
||||
{flagLabel(f)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={5}
|
||||
className="z-100 w-64 rounded-[10px] bg-level-1 p-2 shadow-mid animate-in fade-in slide-in-from-top-2"
|
||||
>
|
||||
{flagGroups.map(group => (
|
||||
<div key={group.label} className="mb-2 last:mb-0">
|
||||
<div className="px-2 py-1 text-xs font-semibold text-txt-tertiary uppercase tracking-wider">
|
||||
{__(group.label)}
|
||||
</div>
|
||||
{group.flags.map(flag => (
|
||||
<label
|
||||
key={flag.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-tertiary-hover"
|
||||
>
|
||||
<Checkbox
|
||||
checked={localFlags.includes(flag.value)}
|
||||
onChange={() => toggleFlag(flag.value)}
|
||||
/>
|
||||
<span className="text-sm text-txt-primary">{__(flag.label)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
type BadgeVariant = "neutral" | "info" | "warning" | "success" | "danger";
|
||||
|
||||
export function statusBadgeVariant(status: string): BadgeVariant {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return "neutral";
|
||||
case "IN_PROGRESS":
|
||||
return "info";
|
||||
case "PENDING_ACTIONS":
|
||||
return "warning";
|
||||
case "COMPLETED":
|
||||
return "success";
|
||||
case "FAILED":
|
||||
case "CANCELLED":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(
|
||||
__: (key: string) => string,
|
||||
status: string,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "DRAFT":
|
||||
return __("Draft");
|
||||
case "IN_PROGRESS":
|
||||
return __("In progress");
|
||||
case "PENDING_ACTIONS":
|
||||
return __("Pending actions");
|
||||
case "COMPLETED":
|
||||
return __("Completed");
|
||||
case "FAILED":
|
||||
return __("Failed");
|
||||
case "CANCELLED":
|
||||
return __("Cancelled");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function decisionBadgeVariant(decision: string): BadgeVariant {
|
||||
switch (decision) {
|
||||
case "APPROVED":
|
||||
return "success";
|
||||
case "REVOKE":
|
||||
return "danger";
|
||||
case "DEFER":
|
||||
return "warning";
|
||||
case "ESCALATE":
|
||||
return "info";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
export function decisionLabel(
|
||||
__: (key: string) => string,
|
||||
decision: string,
|
||||
): string {
|
||||
switch (decision) {
|
||||
case "PENDING":
|
||||
return __("Pending");
|
||||
case "APPROVED":
|
||||
return __("Approved");
|
||||
case "REVOKE":
|
||||
return __("Revoked");
|
||||
case "DEFER":
|
||||
return __("Modified");
|
||||
case "ESCALATE":
|
||||
return __("Escalated");
|
||||
default:
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
export function flagBadgeVariant(flag: string): BadgeVariant {
|
||||
switch (flag) {
|
||||
case "ORPHANED":
|
||||
case "TERMINATED_USER":
|
||||
case "CONTRACTOR_EXPIRED":
|
||||
return "danger";
|
||||
case "DORMANT":
|
||||
case "EXCESSIVE":
|
||||
case "SOD_CONFLICT":
|
||||
case "PRIVILEGED_ACCESS":
|
||||
case "ROLE_CREEP":
|
||||
case "ROLE_MISMATCH":
|
||||
return "warning";
|
||||
case "NO_BUSINESS_JUSTIFICATION":
|
||||
case "OUT_OF_DEPARTMENT":
|
||||
case "SHARED_ACCOUNT":
|
||||
case "INACTIVE":
|
||||
case "NEW":
|
||||
return "info";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
export const flagGroups = [
|
||||
{
|
||||
label: "Account",
|
||||
flags: [
|
||||
{ value: "ORPHANED" as const, label: "Orphan account" },
|
||||
{ value: "DORMANT" as const, label: "Dormant" },
|
||||
{ value: "TERMINATED_USER" as const, label: "Terminated user" },
|
||||
{ value: "CONTRACTOR_EXPIRED" as const, label: "Contractor expired" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Privileges",
|
||||
flags: [
|
||||
{ value: "EXCESSIVE" as const, label: "Excessive privileges" },
|
||||
{ value: "SOD_CONFLICT" as const, label: "SoD conflict" },
|
||||
{ value: "PRIVILEGED_ACCESS" as const, label: "Privileged access" },
|
||||
{ value: "ROLE_CREEP" as const, label: "Role creep" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Anomaly",
|
||||
flags: [
|
||||
{ value: "NO_BUSINESS_JUSTIFICATION" as const, label: "No justification" },
|
||||
{ value: "OUT_OF_DEPARTMENT" as const, label: "Out of department" },
|
||||
{ value: "SHARED_ACCOUNT" as const, label: "Shared account" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function flagLabel(flag: string): string {
|
||||
for (const group of flagGroups) {
|
||||
for (const f of group.flags) {
|
||||
if (f.value === flag) return f.label;
|
||||
}
|
||||
}
|
||||
if (flag === "NONE") return "None";
|
||||
// Legacy flag values not shown in the grouped dropdown
|
||||
if (flag === "INACTIVE") return "Inactive";
|
||||
if (flag === "ROLE_MISMATCH") return "Role mismatch";
|
||||
if (flag === "NEW") return "New";
|
||||
return flag;
|
||||
}
|
||||
|
||||
export function formatStatus(status: string): string {
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
export function NotAvailable() {
|
||||
return (
|
||||
<span className="text-xs text-txt-tertiary">N/A</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
IconPlusLarge,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { graphql, usePaginationFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
|
||||
import type { AccessReviewCampaignsTabFragment$key } from "#/__generated__/core/AccessReviewCampaignsTabFragment.graphql";
|
||||
import type { AccessReviewCampaignsTabPaginationQuery } from "#/__generated__/core/AccessReviewCampaignsTabPaginationQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { statusBadgeVariant, statusLabel } from "../_components/accessReviewHelpers";
|
||||
import { CreateAccessReviewCampaignDialog } from "../dialogs/CreateAccessReviewCampaignDialog";
|
||||
|
||||
const campaignsFragment = graphql`
|
||||
fragment AccessReviewCampaignsTabFragment on Organization
|
||||
@refetchable(queryName: "AccessReviewCampaignsTabPaginationQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 20 }
|
||||
order: {
|
||||
type: "AccessReviewCampaignOrder"
|
||||
defaultValue: { direction: DESC, field: CREATED_AT }
|
||||
}
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
) {
|
||||
accessReviewCampaigns(
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $order
|
||||
) @connection(key: "AccessReviewCampaignsTab_accessReviewCampaigns") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
status
|
||||
createdAt
|
||||
startedAt
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AccessReviewCampaignsTab() {
|
||||
const { __, dateFormat } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { organizationRef, canCreateCampaign } = useOutletContext<{
|
||||
organizationRef: AccessReviewCampaignsTabFragment$key;
|
||||
canCreateCampaign: boolean;
|
||||
}>();
|
||||
|
||||
const {
|
||||
data: { accessReviewCampaigns },
|
||||
loadNext,
|
||||
hasNext,
|
||||
isLoadingNext,
|
||||
} = usePaginationFragment<
|
||||
AccessReviewCampaignsTabPaginationQuery,
|
||||
AccessReviewCampaignsTabFragment$key
|
||||
>(campaignsFragment, organizationRef);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
{canCreateCampaign && (
|
||||
<CreateAccessReviewCampaignDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={accessReviewCampaigns.__id}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("New campaign")}
|
||||
</Button>
|
||||
</CreateAccessReviewCampaignDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accessReviewCampaigns.edges.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Status")}</Th>
|
||||
<Th>{__("Created at")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{accessReviewCampaigns.edges.map(edge => (
|
||||
<Tr
|
||||
key={edge.node.id}
|
||||
to={`/organizations/${organizationId}/access-reviews/campaigns/${edge.node.id}`}
|
||||
>
|
||||
<Td>{edge.node.name}</Td>
|
||||
<Td>
|
||||
<Badge variant={statusBadgeVariant(edge.node.status)}>
|
||||
{statusLabel(__, edge.node.status)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{dateFormat(edge.node.createdAt)}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
{hasNext && (
|
||||
<div className="p-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => loadNext(20)}
|
||||
disabled={isLoadingNext}
|
||||
>
|
||||
{isLoadingNext
|
||||
? __("Loading...")
|
||||
: __("Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-txt-tertiary">
|
||||
{__("No access review campaigns yet. Create your first campaign to start reviewing access.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatDate, formatError, type GraphQLError, sprintf } from "@probo/helpers";
|
||||
import { useList } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPlusLarge,
|
||||
IconRobot,
|
||||
Option,
|
||||
Select,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironment } from "react-relay";
|
||||
import { fetchQuery, graphql } from "relay-runtime";
|
||||
|
||||
import type { AccessEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql";
|
||||
import type { AccessEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql";
|
||||
import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql";
|
||||
import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql";
|
||||
import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import {
|
||||
decisionBadgeVariant,
|
||||
decisionLabel,
|
||||
flagBadgeVariant,
|
||||
flagGroups,
|
||||
flagLabel,
|
||||
formatStatus,
|
||||
NotAvailable,
|
||||
statusBadgeVariant,
|
||||
statusLabel,
|
||||
} from "../_components/accessReviewHelpers";
|
||||
import { EntryDecisionActions } from "../_components/EntryDecisionActions";
|
||||
import { EntryFlagSelect } from "../_components/EntryFlagSelect";
|
||||
import { AddCampaignScopeSourceDialog } from "../dialogs/AddCampaignScopeSourceDialog";
|
||||
|
||||
const startCampaignMutation = graphql`
|
||||
mutation CampaignDetailPageStartMutation(
|
||||
$input: StartAccessReviewCampaignInput!
|
||||
) {
|
||||
startAccessReviewCampaign(input: $input) {
|
||||
accessReviewCampaign {
|
||||
id
|
||||
status
|
||||
startedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const closeCampaignMutation = graphql`
|
||||
mutation CampaignDetailPageCloseMutation(
|
||||
$input: CloseAccessReviewCampaignInput!
|
||||
) {
|
||||
closeAccessReviewCampaign(input: $input) {
|
||||
accessReviewCampaign {
|
||||
id
|
||||
status
|
||||
completedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const bulkDecisionMutation = graphql`
|
||||
mutation CampaignDetailPageBulkDecisionMutation(
|
||||
$input: RecordAccessEntryDecisionsInput!
|
||||
) {
|
||||
recordAccessEntryDecisions(input: $input) {
|
||||
accessEntries {
|
||||
id
|
||||
decision
|
||||
decisionNote
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const bulkFlagMutation = graphql`
|
||||
mutation CampaignDetailPageBulkFlagMutation(
|
||||
$input: FlagAccessEntryInput!
|
||||
) {
|
||||
flagAccessEntry(input: $input) {
|
||||
accessEntry {
|
||||
id
|
||||
flags
|
||||
flagReasons
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const campaignDetailPageQuery = graphql`
|
||||
query CampaignDetailPageQuery($campaignId: ID!) {
|
||||
node(id: $campaignId) {
|
||||
__typename
|
||||
... on AccessReviewCampaign {
|
||||
id
|
||||
name
|
||||
status
|
||||
createdAt
|
||||
startedAt
|
||||
completedAt
|
||||
pendingEntryCount
|
||||
scopeSources {
|
||||
id
|
||||
source {
|
||||
id
|
||||
}
|
||||
name
|
||||
fetchStatus
|
||||
fetchedAccountsCount
|
||||
entries(first: 500) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
role
|
||||
isAdmin
|
||||
mfaStatus
|
||||
accountType
|
||||
lastLogin
|
||||
decision
|
||||
flags
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<CampaignDetailPageQuery>;
|
||||
};
|
||||
|
||||
export default function CampaignDetailPage({ queryRef }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const environment = useRelayEnvironment();
|
||||
const data = usePreloadedQuery(campaignDetailPageQuery, queryRef);
|
||||
|
||||
if (data.node.__typename !== "AccessReviewCampaign") {
|
||||
throw new Error("Campaign not found");
|
||||
}
|
||||
|
||||
const campaign = data.node;
|
||||
const { toast } = useToast();
|
||||
const isInProgress = campaign.status === "IN_PROGRESS";
|
||||
const isDraft = campaign.status === "DRAFT";
|
||||
const isPendingActions = campaign.status === "PENDING_ACTIONS";
|
||||
|
||||
const campaignIdRef = useRef(campaign.id);
|
||||
|
||||
useEffect(() => {
|
||||
campaignIdRef.current = campaign.id;
|
||||
}, [campaign.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInProgress) return;
|
||||
const interval = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
fetchQuery<CampaignDetailPageQuery>(
|
||||
environment,
|
||||
campaignDetailPageQuery,
|
||||
{ campaignId: campaignIdRef.current },
|
||||
{ fetchPolicy: "network-only" },
|
||||
).subscribe({});
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isInProgress, environment]);
|
||||
const existingScopeSourceIds = useMemo(
|
||||
() => campaign.scopeSources.flatMap(s => s.source?.id ? [s.source.id] : []),
|
||||
[campaign.scopeSources],
|
||||
);
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [startCampaign, isStarting]
|
||||
= useMutation<CampaignDetailPageStartMutation>(startCampaignMutation);
|
||||
|
||||
const [closeCampaign, isClosing]
|
||||
= useMutation<CampaignDetailPageCloseMutation>(closeCampaignMutation);
|
||||
|
||||
const allDecided = campaign.scopeSources.length > 0
|
||||
&& campaign.scopeSources.every(source =>
|
||||
source.entries
|
||||
&& source.entries.edges.length > 0
|
||||
&& source.entries.edges.every(edge => edge.node.decision !== "PENDING")
|
||||
&& !source.entries.pageInfo.hasNextPage,
|
||||
);
|
||||
|
||||
const handleStart = () => {
|
||||
startCampaign({
|
||||
variables: {
|
||||
input: {
|
||||
accessReviewCampaignId: campaign.id,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to start campaign"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Campaign started. Sources are being fetched."),
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to start campaign"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
closeCampaign({
|
||||
variables: {
|
||||
input: { accessReviewCampaignId: campaign.id },
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to complete campaign"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Campaign completed successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to complete campaign"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: __(
|
||||
"Are you sure you want to complete this campaign? This action cannot be undone. All decisions will be finalized.",
|
||||
),
|
||||
label: __("Complete"),
|
||||
variant: "primary",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
label: __("Access Reviews"),
|
||||
to: `/organizations/${organizationId}/access-reviews`,
|
||||
},
|
||||
{ label: campaign.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold">{campaign.name}</h1>
|
||||
<Badge variant={statusBadgeVariant(campaign.status)}>
|
||||
{statusLabel(__, campaign.status)}
|
||||
</Badge>
|
||||
{isPendingActions && (
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!allDecided || isClosing}
|
||||
>
|
||||
{isClosing ? __("Completing...") : __("Complete campaign")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isDraft && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<AddCampaignScopeSourceDialog
|
||||
organizationId={organizationId}
|
||||
campaignId={campaign.id}
|
||||
existingScopeSourceIds={existingScopeSourceIds}
|
||||
>
|
||||
<Button icon={IconPlusLarge} variant="secondary">
|
||||
{__("Add source")}
|
||||
</Button>
|
||||
</AddCampaignScopeSourceDialog>
|
||||
{campaign.scopeSources.length > 0 && (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
disabled={isStarting}
|
||||
>
|
||||
{isStarting ? __("Starting...") : __("Start campaign")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{campaign.scopeSources.map(source => (
|
||||
<ScopeSourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
isPendingActions={isPendingActions}
|
||||
/>
|
||||
))}
|
||||
|
||||
{campaign.scopeSources.length === 0 && (
|
||||
<Card padded>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-txt-tertiary">
|
||||
{__("No sources configured for this campaign.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ScopeSource = NonNullable<
|
||||
Extract<
|
||||
CampaignDetailPageQuery["response"]["node"],
|
||||
{ readonly __typename: "AccessReviewCampaign" }
|
||||
>["scopeSources"]
|
||||
>[number];
|
||||
|
||||
function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; isPendingActions: boolean }) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { list: selection, toggle, clear, reset } = useList<string>([]);
|
||||
const [bulkPendingDecision, setBulkPendingDecision] = useState<AccessEntryDecision | null>(null);
|
||||
const [bulkNote, setBulkNote] = useState("");
|
||||
const bulkNoteRef = useDialogRef();
|
||||
|
||||
const [bulkDecide]
|
||||
= useMutation<CampaignDetailPageBulkDecisionMutation>(bulkDecisionMutation);
|
||||
const [bulkFlag]
|
||||
= useMutation<CampaignDetailPageBulkFlagMutation>(bulkFlagMutation);
|
||||
|
||||
const entries = source.entries?.edges ?? [];
|
||||
const entryIds = entries.map(edge => edge.node.id);
|
||||
|
||||
const handleBulkDecision = (value: string) => {
|
||||
const decision = value as AccessEntryDecision;
|
||||
if (decision === "APPROVED") {
|
||||
bulkDecide({
|
||||
variables: {
|
||||
input: {
|
||||
decisions: selection.map(id => ({
|
||||
accessEntryId: id,
|
||||
decision: "APPROVED" as AccessEntryDecision,
|
||||
})),
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to record decisions"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Decisions recorded successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
clear();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to record decisions"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setBulkPendingDecision(decision);
|
||||
setBulkNote("");
|
||||
bulkNoteRef.current?.open();
|
||||
}
|
||||
};
|
||||
|
||||
const [bulkFlagSelection, setBulkFlagSelection] = useState<AccessEntryFlag[]>([]);
|
||||
const [bulkFlagOpen, setBulkFlagOpen] = useState(false);
|
||||
const bulkFlagOpenedWithRef = useRef<AccessEntryFlag[]>([]);
|
||||
|
||||
const toggleBulkFlag = (flagValue: AccessEntryFlag) => {
|
||||
setBulkFlagSelection(prev =>
|
||||
prev.includes(flagValue)
|
||||
? prev.filter(f => f !== flagValue)
|
||||
: [...prev, flagValue],
|
||||
);
|
||||
};
|
||||
|
||||
const handleBulkFlagOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
bulkFlagOpenedWithRef.current = [];
|
||||
setBulkFlagSelection([]);
|
||||
}
|
||||
|
||||
if (!nextOpen && bulkFlagSelection.length > 0) {
|
||||
let errorCount = 0;
|
||||
let completedCount = 0;
|
||||
const total = selection.length;
|
||||
|
||||
for (const entryId of selection) {
|
||||
bulkFlag({
|
||||
variables: {
|
||||
input: {
|
||||
accessEntryId: entryId,
|
||||
flags: bulkFlagSelection,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
errorCount++;
|
||||
}
|
||||
completedCount++;
|
||||
if (completedCount === total) {
|
||||
if (errorCount > 0) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: sprintf(__("Failed to update flags for %d entries."), errorCount),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Flags updated for selected entries."),
|
||||
variant: "success",
|
||||
});
|
||||
}
|
||||
clear();
|
||||
}
|
||||
},
|
||||
onError() {
|
||||
errorCount++;
|
||||
completedCount++;
|
||||
if (completedCount === total) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: sprintf(__("Failed to update flags for %d entries."), errorCount),
|
||||
variant: "error",
|
||||
});
|
||||
clear();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setBulkFlagOpen(nextOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-4 text-left hover:bg-bg-subtle transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expanded
|
||||
? <IconChevronDown className="size-4 text-txt-tertiary" />
|
||||
: <IconChevronRight className="size-4 text-txt-tertiary" />}
|
||||
<span className="font-medium">{source.name}</span>
|
||||
<Badge variant="neutral">
|
||||
{source.fetchedAccountsCount}
|
||||
{" "}
|
||||
{__("accounts")}
|
||||
</Badge>
|
||||
<Badge variant={source.fetchStatus === "SUCCESS" ? "success" : "info"}>
|
||||
{formatStatus(source.fetchStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t">
|
||||
{entries.length === 0
|
||||
? (
|
||||
<div className="px-4 py-6 text-center text-txt-tertiary">
|
||||
{__("No entries found for this source.")}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table className="w-full text-left">
|
||||
<Thead>
|
||||
<Tr>
|
||||
{isPendingActions && (
|
||||
<Th className="w-12">
|
||||
<Checkbox
|
||||
checked={selection.length === entryIds.length && entryIds.length > 0}
|
||||
onChange={() => selection.length === entryIds.length ? clear() : reset(entryIds)}
|
||||
/>
|
||||
</Th>
|
||||
)}
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Email")}</Th>
|
||||
<Th>{__("Role")}</Th>
|
||||
<Th>{__("Admin")}</Th>
|
||||
<Th>{__("MFA")}</Th>
|
||||
<Th>{__("Last login")}</Th>
|
||||
<Th>{__("Flag")}</Th>
|
||||
<Th>{__("Decision")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{entries.map(edge => (
|
||||
<Tr key={edge.node.id}>
|
||||
{isPendingActions && (
|
||||
<Td noLink>
|
||||
<Checkbox
|
||||
checked={selection.includes(edge.node.id)}
|
||||
onChange={() => toggle(edge.node.id)}
|
||||
/>
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{edge.node.accountType === "SERVICE_ACCOUNT" && (
|
||||
<IconRobot size={16} className="text-txt-tertiary shrink-0" />
|
||||
)}
|
||||
{edge.node.fullName || <NotAvailable />}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>{edge.node.email || <NotAvailable />}</Td>
|
||||
<Td>{edge.node.role || <NotAvailable />}</Td>
|
||||
<Td>{edge.node.isAdmin ? __("Yes") : __("No")}</Td>
|
||||
<Td>
|
||||
{edge.node.mfaStatus === "UNKNOWN"
|
||||
? <NotAvailable />
|
||||
: (
|
||||
<Badge variant={edge.node.mfaStatus === "ENABLED" ? "success" : "neutral"}>
|
||||
{formatStatus(edge.node.mfaStatus)}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{edge.node.lastLogin
|
||||
? formatDate(edge.node.lastLogin)
|
||||
: <NotAvailable />}
|
||||
</Td>
|
||||
<Td>
|
||||
{isPendingActions
|
||||
? (
|
||||
<EntryFlagSelect
|
||||
entryId={edge.node.id}
|
||||
currentFlags={edge.node.flags}
|
||||
/>
|
||||
)
|
||||
: edge.node.flags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{edge.node.flags.map(f => (
|
||||
<Badge key={f} variant={flagBadgeVariant(f)}>
|
||||
{flagLabel(f)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{isPendingActions
|
||||
? (
|
||||
<EntryDecisionActions
|
||||
entryId={edge.node.id}
|
||||
decision={edge.node.decision}
|
||||
/>
|
||||
)
|
||||
: edge.node.decision !== "PENDING" && (
|
||||
<Badge variant={decisionBadgeVariant(edge.node.decision)}>
|
||||
{decisionLabel(__, edge.node.decision)}
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selection.length > 0 && (
|
||||
<div className="flex items-center gap-4 p-4 border-t">
|
||||
<span className="text-sm text-txt-secondary">
|
||||
{selection.length}
|
||||
{" "}
|
||||
{__("selected")}
|
||||
</span>
|
||||
<Button variant="secondary" onClick={clear}>
|
||||
{__("Clear")}
|
||||
</Button>
|
||||
<Select
|
||||
variant="editor"
|
||||
placeholder={__("Set decision...")}
|
||||
onValueChange={handleBulkDecision}
|
||||
>
|
||||
<Option value="APPROVED">{__("Approve")}</Option>
|
||||
<Option value="REVOKE">{__("Revoke")}</Option>
|
||||
<Option value="DEFER">{__("Modify")}</Option>
|
||||
<Option value="ESCALATE">{__("Escalate")}</Option>
|
||||
</Select>
|
||||
<Popover.Root open={bulkFlagOpen} onOpenChange={handleBulkFlagOpenChange}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button variant="secondary">
|
||||
{bulkFlagSelection.length > 0
|
||||
? `${bulkFlagSelection.length} ${__("flags")}`
|
||||
: __("Set flags...")}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
sideOffset={5}
|
||||
className="z-100 w-64 rounded-[10px] bg-level-1 p-2 shadow-mid animate-in fade-in slide-in-from-top-2"
|
||||
>
|
||||
{flagGroups.map(group => (
|
||||
<div key={group.label} className="mb-2 last:mb-0">
|
||||
<div className="px-2 py-1 text-xs font-semibold text-txt-tertiary uppercase tracking-wider">
|
||||
{__(group.label)}
|
||||
</div>
|
||||
{group.flags.map(flag => (
|
||||
<label
|
||||
key={flag.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-tertiary-hover"
|
||||
>
|
||||
<Checkbox
|
||||
checked={bulkFlagSelection.includes(flag.value)}
|
||||
onChange={() => toggleBulkFlag(flag.value)}
|
||||
/>
|
||||
<span className="text-sm text-txt-primary">{__(flag.label)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog ref={bulkNoteRef} title={__("Decision note")}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Please provide a reason for this decision.")}
|
||||
</p>
|
||||
<Field
|
||||
label={__("Note")}
|
||||
type="textarea"
|
||||
value={bulkNote}
|
||||
onValueChange={setBulkNote}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={!bulkNote.trim()}
|
||||
onClick={() => {
|
||||
if (bulkPendingDecision) {
|
||||
bulkDecide({
|
||||
variables: {
|
||||
input: {
|
||||
decisions: selection.map(id => ({
|
||||
accessEntryId: id,
|
||||
decision: bulkPendingDecision,
|
||||
decisionNote: bulkNote,
|
||||
})),
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to record decisions"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Decisions recorded successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
clear();
|
||||
setBulkPendingDecision(null);
|
||||
setBulkNote("");
|
||||
bulkNoteRef.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to record decisions"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{__("Confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
|
||||
{source.entries?.pageInfo.hasNextPage && (
|
||||
<div className="p-4 border-t text-center">
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{sprintf(__("Showing first %d entries. Use the CLI for the full list."), entries.length)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
|
||||
import CampaignDetailPage, { campaignDetailPageQuery } from "./CampaignDetailPage";
|
||||
|
||||
export default function CampaignDetailPageLoader() {
|
||||
const { campaignId } = useParams<{ campaignId: string }>();
|
||||
const [queryRef, loadQuery] = useQueryLoader<CampaignDetailPageQuery>(campaignDetailPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (campaignId) {
|
||||
loadQuery({ campaignId });
|
||||
}
|
||||
}, [loadQuery, campaignId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<CampaignDetailPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DropdownItem,
|
||||
Field,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
VendorLogo,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { Link } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { AccessReviewLayoutQuery$data } from "#/__generated__/core/AccessReviewLayoutQuery.graphql";
|
||||
import type { AddAccessSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateAPIKeyConnectorMutation.graphql";
|
||||
import type { AddAccessSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateClientCredentialsConnectorMutation.graphql";
|
||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
||||
|
||||
import { createAccessSourceMutation } from "./CreateAccessSourceDialog";
|
||||
|
||||
type OrganizationData = Extract<
|
||||
AccessReviewLayoutQuery$data["organization"],
|
||||
{ readonly __typename: "Organization" }
|
||||
>;
|
||||
|
||||
export type ProviderInfo = OrganizationData["connectorProviderInfos"][number];
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
providerInfos: ReadonlyArray<ProviderInfo>;
|
||||
existingSourceProviders: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
const createAPIKeyConnectorMutation = graphql`
|
||||
mutation AddAccessSourceDialogCreateAPIKeyConnectorMutation(
|
||||
$input: CreateAPIKeyConnectorInput!
|
||||
) {
|
||||
createAPIKeyConnector(input: $input) {
|
||||
connector {
|
||||
id
|
||||
provider
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createClientCredentialsConnectorMutation = graphql`
|
||||
mutation AddAccessSourceDialogCreateClientCredentialsConnectorMutation(
|
||||
$input: CreateClientCredentialsConnectorInput!
|
||||
) {
|
||||
createClientCredentialsConnector(input: $input) {
|
||||
connector {
|
||||
id
|
||||
provider
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function mapAPIKeyExtraSettingToField(
|
||||
provider: string,
|
||||
settingKey: string,
|
||||
): string | null {
|
||||
switch (provider) {
|
||||
case "TALLY":
|
||||
if (settingKey === "organizationId") return "tallyOrganizationId";
|
||||
break;
|
||||
case "SENTRY":
|
||||
if (settingKey === "organizationSlug") return "sentryOrganizationSlug";
|
||||
break;
|
||||
case "SUPABASE":
|
||||
if (settingKey === "organizationSlug") return "supabaseOrganizationSlug";
|
||||
break;
|
||||
case "GITHUB":
|
||||
if (settingKey === "organization") return "githubOrganization";
|
||||
break;
|
||||
case "ONE_PASSWORD":
|
||||
if (settingKey === "scimBridgeUrl") return "onePasswordScimBridgeUrl";
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapClientCredentialsExtraSettingToField(
|
||||
provider: string,
|
||||
settingKey: string,
|
||||
): string | null {
|
||||
switch (provider) {
|
||||
case "ONE_PASSWORD":
|
||||
if (settingKey === "accountId") return "onePasswordAccountId";
|
||||
if (settingKey === "region") return "onePasswordRegion";
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasRequiredExtraSettings(
|
||||
settings: ReadonlyArray<{ readonly key: string; readonly required: boolean }>,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
return settings
|
||||
.filter(s => s.required)
|
||||
.every(s => values[s.key]?.trim());
|
||||
}
|
||||
|
||||
export function AddAccessSourceDialog({
|
||||
children,
|
||||
organizationId,
|
||||
connectionId,
|
||||
providerInfos,
|
||||
existingSourceProviders,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const dialogRef = useDialogRef();
|
||||
const apiKeyDialogRef = useDialogRef();
|
||||
const clientCredentialsDialogRef = useDialogRef();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeProvider, setActiveProvider] = useState<ProviderInfo | null>(null);
|
||||
|
||||
const [apiKeyValue, setApiKeyValue] = useState("");
|
||||
const [extraSettingValues, setExtraSettingValues] = useState<Record<string, string>>({});
|
||||
const [isConnectingAPIKey, setIsConnectingAPIKey] = useState(false);
|
||||
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [tokenUrl, setTokenUrl] = useState("");
|
||||
const [scope, setScope] = useState("");
|
||||
const [clientCredentialsExtraValues, setClientCredentialsExtraValues] = useState<Record<string, string>>({});
|
||||
const [isConnectingClientCredentials, setIsConnectingClientCredentials] = useState(false);
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
const sorted = [...providerInfos].sort((a, b) =>
|
||||
a.displayName.localeCompare(b.displayName),
|
||||
);
|
||||
if (!searchQuery.trim()) return sorted;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return sorted.filter(
|
||||
info => info.displayName.toLowerCase().includes(q),
|
||||
);
|
||||
}, [providerInfos, searchQuery]);
|
||||
|
||||
const connectedProviders = useMemo(
|
||||
() => new Set(existingSourceProviders),
|
||||
[existingSourceProviders],
|
||||
);
|
||||
|
||||
const [createAccessSource]
|
||||
= useMutation<CreateAccessSourceDialogMutation>(
|
||||
createAccessSourceMutation,
|
||||
);
|
||||
const [createAPIKeyConnector]
|
||||
= useMutation<AddAccessSourceDialogCreateAPIKeyConnectorMutation>(
|
||||
createAPIKeyConnectorMutation,
|
||||
);
|
||||
const [createClientCredentialsConnector]
|
||||
= useMutation<AddAccessSourceDialogCreateClientCredentialsConnectorMutation>(
|
||||
createClientCredentialsConnectorMutation,
|
||||
);
|
||||
|
||||
const connectOAuthProvider = (provider: string) => {
|
||||
const baseURL = import.meta.env.VITE_API_URL || window.location.origin;
|
||||
const url = new URL("/api/console/v1/connectors/initiate", baseURL);
|
||||
url.searchParams.append("organization_id", organizationId);
|
||||
url.searchParams.append("provider", provider);
|
||||
url.searchParams.append(
|
||||
"continue",
|
||||
`/organizations/${organizationId}/access-reviews/sources`,
|
||||
);
|
||||
window.location.assign(url.toString());
|
||||
};
|
||||
|
||||
const openAPIKeyDialog = (info: ProviderInfo) => {
|
||||
setActiveProvider(info);
|
||||
setApiKeyValue("");
|
||||
setExtraSettingValues({});
|
||||
apiKeyDialogRef.current?.open();
|
||||
};
|
||||
|
||||
const openClientCredentialsDialog = (info: ProviderInfo) => {
|
||||
setActiveProvider(info);
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
setTokenUrl("");
|
||||
setScope("");
|
||||
setClientCredentialsExtraValues({});
|
||||
clientCredentialsDialogRef.current?.open();
|
||||
};
|
||||
|
||||
const createSourceAfterConnector = (
|
||||
connectorId: string,
|
||||
displayName: string,
|
||||
onDone: () => void,
|
||||
) => {
|
||||
createAccessSource({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
connectorId,
|
||||
name: displayName,
|
||||
csvData: null,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
onDone();
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Access source created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
onDone();
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const connectAPIKeyProvider = () => {
|
||||
if (!activeProvider || !apiKeyValue.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredSettings = activeProvider.extraSettings.filter(s => s.required);
|
||||
if (!hasRequiredExtraSettings(requiredSettings, extraSettingValues)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnectingAPIKey(true);
|
||||
|
||||
const extraFields: Record<string, string> = {};
|
||||
for (const setting of activeProvider.extraSettings) {
|
||||
const value = extraSettingValues[setting.key]?.trim();
|
||||
if (value) {
|
||||
const fieldName = mapAPIKeyExtraSettingToField(activeProvider.provider, setting.key);
|
||||
if (fieldName) {
|
||||
extraFields[fieldName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createAPIKeyConnector({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
provider: activeProvider.provider,
|
||||
apiKey: apiKeyValue.trim(),
|
||||
...extraFields,
|
||||
},
|
||||
},
|
||||
onCompleted: (response) => {
|
||||
const connectorId = response.createAPIKeyConnector.connector.id;
|
||||
createSourceAfterConnector(
|
||||
connectorId,
|
||||
activeProvider.displayName,
|
||||
() => {
|
||||
setIsConnectingAPIKey(false);
|
||||
setApiKeyValue("");
|
||||
setExtraSettingValues({});
|
||||
setActiveProvider(null);
|
||||
apiKeyDialogRef.current?.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
setIsConnectingAPIKey(false);
|
||||
toast({
|
||||
title: __("Connection failed"),
|
||||
description: __("Failed to connect provider. Please check your API key and try again."),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const connectClientCredentialsProvider = () => {
|
||||
if (!activeProvider || !clientId.trim() || !clientSecret.trim() || !tokenUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredSettings = activeProvider.extraSettings.filter(s => s.required);
|
||||
if (!hasRequiredExtraSettings(requiredSettings, clientCredentialsExtraValues)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnectingClientCredentials(true);
|
||||
|
||||
const extraFields: Record<string, string> = {};
|
||||
for (const setting of activeProvider.extraSettings) {
|
||||
const value = clientCredentialsExtraValues[setting.key]?.trim();
|
||||
if (value) {
|
||||
const fieldName = mapClientCredentialsExtraSettingToField(
|
||||
activeProvider.provider,
|
||||
setting.key,
|
||||
);
|
||||
if (fieldName) {
|
||||
extraFields[fieldName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createClientCredentialsConnector({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
provider: activeProvider.provider,
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim(),
|
||||
tokenUrl: tokenUrl.trim(),
|
||||
scope: scope.trim() || null,
|
||||
...extraFields,
|
||||
},
|
||||
},
|
||||
onCompleted: (response) => {
|
||||
const connector = response.createClientCredentialsConnector?.connector;
|
||||
if (!connector) {
|
||||
setIsConnectingClientCredentials(false);
|
||||
toast({
|
||||
title: __("Connection failed"),
|
||||
description: __("Failed to connect provider. Please check your credentials and try again."),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createSourceAfterConnector(
|
||||
connector.id,
|
||||
activeProvider.displayName,
|
||||
() => {
|
||||
setIsConnectingClientCredentials(false);
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
setTokenUrl("");
|
||||
setScope("");
|
||||
setClientCredentialsExtraValues({});
|
||||
setActiveProvider(null);
|
||||
clientCredentialsDialogRef.current?.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
setIsConnectingClientCredentials(false);
|
||||
toast({
|
||||
title: __("Connection failed"),
|
||||
description: __("Failed to connect provider. Please check your credentials and try again."),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderProviderCard = (info: ProviderInfo) => {
|
||||
const isConnected = connectedProviders.has(info.provider);
|
||||
|
||||
const hasSecondaryOptions = info.oauthConfigured
|
||||
&& (info.apiKeySupported || info.clientCredentialsSupported);
|
||||
|
||||
const renderPrimaryButton = () => {
|
||||
if (info.oauthConfigured) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => connectOAuthProvider(info.provider)}
|
||||
>
|
||||
{__("Connect")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (info.apiKeySupported) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openAPIKeyDialog(info)}
|
||||
>
|
||||
{__("API Key")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (info.clientCredentialsSupported) {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openClientCredentialsDialog(info)}
|
||||
>
|
||||
{__("Client Credentials")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={info.provider} padded className="flex items-center gap-3">
|
||||
<VendorLogo vendor={info.provider} tint className="size-6 shrink-0" />
|
||||
<div className="mr-auto">
|
||||
<h3 className="font-medium">{info.displayName}</h3>
|
||||
</div>
|
||||
{isConnected
|
||||
? (
|
||||
<Badge variant="success" size="md">
|
||||
{__("Connected")}
|
||||
</Badge>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-2">
|
||||
{renderPrimaryButton()}
|
||||
{hasSecondaryOptions && (
|
||||
<ActionDropdown variant="secondary">
|
||||
{info.apiKeySupported && (
|
||||
<DropdownItem
|
||||
onSelect={() => openAPIKeyDialog(info)}
|
||||
>
|
||||
{__("Connect with API Key")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{info.clientCredentialsSupported && (
|
||||
<DropdownItem
|
||||
onSelect={() => openClientCredentialsDialog(info)}
|
||||
>
|
||||
{__("Connect with Client Credentials")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const apiKeyExtraSettingsValid = activeProvider
|
||||
? hasRequiredExtraSettings(activeProvider.extraSettings, extraSettingValues)
|
||||
: true;
|
||||
|
||||
const clientCredentialsExtraSettingsValid = activeProvider
|
||||
? hasRequiredExtraSettings(activeProvider.extraSettings, clientCredentialsExtraValues)
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
__("Access Reviews"),
|
||||
__("Add Source"),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Input
|
||||
placeholder={__("Search providers...")}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredProviders.map(info => renderProviderCard(info))}
|
||||
|
||||
{(!searchQuery.trim() || "csv".includes(searchQuery.toLowerCase())) && (
|
||||
<Card padded className="flex items-center gap-3">
|
||||
<div className="mr-auto">
|
||||
<h3 className="font-medium">{__("CSV")}</h3>
|
||||
<p className="text-sm text-txt-secondary">
|
||||
{__("Upload CSV data directly as an access source.")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
asChild
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
>
|
||||
<Link to={`/organizations/${organizationId}/access-reviews/sources/new/csv`}>
|
||||
{__("Open")}
|
||||
</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter exitLabel={__("Close")} />
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
ref={apiKeyDialogRef}
|
||||
title={activeProvider
|
||||
? sprintf(__("Connect %s"), activeProvider.displayName)
|
||||
: __("Connect provider")}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
connectAPIKeyProvider();
|
||||
}}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{sprintf(
|
||||
__("Enter the API key for %s to connect it as an access source."),
|
||||
activeProvider?.displayName ?? "",
|
||||
)}
|
||||
</p>
|
||||
<Field
|
||||
label={__("API Key")}
|
||||
type="password"
|
||||
value={apiKeyValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setApiKeyValue(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{activeProvider?.extraSettings.map(setting => (
|
||||
<Field
|
||||
key={setting.key}
|
||||
label={__(setting.label)}
|
||||
value={extraSettingValues[setting.key] ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setExtraSettingValues(prev => ({
|
||||
...prev,
|
||||
[setting.key]: e.target.value,
|
||||
}))}
|
||||
required={setting.required}
|
||||
/>
|
||||
))}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isConnectingAPIKey
|
||||
|| !apiKeyValue.trim()
|
||||
|| !apiKeyExtraSettingsValid
|
||||
}
|
||||
>
|
||||
{isConnectingAPIKey ? __("Connecting...") : __("Connect")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
ref={clientCredentialsDialogRef}
|
||||
title={activeProvider
|
||||
? sprintf(__("Connect %s"), activeProvider.displayName)
|
||||
: __("Connect provider")}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
connectClientCredentialsProvider();
|
||||
}}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{sprintf(
|
||||
__("Enter the client credentials for %s to connect it as an access source."),
|
||||
activeProvider?.displayName ?? "",
|
||||
)}
|
||||
</p>
|
||||
<Field
|
||||
label={__("Client ID")}
|
||||
value={clientId}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientId(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Field
|
||||
label={__("Client Secret")}
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientSecret(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={__("Token URL")}
|
||||
value={tokenUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTokenUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={__("Scope")}
|
||||
value={scope}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setScope(e.target.value)}
|
||||
/>
|
||||
{activeProvider?.extraSettings.map(setting =>
|
||||
setting.key === "region"
|
||||
? (
|
||||
<div key={setting.key} className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{__(setting.label)}</label>
|
||||
<Select
|
||||
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
||||
onValueChange={(val: string) =>
|
||||
setClientCredentialsExtraValues(prev => ({
|
||||
...prev,
|
||||
[setting.key]: val,
|
||||
}))}
|
||||
placeholder={__("Select a region")}
|
||||
>
|
||||
<Option value="US">United States (US)</Option>
|
||||
<Option value="CA">Canada (CA)</Option>
|
||||
<Option value="EU">Europe (EU)</Option>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<Field
|
||||
key={setting.key}
|
||||
label={__(setting.label)}
|
||||
value={clientCredentialsExtraValues[setting.key] ?? ""}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setClientCredentialsExtraValues(prev => ({
|
||||
...prev,
|
||||
[setting.key]: e.target.value,
|
||||
}))}
|
||||
required={setting.required}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isConnectingClientCredentials
|
||||
|| !clientId.trim()
|
||||
|| !clientSecret.trim()
|
||||
|| !tokenUrl.trim()
|
||||
|| !clientCredentialsExtraSettingsValid
|
||||
}
|
||||
>
|
||||
{isConnectingClientCredentials ? __("Connecting...") : __("Connect")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Option,
|
||||
Select,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useState } from "react";
|
||||
import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
|
||||
|
||||
import type { AddCampaignScopeSourceDialogMutation } from "#/__generated__/core/AddCampaignScopeSourceDialogMutation.graphql";
|
||||
import type { AddCampaignScopeSourceDialogSourcesQuery } from "#/__generated__/core/AddCampaignScopeSourceDialogSourcesQuery.graphql";
|
||||
|
||||
const addScopeMutation = graphql`
|
||||
mutation AddCampaignScopeSourceDialogMutation(
|
||||
$input: AddAccessReviewCampaignScopeSourceInput!
|
||||
) {
|
||||
addAccessReviewCampaignScopeSource(input: $input) {
|
||||
accessReviewCampaign {
|
||||
id
|
||||
scopeSources {
|
||||
id
|
||||
name
|
||||
fetchStatus
|
||||
fetchedAccountsCount
|
||||
entries(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
email
|
||||
fullName
|
||||
role
|
||||
isAdmin
|
||||
mfaStatus
|
||||
lastLogin
|
||||
decision
|
||||
flags
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sourcesQuery = graphql`
|
||||
query AddCampaignScopeSourceDialogSourcesQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
accessSources(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
campaignId: string;
|
||||
existingScopeSourceIds: string[];
|
||||
};
|
||||
|
||||
export function AddCampaignScopeSourceDialog({
|
||||
children,
|
||||
organizationId,
|
||||
campaignId,
|
||||
existingScopeSourceIds,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const ref = useDialogRef();
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string>("");
|
||||
|
||||
const [addScopeSource, isAdding]
|
||||
= useMutation<AddCampaignScopeSourceDialogMutation>(addScopeMutation);
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!selectedSourceId) return;
|
||||
|
||||
addScopeSource({
|
||||
variables: {
|
||||
input: {
|
||||
accessReviewCampaignId: campaignId,
|
||||
accessSourceId: selectedSourceId,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to add source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Source added to campaign."),
|
||||
variant: "success",
|
||||
});
|
||||
setSelectedSourceId("");
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to add source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
trigger={children}
|
||||
title={
|
||||
<Breadcrumb items={[__("Campaign"), __("Add Source")]} />
|
||||
}
|
||||
>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Select disabled placeholder={__("Loading...")} />
|
||||
}
|
||||
>
|
||||
<SourceSelect
|
||||
organizationId={organizationId}
|
||||
existingScopeSourceIds={existingScopeSourceIds}
|
||||
value={selectedSourceId}
|
||||
onChange={setSelectedSourceId}
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={isAdding || !selectedSourceId}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{__("Add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSelect({
|
||||
organizationId,
|
||||
existingScopeSourceIds,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
organizationId: string;
|
||||
existingScopeSourceIds: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data
|
||||
= useLazyLoadQuery<AddCampaignScopeSourceDialogSourcesQuery>(
|
||||
sourcesQuery,
|
||||
{ organizationId },
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
|
||||
const sources
|
||||
= data?.organization?.accessSources?.edges
|
||||
?.map(edge => edge.node)
|
||||
.filter(
|
||||
(node): node is NonNullable<typeof node> =>
|
||||
node !== null && !existingScopeSourceIds.includes(node.id),
|
||||
) ?? [];
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-txt-tertiary">
|
||||
{__("All available sources are already added to this campaign.")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={__("Select a source")}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
{sources.map(source => (
|
||||
<Option key={source.id} value={source.id}>
|
||||
{source.name}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useState } from "react";
|
||||
import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateAccessReviewCampaignDialogMutation } from "#/__generated__/core/CreateAccessReviewCampaignDialogMutation.graphql";
|
||||
import type { CreateAccessReviewCampaignDialogSourcesQuery } from "#/__generated__/core/CreateAccessReviewCampaignDialogSourcesQuery.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
|
||||
const createCampaignMutation = graphql`
|
||||
mutation CreateAccessReviewCampaignDialogMutation(
|
||||
$input: CreateAccessReviewCampaignInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createAccessReviewCampaign(input: $input) {
|
||||
accessReviewCampaignEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sourcesQuery = graphql`
|
||||
query CreateAccessReviewCampaignDialogSourcesQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
accessSources(first: 500) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
export function CreateAccessReviewCampaignDialog({
|
||||
children,
|
||||
organizationId,
|
||||
connectionId,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const ref = useDialogRef();
|
||||
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>([]);
|
||||
const { register, handleSubmit, reset, formState } = useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [createCampaign, isCreating]
|
||||
= useMutation<CreateAccessReviewCampaignDialogMutation>(
|
||||
createCampaignMutation,
|
||||
);
|
||||
|
||||
const toggleSource = (sourceId: string) => {
|
||||
setSelectedSourceIds(prev =>
|
||||
prev.includes(sourceId)
|
||||
? prev.filter(id => id !== sourceId)
|
||||
: [...prev, sourceId],
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
createCampaign({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
accessSourceIds:
|
||||
selectedSourceIds.length > 0 ? selectedSourceIds : null,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create campaign"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Campaign created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
reset();
|
||||
setSelectedSourceIds([]);
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create campaign"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
setSelectedSourceIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
trigger={children}
|
||||
onClose={handleClose}
|
||||
title={(
|
||||
<Breadcrumb
|
||||
items={[__("Access Reviews"), __("New Campaign")]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Field
|
||||
label={__("Name")}
|
||||
{...register("name")}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={__("Description")}
|
||||
{...register("description")}
|
||||
type="textarea"
|
||||
/>
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="text-sm text-txt-tertiary">
|
||||
{__("Loading sources...")}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<SourceSelector
|
||||
organizationId={organizationId}
|
||||
selectedSourceIds={selectedSourceIds}
|
||||
onToggle={toggleSource}
|
||||
/>
|
||||
</Suspense>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button disabled={isCreating || formState.isSubmitting} type="submit">
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceSelector({
|
||||
organizationId,
|
||||
selectedSourceIds,
|
||||
onToggle,
|
||||
}: {
|
||||
organizationId: string;
|
||||
selectedSourceIds: string[];
|
||||
onToggle: (sourceId: string) => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const data = useLazyLoadQuery<CreateAccessReviewCampaignDialogSourcesQuery>(
|
||||
sourcesQuery,
|
||||
{ organizationId },
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
|
||||
const sources
|
||||
= data?.organization?.accessSources?.edges
|
||||
?.map(edge => edge.node)
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null) ?? [];
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-txt-tertiary">
|
||||
{__("No sources available. Add sources in the Sources tab first.")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium mb-2">{__("Sources")}</legend>
|
||||
<div className="space-y-2">
|
||||
{sources.map(source => (
|
||||
<label
|
||||
key={source.id}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSourceIds.includes(source.id)}
|
||||
onChange={() => onToggle(source.id)}
|
||||
/>
|
||||
<span className="text-sm">{source.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
Option,
|
||||
Select,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, useEffect, useMemo } from "react";
|
||||
import { Controller, useWatch } from "react-hook-form";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
|
||||
export const createAccessSourceMutation = graphql`
|
||||
mutation CreateAccessSourceDialogMutation(
|
||||
$input: CreateAccessSourceInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createAccessSource(input: $input) {
|
||||
accessSourceEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
...AccessSourceRowFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
connectors: ReadonlyArray<{
|
||||
readonly id: string;
|
||||
readonly provider: "GOOGLE_WORKSPACE" | "LINEAR" | "SLACK";
|
||||
readonly createdAt: string;
|
||||
}>;
|
||||
preselectedConnectorId: string | null;
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1),
|
||||
sourceType: z.enum(["CSV", "OAUTH2"]),
|
||||
provider: z.enum(["GOOGLE_WORKSPACE", "LINEAR", "SLACK"]).optional(),
|
||||
connectorId: z.string().optional(),
|
||||
csvData: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.sourceType === "CSV" && !data.csvData?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["csvData"],
|
||||
message: "CSV data is required for CSV sources.",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.sourceType === "OAUTH2") {
|
||||
if (!data.provider) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["provider"],
|
||||
message: "Provider is required for OAuth2 sources.",
|
||||
});
|
||||
}
|
||||
if (!data.connectorId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["connectorId"],
|
||||
message: "Connector is required for OAuth2 sources.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function providerLabel(provider: "GOOGLE_WORKSPACE" | "LINEAR" | "SLACK") {
|
||||
switch (provider) {
|
||||
case "GOOGLE_WORKSPACE":
|
||||
return "Google Workspace";
|
||||
case "LINEAR":
|
||||
return "Linear";
|
||||
case "SLACK":
|
||||
return "Slack";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateAccessSourceDialog({
|
||||
children,
|
||||
organizationId,
|
||||
connectionId,
|
||||
connectors,
|
||||
preselectedConnectorId,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const preselectedConnector = useMemo(
|
||||
() => connectors.find(connector => connector.id === preselectedConnectorId),
|
||||
[connectors, preselectedConnectorId],
|
||||
);
|
||||
const { control, register, handleSubmit, reset, setValue }
|
||||
= useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
name: "",
|
||||
sourceType: preselectedConnector ? "OAUTH2" : "CSV",
|
||||
provider: preselectedConnector?.provider ?? "GOOGLE_WORKSPACE",
|
||||
connectorId: preselectedConnector?.id,
|
||||
csvData: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
const sourceType = useWatch({ control, name: "sourceType" });
|
||||
const provider = useWatch({ control, name: "provider" });
|
||||
const connectorId = useWatch({ control, name: "connectorId" });
|
||||
const ref = useDialogRef();
|
||||
|
||||
const providerConnectors = useMemo(
|
||||
() => connectors,
|
||||
[connectors],
|
||||
);
|
||||
const selectableConnectors = useMemo(
|
||||
() =>
|
||||
providerConnectors.filter(
|
||||
connector => !provider || connector.provider === provider,
|
||||
),
|
||||
[provider, providerConnectors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider) {
|
||||
setValue("connectorId", undefined);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
connectorId
|
||||
&& !selectableConnectors.some(connector => connector.id === connectorId)
|
||||
) {
|
||||
setValue("connectorId", undefined);
|
||||
}
|
||||
}, [provider, connectorId, selectableConnectors, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preselectedConnector) return;
|
||||
setValue("sourceType", "OAUTH2");
|
||||
setValue("provider", preselectedConnector.provider);
|
||||
setValue("connectorId", preselectedConnector.id);
|
||||
}, [preselectedConnector, setValue]);
|
||||
|
||||
const [createAccessSource, isCreating]
|
||||
= useMutation<CreateAccessSourceDialogMutation>(
|
||||
createAccessSourceMutation,
|
||||
);
|
||||
|
||||
const clearConnectorQueryParam = () => {
|
||||
if (!searchParams.get("connector_id")) {
|
||||
return;
|
||||
}
|
||||
setSearchParams((params) => {
|
||||
params.delete("connector_id");
|
||||
return params;
|
||||
});
|
||||
};
|
||||
|
||||
const startOAuthConnection = () => {
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL || window.location.origin;
|
||||
const url = new URL("/api/console/v1/connectors/initiate", baseURL);
|
||||
url.searchParams.append("organization_id", organizationId);
|
||||
url.searchParams.append("provider", provider);
|
||||
url.searchParams.append("continue", `/organizations/${organizationId}/access-reviews`);
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const isOAuth = data.sourceType === "OAUTH2";
|
||||
createAccessSource({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
connectorId: isOAuth ? data.connectorId : null,
|
||||
name: data.name,
|
||||
csvData: isOAuth ? null : (data.csvData || null),
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Access source created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
clearConnectorQueryParam();
|
||||
reset();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ref={ref}
|
||||
trigger={children}
|
||||
title={(
|
||||
<Breadcrumb
|
||||
items={[
|
||||
__("Access Reviews"),
|
||||
__("New Access Source"),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Field
|
||||
label={__("Name")}
|
||||
{...register("name")}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
<Field label={__("Source type")}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sourceType"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Option value="CSV">{__("CSV Upload")}</Option>
|
||||
<Option value="OAUTH2">{__("OAuth2 Connector")}</Option>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{sourceType === "OAUTH2" && (
|
||||
<>
|
||||
<Field label={__("Provider")}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
setValue("connectorId", undefined);
|
||||
}}
|
||||
>
|
||||
<Option value="GOOGLE_WORKSPACE">{__("Google Workspace")}</Option>
|
||||
<Option value="LINEAR">{__("Linear")}</Option>
|
||||
<Option value="SLACK">{__("Slack")}</Option>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={__("Connector")}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="connectorId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
{selectableConnectors.map(connector => (
|
||||
<Option key={connector.id} value={connector.id}>
|
||||
{`${providerLabel(connector.provider)} (${new Date(connector.createdAt).toLocaleDateString(undefined, { month: "short", year: "numeric" })})`}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("Need a new connection? Connect your provider and come back to continue creating this source.")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={startOAuthConnection}
|
||||
disabled={!provider}
|
||||
>
|
||||
{__("Connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sourceType === "CSV" && (
|
||||
<>
|
||||
<Field
|
||||
label={__("CSV Data")}
|
||||
{...register("csvData")}
|
||||
type="textarea"
|
||||
placeholder="email,full_name,role,job_title,is_admin,active,external_id"
|
||||
/>
|
||||
<p className="text-txt-secondary text-sm">
|
||||
{__("Paste CSV content with a header row. Supported columns: email, full_name, role, job_title, is_admin, active, external_id.")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button disabled={isCreating} type="submit">
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
IconPlusLarge,
|
||||
Table,
|
||||
Tbody,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { graphql, useMutation, usePaginationFragment } from "react-relay";
|
||||
import { useOutletContext, useSearchParams } from "react-router";
|
||||
|
||||
import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/AccessReviewSourcesTabFragment.graphql";
|
||||
import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql";
|
||||
import type { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { AccessSourceRow } from "../_components/AccessSourceRow";
|
||||
import { AddAccessSourceDialog, type ProviderInfo } from "../dialogs/AddAccessSourceDialog";
|
||||
import { createAccessSourceMutation } from "../dialogs/CreateAccessSourceDialog";
|
||||
|
||||
const sourcesFragment = graphql`
|
||||
fragment AccessReviewSourcesTabFragment on Organization
|
||||
@refetchable(queryName: "AccessReviewSourcesTabPaginationQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: {
|
||||
type: "AccessSourceOrder"
|
||||
defaultValue: { direction: DESC, field: CREATED_AT }
|
||||
}
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
accessSources(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "AccessReviewSourcesTab_accessSources") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
connectorId
|
||||
connector {
|
||||
provider
|
||||
}
|
||||
...AccessSourceRowFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AccessReviewSourcesTab() {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const processedConnectorIdRef = useRef<string | null>(null);
|
||||
const { organizationRef, canCreateSource, connectorProviderInfos } = useOutletContext<{
|
||||
organizationRef: AccessReviewSourcesTabFragment$key;
|
||||
canCreateSource: boolean;
|
||||
connectorProviderInfos: ReadonlyArray<ProviderInfo>;
|
||||
}>();
|
||||
|
||||
const {
|
||||
data: { accessSources },
|
||||
loadNext,
|
||||
hasNext,
|
||||
isLoadingNext,
|
||||
} = usePaginationFragment<
|
||||
AccessReviewSourcesTabPaginationQuery,
|
||||
AccessReviewSourcesTabFragment$key
|
||||
>(sourcesFragment, organizationRef);
|
||||
|
||||
const existingSourceProviders = useMemo(
|
||||
() =>
|
||||
accessSources.edges
|
||||
.map(edge => edge.node.connector?.provider)
|
||||
.filter((p): p is NonNullable<typeof p> => p != null),
|
||||
[accessSources.edges],
|
||||
);
|
||||
|
||||
const [createAccessSource, isCreatingSource]
|
||||
= useMutation<CreateAccessSourceDialogMutation>(
|
||||
createAccessSourceMutation,
|
||||
);
|
||||
|
||||
// Handle OAuth callback: after the provider redirects back with connector_id,
|
||||
// automatically create the access source for that connector.
|
||||
const callbackConnectorId = searchParams.get("connector_id");
|
||||
const callbackProvider = searchParams.get("provider");
|
||||
const hasSourceForCallback = !!callbackConnectorId
|
||||
&& accessSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callbackConnectorId) return;
|
||||
|
||||
if (hasSourceForCallback) {
|
||||
setSearchParams((params) => {
|
||||
params.delete("connector_id");
|
||||
params.delete("provider");
|
||||
return params;
|
||||
}, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedConnectorIdRef.current === callbackConnectorId || isCreatingSource) {
|
||||
return;
|
||||
}
|
||||
processedConnectorIdRef.current = callbackConnectorId;
|
||||
|
||||
const providerInfo = callbackProvider
|
||||
? connectorProviderInfos.find(p => p.provider === callbackProvider)
|
||||
: null;
|
||||
const sourceName = providerInfo?.displayName ?? callbackProvider ?? "Source";
|
||||
|
||||
createAccessSource({
|
||||
variables: {
|
||||
input: {
|
||||
organizationId,
|
||||
connectorId: callbackConnectorId,
|
||||
name: sourceName,
|
||||
csvData: null,
|
||||
},
|
||||
connections: [accessSources.__id],
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
processedConnectorIdRef.current = null;
|
||||
setSearchParams((params) => {
|
||||
params.delete("connector_id");
|
||||
params.delete("provider");
|
||||
return params;
|
||||
}, { replace: true });
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Access source created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
setSearchParams((params) => {
|
||||
params.delete("connector_id");
|
||||
params.delete("provider");
|
||||
return params;
|
||||
}, { replace: true });
|
||||
},
|
||||
onError(error) {
|
||||
processedConnectorIdRef.current = null;
|
||||
setSearchParams((params) => {
|
||||
params.delete("connector_id");
|
||||
params.delete("provider");
|
||||
return params;
|
||||
}, { replace: true });
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create access source"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [
|
||||
__,
|
||||
callbackConnectorId,
|
||||
callbackProvider,
|
||||
connectorProviderInfos,
|
||||
createAccessSource,
|
||||
hasSourceForCallback,
|
||||
isCreatingSource,
|
||||
organizationId,
|
||||
accessSources.__id,
|
||||
setSearchParams,
|
||||
toast,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
{canCreateSource && (
|
||||
<AddAccessSourceDialog
|
||||
organizationId={organizationId}
|
||||
connectionId={accessSources.__id}
|
||||
providerInfos={connectorProviderInfos}
|
||||
existingSourceProviders={existingSourceProviders}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>
|
||||
{__("Add source")}
|
||||
</Button>
|
||||
</AddAccessSourceDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accessSources && accessSources.edges.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Name")}</Th>
|
||||
<Th>{__("Source")}</Th>
|
||||
<Th>{__("Status")}</Th>
|
||||
<Th>{__("Organization")}</Th>
|
||||
<Th>{__("Created at")}</Th>
|
||||
<Th className="w-12"></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{accessSources.edges.map(edge => (
|
||||
<AccessSourceRow
|
||||
key={edge.node.id}
|
||||
fKey={edge.node}
|
||||
connectionId={accessSources.__id}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
|
||||
{hasNext && (
|
||||
<div className="p-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => loadNext(50)}
|
||||
disabled={isLoadingNext}
|
||||
>
|
||||
{isLoadingNext
|
||||
? __("Loading...")
|
||||
: __("Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-txt-tertiary">
|
||||
{__("No access sources configured yet. Add your first source to start reviewing access.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading
|
||||
import { peopleRoutes } from "./pages/iam/organizations/people/routes";
|
||||
import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";
|
||||
import { CurrentUser } from "./providers/CurrentUser";
|
||||
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
|
||||
import { assetRoutes } from "./routes/assetRoutes";
|
||||
import { auditRoutes } from "./routes/auditRoutes";
|
||||
import { contextRoutes } from "./routes/contextRoutes";
|
||||
@@ -288,6 +289,7 @@ const routes = [
|
||||
...rightsRequestRoutes,
|
||||
...processingActivityRoutes,
|
||||
...statesOfApplicabilityRoutes,
|
||||
...accessReviewRoutes,
|
||||
...compliancePageRoutes,
|
||||
...snapshotsRoutes,
|
||||
{
|
||||
|
||||
44
apps/console/src/routes/accessReviewRoutes.ts
Normal file
44
apps/console/src/routes/accessReviewRoutes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import type { AppRoute } from "@probo/routes";
|
||||
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
|
||||
export const accessReviewRoutes = [
|
||||
{
|
||||
path: "access-reviews",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/access-reviews/AccessReviewLayoutLoader"),
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "sources",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/access-reviews/sources/AccessReviewSourcesTab"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "access-reviews/campaigns/:campaignId",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/access-reviews/campaigns/CampaignDetailPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "access-reviews/sources/new/csv",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(
|
||||
() => import("#/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader"),
|
||||
),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
29
packages/ui/src/Atoms/Icons/IconRobot.tsx
Normal file
29
packages/ui/src/Atoms/Icons/IconRobot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
16
packages/ui/src/Atoms/Vendors/Brex.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Brex.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Cloudflare.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Cloudflare.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/DocuSign.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/DocuSign.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Figma.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Figma.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/GitHub.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/GitHub.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/HubSpot.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/HubSpot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Intercom.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Intercom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Linear.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Linear.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Notion.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Notion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/OnePassword.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/OnePassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/OpenAI.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/OpenAI.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Resend.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Resend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Sentry.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Sentry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Supabase.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Supabase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/ui/src/Atoms/Vendors/Tally.tsx
Normal file
16
packages/ui/src/Atoms/Vendors/Tally.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
packages/ui/src/Atoms/Vendors/VendorLogo.tsx
Normal file
80
packages/ui/src/Atoms/Vendors/VendorLogo.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user