Files
probo/apps/console/src/pages/organizations/access-reviews/campaigns/CampaignDetailPage.tsx
Émile Ré f98b73f073 Upgrade frontend toolchain to latest majors
Bump React 19.2, Relay 21, React Router 8, Vite 8 with
@vitejs/plugin-react 6, ESLint 10, GraphQL 17, TypeScript 6,
@types/node 24, and Tailwind 4.3 across the workspaces.

vite-plugin-react 6 (Vite 8) no longer runs Babel, so the Relay
tagged-template transform now runs through @rolldown/plugin-babel
in the console and trust Vite configs.

Relay 21 ships first-party types and enables the ambiguous-alias
check by default; disable that flag to preserve existing queries
and add explicit usePreloadedQuery type arguments where the new
types no longer infer the operation. TypeScript 6 deprecations and
stricter inference are addressed in tsconfigs and call sites.

Keep n8n-node on ESLint 9 and eslint-plugin-react on 7.37.5, the
newest releases compatible with their toolchains.

Signed-off-by: Émile Ré <emile@probo.com>
2026-06-19 13:51:17 +02:00

927 lines
30 KiB
TypeScript

// Copyright (c) 2026 Probo Inc <hello@probo.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, 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,
IconTrashCan,
IconWarning,
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 { useNavigate } from "react-router";
import { ConnectionHandler, fetchQuery, graphql } from "relay-runtime";
import type { AccessReviewEntryDecision, CampaignDetailPageBulkDecisionMutation } from "#/__generated__/core/CampaignDetailPageBulkDecisionMutation.graphql";
import type { AccessReviewEntryFlag, CampaignDetailPageBulkFlagMutation } from "#/__generated__/core/CampaignDetailPageBulkFlagMutation.graphql";
import type { CampaignDetailPageCloseMutation } from "#/__generated__/core/CampaignDetailPageCloseMutation.graphql";
import type { CampaignDetailPageDeleteMutation } from "#/__generated__/core/CampaignDetailPageDeleteMutation.graphql";
import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetailPageQuery.graphql";
import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { AccessEntryRolesCell } from "../_components/AccessEntryRolesCell";
import {
decisionBadgeVariant,
decisionLabel,
fetchStatusBadgeVariant,
flagBadgeVariant,
flagGroups,
flagLabel,
formatStatus,
NotAvailable,
statusBadgeVariant,
statusLabel,
} from "../_components/accessReviewHelpers";
import { EntryDecisionActions } from "../_components/EntryDecisionActions";
import { EntryFlagSelect } from "../_components/EntryFlagSelect";
import { AddCampaignSourceDialog } from "../dialogs/AddCampaignSourceDialog";
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 deleteCampaignMutation = graphql`
mutation CampaignDetailPageDeleteMutation(
$input: DeleteAccessReviewCampaignInput!
$connections: [ID!]!
) {
deleteAccessReviewCampaign(input: $input) {
deletedAccessReviewCampaignId @deleteEdge(connections: $connections)
}
}
`;
const bulkDecisionMutation = graphql`
mutation CampaignDetailPageBulkDecisionMutation(
$input: RecordAccessReviewEntryDecisionsInput!
) {
recordAccessReviewEntryDecisions(input: $input) {
accessReviewEntries {
id
decision
decisionNote
}
}
}
`;
const bulkFlagMutation = graphql`
mutation CampaignDetailPageBulkFlagMutation(
$input: FlagAccessReviewEntryInput!
) {
flagAccessReviewEntry(input: $input) {
accessReviewEntry {
id
flags
flagReasons
}
}
}
`;
export const campaignDetailPageQuery = graphql`
query CampaignDetailPageQuery($campaignId: ID!) {
node(id: $campaignId) {
__typename
... on AccessReviewCampaign {
id
name
status
canDelete: permission(action: "access-review:campaign:delete")
sources {
id
source {
id
}
name
fetchAttempts(first: 1) {
edges {
node {
status
fetchedAccountsCount
error
}
}
}
entries(first: 500) {
edges {
node {
id
email
fullName
...AccessEntryRolesCell_accessEntry
isAdmin
active
mfaStatus
accountType
lastLogin
decision
flags
}
}
pageInfo {
hasNextPage
}
}
}
}
}
}
`;
type Props = {
queryRef: PreloadedQuery<CampaignDetailPageQuery>;
};
export default function CampaignDetailPage({ queryRef }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const navigate = useNavigate();
const environment = useRelayEnvironment();
const data = usePreloadedQuery<CampaignDetailPageQuery>(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 isCancelled = campaign.status === "CANCELLED";
const canDelete = campaign.canDelete && (isDraft || isCancelled);
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 existingCampaignSourceIds = useMemo(
() => campaign.sources.flatMap(s => s.source?.id ? [s.source.id] : []),
[campaign.sources],
);
const confirm = useConfirm();
const [startCampaign, isStarting]
= useMutation<CampaignDetailPageStartMutation>(startCampaignMutation);
const [closeCampaign, isClosing]
= useMutation<CampaignDetailPageCloseMutation>(closeCampaignMutation);
const [deleteCampaign, isDeleting]
= useMutation<CampaignDetailPageDeleteMutation>(deleteCampaignMutation);
const allDecided = campaign.sources.length > 0
&& campaign.sources.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,
),
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,
),
variant: "error",
});
},
});
};
const handleDelete = () => {
const connections = [
ConnectionHandler.getConnectionID(
organizationId,
"AccessReviewCampaignsTab_accessReviewCampaigns",
),
];
confirm(
() =>
new Promise<void>((resolve) => {
deleteCampaign({
variables: {
input: { accessReviewCampaignId: campaign.id },
connections,
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete campaign"),
errors,
),
variant: "error",
});
resolve();
return;
}
toast({
title: __("Success"),
description: __("Campaign deleted successfully."),
variant: "success",
});
resolve();
void navigate(`/organizations/${organizationId}/access-reviews`);
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete campaign"),
error,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__("This will permanently delete \"%s\". This action cannot be undone."),
campaign.name,
),
label: __("Delete"),
variant: "danger",
},
);
};
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,
),
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,
),
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>
)}
{canDelete && (
<Button
icon={IconTrashCan}
variant="danger"
onClick={handleDelete}
disabled={isDeleting}
className="ml-auto"
>
{isDeleting ? __("Deleting...") : __("Delete")}
</Button>
)}
</div>
<div className="space-y-4">
{isDraft && (
<div className="flex items-center justify-end gap-2">
<AddCampaignSourceDialog
organizationId={organizationId}
campaignId={campaign.id}
existingCampaignSourceIds={existingCampaignSourceIds}
>
<Button icon={IconPlusLarge} variant="secondary">
{__("Add source")}
</Button>
</AddCampaignSourceDialog>
{campaign.sources.length > 0 && (
<Button
onClick={handleStart}
disabled={isStarting}
>
{isStarting ? __("Starting...") : __("Start campaign")}
</Button>
)}
</div>
)}
{campaign.sources.map(source => (
<CampaignSourceCard
key={source.id}
source={source}
isPendingActions={isPendingActions}
/>
))}
{campaign.sources.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 CampaignSource = NonNullable<
Extract<
CampaignDetailPageQuery["response"]["node"],
{ readonly __typename: "AccessReviewCampaign" }
>["sources"]
>[number];
function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSource; isPendingActions: boolean }) {
const { __ } = useTranslate();
const { toast } = useToast();
const [expanded, setExpanded] = useState(false);
const { list: selection, toggle, clear, reset } = useList<string>([]);
const [bulkPendingDecision, setBulkPendingDecision] = useState<AccessReviewEntryDecision | 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 latestAttempt = source.fetchAttempts.edges[0]?.node;
const fetchStatus = latestAttempt?.status ?? "QUEUED";
const fetchedAccountsCount = latestAttempt?.fetchedAccountsCount ?? 0;
const lastError = latestAttempt?.error;
const handleBulkDecision = (value: string) => {
const decision = value as AccessReviewEntryDecision;
if (decision === "APPROVED") {
bulkDecide({
variables: {
input: {
decisions: selection.map(id => ({
accessReviewEntryId: id,
decision: "APPROVED",
})),
},
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to record decisions"),
errors,
),
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,
),
variant: "error",
});
},
});
} else {
setBulkPendingDecision(decision);
setBulkNote("");
bulkNoteRef.current?.open();
}
};
const [bulkFlagSelection, setBulkFlagSelection] = useState<AccessReviewEntryFlag[]>([]);
const [bulkFlagOpen, setBulkFlagOpen] = useState(false);
const bulkFlagOpenedWithRef = useRef<AccessReviewEntryFlag[]>([]);
const toggleBulkFlag = (flagValue: AccessReviewEntryFlag) => {
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: {
accessReviewEntryId: 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">
{fetchedAccountsCount}
{" "}
{__("accounts")}
</Badge>
<Badge variant={fetchStatusBadgeVariant(fetchStatus)}>
{formatStatus(fetchStatus)}
</Badge>
</div>
</button>
{fetchStatus === "FAILED" && lastError && (
<div className="flex items-start gap-2 border-t bg-danger px-4 py-3 text-sm text-txt-danger">
<IconWarning className="mt-0.5 size-4 shrink-0" />
<div>
<p className="font-medium">{__("Fetch failed")}</p>
<p>{lastError}</p>
</div>
</div>
)}
{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>{__("Status")}</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>
<AccessEntryRolesCell accessEntryKey={edge.node} />
<Td>{edge.node.isAdmin ? __("Yes") : __("No")}</Td>
<Td>
{edge.node.active == null
? <NotAvailable />
: (
<Badge variant={edge.node.active ? "success" : "danger"}>
{edge.node.active ? __("Active") : __("Disabled")}
</Badge>
)}
</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 => ({
accessReviewEntryId: id,
decision: bulkPendingDecision,
decisionNote: bulkNote,
})),
},
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: formatError(
__("Failed to record decisions"),
errors,
),
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,
),
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>
);
}