Introduce access-review source snapshot and normalize naming

Decouple each campaign from the live access-review sources it was started
with by introducing a per-campaign source snapshot table
(access_review_campaign_sources). The snapshot captures the source name,
category, and connector at start time, so a review remains coherent even
after the underlying source is edited or deleted. Fetch tracking becomes
an append-only log (access_review_campaign_source_fetch_attempts) that
preserves every attempt with its own status and error rather than
overwriting a single row.

Rename the shared access-review tables and enums to use a consistent
access_review_ prefix throughout:

  access_entries          → access_review_entries
  access_sources          → access_review_sources
  access_source_category  → access_review_source_category
  access_entry_*          → access_review_entry_*

The same rename propagates to every coredata type, service, GraphQL
schema, MCP specification, CLI command, frontend component, and e2e test.
The accessreview package gains dedicated actions.go and policies.go files
for its own IAM policy set, mirroring the agentrun package pattern.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-11 15:58:30 +02:00
parent 8a53e0ec1c
commit 4b64e59da4
146 changed files with 4795 additions and 4069 deletions

View File

@@ -69,7 +69,7 @@ const fragment = graphql`
action: "core:statement-of-applicability:list"
)
canListAccessReviewCampaigns: permission(
action: "core:access-review-campaign:list"
action: "access-review:campaign:list"
)
}
`;

View File

