diff --git a/apps/studio/components/interfaces/Auth/Users/DeleteUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/DeleteUserModal.tsx new file mode 100644 index 00000000000..ff513fb397f --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/DeleteUserModal.tsx @@ -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 ( + 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.', + }} + > +

+ This is permanent! Are you sure you want to delete the user{' '} + {selectedUser?.email ?? selectedUser?.phone ?? 'this user'}? +

+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index 958d95fe0c3..d8807b64234 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -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) => { )} -
+
{
- 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() }} - > -

- This is permanent! Are you sure you want to delete the user{' '} - {user.email ?? user.phone ?? 'this user'}? -

-
+ />
-

{property}

+

{property}

{typeof value === 'boolean' ? (
{value ? ( @@ -481,7 +457,7 @@ export const RowData = ({ property, value }: { property: string; value?: string
) : (
-

{!value ? '-' : value}

+

{!value ? '-' : value}

{!!value && ( 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 ( -
- {/* [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 ( -
- {`${provider} -
- ) - })} - {col.id === 'last_sign_in_at' && !isConfirmed ? ( -

Waiting for verification

- ) : ( -

- {formattedValue === null ? '-' : formattedValue} -

- )} -
+ + +
+ {/* [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 ( +
+ {`${provider} +
+ ) + })} + {col.id === 'last_sign_in_at' && !isConfirmed ? ( +

Waiting for verification

+ ) : ( +

+ {formattedValue === null ? '-' : formattedValue} +

+ )} +
+
+ e.stopPropagation()}> + e.stopPropagation()} + onSelect={() => { + const value = col.id === 'providers' ? row.providers.join(', ') : formattedValue + copyToClipboard(value) + }} + > + + Copy {col.id === 'id' ? col.name : col.name.toLowerCase()} + + + e.stopPropagation()} + onSelect={() => { + if (user) onSelectDeleteUser(user) + }} + > + + Delete user + + +
) }, } diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index f8dfb7a3afd..dca0bf82095 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -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('all') const [filterKeywords, setFilterKeywords] = useState('') - const [selectedUsers, setSelectedUsers] = useState>(new Set([])) const [selectedColumns, setSelectedColumns] = useState([]) const [selectedProviders, setSelectedProviders] = useState([]) - const [selectedUser, setSelectedUser] = useState() const [sortByValue, setSortByValue] = useState('created_at:desc') + + const [selectedUser, setSelectedUser] = useState() + const [selectedUsers, setSelectedUsers] = useState>(new Set([])) + const [selectedUserToDelete, setSelectedUserToDelete] = useState() 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) => { 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 ? ( {' '} - {selectedUserToDelete?.email ?? selectedUserToDelete?.phone ?? 'this user'} + {selectedUserFromCheckbox?.email ?? selectedUserFromCheckbox?.phone ?? 'this user'} ) : null} ?

+ + {/* [Joshen] For deleting via context menu, the dialog above is dependent on the selectedUsers state */} + setSelectedUserToDelete(undefined)} + onDeleteSuccess={() => setSelectedUserToDelete(undefined)} + /> ) }