Skip to content

Commit 585a040

Browse files
committed
improvement(admin): move user row actions into an overflow menu with confirm modals
1 parent d2964af commit 585a040

1 file changed

Lines changed: 173 additions & 122 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/settings/components/admin

apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx

Lines changed: 173 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
'use client'
22

33
import { useEffect, useMemo, useRef, useState } from 'react'
4-
import { Badge, Button, Chip, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn'
4+
import {
5+
Badge,
6+
Button,
7+
Chip,
8+
ChipConfirmModal,
9+
ChipInput,
10+
ChipModalField,
11+
ChipSelect,
12+
Label,
13+
Search,
14+
Switch,
15+
} from '@sim/emcn'
516
import { getErrorMessage } from '@sim/utils/errors'
617
import { useQueryStates } from 'nuqs'
718
import type { MothershipEnvironment } from '@/lib/api/contracts'
@@ -12,6 +23,7 @@ import {
1223
adminUrlKeys,
1324
} from '@/app/workspace/[workspaceId]/settings/components/admin/search-params'
1425
import { useRecentImpersonations } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations'
26+
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
1527
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
1628
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1729
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -37,10 +49,13 @@ const USER_TABLE_HEADER = (
3749
<span className='flex-1'>Email</span>
3850
<span className='w-[60px]'>Role</span>
3951
<span className='w-[55px]'>Status</span>
40-
<span className='w-[300px] text-right'>Actions</span>
52+
<span className='w-[150px] text-right'>Actions</span>
4153
</div>
4254
)
4355

56+
/** The row action awaiting confirmation in {@link ChipConfirmModal}. */
57+
type PendingUserAction = { type: 'ban'; user: AdminUser } | { type: 'role'; user: AdminUser }
58+
4459
const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[] = [
4560
{ value: 'default', label: 'Default' },
4661
{ value: 'dev', label: 'Dev' },
@@ -72,7 +87,7 @@ export function Admin() {
7287
)
7388

7489
const [searchInput, setSearchInput] = useState(searchQuery)
75-
const [banUserId, setBanUserId] = useState<string | null>(null)
90+
const [pendingAction, setPendingAction] = useState<PendingUserAction | null>(null)
7691
const [banReason, setBanReason] = useState('')
7792
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
7893
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
@@ -155,6 +170,38 @@ export function Admin() {
155170
)
156171
}
157172

173+
const isDemotion = pendingAction?.user.role === 'admin'
174+
175+
const closePendingAction = () => {
176+
setPendingAction(null)
177+
setBanReason('')
178+
}
179+
180+
const handleConfirmBan = () => {
181+
if (pendingAction?.type !== 'ban') return
182+
const trimmedReason = banReason.trim()
183+
banUser.reset()
184+
banUser.mutate(
185+
{
186+
userId: pendingAction.user.id,
187+
...(trimmedReason ? { banReason: trimmedReason } : {}),
188+
},
189+
{ onSuccess: closePendingAction }
190+
)
191+
}
192+
193+
const handleConfirmRoleChange = () => {
194+
if (pendingAction?.type !== 'role') return
195+
setUserRole.reset()
196+
setUserRole.mutate(
197+
{
198+
userId: pendingAction.user.id,
199+
role: pendingAction.user.role === 'admin' ? 'user' : 'admin',
200+
},
201+
{ onSuccess: closePendingAction }
202+
)
203+
}
204+
158205
const pendingUserIds = useMemo(() => {
159206
const ids = new Set<string>()
160207
if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId)
@@ -183,7 +230,7 @@ export function Admin() {
183230
impersonatingUserId,
184231
])
185232

186-
/** Confirms the send in place, since nothing about the user row changes. */
233+
/** Confirms the send on the menu item itself, since nothing about the user row changes. */
187234
const resetPasswordLabel = (userId: string) => {
188235
if (sendPasswordReset.variables?.userId !== userId) return 'Reset password'
189236
if (sendPasswordReset.isPending) return 'Sending...'
@@ -192,126 +239,70 @@ export function Admin() {
192239
}
193240

194241
const renderUserRow = (u: AdminUser) => (
195-
<div key={u.id} className='flex flex-col gap-2 px-3 py-2 text-small'>
196-
<div className='flex items-center gap-3'>
197-
<span className='w-[170px] truncate text-[var(--text-primary)]'>{u.name || '—'}</span>
198-
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
199-
<span className='w-[60px]'>
200-
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
201-
</span>
202-
<span className='w-[55px]'>
203-
{u.banned ? <Badge variant='red'>Banned</Badge> : <Badge variant='green'>Active</Badge>}
204-
</span>
205-
<span className='flex w-[300px] justify-end gap-1'>
206-
{u.id !== session?.user?.id && (
207-
<>
208-
<Button
209-
variant='active'
210-
className='h-[28px] px-2 text-caption'
211-
onClick={() => {
212-
setProvisionWarning(null)
213-
sendPasswordReset.reset()
214-
sendPasswordReset.mutate({ userId: u.id, email: u.email })
215-
}}
216-
disabled={pendingUserIds.has(u.id)}
217-
>
218-
{resetPasswordLabel(u.id)}
219-
</Button>
220-
<Button
221-
variant='active'
222-
className='h-[28px] px-2 text-caption'
223-
onClick={() => handleImpersonate(u.id, u.email)}
224-
disabled={pendingUserIds.has(u.id)}
225-
>
226-
{impersonatingUserId === u.id ||
227-
(impersonateUser.isPending &&
228-
(impersonateUser.variables as { userId?: string } | undefined)?.userId === u.id)
229-
? 'Switching...'
230-
: 'Impersonate'}
231-
</Button>
232-
<Button
233-
variant='active'
234-
className='h-[28px] px-2 text-caption'
235-
onClick={() => {
236-
setUserRole.reset()
237-
setUserRole.mutate({
238-
userId: u.id,
239-
role: u.role === 'admin' ? 'user' : 'admin',
240-
})
241-
}}
242-
disabled={pendingUserIds.has(u.id)}
243-
>
244-
{u.role === 'admin' ? 'Demote' : 'Promote'}
245-
</Button>
246-
{u.banned ? (
247-
<Button
248-
variant='active'
249-
className='h-[28px] px-2 text-caption'
250-
onClick={() => {
251-
unbanUser.reset()
252-
unbanUser.mutate({ userId: u.id })
253-
}}
254-
disabled={pendingUserIds.has(u.id)}
255-
>
256-
Unban
257-
</Button>
258-
) : (
259-
<Button
260-
variant='active'
261-
className={cn(
262-
'h-[28px] px-2 text-caption',
263-
banUserId === u.id ? 'text-[var(--text-primary)]' : 'text-[var(--text-error)]'
264-
)}
265-
onClick={() => {
266-
if (banUserId === u.id) {
267-
setBanUserId(null)
268-
setBanReason('')
269-
} else {
270-
setBanUserId(u.id)
271-
setBanReason('')
272-
}
273-
}}
274-
disabled={pendingUserIds.has(u.id)}
275-
>
276-
{banUserId === u.id ? 'Cancel' : 'Ban'}
277-
</Button>
278-
)}
279-
</>
280-
)}
281-
</span>
282-
</div>
283-
{banUserId === u.id && !u.banned && (
284-
<div className='flex items-center gap-2 pl-[170px]'>
285-
<ChipInput
286-
value={banReason}
287-
onChange={(e) => setBanReason(e.target.value)}
288-
placeholder='Reason (optional)'
289-
className='flex-1'
290-
/>
291-
<Button
292-
variant='primary'
293-
className='h-[28px] px-3 text-caption'
294-
onClick={() => {
295-
banUser.reset()
296-
banUser.mutate(
242+
<div key={u.id} className='flex items-center gap-3 px-3 py-2 text-small'>
243+
<span className='w-[170px] truncate text-[var(--text-primary)]'>{u.name || '—'}</span>
244+
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
245+
<span className='w-[60px]'>
246+
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
247+
</span>
248+
<span className='w-[55px]'>
249+
{u.banned ? <Badge variant='red'>Banned</Badge> : <Badge variant='green'>Active</Badge>}
250+
</span>
251+
<span className='flex w-[150px] items-center justify-end gap-1'>
252+
{u.id !== session?.user?.id && (
253+
<>
254+
<Button
255+
variant='active'
256+
className='h-[28px] px-2 text-caption'
257+
onClick={() => handleImpersonate(u.id, u.email)}
258+
disabled={pendingUserIds.has(u.id)}
259+
>
260+
{impersonatingUserId === u.id ||
261+
(impersonateUser.isPending &&
262+
(impersonateUser.variables as { userId?: string } | undefined)?.userId === u.id)
263+
? 'Switching...'
264+
: 'Impersonate'}
265+
</Button>
266+
<RowActionsMenu
267+
label={`Actions for ${u.email}`}
268+
actions={[
297269
{
298-
userId: u.id,
299-
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
270+
label: resetPasswordLabel(u.id),
271+
onSelect: () => {
272+
setProvisionWarning(null)
273+
sendPasswordReset.reset()
274+
sendPasswordReset.mutate({ userId: u.id, email: u.email })
275+
},
276+
disabled: pendingUserIds.has(u.id),
300277
},
301278
{
302-
onSuccess: () => {
303-
setBanUserId(null)
304-
setBanReason('')
305-
},
306-
}
307-
)
308-
}}
309-
disabled={pendingUserIds.has(u.id)}
310-
>
311-
Confirm Ban
312-
</Button>
313-
</div>
314-
)}
279+
label: u.role === 'admin' ? 'Demote' : 'Promote',
280+
onSelect: () => setPendingAction({ type: 'role', user: u }),
281+
disabled: pendingUserIds.has(u.id),
282+
},
283+
u.banned
284+
? {
285+
label: 'Unban',
286+
onSelect: () => {
287+
unbanUser.reset()
288+
unbanUser.mutate({ userId: u.id })
289+
},
290+
disabled: pendingUserIds.has(u.id),
291+
}
292+
: {
293+
label: 'Ban',
294+
onSelect: () => {
295+
setBanReason('')
296+
setPendingAction({ type: 'ban', user: u })
297+
},
298+
destructive: true,
299+
disabled: pendingUserIds.has(u.id),
300+
},
301+
]}
302+
/>
303+
</>
304+
)}
305+
</span>
315306
</div>
316307
)
317308

@@ -503,6 +494,66 @@ export function Admin() {
503494
)}
504495
</div>
505496
</SettingsSection>
497+
<ChipConfirmModal
498+
open={pendingAction?.type === 'ban'}
499+
onOpenChange={(open) => {
500+
if (!open) closePendingAction()
501+
}}
502+
srTitle='Ban user'
503+
title='Ban user'
504+
text={[
505+
'Banning ',
506+
{ text: pendingAction?.user.email ?? 'this user', bold: true },
507+
' ',
508+
{
509+
text: 'signs them out everywhere and blocks them from signing back in.',
510+
error: true,
511+
},
512+
' You can unban them later.',
513+
]}
514+
confirm={{
515+
label: 'Ban',
516+
onClick: handleConfirmBan,
517+
pending: banUser.isPending,
518+
pendingLabel: 'Banning...',
519+
}}
520+
>
521+
<ChipModalField
522+
type='input'
523+
title='Reason'
524+
value={banReason}
525+
onChange={setBanReason}
526+
placeholder='Optional'
527+
disabled={banUser.isPending}
528+
/>
529+
</ChipConfirmModal>
530+
531+
<ChipConfirmModal
532+
open={pendingAction?.type === 'role'}
533+
onOpenChange={(open) => {
534+
if (!open) closePendingAction()
535+
}}
536+
srTitle={isDemotion ? 'Demote user' : 'Promote user'}
537+
title={isDemotion ? 'Demote user' : 'Promote user'}
538+
text={[
539+
isDemotion ? 'Demoting ' : 'Promoting ',
540+
{ text: pendingAction?.user.email ?? 'this user', bold: true },
541+
' ',
542+
isDemotion
543+
? { text: 'revokes their platform admin access.', error: true }
544+
: {
545+
text: 'grants full platform admin access, including impersonating any user.',
546+
error: true,
547+
},
548+
]}
549+
confirm={{
550+
label: isDemotion ? 'Demote' : 'Promote',
551+
onClick: handleConfirmRoleChange,
552+
pending: setUserRole.isPending,
553+
pendingLabel: isDemotion ? 'Demoting...' : 'Promoting...',
554+
}}
555+
/>
556+
506557
<AddUserModal
507558
open={isAddUserOpen}
508559
onOpenChange={setIsAddUserOpen}
@@ -513,7 +564,7 @@ export function Admin() {
513564
setAdminParams({ q: user.email, offset: null })
514565
setProvisionWarning(
515566
resetEmailError
516-
? `Created ${user.email}, but the password reset email failed to send (${resetEmailError}). Use Reset password on their row to try again.`
567+
? `Created ${user.email}, but the password reset email failed to send (${resetEmailError}). Use Reset password in that row's actions menu to try again.`
517568
: null
518569
)
519570
}}

0 commit comments

Comments
 (0)