* Update Supabase docs URLs to use env variable Co-authored-by: a <a@alaisteryoung.com> * Refactor: Use DOCS_URL constant for documentation links This change centralizes documentation links using a new DOCS_URL constant, improving maintainability and consistency. Co-authored-by: a <a@alaisteryoung.com> * Refactor: Use DOCS_URL constant for all documentation links This change replaces hardcoded documentation URLs with a centralized constant, improving maintainability and consistency. Co-authored-by: a <a@alaisteryoung.com> * replace more instances * ci: Autofix updates from GitHub workflow * remaining instances * fix duplicate useRouter --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: alaister <10985857+alaister@users.noreply.github.com>
405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
import { isEqual } from 'lodash'
|
|
import { X } from 'lucide-react'
|
|
import { useEffect, useState } from 'react'
|
|
|
|
import { useParams } from 'common'
|
|
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
|
import { DocsButton } from 'components/ui/DocsButton'
|
|
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 { useProjectsQuery } from 'data/projects/projects-query'
|
|
import { useHasAccessToProjectLevelPermissions } from 'data/subscriptions/org-subscription-query'
|
|
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
|
import { DOCS_URL } from 'lib/constants'
|
|
import {
|
|
AlertDescription_Shadcn_,
|
|
AlertTitle_Shadcn_,
|
|
Alert_Shadcn_,
|
|
Button,
|
|
CommandEmpty_Shadcn_,
|
|
CommandGroup_Shadcn_,
|
|
CommandInput_Shadcn_,
|
|
CommandItem_Shadcn_,
|
|
CommandList_Shadcn_,
|
|
Command_Shadcn_,
|
|
PopoverContent_Shadcn_,
|
|
PopoverTrigger_Shadcn_,
|
|
Popover_Shadcn_,
|
|
ScrollArea,
|
|
SelectContent_Shadcn_,
|
|
SelectGroup_Shadcn_,
|
|
SelectItem_Shadcn_,
|
|
SelectTrigger_Shadcn_,
|
|
Select_Shadcn_,
|
|
Sheet,
|
|
SheetContent,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetSection,
|
|
Switch,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
WarningIcon,
|
|
cn,
|
|
} from 'ui'
|
|
import { useGetRolesManagementPermissions } from '../TeamSettings.utils'
|
|
import { UpdateRolesConfirmationModal } from './UpdateRolesConfirmationModal'
|
|
import {
|
|
ProjectRoleConfiguration,
|
|
formatMemberRoleToProjectRoleConfiguration,
|
|
} from './UpdateRolesPanel.utils'
|
|
|
|
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 } = useProjectsQuery()
|
|
const projects = data?.projects ?? []
|
|
const { data: permissions } = usePermissionsQuery()
|
|
const { data: allRoles, isSuccess: isSuccessRoles } = useOrganizationRolesV2Query({ slug })
|
|
|
|
// [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 [showConfirmation, setShowConfirmation] = useState(false)
|
|
const [showProjectDropdown, setShowProjectDropdown] = useState(false)
|
|
const [projectsRoleConfiguration, setProjectsRoleConfiguration] = useState<
|
|
ProjectRoleConfiguration[]
|
|
>([])
|
|
|
|
const originalConfiguration =
|
|
allRoles !== undefined
|
|
? formatMemberRoleToProjectRoleConfiguration(member, allRoles, projects ?? [])
|
|
: []
|
|
const originalConfigurationType =
|
|
originalConfiguration.length === 1 &&
|
|
!!orgScopedRoles.find((r) => r.id === originalConfiguration[0].roleId)
|
|
? 'org-scope'
|
|
: 'project-scope'
|
|
|
|
const orgProjects = (projects ?? []).filter((p) => p.organization_id === organization?.id)
|
|
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 = orgProjects.length - noAccessProjects.length
|
|
const numberOfAccessHasChanges = originalConfiguration.length !== noAccessProjects.length
|
|
|
|
const hasNoChanges = isEqual(projectsRoleConfiguration, originalConfiguration)
|
|
|
|
const onSelectProject = (ref: string) => {
|
|
setProjectsRoleConfiguration(
|
|
projectsRoleConfiguration.concat({
|
|
ref,
|
|
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, projectId: p.projectId, 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, roleId: roleIdToApply }])
|
|
}
|
|
} else {
|
|
if (originalConfigurationType === 'project-scope') {
|
|
setProjectsRoleConfiguration(originalConfiguration)
|
|
} else {
|
|
setProjectsRoleConfiguration(
|
|
orgProjects.map((p) => {
|
|
return { ref: p.ref, projectId: p.id, roleId: roleIdToApply }
|
|
})
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (visible && isSuccessRoles) {
|
|
const roleConfiguration = formatMemberRoleToProjectRoleConfiguration(
|
|
member,
|
|
allRoles,
|
|
projects ?? []
|
|
)
|
|
setProjectsRoleConfiguration(roleConfiguration)
|
|
}
|
|
}, [visible, isSuccessRoles])
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={visible} onOpenChange={() => onClose()}>
|
|
<SheetContent
|
|
showClose={false}
|
|
size="default"
|
|
className={cn('bg-surface-200 p-0 flex flex-row gap-0 !min-w-[400px]')}
|
|
>
|
|
<div className={cn('flex flex-col grow w-full')}>
|
|
<SheetHeader
|
|
className={cn('py-3 flex flex-row justify-between gap-x-4 items-center border-b')}
|
|
>
|
|
<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 gap-y-4">
|
|
{isOptedIntoProjectLevelPermissions && (
|
|
<div className="flex items-center gap-x-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_Shadcn_>
|
|
<WarningIcon />
|
|
<AlertTitle_Shadcn_>
|
|
Team members need to be assigned at least one role
|
|
</AlertTitle_Shadcn_>
|
|
<AlertDescription_Shadcn_>
|
|
You may not remove all roles from a team member
|
|
</AlertDescription_Shadcn_>
|
|
</Alert_Shadcn_>
|
|
)}
|
|
|
|
{!isApplyingRoleToAllProjects &&
|
|
projectsRoleConfiguration.length > 0 &&
|
|
projectsRoleConfiguration.length !== orgProjects.length && (
|
|
<Alert_Shadcn_>
|
|
<AlertTitle_Shadcn_>
|
|
{numberOfAccessHasChanges
|
|
? `This member will only have access to ${numberOfProjectsWithAccess} project${numberOfProjectsWithAccess > 1 ? 's' : ''}`
|
|
: `This member only has access to ${numberOfProjectsWithAccess} project${numberOfProjectsWithAccess > 1 ? 's' : ''}`}
|
|
</AlertTitle_Shadcn_>
|
|
<AlertDescription_Shadcn_>
|
|
{member.username} {numberOfAccessHasChanges ? 'will' : 'does'} not have access
|
|
to the following {noAccessProjects.length} project
|
|
{noAccessProjects.length > 1 ? 's' : ''}:
|
|
<ul className="list-disc pl-6">
|
|
{noAccessProjects.map((project) => {
|
|
return <li key={project.id}>{project.name}</li>
|
|
})}
|
|
</ul>
|
|
</AlertDescription_Shadcn_>
|
|
</Alert_Shadcn_>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-y-2">
|
|
{projectsRoleConfiguration
|
|
.sort((a, b) => (a?.projectId ?? 0) - (b?.projectId ?? 0))
|
|
.map((project) => {
|
|
const name =
|
|
project.ref === undefined
|
|
? 'All projects'
|
|
: projects?.find((p) => p.ref === project.ref)?.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"
|
|
>
|
|
<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_Shadcn_
|
|
value={(project?.baseRoleId ?? project.roleId).toString()}
|
|
onValueChange={(value) => onSelectRole(value, project)}
|
|
>
|
|
<SelectTrigger_Shadcn_
|
|
className={cn(
|
|
'text-sm h-10 w-56',
|
|
role?.name === undefined && 'text-foreground-light'
|
|
)}
|
|
>
|
|
{role?.name ?? 'Please select a role'}
|
|
</SelectTrigger_Shadcn_>
|
|
<SelectContent_Shadcn_>
|
|
<SelectGroup_Shadcn_>
|
|
{(orgScopedRoles ?? []).map((role) => {
|
|
const canAssignRole = rolesAddable.includes(role.id)
|
|
|
|
return (
|
|
<SelectItem_Shadcn_
|
|
key={role.id}
|
|
value={role.id.toString()}
|
|
className="text-sm hover:bg-selection cursor-pointer"
|
|
disabled={!canAssignRole}
|
|
>
|
|
{role.name}
|
|
</SelectItem_Shadcn_>
|
|
)
|
|
})}
|
|
</SelectGroup_Shadcn_>
|
|
</SelectContent_Shadcn_>
|
|
</Select_Shadcn_>
|
|
)}
|
|
|
|
{!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 && (
|
|
<Popover_Shadcn_
|
|
open={showProjectDropdown}
|
|
onOpenChange={setShowProjectDropdown}
|
|
modal={false}
|
|
>
|
|
<PopoverTrigger_Shadcn_ asChild>
|
|
<Button type="default" className="w-min">
|
|
Add project
|
|
</Button>
|
|
</PopoverTrigger_Shadcn_>
|
|
<PopoverContent_Shadcn_ className="p-0" side="bottom" align="start">
|
|
<Command_Shadcn_>
|
|
<CommandInput_Shadcn_ placeholder="Find project..." />
|
|
<CommandList_Shadcn_>
|
|
<CommandEmpty_Shadcn_>No projects found</CommandEmpty_Shadcn_>
|
|
<CommandGroup_Shadcn_>
|
|
<ScrollArea className={(projects || []).length > 7 ? 'h-[210px]' : ''}>
|
|
{orgProjects.map((project) => {
|
|
const hasRoleAssigned = projectsRoleConfiguration.some(
|
|
(p) => p.ref === project.ref
|
|
)
|
|
return (
|
|
<CommandItem_Shadcn_
|
|
key={project.ref}
|
|
disabled={hasRoleAssigned}
|
|
className="cursor-pointer w-full justify-between"
|
|
onSelect={() => onSelectProject(project.ref)}
|
|
onClick={() => onSelectProject(project.ref)}
|
|
>
|
|
<p className="truncate">{project.name}</p>
|
|
{hasRoleAssigned && (
|
|
<p className="w-[45%] text-right">Already assigned</p>
|
|
)}
|
|
</CommandItem_Shadcn_>
|
|
)
|
|
})}
|
|
</ScrollArea>
|
|
</CommandGroup_Shadcn_>
|
|
</CommandList_Shadcn_>
|
|
</Command_Shadcn_>
|
|
</PopoverContent_Shadcn_>
|
|
</Popover_Shadcn_>
|
|
)}
|
|
</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()
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|