UpdateRolesConfirmationModal.tsx226 lines · main
| 1 | import { useQueryClient } from '@tanstack/react-query' |
| 2 | import { useParams } from 'common' |
| 3 | import { useState } from 'react' |
| 4 | import { toast } from 'sonner' |
| 5 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 6 | |
| 7 | import { |
| 8 | deriveChanges, |
| 9 | deriveRoleChangeActions, |
| 10 | formatMemberRoleToProjectRoleConfiguration, |
| 11 | ProjectRoleConfiguration, |
| 12 | } from './UpdateRolesPanel.utils' |
| 13 | import { organizationKeys } from '@/data/organization-members/keys' |
| 14 | import { useOrganizationMemberAssignRoleMutation } from '@/data/organization-members/organization-member-role-assign-mutation' |
| 15 | import { useOrganizationMemberUnassignRoleMutation } from '@/data/organization-members/organization-member-role-unassign-mutation' |
| 16 | import { useOrganizationMemberUpdateRoleMutation } from '@/data/organization-members/organization-member-role-update-mutation' |
| 17 | import { |
| 18 | OrganizationRole, |
| 19 | useOrganizationRolesV2Query, |
| 20 | } from '@/data/organization-members/organization-roles-query' |
| 21 | import { organizationKeys as organizationKeysV1 } from '@/data/organizations/keys' |
| 22 | import { OrganizationMember } from '@/data/organizations/organization-members-query' |
| 23 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 24 | |
| 25 | interface UpdateRolesConfirmationModal { |
| 26 | visible: boolean |
| 27 | member: OrganizationMember |
| 28 | projectsRoleConfiguration: ProjectRoleConfiguration[] |
| 29 | onClose: (success?: boolean) => void |
| 30 | } |
| 31 | |
| 32 | export const UpdateRolesConfirmationModal = ({ |
| 33 | visible, |
| 34 | member, |
| 35 | projectsRoleConfiguration, |
| 36 | onClose, |
| 37 | }: UpdateRolesConfirmationModal) => { |
| 38 | const { slug } = useParams() |
| 39 | const queryClient = useQueryClient() |
| 40 | const { data: organization } = useSelectedOrganizationQuery() |
| 41 | |
| 42 | const { data: allRoles } = useOrganizationRolesV2Query({ slug: organization?.slug }) |
| 43 | |
| 44 | // [Joshen] Separate saving state instead of using RQ due to several successive steps |
| 45 | const [saving, setSaving] = useState(false) |
| 46 | const { mutateAsync: assignRole } = useOrganizationMemberAssignRoleMutation() |
| 47 | const { mutateAsync: removeRole } = useOrganizationMemberUnassignRoleMutation({ |
| 48 | onError: () => {}, |
| 49 | }) |
| 50 | const { mutateAsync: updateRole } = useOrganizationMemberUpdateRoleMutation() |
| 51 | |
| 52 | const availableRoles = allRoles?.org_scoped_roles ?? [] |
| 53 | const { org_scoped_roles, project_scoped_roles } = allRoles ?? { |
| 54 | org_scoped_roles: [], |
| 55 | project_scoped_roles: [], |
| 56 | } |
| 57 | const originalConfiguration = |
| 58 | allRoles !== undefined ? formatMemberRoleToProjectRoleConfiguration(member, allRoles) : [] |
| 59 | const changesToRoles = deriveChanges(originalConfiguration, projectsRoleConfiguration) |
| 60 | |
| 61 | const onConfirmUpdateMemberRoles = async () => { |
| 62 | if (slug === undefined) return console.error('Slug is required') |
| 63 | |
| 64 | setSaving(true) |
| 65 | const gotrueId = member.gotrue_id |
| 66 | const existingRoles = member.role_ids |
| 67 | .map((id) => { |
| 68 | return [...org_scoped_roles, ...project_scoped_roles].find((r) => r.id === id) |
| 69 | }) |
| 70 | .filter(Boolean) as OrganizationRole[] |
| 71 | |
| 72 | const isChangeWithinOrgScope = |
| 73 | projectsRoleConfiguration.length === 1 && projectsRoleConfiguration[0].ref === undefined |
| 74 | |
| 75 | // Early return if we're just updating org level roles |
| 76 | // Everything else below is just project level role changes then |
| 77 | if (isChangeWithinOrgScope) { |
| 78 | try { |
| 79 | await assignRole({ |
| 80 | slug, |
| 81 | gotrueId, |
| 82 | roleId: projectsRoleConfiguration[0].roleId, |
| 83 | }) |
| 84 | toast.success(`Successfully updated role for ${member.username}`) |
| 85 | onClose(true) |
| 86 | } catch (error: any) { |
| 87 | toast.error(`Failed to update role: ${error.message}`) |
| 88 | } finally { |
| 89 | setSaving(false) |
| 90 | return |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | const { toRemove, toAssign, toUpdate } = deriveRoleChangeActions(existingRoles, changesToRoles) |
| 95 | |
| 96 | try { |
| 97 | for (const { roleId, refs } of toAssign) { |
| 98 | await assignRole({ |
| 99 | slug, |
| 100 | gotrueId, |
| 101 | roleId, |
| 102 | projects: refs, |
| 103 | skipInvalidation: true, |
| 104 | }) |
| 105 | } |
| 106 | for (const roleId of toRemove) { |
| 107 | await removeRole({ slug, gotrueId, roleId, skipInvalidation: true }) |
| 108 | } |
| 109 | for (const { roleId, refs } of toUpdate) { |
| 110 | await updateRole({ |
| 111 | slug, |
| 112 | gotrueId, |
| 113 | roleId, |
| 114 | roleName: project_scoped_roles.find((r) => r.id === roleId)?.name as string, |
| 115 | projects: refs, |
| 116 | skipInvalidation: true, |
| 117 | }) |
| 118 | } |
| 119 | |
| 120 | await Promise.all([ |
| 121 | queryClient.invalidateQueries({ queryKey: organizationKeys.rolesV2(slug) }), |
| 122 | queryClient.invalidateQueries({ queryKey: organizationKeysV1.members(slug) }), |
| 123 | ]) |
| 124 | toast.success(`Successfully updated role for ${member.username}`) |
| 125 | onClose(true) |
| 126 | } catch (error: any) { |
| 127 | toast.error(`Failed to update role: ${error.message}`) |
| 128 | } finally { |
| 129 | setSaving(false) |
| 130 | return |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | return ( |
| 135 | <ConfirmationModal |
| 136 | size="medium" |
| 137 | visible={visible} |
| 138 | loading={saving} |
| 139 | title="Confirm to change roles of member" |
| 140 | confirmLabel="Update roles" |
| 141 | confirmLabelLoading="Updating" |
| 142 | onCancel={() => onClose()} |
| 143 | onConfirm={onConfirmUpdateMemberRoles} |
| 144 | > |
| 145 | <div className="flex flex-col gap-y-3"> |
| 146 | <p className="text-sm text-foreground-light"> |
| 147 | You are making the following changes to the role of{' '} |
| 148 | <span className="text-foreground">{member.username}</span> in the organization{' '} |
| 149 | <span className="text-foreground">{organization?.name}</span>: |
| 150 | </p> |
| 151 | <div className="flex flex-col gap-y-2"> |
| 152 | {changesToRoles.removed.length !== 0 && ( |
| 153 | <div> |
| 154 | <p className="text-sm"> |
| 155 | Removing {changesToRoles.removed.length} role |
| 156 | {changesToRoles.removed.length > 1 ? 's' : ''} for user: |
| 157 | </p> |
| 158 | <ul className="list-disc pl-6"> |
| 159 | {changesToRoles.removed.map((x, i) => { |
| 160 | const role = |
| 161 | org_scoped_roles.find((y) => y.id === x.roleId) ?? |
| 162 | project_scoped_roles.find((y) => y.id === x.roleId) |
| 163 | const roleName = (role?.name ?? 'Unknown').split('_')[0] |
| 164 | |
| 165 | return ( |
| 166 | <li key={`update-${i}`} className="text-sm text-foreground-light"> |
| 167 | <span className="text-foreground">{roleName}</span> on{' '} |
| 168 | <span className="text-foreground">{x?.name ?? 'organization'}</span> |
| 169 | </li> |
| 170 | ) |
| 171 | })} |
| 172 | </ul> |
| 173 | </div> |
| 174 | )} |
| 175 | {changesToRoles.added.length !== 0 && ( |
| 176 | <div> |
| 177 | <p className="text-sm"> |
| 178 | Adding {changesToRoles.added.length} role |
| 179 | {changesToRoles.added.length > 1 ? 's' : ''} for user: |
| 180 | </p> |
| 181 | <ul className="list-disc pl-6"> |
| 182 | {changesToRoles.added.map((x, i) => { |
| 183 | const role = availableRoles.find((y) => y.id === x.roleId) |
| 184 | return ( |
| 185 | <li key={`update-${i}`} className="text-sm text-foreground-light"> |
| 186 | <span className="text-foreground">{role?.name}</span> on{' '} |
| 187 | <span className="text-foreground">{x?.name ?? 'organization'}</span> |
| 188 | </li> |
| 189 | ) |
| 190 | })} |
| 191 | </ul> |
| 192 | </div> |
| 193 | )} |
| 194 | {changesToRoles.updated.length !== 0 && ( |
| 195 | <div> |
| 196 | <p className="text-sm"> |
| 197 | Updating {changesToRoles.updated.length} role |
| 198 | {changesToRoles.updated.length > 1 ? 's' : ''} for user: |
| 199 | </p> |
| 200 | <ul className="list-disc pl-6"> |
| 201 | {changesToRoles.updated.map((x, i) => { |
| 202 | const originalRole = |
| 203 | org_scoped_roles.find((y) => y.id === x.originalRole) ?? |
| 204 | project_scoped_roles.find((y) => y.id === x.originalRole) |
| 205 | const updatedRole = org_scoped_roles.find((y) => y.id === x.updatedRole) |
| 206 | const originalRoleName = (originalRole?.name ?? 'Unknown').split('_')[0] |
| 207 | |
| 208 | return ( |
| 209 | <li key={`update-${i}`} className="text-sm text-foreground-light"> |
| 210 | From <span className="text-foreground">{originalRoleName}</span> to{' '} |
| 211 | <span className="text-foreground">{updatedRole?.name ?? 'Unknown'}</span> on{' '} |
| 212 | <span className="text-foreground">{x?.name ?? 'organization'}</span> |
| 213 | </li> |
| 214 | ) |
| 215 | })} |
| 216 | </ul> |
| 217 | </div> |
| 218 | )} |
| 219 | </div> |
| 220 | <p className="text-sm text-foreground"> |
| 221 | By changing the role of this member their permissions will change. |
| 222 | </p> |
| 223 | </div> |
| 224 | </ConfirmationModal> |
| 225 | ) |
| 226 | } |