UpdateRolesPanel.tsx395 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import { isEqual } from 'lodash' |
| 3 | import { ChevronDown, X } from 'lucide-react' |
| 4 | import { useEffect, useMemo, useState } from 'react' |
| 5 | import { |
| 6 | Alert, |
| 7 | AlertDescription, |
| 8 | AlertTitle, |
| 9 | Button, |
| 10 | cn, |
| 11 | Collapsible, |
| 12 | CollapsibleContent, |
| 13 | CollapsibleTrigger, |
| 14 | Select, |
| 15 | SelectContent, |
| 16 | SelectGroup, |
| 17 | SelectItem, |
| 18 | SelectTrigger, |
| 19 | Sheet, |
| 20 | SheetContent, |
| 21 | SheetFooter, |
| 22 | SheetHeader, |
| 23 | SheetSection, |
| 24 | Switch, |
| 25 | Tooltip, |
| 26 | TooltipContent, |
| 27 | TooltipTrigger, |
| 28 | WarningIcon, |
| 29 | } from 'ui' |
| 30 | |
| 31 | import { useGetRolesManagementPermissions } from '../TeamSettings.utils' |
| 32 | import { UpdateRolesConfirmationModal } from './UpdateRolesConfirmationModal' |
| 33 | import { |
| 34 | formatMemberRoleToProjectRoleConfiguration, |
| 35 | ProjectRoleConfiguration, |
| 36 | } from './UpdateRolesPanel.utils' |
| 37 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 38 | import { DocsButton } from '@/components/ui/DocsButton' |
| 39 | import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' |
| 40 | import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query' |
| 41 | import { OrganizationMember } from '@/data/organizations/organization-members-query' |
| 42 | import { usePermissionsQuery } from '@/data/permissions/permissions-query' |
| 43 | import { |
| 44 | OrgProject, |
| 45 | useOrgProjectsInfiniteQuery, |
| 46 | } from '@/data/projects/org-projects-infinite-query' |
| 47 | import { useHasAccessToProjectLevelPermissions } from '@/data/subscriptions/org-subscription-query' |
| 48 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 49 | import { DOCS_URL } from '@/lib/constants' |
| 50 | import { MANAGED_BY } from '@/lib/constants/infrastructure' |
| 51 | |
| 52 | interface UpdateRolesPanelProps { |
| 53 | visible: boolean |
| 54 | member: OrganizationMember |
| 55 | onClose: () => void |
| 56 | } |
| 57 | |
| 58 | export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelProps) => { |
| 59 | const { slug } = useParams() |
| 60 | const { data: organization } = useSelectedOrganizationQuery() |
| 61 | const isOptedIntoProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string) |
| 62 | |
| 63 | const { data: permissions } = usePermissionsQuery() |
| 64 | const { data: allRoles, isSuccess: isSuccessRoles } = useOrganizationRolesV2Query({ slug }) |
| 65 | |
| 66 | const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug }) |
| 67 | const totalNumOrgProjects = projectsData?.pages[0].pagination.count ?? 0 |
| 68 | const orgProjects = |
| 69 | useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || [] |
| 70 | |
| 71 | // [Joshen] We use the org scoped roles as the source for available roles |
| 72 | const orgScopedRoles = allRoles?.org_scoped_roles ?? [] |
| 73 | const projectScopedRoles = allRoles?.project_scoped_roles ?? [] |
| 74 | |
| 75 | const { rolesAddable, rolesRemovable } = useGetRolesManagementPermissions( |
| 76 | organization?.slug, |
| 77 | orgScopedRoles.concat(projectScopedRoles), |
| 78 | permissions ?? [] |
| 79 | ) |
| 80 | const cannotAddAnyRoles = orgScopedRoles.every((r) => !rolesAddable.includes(r.id)) |
| 81 | const isStripeProjectsOrg = organization?.managed_by === MANAGED_BY.STRIPE_PROJECTS |
| 82 | |
| 83 | const [showConfirmation, setShowConfirmation] = useState(false) |
| 84 | const [showProjectDropdown, setShowProjectDropdown] = useState(false) |
| 85 | const [projectsRoleConfiguration, setProjectsRoleConfiguration] = useState< |
| 86 | ProjectRoleConfiguration[] |
| 87 | >([]) |
| 88 | |
| 89 | const originalConfiguration = |
| 90 | allRoles !== undefined ? formatMemberRoleToProjectRoleConfiguration(member, allRoles) : [] |
| 91 | const originalConfigurationType = |
| 92 | originalConfiguration.length === 1 && |
| 93 | !!orgScopedRoles.find((r) => r.id === originalConfiguration[0].roleId) |
| 94 | ? 'org-scope' |
| 95 | : 'project-scope' |
| 96 | |
| 97 | const isApplyingRoleToAllProjects = |
| 98 | projectsRoleConfiguration.length === 1 && projectsRoleConfiguration[0]?.ref === undefined |
| 99 | const canSaveRoles = projectsRoleConfiguration.length > 0 |
| 100 | |
| 101 | const lowerPermissionsRole = orgScopedRoles.find((r) => r.name === 'Developer')?.id |
| 102 | const noAccessProjects = orgProjects.filter((project) => { |
| 103 | return !projectsRoleConfiguration.some((p) => p.ref === project.ref) |
| 104 | }) |
| 105 | const numberOfProjectsWithAccess = projectsRoleConfiguration.filter( |
| 106 | (p) => p.ref !== undefined |
| 107 | ).length |
| 108 | const hasNoChanges = isEqual(projectsRoleConfiguration, originalConfiguration) |
| 109 | |
| 110 | const onSelectProject = (project: OrgProject) => { |
| 111 | setProjectsRoleConfiguration( |
| 112 | projectsRoleConfiguration.concat({ |
| 113 | ref: project.ref, |
| 114 | name: project.name, |
| 115 | roleId: lowerPermissionsRole ?? orgScopedRoles[0].id, |
| 116 | }) |
| 117 | ) |
| 118 | setShowProjectDropdown(false) |
| 119 | } |
| 120 | |
| 121 | const onRemoveProject = (ref?: string) => { |
| 122 | if (ref === undefined) return |
| 123 | setProjectsRoleConfiguration(projectsRoleConfiguration.filter((p) => p.ref !== ref)) |
| 124 | } |
| 125 | |
| 126 | const onSelectRole = (value: string, project: ProjectRoleConfiguration) => { |
| 127 | if (project.ref !== undefined) { |
| 128 | setProjectsRoleConfiguration( |
| 129 | projectsRoleConfiguration.map((p) => { |
| 130 | if (p.ref === project.ref) { |
| 131 | return { ref: p.ref, name: p.name, roleId: Number(value) } |
| 132 | } else { |
| 133 | return p |
| 134 | } |
| 135 | }) |
| 136 | ) |
| 137 | } else { |
| 138 | setProjectsRoleConfiguration([{ ref: undefined, roleId: Number(value) }]) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | const onToggleApplyToAllProjects = (isApplyAllProjects: boolean) => { |
| 143 | const roleIdToApply = lowerPermissionsRole ?? orgScopedRoles[0].id |
| 144 | |
| 145 | if (isApplyAllProjects) { |
| 146 | if (originalConfigurationType === 'org-scope') { |
| 147 | setProjectsRoleConfiguration(originalConfiguration) |
| 148 | } else { |
| 149 | setProjectsRoleConfiguration([{ ref: undefined, name: undefined, roleId: roleIdToApply }]) |
| 150 | } |
| 151 | } else { |
| 152 | if (originalConfigurationType === 'project-scope') { |
| 153 | setProjectsRoleConfiguration(originalConfiguration) |
| 154 | } else { |
| 155 | setProjectsRoleConfiguration( |
| 156 | orgProjects.map((p) => { |
| 157 | return { ref: p.ref, name: p.name, roleId: roleIdToApply } |
| 158 | }) |
| 159 | ) |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | useEffect(() => { |
| 165 | if (visible && isSuccessRoles) { |
| 166 | const roleConfiguration = formatMemberRoleToProjectRoleConfiguration(member, allRoles) |
| 167 | setProjectsRoleConfiguration(roleConfiguration) |
| 168 | } |
| 169 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 170 | }, [visible, isSuccessRoles]) |
| 171 | |
| 172 | return ( |
| 173 | <> |
| 174 | <Sheet open={visible} onOpenChange={() => onClose()}> |
| 175 | <SheetContent |
| 176 | showClose={false} |
| 177 | size="default" |
| 178 | className="bg-surface-200 p-0 flex flex-row gap-0 md:w-[600px] lg:w-[600px] w-full" |
| 179 | > |
| 180 | <div className="flex flex-col grow w-full"> |
| 181 | <SheetHeader className="py-3 flex flex-row justify-between gap-x-4 items-center border-b bg-transparent"> |
| 182 | <p className="truncate" title={`Manage access for ${member.username}`}> |
| 183 | Manage access for {member.username} |
| 184 | </p> |
| 185 | <DocsButton href={`${DOCS_URL}/guides/platform/access-control`} /> |
| 186 | </SheetHeader> |
| 187 | |
| 188 | <SheetSection className="h-full overflow-auto flex flex-col"> |
| 189 | {isOptedIntoProjectLevelPermissions && ( |
| 190 | <div className="flex items-center gap-x-4 border-b border-border pb-4"> |
| 191 | <Switch |
| 192 | disabled={cannotAddAnyRoles} |
| 193 | checked={isApplyingRoleToAllProjects} |
| 194 | onCheckedChange={onToggleApplyToAllProjects} |
| 195 | /> |
| 196 | <p className="text-sm">Apply roles to all projects in the organization</p> |
| 197 | </div> |
| 198 | )} |
| 199 | |
| 200 | {projectsRoleConfiguration.length === 0 && ( |
| 201 | <Alert> |
| 202 | <WarningIcon /> |
| 203 | <AlertTitle>Team members need to be assigned at least one role</AlertTitle> |
| 204 | <AlertDescription> |
| 205 | You may not remove all roles from a team member |
| 206 | </AlertDescription> |
| 207 | </Alert> |
| 208 | )} |
| 209 | |
| 210 | {!isApplyingRoleToAllProjects && |
| 211 | projectsRoleConfiguration.length > 0 && |
| 212 | projectsRoleConfiguration.length < totalNumOrgProjects && ( |
| 213 | <Collapsible className="bg-alternative border rounded-lg py-4 group"> |
| 214 | <CollapsibleTrigger className="w-full text-left px-4 flex items-center justify-between"> |
| 215 | <span className="text-sm"> |
| 216 | {hasNoChanges |
| 217 | ? `This member only has access to ${numberOfProjectsWithAccess} project${numberOfProjectsWithAccess > 1 ? 's' : ''}` |
| 218 | : `This member will only have access to ${numberOfProjectsWithAccess} project${numberOfProjectsWithAccess > 1 ? 's' : ''}`} |
| 219 | </span> |
| 220 | <ChevronDown size={14} className="transition group-data-open:-rotate-180" /> |
| 221 | </CollapsibleTrigger> |
| 222 | <CollapsibleContent className="text-foreground-light text-sm px-4"> |
| 223 | <p> |
| 224 | {member.username} {hasNoChanges ? 'does' : 'will'} not have access to the |
| 225 | following {noAccessProjects.length} project |
| 226 | {noAccessProjects.length > 1 ? 's' : ''}: |
| 227 | </p> |
| 228 | <ul className="list-disc pl-6"> |
| 229 | {noAccessProjects.map((project) => { |
| 230 | return <li key={project.ref}>{project.name}</li> |
| 231 | })} |
| 232 | </ul> |
| 233 | </CollapsibleContent> |
| 234 | </Collapsible> |
| 235 | )} |
| 236 | |
| 237 | <div className="flex flex-col divide-y divide-border"> |
| 238 | {projectsRoleConfiguration.map((project) => { |
| 239 | const name = project.ref === undefined ? 'All projects' : project.name |
| 240 | const role = orgScopedRoles.find((r) => { |
| 241 | if (project.baseRoleId !== undefined) return r.id === project.baseRoleId |
| 242 | else return r.id === project.roleId |
| 243 | }) |
| 244 | const canRemoveRole = rolesRemovable.includes(role?.id ?? 0) |
| 245 | |
| 246 | return ( |
| 247 | <div |
| 248 | key={`${project.ref}-${project.roleId}`} |
| 249 | className="flex items-center justify-between py-2" |
| 250 | > |
| 251 | <p className="text-sm">{name}</p> |
| 252 | |
| 253 | <div className="flex items-center gap-x-2"> |
| 254 | {cannotAddAnyRoles ? ( |
| 255 | <Tooltip> |
| 256 | <TooltipTrigger asChild> |
| 257 | <div className="flex items-center justify-between rounded-md border border-button bg-button px-3 py-2 text-sm h-10 w-56 text-foreground-light"> |
| 258 | {role?.name ?? 'Unknown'} |
| 259 | </div> |
| 260 | </TooltipTrigger> |
| 261 | <TooltipContent side="bottom"> |
| 262 | Additional permissions required to update role |
| 263 | </TooltipContent> |
| 264 | </Tooltip> |
| 265 | ) : ( |
| 266 | <Select |
| 267 | value={(project?.baseRoleId ?? project.roleId).toString()} |
| 268 | onValueChange={(value) => onSelectRole(value, project)} |
| 269 | > |
| 270 | <SelectTrigger |
| 271 | className={cn( |
| 272 | ' w-40', |
| 273 | role?.name === undefined && 'text-foreground-light' |
| 274 | )} |
| 275 | > |
| 276 | {role?.name ?? 'Please select a role'} |
| 277 | </SelectTrigger> |
| 278 | <SelectContent align="end"> |
| 279 | <SelectGroup> |
| 280 | {(orgScopedRoles ?? []).map((role) => { |
| 281 | const canAssignRole = rolesAddable.includes(role.id) |
| 282 | const isOwnerRole = role.name === 'Owner' |
| 283 | const disabledForStripe = isStripeProjectsOrg && isOwnerRole |
| 284 | const disabled = !canAssignRole || disabledForStripe |
| 285 | const disabledReason = disabledForStripe |
| 286 | ? 'Cannot be assigned in Stripe Projects organizations' |
| 287 | : !canAssignRole |
| 288 | ? 'Additional permissions required to assign role' |
| 289 | : undefined |
| 290 | |
| 291 | return ( |
| 292 | <SelectItem |
| 293 | key={role.id} |
| 294 | value={role.id.toString()} |
| 295 | className="text-sm hover:bg-selection cursor-pointer" |
| 296 | disabled={disabled} |
| 297 | > |
| 298 | <div className="flex flex-col gap-0.5"> |
| 299 | <span>{role.name}</span> |
| 300 | {disabledReason && ( |
| 301 | <span className="text-xs text-foreground-lighter"> |
| 302 | {disabledReason} |
| 303 | </span> |
| 304 | )} |
| 305 | </div> |
| 306 | </SelectItem> |
| 307 | ) |
| 308 | })} |
| 309 | </SelectGroup> |
| 310 | </SelectContent> |
| 311 | </Select> |
| 312 | )} |
| 313 | |
| 314 | {!isApplyingRoleToAllProjects && ( |
| 315 | <ButtonTooltip |
| 316 | type="text" |
| 317 | disabled={!canRemoveRole} |
| 318 | className="px-1" |
| 319 | icon={<X />} |
| 320 | onClick={() => onRemoveProject(project?.ref)} |
| 321 | tooltip={{ |
| 322 | content: { |
| 323 | side: 'bottom', |
| 324 | text: !canRemoveRole |
| 325 | ? 'Additional permission required to remove role from member' |
| 326 | : 'Remove access to project', |
| 327 | }, |
| 328 | }} |
| 329 | /> |
| 330 | )} |
| 331 | </div> |
| 332 | </div> |
| 333 | ) |
| 334 | })} |
| 335 | </div> |
| 336 | |
| 337 | {!isApplyingRoleToAllProjects && ( |
| 338 | <OrganizationProjectSelector |
| 339 | open={showProjectDropdown} |
| 340 | setOpen={setShowProjectDropdown} |
| 341 | modal={true} |
| 342 | onSelect={onSelectProject} |
| 343 | renderTrigger={() => ( |
| 344 | <Button type="default" className="w-min"> |
| 345 | Add project |
| 346 | </Button> |
| 347 | )} |
| 348 | renderRow={(project) => { |
| 349 | const hasRoleAssigned = projectsRoleConfiguration.some( |
| 350 | (p) => p.ref === project.ref |
| 351 | ) |
| 352 | return ( |
| 353 | <div className="w-full flex items-center justify-between"> |
| 354 | <span className="truncate">{project.name}</span> |
| 355 | {hasRoleAssigned && <p className="w-[45%] text-right">Already assigned</p>} |
| 356 | </div> |
| 357 | ) |
| 358 | }} |
| 359 | isOptionDisabled={(project) => |
| 360 | projectsRoleConfiguration.some((p) => p.ref === project.ref) |
| 361 | } |
| 362 | /> |
| 363 | )} |
| 364 | </SheetSection> |
| 365 | |
| 366 | <SheetFooter className="flex items-center justify-end! px-5 py-4 w-full border-t"> |
| 367 | <Button type="default" disabled={false} onClick={() => onClose()}> |
| 368 | Cancel |
| 369 | </Button> |
| 370 | <Button |
| 371 | loading={false} |
| 372 | disabled={!canSaveRoles || hasNoChanges} |
| 373 | onClick={() => { |
| 374 | setShowConfirmation(true) |
| 375 | }} |
| 376 | > |
| 377 | Save roles |
| 378 | </Button> |
| 379 | </SheetFooter> |
| 380 | </div> |
| 381 | </SheetContent> |
| 382 | </Sheet> |
| 383 | |
| 384 | <UpdateRolesConfirmationModal |
| 385 | visible={showConfirmation} |
| 386 | member={member} |
| 387 | projectsRoleConfiguration={projectsRoleConfiguration} |
| 388 | onClose={(success) => { |
| 389 | setShowConfirmation(false) |
| 390 | if (success) onClose() |
| 391 | }} |
| 392 | /> |
| 393 | </> |
| 394 | ) |
| 395 | } |