Add context menu to auth users (#33524)

* Add context menu to auth users

* Smol lint
This commit is contained in:
Joshen Lim authored and GitHub committed 2025-02-12 11:24:02 +08:00
1 parent 13aab8dd8a
commit 25a4424869
4 files changed
+173 -78

No files matched your search

@@ -0,0 +1,61 @@
import { useParams } from 'common'
import { toast } from 'sonner'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { User } from 'data/auth/users-infinite-query'
import { timeout } from 'lib/helpers'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
interface DeleteUserModalProps {
visible: boolean
selectedUser?: User
onClose: () => void
onDeleteSuccess?: () => void
}
export const DeleteUserModal = ({
visible,
selectedUser,
onClose,
onDeleteSuccess,
}: DeleteUserModalProps) => {
const { ref: projectRef } = useParams()
const { mutate: deleteUser, isLoading: isDeleting } = useUserDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted ${selectedUser?.email}`)
onDeleteSuccess?.()
},
})
const handleDeleteUser = async () => {
await timeout(200)
if (!projectRef) return console.error('Project ref is required')
if (selectedUser?.id === undefined) {
return toast.error(`Failed to delete user: User ID not found`)
}
deleteUser({ projectRef, userId: selectedUser.id })
}
return (
<ConfirmationModal
visible={visible}
variant="destructive"
title="Confirm to delete user"
loading={isDeleting}
confirmLabel="Delete"
onCancel={() => onClose()}
onConfirm={() => handleDeleteUser()}
alert={{
title: 'Deleting a user is irreversible',
description:
'This will remove the selected the user from the project and all associated data.',
}}
>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to delete the user{' '}
{selectedUser?.email ?? selectedUser?.phone ?? 'this user'}?
</p>
</ConfirmationModal>
)
}
@@ -10,7 +10,6 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import CopyButton from 'components/ui/CopyButton'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useUserDeleteMFAFactorsMutation } from 'data/auth/user-delete-mfa-factors-mutation'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { useUserResetPasswordMutation } from 'data/auth/user-reset-password-mutation'
import { useUserSendMagicLinkMutation } from 'data/auth/user-send-magic-link-mutation'
import { useUserSendOTPMutation } from 'data/auth/user-send-otp-mutation'
@@ -24,6 +23,7 @@ import { Admonition } from 'ui-patterns/admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
import { BanUserModal } from './BanUserModal'
import { DeleteUserModal } from './DeleteUserModal'
import { UserHeader } from './UserHeader'
import { PANEL_PADDING } from './UserPanel'
import { providerIconMap } from './Users.utils'
@@ -109,13 +109,6 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
toast.error(`Failed to send OTP: ${err.message}`)
},
})
const { mutate: deleteUser } = useUserDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted ${user?.email}`)
setIsDeleteModalOpen(false)
onDeleteSuccess()
},
})
const { mutate: deleteUserMFAFactors } = useUserDeleteMFAFactorsMutation({
onSuccess: () => {
toast.success("Successfully deleted the user's factors")
@@ -129,15 +122,6 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
},
})
const handleDelete = async () => {
await timeout(200)
if (!projectRef) return console.error('Project ref is required')
if (user.id === undefined) {
return toast.error(`Failed to delete user: User ID not found`)
}
deleteUser({ projectRef, userId: user.id })
}
const handleDeleteFactors = async () => {
await timeout(200)
if (!projectRef) return console.error('Project ref is required')
@@ -179,7 +163,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
<Separator />
)}
<div className={cn('flex flex-col gap-1.5', PANEL_PADDING)}>
<div className={cn('flex flex-col gap-y-1', PANEL_PADDING)}>
<RowData property="User UID" value={user.id} />
<RowData
property="Created at"
@@ -403,23 +387,15 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => {
</div>
</div>
<ConfirmationModal
<DeleteUserModal
visible={isDeleteModalOpen}
variant="destructive"
title="Confirm to delete user"
confirmLabel="Delete"
onCancel={() => setIsDeleteModalOpen(false)}
onConfirm={() => handleDelete()}
alert={{
title: 'Deleting a user is irreversible',
description: 'This will remove the user from the project and all associated data.',
selectedUser={user}
onClose={() => setIsDeleteModalOpen(false)}
onDeleteSuccess={() => {
setIsDeleteModalOpen(false)
onDeleteSuccess()
}}
>
<p className="text-sm text-foreground-light">
This is permanent! Are you sure you want to delete the user{' '}
<span className="text-foreground">{user.email ?? user.phone ?? 'this user'}</span>?
</p>
</ConfirmationModal>
/>
<ConfirmationModal
visible={isDeleteFactorsModalOpen}
@@ -466,7 +442,7 @@ export const RowData = ({ property, value }: { property: string; value?: string
return (
<>
<div className="flex items-center gap-x-2 group justify-between">
<p className=" text-foreground-lighter text-sm">{property}</p>
<p className=" text-foreground-lighter text-xs">{property}</p>
{typeof value === 'boolean' ? (
<div className="h-[26px] flex items-center justify-center min-w-[70px]">
{value ? (
@@ -481,7 +457,7 @@ export const RowData = ({ property, value }: { property: string; value?: string
</div>
) : (
<div className="flex items-center gap-x-2 h-[26px] font-mono min-w-[40px]">
<p className="text-sm">{!value ? '-' : value}</p>
<p className="text-xs">{!value ? '-' : value}</p>
{!!value && (
<CopyButton
iconOnly
@@ -1,11 +1,20 @@
import dayjs from 'dayjs'
import { UserIcon } from 'lucide-react'
import { Clipboard, Trash, UserIcon } from 'lucide-react'
import { UIEvent } from 'react'
import { Column, useRowSelection } from 'react-data-grid'
import { User } from 'data/auth/users-infinite-query'
import { BASE_PATH } from 'lib/constants'
import { Checkbox_Shadcn_, cn } from 'ui'
import { copyToClipboard } from 'lib/helpers'
import {
Checkbox_Shadcn_,
cn,
ContextMenu_Shadcn_,
ContextMenuContent_Shadcn_,
ContextMenuItem_Shadcn_,
ContextMenuSeparator_Shadcn_,
ContextMenuTrigger_Shadcn_,
} from 'ui'
import { HeaderCell } from './UsersGridComponents'
import { ColumnConfiguration, USERS_TABLE_COLUMNS } from './UsersV2'
@@ -248,11 +257,13 @@ export const formatUserColumns = ({
users,
visibleColumns = [],
setSortByValue,
onSelectDeleteUser,
}: {
config: ColumnConfiguration[]
users: User[]
visibleColumns?: string[]
setSortByValue: (val: string) => void
onSelectDeleteUser: (user: User) => void
}) => {
const columnOrder = config.map((c) => c.id) ?? USERS_TABLE_COLUMNS.map((c) => c.id)
@@ -319,42 +330,70 @@ export const formatUserColumns = ({
}
return (
<div
className={cn(
'w-full flex items-center text-xs',
col.id.includes('provider') ? 'capitalize' : ''
)}
>
{/* [Joshen] Not convinced this is the ideal way to display the icons, but for now */}
{col.id === 'providers' &&
row.provider_icons.map((icon: string, idx: number) => {
const provider = row.providers[idx]
return (
<div
className="min-w-6 min-h-6 rounded-full border flex items-center justify-center bg-surface-75"
style={{
marginLeft: idx === 0 ? 0 : `-8px`,
zIndex: row.provider_icons.length - idx,
}}
>
<img
key={`${user?.id}-${provider}`}
width={16}
src={icon}
alt={`${provider} auth icon`}
className={cn(provider === 'github' && 'dark:invert')}
/>
</div>
)
})}
{col.id === 'last_sign_in_at' && !isConfirmed ? (
<p className="text-foreground-lighter">Waiting for verification</p>
) : (
<p className={cn(col.id === 'providers' && 'ml-1')}>
{formattedValue === null ? '-' : formattedValue}
</p>
)}
</div>
<ContextMenu_Shadcn_>
<ContextMenuTrigger_Shadcn_ asChild>
<div
className={cn(
'w-full flex items-center text-xs',
col.id.includes('provider') ? 'capitalize' : ''
)}
>
{/* [Joshen] Not convinced this is the ideal way to display the icons, but for now */}
{col.id === 'providers' &&
row.provider_icons.map((icon: string, idx: number) => {
const provider = row.providers[idx]
return (
<div
className="min-w-6 min-h-6 rounded-full border flex items-center justify-center bg-surface-75"
style={{
marginLeft: idx === 0 ? 0 : `-8px`,
zIndex: row.provider_icons.length - idx,
}}
>
<img
key={`${user?.id}-${provider}`}
width={16}
src={icon}
alt={`${provider} auth icon`}
className={cn(provider === 'github' && 'dark:invert')}
/>
</div>
)
})}
{col.id === 'last_sign_in_at' && !isConfirmed ? (
<p className="text-foreground-lighter">Waiting for verification</p>
) : (
<p className={cn(col.id === 'providers' && 'ml-1')}>
{formattedValue === null ? '-' : formattedValue}
</p>
)}
</div>
</ContextMenuTrigger_Shadcn_>
<ContextMenuContent_Shadcn_ onClick={(e) => e.stopPropagation()}>
<ContextMenuItem_Shadcn_
className="gap-x-2"
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => {
const value = col.id === 'providers' ? row.providers.join(', ') : formattedValue
copyToClipboard(value)
}}
>
<Clipboard size={12} />
<span>Copy {col.id === 'id' ? col.name : col.name.toLowerCase()}</span>
</ContextMenuItem_Shadcn_>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_
className="gap-x-2"
onFocusCapture={(e) => e.stopPropagation()}
onSelect={() => {
if (user) onSelectDeleteUser(user)
}}
>
<Trash size={12} />
<span>Delete user</span>
</ContextMenuItem_Shadcn_>
</ContextMenuContent_Shadcn_>
</ContextMenu_Shadcn_>
)
},
}
@@ -16,7 +16,7 @@ import { FormHeader } from 'components/ui/Forms/FormHeader'
import { authKeys } from 'data/auth/keys'
import { useUserDeleteMutation } from 'data/auth/user-delete-mutation'
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import {
@@ -44,6 +44,7 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import AddUserDropdown from './AddUserDropdown'
import { DeleteUserModal } from './DeleteUserModal'
import { UserPanel } from './UserPanel'
import { MAX_BULK_DELETE, PROVIDER_FILTER_OPTIONS } from './Users.constants'
import { formatUserColumns, formatUsersData, isAtBottom } from './Users.utils'
@@ -83,13 +84,16 @@ export const UsersV2 = () => {
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [filterKeywords, setFilterKeywords] = useState('')
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [selectedUser, setSelectedUser] = useState<string>()
const [sortByValue, setSortByValue] = useState<string>('created_at:desc')
const [selectedUser, setSelectedUser] = useState<string>()
const [selectedUsers, setSelectedUsers] = useState<Set<any>>(new Set([]))
const [selectedUserToDelete, setSelectedUserToDelete] = useState<User>()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isDeletingUsers, setIsDeletingUsers] = useState(false)
const [
columnConfiguration,
setColumnConfiguration,
@@ -143,7 +147,7 @@ export const UsersV2 = () => {
const totalUsers = countData ?? 0
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
// [Joshen] Only relevant for when selecting one user only
const selectedUserToDelete = users.find((u) => u.id === [...selectedUsers][0])
const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0])
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft
@@ -199,6 +203,11 @@ export const UsersV2 = () => {
500
)
const onSelectDeleteUser = (user: User) => {
setSelectedUsers(new Set([user.id]))
setShowDeleteModal(true)
}
const handleDeleteUsers = async () => {
if (!projectRef) return console.error('Project ref is required')
const userIds = [...selectedUsers]
@@ -237,6 +246,7 @@ export const UsersV2 = () => {
users: users ?? [],
visibleColumns: selectedColumns,
setSortByValue,
onSelectDeleteUser,
})
setColumns(columns)
if (columns.length < USERS_TABLE_COLUMNS.length) {
@@ -377,6 +387,7 @@ export const UsersV2 = () => {
users: users ?? [],
visibleColumns: value,
setSortByValue,
onSelectDeleteUser,
})
setSelectedColumns(value)
@@ -580,12 +591,20 @@ export const UsersV2 = () => {
{selectedUsers.size === 1 ? (
<span className="text-foreground">
{' '}
{selectedUserToDelete?.email ?? selectedUserToDelete?.phone ?? 'this user'}
{selectedUserFromCheckbox?.email ?? selectedUserFromCheckbox?.phone ?? 'this user'}
</span>
) : null}
?
</p>
</ConfirmationModal>
{/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */}
<DeleteUserModal
visible={!!selectedUserToDelete}
selectedUser={selectedUserToDelete}
onClose={() => setSelectedUserToDelete(undefined)}
onDeleteSuccess={() => setSelectedUserToDelete(undefined)}
/>
</>
)
}