From e0f6bbd204049b56d52cedde7768989cec756fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 26 Dec 2025 22:57:56 +0100 Subject: [PATCH] Plug back organization members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- .../organizations/settings/MembersPage.tsx | 155 +++++++ .../settings/MembersPageLoader.tsx | 32 ++ .../__generated__/MembersPageQuery.graphql.ts | 431 ++++++++++++++++++ .../settings/_components/EditMemberDialog.tsx | 131 ++++++ .../settings/_components/MemberList.tsx | 108 +++++ .../settings/_components/MemberListItem.tsx | 194 ++++++++ ...EditMemberDialog_updateMutation.graphql.ts | 116 +++++ .../MemberListFragment.graphql.ts | 250 ++++++++++ ...MemberListFragment_RefetchQuery.graphql.ts | 392 ++++++++++++++++ .../MemberListItemFragment.graphql.ts | 109 +++++ ...berListItem_currentRoleFragment.graphql.ts | 60 +++ ...berListItem_permissionsFragment.graphql.ts | 77 ++++ .../MemberListItem_removeMutation.graphql.ts | 133 ++++++ apps/console/src/routes.tsx | 13 +- pkg/coredata/membership.go | 2 +- pkg/iam/iam_actions.go | 1 + pkg/iam/organization_service.go | 67 +++ pkg/server/api/connect/v1/schema.graphql | 13 +- pkg/server/api/connect/v1/schema/schema.go | 262 ++++++++++- pkg/server/api/connect/v1/types/membership.go | 1 + pkg/server/api/connect/v1/types/types.go | 10 + pkg/server/api/connect/v1/v1_resolver.go | 21 + 22 files changed, 2555 insertions(+), 23 deletions(-) create mode 100644 apps/console/src/pages/iam/organizations/settings/MembersPage.tsx create mode 100644 apps/console/src/pages/iam/organizations/settings/MembersPageLoader.tsx create mode 100644 apps/console/src/pages/iam/organizations/settings/__generated__/MembersPageQuery.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/EditMemberDialog.tsx create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/MemberList.tsx create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/__generated__/EditMemberDialog_updateMutation.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment_RefetchQuery.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItemFragment.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_currentRoleFragment.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_permissionsFragment.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_removeMutation.graphql.ts diff --git a/apps/console/src/pages/iam/organizations/settings/MembersPage.tsx b/apps/console/src/pages/iam/organizations/settings/MembersPage.tsx new file mode 100644 index 000000000..cfa12ede9 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/MembersPage.tsx @@ -0,0 +1,155 @@ +import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; +import { graphql } from "relay-runtime"; +import { MemberList } from "./_components/MemberList"; +import type { MembersPageQuery } from "./__generated__/MembersPageQuery.graphql"; +import { useTranslate } from "@probo/i18n"; +import { Card, TabBadge, TabItem, Tabs } from "@probo/ui"; +import { useState } from "react"; + +export const membersPageQuery = graphql` + query MembersPageQuery($organizationId: ID!) { + viewer @required(action: THROW) { + ...MemberListItem_permissionsFragment + @arguments(organizationId: $organizationId) + } + organization: node(id: $organizationId) @required(action: THROW) { + __typename + ... on Organization { + ...MemberListFragment + @arguments(first: 20, order: { direction: ASC, field: CREATED_AT }) + members(first: 20, orderBy: { direction: ASC, field: CREATED_AT }) + @required(action: THROW) { + totalCount + } + } + } + } +`; + +export function MembersPage(props: { + queryRef: PreloadedQuery; +}) { + const { queryRef } = props; + + const { __ } = useTranslate(); + + const [activeTab, setActiveTab] = useState<"memberships" | "invitations">( + "memberships", + ); + + const { organization, viewer } = usePreloadedQuery( + membersPageQuery, + queryRef, + ); + if (organization.__typename !== "Organization") { + throw new Error("node is of invalid type"); + } + + return ( +
+
+

{__("Workspace members")}

+ {/* {isAuthorized("Organization", "inviteUser") && ( + + + + )} */} +
+ + + setActiveTab("memberships")} + > + {__("Members")} + {(organization.members.totalCount ?? 0) > 0 && ( + {organization.members.totalCount} + )} + + {/* setActiveTab("invitations")} + > + {__("Invitations")} + {(invitationsPagination.data.invitations?.totalCount || 0) > 0 && ( + + {invitationsPagination.data.invitations?.totalCount} + + )} + */} + + + +
+ {activeTab === "memberships" && ( + + )} + + {/* {activeTab === "invitations" && ( + { + invitationsPagination.refetch({ + order: { + direction: order.direction as "ASC" | "DESC", + field: order.field as + | "CREATED_AT" + | "EXPIRES_AT" + | "FULL_NAME" + | "EMAIL" + | "ROLE" + | "STATUS" + | "ACCEPTED_AT", + }, + }); + }} + pageSize={20} + > + + + {__("Name")} + {__("Email")} + {__("Role")} + {__("Invited")} + {__("Status")} + + {__("Accepted at")} + + + + + + {invitations.length === 0 ? ( + + + {__("No invitations")} + + + ) : ( + invitations.map((invitation) => ( + + )) + )} + + + )} */} +
+
+
+ ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/MembersPageLoader.tsx b/apps/console/src/pages/iam/organizations/settings/MembersPageLoader.tsx new file mode 100644 index 000000000..aac0ab0fc --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/MembersPageLoader.tsx @@ -0,0 +1,32 @@ +import { useQueryLoader } from "react-relay"; +import { MembersPage, membersPageQuery } from "./MembersPage"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import { IAMRelayProvider } from "/providers/IAMRelayProvider"; +import { useEffect } from "react"; +import type { MembersPageQuery } from "./__generated__/MembersPageQuery.graphql"; + +function MembersPageLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] = + useQueryLoader(membersPageQuery); + + useEffect(() => { + loadQuery({ + organizationId, + }); + }, [loadQuery, organizationId]); + + if (!queryRef) { + return null; + } + + return ; +} + +export default function () { + return ( + + + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/__generated__/MembersPageQuery.graphql.ts b/apps/console/src/pages/iam/organizations/settings/__generated__/MembersPageQuery.graphql.ts new file mode 100644 index 000000000..97ad651a1 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/__generated__/MembersPageQuery.graphql.ts @@ -0,0 +1,431 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type MembersPageQuery$variables = { + organizationId: string; +}; +export type MembersPageQuery$data = { + readonly organization: { + readonly __typename: "Organization"; + readonly members: { + readonly totalCount: number | null | undefined; + }; + readonly " $fragmentSpreads": FragmentRefs<"MemberListFragment">; + } | { + // This will never be '%other', but we need some + // value in case none of the concrete values match. + readonly __typename: "%other"; + }; + readonly viewer: { + readonly " $fragmentSpreads": FragmentRefs<"MemberListItem_permissionsFragment">; + }; +}; +export type MembersPageQuery = { + response: MembersPageQuery$data; + variables: MembersPageQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "organizationId" + } +], +v1 = { + "kind": "Variable", + "name": "id", + "variableName": "organizationId" +}, +v2 = [ + (v1/*: any*/) +], +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v4 = { + "kind": "Literal", + "name": "first", + "value": 20 +}, +v5 = { + "direction": "ASC", + "field": "CREATED_AT" +}, +v6 = [ + (v4/*: any*/), + { + "kind": "Literal", + "name": "orderBy", + "value": (v5/*: any*/) + } +], +v7 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "totalCount", + "storageKey": null +}, +v8 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v9 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "role", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "MembersPageQuery", + "selections": [ + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + { + "args": [ + { + "kind": "Variable", + "name": "organizationId", + "variableName": "organizationId" + } + ], + "kind": "FragmentSpread", + "name": "MemberListItem_permissionsFragment" + } + ], + "storageKey": null + }, + "action": "THROW" + }, + { + "kind": "RequiredField", + "field": { + "alias": "organization", + "args": (v2/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "args": [ + (v4/*: any*/), + { + "kind": "Literal", + "name": "order", + "value": (v5/*: any*/) + } + ], + "kind": "FragmentSpread", + "name": "MemberListFragment" + }, + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": (v6/*: any*/), + "concreteType": "MembershipConnection", + "kind": "LinkedField", + "name": "members", + "plural": false, + "selections": [ + (v7/*: any*/) + ], + "storageKey": "members(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})" + }, + "action": "THROW" + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "MembersPageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + { + "alias": "canUpdateMembership", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:membership:update" + }, + (v1/*: any*/) + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": null + }, + { + "alias": "canDeleteMembership", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:membership:delete" + }, + (v1/*: any*/) + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": null + }, + (v8/*: any*/) + ], + "storageKey": null + }, + { + "alias": "organization", + "args": (v2/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v3/*: any*/), + (v8/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "viewerMembership", + "plural": false, + "selections": [ + (v9/*: any*/), + (v8/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": (v6/*: any*/), + "concreteType": "MembershipConnection", + "kind": "LinkedField", + "name": "members", + "plural": false, + "selections": [ + (v7/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "MembershipEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v8/*: any*/), + (v9/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "MembershipProfile", + "kind": "LinkedField", + "name": "profile", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + }, + (v8/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "identity", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + }, + (v8/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "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 + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasPreviousPage", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "startCursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__id", + "storageKey": null + } + ] + } + ], + "storageKey": "members(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})" + }, + { + "alias": null, + "args": (v6/*: any*/), + "filters": [ + "orderBy" + ], + "handle": "connection", + "key": "MembersListFragment_members", + "kind": "LinkedHandle", + "name": "members" + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "b125343184bf4d82a7d0e264faf7ac34", + "id": null, + "metadata": {}, + "name": "MembersPageQuery", + "operationKind": "query", + "text": "query MembersPageQuery(\n $organizationId: ID!\n) {\n viewer {\n ...MemberListItem_permissionsFragment_4xMPKw\n id\n }\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...MemberListFragment_1jRT0c\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n }\n id\n }\n}\n\nfragment MemberListFragment_1jRT0c on Organization {\n ...MemberListItem_currentRoleFragment\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n}\n\nfragment MemberListItem_currentRoleFragment on Organization {\n viewerMembership {\n role\n id\n }\n}\n\nfragment MemberListItem_permissionsFragment_4xMPKw on Identity {\n canUpdateMembership: permission(action: \"iam:membership:update\", id: $organizationId)\n canDeleteMembership: permission(action: \"iam:membership:delete\", id: $organizationId)\n}\n" + } +}; +})(); + +(node as any).hash = "8c40640ce96580ebae677d117741094e"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/EditMemberDialog.tsx b/apps/console/src/pages/iam/organizations/settings/_components/EditMemberDialog.tsx new file mode 100644 index 000000000..f9cf1cd35 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/EditMemberDialog.tsx @@ -0,0 +1,131 @@ +import { getAssignableRoles, Role, sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + DialogContent, + Field, + Select, + Option, + DialogFooter, + Button, + Spinner, + Dialog, +} from "@probo/ui"; +import { useState } from "react"; +import { graphql } from "relay-runtime"; +import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import type { MemberListItemFragment$data } from "./__generated__/MemberListItemFragment.graphql"; + +const updateMembershipMutation = graphql` + mutation EditMemberDialog_updateMutation($input: UpdateMembershipInput!) { + updateMembership(input: $input) { + membership { + id + role + } + } + } +`; + +export function EditMemberDialog(props: { + currentRole: Role; + membership: MemberListItemFragment$data; + onClose: () => void; +}) { + const { currentRole, membership, onClose } = props; + + const organizationId = useOrganizationId(); + const { __ } = useTranslate(); + + const availableRoles = getAssignableRoles(currentRole); + + const [selectedRole, setSelectedRole] = useState(membership.role); + + const [updateMembership, isUpdating] = useMutationWithToasts( + updateMembershipMutation, + { + successMessage: __("Role updated successfully"), + errorMessage: __("Failed to update role"), + }, + ); + + const handleUpdateRole = (e: React.FormEvent) => { + e.preventDefault(); + updateMembership({ + variables: { + input: { + membershipId: membership.id, + organizationId: organizationId, + role: selectedRole, + }, + }, + onCompleted: onClose, + }); + }; + + return ( + +
+ +
+

+ {sprintf( + __("Update the role for %s"), + membership.profile.fullName, + )} +

+ + + + + +
+ {selectedRole === "OWNER" && ( +

{__("Full access to everything")}

+ )} + {selectedRole === "ADMIN" && ( +

+ {__("Full access except organization setup and API keys")} +

+ )} + {selectedRole === "VIEWER" &&

{__("Read-only access")}

} + {selectedRole === "AUDITOR" && ( +

+ {__("Read-only access without settings, tasks and meetings")} +

+ )} + {selectedRole === "EMPLOYEE" && ( +

{__("Access to employee page")}

+ )} +
+
+
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/MemberList.tsx b/apps/console/src/pages/iam/organizations/settings/_components/MemberList.tsx new file mode 100644 index 000000000..7397c58e4 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/MemberList.tsx @@ -0,0 +1,108 @@ +import { graphql, usePaginationFragment } from "react-relay"; +import { Tbody, Td, Th, Thead, Tr } from "@probo/ui"; +import { useTranslate } from "@probo/i18n"; +import { SortableTable, SortableTh } from "/components/SortableTable"; +import type { MemberListFragment_RefetchQuery } from "./__generated__/MemberListFragment_RefetchQuery.graphql"; +import { MemberListItem } from "./MemberListItem"; +import type { MemberListFragment$key } from "./__generated__/MemberListFragment.graphql"; +import type { MemberListItem_permissionsFragment$key } from "./__generated__/MemberListItem_permissionsFragment.graphql"; + +const fragment = graphql` + fragment MemberListFragment on Organization + @refetchable(queryName: "MemberListFragment_RefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 20 } + order: { + type: "MembershipOrder" + defaultValue: { direction: ASC, field: CREATED_AT } + } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + ...MemberListItem_currentRoleFragment + members( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + ) @connection(key: "MembersListFragment_members") @required(action: THROW) { + __id + totalCount + edges @required(action: THROW) { + node { + id + ...MemberListItemFragment + } + } + } + } +`; + +export function MemberList(props: { + fKey: MemberListFragment$key; + viewerFKey: MemberListItem_permissionsFragment$key; +}) { + const { fKey, viewerFKey } = props; + + const { __ } = useTranslate(); + + const membersPagination = usePaginationFragment< + MemberListFragment_RefetchQuery, + MemberListFragment$key + >(fragment, fKey); + + const refetchMemberships = () => { + membersPagination.refetch({}, { fetchPolicy: "network-only" }); + }; + + return ( + { + membersPagination.refetch({ + order: { + direction: order.direction as "ASC" | "DESC", + field: order.field as + | "CREATED_AT" + // FIXME: add those back + // | "FULL_NAME" + // | "EMAIL_ADDRESS" + | "ROLE", + }, + }); + }} + pageSize={20} + > + + + {__("Name")} + {__("Email")} + {__("Role")} + {__("Joined")} + + + + + {membersPagination.data.members.edges.length === 0 ? ( + + + {__("No members")} + + + ) : ( + membersPagination.data.members.edges.map(({ node: membership }) => ( + + )) + )} + + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx b/apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx new file mode 100644 index 000000000..590ddffea --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx @@ -0,0 +1,194 @@ +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Button, + IconPencil, + IconTrashCan, + Spinner, + Td, + Tr, + useConfirm, +} from "@probo/ui"; +import clsx from "clsx"; +import { useState } from "react"; +import { useFragment } from "react-relay"; +import { ConnectionHandler, graphql } from "relay-runtime"; +import type { MemberListItemFragment$key } from "./__generated__/MemberListItemFragment.graphql"; +import type { MemberListItem_permissionsFragment$key } from "./__generated__/MemberListItem_permissionsFragment.graphql"; +import type { MemberListItem_currentRoleFragment$key } from "./__generated__/MemberListItem_currentRoleFragment.graphql"; +import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import { sprintf } from "@probo/helpers"; +import { EditMemberDialog } from "./EditMemberDialog"; + +const fragment = graphql` + fragment MemberListItemFragment on Membership { + id + role + profile @required(action: THROW) { + fullName + } + identity @required(action: THROW) { + email + } + createdAt + } +`; + +const currentRoleFragment = graphql` + fragment MemberListItem_currentRoleFragment on Organization { + viewerMembership @required(action: THROW) { + role + } + } +`; + +const permissionsFragment = graphql` + fragment MemberListItem_permissionsFragment on Identity + @argumentDefinitions(organizationId: { type: "ID!" }) { + canUpdateMembership: permission( + action: "iam:membership:update" + id: $organizationId + ) + canDeleteMembership: permission( + action: "iam:membership:delete" + id: $organizationId + ) + } +`; + +const removeMemberMutation = graphql` + mutation MemberListItem_removeMutation( + $input: RemoveMemberInput! + $connections: [ID!]! + ) { + removeMember(input: $input) { + deletedMembershipId @deleteEdge(connections: $connections) + } + } +`; + +export function MemberListItem(props: { + fKey: MemberListItemFragment$key; + permissionsFKey: MemberListItem_currentRoleFragment$key; + viewerFKey: MemberListItem_permissionsFragment$key; + onRefetch: () => void; +}) { + const { fKey, onRefetch, permissionsFKey, viewerFKey } = props; + + const organizationId = useOrganizationId(); + const { __ } = useTranslate(); + const confirm = useConfirm(); + const [dialogOpen, setDialogOpen] = useState(false); + + const membership = useFragment(fragment, fKey); + const { viewerMembership } = + useFragment( + currentRoleFragment, + permissionsFKey, + ); + const permissions = useFragment( + permissionsFragment, + viewerFKey, + ); + + // Only OWNER can edit OWNER members + const canEditThisRole = + membership.role === "OWNER" ? viewerMembership.role === "OWNER" : true; + + const [removeMembership, isRemoving] = useMutationWithToasts( + removeMemberMutation, + { + successMessage: __("Member removed successfully"), + errorMessage: __("Failed to remove member"), + }, + ); + + const handleRemove = async () => { + const connectionId = ConnectionHandler.getConnectionID( + organizationId, + "MembersListFragment_members", + ); + confirm( + () => { + return removeMembership({ + variables: { + input: { + membershipId: membership.id, + organizationId: organizationId, + }, + connections: [connectionId], + }, + onCompleted: onRefetch, + }); + }, + { + message: sprintf( + __("Are you sure you want to remove %s?"), + membership.profile.fullName, + ), + }, + ); + }; + + return ( + <> + + +
{membership.profile.fullName}
+ + +
+ {membership.identity.email} + {/* FIXME: put back */} + {/* {membership.authMethod === "SAML" && ( + SAML + )} */} +
+ + + {membership.role} + + {new Date(membership.createdAt).toLocaleDateString()} + +
e.stopPropagation()} + > + {permissions.canUpdateMembership && canEditThisRole && ( +
+ + + + {dialogOpen && ( + setDialogOpen(false)} + membership={membership} + /> + )} + + ); +} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/__generated__/EditMemberDialog_updateMutation.graphql.ts b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/EditMemberDialog_updateMutation.graphql.ts new file mode 100644 index 000000000..737d88ee5 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/EditMemberDialog_updateMutation.graphql.ts @@ -0,0 +1,116 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER"; +export type UpdateMembershipInput = { + membershipId: string; + organizationId: string; + role: MembershipRole; +}; +export type EditMemberDialog_updateMutation$variables = { + input: UpdateMembershipInput; +}; +export type EditMemberDialog_updateMutation$data = { + readonly updateMembership: { + readonly membership: { + readonly id: string; + readonly role: MembershipRole; + }; + }; +}; +export type EditMemberDialog_updateMutation = { + response: EditMemberDialog_updateMutation$data; + variables: EditMemberDialog_updateMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "UpdateMembershipPayload", + "kind": "LinkedField", + "name": "updateMembership", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "membership", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "role", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "EditMemberDialog_updateMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "EditMemberDialog_updateMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "696ec9f74220b92171bf60630e5c8d7f", + "id": null, + "metadata": {}, + "name": "EditMemberDialog_updateMutation", + "operationKind": "mutation", + "text": "mutation EditMemberDialog_updateMutation(\n $input: UpdateMembershipInput!\n) {\n updateMembership(input: $input) {\n membership {\n id\n role\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "fc971ff69092912586c14c7f6e2111ed"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment.graphql.ts b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment.graphql.ts new file mode 100644 index 000000000..e45d14616 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment.graphql.ts @@ -0,0 +1,250 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type MemberListFragment$data = { + readonly id: string; + readonly members: { + readonly __id: string; + readonly edges: ReadonlyArray<{ + readonly node: { + readonly id: string; + readonly " $fragmentSpreads": FragmentRefs<"MemberListItemFragment">; + }; + }>; + readonly totalCount: number | null | undefined; + }; + readonly " $fragmentSpreads": FragmentRefs<"MemberListItem_currentRoleFragment">; + readonly " $fragmentType": "MemberListFragment"; +}; +export type MemberListFragment$key = { + readonly " $data"?: MemberListFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"MemberListFragment">; +}; + +import MemberListFragment_RefetchQuery_graphql from './MemberListFragment_RefetchQuery.graphql'; + +const node: ReaderFragment = (function(){ +var v0 = [ + "members" +], +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}; +return { + "argumentDefinitions": [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "after" + }, + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "before" + }, + { + "defaultValue": 20, + "kind": "LocalArgument", + "name": "first" + }, + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "last" + }, + { + "defaultValue": { + "direction": "ASC", + "field": "CREATED_AT" + }, + "kind": "LocalArgument", + "name": "order" + } + ], + "kind": "Fragment", + "metadata": { + "connection": [ + { + "count": null, + "cursor": null, + "direction": "bidirectional", + "path": (v0/*: any*/) + } + ], + "refetch": { + "connection": { + "forward": { + "count": "first", + "cursor": "after" + }, + "backward": { + "count": "last", + "cursor": "before" + }, + "path": (v0/*: any*/) + }, + "fragmentPathInResult": [ + "node" + ], + "operation": MemberListFragment_RefetchQuery_graphql, + "identifierInfo": { + "identifierField": "id", + "identifierQueryVariableName": "id" + } + } + }, + "name": "MemberListFragment", + "selections": [ + { + "args": null, + "kind": "FragmentSpread", + "name": "MemberListItem_currentRoleFragment" + }, + { + "kind": "RequiredField", + "field": { + "alias": "members", + "args": [ + { + "kind": "Variable", + "name": "orderBy", + "variableName": "order" + } + ], + "concreteType": "MembershipConnection", + "kind": "LinkedField", + "name": "__MembersListFragment_members_connection", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "totalCount", + "storageKey": null + }, + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "MembershipEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v1/*: any*/), + { + "args": null, + "kind": "FragmentSpread", + "name": "MemberListItemFragment" + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + }, + { + "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 + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasPreviousPage", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "startCursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__id", + "storageKey": null + } + ] + } + ], + "storageKey": null + }, + "action": "THROW" + }, + (v1/*: any*/) + ], + "type": "Organization", + "abstractKey": null +}; +})(); + +(node as any).hash = "dcdc5a8acd5e8f7317fbd1bcd3570f61"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment_RefetchQuery.graphql.ts b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment_RefetchQuery.graphql.ts new file mode 100644 index 000000000..05978133b --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListFragment_RefetchQuery.graphql.ts @@ -0,0 +1,392 @@ +/** + * @generated SignedSource<<4966da1a2a597f71a90c8766ad760615>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type MembershipOrderField = "CREATED_AT" | "ROLE"; +export type OrderDirection = "ASC" | "DESC"; +export type MembershipOrder = { + direction: OrderDirection; + field: MembershipOrderField; +}; +export type MemberListFragment_RefetchQuery$variables = { + after?: any | null | undefined; + before?: any | null | undefined; + first?: number | null | undefined; + id: string; + last?: number | null | undefined; + order?: MembershipOrder | null | undefined; +}; +export type MemberListFragment_RefetchQuery$data = { + readonly node: { + readonly " $fragmentSpreads": FragmentRefs<"MemberListFragment">; + } | null | undefined; +}; +export type MemberListFragment_RefetchQuery = { + response: MemberListFragment_RefetchQuery$data; + variables: MemberListFragment_RefetchQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "after" +}, +v1 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "before" +}, +v2 = { + "defaultValue": 20, + "kind": "LocalArgument", + "name": "first" +}, +v3 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" +}, +v4 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "last" +}, +v5 = { + "defaultValue": { + "direction": "ASC", + "field": "CREATED_AT" + }, + "kind": "LocalArgument", + "name": "order" +}, +v6 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } +], +v7 = { + "kind": "Variable", + "name": "after", + "variableName": "after" +}, +v8 = { + "kind": "Variable", + "name": "before", + "variableName": "before" +}, +v9 = { + "kind": "Variable", + "name": "first", + "variableName": "first" +}, +v10 = { + "kind": "Variable", + "name": "last", + "variableName": "last" +}, +v11 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v12 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v13 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "role", + "storageKey": null +}, +v14 = [ + (v7/*: any*/), + (v8/*: any*/), + (v9/*: any*/), + (v10/*: any*/), + { + "kind": "Variable", + "name": "orderBy", + "variableName": "order" + } +]; +return { + "fragment": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/), + (v2/*: any*/), + (v3/*: any*/), + (v4/*: any*/), + (v5/*: any*/) + ], + "kind": "Fragment", + "metadata": null, + "name": "MemberListFragment_RefetchQuery", + "selections": [ + { + "alias": null, + "args": (v6/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "args": [ + (v7/*: any*/), + (v8/*: any*/), + (v9/*: any*/), + (v10/*: any*/), + { + "kind": "Variable", + "name": "order", + "variableName": "order" + } + ], + "kind": "FragmentSpread", + "name": "MemberListFragment" + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/), + (v2/*: any*/), + (v4/*: any*/), + (v5/*: any*/), + (v3/*: any*/) + ], + "kind": "Operation", + "name": "MemberListFragment_RefetchQuery", + "selections": [ + { + "alias": null, + "args": (v6/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v11/*: any*/), + (v12/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "viewerMembership", + "plural": false, + "selections": [ + (v13/*: any*/), + (v12/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": (v14/*: any*/), + "concreteType": "MembershipConnection", + "kind": "LinkedField", + "name": "members", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "totalCount", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "MembershipEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v12/*: any*/), + (v13/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "MembershipProfile", + "kind": "LinkedField", + "name": "profile", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + }, + (v12/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "identity", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + }, + (v12/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + (v11/*: 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 + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasPreviousPage", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "startCursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__id", + "storageKey": null + } + ] + } + ], + "storageKey": null + }, + { + "alias": null, + "args": (v14/*: any*/), + "filters": [ + "orderBy" + ], + "handle": "connection", + "key": "MembersListFragment_members", + "kind": "LinkedHandle", + "name": "members" + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "df1f40dcee56d6e5181849cd82acb824", + "id": null, + "metadata": {}, + "name": "MemberListFragment_RefetchQuery", + "operationKind": "query", + "text": "query MemberListFragment_RefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MemberListFragment_16fISc\n id\n }\n}\n\nfragment MemberListFragment_16fISc on Organization {\n ...MemberListItem_currentRoleFragment\n members(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n}\n\nfragment MemberListItem_currentRoleFragment on Organization {\n viewerMembership {\n role\n id\n }\n}\n" + } +}; +})(); + +(node as any).hash = "dcdc5a8acd5e8f7317fbd1bcd3570f61"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItemFragment.graphql.ts b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItemFragment.graphql.ts new file mode 100644 index 000000000..162733377 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItemFragment.graphql.ts @@ -0,0 +1,109 @@ +/** + * @generated SignedSource<<50f7d8bffdec238d8a262e892db7be39>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER"; +import { FragmentRefs } from "relay-runtime"; +export type MemberListItemFragment$data = { + readonly createdAt: any; + readonly id: string; + readonly identity: { + readonly email: any; + }; + readonly profile: { + readonly fullName: string; + }; + readonly role: MembershipRole; + readonly " $fragmentType": "MemberListItemFragment"; +}; +export type MemberListItemFragment$key = { + readonly " $data"?: MemberListItemFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"MemberListItemFragment">; +}; + +const node: ReaderFragment = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "MemberListItemFragment", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "role", + "storageKey": null + }, + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "MembershipProfile", + "kind": "LinkedField", + "name": "profile", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + }, + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "identity", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + } + ], + "type": "Membership", + "abstractKey": null +}; + +(node as any).hash = "31ca1c20d23f9709b0c1575ae56aeb64"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_currentRoleFragment.graphql.ts b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_currentRoleFragment.graphql.ts new file mode 100644 index 000000000..6f96e4913 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_currentRoleFragment.graphql.ts @@ -0,0 +1,60 @@ +/** + * @generated SignedSource<<89f39cf7aa4cd7702b62f549ed8defdd>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER"; +import { FragmentRefs } from "relay-runtime"; +export type MemberListItem_currentRoleFragment$data = { + readonly viewerMembership: { + readonly role: MembershipRole; + }; + readonly " $fragmentType": "MemberListItem_currentRoleFragment"; +}; +export type MemberListItem_currentRoleFragment$key = { + readonly " $data"?: MemberListItem_currentRoleFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"MemberListItem_currentRoleFragment">; +}; + +const node: ReaderFragment = { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "MemberListItem_currentRoleFragment", + "selections": [ + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "Membership", + "kind": "LinkedField", + "name": "viewerMembership", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "role", + "storageKey": null + } + ], + "storageKey": null + }, + "action": "THROW" + } + ], + "type": "Organization", + "abstractKey": null +}; + +(node as any).hash = "9e7e3eefcb6f2f4398b0703a9342548b"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_permissionsFragment.graphql.ts b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_permissionsFragment.graphql.ts new file mode 100644 index 000000000..0bb88145d --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_permissionsFragment.graphql.ts @@ -0,0 +1,77 @@ +/** + * @generated SignedSource<<66b5754d2d4ecfc54f6c698df247e241>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type MemberListItem_permissionsFragment$data = { + readonly canDeleteMembership: boolean; + readonly canUpdateMembership: boolean; + readonly " $fragmentType": "MemberListItem_permissionsFragment"; +}; +export type MemberListItem_permissionsFragment$key = { + readonly " $data"?: MemberListItem_permissionsFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"MemberListItem_permissionsFragment">; +}; + +const node: ReaderFragment = (function(){ +var v0 = { + "kind": "Variable", + "name": "id", + "variableName": "organizationId" +}; +return { + "argumentDefinitions": [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "organizationId" + } + ], + "kind": "Fragment", + "metadata": null, + "name": "MemberListItem_permissionsFragment", + "selections": [ + { + "alias": "canUpdateMembership", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:membership:update" + }, + (v0/*: any*/) + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": null + }, + { + "alias": "canDeleteMembership", + "args": [ + { + "kind": "Literal", + "name": "action", + "value": "iam:membership:delete" + }, + (v0/*: any*/) + ], + "kind": "ScalarField", + "name": "permission", + "storageKey": null + } + ], + "type": "Identity", + "abstractKey": null +}; +})(); + +(node as any).hash = "d467db6a9f7f80521cf35c8c1a0c3416"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_removeMutation.graphql.ts b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_removeMutation.graphql.ts new file mode 100644 index 000000000..b2f142f0d --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/__generated__/MemberListItem_removeMutation.graphql.ts @@ -0,0 +1,133 @@ +/** + * @generated SignedSource<<1eebc63e903c6dd5ab9f0178d2aaaa90>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type RemoveMemberInput = { + membershipId: string; + organizationId: string; +}; +export type MemberListItem_removeMutation$variables = { + connections: ReadonlyArray; + input: RemoveMemberInput; +}; +export type MemberListItem_removeMutation$data = { + readonly removeMember: { + readonly deletedMembershipId: string; + } | null | undefined; +}; +export type MemberListItem_removeMutation = { + response: MemberListItem_removeMutation$data; + variables: MemberListItem_removeMutation$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": "deletedMembershipId", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/) + ], + "kind": "Fragment", + "metadata": null, + "name": "MemberListItem_removeMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "RemoveMemberPayload", + "kind": "LinkedField", + "name": "removeMember", + "plural": false, + "selections": [ + (v3/*: any*/) + ], + "storageKey": null + } + ], + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [ + (v1/*: any*/), + (v0/*: any*/) + ], + "kind": "Operation", + "name": "MemberListItem_removeMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "RemoveMemberPayload", + "kind": "LinkedField", + "name": "removeMember", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "filters": null, + "handle": "deleteEdge", + "key": "", + "kind": "ScalarHandle", + "name": "deletedMembershipId", + "handleArgs": [ + { + "kind": "Variable", + "name": "connections", + "variableName": "connections" + } + ] + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "6bf37e12e069385eabd2bba63e0e980f", + "id": null, + "metadata": {}, + "name": "MemberListItem_removeMutation", + "operationKind": "mutation", + "text": "mutation MemberListItem_removeMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n deletedMembershipId\n }\n}\n" + } +}; +})(); + +(node as any).hash = "9732b5ce9ca9de5c31e71a690e1763ed"; + +export default node; diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 6800b5bff..2e09af6c1 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -208,12 +208,13 @@ const routes = [ import("./pages/iam/organizations/settings/GeneralSettingsPageLoader.tsx"), ), }, - // { - // path: "members", - // Component: lazy( - // () => import("./pages/organizations/settings/MembersSettingsTab") - // ), - // }, + { + path: "members", + Component: lazy( + () => + import("./pages/iam/organizations/settings/MembersPageLoader.tsx"), + ), + }, { path: "domain", Component: lazy( diff --git a/pkg/coredata/membership.go b/pkg/coredata/membership.go index 8657d7b2f..d6af16239 100644 --- a/pkg/coredata/membership.go +++ b/pkg/coredata/membership.go @@ -503,7 +503,7 @@ WITH membership_with_profile AS ( iam_membership_profiles mp ON mp.membership_id = m.id WHERE m.organization_id = @organization_id - AND %s + AND m.%s ) SELECT id, diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go index 560f18bd8..7f2b0b188 100644 --- a/pkg/iam/iam_actions.go +++ b/pkg/iam/iam_actions.go @@ -51,6 +51,7 @@ const ( ActionIAMMembershipGet = "iam:membership:get" ActionIAMMembershipLit = "iam:membership:list" ActionIAMMembershipUpdate = "iam:membership:update" + ActionIAMMembershipDelete = "iam:membership:delete" // Personal API Key actions ActionIAMPersonalAPIKeyCreate = "iam:personal-api-key:create" diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 19d7aca56..1e730ab21 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -155,6 +155,73 @@ func NewOrganizationService(svc *Service) *OrganizationService { return &OrganizationService{Service: svc} } +func (s *OrganizationService) CountMemberships( + ctx context.Context, + organizationID gid.GID, +) (int, error) { + var count int + scope := coredata.NewScopeFromObjectID(organizationID) + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) (err error) { + memberships := coredata.Memberships{} + count, err = memberships.CountByOrganizationID(ctx, conn, scope, organizationID) + if err != nil { + return fmt.Errorf("cannot count memberships: %w", err) + } + + return nil + }, + ) + + if err != nil { + return 0, err + } + + return count, nil +} + +func (s *OrganizationService) UpdateMempership( + ctx context.Context, + organizationID gid.GID, + membershipID gid.GID, + role coredata.MembershipRole, +) (*coredata.Membership, error) { + scope := coredata.NewScopeFromObjectID(organizationID) + + membership := coredata.Membership{} + if err := s.pg.WithTx(ctx, + func(tx pg.Conn) error { + + if err := membership.LoadByID(ctx, tx, scope, membershipID); err != nil { + if err == coredata.ErrResourceNotFound { + return NewMembershipNotFoundError(membershipID) + } + + return fmt.Errorf("cannot load membership: %w", err) + } + + if membership.OrganizationID != organizationID { + return NewMembershipNotFoundError(membership.ID) + } + + membership.Role = role + membership.UpdatedAt = time.Now() + + if err := membership.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update membership: %w", err) + } + + return nil + }, + ); err != nil { + return nil, err + } + + return &membership, nil +} + func (s *OrganizationService) RemoveMember( ctx context.Context, organizationID gid.GID, diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index cca78ac9d..e6b6283fc 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -110,6 +110,7 @@ type Mutation { @session(required: PRESENT) deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload @session(required: PRESENT) + updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload! removeMember(input: RemoveMemberInput!): RemoveMemberPayload @session(required: PRESENT) @@ -232,7 +233,7 @@ type Membership implements Node { id: ID! createdAt: Datetime! identity: Identity @goField(forceResolver: true) - profile: MembershipProfile @goField(forceResolver: true) @isViewer + profile: MembershipProfile @goField(forceResolver: true) organization: Organization @goField(forceResolver: true) role: MembershipRole! permissions: [Permission!] @goField(forceResolver: true) @@ -601,6 +602,12 @@ input InviteMemberInput { fullName: String! } +input UpdateMembershipInput { + organizationId: ID! + membershipId: ID! + role: MembershipRole! +} + input RemoveMemberInput { organizationId: ID! membershipId: ID! @@ -754,6 +761,10 @@ type InviteMemberPayload { invitationEdge: InvitationEdge! } +type UpdateMembershipPayload { + membership: Membership! +} + type RemoveMemberPayload { deletedMembershipId: ID! } diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index 5e1e32191..55298f222 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -220,6 +220,7 @@ type ComplexityRoot struct { SignOut func(childComplexity int) int SignUp func(childComplexity int, input types.SignUpInput) int SignUpFromInvitation func(childComplexity int, input types.SignUpFromInvitationInput) int + UpdateMembership func(childComplexity int, input types.UpdateMembershipInput) int UpdateOrganization func(childComplexity int, input types.UpdateOrganizationInput) int UpdatePersonalAPIKey func(childComplexity int, input types.UpdatePersonalAPIKeyInput) int UpdateSAMLConfiguration func(childComplexity int, input types.UpdateSAMLConfigurationInput) int @@ -399,6 +400,10 @@ type ComplexityRoot struct { Identity func(childComplexity int) int } + UpdateMembershipPayload struct { + Membership func(childComplexity int) int + } + UpdateOrganizationPayload struct { Organization func(childComplexity int) int } @@ -462,6 +467,7 @@ type MutationResolver interface { DeleteOrganizationHorizontalLogo(ctx context.Context, input types.DeleteOrganizationHorizontalLogoInput) (*types.DeleteOrganizationHorizontalLogoPayload, error) InviteMember(ctx context.Context, input types.InviteMemberInput) (*types.InviteMemberPayload, error) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) + UpdateMembership(ctx context.Context, input types.UpdateMembershipInput) (*types.UpdateMembershipPayload, error) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) AcceptInvitation(ctx context.Context, input types.AcceptInvitationInput) (*types.AcceptInvitationPayload, error) CreateSAMLConfiguration(ctx context.Context, input types.CreateSAMLConfigurationInput) (*types.CreateSAMLConfigurationPayload, error) @@ -1163,6 +1169,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Mutation.SignUpFromInvitation(childComplexity, args["input"].(types.SignUpFromInvitationInput)), true + case "Mutation.updateMembership": + if e.complexity.Mutation.UpdateMembership == nil { + break + } + + args, err := ec.field_Mutation_updateMembership_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateMembership(childComplexity, args["input"].(types.UpdateMembershipInput)), true case "Mutation.updateOrganization": if e.complexity.Mutation.UpdateOrganization == nil { break @@ -1812,6 +1829,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.SignUpPayload.Identity(childComplexity), true + case "UpdateMembershipPayload.membership": + if e.complexity.UpdateMembershipPayload.Membership == nil { + break + } + + return e.complexity.UpdateMembershipPayload.Membership(childComplexity), true + case "UpdateOrganizationPayload.organization": if e.complexity.UpdateOrganizationPayload.Organization == nil { break @@ -1872,6 +1896,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputSignInInput, ec.unmarshalInputSignUpFromInvitationInput, ec.unmarshalInputSignUpInput, + ec.unmarshalInputUpdateMembershipInput, ec.unmarshalInputUpdateOrganizationInput, ec.unmarshalInputUpdatePersonalAPIKeyInput, ec.unmarshalInputUpdateSAMLConfigurationInput, @@ -2085,6 +2110,7 @@ type Mutation { @session(required: PRESENT) deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload @session(required: PRESENT) + updateMembership(input: UpdateMembershipInput!): UpdateMembershipPayload! removeMember(input: RemoveMemberInput!): RemoveMemberPayload @session(required: PRESENT) @@ -2207,7 +2233,7 @@ type Membership implements Node { id: ID! createdAt: Datetime! identity: Identity @goField(forceResolver: true) - profile: MembershipProfile @goField(forceResolver: true) @isViewer + profile: MembershipProfile @goField(forceResolver: true) organization: Organization @goField(forceResolver: true) role: MembershipRole! permissions: [Permission!] @goField(forceResolver: true) @@ -2576,6 +2602,12 @@ input InviteMemberInput { fullName: String! } +input UpdateMembershipInput { + organizationId: ID! + membershipId: ID! + role: MembershipRole! +} + input RemoveMemberInput { organizationId: ID! membershipId: ID! @@ -2729,6 +2761,10 @@ type InviteMemberPayload { invitationEdge: InvitationEdge! } +type UpdateMembershipPayload { + membership: Membership! +} + type RemoveMemberPayload { deletedMembershipId: ID! } @@ -3126,6 +3162,17 @@ func (ec *executionContext) field_Mutation_signUp_args(ctx context.Context, rawA return args, nil } +func (ec *executionContext) field_Mutation_updateMembership_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNUpdateMembershipInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateMembershipInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_updateOrganization_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -5062,20 +5109,7 @@ func (ec *executionContext) _Membership_profile(ctx context.Context, field graph func(ctx context.Context) (any, error) { return ec.resolvers.Membership().Profile(ctx, obj) }, - func(ctx context.Context, next graphql.Resolver) graphql.Resolver { - directive0 := next - - directive1 := func(ctx context.Context) (any, error) { - if ec.directives.IsViewer == nil { - var zeroVal *types.MembershipProfile - return zeroVal, errors.New("directive isViewer is not implemented") - } - return ec.directives.IsViewer(ctx, obj, directive0) - } - - next = directive1 - return next - }, + nil, ec.marshalOMembershipProfile2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐMembershipProfile, true, false, @@ -6896,6 +6930,51 @@ func (ec *executionContext) fieldContext_Mutation_deleteInvitation(ctx context.C return fc, nil } +func (ec *executionContext) _Mutation_updateMembership(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_updateMembership, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().UpdateMembership(ctx, fc.Args["input"].(types.UpdateMembershipInput)) + }, + nil, + ec.marshalNUpdateMembershipPayload2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateMembershipPayload, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_updateMembership(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 "membership": + return ec.fieldContext_UpdateMembershipPayload_membership(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UpdateMembershipPayload", 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_updateMembership_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_removeMember(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10522,6 +10601,53 @@ func (ec *executionContext) fieldContext_SignUpPayload_identity(_ context.Contex return fc, nil } +func (ec *executionContext) _UpdateMembershipPayload_membership(ctx context.Context, field graphql.CollectedField, obj *types.UpdateMembershipPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UpdateMembershipPayload_membership, + func(ctx context.Context) (any, error) { + return obj.Membership, nil + }, + nil, + ec.marshalNMembership2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐMembership, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UpdateMembershipPayload_membership(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateMembershipPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Membership_id(ctx, field) + case "createdAt": + return ec.fieldContext_Membership_createdAt(ctx, field) + case "identity": + return ec.fieldContext_Membership_identity(ctx, field) + case "profile": + return ec.fieldContext_Membership_profile(ctx, field) + case "organization": + return ec.fieldContext_Membership_organization(ctx, field) + case "role": + return ec.fieldContext_Membership_role(ctx, field) + case "permissions": + return ec.fieldContext_Membership_permissions(ctx, field) + case "lastSession": + return ec.fieldContext_Membership_lastSession(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Membership", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _UpdateOrganizationPayload_organization(ctx context.Context, field graphql.CollectedField, obj *types.UpdateOrganizationPayload) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -13002,6 +13128,47 @@ func (ec *executionContext) unmarshalInputSignUpInput(ctx context.Context, obj a return it, nil } +func (ec *executionContext) unmarshalInputUpdateMembershipInput(ctx context.Context, obj any) (types.UpdateMembershipInput, error) { + var it types.UpdateMembershipInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"organizationId", "membershipId", "role"} + 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.unmarshalNID2goᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.OrganizationID = data + case "membershipId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("membershipId")) + data, err := ec.unmarshalNID2goᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.MembershipID = data + case "role": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("role")) + data, err := ec.unmarshalNMembershipRole2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipRole(ctx, v) + if err != nil { + return it, err + } + it.Role = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputUpdateOrganizationInput(ctx context.Context, obj any) (types.UpdateOrganizationInput, error) { var it types.UpdateOrganizationInput asMap := map[string]any{} @@ -14886,6 +15053,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_deleteInvitation(ctx, field) }) + case "updateMembership": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateMembership(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "removeMember": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_removeMember(ctx, field) @@ -16634,6 +16808,45 @@ func (ec *executionContext) _SignUpPayload(ctx context.Context, sel ast.Selectio return out } +var updateMembershipPayloadImplementors = []string{"UpdateMembershipPayload"} + +func (ec *executionContext) _UpdateMembershipPayload(ctx context.Context, sel ast.SelectionSet, obj *types.UpdateMembershipPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, updateMembershipPayloadImplementors) + + 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("UpdateMembershipPayload") + case "membership": + out.Values[i] = ec._UpdateMembershipPayload_membership(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 updateOrganizationPayloadImplementors = []string{"UpdateOrganizationPayload"} func (ec *executionContext) _UpdateOrganizationPayload(ctx context.Context, sel ast.SelectionSet, obj *types.UpdateOrganizationPayload) graphql.Marshaler { @@ -18196,6 +18409,25 @@ func (ec *executionContext) marshalNTokenScope2ᚕgoᚗproboᚗincᚋproboᚋpkg return ret } +func (ec *executionContext) unmarshalNUpdateMembershipInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateMembershipInput(ctx context.Context, v any) (types.UpdateMembershipInput, error) { + res, err := ec.unmarshalInputUpdateMembershipInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNUpdateMembershipPayload2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateMembershipPayload(ctx context.Context, sel ast.SelectionSet, v types.UpdateMembershipPayload) graphql.Marshaler { + return ec._UpdateMembershipPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNUpdateMembershipPayload2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateMembershipPayload(ctx context.Context, sel ast.SelectionSet, v *types.UpdateMembershipPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._UpdateMembershipPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNUpdateOrganizationInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateOrganizationInput(ctx context.Context, v any) (types.UpdateOrganizationInput, error) { res, err := ec.unmarshalInputUpdateOrganizationInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/pkg/server/api/connect/v1/types/membership.go b/pkg/server/api/connect/v1/types/membership.go index 4a195082d..b5adced31 100644 --- a/pkg/server/api/connect/v1/types/membership.go +++ b/pkg/server/api/connect/v1/types/membership.go @@ -72,6 +72,7 @@ func NewMembership(membership *coredata.Membership) *Membership { Profile: &MembershipProfile{ ID: membership.ID, }, + Role: membership.Role, // Permissions: membership.Permissions, // ProvisionedBy: membership.ProvisionedBy, // Active: membership.Active, diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 39a07ae4c..3d072ac07 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -445,6 +445,16 @@ type SignUpPayload struct { Identity *Identity `json:"identity,omitempty"` } +type UpdateMembershipInput struct { + OrganizationID gid.GID `json:"organizationId"` + MembershipID gid.GID `json:"membershipId"` + Role coredata.MembershipRole `json:"role"` +} + +type UpdateMembershipPayload struct { + Membership *Membership `json:"membership"` +} + type UpdateOrganizationInput struct { OrganizationID gid.GID `json:"organizationId"` Name *string `json:"name,omitempty"` diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index 3daabeb3d..2557893d2 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -302,6 +302,14 @@ func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *type return nil, gqlutils.InternalServerError(ctx) } + return &count, nil + case *organizationResolver: + count, err := r.iam.OrganizationService.CountMemberships(ctx, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count memberships", log.Error(err)) + return nil, gqlutils.InternalServerError(ctx) + } + return &count, nil } @@ -864,6 +872,19 @@ func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.Del return &types.DeleteInvitationPayload{DeletedInvitationID: input.InvitationID}, nil } +// UpdateMembership is the resolver for the updateMembership field. +func (r *mutationResolver) UpdateMembership(ctx context.Context, input types.UpdateMembershipInput) (*types.UpdateMembershipPayload, error) { + membership, err := r.iam.OrganizationService.UpdateMempership(ctx, input.OrganizationID, input.MembershipID, input.Role) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot update membership", log.Error(err)) + return nil, gqlutils.InternalServerError(ctx) + } + + return &types.UpdateMembershipPayload{ + Membership: types.NewMembership(membership), + }, nil +} + // RemoveMember is the resolver for the removeMember field. func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) { err := r.iam.OrganizationService.RemoveMember(ctx, input.OrganizationID, input.MembershipID)