Shift positioning of "Leave team" button in Org Team Settings (#36386)

* Shift leave team button position, and fix sorting own user as first in list

* Nit
This commit is contained in:
Joshen Lim
2025-06-16 14:00:38 +08:00
committed by GitHub
parent c4316c3ace
commit ddc2250e1f
6 changed files with 191 additions and 152 deletions

View File

@@ -189,7 +189,7 @@ export const InviteMemberButton = () => {
className="pointer-events-auto flex-grow md:flex-grow-0"
onClick={() => setIsOpen(true)}
>
Invite
Invite member
</Button>
</TooltipTrigger>
{!canInviteMembers && (

View File

@@ -0,0 +1,121 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useProfile } from 'lib/profile'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { hasMultipleOwners } from './TeamSettings.utils'
export const LeaveTeamButton = () => {
const router = useRouter()
const { slug } = useParams()
const { profile } = useProfile()
const selectedOrganization = useSelectedOrganization()
// if organizationMembersDeletionEnabled is false, you also can't delete yourself
const { organizationMembersDelete: organizationMembersDeletionEnabled } = useIsFeatureEnabled([
'organization_members:delete',
])
const [isLeaving, setIsLeaving] = useState(false)
const [isLeaveTeamModalOpen, setIsLeaveTeamModalOpen] = useState(false)
const [_, setLastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const { refetch: refetchOrganizations } = useOrganizationsQuery()
const { data: members } = useOrganizationMembersQuery({ slug })
const { data: allRoles } = useOrganizationRolesV2Query({ slug })
const isOwner = selectedOrganization?.is_owner
const roles = allRoles?.org_scoped_roles ?? []
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
const { mutate: deleteMember } = useOrganizationMemberDeleteMutation({
onSuccess: async () => {
setIsLeaving(false)
setIsLeaveTeamModalOpen(false)
await refetchOrganizations()
toast.success(`Successfully left ${selectedOrganization?.name}`)
setLastVisitedOrganization('')
router.push('/organizations')
},
onError: (error) => {
setIsLeaving(false)
toast.error(`Failed to leave organization: ${error?.message}`)
},
})
const leaveTeam = async () => {
if (!slug) return console.error('Org slug is required')
if (!profile) return console.error('Profile is required')
setIsLeaving(true)
deleteMember({ slug, gotrueId: profile.gotrue_id })
}
return (
<>
<ButtonTooltip
type="default"
disabled={!canLeave || !organizationMembersDeletionEnabled || isLeaving}
onClick={() => setIsLeaveTeamModalOpen(true)}
tooltip={{
content: {
side: 'bottom',
text: !canLeave
? 'An organization requires at least 1 owner'
: !organizationMembersDeletionEnabled
? 'Unable to leave organization'
: undefined,
},
}}
>
Leave team
</ButtonTooltip>
<ConfirmationModal
size="medium"
visible={isLeaveTeamModalOpen}
title="Confirm to leave organization"
confirmLabel="Leave"
variant="warning"
alert={{
title: 'All of your user content will be permanently removed.',
description: (
<div>
<p>
Leaving the organization will delete all of your saved content in the projects of
the organization, which includes:
</p>
<ul className="list-disc pl-4">
<li>
SQL snippets <span className="text-foreground">(both private and shared)</span>
</li>
<li>Custom reports</li>
<li>Log Explorer queries</li>
</ul>
</div>
),
}}
onCancel={() => setIsLeaveTeamModalOpen(false)}
onConfirm={() => leaveTeam()}
>
<p className="text-sm text-foreground-light">
Are you sure you want to leave this organization? This is permanent.
</p>
</ConfirmationModal>
</>
)
}

View File

@@ -15,7 +15,6 @@ import {
} 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 { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
@@ -29,7 +28,8 @@ import {
DropdownMenuTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
import { LeaveTeamButton } from './LeaveTeamButton'
import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSettings.utils'
import { UpdateRolesPanel } from './UpdateRolesPanel/UpdateRolesPanel'
interface MemberActionsProps {
@@ -48,7 +48,11 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
const { data: allProjects } = useProjectsQuery()
const { data: members } = useOrganizationMembersQuery({ slug })
const { data: allRoles } = useOrganizationRolesV2Query({ slug })
const isOptedIntoProjectLevelPermissions = useHasAccessToProjectLevelPermissions(slug as string)
const memberIsUser = member.gotrue_id == profile?.gotrue_id
const isOwner = selectedOrganization?.is_owner
const roles = allRoles?.org_scoped_roles ?? []
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
const orgScopedRoles = allRoles?.org_scoped_roles ?? []
const projectScopedRoles = allRoles?.project_scoped_roles ?? []
@@ -166,6 +170,14 @@ export const MemberActions = ({ member }: MemberActionsProps) => {
)
}
if (memberIsUser) {
return (
<div className="flex items-center justify-end">
<LeaveTeamButton />
</div>
)
}
return (
<>
<div className="flex items-center justify-end gap-x-2">

View File

@@ -41,7 +41,6 @@ export const MemberRow = ({ member }: MemberRowProps) => {
const orgProjects = projects?.filter((p) => p.organization_id === selectedOrganization?.id)
const hasProjectScopedRoles = (roles?.project_scoped_roles ?? []).length > 0
const memberIsUser = member.gotrue_id == profile?.gotrue_id
const isInvitedUser = Boolean(member.invited_id)
const isEmailUser = member.username === member.primary_email
const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io'))
@@ -204,7 +203,9 @@ export const MemberRow = ({ member }: MemberRowProps) => {
)}
</Table.td>
<Table.td>{!memberIsUser && <MemberActions member={member} />}</Table.td>
<Table.td>
<MemberActions member={member} />
</Table.td>
</Table.tr>
)
}

View File

@@ -7,6 +7,8 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useProfile } from 'lib/profile'
import { partition } from 'lodash'
import { useMemo } from 'react'
import { Button, Loading, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { Admonition } from 'ui-patterns'
import { MemberRow } from './MemberRow'
@@ -20,7 +22,7 @@ const MembersView = ({ searchString }: MembersViewProps) => {
const { profile } = useProfile()
const {
data: members,
data: members = [],
error: membersError,
isLoading: isLoadingMembers,
isError: isErrorMembers,
@@ -35,11 +37,10 @@ const MembersView = ({ searchString }: MembersViewProps) => {
slug,
})
const allMembers = members ?? []
const filteredMembers = (
!searchString
? allMembers
: allMembers.filter((member) => {
const filteredMembers = useMemo(() => {
return !searchString
? members
: members.filter((member) => {
if (member.invited_at) {
return member.primary_email?.includes(searchString)
}
@@ -49,15 +50,17 @@ const MembersView = ({ searchString }: MembersViewProps) => {
)
}
})
)
.slice()
.sort((a, b) => {
// [Joshen] Have own account show up top
if (a.primary_email === profile?.primary_email) return -1
return a.username.localeCompare(b.username)
})
}, [members, searchString])
const userMember = allMembers.find((m) => m.primary_email === profile?.primary_email)
const [[user], otherMembers] = partition(
filteredMembers,
(m) => m.primary_email === profile?.primary_email
)
const sortedMembers = otherMembers.sort((a, b) =>
(a.primary_email ?? '').localeCompare(b.primary_email ?? '')
)
const userMember = members.find((m) => m.primary_email === profile?.primary_email)
const orgScopedRoleIds = (roles?.org_scoped_roles ?? []).map((r) => r.id)
const isOrgScopedRole = orgScopedRoleIds.includes(userMember?.role_ids?.[0] ?? -1)
@@ -117,7 +120,8 @@ const MembersView = ({ searchString }: MembersViewProps) => {
</Table.tr>,
]
: []),
...filteredMembers.map((member) => (
...(!!user ? [<MemberRow key={user.gotrue_id} member={user} />] : []),
...sortedMembers.map((member) => (
<MemberRow key={member.gotrue_id} member={member} />
)),
...(searchString.length > 0 && filteredMembers.length === 0
@@ -138,7 +142,7 @@ const MembersView = ({ searchString }: MembersViewProps) => {
<Table.td colSpan={12}>
<p className="text-foreground-light">
{searchString ? `${filteredMembers.length} of ` : ''}
{allMembers.length || '0'} {allMembers.length == 1 ? 'user' : 'users'}
{members.length || '0'} {members.length == 1 ? 'user' : 'users'}
</p>
</Table.td>
</Table.tr>,

View File

@@ -1,9 +1,7 @@
import { Search } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { toast } from 'sonner'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useParams } from 'common'
import {
ScaffoldActionsContainer,
ScaffoldActionsGroup,
@@ -12,43 +10,27 @@ import {
ScaffoldSectionContent,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { useOrganizationMemberDeleteMutation } from 'data/organizations/organization-member-delete-mutation'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { usePermissionsQuery } from 'data/permissions/permissions-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useProfile } from 'lib/profile'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { InviteMemberButton } from './InviteMemberButton'
import MembersView from './MembersView'
import { hasMultipleOwners, useGetRolesManagementPermissions } from './TeamSettings.utils'
import { useGetRolesManagementPermissions } from './TeamSettings.utils'
export const TeamSettings = () => {
const [_, setLastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const {
organizationMembersCreate: organizationMembersCreationEnabled,
organizationMembersDelete: organizationMembersDeletionEnabled,
} = useIsFeatureEnabled(['organization_members:create', 'organization_members:delete'])
const { organizationMembersCreate: organizationMembersCreationEnabled } = useIsFeatureEnabled([
'organization_members:create',
])
const { slug } = useParams()
const router = useRouter()
const { profile } = useProfile()
const selectedOrganization = useSelectedOrganization()
const isOwner = selectedOrganization?.is_owner
const { data: permissions } = usePermissionsQuery()
const { refetch: refetchOrganizations } = useOrganizationsQuery()
const { data: rolesData } = useOrganizationRolesV2Query({ slug })
const { data: members } = useOrganizationMembersQuery({ slug })
const roles = rolesData?.org_scoped_roles ?? []
@@ -58,117 +40,36 @@ export const TeamSettings = () => {
permissions ?? []
)
const [isLeaving, setIsLeaving] = useState(false)
const [searchString, setSearchString] = useState('')
const canAddMembers = rolesAddable.length > 0
const canLeave = !isOwner || (isOwner && hasMultipleOwners(members, roles))
const { mutate: deleteMember } = useOrganizationMemberDeleteMutation({
onSuccess: async () => {
setIsLeaving(false)
setIsLeaveTeamModalOpen(false)
await refetchOrganizations()
toast.success(`Successfully left ${selectedOrganization?.name}`)
setLastVisitedOrganization('')
router.push('/organizations')
},
onError: (error) => {
setIsLeaving(false)
toast.error(`Failed to leave organization: ${error?.message}`)
},
})
const [isLeaveTeamModalOpen, setIsLeaveTeamModalOpen] = useState(false)
const leaveTeam = async () => {
if (!slug) return console.error('Org slug is required')
setIsLeaving(true)
deleteMember({ slug, gotrueId: profile!.gotrue_id })
}
return (
<>
<ScaffoldContainerLegacy>
<ScaffoldTitle>Team</ScaffoldTitle>
<ScaffoldFilterAndContent>
<ScaffoldActionsContainer className="w-full flex-col md:flex-row gap-2 justify-between">
<Input
size="tiny"
autoComplete="off"
icon={<Search size={12} />}
value={searchString}
onChange={(e: any) => setSearchString(e.target.value)}
name="email"
id="email"
placeholder="Filter members"
/>
<ScaffoldActionsGroup className="w-full md:w-auto">
{organizationMembersCreationEnabled &&
canAddMembers &&
profile !== undefined &&
selectedOrganization !== undefined && <InviteMemberButton />}
{/* if organizationMembersDeletionEnabled is false, you also can't delete yourself */}
{organizationMembersDeletionEnabled && (
<>
<ButtonTooltip
type="default"
loading={isLeaving}
disabled={!canLeave}
tooltip={{
content: {
side: 'bottom',
text: !canLeave ? 'An organization requires at least 1 owner' : undefined,
},
}}
onClick={() => setIsLeaveTeamModalOpen(true)}
>
Leave team
</ButtonTooltip>
</>
)}
</ScaffoldActionsGroup>
</ScaffoldActionsContainer>
<ScaffoldSectionContent className="w-full">
<MembersView searchString={searchString} />
</ScaffoldSectionContent>
</ScaffoldFilterAndContent>
</ScaffoldContainerLegacy>
<ConfirmationModal
size="medium"
visible={isLeaveTeamModalOpen}
title="Confirm to leave organization"
confirmLabel="Leave"
variant="warning"
alert={{
title: 'All of your user content will be permanently removed.',
description: (
<div>
<p>
Leaving the organization will delete all of your saved content in the projects of
the organization, which includes:
</p>
<ul className="list-disc pl-4">
<li>
SQL snippets <span className="text-foreground">(both private and shared)</span>
</li>
<li>Custom reports</li>
<li>Log Explorer queries</li>
</ul>
</div>
),
}}
onCancel={() => setIsLeaveTeamModalOpen(false)}
onConfirm={() => leaveTeam()}
>
<p className="text-sm text-foreground-light">
Are you sure you want to leave this organization? This is permanent.
</p>
</ConfirmationModal>
</>
<ScaffoldContainerLegacy>
<ScaffoldTitle>Team</ScaffoldTitle>
<ScaffoldFilterAndContent>
<ScaffoldActionsContainer className="w-full flex-col md:flex-row gap-2 justify-between">
<Input
size="tiny"
autoComplete="off"
icon={<Search size={12} />}
value={searchString}
onChange={(e: any) => setSearchString(e.target.value)}
name="email"
id="email"
placeholder="Filter members"
/>
<ScaffoldActionsGroup className="w-full md:w-auto">
{organizationMembersCreationEnabled &&
canAddMembers &&
profile !== undefined &&
selectedOrganization !== undefined && <InviteMemberButton />}
</ScaffoldActionsGroup>
</ScaffoldActionsContainer>
<ScaffoldSectionContent className="w-full">
<MembersView searchString={searchString} />
</ScaffoldSectionContent>
</ScaffoldFilterAndContent>
</ScaffoldContainerLegacy>
)
}