UpdateRolesPanel.tsx 16 KB

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