Skip to content

Commit a4e2fb9

Browse files
feat(admin): show recently impersonated users in admin panel (#5750)
* feat(admin): show recently impersonated users in admin panel * improvement(admin): align user table with settings list idiom * fix(admin): address review findings on recent impersonations * fix(admin): exact server-side email filter for recent impersonation lookup
1 parent c9baae7 commit a4e2fb9

4 files changed

Lines changed: 217 additions & 140 deletions

File tree

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

Lines changed: 142 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import {
1010
adminParsers,
1111
adminUrlKeys,
1212
} from '@/app/workspace/[workspaceId]/settings/components/admin/search-params'
13+
import { useRecentImpersonations } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations'
1314
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
1415
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1516
import {
17+
type AdminUser,
1618
useAdminUsers,
19+
useAdminUsersByEmails,
1720
useBanUser,
1821
useImpersonateUser,
1922
useSetUserRole,
@@ -25,6 +28,16 @@ import { clearUserData } from '@/stores'
2528

2629
const PAGE_SIZE = 20 as const
2730

31+
const USER_TABLE_HEADER = (
32+
<div className='flex items-center gap-3 px-3 pb-1 text-[var(--text-tertiary)] text-caption'>
33+
<span className='w-[170px]'>Name</span>
34+
<span className='flex-1'>Email</span>
35+
<span className='w-[60px]'>Role</span>
36+
<span className='w-[55px]'>Status</span>
37+
<span className='w-[200px] text-right'>Actions</span>
38+
</div>
39+
)
40+
2841
const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[] = [
2942
{ value: 'default', label: 'Default' },
3043
{ value: 'dev', label: 'Dev' },
@@ -43,6 +56,8 @@ export function Admin() {
4356
const banUser = useBanUser()
4457
const unbanUser = useUnbanUser()
4558
const impersonateUser = useImpersonateUser()
59+
const { recentEmails, recordImpersonation } = useRecentImpersonations()
60+
const { data: recentUsers } = useAdminUsersByEmails(recentEmails)
4661

4762
const [workflowId, setWorkflowId] = useState('')
4863
const [targetWorkspaceId, setTargetWorkspaceId] = useState('')
@@ -94,7 +109,7 @@ export function Admin() {
94109
}
95110
}
96111

97-
const handleImpersonate = (userId: string) => {
112+
const handleImpersonate = (userId: string, email: string) => {
98113
setImpersonationGuardError(null)
99114
if (session?.user?.role !== 'admin') {
100115
setImpersonatingUserId(null)
@@ -111,6 +126,7 @@ export function Admin() {
111126
setImpersonatingUserId(null)
112127
},
113128
onSuccess: async () => {
129+
recordImpersonation(email)
114130
await clearUserData()
115131
window.location.assign('/workspace')
116132
},
@@ -156,6 +172,119 @@ export function Admin() {
156172
impersonateUser.variables,
157173
impersonatingUserId,
158174
])
175+
176+
const renderUserRow = (u: AdminUser) => (
177+
<div key={u.id} className='flex flex-col gap-2 px-3 py-2 text-small'>
178+
<div className='flex items-center gap-3'>
179+
<span className='w-[170px] truncate text-[var(--text-primary)]'>{u.name || '—'}</span>
180+
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
181+
<span className='w-[60px]'>
182+
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>{u.role || 'user'}</Badge>
183+
</span>
184+
<span className='w-[55px]'>
185+
{u.banned ? <Badge variant='red'>Banned</Badge> : <Badge variant='green'>Active</Badge>}
186+
</span>
187+
<span className='flex w-[200px] justify-end gap-1'>
188+
{u.id !== session?.user?.id && (
189+
<>
190+
<Button
191+
variant='active'
192+
className='h-[28px] px-2 text-caption'
193+
onClick={() => handleImpersonate(u.id, u.email)}
194+
disabled={pendingUserIds.has(u.id)}
195+
>
196+
{impersonatingUserId === u.id ||
197+
(impersonateUser.isPending &&
198+
(impersonateUser.variables as { userId?: string } | undefined)?.userId === u.id)
199+
? 'Switching...'
200+
: 'Impersonate'}
201+
</Button>
202+
<Button
203+
variant='active'
204+
className='h-[28px] px-2 text-caption'
205+
onClick={() => {
206+
setUserRole.reset()
207+
setUserRole.mutate({
208+
userId: u.id,
209+
role: u.role === 'admin' ? 'user' : 'admin',
210+
})
211+
}}
212+
disabled={pendingUserIds.has(u.id)}
213+
>
214+
{u.role === 'admin' ? 'Demote' : 'Promote'}
215+
</Button>
216+
{u.banned ? (
217+
<Button
218+
variant='active'
219+
className='h-[28px] px-2 text-caption'
220+
onClick={() => {
221+
unbanUser.reset()
222+
unbanUser.mutate({ userId: u.id })
223+
}}
224+
disabled={pendingUserIds.has(u.id)}
225+
>
226+
Unban
227+
</Button>
228+
) : (
229+
<Button
230+
variant='active'
231+
className={cn(
232+
'h-[28px] px-2 text-caption',
233+
banUserId === u.id ? 'text-[var(--text-primary)]' : 'text-[var(--text-error)]'
234+
)}
235+
onClick={() => {
236+
if (banUserId === u.id) {
237+
setBanUserId(null)
238+
setBanReason('')
239+
} else {
240+
setBanUserId(u.id)
241+
setBanReason('')
242+
}
243+
}}
244+
disabled={pendingUserIds.has(u.id)}
245+
>
246+
{banUserId === u.id ? 'Cancel' : 'Ban'}
247+
</Button>
248+
)}
249+
</>
250+
)}
251+
</span>
252+
</div>
253+
{banUserId === u.id && !u.banned && (
254+
<div className='flex items-center gap-2 pl-[170px]'>
255+
<ChipInput
256+
value={banReason}
257+
onChange={(e) => setBanReason(e.target.value)}
258+
placeholder='Reason (optional)'
259+
className='flex-1'
260+
/>
261+
<Button
262+
variant='primary'
263+
className='h-[28px] px-3 text-caption'
264+
onClick={() => {
265+
banUser.reset()
266+
banUser.mutate(
267+
{
268+
userId: u.id,
269+
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
270+
},
271+
{
272+
onSuccess: () => {
273+
setBanUserId(null)
274+
setBanReason('')
275+
},
276+
}
277+
)
278+
}}
279+
disabled={pendingUserIds.has(u.id)}
280+
>
281+
Confirm Ban
282+
</Button>
283+
</div>
284+
)}
285+
</div>
286+
)
287+
159288
return (
160289
<SettingsPanel>
161290
<div className='flex flex-col gap-4'>
@@ -275,149 +404,16 @@ export function Admin() {
275404
</p>
276405
)}
277406

278-
{searchQuery.length > 0 && usersData && (
407+
{searchQuery.length > 0 && usersData ? (
279408
<>
280409
<div className='flex flex-col gap-0.5'>
281-
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
282-
<span className='w-[200px]'>Name</span>
283-
<span className='flex-1'>Email</span>
284-
<span className='w-[80px]'>Role</span>
285-
<span className='w-[80px]'>Status</span>
286-
<span className='w-[250px] text-right'>Actions</span>
287-
</div>
410+
{USER_TABLE_HEADER}
288411

289412
{usersData.users.length === 0 && (
290413
<SettingsEmptyState variant='inline'>No users found.</SettingsEmptyState>
291414
)}
292415

293-
{usersData.users.map((u) => (
294-
<div
295-
key={u.id}
296-
className={cn(
297-
'flex flex-col gap-2 px-3 py-2 text-small',
298-
'border-[var(--border-secondary)] border-b last:border-b-0'
299-
)}
300-
>
301-
<div className='flex items-center gap-3'>
302-
<span className='w-[200px] truncate text-[var(--text-primary)]'>
303-
{u.name || '—'}
304-
</span>
305-
<span className='flex-1 truncate text-[var(--text-secondary)]'>{u.email}</span>
306-
<span className='w-[80px]'>
307-
<Badge variant={u.role === 'admin' ? 'blue' : 'gray'}>
308-
{u.role || 'user'}
309-
</Badge>
310-
</span>
311-
<span className='w-[80px]'>
312-
{u.banned ? (
313-
<Badge variant='red'>Banned</Badge>
314-
) : (
315-
<Badge variant='green'>Active</Badge>
316-
)}
317-
</span>
318-
<span className='flex w-[250px] justify-end gap-1'>
319-
{u.id !== session?.user?.id && (
320-
<>
321-
<Button
322-
variant='active'
323-
className='h-[28px] px-2 text-caption'
324-
onClick={() => handleImpersonate(u.id)}
325-
disabled={pendingUserIds.has(u.id)}
326-
>
327-
{impersonatingUserId === u.id ||
328-
(impersonateUser.isPending &&
329-
(impersonateUser.variables as { userId?: string } | undefined)
330-
?.userId === u.id)
331-
? 'Switching...'
332-
: 'Impersonate'}
333-
</Button>
334-
<Button
335-
variant='active'
336-
className='h-[28px] px-2 text-caption'
337-
onClick={() => {
338-
setUserRole.reset()
339-
setUserRole.mutate({
340-
userId: u.id,
341-
role: u.role === 'admin' ? 'user' : 'admin',
342-
})
343-
}}
344-
disabled={pendingUserIds.has(u.id)}
345-
>
346-
{u.role === 'admin' ? 'Demote' : 'Promote'}
347-
</Button>
348-
{u.banned ? (
349-
<Button
350-
variant='active'
351-
className='h-[28px] px-2 text-caption'
352-
onClick={() => {
353-
unbanUser.reset()
354-
unbanUser.mutate({ userId: u.id })
355-
}}
356-
disabled={pendingUserIds.has(u.id)}
357-
>
358-
Unban
359-
</Button>
360-
) : (
361-
<Button
362-
variant='active'
363-
className={cn(
364-
'h-[28px] px-2 text-caption',
365-
banUserId === u.id
366-
? 'text-[var(--text-primary)]'
367-
: 'text-[var(--text-error)]'
368-
)}
369-
onClick={() => {
370-
if (banUserId === u.id) {
371-
setBanUserId(null)
372-
setBanReason('')
373-
} else {
374-
setBanUserId(u.id)
375-
setBanReason('')
376-
}
377-
}}
378-
disabled={pendingUserIds.has(u.id)}
379-
>
380-
{banUserId === u.id ? 'Cancel' : 'Ban'}
381-
</Button>
382-
)}
383-
</>
384-
)}
385-
</span>
386-
</div>
387-
{banUserId === u.id && !u.banned && (
388-
<div className='flex items-center gap-2 pl-[200px]'>
389-
<ChipInput
390-
value={banReason}
391-
onChange={(e) => setBanReason(e.target.value)}
392-
placeholder='Reason (optional)'
393-
className='flex-1'
394-
/>
395-
<Button
396-
variant='primary'
397-
className='h-[28px] px-3 text-caption'
398-
onClick={() => {
399-
banUser.reset()
400-
banUser.mutate(
401-
{
402-
userId: u.id,
403-
...(banReason.trim() ? { banReason: banReason.trim() } : {}),
404-
},
405-
{
406-
onSuccess: () => {
407-
setBanUserId(null)
408-
setBanReason('')
409-
},
410-
}
411-
)
412-
}}
413-
disabled={pendingUserIds.has(u.id)}
414-
>
415-
Confirm Ban
416-
</Button>
417-
</div>
418-
)}
419-
</div>
420-
))}
416+
{usersData.users.map((u) => renderUserRow(u))}
421417
</div>
422418

423419
{totalPages > 1 && (
@@ -450,6 +446,15 @@ export function Admin() {
450446
</div>
451447
)}
452448
</>
449+
) : (
450+
searchQuery.length === 0 &&
451+
recentUsers &&
452+
recentUsers.length > 0 && (
453+
<div className='flex flex-col gap-0.5'>
454+
{USER_TABLE_HEADER}
455+
{recentUsers.map((u) => renderUserRow(u))}
456+
</div>
457+
)
453458
)}
454459
</div>
455460
</SettingsPanel>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useCallback, useState } from 'react'
2+
import { RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores'
3+
4+
const MAX_RECENT = 5
5+
6+
function readRecentEmails(): string[] {
7+
try {
8+
const parsed = JSON.parse(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY) ?? '[]')
9+
if (!Array.isArray(parsed)) return []
10+
return parsed.filter((e): e is string => typeof e === 'string').slice(0, MAX_RECENT)
11+
} catch {
12+
return []
13+
}
14+
}
15+
16+
/**
17+
* Last {@link MAX_RECENT} emails the admin impersonated, most recent first,
18+
* persisted in localStorage on this browser.
19+
*/
20+
export function useRecentImpersonations() {
21+
const [recentEmails, setRecentEmails] = useState<string[]>(() => readRecentEmails())
22+
23+
const recordImpersonation = useCallback((email: string) => {
24+
const next = [email, ...readRecentEmails().filter((e) => e !== email)].slice(0, MAX_RECENT)
25+
try {
26+
localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, JSON.stringify(next))
27+
} catch {
28+
/* best-effort: recording must never block the impersonation transition */
29+
}
30+
setRecentEmails(next)
31+
}, [])
32+
33+
return { recentEmails, recordImpersonation }
34+
}

0 commit comments

Comments
 (0)