UpdateRolesPanel.tsx395 lines · main
1import { useParams } from 'common'
2import { isEqual } from 'lodash'
3import { ChevronDown, X } from 'lucide-react'
4import { useEffect, useMemo, useState } from 'react'
5import {
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
31import { useGetRolesManagementPermissions } from '../TeamSettings.utils'
32import { UpdateRolesConfirmationModal } from './UpdateRolesConfirmationModal'
33import {
34 formatMemberRoleToProjectRoleConfiguration,
35 ProjectRoleConfiguration,
36} from './UpdateRolesPanel.utils'
37import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
38import { DocsButton } from '@/components/ui/DocsButton'
39import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
40import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
41import { OrganizationMember } from '@/data/organizations/organization-members-query'
42import { usePermissionsQuery } from '@/data/permissions/permissions-query'
43import {
44 OrgProject,
45 useOrgProjectsInfiniteQuery,
46} from '@/data/projects/org-projects-infinite-query'
47import { useHasAccessToProjectLevelPermissions } from '@/data/subscriptions/org-subscription-query'
48import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
49import { DOCS_URL } from '@/lib/constants'
50import { MANAGED_BY } from '@/lib/constants/infrastructure'
51
52interface UpdateRolesPanelProps {
53 visible: boolean
54 member: OrganizationMember
55 onClose: () => void
56}
57
58export 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}