Skip to content

Commit 7f2a165

Browse files
feat(admin): show recently impersonated users in admin panel
1 parent f1deb31 commit 7f2a165

4 files changed

Lines changed: 222 additions & 140 deletions

File tree

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

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

278-
{searchQuery.length > 0 && usersData && (
413+
{searchQuery.length > 0 && usersData ? (
279414
<>
280415
<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>
416+
{USER_TABLE_HEADER}
288417

289418
{usersData.users.length === 0 && (
290419
<SettingsEmptyState variant='inline'>No users found.</SettingsEmptyState>
291420
)}
292421

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-
))}
422+
{usersData.users.map((u) => renderUserRow(u))}
421423
</div>
422424

423425
{totalPages > 1 && (
@@ -450,6 +452,15 @@ export function Admin() {
450452
</div>
451453
)}
452454
</>
455+
) : (
456+
searchQuery.length === 0 &&
457+
recentUsers &&
458+
recentUsers.length > 0 && (
459+
<div className='flex flex-col gap-0.5'>
460+
{USER_TABLE_HEADER}
461+
{recentUsers.map((u) => renderUserRow(u))}
462+
</div>
463+
)
453464
)}
454465
</div>
455466
</SettingsPanel>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useCallback, useState } from 'react'
2+
3+
/** Survives clearUserData via the keysToKeep allowlist in @/stores. */
4+
export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations'
5+
6+
const MAX_RECENT = 5
7+
8+
function readRecentEmails(): string[] {
9+
try {
10+
const parsed = JSON.parse(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY) ?? '[]')
11+
if (!Array.isArray(parsed)) return []
12+
return parsed.filter((e): e is string => typeof e === 'string').slice(0, MAX_RECENT)
13+
} catch {
14+
return []
15+
}
16+
}
17+
18+
/**
19+
* Last {@link MAX_RECENT} emails the admin impersonated, most recent first,
20+
* persisted in localStorage on this browser.
21+
*/
22+
export function useRecentImpersonations() {
23+
const [recentEmails, setRecentEmails] = useState<string[]>(() => readRecentEmails())
24+
25+
const recordImpersonation = useCallback((email: string) => {
26+
const next = [email, ...readRecentEmails().filter((e) => e !== email)].slice(0, MAX_RECENT)
27+
localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, JSON.stringify(next))
28+
setRecentEmails(next)
29+
}, [])
30+
31+
return { recentEmails, recordImpersonation }
32+
}

0 commit comments

Comments
 (0)