diff --git a/apps/console/src/components/organizations/DeleteOrganizationDialog.tsx b/apps/console/src/components/organizations/DeleteOrganizationDialog.tsx new file mode 100644 index 000000000..226abc96c --- /dev/null +++ b/apps/console/src/components/organizations/DeleteOrganizationDialog.tsx @@ -0,0 +1,89 @@ +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogFooter, + Button, + Field, + IconTrashCan, + useDialogRef, +} from "@probo/ui"; +import { useTranslate } from "@probo/i18n"; +import { sprintf } from "@probo/helpers"; + +type DeleteOrganizationDialogProps = { + children: React.ReactNode; + organizationName: string; + onConfirm: () => void; + isDeleting?: boolean; +}; + +export function DeleteOrganizationDialog({ + children, + organizationName, + onConfirm, + isDeleting = false, +}: DeleteOrganizationDialogProps) { + const { __ } = useTranslate(); + const [inputValue, setInputValue] = useState(""); + const dialogRef = useDialogRef(); + const isConfirmDisabled = inputValue !== organizationName || isDeleting; + + const handleConfirm = () => { + if (inputValue === organizationName) { + onConfirm(); + } + }; + + useEffect(() => { + if (!isDeleting) { + setInputValue(""); + } + }, [isDeleting]); + + return ( + + +

+ {sprintf( + __("This will permanently delete the organization %s and all its data."), + organizationName + )} +

+ +

+ {__("This action cannot be undone.")} +

+ + setInputValue(e.target.value)} + placeholder={organizationName} + disabled={isDeleting} + autoComplete="off" + autoFocus + /> +
+ + + +
+ ); +} diff --git a/apps/console/src/hooks/graph/OrganizationGraph.ts b/apps/console/src/hooks/graph/OrganizationGraph.ts index 6f924e88b..2d4330cb5 100644 --- a/apps/console/src/hooks/graph/OrganizationGraph.ts +++ b/apps/console/src/hooks/graph/OrganizationGraph.ts @@ -1,4 +1,7 @@ import { graphql } from "relay-runtime"; +import { useTranslate } from "@probo/i18n"; +import { useMutationWithToasts } from "../useMutationWithToasts"; +import type { OrganizationGraphDeleteMutation } from "./__generated__/OrganizationGraphDeleteMutation.graphql"; export const organizationViewQuery = graphql` query OrganizationGraph_ViewQuery($organizationId: ID!) { @@ -11,3 +14,26 @@ export const organizationViewQuery = graphql` } } `; + +const deleteOrganizationMutation = graphql` + mutation OrganizationGraphDeleteMutation( + $input: DeleteOrganizationInput! + $connections: [ID!]! + ) { + deleteOrganization(input: $input) { + deletedOrganizationId @deleteEdge(connections: $connections) + } + } +`; + +export function useDeleteOrganizationMutation() { + const { __ } = useTranslate(); + + return useMutationWithToasts( + deleteOrganizationMutation, + { + successMessage: __("Organization deleted successfully."), + errorMessage: __("Failed to delete organization. Please try again."), + } + ); +} diff --git a/apps/console/src/hooks/graph/__generated__/OrganizationGraphDeleteMutation.graphql.ts b/apps/console/src/hooks/graph/__generated__/OrganizationGraphDeleteMutation.graphql.ts new file mode 100644 index 000000000..bd5041c71 --- /dev/null +++ b/apps/console/src/hooks/graph/__generated__/OrganizationGraphDeleteMutation.graphql.ts @@ -0,0 +1,132 @@ +/** + * @generated SignedSource<<4c0f8b7da3434de8036a0a24cf89a7ba>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type DeleteOrganizationInput = { + organizationId: string; +}; +export type OrganizationGraphDeleteMutation$variables = { + connections: ReadonlyArray; + input: DeleteOrganizationInput; +}; +export type OrganizationGraphDeleteMutation$data = { + readonly deleteOrganization: { + readonly deletedOrganizationId: string; + }; +}; +export type OrganizationGraphDeleteMutation = { + response: OrganizationGraphDeleteMutation$data; + variables: OrganizationGraphDeleteMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "connections" +}, +v1 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" +}, +v2 = [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } +], +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "deletedOrganizationId", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/) + ], + "kind": "Fragment", + "metadata": null, + "name": "OrganizationGraphDeleteMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "DeleteOrganizationPayload", + "kind": "LinkedField", + "name": "deleteOrganization", + "plural": false, + "selections": [ + (v3/*: any*/) + ], + "storageKey": null + } + ], + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [ + (v1/*: any*/), + (v0/*: any*/) + ], + "kind": "Operation", + "name": "OrganizationGraphDeleteMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "DeleteOrganizationPayload", + "kind": "LinkedField", + "name": "deleteOrganization", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "filters": null, + "handle": "deleteEdge", + "key": "", + "kind": "ScalarHandle", + "name": "deletedOrganizationId", + "handleArgs": [ + { + "kind": "Variable", + "name": "connections", + "variableName": "connections" + } + ] + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "0f8180f1b546e4e232443869b04cfd36", + "id": null, + "metadata": {}, + "name": "OrganizationGraphDeleteMutation", + "operationKind": "mutation", + "text": "mutation OrganizationGraphDeleteMutation(\n $input: DeleteOrganizationInput!\n) {\n deleteOrganization(input: $input) {\n deletedOrganizationId\n }\n}\n" + } +}; +})(); + +(node as any).hash = "9cd2bd6602e261d2728652849d134c71"; + +export default node; diff --git a/apps/console/src/pages/OrganizationsPage.tsx b/apps/console/src/pages/OrganizationsPage.tsx index 590edc42f..e4da1f8de 100644 --- a/apps/console/src/pages/OrganizationsPage.tsx +++ b/apps/console/src/pages/OrganizationsPage.tsx @@ -4,13 +4,24 @@ import { graphql } from "relay-runtime"; import type { OrganizationsPageQuery as OrganizationsPageQueryType } from "./__generated__/OrganizationsPageQuery.graphql"; import { useEffect } from "react"; import { Link, useNavigate } from "react-router"; -import { Avatar, Button, Card, IconPlusLarge } from "@probo/ui"; +import { + Avatar, + Button, + Card, + IconPlusLarge, + ActionDropdown, + DropdownItem, + IconTrashCan, +} from "@probo/ui"; import { usePageTitle } from "@probo/hooks"; +import { useDeleteOrganizationMutation } from "../hooks/graph/OrganizationGraph"; +import { DeleteOrganizationDialog } from "../components/organizations/DeleteOrganizationDialog"; const OrganizationsPageQuery = graphql` query OrganizationsPageQuery { viewer { - organizations(first: 25) { + organizations(first: 25) @connection(key: "OrganizationsPage_organizations") { + __id edges { node { id @@ -37,10 +48,11 @@ export default function OrganizationsPage() { usePageTitle(__("Select an organization")); - // Redirect to the first organization if only one exists useEffect(() => { if (organizations.length === 1) { navigate(`/organizations/${organizations[0].id}`); + } else if (organizations.length === 0) { + navigate("/organizations/new"); } }, [organizations]); @@ -52,27 +64,11 @@ export default function OrganizationsPage() {
{organizations.map((organization) => ( - - -
- -

{organization.name}

-
- - -
+ organization={organization} + connectionId={data.viewer.organizations.__id} + /> ))}

@@ -95,3 +91,68 @@ export default function OrganizationsPage() { ); } + +type OrganizationCardProps = { + organization: { + id: string; + name: string; + logoUrl: string | null | undefined; + }; + connectionId: string; +}; + +function OrganizationCard({ organization, connectionId }: OrganizationCardProps) { + const { __ } = useTranslate(); + const [deleteOrganization, isDeleting] = useDeleteOrganizationMutation(); + + const handleDelete = () => { + return deleteOrganization({ + variables: { + input: { + organizationId: organization.id, + }, + connections: [connectionId], + }, + }); + }; + + return ( + +
+ + +

{organization.name}

+ +
+ + + + + {__("Delete organization")} + + + +
+
+
+ ); +} diff --git a/apps/console/src/pages/__generated__/OrganizationsPageQuery.graphql.ts b/apps/console/src/pages/__generated__/OrganizationsPageQuery.graphql.ts index 85bd22adb..fec9aa8f6 100644 --- a/apps/console/src/pages/__generated__/OrganizationsPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/OrganizationsPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<91cc89e5e07572ab16c7bc9b8443fcd6>> + * @generated SignedSource<<70d28036f17c3b03dcfecce6abd5b476>> * @lightSyntaxTransform * @nogrep */ @@ -13,6 +13,7 @@ export type OrganizationsPageQuery$variables = Record; export type OrganizationsPageQuery$data = { readonly viewer: { readonly organizations: { + readonly __id: string; readonly edges: ReadonlyArray<{ readonly node: { readonly id: string; @@ -36,60 +37,103 @@ var v0 = { "name": "id", "storageKey": null }, -v1 = { - "alias": null, - "args": [ - { - "kind": "Literal", - "name": "first", - "value": 25 - } - ], - "concreteType": "OrganizationConnection", - "kind": "LinkedField", - "name": "organizations", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "concreteType": "OrganizationEdge", - "kind": "LinkedField", - "name": "edges", - "plural": true, - "selections": [ - { - "alias": null, - "args": null, - "concreteType": "Organization", - "kind": "LinkedField", - "name": "node", - "plural": false, - "selections": [ - (v0/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "name", - "storageKey": null - }, - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "logoUrl", - "storageKey": null - } - ], - "storageKey": null - } - ], - "storageKey": null - } - ], - "storageKey": "organizations(first:25)" -}; +v1 = [ + { + "alias": null, + "args": null, + "concreteType": "OrganizationEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Organization", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "logoUrl", + "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 + }, + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__id", + "storageKey": null + } + ] + } +], +v2 = [ + { + "kind": "Literal", + "name": "first", + "value": 25 + } +]; return { "fragment": { "argumentDefinitions": [], @@ -105,7 +149,16 @@ return { "name": "viewer", "plural": false, "selections": [ - (v1/*: any*/) + { + "alias": "organizations", + "args": null, + "concreteType": "OrganizationConnection", + "kind": "LinkedField", + "name": "__OrganizationsPage_organizations_connection", + "plural": false, + "selections": (v1/*: any*/), + "storageKey": null + } ], "storageKey": null } @@ -127,7 +180,25 @@ return { "name": "viewer", "plural": false, "selections": [ - (v1/*: any*/), + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "OrganizationConnection", + "kind": "LinkedField", + "name": "organizations", + "plural": false, + "selections": (v1/*: any*/), + "storageKey": "organizations(first:25)" + }, + { + "alias": null, + "args": (v2/*: any*/), + "filters": null, + "handle": "connection", + "key": "OrganizationsPage_organizations", + "kind": "LinkedHandle", + "name": "organizations" + }, (v0/*: any*/) ], "storageKey": null @@ -135,16 +206,28 @@ return { ] }, "params": { - "cacheID": "e771f8e44e9f9224d9cbe0f271b096ed", + "cacheID": "ddaafef96749a932a0cee5286a3c2bbd", "id": null, - "metadata": {}, + "metadata": { + "connection": [ + { + "count": null, + "cursor": null, + "direction": "forward", + "path": [ + "viewer", + "organizations" + ] + } + ] + }, "name": "OrganizationsPageQuery", "operationKind": "query", - "text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n }\n }\n }\n id\n }\n}\n" + "text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n id\n }\n}\n" } }; })(); -(node as any).hash = "457b1b8b28355830d77458bab7af23f2"; +(node as any).hash = "122ab4331082ffe892fa7fe48692d961"; export default node; diff --git a/apps/console/src/pages/organizations/SettingsPage.tsx b/apps/console/src/pages/organizations/SettingsPage.tsx index 8d4f7e94f..39e99dda7 100644 --- a/apps/console/src/pages/organizations/SettingsPage.tsx +++ b/apps/console/src/pages/organizations/SettingsPage.tsx @@ -32,6 +32,9 @@ import clsx from "clsx"; import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; import { useOrganizationId } from "/hooks/useOrganizationId"; import { InviteUserDialog } from "/components/organizations/InviteUserDialog"; +import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph"; +import { useNavigate } from "react-router"; +import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog"; type Props = { queryRef: PreloadedQuery; @@ -79,6 +82,7 @@ const updateOrganizationMutation = graphql` export default function SettingsPage({ queryRef }: Props) { const { __ } = useTranslate(); + const navigate = useNavigate(); const organizationKey = usePreloadedQuery( organizationViewQuery, queryRef @@ -91,6 +95,7 @@ export default function SettingsPage({ queryRef }: Props) { const [updateOrganization, isUpdating] = useMutation( updateOrganizationMutation ); + const [deleteOrganization, isDeleting] = useDeleteOrganizationMutation(); const users = organization.users.edges.map((edge) => edge.node); const updateOrganizationName = useDebounceCallback((name: string) => { @@ -132,6 +137,20 @@ export default function SettingsPage({ queryRef }: Props) { }); }; + const handleDeleteOrganization = () => { + return deleteOrganization({ + variables: { + input: { + organizationId: organization.id, + }, + connections: [], + }, + onSuccess: () => { + navigate("/", { replace: true }); + }, + }); + }; + return (
@@ -200,6 +219,36 @@ export default function SettingsPage({ queryRef }: Props) { />
+ +
+

{__("Danger Zone")}

+ +
+

+ {__("Delete Organization")} +

+

+ {__("Permanently delete this organization and all its data.")}{" "} + + {__("This action cannot be undone.")} + +

+
+ + + +
+

); } diff --git a/apps/console/src/pages/organizations/documents/__generated__/DocumentDetailPageUpdateMutation.graphql.ts b/apps/console/src/pages/organizations/documents/__generated__/DocumentDetailPageUpdateMutation.graphql.ts index 5d92463c6..e7463c41b 100644 --- a/apps/console/src/pages/organizations/documents/__generated__/DocumentDetailPageUpdateMutation.graphql.ts +++ b/apps/console/src/pages/organizations/documents/__generated__/DocumentDetailPageUpdateMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<78eb861601ac1babc8f02dc877d9e1cf>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -25,6 +25,7 @@ export type DocumentDetailPageUpdateMutation$variables = { export type DocumentDetailPageUpdateMutation$data = { readonly updateDocument: { readonly document: { + readonly documentType: DocumentType; readonly id: string; readonly owner: { readonly fullName: string; @@ -85,6 +86,13 @@ v2 = [ "name": "title", "storageKey": null }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "documentType", + "storageKey": null + }, { "alias": null, "args": null, @@ -129,16 +137,16 @@ return { "selections": (v2/*: any*/) }, "params": { - "cacheID": "bb375479bf3446cccb49753a27fb6bcb", + "cacheID": "23268b13b11a1a56164dcc4cb96e24b3", "id": null, "metadata": {}, "name": "DocumentDetailPageUpdateMutation", "operationKind": "mutation", - "text": "mutation DocumentDetailPageUpdateMutation(\n $input: UpdateDocumentInput!\n) {\n updateDocument(input: $input) {\n document {\n id\n title\n owner {\n id\n fullName\n }\n }\n }\n}\n" + "text": "mutation DocumentDetailPageUpdateMutation(\n $input: UpdateDocumentInput!\n) {\n updateDocument(input: $input) {\n document {\n id\n title\n documentType\n owner {\n id\n fullName\n }\n }\n }\n}\n" } }; })(); -(node as any).hash = "9d204fe6a01448bd05d68699c06d18f9"; +(node as any).hash = "507a2bd40b923d2064fdd864f16f29aa"; export default node; diff --git a/apps/console/src/pages/organizations/documents/dialogs/__generated__/UpdateDocumentDialogMutation.graphql.ts b/apps/console/src/pages/organizations/documents/dialogs/__generated__/UpdateDocumentDialogMutation.graphql.ts deleted file mode 100644 index 8ca538063..000000000 --- a/apps/console/src/pages/organizations/documents/dialogs/__generated__/UpdateDocumentDialogMutation.graphql.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @generated SignedSource<<5b79ea2de8e0c11b167b479ef72558c6>> - * @lightSyntaxTransform - * @nogrep - */ - -/* tslint:disable */ -/* eslint-disable */ -// @ts-nocheck - -import { ConcreteRequest } from 'relay-runtime'; -export type DocumentType = "ISMS" | "OTHER" | "POLICY"; -export type UpdateDocumentInput = { - content?: string | null | undefined; - createdBy?: string | null | undefined; - documentType?: DocumentType | null | undefined; - id: string; - ownerId?: string | null | undefined; - showOnTrustCenter?: boolean | null | undefined; - title?: string | null | undefined; -}; -export type UpdateDocumentDialogMutation$variables = { - input: UpdateDocumentInput; -}; -export type UpdateDocumentDialogMutation$data = { - readonly updateDocument: { - readonly document: { - readonly id: string; - readonly owner: { - readonly fullName: string; - readonly id: string; - }; - readonly title: string; - }; - }; -}; -export type UpdateDocumentDialogMutation = { - response: UpdateDocumentDialogMutation$data; - variables: UpdateDocumentDialogMutation$variables; -}; - -const node: ConcreteRequest = (function(){ -var v0 = [ - { - "defaultValue": null, - "kind": "LocalArgument", - "name": "input" - } -], -v1 = { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "id", - "storageKey": null -}, -v2 = [ - { - "alias": null, - "args": [ - { - "kind": "Variable", - "name": "input", - "variableName": "input" - } - ], - "concreteType": "UpdateDocumentPayload", - "kind": "LinkedField", - "name": "updateDocument", - "plural": false, - "selections": [ - { - "alias": null, - "args": null, - "concreteType": "Document", - "kind": "LinkedField", - "name": "document", - "plural": false, - "selections": [ - (v1/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "title", - "storageKey": null - }, - { - "alias": null, - "args": null, - "concreteType": "People", - "kind": "LinkedField", - "name": "owner", - "plural": false, - "selections": [ - (v1/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "fullName", - "storageKey": null - } - ], - "storageKey": null - } - ], - "storageKey": null - } - ], - "storageKey": null - } -]; -return { - "fragment": { - "argumentDefinitions": (v0/*: any*/), - "kind": "Fragment", - "metadata": null, - "name": "UpdateDocumentDialogMutation", - "selections": (v2/*: any*/), - "type": "Mutation", - "abstractKey": null - }, - "kind": "Request", - "operation": { - "argumentDefinitions": (v0/*: any*/), - "kind": "Operation", - "name": "UpdateDocumentDialogMutation", - "selections": (v2/*: any*/) - }, - "params": { - "cacheID": "27831214e4f4c0a988553abccf99d547", - "id": null, - "metadata": {}, - "name": "UpdateDocumentDialogMutation", - "operationKind": "mutation", - "text": "mutation UpdateDocumentDialogMutation(\n $input: UpdateDocumentInput!\n) {\n updateDocument(input: $input) {\n document {\n id\n title\n owner {\n id\n fullName\n }\n }\n }\n}\n" - } -}; -})(); - -(node as any).hash = "ea3902438d59438b49c5d34edadbe5e4"; - -export default node; diff --git a/pkg/coredata/document.go b/pkg/coredata/document.go index baf0f2f25..15f17d79d 100644 --- a/pkg/coredata/document.go +++ b/pkg/coredata/document.go @@ -250,6 +250,25 @@ DELETE FROM documents WHERE %s AND id = @document_id return err } +func (p Document) DeleteByOrganizationID( + ctx context.Context, + conn pg.Conn, + scope Scoper, + organizationID gid.GID, +) error { + q := ` +DELETE FROM documents WHERE %s AND organization_id = @organization_id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"organization_id": organizationID} + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + return err +} + func (p *Document) Update( ctx context.Context, conn pg.Conn, diff --git a/pkg/coredata/migrations/20250807T141252Z.sql b/pkg/coredata/migrations/20250807T141252Z.sql new file mode 100644 index 000000000..aa84fac87 --- /dev/null +++ b/pkg/coredata/migrations/20250807T141252Z.sql @@ -0,0 +1,45 @@ +ALTER TABLE frameworks DROP CONSTRAINT IF EXISTS frameworks_organization_id_fkey; +ALTER TABLE frameworks ADD CONSTRAINT frameworks_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE peoples DROP CONSTRAINT IF EXISTS peoples_organization_id_fkey; +ALTER TABLE peoples ADD CONSTRAINT peoples_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE vendors DROP CONSTRAINT IF EXISTS vendors_organization_id_fkey; +ALTER TABLE vendors ADD CONSTRAINT vendors_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE documents DROP CONSTRAINT IF EXISTS policies_organization_id_fkey; +ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_organization_id_fkey; +ALTER TABLE documents ADD CONSTRAINT documents_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE risks DROP CONSTRAINT IF EXISTS risks_organization_id_fkey; +ALTER TABLE risks ADD CONSTRAINT risks_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE connectors DROP CONSTRAINT IF EXISTS connectors_organization_id_fkey; +ALTER TABLE connectors ADD CONSTRAINT connectors_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE data DROP CONSTRAINT IF EXISTS data_organization_id_fkey; +ALTER TABLE data ADD CONSTRAINT data_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE users_organizations ADD CONSTRAINT users_organizations_organization_id_fkey + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE controls DROP CONSTRAINT IF EXISTS controls_framework_id_fkey; +ALTER TABLE controls ADD CONSTRAINT controls_framework_id_fkey + FOREIGN KEY (framework_id) REFERENCES frameworks(id) ON DELETE CASCADE; + +ALTER TABLE document_versions DROP CONSTRAINT IF EXISTS document_versions_document_id_fkey; +ALTER TABLE document_versions ADD CONSTRAINT document_versions_document_id_fkey + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE; + +ALTER TABLE document_versions DROP CONSTRAINT IF EXISTS document_versions_published_by_fkey; +ALTER TABLE document_versions ADD CONSTRAINT document_versions_published_by_fkey + FOREIGN KEY (published_by) REFERENCES peoples(id) + ON DELETE SET NULL + ON UPDATE CASCADE; diff --git a/pkg/coredata/organization.go b/pkg/coredata/organization.go index e3c03f85b..825e913a7 100644 --- a/pkg/coredata/organization.go +++ b/pkg/coredata/organization.go @@ -156,3 +156,28 @@ WHERE return nil } + +func (o *Organization) Delete( + ctx context.Context, + conn pg.Conn, + scope Scoper, +) error { + q := ` +DELETE FROM organizations +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"id": o.ID} + maps.Copy(args, scope.SQLArguments()) + + _, err := conn.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot delete organization: %w", err) + } + + return nil +} diff --git a/pkg/probo/organization_service.go b/pkg/probo/organization_service.go index 5f7a3be5c..182270567 100644 --- a/pkg/probo/organization_service.go +++ b/pkg/probo/organization_service.go @@ -281,6 +281,51 @@ func (s OrganizationService) GenerateLogoURL( return &presignedReq.URL, nil } +func (s OrganizationService) Delete( + ctx context.Context, + organizationID gid.GID, +) error { + organization := &coredata.Organization{} + + err := s.svc.pg.WithTx( + ctx, + func(tx pg.Conn) error { + if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil { + return fmt.Errorf("cannot load organization: %w", err) + } + + // Delete documents first because versions and signatures have a delete restriction on people + // that must be resolved before deleting the organization + document := &coredata.Document{} + if err := document.DeleteByOrganizationID(ctx, tx, s.svc.scope, organizationID); err != nil { + return fmt.Errorf("cannot delete documents: %w", err) + } + + if err := organization.Delete(ctx, tx, s.svc.scope); err != nil { + return fmt.Errorf("cannot delete organization: %w", err) + } + + return nil + }, + ) + + if err != nil { + return err + } + + if organization.LogoObjectKey != "" { + _, err := s.svc.s3.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.svc.bucket), + Key: aws.String(organization.LogoObjectKey), + }) + if err != nil { + return fmt.Errorf("organization deleted but failed to delete logo from S3: %w", err) + } + } + + return nil +} + func (s OrganizationService) createProboVendor(ctx context.Context, tx pg.Conn, organization *coredata.Organization, now time.Time) error { proboData := &coredata.Vendor{ ID: gid.New(s.svc.scope.GetTenantID(), coredata.VendorEntityType), diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index f16f63b06..24949a787 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -1478,6 +1478,9 @@ type Mutation { updateOrganization( input: UpdateOrganizationInput! ): UpdateOrganizationPayload! + deleteOrganization( + input: DeleteOrganizationInput! + ): DeleteOrganizationPayload! updateTrustCenter( input: UpdateTrustCenterInput! @@ -1658,6 +1661,10 @@ input UpdateOrganizationInput { logo: Upload } +input DeleteOrganizationInput { + organizationId: ID! +} + input UpdateTrustCenterInput { trustCenterId: ID! active: Boolean @@ -2035,6 +2042,10 @@ type UpdateOrganizationPayload { organization: Organization! } +type DeleteOrganizationPayload { + deletedOrganizationId: ID! +} + type UpdateTrustCenterPayload { trustCenter: TrustCenter! } diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index 1bda67736..120da54f9 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -355,6 +355,10 @@ type ComplexityRoot struct { DeletedMeasureID func(childComplexity int) int } + DeleteOrganizationPayload struct { + DeletedOrganizationID func(childComplexity int) int + } + DeletePeoplePayload struct { DeletedPeopleID func(childComplexity int) int } @@ -601,6 +605,7 @@ type ComplexityRoot struct { DeleteEvidence func(childComplexity int, input types.DeleteEvidenceInput) int DeleteFramework func(childComplexity int, input types.DeleteFrameworkInput) int DeleteMeasure func(childComplexity int, input types.DeleteMeasureInput) int + DeleteOrganization func(childComplexity int, input types.DeleteOrganizationInput) int DeletePeople func(childComplexity int, input types.DeletePeopleInput) int DeleteRisk func(childComplexity int, input types.DeleteRiskInput) int DeleteRiskDocumentMapping func(childComplexity int, input types.DeleteRiskDocumentMappingInput) int @@ -1119,6 +1124,7 @@ type MeasureConnectionResolver interface { type MutationResolver interface { CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) UpdateOrganization(ctx context.Context, input types.UpdateOrganizationInput) (*types.UpdateOrganizationPayload, error) + DeleteOrganization(ctx context.Context, input types.DeleteOrganizationInput) (*types.DeleteOrganizationPayload, error) UpdateTrustCenter(ctx context.Context, input types.UpdateTrustCenterInput) (*types.UpdateTrustCenterPayload, error) CreateTrustCenterAccess(ctx context.Context, input types.CreateTrustCenterAccessInput) (*types.CreateTrustCenterAccessPayload, error) DeleteTrustCenterAccess(ctx context.Context, input types.DeleteTrustCenterAccessInput) (*types.DeleteTrustCenterAccessPayload, error) @@ -2115,6 +2121,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.DeleteMeasurePayload.DeletedMeasureID(childComplexity), true + case "DeleteOrganizationPayload.deletedOrganizationId": + if e.complexity.DeleteOrganizationPayload.DeletedOrganizationID == nil { + break + } + + return e.complexity.DeleteOrganizationPayload.DeletedOrganizationID(childComplexity), true + case "DeletePeoplePayload.deletedPeopleId": if e.complexity.DeletePeoplePayload.DeletedPeopleID == nil { break @@ -3357,6 +3370,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.DeleteMeasure(childComplexity, args["input"].(types.DeleteMeasureInput)), true + case "Mutation.deleteOrganization": + if e.complexity.Mutation.DeleteOrganization == nil { + break + } + + args, err := ec.field_Mutation_deleteOrganization_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.DeleteOrganization(childComplexity, args["input"].(types.DeleteOrganizationInput)), true + case "Mutation.deletePeople": if e.complexity.Mutation.DeletePeople == nil { break @@ -5473,6 +5498,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputDeleteEvidenceInput, ec.unmarshalInputDeleteFrameworkInput, ec.unmarshalInputDeleteMeasureInput, + ec.unmarshalInputDeleteOrganizationInput, ec.unmarshalInputDeletePeopleInput, ec.unmarshalInputDeleteRiskDocumentMappingInput, ec.unmarshalInputDeleteRiskInput, @@ -7110,6 +7136,9 @@ type Mutation { updateOrganization( input: UpdateOrganizationInput! ): UpdateOrganizationPayload! + deleteOrganization( + input: DeleteOrganizationInput! + ): DeleteOrganizationPayload! updateTrustCenter( input: UpdateTrustCenterInput! @@ -7290,6 +7319,10 @@ input UpdateOrganizationInput { logo: Upload } +input DeleteOrganizationInput { + organizationId: ID! +} + input UpdateTrustCenterInput { trustCenterId: ID! active: Boolean @@ -7667,6 +7700,10 @@ type UpdateOrganizationPayload { organization: Organization! } +type DeleteOrganizationPayload { + deletedOrganizationId: ID! +} + type UpdateTrustCenterPayload { trustCenter: TrustCenter! } @@ -10370,6 +10407,29 @@ func (ec *executionContext) field_Mutation_deleteMeasure_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_deleteOrganization_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_deleteOrganization_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_deleteOrganization_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.DeleteOrganizationInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNDeleteOrganizationInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationInput(ctx, tmp) + } + + var zeroVal types.DeleteOrganizationInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_deletePeople_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -19457,6 +19517,50 @@ func (ec *executionContext) fieldContext_DeleteMeasurePayload_deletedMeasureId(_ return fc, nil } +func (ec *executionContext) _DeleteOrganizationPayload_deletedOrganizationId(ctx context.Context, field graphql.CollectedField, obj *types.DeleteOrganizationPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_DeleteOrganizationPayload_deletedOrganizationId(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.DeletedOrganizationID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(gid.GID) + fc.Result = res + return ec.marshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_DeleteOrganizationPayload_deletedOrganizationId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "DeleteOrganizationPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _DeletePeoplePayload_deletedPeopleId(ctx context.Context, field graphql.CollectedField, obj *types.DeletePeoplePayload) (ret graphql.Marshaler) { fc, err := ec.fieldContext_DeletePeoplePayload_deletedPeopleId(ctx, field) if err != nil { @@ -25092,6 +25196,65 @@ func (ec *executionContext) fieldContext_Mutation_updateOrganization(ctx context return fc, nil } +func (ec *executionContext) _Mutation_deleteOrganization(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_deleteOrganization(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DeleteOrganization(rctx, fc.Args["input"].(types.DeleteOrganizationInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.DeleteOrganizationPayload) + fc.Result = res + return ec.marshalNDeleteOrganizationPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_deleteOrganization(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "deletedOrganizationId": + return ec.fieldContext_DeleteOrganizationPayload_deletedOrganizationId(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DeleteOrganizationPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deleteOrganization_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_updateTrustCenter(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_updateTrustCenter(ctx, field) if err != nil { @@ -44360,6 +44523,33 @@ func (ec *executionContext) unmarshalInputDeleteMeasureInput(ctx context.Context return it, nil } +func (ec *executionContext) unmarshalInputDeleteOrganizationInput(ctx context.Context, obj any) (types.DeleteOrganizationInput, error) { + var it types.DeleteOrganizationInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"organizationId"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "organizationId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("organizationId")) + data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.OrganizationID = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputDeletePeopleInput(ctx context.Context, obj any) (types.DeletePeopleInput, error) { var it types.DeletePeopleInput asMap := map[string]any{} @@ -49842,6 +50032,45 @@ func (ec *executionContext) _DeleteMeasurePayload(ctx context.Context, sel ast.S return out } +var deleteOrganizationPayloadImplementors = []string{"DeleteOrganizationPayload"} + +func (ec *executionContext) _DeleteOrganizationPayload(ctx context.Context, sel ast.SelectionSet, obj *types.DeleteOrganizationPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, deleteOrganizationPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeleteOrganizationPayload") + case "deletedOrganizationId": + out.Values[i] = ec._DeleteOrganizationPayload_deletedOrganizationId(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var deletePeoplePayloadImplementors = []string{"DeletePeoplePayload"} func (ec *executionContext) _DeletePeoplePayload(ctx context.Context, sel ast.SelectionSet, obj *types.DeletePeoplePayload) graphql.Marshaler { @@ -52270,6 +52499,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "deleteOrganization": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteOrganization(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "updateTrustCenter": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_updateTrustCenter(ctx, field) @@ -58964,6 +59200,25 @@ func (ec *executionContext) marshalNDeleteMeasurePayload2ᚖgithubᚗcomᚋgetpr return ec._DeleteMeasurePayload(ctx, sel, v) } +func (ec *executionContext) unmarshalNDeleteOrganizationInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationInput(ctx context.Context, v any) (types.DeleteOrganizationInput, error) { + res, err := ec.unmarshalInputDeleteOrganizationInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNDeleteOrganizationPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationPayload(ctx context.Context, sel ast.SelectionSet, v types.DeleteOrganizationPayload) graphql.Marshaler { + return ec._DeleteOrganizationPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDeleteOrganizationPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationPayload(ctx context.Context, sel ast.SelectionSet, v *types.DeleteOrganizationPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DeleteOrganizationPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNDeletePeopleInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeletePeopleInput(ctx context.Context, v any) (types.DeletePeopleInput, error) { res, err := ec.unmarshalInputDeletePeopleInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index 4c7204db7..64dc91a90 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -527,6 +527,14 @@ type DeleteMeasurePayload struct { DeletedMeasureID gid.GID `json:"deletedMeasureId"` } +type DeleteOrganizationInput struct { + OrganizationID gid.GID `json:"organizationId"` +} + +type DeleteOrganizationPayload struct { + DeletedOrganizationID gid.GID `json:"deletedOrganizationId"` +} + type DeletePeopleInput struct { PeopleID gid.GID `json:"peopleId"` } diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index aa5616a3d..0c44a04d0 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -980,6 +980,20 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input types.U }, nil } +// DeleteOrganization is the resolver for the deleteOrganization field. +func (r *mutationResolver) DeleteOrganization(ctx context.Context, input types.DeleteOrganizationInput) (*types.DeleteOrganizationPayload, error) { + prb := r.ProboService(ctx, input.OrganizationID.TenantID()) + + err := prb.Organizations.Delete(ctx, input.OrganizationID) + if err != nil { + return nil, fmt.Errorf("cannot delete organization: %w", err) + } + + return &types.DeleteOrganizationPayload{ + DeletedOrganizationID: input.OrganizationID, + }, nil +} + // UpdateTrustCenter is the resolver for the updateTrustCenter field. func (r *mutationResolver) UpdateTrustCenter(ctx context.Context, input types.UpdateTrustCenterInput) (*types.UpdateTrustCenterPayload, error) { prb := r.ProboService(ctx, input.TrustCenterID.TenantID())