diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index c7cd6f626af..3e5d8f4e1d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -1,7 +1,20 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' -import { Badge, Button, Chip, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn' +import { useEffect, useRef, useState } from 'react' +import { + Badge, + Button, + Chip, + ChipConfirmModal, + ChipInput, + ChipModalError, + ChipModalField, + ChipSelect, + Label, + Search, + Switch, + toast, +} from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useQueryStates } from 'nuqs' import type { MothershipEnvironment } from '@/lib/api/contracts' @@ -12,6 +25,7 @@ import { adminUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/admin/search-params' import { useRecentImpersonations } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -37,10 +51,19 @@ const USER_TABLE_HEADER = ( Email Role Status - Actions + Actions ) +/** + * The row action awaiting confirmation. Holds ids, never the user row, so a + * refetch while the modal is open refreshes the name it shows without + * redirecting the action the admin committed to. + */ +type PendingUserAction = + | { type: 'ban'; userId: string } + | { type: 'role'; userId: string; nextRole: 'admin' | 'user' } + const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[] = [ { value: 'default', label: 'Default' }, { value: 'dev', label: 'Dev' }, @@ -72,7 +95,7 @@ export function Admin() { ) const [searchInput, setSearchInput] = useState(searchQuery) - const [banUserId, setBanUserId] = useState(null) + const [pendingAction, setPendingAction] = useState(null) const [banReason, setBanReason] = useState('') const [impersonatingUserId, setImpersonatingUserId] = useState(null) const [impersonationGuardError, setImpersonationGuardError] = useState(null) @@ -155,163 +178,121 @@ export function Admin() { ) } - const pendingUserIds = useMemo(() => { - const ids = new Set() - if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId) - ids.add((setUserRole.variables as { userId: string }).userId) - if (banUser.isPending && (banUser.variables as { userId?: string })?.userId) - ids.add((banUser.variables as { userId: string }).userId) - if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId) - ids.add((unbanUser.variables as { userId: string }).userId) - if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId) - ids.add((impersonateUser.variables as { userId: string }).userId) - if (sendPasswordReset.isPending && sendPasswordReset.variables?.userId) - ids.add(sendPasswordReset.variables.userId) - if (impersonatingUserId) ids.add(impersonatingUserId) - return ids - }, [ - setUserRole.isPending, - setUserRole.variables, - banUser.isPending, - banUser.variables, - unbanUser.isPending, - unbanUser.variables, - impersonateUser.isPending, - impersonateUser.variables, - sendPasswordReset.isPending, - sendPasswordReset.variables, - impersonatingUserId, - ]) - - /** Confirms the send in place, since nothing about the user row changes. */ - const resetPasswordLabel = (userId: string) => { - if (sendPasswordReset.variables?.userId !== userId) return 'Reset password' - if (sendPasswordReset.isPending) return 'Sending...' - if (sendPasswordReset.isSuccess) return 'Reset sent' - return 'Reset password' + const pendingUser = pendingAction + ? (usersData?.users.find((u) => u.id === pendingAction.userId) ?? + recentUsers?.find((u) => u.id === pendingAction.userId) ?? + null) + : null + const isDemotion = pendingAction?.type === 'role' && pendingAction.nextRole === 'user' + + const closePendingAction = () => { + setPendingAction(null) + setBanReason('') + } + + const handleConfirmBan = () => { + if (pendingAction?.type !== 'ban') return + const trimmedReason = banReason.trim() + banUser.mutate( + { + userId: pendingAction.userId, + ...(trimmedReason ? { banReason: trimmedReason } : {}), + }, + { onSuccess: closePendingAction } + ) + } + + const handleConfirmRoleChange = () => { + if (pendingAction?.type !== 'role') return + setUserRole.mutate( + { userId: pendingAction.userId, role: pendingAction.nextRole }, + { onSuccess: closePendingAction } + ) + } + + const pendingUserIds = new Set() + for (const mutation of [setUserRole, banUser, unbanUser, impersonateUser, sendPasswordReset]) { + if (mutation.isPending && mutation.variables?.userId) + pendingUserIds.add(mutation.variables.userId) } + if (impersonatingUserId) pendingUserIds.add(impersonatingUserId) const renderUserRow = (u: AdminUser) => ( -
-
- {u.name || '—'} - {u.email} - - {u.role || 'user'} - - - {u.banned ? Banned : Active} - - - {u.id !== session?.user?.id && ( - <> - - - - {u.banned ? ( - - ) : ( - - )} - - )} - -
- {banUserId === u.id && !u.banned && ( -
- setBanReason(e.target.value)} - placeholder='Reason (optional)' - className='flex-1' - /> - -
- )} + disabled: pendingUserIds.has(u.id), + }, + u.banned + ? { + label: 'Unban', + onSelect: () => unbanUser.mutate({ userId: u.id }), + disabled: pendingUserIds.has(u.id), + } + : { + label: 'Ban', + onSelect: () => { + banUser.reset() + setBanReason('') + setPendingAction({ type: 'ban', userId: u.id }) + }, + destructive: true, + disabled: pendingUserIds.has(u.id), + }, + ]} + /> + + )} +
) @@ -424,21 +405,10 @@ export function Admin() {

)} - {(setUserRole.error || - banUser.error || - unbanUser.error || - impersonateUser.error || - sendPasswordReset.error || - impersonationGuardError) && ( + {(unbanUser.error || impersonateUser.error || impersonationGuardError) && (

{impersonationGuardError || - ( - setUserRole.error || - banUser.error || - unbanUser.error || - impersonateUser.error || - sendPasswordReset.error - )?.message || + (unbanUser.error || impersonateUser.error)?.message || 'Action failed. Please try again.'}

)} @@ -447,6 +417,12 @@ export function Admin() {

{provisionWarning}

)} + {sendPasswordReset.isPending && sendPasswordReset.variables && ( +

+ Sending a password reset email to {sendPasswordReset.variables.email}... +

+ )} + {searchQuery.length > 0 && usersData ? ( <>
@@ -503,6 +479,67 @@ export function Admin() { )}
+ { + if (!open) closePendingAction() + }} + srTitle='Ban user' + title='Ban user' + text={[ + 'Banning ', + { text: pendingUser?.email ?? 'this user', bold: true }, + ' ', + { + text: 'signs them out everywhere and blocks them from signing back in.', + error: true, + }, + ' You can unban them later.', + ]} + confirm={{ + label: 'Ban', + onClick: handleConfirmBan, + pending: banUser.isPending, + pendingLabel: 'Banning...', + }} + > + + {banUser.error?.message} + + + { + if (!open) closePendingAction() + }} + srTitle={isDemotion ? 'Demote user' : 'Promote user'} + title={isDemotion ? 'Demote user' : 'Promote user'} + text={[ + isDemotion ? 'Demoting ' : 'Promoting ', + { text: pendingUser?.email ?? 'this user', bold: true }, + ' ', + isDemotion + ? { text: 'revokes their platform admin access.', error: true } + : 'grants full platform admin access, including impersonating any user.', + ]} + confirm={{ + label: isDemotion ? 'Demote' : 'Promote', + onClick: handleConfirmRoleChange, + variant: isDemotion ? 'destructive' : 'primary', + pending: setUserRole.isPending, + pendingLabel: isDemotion ? 'Demoting...' : 'Promoting...', + }} + > + {setUserRole.error?.message} + +