| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- import { useParams } from 'common'
- import { isEqual } from 'lodash'
- import { ChevronDown, X } from 'lucide-react'
- import { useEffect, useMemo, useState } from 'react'
- import {
- Alert,
- AlertDescription,
- AlertTitle,
- Button,
- cn,
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- Sheet,
- SheetContent,
- SheetFooter,
- SheetHeader,
- SheetSection,
- Switch,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- WarningIcon,
- } from 'ui'
- import { useGetRolesManagementPermissions } from '../TeamSettings.utils'
- import { UpdateRolesConfirmationModal } from './UpdateRolesConfirmationModal'
- import {
- formatMemberRoleToProjectRoleConfiguration,
- ProjectRoleConfiguration,
- } from './UpdateRolesPanel.utils'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
- import { useOrganizationRolesV2Query } from '@/data/organization-members/organization-roles-query'
- import { OrganizationMember } from '@/data/organizations/organization-members-query'
- import { usePermissionsQuery } from '@/data/permissions/permissions-query'
- import {
- OrgProject,
- useOrgProjectsInfiniteQuery,
- } from '@/data/projects/org-projects-infinite-query'
- import { useHasAccessToProjectLevelPermissions } from '@/data/subscriptions/org-subscription-query'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { DOCS_URL } from '@/lib/constants'
- import { MANAGED_BY } from '@/lib/constants/infrastructure'
- interface UpdateRolesPanelProps {
- visible: boolean
- member: OrganizationMember
- onClose: () => void
- }
- export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelProps) => {
- const { slug } = useParams()
- const { data: organization } = useSelectedOrganizationQuery()
- const isOptedIntoProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string)
- const { data: permissions } = usePermissionsQuery()
- const { data: allRoles, isSuccess: isSuccessRoles } = useOrganizationRolesV2Query({ slug })
- const { data: projectsData } = useOrgProjectsInfiniteQuery({ slug })
- const totalNumOrgProjects = projectsData?.pages[0].pagination.count ?? 0
- const orgProjects =
- useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
- // [Joshen] We use the org scoped roles as the source for available roles
- const orgScopedRoles = allRoles?.org_scoped_roles ?? []
- const projectScopedRoles = allRoles?.project_scoped_roles ?? []
- const { rolesAddable, rolesRemovable } = useGetRolesManagementPermissions(
- organization?.slug,
- orgScopedRoles.concat(projectScopedRoles),
- permissions ?? []
- )
- const cannotAddAnyRoles = orgScopedRoles.every((r) => !rolesAddable.includes(r.id))
- const isStripeProjectsOrg = organization?.managed_by === MANAGED_BY.STRIPE_PROJECTS
- const [showConfirmation, setShowConfirmation] = useState(false)
- const [showProjectDropdown, setShowProjectDropdown] = useState(false)
- const [projectsRoleConfiguration, setProjectsRoleConfiguration] = useState<
- ProjectRoleConfiguration[]
- >([])
- const originalConfiguration =
- allRoles !== undefined ? formatMemberRoleToProjectRoleConfiguration(member, allRoles) : []
- const originalConfigurationType =
- originalConfiguration.length === 1 &&
- !!orgScopedRoles.find((r) => r.id === originalConfiguration[0].roleId)
- ? 'org-scope'
- : 'project-scope'
- const isApplyingRoleToAllProjects =
- projectsRoleConfiguration.length === 1 && projectsRoleConfiguration[0]?.ref === undefined
- const canSaveRoles = projectsRoleConfiguration.length > 0
- const lowerPermissionsRole = orgScopedRoles.find((r) => r.name === 'Developer')?.id
- const noAccessProjects = orgProjects.filter((project) => {
- return !projectsRoleConfiguration.some((p) => p.ref === project.ref)
- })
- const numberOfProjectsWithAccess = projectsRoleConfiguration.filter(
- (p) => p.ref !== undefined
- ).length
- const hasNoChanges = isEqual(projectsRoleConfiguration, originalConfiguration)
- const onSelectProject = (project: OrgProject) => {
- setProjectsRoleConfiguration(
- projectsRoleConfiguration.concat({
- ref: project.ref,
- name: project.name,
- roleId: lowerPermissionsRole ?? orgScopedRoles[0].id,
- })
- )
- setShowProjectDropdown(false)
- }
- const onRemoveProject = (ref?: string) => {
- if (ref === undefined) return
- setProjectsRoleConfiguration(projectsRoleConfiguration.filter((p) => p.ref !== ref))
- }
- const onSelectRole = (value: string, project: ProjectRoleConfiguration) => {
- if (project.ref !== undefined) {
- setProjectsRoleConfiguration(
- projectsRoleConfiguration.map((p) => {
- if (p.ref === project.ref) {
- return { ref: p.ref, name: p.name, roleId: Number(value) }
- } else {
- return p
- }
- })
- )
- } else {
- setProjectsRoleConfiguration([{ ref: undefined, roleId: Number(value) }])
- }
- }
- const onToggleApplyToAllProjects = (isApplyAllProjects: boolean) => {
- const roleIdToApply = lowerPermissionsRole ?? orgScopedRoles[0].id
- if (isApplyAllProjects) {
- if (originalConfigurationType === 'org-scope') {
- setProjectsRoleConfiguration(originalConfiguration)
- } else {
- setProjectsRoleConfiguration([{ ref: undefined, name: undefined, roleId: roleIdToApply }])
- }
- } else {
- if (originalConfigurationType === 'project-scope') {
- setProjectsRoleConfiguration(originalConfiguration)
- } else {
- setProjectsRoleConfiguration(
- orgProjects.map((p) => {
- return { ref: p.ref, name: p.name, roleId: roleIdToApply }
- })
- )
- }
- }
- }
- useEffect(() => {
- if (visible && isSuccessRoles) {
- const roleConfiguration = formatMemberRoleToProjectRoleConfiguration(member, allRoles)
- setProjectsRoleConfiguration(roleConfiguration)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [visible, isSuccessRoles])
- return (
- <>
- <Sheet open={visible} onOpenChange={() => onClose()}>
- <SheetContent
- showClose={false}
- size="default"
- className="bg-surface-200 p-0 flex flex-row gap-0 md:w-[600px] lg:w-[600px] w-full"
- >
- <div className="flex flex-col grow w-full">
- <SheetHeader className="py-3 flex flex-row justify-between gap-x-4 items-center border-b bg-transparent">
- <p className="truncate" title={`Manage access for ${member.username}`}>
- Manage access for {member.username}
- </p>
- <DocsButton href={`${DOCS_URL}/guides/platform/access-control`} />
- </SheetHeader>
- <SheetSection className="h-full overflow-auto flex flex-col">
- {isOptedIntoProjectLevelPermissions && (
- <div className="flex items-center gap-x-4 border-b border-border pb-4">
- <Switch
- disabled={cannotAddAnyRoles}
- checked={isApplyingRoleToAllProjects}
- onCheckedChange={onToggleApplyToAllProjects}
- />
- <p className="text-sm">Apply roles to all projects in the organization</p>
- </div>
- )}
- {projectsRoleConfiguration.length === 0 && (
- <Alert>
- <WarningIcon />
- <AlertTitle>Team members need to be assigned at least one role</AlertTitle>
- <AlertDescription>
- You may not remove all roles from a team member
- </AlertDescription>
- </Alert>
- )}
- {!isApplyingRoleToAllProjects &&
- projectsRoleConfiguration.length > 0 &&
- projectsRoleConfiguration.length < totalNumOrgProjects && (
- <Collapsible className="bg-alternative border rounded-lg py-4 group">
- <CollapsibleTrigger className="w-full text-left px-4 flex items-center justify-between">
- <span className="text-sm">
- {hasNoChanges
- ? `This member only has access to ${numberOfProjectsWithAccess} project${numberOfProjectsWithAccess > 1 ? 's' : ''}`
- : `This member will only have access to ${numberOfProjectsWithAccess} project${numberOfProjectsWithAccess > 1 ? 's' : ''}`}
- </span>
- <ChevronDown size={14} className="transition group-data-open:-rotate-180" />
- </CollapsibleTrigger>
- <CollapsibleContent className="text-foreground-light text-sm px-4">
- <p>
- {member.username} {hasNoChanges ? 'does' : 'will'} not have access to the
- following {noAccessProjects.length} project
- {noAccessProjects.length > 1 ? 's' : ''}:
- </p>
- <ul className="list-disc pl-6">
- {noAccessProjects.map((project) => {
- return <li key={project.ref}>{project.name}</li>
- })}
- </ul>
- </CollapsibleContent>
- </Collapsible>
- )}
- <div className="flex flex-col divide-y divide-border">
- {projectsRoleConfiguration.map((project) => {
- const name = project.ref === undefined ? 'All projects' : project.name
- const role = orgScopedRoles.find((r) => {
- if (project.baseRoleId !== undefined) return r.id === project.baseRoleId
- else return r.id === project.roleId
- })
- const canRemoveRole = rolesRemovable.includes(role?.id ?? 0)
- return (
- <div
- key={`${project.ref}-${project.roleId}`}
- className="flex items-center justify-between py-2"
- >
- <p className="text-sm">{name}</p>
- <div className="flex items-center gap-x-2">
- {cannotAddAnyRoles ? (
- <Tooltip>
- <TooltipTrigger asChild>
- <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">
- {role?.name ?? 'Unknown'}
- </div>
- </TooltipTrigger>
- <TooltipContent side="bottom">
- Additional permissions required to update role
- </TooltipContent>
- </Tooltip>
- ) : (
- <Select
- value={(project?.baseRoleId ?? project.roleId).toString()}
- onValueChange={(value) => onSelectRole(value, project)}
- >
- <SelectTrigger
- className={cn(
- ' w-40',
- role?.name === undefined && 'text-foreground-light'
- )}
- >
- {role?.name ?? 'Please select a role'}
- </SelectTrigger>
- <SelectContent align="end">
- <SelectGroup>
- {(orgScopedRoles ?? []).map((role) => {
- const canAssignRole = rolesAddable.includes(role.id)
- const isOwnerRole = role.name === 'Owner'
- const disabledForStripe = isStripeProjectsOrg && isOwnerRole
- const disabled = !canAssignRole || disabledForStripe
- const disabledReason = disabledForStripe
- ? 'Cannot be assigned in Stripe Projects organizations'
- : !canAssignRole
- ? 'Additional permissions required to assign role'
- : undefined
- return (
- <SelectItem
- key={role.id}
- value={role.id.toString()}
- className="text-sm hover:bg-selection cursor-pointer"
- disabled={disabled}
- >
- <div className="flex flex-col gap-0.5">
- <span>{role.name}</span>
- {disabledReason && (
- <span className="text-xs text-foreground-lighter">
- {disabledReason}
- </span>
- )}
- </div>
- </SelectItem>
- )
- })}
- </SelectGroup>
- </SelectContent>
- </Select>
- )}
- {!isApplyingRoleToAllProjects && (
- <ButtonTooltip
- type="text"
- disabled={!canRemoveRole}
- className="px-1"
- icon={<X />}
- onClick={() => onRemoveProject(project?.ref)}
- tooltip={{
- content: {
- side: 'bottom',
- text: !canRemoveRole
- ? 'Additional permission required to remove role from member'
- : 'Remove access to project',
- },
- }}
- />
- )}
- </div>
- </div>
- )
- })}
- </div>
- {!isApplyingRoleToAllProjects && (
- <OrganizationProjectSelector
- open={showProjectDropdown}
- setOpen={setShowProjectDropdown}
- modal={true}
- onSelect={onSelectProject}
- renderTrigger={() => (
- <Button type="default" className="w-min">
- Add project
- </Button>
- )}
- renderRow={(project) => {
- const hasRoleAssigned = projectsRoleConfiguration.some(
- (p) => p.ref === project.ref
- )
- return (
- <div className="w-full flex items-center justify-between">
- <span className="truncate">{project.name}</span>
- {hasRoleAssigned && <p className="w-[45%] text-right">Already assigned</p>}
- </div>
- )
- }}
- isOptionDisabled={(project) =>
- projectsRoleConfiguration.some((p) => p.ref === project.ref)
- }
- />
- )}
- </SheetSection>
- <SheetFooter className="flex items-center justify-end! px-5 py-4 w-full border-t">
- <Button type="default" disabled={false} onClick={() => onClose()}>
- Cancel
- </Button>
- <Button
- loading={false}
- disabled={!canSaveRoles || hasNoChanges}
- onClick={() => {
- setShowConfirmation(true)
- }}
- >
- Save roles
- </Button>
- </SheetFooter>
- </div>
- </SheetContent>
- </Sheet>
- <UpdateRolesConfirmationModal
- visible={showConfirmation}
- member={member}
- projectsRoleConfiguration={projectsRoleConfiguration}
- onClose={(success) => {
- setShowConfirmation(false)
- if (success) onClose()
- }}
- />
- </>
- )
- }
|