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:
@@ -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,
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user