Framework view with control list and view

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-03-28 17:36:40 +04:00
parent 0f16cc945b
commit f04e11866f
11 changed files with 582 additions and 99 deletions

View File

@@ -7,7 +7,10 @@ import { BreadCrumb } from "./OrganizationBreadcrumb";
import { cn } from "@/lib/utils";
export default function OrganizationLayout() {
const match = useMatch(
const controlMatch = useMatch(
"organizations/:organizationId/frameworks/:frameworkId/controls/*"
);
const frameworkMatch = useMatch(
"organizations/:organizationId/frameworks/:frameworkId"
);
@@ -19,16 +22,16 @@ export default function OrganizationLayout() {
<SidebarInset>
<div
className={cn(
"p-8",
match
"p-8 h-screen overflow-auto",
controlMatch || frameworkMatch
? "w-full"
: "mx-auto w-lg md:w-xl lg:w-3xl xl:w-4xl 2xl:w-5xl"
)}
>
<header className="flex shrink-0 items-center gap-2">
<header className="flex shrink-0 items-center gap-2 mb-7">
<BreadCrumb />
</header>
<div className="mt-7 w-full">
<div className="w-full">
<Outlet />
</div>
</div>

View File

@@ -1,26 +1,26 @@
import { Route, Routes } from "react-router";
import { VendorListPage } from "./vendors/VendorListPage";
import OrganizationLayout from "./OrganizationLayout";
import NotFoundPage from "../NotFoundPage";
import { CreateOrganizationPage } from "./CreateOrganizationPage";
import HomePage from "./HomePage";
import NoOrganizationLayout from "./NoOrganizationLayout";
import OrganizationLayout from "./OrganizationLayout";
import { SettingsPage } from "./SettingsPage";
import { CreateFrameworkPage } from "./frameworks/CreateFrameworkPage";
import { FrameworkListPage } from "./frameworks/FrameworkListPage";
import { FrameworkPage } from "./frameworks/FrameworkPage";
import { PeopleListPage } from "./people/PeopleListPage";
import { CreatePeoplePage } from "./people/CreatePeoplePage";
import { PeoplePage } from "./people/PeoplePage";
import { CreateFrameworkPage } from "./frameworks/CreateFrameworkPage";
import { UpdateFrameworkPage } from "./frameworks/UpdateFrameworkPage";
import { ControlPage } from "./frameworks/controls/ControlPage";
import { MitigationPage } from "./mitigations/MitigationPage";
import { VendorPage } from "./vendors/VendorPage";
import { PolicyListPage } from "./policies/PolicyListPage";
import { MitigationListPage } from "./mitigations/MitigationListPage";
import { CreatePeoplePage } from "./people/CreatePeoplePage";
import { PeopleListPage } from "./people/PeopleListPage";
import { PeoplePage } from "./people/PeoplePage";
import { CreatePolicyPage } from "./policies/CreatePolicyPage";
import { PolicyListPage } from "./policies/PolicyListPage";
import { PolicyPage } from "./policies/PolicyPage";
import { UpdatePolicyPage } from "./policies/UpdatePolicyPage";
import { SettingsPage } from "./SettingsPage";
import { CreateOrganizationPage } from "./CreateOrganizationPage";
import { MitigationListPage } from "./mitigations/MitigationListPage";
import HomePage from "./HomePage";
import NotFoundPage from "../NotFoundPage";
import { VendorListPage } from "./vendors/VendorListPage";
import { VendorPage } from "./vendors/VendorPage";
export function OrganizationsRoutes() {
return (
@@ -33,18 +33,16 @@ export function OrganizationsRoutes() {
<Route path="vendors" element={<VendorListPage />} />
<Route path="frameworks" element={<FrameworkListPage />} />
<Route path="frameworks/create" element={<CreateFrameworkPage />} />
<Route path="frameworks/:frameworkId" element={<FrameworkPage />} />
<Route
path="frameworks/:frameworkId/update"
element={<UpdateFrameworkPage />}
/>
<Route path="frameworks/:frameworkId/*">
<Route element={<FrameworkPage />}>
<Route index />
<Route path="controls/:controlId" element={<ControlPage />} />
</Route>
<Route path="update" element={<UpdateFrameworkPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="mitigations" element={<MitigationListPage />} />
<Route path="mitigations/:mitigationId" element={<MitigationPage />} />
{/* <Route path="mitigations/create" element={<CreateMitigationPage />} />
<Route
path="mitigations/:mitigationId/update"
element={<UpdateMitigationPage />}
/> */}
<Route path="vendors/:vendorId" element={<VendorPage />} />
<Route path="policies" element={<PolicyListPage />} />
<Route path="policies/create" element={<CreatePolicyPage />} />

View File

@@ -1,9 +1,9 @@
import { PageTemplateSkeleton } from "@/components/PageTemplate";
import { Card, CardContent } from "@/components/ui/card";
import { Suspense } from "react";
import { useLocation } from "react-router";
import { ErrorBoundaryWithLocation } from "../ErrorBoundary";
import { useMatch } from "react-router";
import { lazy } from "@probo/react-lazy";
import ErrorBoundary from "@/components/ErrorBoundary";
const FrameworkView = lazy(() => import("./FrameworkView"));
@@ -36,13 +36,15 @@ export function FrameworkViewSkeleton() {
}
export function FrameworkPage() {
const location = useLocation();
const match = useMatch(
"organizations/:organizationId/frameworks/:frameworkId/*"
);
return (
<Suspense key={location.pathname} fallback={<FrameworkViewSkeleton />}>
<ErrorBoundaryWithLocation>
<Suspense key={match?.pathnameBase} fallback={<FrameworkViewSkeleton />}>
<ErrorBoundary key={match?.pathnameBase}>
<FrameworkView />
</ErrorBoundaryWithLocation>
</ErrorBoundary>
</Suspense>
);
}

View File

@@ -1,5 +1,5 @@
import { Suspense, useEffect, useState, useCallback } from "react";
import { useParams, useNavigate, Link } from "react-router";
import { useParams, useNavigate, Link, Outlet } from "react-router";
import {
graphql,
PreloadedQuery,
@@ -8,8 +8,6 @@ import {
useMutation,
ConnectionHandler,
} from "react-relay";
import { Plus } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -24,6 +22,8 @@ import type { FrameworkViewQuery as FrameworkViewQueryType } from "./__generated
import type { FrameworkViewDeleteMutation } from "./__generated__/FrameworkViewDeleteMutation.graphql";
import { PageTemplate } from "@/components/PageTemplate";
import { FrameworkViewSkeleton } from "./FrameworkPage";
import { ControlList } from "./FrameworkView/ControlList";
import ControlView from "./controls/ControlView";
const FrameworkViewQuery = graphql`
query FrameworkViewQuery($frameworkId: ID!) {
@@ -32,14 +32,14 @@ const FrameworkViewQuery = graphql`
... on Framework {
name
description
controls(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
...ControlList_List
controls(first: 1, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "FrameworkView_controls") {
edges {
node {
id
referenceId
name
description
}
}
}
@@ -66,15 +66,13 @@ function FrameworkViewContent({
}) {
const data = usePreloadedQuery(FrameworkViewQuery, queryRef);
const framework = data.node;
const control = data.node.controls?.edges[0];
const navigate = useNavigate();
const { organizationId } = useParams();
const { organizationId, controlId } = useParams();
const { toast } = useToast();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Extract controls from the GraphQL response
const controls = framework?.controls?.edges?.map((edge) => edge.node) || [];
// Setup delete mutation
const [commitDeleteMutation] = useMutation<FrameworkViewDeleteMutation>(
DeleteFrameworkMutation
@@ -151,54 +149,9 @@ function FrameworkViewContent({
</div>
}
>
<div className="grid gap-6">
{controls.length > 0 ? (
controls.map((control) => (
<Card key={control.id} className="overflow-hidden">
<CardHeader className="bg-muted/20">
<div
className="font-medium cursor-pointer flex items-center"
onClick={() => {
navigate(
`/organizations/${organizationId}/frameworks/${framework.id}/controls/${control.id}`
);
}}
>
<span className="font-mono text-sm mr-3">
{control.referenceId}
</span>
<CardTitle>{control.name}</CardTitle>
</div>
<div className="text-sm text-muted-foreground mt-1 whitespace-pre-wrap">
{control.description}
</div>
</CardHeader>
<CardContent className="pt-4">
<h4 className="text-sm font-semibold mb-3">Mitigations</h4>
<div className="text-sm text-muted-foreground text-center py-4 border rounded-md">
<p>
Mitigations will be displayed here once connected to this
control
</p>
<div className="mt-2">
<Button variant="outline" size="sm" asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${framework.id}/mitigations/create`}
>
<Plus className="h-3 w-3 mr-1" />
Link Mitigation
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
))
) : (
<div className="flex items-center justify-center p-6 text-center text-muted-foreground">
No controls available for this framework
</div>
)}
<div className="flex flex-row items-start justify-start w-full h-[calc(100vh-216px)] overflow-hidden -ml-8">
<ControlList fragmentKey={framework} />
{controlId ? <Outlet /> : <ControlView controlId={control?.node.id} />}
</div>
{/* Delete Confirmation Dialog */}
@@ -248,7 +201,7 @@ export default function FrameworkView() {
return (
<Suspense fallback={<FrameworkViewSkeleton />}>
{queryRef && <FrameworkViewContent queryRef={queryRef} />}
<FrameworkViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,80 @@
import { graphql, useFragment } from "react-relay";
import { ControlList_List$key } from "./__generated__/ControlList_List.graphql";
import { NavLink, useParams } from "react-router";
import { cn } from "@/lib/utils";
export const controlListFragment = graphql`
fragment ControlList_List on Framework {
controls(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@connection(key: "FrameworkView_controls") {
edges {
node {
id
referenceId
name
}
}
}
}
`;
interface ControlListProps {
fragmentKey: ControlList_List$key;
}
export function ControlList(props: ControlListProps) {
const { organizationId, frameworkId, controlId } = useParams<{
organizationId: string;
frameworkId: string;
controlId?: string;
}>();
const { fragmentKey } = props;
const { controls } = useFragment<ControlList_List$key>(
controlListFragment,
fragmentKey
);
if (controls.edges.length === 0) {
return "No controls available for this framework";
}
return (
<aside className="shrink-0 w-70 border-r border-gray-100 h-full overflow-x-hidden overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-xs">
{controls.edges.map(({ node: control }, i) => (
<NavLink
key={control.id}
to={`/organizations/${organizationId}/frameworks/${frameworkId}/controls/${control.id}`}
className={({ isActive }) =>
cn(
"block pl-8 pr-4 py-4 hover:bg-gray-50 border-b border-gray-100",
(isActive || (i === 0 && !controlId)) && "bg-gray-50"
)
}
>
{({ isActive }) => {
return (
<>
<div
className={cn(
"inline-block font-mono text-sm px-1 py-0.25 rounded-sm bg-lime-2 border border-lime-4 text-lime-9 font-semibold",
isActive && "font-bold bg-lime-3 border-lime-6 text-lime-11"
)}
>
{control.referenceId}
</div>
<div
className={cn(
"text-sm leading-none break-words mt-2",
isActive && "font-medium"
)}
>
{control.name}
</div>
</>
);
}}
</NavLink>
))}
</aside>
);
}

View File

@@ -0,0 +1,156 @@
/**
* @generated SignedSource<<a3e361b3b8152318476292098dd890f4>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type ControlList_List$data = {
readonly controls: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
readonly name: string;
readonly referenceId: string;
};
}>;
};
readonly " $fragmentType": "ControlList_List";
};
export type ControlList_List$key = {
readonly " $data"?: ControlList_List$data;
readonly " $fragmentSpreads": FragmentRefs<"ControlList_List">;
};
const node: ReaderFragment = {
"argumentDefinitions": [],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"controls"
]
}
]
},
"name": "ControlList_List",
"selections": [
{
"alias": "controls",
"args": [
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "CREATED_AT"
}
}
],
"concreteType": "ControlConnection",
"kind": "LinkedField",
"name": "__FrameworkView_controls_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "ControlEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Control",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "referenceId",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "__FrameworkView_controls_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
}
],
"type": "Framework",
"abstractKey": null
};
(node as any).hash = "aa95219172d60909d7a503d246e0f4f5";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<0185ea6dc887f2bbb5ca70ec28e916b2>>
* @generated SignedSource<<11d451bf710d818645f55fe5f0ddb114>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,6 +9,7 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type FrameworkViewQuery$variables = {
frameworkId: string;
};
@@ -17,7 +18,6 @@ export type FrameworkViewQuery$data = {
readonly controls?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly description: string;
readonly id: string;
readonly name: string;
readonly referenceId: string;
@@ -27,6 +27,7 @@ export type FrameworkViewQuery$data = {
readonly description?: string;
readonly id: string;
readonly name?: string;
readonly " $fragmentSpreads": FragmentRefs<"ControlList_List">;
};
};
export type FrameworkViewQuery = {
@@ -111,7 +112,6 @@ v7 = [
"storageKey": null
},
(v3/*: any*/),
(v4/*: any*/),
(v6/*: any*/)
],
"storageKey": null
@@ -181,6 +181,11 @@ return {
"selections": [
(v3/*: any*/),
(v4/*: any*/),
{
"args": null,
"kind": "FragmentSpread",
"name": "ControlList_List"
},
{
"alias": "controls",
"args": [
@@ -256,7 +261,7 @@ return {
]
},
"params": {
"cacheID": "a1fa5a0efc291c92478c061d32b90e43",
"cacheID": "5bf64e09bf7f44c56b75bbb63b9b272f",
"id": null,
"metadata": {
"connection": [
@@ -273,11 +278,11 @@ return {
},
"name": "FrameworkViewQuery",
"operationKind": "query",
"text": "query FrameworkViewQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n id\n ... on Framework {\n name\n description\n controls(first: 100, orderBy: {field: CREATED_AT, direction: ASC}) {\n edges {\n node {\n id\n referenceId\n name\n description\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
"text": "query FrameworkViewQuery(\n $frameworkId: ID!\n) {\n node(id: $frameworkId) {\n __typename\n id\n ... on Framework {\n name\n description\n ...ControlList_List\n controls(first: 100, orderBy: {field: CREATED_AT, direction: ASC}) {\n edges {\n node {\n id\n referenceId\n name\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n\nfragment ControlList_List on Framework {\n controls(first: 100, orderBy: {field: CREATED_AT, direction: ASC}) {\n edges {\n node {\n id\n referenceId\n name\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n"
}
};
})();
(node as any).hash = "ff9593fc321c840ae9ef1da48a13c3e5";
(node as any).hash = "d4837d95c7bd721bf1851c1b977fd018";
export default node;

View File

@@ -0,0 +1,38 @@
import { Card, CardContent } from "@/components/ui/card";
import { Suspense } from "react";
import { useLocation } from "react-router";
import { lazy } from "@probo/react-lazy";
import ErrorBoundary from "@/components/ErrorBoundary";
const ControlView = lazy(() => import("./ControlView"));
export function ControlViewSkeleton() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="relative mb-6">
<div className="bg-muted w-24 h-24 rounded-full animate-pulse mb-4" />
<div className="h-6 w-48 bg-muted animate-pulse rounded mb-2" />
<div className="h-20 w-full bg-muted animate-pulse rounded" />
</div>
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
export function ControlPage() {
const location = useLocation();
return (
<Suspense key={location.pathname} fallback={<ControlViewSkeleton />}>
<ErrorBoundary key={location.pathname}>
<ControlView />
</ErrorBoundary>
</Suspense>
);
}

View File

@@ -0,0 +1,98 @@
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";
import { ControlViewSkeleton } from "./ControlPage";
import { Suspense, useEffect } from "react";
import {
ControlViewQuery,
ControlViewQuery$data,
} from "./__generated__/ControlViewQuery.graphql";
import { Button } from "@/components/ui/button";
import { Link, useParams } from "react-router";
import { Plus } from "lucide-react";
const controlViewQuery = graphql`
query ControlViewQuery($controlId: ID!) {
node(id: $controlId) {
id
... on Control {
description
name
referenceId
}
}
}
`;
export function Control({
control,
}: {
control: ControlViewQuery$data["node"];
}) {
const { organizationId, frameworkId } = useParams<{
organizationId: string;
frameworkId: string;
}>();
return (
<div className="w-auto p-5 flex items-start gap-5">
<div className="font-mono text-lg px-1 py-0.25 rounded-sm bg-lime-3 border border-lime-6 text-lime-11 font-bold">
{control.referenceId}
</div>
<div className="flex-1">
<h2 className="text-2xl font-medium">{control.name}</h2>
<h3 className="text-xl font-medium text-gray-600 mt-8">
Security measures
</h3>
<p className="mt-4">
Security measures will be displayed here once connected to this
control
</p>
<div className="mt-2">
<Button asChild>
<Link
to={`/organizations/${organizationId}/frameworks/${frameworkId}/mitigations/create`}
>
<Plus className="h-3 w-3 mr-1" />
Link Security Measures
</Link>
</Button>
</div>
</div>
</div>
);
}
function ControlViewContent({
queryRef,
}: {
queryRef: PreloadedQuery<ControlViewQuery>;
}) {
const { node: control } = usePreloadedQuery<ControlViewQuery>(
controlViewQuery,
queryRef
);
return <Control control={control} />;
}
export default function ControlView({ controlId }: { controlId?: string }) {
const { controlId: controlIdParam } = useParams();
const [queryRef, loadQuery] =
useQueryLoader<ControlViewQuery>(controlViewQuery);
useEffect(() => {
loadQuery({ controlId: (controlId ?? controlIdParam)! });
}, [loadQuery, controlId, controlIdParam]);
if (!queryRef) return <ControlViewSkeleton />;
return (
<Suspense>
<ControlViewContent queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,143 @@
/**
* @generated SignedSource<<08f7a2f65fb9ae3f6970e76485fa59e4>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type ControlViewQuery$variables = {
controlId: string;
};
export type ControlViewQuery$data = {
readonly node: {
readonly description?: string;
readonly id: string;
readonly name?: string;
readonly referenceId?: string;
};
};
export type ControlViewQuery = {
response: ControlViewQuery$data;
variables: ControlViewQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "controlId"
}
],
v1 = [
{
"kind": "Variable",
"name": "id",
"variableName": "controlId"
}
],
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v3 = {
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "description",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "referenceId",
"storageKey": null
}
],
"type": "Control",
"abstractKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ControlViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ControlViewQuery",
"selections": [
{
"alias": null,
"args": (v1/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
]
},
"params": {
"cacheID": "be2e7ad630198d4c5476be3767a45d97",
"id": null,
"metadata": {},
"name": "ControlViewQuery",
"operationKind": "query",
"text": "query ControlViewQuery(\n $controlId: ID!\n) {\n node(id: $controlId) {\n __typename\n id\n ... on Control {\n description\n name\n referenceId\n }\n }\n}\n"
}
};
})();
(node as any).hash = "d692873ef9c85a8f56b8a35e61e5a5bc";
export default node;

View File

@@ -844,6 +844,13 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return nil, err
}
return types.NewPolicy(policy), nil
case coredata.ControlEntityType:
control, err := svc.Controls.Get(ctx, id)
if err != nil {
return nil, err
}
return types.NewControl(control), nil
default:
}