Add ability to link policies to controls
Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
@@ -4,15 +4,17 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.2] - 2025-04-09
|
||||
|
||||
### Added
|
||||
|
||||
- Add vendor compliance reports UI
|
||||
- Controls can now be linked to policies, enabling better organization of compliance documentation and clearer traceability between policies and security controls
|
||||
- New UI for viewing and managing policies related to a specific control
|
||||
|
||||
## [0.4.2] - 2025-04-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified policy data model by removing version field and optimistic concurrency
|
||||
- Simplified policy data model by removing version field and optimistic concurrency
|
||||
- Refactored policy update flow to load-modify-save pattern
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -34,6 +34,14 @@ import {
|
||||
ControlOrganizationMitigationsQuery,
|
||||
} from "./__generated__/ControlOrganizationMitigationsQuery.graphql";
|
||||
import { ControlFragment_Control$key } from "./__generated__/ControlFragment_Control.graphql";
|
||||
import {
|
||||
ControlLinkedPoliciesQuery$data,
|
||||
ControlLinkedPoliciesQuery,
|
||||
} from "./__generated__/ControlLinkedPoliciesQuery.graphql";
|
||||
import {
|
||||
ControlOrganizationPoliciesQuery$data,
|
||||
ControlOrganizationPoliciesQuery,
|
||||
} from "./__generated__/ControlOrganizationPoliciesQuery.graphql";
|
||||
|
||||
const controlFragment = graphql`
|
||||
fragment ControlFragment_Control on Control {
|
||||
@@ -90,6 +98,50 @@ const organizationMitigationsQuery = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Query to fetch linked policies
|
||||
const linkedPoliciesQuery = graphql`
|
||||
query ControlLinkedPoliciesQuery($controlId: ID!) {
|
||||
control: node(id: $controlId) {
|
||||
id
|
||||
... on Control {
|
||||
policies(first: 100) @connection(key: "Control__policies") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
content
|
||||
status
|
||||
reviewDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Query to fetch all policies for the organization
|
||||
const organizationPoliciesQuery = graphql`
|
||||
query ControlOrganizationPoliciesQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
id
|
||||
... on Organization {
|
||||
policies(first: 100) @connection(key: "Organization__policies") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
content
|
||||
status
|
||||
reviewDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Mutation to create a mapping between a control and a mitigation
|
||||
const createMitigationMappingMutation = graphql`
|
||||
mutation ControlCreateMitigationMappingMutation(
|
||||
@@ -112,6 +164,28 @@ const deleteMitigationMappingMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Mutation to create a mapping between a control and a policy
|
||||
const createPolicyMappingMutation = graphql`
|
||||
mutation ControlCreatePolicyMappingMutation(
|
||||
$input: CreateControlPolicyMappingInput!
|
||||
) {
|
||||
createControlPolicyMapping(input: $input) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Mutation to delete a mapping between a control and a policy
|
||||
const deletePolicyMappingMutation = graphql`
|
||||
mutation ControlDeletePolicyMappingMutation(
|
||||
$input: DeleteControlPolicyMappingInput!
|
||||
) {
|
||||
deleteControlPolicyMapping(input: $input) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function Control({
|
||||
controlKey,
|
||||
}: {
|
||||
@@ -138,6 +212,18 @@ export function Control({
|
||||
const [isUnlinkingMitigation, setIsUnlinkingMitigation] = useState(false);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
||||
|
||||
// Policy state
|
||||
const [isPolicyMappingDialogOpen, setIsPolicyMappingDialogOpen] =
|
||||
useState(false);
|
||||
const [linkedPoliciesData, setLinkedPoliciesData] =
|
||||
useState<ControlLinkedPoliciesQuery$data | null>(null);
|
||||
const [organizationPoliciesData, setOrganizationPoliciesData] =
|
||||
useState<ControlOrganizationPoliciesQuery$data | null>(null);
|
||||
const [policySearchQuery, setPolicySearchQuery] = useState("");
|
||||
const [isLoadingPolicies, setIsLoadingPolicies] = useState(false);
|
||||
const [isLinkingPolicy, setIsLinkingPolicy] = useState(false);
|
||||
const [isUnlinkingPolicy, setIsUnlinkingPolicy] = useState(false);
|
||||
|
||||
// Create mutation hooks
|
||||
const [commitCreateMitigationMapping] = useMutation(
|
||||
createMitigationMappingMutation
|
||||
@@ -145,6 +231,8 @@ export function Control({
|
||||
const [commitDeleteMitigationMapping] = useMutation(
|
||||
deleteMitigationMappingMutation
|
||||
);
|
||||
const [commitCreatePolicyMapping] = useMutation(createPolicyMappingMutation);
|
||||
const [commitDeletePolicyMapping] = useMutation(deletePolicyMappingMutation);
|
||||
|
||||
// Load initial linked mitigations data
|
||||
useEffect(() => {
|
||||
@@ -415,6 +503,243 @@ export function Control({
|
||||
setIsMitigationMappingDialogOpen(true);
|
||||
}, [loadMitigationsData]);
|
||||
|
||||
// Load initial linked policies data
|
||||
useEffect(() => {
|
||||
if (control.id) {
|
||||
setIsLoadingPolicies(true);
|
||||
fetchQuery<ControlLinkedPoliciesQuery>(environment, linkedPoliciesQuery, {
|
||||
controlId: control.id,
|
||||
}).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedPoliciesData(data);
|
||||
setIsLoadingPolicies(false);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
console.error("Error loading initial policies:", error);
|
||||
setIsLoadingPolicies(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [control.id, environment]);
|
||||
|
||||
// Load policies data
|
||||
const loadPoliciesData = useCallback(() => {
|
||||
if (!organizationId || !control.id) return;
|
||||
|
||||
setIsLoadingPolicies(true);
|
||||
|
||||
// Fetch all policies for the organization
|
||||
fetchQuery<ControlOrganizationPoliciesQuery>(
|
||||
environment,
|
||||
organizationPoliciesQuery,
|
||||
{
|
||||
organizationId,
|
||||
}
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setOrganizationPoliciesData(data);
|
||||
},
|
||||
complete: () => {
|
||||
// Fetch linked policies for this control
|
||||
fetchQuery<ControlLinkedPoliciesQuery>(
|
||||
environment,
|
||||
linkedPoliciesQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedPoliciesData(data);
|
||||
setIsLoadingPolicies(false);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
console.error("Error fetching linked policies:", error);
|
||||
setIsLoadingPolicies(false);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load linked policies.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
error: (error: Error) => {
|
||||
console.error("Error fetching organization policies:", error);
|
||||
setIsLoadingPolicies(false);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load policies.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [control.id, environment, organizationId, toast]);
|
||||
|
||||
// Policy helper functions
|
||||
const getPolicies = useCallback(() => {
|
||||
if (!organizationPoliciesData?.organization?.policies?.edges) return [];
|
||||
return organizationPoliciesData.organization.policies.edges.map(
|
||||
(edge) => edge.node
|
||||
);
|
||||
}, [organizationPoliciesData]);
|
||||
|
||||
const getLinkedPolicies = useCallback(() => {
|
||||
if (!linkedPoliciesData?.control?.policies?.edges) return [];
|
||||
return linkedPoliciesData.control.policies.edges.map((edge) => edge.node);
|
||||
}, [linkedPoliciesData]);
|
||||
|
||||
const isPolicyLinked = useCallback(
|
||||
(policyId: string) => {
|
||||
const linkedPolicies = getLinkedPolicies();
|
||||
return linkedPolicies.some((policy) => policy.id === policyId);
|
||||
},
|
||||
[getLinkedPolicies]
|
||||
);
|
||||
|
||||
const filteredPolicies = useCallback(() => {
|
||||
const policies = getPolicies();
|
||||
if (!policySearchQuery) return policies;
|
||||
|
||||
return policies.filter((policy) => {
|
||||
return (
|
||||
!policySearchQuery ||
|
||||
policy.name.toLowerCase().includes(policySearchQuery.toLowerCase()) ||
|
||||
(policy.content &&
|
||||
policy.content
|
||||
.toLowerCase()
|
||||
.includes(policySearchQuery.toLowerCase()))
|
||||
);
|
||||
});
|
||||
}, [getPolicies, policySearchQuery]);
|
||||
|
||||
// Policy link/unlink handlers
|
||||
const handleLinkPolicy = useCallback(
|
||||
(policyId: string) => {
|
||||
if (!control.id) return;
|
||||
|
||||
setIsLinkingPolicy(true);
|
||||
|
||||
commitCreatePolicyMapping({
|
||||
variables: {
|
||||
input: {
|
||||
controlId: control.id,
|
||||
policyId: policyId,
|
||||
},
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
setIsLinkingPolicy(false);
|
||||
|
||||
if (errors) {
|
||||
console.error("Error linking policy:", errors);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to link policy. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh linked policies data
|
||||
fetchQuery<ControlLinkedPoliciesQuery>(
|
||||
environment,
|
||||
linkedPoliciesQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedPoliciesData(data);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
console.error("Error refreshing linked policies:", error);
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy successfully linked to control.",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsLinkingPolicy(false);
|
||||
console.error("Error linking policy:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to link policy. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[commitCreatePolicyMapping, control.id, environment, toast]
|
||||
);
|
||||
|
||||
const handleUnlinkPolicy = useCallback(
|
||||
(policyId: string) => {
|
||||
if (!control.id) return;
|
||||
|
||||
setIsUnlinkingPolicy(true);
|
||||
|
||||
commitDeletePolicyMapping({
|
||||
variables: {
|
||||
input: {
|
||||
controlId: control.id,
|
||||
policyId: policyId,
|
||||
},
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
setIsUnlinkingPolicy(false);
|
||||
|
||||
if (errors) {
|
||||
console.error("Error unlinking policy:", errors);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to unlink policy. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh linked policies data
|
||||
fetchQuery<ControlLinkedPoliciesQuery>(
|
||||
environment,
|
||||
linkedPoliciesQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedPoliciesData(data);
|
||||
},
|
||||
error: (error: Error) => {
|
||||
console.error("Error refreshing linked policies:", error);
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy successfully unlinked from control.",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsUnlinkingPolicy(false);
|
||||
console.error("Error unlinking policy:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to unlink policy. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[commitDeletePolicyMapping, control.id, environment, toast]
|
||||
);
|
||||
|
||||
const handleOpenPolicyMappingDialog = useCallback(() => {
|
||||
loadPoliciesData();
|
||||
setIsPolicyMappingDialogOpen(true);
|
||||
}, [loadPoliciesData]);
|
||||
|
||||
// UI helper functions
|
||||
const formatImportance = (importance: string | undefined): string => {
|
||||
if (!importance) return "Unknown";
|
||||
@@ -773,6 +1098,256 @@ export function Control({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policies Section */}
|
||||
<div className="mt-8">
|
||||
{/* Policy Mapping Dialog */}
|
||||
<Dialog
|
||||
open={isPolicyMappingDialogOpen}
|
||||
onOpenChange={setIsPolicyMappingDialogOpen}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link Policies to Control</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search and select policies to link to this control. This helps
|
||||
track which policies address this control.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-tertiary" />
|
||||
<Input
|
||||
placeholder="Search policies by name or content..."
|
||||
value={policySearchQuery}
|
||||
onChange={(e) => setPolicySearchQuery(e.target.value)}
|
||||
className="w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isLoadingPolicies ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-info" />
|
||||
<span className="ml-2">Loading policies...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[50vh] overflow-y-auto pr-2">
|
||||
{filteredPolicies().length === 0 ? (
|
||||
<div className="text-center py-8 text-secondary">
|
||||
No policies found. Try adjusting your search.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full bg-level-1">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b text-left text-sm text-secondary bg-invert-bg">
|
||||
<th className="py-3 px-4 font-medium">Name</th>
|
||||
<th className="py-3 px-4 font-medium">Status</th>
|
||||
<th className="py-3 px-4 font-medium">
|
||||
Review Date
|
||||
</th>
|
||||
<th className="py-3 px-4 font-medium text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPolicies().map((policy) => {
|
||||
const isLinked = isPolicyLinked(policy.id);
|
||||
return (
|
||||
<tr
|
||||
key={policy.id}
|
||||
className="border-b hover:bg-invert-bg"
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium">
|
||||
{policy.name}
|
||||
</div>
|
||||
{policy.content && (
|
||||
<div className="text-xs text-secondary line-clamp-1 mt-0.5">
|
||||
{policy.content}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
policy.status === "ACTIVE"
|
||||
? "bg-success-bg text-success"
|
||||
: "bg-secondary-bg text-secondary"
|
||||
} inline-block`}
|
||||
>
|
||||
{policy.status}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{policy.reviewDate
|
||||
? new Date(
|
||||
policy.reviewDate
|
||||
).toLocaleDateString()
|
||||
: "Not set"}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right whitespace-nowrap">
|
||||
{isLinked ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleUnlinkPolicy(policy.id)
|
||||
}
|
||||
disabled={isUnlinkingPolicy}
|
||||
className="text-xs h-7 text-danger border-danger-b hover:bg-h-danger-bg"
|
||||
>
|
||||
{isUnlinkingPolicy ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<X className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1">Unlink</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleLinkPolicy(policy.id)
|
||||
}
|
||||
disabled={isLinkingPolicy}
|
||||
className="text-xs h-7 text-info border-info-b hover:bg-h-info-bg"
|
||||
>
|
||||
{isLinkingPolicy ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1">Link</span>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button onClick={() => setIsPolicyMappingDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Linked Policies List */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-medium text-secondary">Policies</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-1"
|
||||
onClick={handleOpenPolicyMappingDialog}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
<span>Link Policies</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoadingPolicies ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-info" />
|
||||
<span className="ml-2">Loading policies...</span>
|
||||
</div>
|
||||
) : linkedPoliciesData?.control?.policies?.edges &&
|
||||
linkedPoliciesData.control.policies.edges.length > 0 ? (
|
||||
<div className="overflow-x-auto border rounded-md">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-secondary bg-invert-bg">
|
||||
<th className="py-3 px-4 font-medium">Name</th>
|
||||
<th className="py-3 px-4 font-medium">Status</th>
|
||||
<th className="py-3 px-4 font-medium">Review Date</th>
|
||||
<th className="py-3 px-4 font-medium text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{getLinkedPolicies().map((policy) => (
|
||||
<tr
|
||||
key={policy.id}
|
||||
className="border-b hover:bg-invert-bg"
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium">{policy.name}</div>
|
||||
{policy.content && (
|
||||
<div className="text-xs text-secondary line-clamp-1 mt-0.5">
|
||||
{policy.content}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
policy.status === "ACTIVE"
|
||||
? "bg-success-bg text-success"
|
||||
: "bg-secondary-bg text-secondary"
|
||||
} inline-block`}
|
||||
>
|
||||
{policy.status}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{policy.reviewDate
|
||||
? new Date(policy.reviewDate).toLocaleDateString()
|
||||
: "Not set"}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right whitespace-nowrap">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<Link
|
||||
to={`/organizations/${organizationId}/policies/${policy.id}`}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnlinkPolicy(policy.id)}
|
||||
disabled={isUnlinkingPolicy}
|
||||
className="text-xs h-7 text-danger border-danger-b hover:bg-h-danger-bg"
|
||||
>
|
||||
Unlink
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-secondary border rounded-md">
|
||||
No policies linked to this control yet. Click "Link
|
||||
Policies" to connect some.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @generated SignedSource<<d614f25231ff53581f96021c5b52628a>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type CreateControlPolicyMappingInput = {
|
||||
controlId: string;
|
||||
policyId: string;
|
||||
};
|
||||
export type ControlCreatePolicyMappingMutation$variables = {
|
||||
input: CreateControlPolicyMappingInput;
|
||||
};
|
||||
export type ControlCreatePolicyMappingMutation$data = {
|
||||
readonly createControlPolicyMapping: {
|
||||
readonly success: boolean;
|
||||
};
|
||||
};
|
||||
export type ControlCreatePolicyMappingMutation = {
|
||||
response: ControlCreatePolicyMappingMutation$data;
|
||||
variables: ControlCreatePolicyMappingMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "CreateControlPolicyMappingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "createControlPolicyMapping",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "success",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "ControlCreatePolicyMappingMutation",
|
||||
"selections": (v1/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "ControlCreatePolicyMappingMutation",
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "bde220ef49867cec6b77bffc06878258",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "ControlCreatePolicyMappingMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation ControlCreatePolicyMappingMutation(\n $input: CreateControlPolicyMappingInput!\n) {\n createControlPolicyMapping(input: $input) {\n success\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "8e6ef41146efb59340331e16ce0493d6";
|
||||
|
||||
export default node;
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @generated SignedSource<<5988b98a6e2ae42a565e51fc13fa5c91>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type DeleteControlPolicyMappingInput = {
|
||||
controlId: string;
|
||||
policyId: string;
|
||||
};
|
||||
export type ControlDeletePolicyMappingMutation$variables = {
|
||||
input: DeleteControlPolicyMappingInput;
|
||||
};
|
||||
export type ControlDeletePolicyMappingMutation$data = {
|
||||
readonly deleteControlPolicyMapping: {
|
||||
readonly success: boolean;
|
||||
};
|
||||
};
|
||||
export type ControlDeletePolicyMappingMutation = {
|
||||
response: ControlDeletePolicyMappingMutation$data;
|
||||
variables: ControlDeletePolicyMappingMutation$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "input"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "input",
|
||||
"variableName": "input"
|
||||
}
|
||||
],
|
||||
"concreteType": "DeleteControlPolicyMappingPayload",
|
||||
"kind": "LinkedField",
|
||||
"name": "deleteControlPolicyMapping",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "success",
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "ControlDeletePolicyMappingMutation",
|
||||
"selections": (v1/*: any*/),
|
||||
"type": "Mutation",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "ControlDeletePolicyMappingMutation",
|
||||
"selections": (v1/*: any*/)
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "3b84cd899eee67303695eae81d1ba453",
|
||||
"id": null,
|
||||
"metadata": {},
|
||||
"name": "ControlDeletePolicyMappingMutation",
|
||||
"operationKind": "mutation",
|
||||
"text": "mutation ControlDeletePolicyMappingMutation(\n $input: DeleteControlPolicyMappingInput!\n) {\n deleteControlPolicyMapping(input: $input) {\n success\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "e706f52dac09ef3fcadeb588ccd0274d";
|
||||
|
||||
export default node;
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @generated SignedSource<<9bddb18bf52f14881a85995793bdc1ca>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type PolicyStatus = "ACTIVE" | "DRAFT";
|
||||
export type ControlLinkedPoliciesQuery$variables = {
|
||||
controlId: string;
|
||||
};
|
||||
export type ControlLinkedPoliciesQuery$data = {
|
||||
readonly control: {
|
||||
readonly id: string;
|
||||
readonly policies?: {
|
||||
readonly edges: ReadonlyArray<{
|
||||
readonly node: {
|
||||
readonly content: string;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly reviewDate: string | null | undefined;
|
||||
readonly status: PolicyStatus;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type ControlLinkedPoliciesQuery = {
|
||||
response: ControlLinkedPoliciesQuery$data;
|
||||
variables: ControlLinkedPoliciesQuery$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 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
},
|
||||
v4 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "PolicyEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "edges",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Policy",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "content",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "status",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "reviewDate",
|
||||
"storageKey": null
|
||||
},
|
||||
(v3/*: any*/)
|
||||
],
|
||||
"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
|
||||
}
|
||||
],
|
||||
v5 = [
|
||||
{
|
||||
"kind": "Literal",
|
||||
"name": "first",
|
||||
"value": 100
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "ControlLinkedPoliciesQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "control",
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "policies",
|
||||
"args": null,
|
||||
"concreteType": "PolicyConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "__Control__policies_connection",
|
||||
"plural": false,
|
||||
"selections": (v4/*: any*/),
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Control",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "ControlLinkedPoliciesQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "control",
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v5/*: any*/),
|
||||
"concreteType": "PolicyConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "policies",
|
||||
"plural": false,
|
||||
"selections": (v4/*: any*/),
|
||||
"storageKey": "policies(first:100)"
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v5/*: any*/),
|
||||
"filters": null,
|
||||
"handle": "connection",
|
||||
"key": "Control__policies",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "policies"
|
||||
}
|
||||
],
|
||||
"type": "Control",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "67f9bf1d5f255f5406e932abde540e08",
|
||||
"id": null,
|
||||
"metadata": {
|
||||
"connection": [
|
||||
{
|
||||
"count": null,
|
||||
"cursor": null,
|
||||
"direction": "forward",
|
||||
"path": [
|
||||
"control",
|
||||
"policies"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "ControlLinkedPoliciesQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query ControlLinkedPoliciesQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n policies(first: 100) {\n edges {\n node {\n id\n name\n content\n status\n reviewDate\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "3904c2344754177177fa7c16129dc894";
|
||||
|
||||
export default node;
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @generated SignedSource<<cfe85b9fdabaf24f9a7f575eca8a6417>>
|
||||
* @lightSyntaxTransform
|
||||
* @nogrep
|
||||
*/
|
||||
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { ConcreteRequest } from 'relay-runtime';
|
||||
export type PolicyStatus = "ACTIVE" | "DRAFT";
|
||||
export type ControlOrganizationPoliciesQuery$variables = {
|
||||
organizationId: string;
|
||||
};
|
||||
export type ControlOrganizationPoliciesQuery$data = {
|
||||
readonly organization: {
|
||||
readonly id: string;
|
||||
readonly policies?: {
|
||||
readonly edges: ReadonlyArray<{
|
||||
readonly node: {
|
||||
readonly content: string;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly reviewDate: string | null | undefined;
|
||||
readonly status: PolicyStatus;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
export type ControlOrganizationPoliciesQuery = {
|
||||
response: ControlOrganizationPoliciesQuery$data;
|
||||
variables: ControlOrganizationPoliciesQuery$variables;
|
||||
};
|
||||
|
||||
const node: ConcreteRequest = (function(){
|
||||
var v0 = [
|
||||
{
|
||||
"defaultValue": null,
|
||||
"kind": "LocalArgument",
|
||||
"name": "organizationId"
|
||||
}
|
||||
],
|
||||
v1 = [
|
||||
{
|
||||
"kind": "Variable",
|
||||
"name": "id",
|
||||
"variableName": "organizationId"
|
||||
}
|
||||
],
|
||||
v2 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "id",
|
||||
"storageKey": null
|
||||
},
|
||||
v3 = {
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "__typename",
|
||||
"storageKey": null
|
||||
},
|
||||
v4 = [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "PolicyEdge",
|
||||
"kind": "LinkedField",
|
||||
"name": "edges",
|
||||
"plural": true,
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"concreteType": "Policy",
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "name",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "content",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "status",
|
||||
"storageKey": null
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": null,
|
||||
"kind": "ScalarField",
|
||||
"name": "reviewDate",
|
||||
"storageKey": null
|
||||
},
|
||||
(v3/*: any*/)
|
||||
],
|
||||
"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
|
||||
}
|
||||
],
|
||||
v5 = [
|
||||
{
|
||||
"kind": "Literal",
|
||||
"name": "first",
|
||||
"value": 100
|
||||
}
|
||||
];
|
||||
return {
|
||||
"fragment": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Fragment",
|
||||
"metadata": null,
|
||||
"name": "ControlOrganizationPoliciesQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "organization",
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "policies",
|
||||
"args": null,
|
||||
"concreteType": "PolicyConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "__Organization__policies_connection",
|
||||
"plural": false,
|
||||
"selections": (v4/*: any*/),
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Organization",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
],
|
||||
"type": "Query",
|
||||
"abstractKey": null
|
||||
},
|
||||
"kind": "Request",
|
||||
"operation": {
|
||||
"argumentDefinitions": (v0/*: any*/),
|
||||
"kind": "Operation",
|
||||
"name": "ControlOrganizationPoliciesQuery",
|
||||
"selections": [
|
||||
{
|
||||
"alias": "organization",
|
||||
"args": (v1/*: any*/),
|
||||
"concreteType": null,
|
||||
"kind": "LinkedField",
|
||||
"name": "node",
|
||||
"plural": false,
|
||||
"selections": [
|
||||
(v3/*: any*/),
|
||||
(v2/*: any*/),
|
||||
{
|
||||
"kind": "InlineFragment",
|
||||
"selections": [
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v5/*: any*/),
|
||||
"concreteType": "PolicyConnection",
|
||||
"kind": "LinkedField",
|
||||
"name": "policies",
|
||||
"plural": false,
|
||||
"selections": (v4/*: any*/),
|
||||
"storageKey": "policies(first:100)"
|
||||
},
|
||||
{
|
||||
"alias": null,
|
||||
"args": (v5/*: any*/),
|
||||
"filters": null,
|
||||
"handle": "connection",
|
||||
"key": "Organization__policies",
|
||||
"kind": "LinkedHandle",
|
||||
"name": "policies"
|
||||
}
|
||||
],
|
||||
"type": "Organization",
|
||||
"abstractKey": null
|
||||
}
|
||||
],
|
||||
"storageKey": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"cacheID": "3a3c91f3e18e8a4b66011aa583e262e4",
|
||||
"id": null,
|
||||
"metadata": {
|
||||
"connection": [
|
||||
{
|
||||
"count": null,
|
||||
"cursor": null,
|
||||
"direction": "forward",
|
||||
"path": [
|
||||
"organization",
|
||||
"policies"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "ControlOrganizationPoliciesQuery",
|
||||
"operationKind": "query",
|
||||
"text": "query ControlOrganizationPoliciesQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ... on Organization {\n policies(first: 100) {\n edges {\n node {\n id\n name\n content\n status\n reviewDate\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
(node as any).hash = "f4699d729e6df6192efd201047689204";
|
||||
|
||||
export default node;
|
||||
@@ -43,7 +43,7 @@ func (cp ControlPolicy) Insert(
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
controls_mitigations (
|
||||
controls_policies (
|
||||
control_id,
|
||||
policy_id,
|
||||
tenant_id,
|
||||
@@ -75,7 +75,7 @@ func (cp ControlPolicy) Delete(
|
||||
q := `
|
||||
DELETE
|
||||
FROM
|
||||
controls_mitigations
|
||||
controls_policies
|
||||
WHERE
|
||||
%s
|
||||
AND control_id = @control_id
|
||||
|
||||
@@ -266,7 +266,6 @@ WITH plcs AS (
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
owner_id,
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user