mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
## TL;DR Bolds the consequences in the Owner and Administrator role descriptions, and adds a confirmation step before an invite for either role is sent. https://github.com/user-attachments/assets/c8fb53ae-66c3-4862-865e-f2ff9fb84a8e ## ref: - recurring in the community: organizations losing access to their own projects after inviting someone as owner eg: https://discord.com/channels/839993398554656828/1545087567706193970/1545102402871492759 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a confirmation step when inviting members as Owners or Administrators. - Invitations are sent only after the elevated-role warning is confirmed. - Confirmation behavior is consistent for form submissions and keyboard-triggered invitations. - **Updates** - Refined role permission descriptions, including clearer details about elevated access and project deletion capabilities. - Improved role descriptions shown during member invitations and role selection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
394 lines
16 KiB
TypeScript
394 lines
16 KiB
TypeScript
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 { ROLE_DESCRIPTIONS } from '../Roles.constants'
|
|
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 max-w-xs">
|
|
<span>{role.name}</span>
|
|
<span className="text-xs text-foreground-lighter">
|
|
{ROLE_DESCRIPTIONS[role.name] ??
|
|
'Permissions are based on the configured organization role.'}
|
|
{disabledReason && ` ${disabledReason}`}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
|
|
{!isApplyingRoleToAllProjects && (
|
|
<ButtonTooltip
|
|
variant="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 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 disabled={false} onClick={() => onClose()}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
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()
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|