Files
Vaibhav 254da82c3a feat: surface role risks (#50410)
## 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 -->
2026-09-15 15:25:46 +00:00

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()
}}
/>
</>
)
}