Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-04-02 16:28:23 +02:00
parent ff20b383d9
commit 28cf3f167a
9 changed files with 165 additions and 106 deletions

View File

@@ -21,55 +21,16 @@ import {
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>;
}) {
export default function AccessReviewLayout() {
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
@@ -90,13 +51,7 @@ export default function AccessReviewLayout({
</TabLink>
</Tabs>
<Outlet context={{
organizationRef: organization,
canCreateSource: organization.canCreateSource,
canCreateCampaign: organization.canCreateCampaign,
connectorProviderInfos: organization.connectorProviderInfos,
}}
/>
<Outlet />
</div>
);
}

View File

@@ -12,30 +12,8 @@
// 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";
import AccessReviewLayout 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>
);
return <AccessReviewLayout />;
}

View File

@@ -25,16 +25,29 @@ import {
Thead,
Tr,
} from "@probo/ui";
import { graphql, usePaginationFragment } from "react-relay";
import { useOutletContext } from "react-router";
import type { PreloadedQuery } from "react-relay";
import { graphql, usePaginationFragment, usePreloadedQuery } from "react-relay";
import type { AccessReviewCampaignsTabFragment$key } from "#/__generated__/core/AccessReviewCampaignsTabFragment.graphql";
import type { AccessReviewCampaignsTabPaginationQuery } from "#/__generated__/core/AccessReviewCampaignsTabPaginationQuery.graphql";
import type { AccessReviewCampaignsTabQuery } from "#/__generated__/core/AccessReviewCampaignsTabQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { statusBadgeVariant, statusLabel } from "../_components/accessReviewHelpers";
import { CreateAccessReviewCampaignDialog } from "../dialogs/CreateAccessReviewCampaignDialog";
export const accessReviewCampaignsTabQuery = graphql`
query AccessReviewCampaignsTabQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
__typename
... on Organization {
canCreateCampaign: permission(action: "core:access-review-campaign:create")
...AccessReviewCampaignsTabFragment
}
}
}
`;
const campaignsFragment = graphql`
fragment AccessReviewCampaignsTabFragment on Organization
@refetchable(queryName: "AccessReviewCampaignsTabPaginationQuery")
@@ -58,21 +71,24 @@ const campaignsFragment = graphql`
name
status
createdAt
startedAt
completedAt
}
}
}
}
`;
export default function AccessReviewCampaignsTab() {
type Props = {
queryRef: PreloadedQuery<AccessReviewCampaignsTabQuery>;
};
export default function AccessReviewCampaignsTab({ queryRef }: Props) {
const { __, dateFormat } = useTranslate();
const organizationId = useOrganizationId();
const { organizationRef, canCreateCampaign } = useOutletContext<{
organizationRef: AccessReviewCampaignsTabFragment$key;
canCreateCampaign: boolean;
}>();
const { organization } = usePreloadedQuery(accessReviewCampaignsTabQuery, queryRef);
if (organization.__typename !== "Organization") {
throw new Error("Organization not found");
}
const {
data: { accessReviewCampaigns },
@@ -82,12 +98,12 @@ export default function AccessReviewCampaignsTab() {
} = usePaginationFragment<
AccessReviewCampaignsTabPaginationQuery,
AccessReviewCampaignsTabFragment$key
>(campaignsFragment, organizationRef);
>(campaignsFragment, organization);
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
{canCreateCampaign && (
{organization.canCreateCampaign && (
<CreateAccessReviewCampaignDialog
organizationId={organizationId}
connectionId={accessReviewCampaigns.__id}

View File

@@ -0,0 +1,39 @@
// 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 { AccessReviewCampaignsTabQuery } from "#/__generated__/core/AccessReviewCampaignsTabQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import AccessReviewCampaignsTab, { accessReviewCampaignsTabQuery } from "./AccessReviewCampaignsTab";
export default function AccessReviewCampaignsTabLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<AccessReviewCampaignsTabQuery>(accessReviewCampaignsTabQuery);
useEffect(() => {
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) return <PageSkeleton />;
return (
<Suspense fallback={<PageSkeleton />}>
<AccessReviewCampaignsTab queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -131,10 +131,6 @@ export const campaignDetailPageQuery = graphql`
id
name
status
createdAt
startedAt
completedAt
pendingEntryCount
scopeSources {
id
source {

View File

@@ -37,19 +37,29 @@ 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 { 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 { CreateAccessSourceDialogMutation } from "#/__generated__/core/CreateAccessSourceDialogMutation.graphql";
import { createAccessSourceMutation } from "./CreateAccessSourceDialog";
type OrganizationData = Extract<
AccessReviewLayoutQuery$data["organization"],
{ readonly __typename: "Organization" }
>;
export const addAccessSourceDialogConnectorProviderInfoFragment = graphql`
fragment AddAccessSourceDialogConnectorProviderInfoFragment on ConnectorProviderInfo @relay(plural: true) {
provider
displayName
oauthConfigured
apiKeySupported
clientCredentialsSupported
extraSettings {
key
label
required
}
}
`;
export type ProviderInfo = OrganizationData["connectorProviderInfos"][number];
export type ProviderInfo = AddAccessSourceDialogConnectorProviderInfoFragment$data[number];
type Props = {
children: ReactNode;

View File

@@ -26,18 +26,36 @@ import {
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 { PreloadedQuery } from "react-relay";
import { graphql, useFragment, useMutation, usePaginationFragment, usePreloadedQuery } from "react-relay";
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 { AddAccessSourceDialogConnectorProviderInfoFragment$key } from "#/__generated__/core/AddAccessSourceDialogConnectorProviderInfoFragment.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 { AddAccessSourceDialog, addAccessSourceDialogConnectorProviderInfoFragment } from "../dialogs/AddAccessSourceDialog";
import { createAccessSourceMutation } from "../dialogs/CreateAccessSourceDialog";
export const accessReviewSourcesTabQuery = graphql`
query AccessReviewSourcesTabQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
__typename
... on Organization {
canCreateSource: permission(action: "core:access-source:create")
connectorProviderInfos {
...AddAccessSourceDialogConnectorProviderInfoFragment
}
...AccessReviewSourcesTabFragment
}
}
}
`;
const sourcesFragment = graphql`
fragment AccessReviewSourcesTabFragment on Organization
@refetchable(queryName: "AccessReviewSourcesTabPaginationQuery")
@@ -62,7 +80,6 @@ const sourcesFragment = graphql`
edges {
node {
id
name
connectorId
connector {
provider
@@ -74,17 +91,26 @@ const sourcesFragment = graphql`
}
`;
export default function AccessReviewSourcesTab() {
type Props = {
queryRef: PreloadedQuery<AccessReviewSourcesTabQuery>;
};
export default function AccessReviewSourcesTab({ queryRef }: Props) {
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 { organization } = usePreloadedQuery(accessReviewSourcesTabQuery, queryRef);
if (organization.__typename !== "Organization") {
throw new Error("Organization not found");
}
const connectorProviderInfos = useFragment<AddAccessSourceDialogConnectorProviderInfoFragment$key>(
addAccessSourceDialogConnectorProviderInfoFragment,
organization.connectorProviderInfos,
);
const {
data: { accessSources },
@@ -94,7 +120,7 @@ export default function AccessReviewSourcesTab() {
} = usePaginationFragment<
AccessReviewSourcesTabPaginationQuery,
AccessReviewSourcesTabFragment$key
>(sourcesFragment, organizationRef);
>(sourcesFragment, organization);
const existingSourceProviders = useMemo(
() =>
@@ -211,7 +237,7 @@ export default function AccessReviewSourcesTab() {
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
{canCreateSource && (
{organization.canCreateSource && (
<AddAccessSourceDialog
organizationId={organizationId}
connectionId={accessSources.__id}

View File

@@ -0,0 +1,39 @@
// 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 { AccessReviewSourcesTabQuery } from "#/__generated__/core/AccessReviewSourcesTabQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import AccessReviewSourcesTab, { accessReviewSourcesTabQuery } from "./AccessReviewSourcesTab";
export default function AccessReviewSourcesTabLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<AccessReviewSourcesTabQuery>(accessReviewSourcesTabQuery);
useEffect(() => {
loadQuery({ organizationId });
}, [loadQuery, organizationId]);
if (!queryRef) return <PageSkeleton />;
return (
<Suspense fallback={<PageSkeleton />}>
<AccessReviewSourcesTab queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -15,14 +15,14 @@ export const accessReviewRoutes = [
index: true,
Fallback: PageSkeleton,
Component: lazy(
() => import("#/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTab"),
() => import("#/pages/organizations/access-reviews/campaigns/AccessReviewCampaignsTabLoader"),
),
},
{
path: "sources",
Fallback: PageSkeleton,
Component: lazy(
() => import("#/pages/organizations/access-reviews/sources/AccessReviewSourcesTab"),
() => import("#/pages/organizations/access-reviews/sources/AccessReviewSourcesTabLoader"),
),
},
],