@@ -27,20 +27,20 @@ import { Link, useNavigate } from "react-router";
import { ConnectionHandler, graphql } from "relay-runtime";
import { z } from "zod";
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql";
import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql";
import type { CreateCsvAccessReviewSourcePageQuery } from "#/__generated__/core/CreateCsvAccessReviewSourcePageQuery.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { createAccessSourceMutation } from "./dialogs/accessSourceMutations";
import { createAccessReviewSourceMutation } from "./dialogs/accessReviewSourceMutations";
export const createCsvAccessSourcePageQuery = graphql`
query CreateCsvAccessSourcePageQuery($organizationId: ID!) {
export const createCsvAccessReviewSourcePageQuery = graphql`
query CreateCsvAccessReviewSourcePageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
__typename
... on Organization {
id
canCreateSource: permission(action: "core:access-source:create")
canCreateSource: permission(action: "access-review:source:create")
}
}
}
@@ -51,10 +51,10 @@ const csvSchema = z.object({
csvData: z.string().min(1),
});
export default function CreateCsvAccessSourcePage({
export default function CreateCsvAccessReviewSourcePage({
queryRef,
}: {
queryRef: PreloadedQuery<CreateCsvAccessSourcePageQuery>;
queryRef: PreloadedQuery<CreateCsvAccessReviewSourcePageQuery>;
}) {
const { __ } = useTranslate();
const { toast } = useToast();
@@ -70,19 +70,19 @@ export default function CreateCsvAccessSourcePage({
usePageTitle(__("Add CSV Access Source"));
const { organization } = usePreloadedQuery(createCsvAccessSourcePageQuery, queryRef);
const { organization } = usePreloadedQuery(createCsvAccessReviewSourcePageQuery, queryRef);
if (organization.__typename !== "Organization") {
throw new Error("Organization not found");
}
const connectionId = ConnectionHandler.getConnectionID(
organization.id,
"AccessReviewSourcesTab_accessSources",
"AccessReviewSourcesTab_accessReviewSources",
);
const [createAccessSource, isCreating]
= useMutation<accessSourceMutationsCreateMutation>(
createAccessSourceMutation,
const [createAccessReviewSource, isCreating]
= useMutation<accessReviewSourceMutationsCreateMutation>(
createAccessReviewSourceMutation,
);
if (!organization.canCreateSource) {
@@ -96,7 +96,7 @@ export default function CreateCsvAccessSourcePage({
}
const onSubmit = (data: z.infer<typeof csvSchema>) => {
createAccessSource({
createAccessReviewSource({
variables: {
input: {
organizationId,

View File

@@ -15,16 +15,16 @@
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { CreateCsvAccessSourcePageQuery } from "#/__generated__/core/CreateCsvAccessSourcePageQuery.graphql";
import type { CreateCsvAccessReviewSourcePageQuery } from "#/__generated__/core/CreateCsvAccessReviewSourcePageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import CreateCsvAccessSourcePage, { createCsvAccessSourcePageQuery } from "./CreateCsvAccessSourcePage";
import CreateCsvAccessReviewSourcePage, { createCsvAccessReviewSourcePageQuery } from "./CreateCsvAccessReviewSourcePage";
export default function CreateCsvAccessSourcePageLoader() {
export default function CreateCsvAccessReviewSourcePageLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery]
= useQueryLoader<CreateCsvAccessSourcePageQuery>(createCsvAccessSourcePageQuery);
= useQueryLoader<CreateCsvAccessReviewSourcePageQuery>(createCsvAccessReviewSourcePageQuery);
useEffect(() => {
loadQuery({ organizationId });
@@ -36,7 +36,7 @@ export default function CreateCsvAccessSourcePageLoader() {
return (
<Suspense fallback={<PageSkeleton />}>
<CreateCsvAccessSourcePage queryRef={queryRef} />
<CreateCsvAccessReviewSourcePage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -32,13 +32,13 @@ 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";
import type { AccessReviewSourceRowConfigureMutation } from "#/__generated__/core/AccessReviewSourceRowConfigureMutation.graphql";
import type { AccessReviewSourceRowDeleteMutation } from "#/__generated__/core/AccessReviewSourceRowDeleteMutation.graphql";
import type { AccessReviewSourceRowFragment$key } from "#/__generated__/core/AccessReviewSourceRowFragment.graphql";
import type { AccessReviewSourceRowOrgsQuery } from "#/__generated__/core/AccessReviewSourceRowOrgsQuery.graphql";
const fragment = graphql`
fragment AccessSourceRowFragment on AccessSource {
fragment AccessReviewSourceRowFragment on AccessReviewSource {
id
name
connectorId
@@ -50,27 +50,27 @@ const fragment = graphql`
selectedOrganization
needsConfiguration
createdAt
canDelete: permission(action: "core:access-source:delete")
canDelete: permission(action: "access-review:source:delete")
}
`;
export const deleteAccessSourceMutation = graphql`
mutation AccessSourceRowDeleteMutation(
$input: DeleteAccessSourceInput!
export const deleteAccessReviewSourceMutation = graphql`
mutation AccessReviewSourceRowDeleteMutation(
$input: DeleteAccessReviewSourceInput!
$connections: [ID!]!
) {
deleteAccessSource(input: $input) {
deletedAccessSourceId @deleteEdge(connections: $connections)
deleteAccessReviewSource(input: $input) {
deletedAccessReviewSourceId @deleteEdge(connections: $connections)
}
}
`;
const configureMutation = graphql`
mutation AccessSourceRowConfigureMutation(
$input: ConfigureAccessSourceInput!
mutation AccessReviewSourceRowConfigureMutation(
$input: ConfigureAccessReviewSourceInput!
) {
configureAccessSource(input: $input) {
accessSource {
configureAccessReviewSource(input: $input) {
accessReviewSource {
id
selectedOrganization
needsConfiguration
@@ -80,9 +80,9 @@ const configureMutation = graphql`
`;
const orgsQuery = graphql`
query AccessSourceRowOrgsQuery($accessSourceId: ID!) {
node(id: $accessSourceId) @required(action: THROW) {
... on AccessSource {
query AccessReviewSourceRowOrgsQuery($accessReviewSourceId: ID!) {
node(id: $accessReviewSourceId) @required(action: THROW) {
... on AccessReviewSource {
providerOrganizations {
slug
displayName
@@ -93,7 +93,7 @@ const orgsQuery = graphql`
`;
type Props = {
fKey: AccessSourceRowFragment$key;
fKey: AccessReviewSourceRowFragment$key;
connectionId: string;
organizationId: string;
};
@@ -121,22 +121,22 @@ function sourceLabel(connectorProvider: string | null | undefined): string {
}
}
export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
export function AccessReviewSourceRow({ 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 [deleteAccessReviewSource] = useMutation<AccessReviewSourceRowDeleteMutation>(deleteAccessReviewSourceMutation);
const [configure] = useMutation<AccessReviewSourceRowConfigureMutation>(configureMutation);
const handleDelete = () => {
confirm(
() => {
deleteAccessSource({
deleteAccessReviewSource({
variables: {
input: { accessSourceId: accessSource.id },
input: { accessReviewSourceId: accessSource.id },
connections: [connectionId],
},
onCompleted: (_response, errors) => {
@@ -176,7 +176,7 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
configure({
variables: {
input: {
accessSourceId: accessSource.id,
accessReviewSourceId: accessSource.id,
organizationSlug: slug,
},
},
@@ -261,7 +261,7 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
}
>
<InlineOrgSelect
accessSourceId={accessSource.id}
accessReviewSourceId={accessSource.id}
selectedOrganization={accessSource.selectedOrganization ?? ""}
onSelect={handleOrgChange}
/>
@@ -295,18 +295,18 @@ export function AccessSourceRow({ fKey, connectionId, organizationId }: Props) {
}
function InlineOrgSelect({
accessSourceId,
accessReviewSourceId,
selectedOrganization,
onSelect,
}: {
accessSourceId: string;
accessReviewSourceId: string;
selectedOrganization: string;
onSelect: (slug: string) => void;
}) {
const { __ } = useTranslate();
const data = useLazyLoadQuery<AccessSourceRowOrgsQuery>(
const data = useLazyLoadQuery<AccessReviewSourceRowOrgsQuery>(
orgsQuery,
{ accessSourceId },
{ accessReviewSourceId },
{ fetchPolicy: "store-or-network" },
);

View File

@@ -31,16 +31,16 @@ import { useState } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import type { AccessEntryDecision, EntryDecisionActionsMutation } from "#/__generated__/core/EntryDecisionActionsMutation.graphql";
import type { AccessReviewEntryDecision, EntryDecisionActionsMutation } from "#/__generated__/core/EntryDecisionActionsMutation.graphql";
import { decisionBadgeVariant, decisionLabel } from "./accessReviewHelpers";
const mutation = graphql`
mutation EntryDecisionActionsMutation(
$input: RecordAccessEntryDecisionInput!
$input: RecordAccessReviewEntryDecisionInput!
) {
recordAccessEntryDecision(input: $input) {
accessEntry {
recordAccessReviewEntryDecision(input: $input) {
accessReviewEntry {
id
decision
decisionNote
@@ -59,16 +59,16 @@ export function EntryDecisionActions({ entryId, decision }: Props) {
const { toast } = useToast();
const ref = useDialogRef();
const [editing, setEditing] = useState(false);
const [pendingDecision, setPendingDecision] = useState<AccessEntryDecision | null>(null);
const [pendingDecision, setPendingDecision] = useState<AccessReviewEntryDecision | null>(null);
const [note, setNote] = useState("");
const [recordDecision, isRecording]
= useMutation<EntryDecisionActionsMutation>(mutation);
const submitDecision = (decisionValue: AccessEntryDecision, decisionNote?: string) => {
const submitDecision = (decisionValue: AccessReviewEntryDecision, decisionNote?: string) => {
recordDecision({
variables: {
input: {
accessEntryId: entryId,
accessReviewEntryId: entryId,
decision: decisionValue,
decisionNote: decisionNote || null,
},
@@ -103,14 +103,14 @@ export function EntryDecisionActions({ entryId, decision }: Props) {
});
};
const openNoteDialog = (decisionValue: AccessEntryDecision) => {
const openNoteDialog = (decisionValue: AccessReviewEntryDecision) => {
setPendingDecision(decisionValue);
setNote("");
ref.current?.open();
};
const handleDecision = (value: string) => {
const decision = value as AccessEntryDecision;
const decision = value as AccessReviewEntryDecision;
if (decision === "APPROVED") {
submitDecision(decision);
} else {

View File

@@ -20,14 +20,14 @@ 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 type { AccessReviewEntryFlag, EntryFlagSelectMutation } from "#/__generated__/core/EntryFlagSelectMutation.graphql";
import { flagBadgeVariant, flagGroups, flagLabel } from "./accessReviewHelpers";
const mutation = graphql`
mutation EntryFlagSelectMutation($input: FlagAccessEntryInput!) {
flagAccessEntry(input: $input) {
accessEntry {
mutation EntryFlagSelectMutation($input: FlagAccessReviewEntryInput!) {
flagAccessReviewEntry(input: $input) {
accessReviewEntry {
id
flags
flagReasons
@@ -38,18 +38,18 @@ const mutation = graphql`
type Props = {
entryId: string;
currentFlags: readonly AccessEntryFlag[];
currentFlags: readonly AccessReviewEntryFlag[];
};
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 [localFlags, setLocalFlags] = useState<AccessReviewEntryFlag[]>([...currentFlags]);
const openedWithRef = useRef<readonly AccessReviewEntryFlag[]>(currentFlags);
const [flagEntry] = useMutation<EntryFlagSelectMutation>(mutation);
const toggleFlag = (flagValue: AccessEntryFlag) => {
const toggleFlag = (flagValue: AccessReviewEntryFlag) => {
setLocalFlags(prev =>
prev.includes(flagValue)
? prev.filter(f => f !== flagValue)
@@ -73,7 +73,7 @@ export function EntryFlagSelect({ entryId, currentFlags }: Props) {
flagEntry({
variables: {
input: {
accessEntryId: entryId,
accessReviewEntryId: entryId,
flags: localFlags,
},
},

View File

@@ -31,6 +31,21 @@ export function statusBadgeVariant(status: string): BadgeVariant {
}
}
export function fetchStatusBadgeVariant(status: string): BadgeVariant {
switch (status) {
case "SUCCESS":
return "success";
case "FAILED":
return "danger";
case "FETCHING":
return "info";
case "QUEUED":
return "neutral";
default:
return "info";
}
}
export function statusLabel(
__: (key: string) => string,
status: string,

View File

@@ -48,7 +48,7 @@ export const accessReviewCampaignsTabQuery = graphql`
organization: node(id: $organizationId) {
__typename
... on Organization {
canCreateCampaign: permission(action: "core:access-review-campaign:create")
canCreateCampaign: permission(action: "access-review:campaign:create")
...AccessReviewCampaignsTabFragment
}
}
@@ -78,7 +78,7 @@ const campaignsFragment = graphql`
name
status
createdAt
canDelete: permission(action: "core:access-review-campaign:delete")
canDelete: permission(action: "access-review:campaign:delete")
}
}
}

View File

@@ -30,6 +30,7 @@ import {
IconPlusLarge,
IconRobot,
IconTrashCan,
IconWarning,
Option,
Select,
Tbody,
@@ -47,8 +48,8 @@ import { type PreloadedQuery, useMutation, usePreloadedQuery, useRelayEnvironmen
import { useNavigate } from "react-router";
import { ConnectionHandler, 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 { 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";
@@ -58,6 +59,7 @@ import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
decisionBadgeVariant,
decisionLabel,
fetchStatusBadgeVariant,
flagBadgeVariant,
flagGroups,
flagLabel,
@@ -68,7 +70,7 @@ import {
} from "../_components/accessReviewHelpers";
import { EntryDecisionActions } from "../_components/EntryDecisionActions";
import { EntryFlagSelect } from "../_components/EntryFlagSelect";
import { AddCampaignScopeSourceDialog } from "../dialogs/AddCampaignScopeSourceDialog";
import { AddCampaignSourceDialog } from "../dialogs/AddCampaignSourceDialog";
const startCampaignMutation = graphql`
mutation CampaignDetailPageStartMutation(
@@ -111,10 +113,10 @@ const deleteCampaignMutation = graphql`
const bulkDecisionMutation = graphql`
mutation CampaignDetailPageBulkDecisionMutation(
$input: RecordAccessEntryDecisionsInput!
$input: RecordAccessReviewEntryDecisionsInput!
) {
recordAccessEntryDecisions(input: $input) {
accessEntries {
recordAccessReviewEntryDecisions(input: $input) {
accessReviewEntries {
id
decision
decisionNote
@@ -125,10 +127,10 @@ const bulkDecisionMutation = graphql`
const bulkFlagMutation = graphql`
mutation CampaignDetailPageBulkFlagMutation(
$input: FlagAccessEntryInput!
$input: FlagAccessReviewEntryInput!
) {
flagAccessEntry(input: $input) {
accessEntry {
flagAccessReviewEntry(input: $input) {
accessReviewEntry {
id
flags
flagReasons
@@ -145,8 +147,8 @@ export const campaignDetailPageQuery = graphql`
id
name
status
canDelete: permission(action: "core:access-review-campaign:delete")
scopeSources {
canDelete: permission(action: "access-review:campaign:delete")
sources {
id
source {
id
@@ -154,6 +156,7 @@ export const campaignDetailPageQuery = graphql`
name
fetchStatus
fetchedAccountsCount
lastError
entries(first: 500) {
edges {
node {
@@ -222,9 +225,9 @@ export default function CampaignDetailPage({ queryRef }: Props) {
}, 3000);
return () => clearInterval(interval);
}, [isInProgress, environment]);
const existingScopeSourceIds = useMemo(
() => campaign.scopeSources.flatMap(s => s.source?.id ? [s.source.id] : []),
[campaign.scopeSources],
const existingCampaignSourceIds = useMemo(
() => campaign.sources.flatMap(s => s.source?.id ? [s.source.id] : []),
[campaign.sources],
);
const confirm = useConfirm();
@@ -238,8 +241,8 @@ export default function CampaignDetailPage({ queryRef }: Props) {
const [deleteCampaign, isDeleting]
= useMutation<CampaignDetailPageDeleteMutation>(deleteCampaignMutation);
const allDecided = campaign.scopeSources.length > 0
&& campaign.scopeSources.every(source =>
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")
@@ -436,16 +439,16 @@ export default function CampaignDetailPage({ queryRef }: Props) {
<div className="space-y-4">
{isDraft && (
<div className="flex items-center justify-end gap-2">
<AddCampaignScopeSourceDialog
<AddCampaignSourceDialog
organizationId={organizationId}
campaignId={campaign.id}
existingScopeSourceIds={existingScopeSourceIds}
existingCampaignSourceIds={existingCampaignSourceIds}
>
<Button icon={IconPlusLarge} variant="secondary">
{__("Add source")}
</Button>
</AddCampaignScopeSourceDialog>
{campaign.scopeSources.length > 0 && (
</AddCampaignSourceDialog>
{campaign.sources.length > 0 && (
<Button
onClick={handleStart}
disabled={isStarting}
@@ -456,15 +459,15 @@ export default function CampaignDetailPage({ queryRef }: Props) {
</div>
)}
{campaign.scopeSources.map(source => (
<ScopeSourceCard
{campaign.sources.map(source => (
<CampaignSourceCard
key={source.id}
source={source}
isPendingActions={isPendingActions}
/>
))}
{campaign.scopeSources.length === 0 && (
{campaign.sources.length === 0 && (
<Card padded>
<div className="text-center py-8">
<p className="text-txt-tertiary">
@@ -478,19 +481,19 @@ export default function CampaignDetailPage({ queryRef }: Props) {
);
}
type ScopeSource = NonNullable<
type CampaignSource = NonNullable<
Extract<
CampaignDetailPageQuery["response"]["node"],
{ readonly __typename: "AccessReviewCampaign" }
>["scopeSources"]
>["sources"]
>[number];
function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; isPendingActions: boolean }) {
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<AccessEntryDecision | null>(null);
const [bulkPendingDecision, setBulkPendingDecision] = useState<AccessReviewEntryDecision | null>(null);
const [bulkNote, setBulkNote] = useState("");
const bulkNoteRef = useDialogRef();
@@ -503,14 +506,14 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
const entryIds = entries.map(edge => edge.node.id);
const handleBulkDecision = (value: string) => {
const decision = value as AccessEntryDecision;
const decision = value as AccessReviewEntryDecision;
if (decision === "APPROVED") {
bulkDecide({
variables: {
input: {
decisions: selection.map(id => ({
accessEntryId: id,
decision: "APPROVED" as AccessEntryDecision,
accessReviewEntryId: id,
decision: "APPROVED" as AccessReviewEntryDecision,
})),
},
},
@@ -551,11 +554,11 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
}
};
const [bulkFlagSelection, setBulkFlagSelection] = useState<AccessEntryFlag[]>([]);
const [bulkFlagSelection, setBulkFlagSelection] = useState<AccessReviewEntryFlag[]>([]);
const [bulkFlagOpen, setBulkFlagOpen] = useState(false);
const bulkFlagOpenedWithRef = useRef<AccessEntryFlag[]>([]);
const bulkFlagOpenedWithRef = useRef<AccessReviewEntryFlag[]>([]);
const toggleBulkFlag = (flagValue: AccessEntryFlag) => {
const toggleBulkFlag = (flagValue: AccessReviewEntryFlag) => {
setBulkFlagSelection(prev =>
prev.includes(flagValue)
? prev.filter(f => f !== flagValue)
@@ -578,7 +581,7 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
bulkFlag({
variables: {
input: {
accessEntryId: entryId,
accessReviewEntryId: entryId,
flags: bulkFlagSelection,
},
},
@@ -635,17 +638,30 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
? <IconChevronDown className="size-4 text-txt-tertiary" />
: <IconChevronRight className="size-4 text-txt-tertiary" />}
<span className="font-medium">{source.name}</span>
{!source.source && (
<Badge variant="neutral">{__("Source deleted")}</Badge>
)}
<Badge variant="neutral">
{source.fetchedAccountsCount}
{" "}
{__("accounts")}
</Badge>
<Badge variant={source.fetchStatus === "SUCCESS" ? "success" : "info"}>
<Badge variant={fetchStatusBadgeVariant(source.fetchStatus)}>
{formatStatus(source.fetchStatus)}
</Badge>
</div>
</button>
{source.fetchStatus === "FAILED" && source.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>{source.lastError}</p>
</div>
</div>
)}
{expanded && (
<div className="border-t">
{entries.length === 0
@@ -841,7 +857,7 @@ function ScopeSourceCard({ source, isPendingActions }: { source: ScopeSource; is
variables: {
input: {
decisions: selection.map(id => ({
accessEntryId: id,
accessReviewEntryId: id,
decision: bulkPendingDecision,
decisionNote: bulkNote,
})),

View File

@@ -37,19 +37,19 @@ import { useMutation } from "react-relay";
import { Link } from "react-router";
import { graphql } from "relay-runtime";
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
import type { AddAccessSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
import type { AddAccessSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateAPIKeyConnectorMutation.graphql";
import type { AddAccessSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessSourceDialogCreateClientCredentialsConnectorMutation.graphql";
import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql";
import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$data } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.graphql";
import type { AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation.graphql";
import type { AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation } from "#/__generated__/core/AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation.graphql";
import { createAccessSourceMutation } from "./accessSourceMutations";
import { createAccessReviewSourceMutation } from "./accessReviewSourceMutations";
import {
isPostHogDeploymentSelected,
PostHogDeploymentField,
} from "./PostHogDeploymentField";
export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
fragment AddAccessSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
export const addAccessReviewSourceDialogConnectorProviderInfoFragment = graphql`
fragment AddAccessReviewSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
provider
displayName
oauthConfigured
@@ -64,7 +64,7 @@ export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
}
`;
export type ProviderInfo = AddAccessSourceDialogConnectorProviderInfoFragment$data[number];
export type ProviderInfo = AddAccessReviewSourceDialogConnectorProviderInfoFragment$data[number];
// DATADOG_SITES labels are technical identifiers (region code + hostname),
// intentionally not wrapped in __(). The dialog's prose strings are.
@@ -87,7 +87,7 @@ type Props = {
};
const createAPIKeyConnectorMutation = graphql`
mutation AddAccessSourceDialogCreateAPIKeyConnectorMutation(
mutation AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation(
$input: CreateAPIKeyConnectorInput!
) {
createAPIKeyConnector(input: $input) {
@@ -100,7 +100,7 @@ const createAPIKeyConnectorMutation = graphql`
`;
const createClientCredentialsConnectorMutation = graphql`
mutation AddAccessSourceDialogCreateClientCredentialsConnectorMutation(
mutation AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation(
$input: CreateClientCredentialsConnectorInput!
) {
createClientCredentialsConnector(input: $input) {
@@ -199,7 +199,7 @@ function cleanZendeskSubdomain(raw: string): string {
return value.trim();
}
export function AddAccessSourceDialog({
export function AddAccessReviewSourceDialog({
children,
organizationId,
connectionId,
@@ -250,16 +250,16 @@ export function AddAccessSourceDialog({
[existingSourceProviders],
);
const [createAccessSource]
= useMutation<accessSourceMutationsCreateMutation>(
createAccessSourceMutation,
const [createAccessReviewSource]
= useMutation<accessReviewSourceMutationsCreateMutation>(
createAccessReviewSourceMutation,
);
const [createAPIKeyConnector]
= useMutation<AddAccessSourceDialogCreateAPIKeyConnectorMutation>(
= useMutation<AddAccessReviewSourceDialogCreateAPIKeyConnectorMutation>(
createAPIKeyConnectorMutation,
);
const [createClientCredentialsConnector]
= useMutation<AddAccessSourceDialogCreateClientCredentialsConnectorMutation>(
= useMutation<AddAccessReviewSourceDialogCreateClientCredentialsConnectorMutation>(
createClientCredentialsConnectorMutation,
);
@@ -320,7 +320,7 @@ export function AddAccessSourceDialog({
displayName: string,
onDone: () => void,
) => {
createAccessSource({
createAccessReviewSource({
variables: {
input: {
organizationId,

View File

@@ -28,17 +28,17 @@ import {
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";
import type { AddCampaignSourceDialogMutation } from "#/__generated__/core/AddCampaignSourceDialogMutation.graphql";
import type { AddCampaignSourceDialogSourcesQuery } from "#/__generated__/core/AddCampaignSourceDialogSourcesQuery.graphql";
const addScopeMutation = graphql`
mutation AddCampaignScopeSourceDialogMutation(
$input: AddAccessReviewCampaignScopeSourceInput!
mutation AddCampaignSourceDialogMutation(
$input: AddAccessReviewCampaignSourceInput!
) {
addAccessReviewCampaignScopeSource(input: $input) {
addAccessReviewCampaignSource(input: $input) {
accessReviewCampaign {
id
scopeSources {
sources {
id
name
fetchStatus
@@ -68,10 +68,10 @@ const addScopeMutation = graphql`
`;
const sourcesQuery = graphql`
query AddCampaignScopeSourceDialogSourcesQuery($organizationId: ID!) {
query AddCampaignSourceDialogSourcesQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
accessSources(first: 100) {
accessReviewSources(first: 100) {
edges {
node {
id
@@ -88,31 +88,31 @@ type Props = {
children: ReactNode;
organizationId: string;
campaignId: string;
existingScopeSourceIds: string[];
existingCampaignSourceIds: string[];
};
export function AddCampaignScopeSourceDialog({
export function AddCampaignSourceDialog({
children,
organizationId,
campaignId,
existingScopeSourceIds,
existingCampaignSourceIds,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const ref = useDialogRef();
const [selectedSourceId, setSelectedSourceId] = useState<string>("");
const [addScopeSource, isAdding]
= useMutation<AddCampaignScopeSourceDialogMutation>(addScopeMutation);
const [addCampaignSource, isAdding]
= useMutation<AddCampaignSourceDialogMutation>(addScopeMutation);
const onSubmit = () => {
if (!selectedSourceId) return;
addScopeSource({
addCampaignSource({
variables: {
input: {
accessReviewCampaignId: campaignId,
accessSourceId: selectedSourceId,
accessReviewSourceId: selectedSourceId,
},
},
onCompleted(_, errors) {
@@ -164,7 +164,7 @@ export function AddCampaignScopeSourceDialog({
>
<SourceSelect
organizationId={organizationId}
existingScopeSourceIds={existingScopeSourceIds}
existingCampaignSourceIds={existingCampaignSourceIds}
value={selectedSourceId}
onChange={setSelectedSourceId}
/>
@@ -184,29 +184,29 @@ export function AddCampaignScopeSourceDialog({
function SourceSelect({
organizationId,
existingScopeSourceIds,
existingCampaignSourceIds,
value,
onChange,
}: {
organizationId: string;
existingScopeSourceIds: string[];
existingCampaignSourceIds: string[];
value: string;
onChange: (value: string) => void;
}) {
const { __ } = useTranslate();
const data
= useLazyLoadQuery<AddCampaignScopeSourceDialogSourcesQuery>(
= useLazyLoadQuery<AddCampaignSourceDialogSourcesQuery>(
sourcesQuery,
{ organizationId },
{ fetchPolicy: "network-only" },
);
const sources
= data?.organization?.accessSources?.edges
= data?.organization?.accessReviewSources?.edges
?.map(edge => edge.node)
.filter(
(node): node is NonNullable<typeof node> =>
node !== null && !existingScopeSourceIds.includes(node.id),
node !== null && !existingCampaignSourceIds.includes(node.id),
) ?? [];
if (sources.length === 0) {

View File

@@ -55,7 +55,7 @@ const sourcesQuery = graphql`
query CreateAccessReviewCampaignDialogSourcesQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
accessSources(first: 500) {
accessReviewSources(first: 500) {
edges {
node {
id
@@ -118,7 +118,7 @@ export function CreateAccessReviewCampaignDialog({
organizationId,
name: data.name,
description: data.description || null,
accessSourceIds:
accessReviewSourceIds:
selectedSourceIds.length > 0 ? selectedSourceIds : null,
},
connections: [connectionId],
@@ -227,7 +227,7 @@ function SourceSelector({
);
const sources
= data?.organization?.accessSources?.edges
= data?.organization?.accessReviewSources?.edges
?.map(edge => edge.node)
.filter((node): node is NonNullable<typeof node> => node !== null) ?? [];

View File

@@ -14,18 +14,18 @@
import { graphql } from "relay-runtime";
export const createAccessSourceMutation = graphql`
mutation accessSourceMutationsCreateMutation(
$input: CreateAccessSourceInput!
export const createAccessReviewSourceMutation = graphql`
mutation accessReviewSourceMutationsCreateMutation(
$input: CreateAccessReviewSourceInput!
$connections: [ID!]!
) {
createAccessSource(input: $input) {
accessSourceEdge @prependEdge(connections: $connections) {
createAccessReviewSource(input: $input) {
accessReviewSourceEdge @prependEdge(connections: $connections) {
node {
id
name
createdAt
...AccessSourceRowFragment
...AccessReviewSourceRowFragment
}
}
}

View File

@@ -33,23 +33,23 @@ import { useSearchParams } from "react-router";
import type { AccessReviewSourcesTabFragment$key } from "#/__generated__/core/AccessReviewSourcesTabFragment.graphql";
import type { AccessReviewSourcesTabPaginationQuery } from "#/__generated__/core/AccessReviewSourcesTabPaginationQuery.graphql";
import type { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.graphql";
import type { accessSourceMutationsCreateMutation } from "#/__generated__/core/accessSourceMutationsCreateMutation.graphql";
import type { AddAccessSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.graphql";
import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql";
import type { AddAccessReviewSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessReviewSourceDialogConnectorProviderInfoFragment.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { AccessSourceRow } from "../_components/AccessSourceRow";
import { createAccessSourceMutation } from "../dialogs/accessSourceMutations";
import { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessSourceDialog";
import { AccessReviewSourceRow } from "../_components/AccessReviewSourceRow";
import { createAccessReviewSourceMutation } from "../dialogs/accessReviewSourceMutations";
import { AddAccessReviewSourceDialog, addAccessReviewSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessReviewSourceDialog";
export const accessReviewSourcesTabQuery = graphql`
query AccessReviewSourcesTabQuery($organizationId: ID!) {
accessReviewDrivers {
...AddAccessSourceDialogConnectorProviderInfoFragment
...AddAccessReviewSourceDialogConnectorProviderInfoFragment
}
organization: node(id: $organizationId) {
__typename
... on Organization {
canCreateSource: permission(action: "core:access-source:create")
canCreateSource: permission(action: "access-review:source:create")
...AccessReviewSourcesTabFragment
}
}
@@ -62,20 +62,20 @@ const sourcesFragment = graphql`
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: {
type: "AccessSourceOrder"
type: "AccessReviewSourceOrder"
defaultValue: { direction: DESC, field: CREATED_AT }
}
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
accessSources(
accessReviewSources(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "AccessReviewSourcesTab_accessSources") {
) @connection(key: "AccessReviewSourcesTab_accessReviewSources") {
__id
edges {
node {
@@ -84,7 +84,7 @@ const sourcesFragment = graphql`
connector {
provider
}
...AccessSourceRowFragment
...AccessReviewSourceRowFragment
}
}
}
@@ -107,13 +107,13 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
throw new Error("Organization not found");
}
const connectorProviderInfos = useFragment<AddAccessSourceDialogConnectorProviderInfoFragment$key>(
addAccessSourceDialogConnectorProviderInfoFragment,
const connectorProviderInfos = useFragment<AddAccessReviewSourceDialogConnectorProviderInfoFragment$key>(
addAccessReviewSourceDialogConnectorProviderInfoFragment,
accessReviewDrivers,
);
const {
data: { accessSources },
data: { accessReviewSources },
loadNext,
hasNext,
isLoadingNext,
@@ -124,15 +124,15 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
const existingSourceProviders = useMemo(
() =>
accessSources.edges
accessReviewSources.edges
.map(edge => edge.node.connector?.provider)
.filter((p): p is NonNullable<typeof p> => p != null),
[accessSources.edges],
[accessReviewSources.edges],
);
const [createAccessSource, isCreatingSource]
= useMutation<accessSourceMutationsCreateMutation>(
createAccessSourceMutation,
const [createAccessReviewSource, isCreatingSource]
= useMutation<accessReviewSourceMutationsCreateMutation>(
createAccessReviewSourceMutation,
);
// Handle OAuth callback: after the provider redirects back with connector_id,
@@ -140,7 +140,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
const callbackConnectorId = searchParams.get("connector_id");
const callbackProvider = searchParams.get("provider");
const hasSourceForCallback = !!callbackConnectorId
&& accessSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId);
&& accessReviewSources?.edges.some(edge => edge.node.connectorId === callbackConnectorId);
useEffect(() => {
if (!callbackConnectorId) return;
@@ -164,7 +164,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
: null;
const sourceName = providerInfo?.displayName ?? callbackProvider ?? "Source";
createAccessSource({
createAccessReviewSource({
variables: {
input: {
organizationId,
@@ -172,7 +172,7 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
name: sourceName,
csvData: null,
},
connections: [accessSources.__id],
connections: [accessReviewSources.__id],
},
onCompleted(_, errors) {
if (errors?.length) {
@@ -225,11 +225,11 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
callbackConnectorId,
callbackProvider,
connectorProviderInfos,
createAccessSource,
createAccessReviewSource,
hasSourceForCallback,
isCreatingSource,
organizationId,
accessSources.__id,
accessReviewSources.__id,
setSearchParams,
toast,
]);
@@ -238,20 +238,20 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
<div className="space-y-4">
<div className="flex items-center justify-end">
{organization.canCreateSource && (
<AddAccessSourceDialog
<AddAccessReviewSourceDialog
organizationId={organizationId}
connectionId={accessSources.__id}
connectionId={accessReviewSources.__id}
providerInfos={connectorProviderInfos}
existingSourceProviders={existingSourceProviders}
>
<Button icon={IconPlusLarge}>
{__("Add source")}
</Button>
</AddAccessSourceDialog>
</AddAccessReviewSourceDialog>
)}
</div>
{accessSources && accessSources.edges.length > 0
{accessReviewSources && accessReviewSources.edges.length > 0
? (
<Card>
<Table>
@@ -266,11 +266,11 @@ export default function AccessReviewSourcesTab({ queryRef }: Props) {
</Tr>
</Thead>
<Tbody>
{accessSources.edges.map(edge => (
<AccessSourceRow
{accessReviewSources.edges.map(edge => (
<AccessReviewSourceRow
key={edge.node.id}
fKey={edge.node}
connectionId={accessSources.__id}
connectionId={accessReviewSources.__id}
organizationId={organizationId}
/>
))}

View File

@@ -38,7 +38,7 @@ export const accessReviewRoutes = [
path: "access-reviews/sources/new/csv",
Fallback: PageSkeleton,
Component: lazy(
() => import("#/pages/organizations/access-reviews/CreateCsvAccessSourcePageLoader"),
() => import("#/pages/organizations/access-reviews/CreateCsvAccessReviewSourcePageLoader"),
),
},
] satisfies AppRoute[];