mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Add context menu to auth users (#33524)
* Add context menu to auth users * Smol lint
This commit is contained in:
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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user