From 7f2a165c7febefa81f8110950f1cc75035283b37 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 17 Jul 2026 15:06:29 -0700 Subject: [PATCH 1/4] feat(admin): show recently impersonated users in admin panel --- .../settings/components/admin/admin.tsx | 285 +++++++++--------- .../admin/use-recent-impersonations.ts | 32 ++ apps/sim/hooks/queries/admin-users.ts | 41 ++- apps/sim/stores/index.ts | 4 +- 4 files changed, 222 insertions(+), 140 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts 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 9d7094ab17f..24313aa2639 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -10,10 +10,13 @@ import { adminParsers, adminUrlKeys, } from '@/app/workspace/[workspaceId]/settings/components/admin/search-params' +import { useRecentImpersonations } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { + type AdminUser, useAdminUsers, + useAdminUsersByEmails, useBanUser, useImpersonateUser, useSetUserRole, @@ -25,6 +28,16 @@ import { clearUserData } from '@/stores' const PAGE_SIZE = 20 as const +const USER_TABLE_HEADER = ( +
+ Name + Email + Role + Status + Actions +
+) + const MOTHERSHIP_ENV_OPTIONS: { value: MothershipEnvironment; label: string }[] = [ { value: 'default', label: 'Default' }, { value: 'dev', label: 'Dev' }, @@ -43,6 +56,8 @@ export function Admin() { const banUser = useBanUser() const unbanUser = useUnbanUser() const impersonateUser = useImpersonateUser() + const { recentEmails, recordImpersonation } = useRecentImpersonations() + const { data: recentUsers } = useAdminUsersByEmails(recentEmails) const [workflowId, setWorkflowId] = useState('') const [targetWorkspaceId, setTargetWorkspaceId] = useState('') @@ -94,7 +109,7 @@ export function Admin() { } } - const handleImpersonate = (userId: string) => { + const handleImpersonate = (userId: string, email: string) => { setImpersonationGuardError(null) if (session?.user?.role !== 'admin') { setImpersonatingUserId(null) @@ -111,6 +126,7 @@ export function Admin() { setImpersonatingUserId(null) }, onSuccess: async () => { + recordImpersonation(email) await clearUserData() window.location.assign('/workspace') }, @@ -156,6 +172,125 @@ export function Admin() { impersonateUser.variables, 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' + /> + +
+ )} +
+ ) + return (
@@ -275,149 +410,16 @@ export function Admin() {

)} - {searchQuery.length > 0 && usersData && ( + {searchQuery.length > 0 && usersData ? ( <>
-
- Name - Email - Role - Status - Actions -
+ {USER_TABLE_HEADER} {usersData.users.length === 0 && ( No users found. )} - {usersData.users.map((u) => ( -
-
- - {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' - /> - -
- )} -
- ))} + {usersData.users.map((u) => renderUserRow(u))}
{totalPages > 1 && ( @@ -450,6 +452,15 @@ export function Admin() {
)} + ) : ( + searchQuery.length === 0 && + recentUsers && + recentUsers.length > 0 && ( +
+ {USER_TABLE_HEADER} + {recentUsers.map((u) => renderUserRow(u))} +
+ ) )}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts new file mode 100644 index 00000000000..6975c233ec4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from 'react' + +/** Survives clearUserData via the keysToKeep allowlist in @/stores. */ +export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations' + +const MAX_RECENT = 5 + +function readRecentEmails(): string[] { + try { + const parsed = JSON.parse(localStorage.getItem(RECENT_IMPERSONATIONS_STORAGE_KEY) ?? '[]') + if (!Array.isArray(parsed)) return [] + return parsed.filter((e): e is string => typeof e === 'string').slice(0, MAX_RECENT) + } catch { + return [] + } +} + +/** + * Last {@link MAX_RECENT} emails the admin impersonated, most recent first, + * persisted in localStorage on this browser. + */ +export function useRecentImpersonations() { + const [recentEmails, setRecentEmails] = useState(() => readRecentEmails()) + + const recordImpersonation = useCallback((email: string) => { + const next = [email, ...readRecentEmails().filter((e) => e !== email)].slice(0, MAX_RECENT) + localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, JSON.stringify(next)) + setRecentEmails(next) + }, []) + + return { recentEmails, recordImpersonation } +} diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index 658b6ebe009..d2339ce3f44 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -12,9 +12,10 @@ export const adminUserKeys = { lists: () => [...adminUserKeys.all, 'list'] as const, list: (offset: number, limit: number, searchQuery: string) => [...adminUserKeys.lists(), offset, limit, searchQuery] as const, + byEmails: (emails: string[]) => [...adminUserKeys.lists(), 'byEmails', emails] as const, } -interface AdminUser { +export interface AdminUser { id: string name: string email: string @@ -81,6 +82,44 @@ async function fetchAdminUsers( } } +async function fetchAdminUsersByEmails( + emails: string[], + signal?: AbortSignal +): Promise { + const results = await Promise.all( + emails.map(async (email) => { + const { data, error } = await client.admin.listUsers( + { + query: { + limit: 5, + searchField: 'email', + searchValue: email, + searchOperator: 'contains', + }, + }, + { signal } + ) + if (error) throw new Error(error.message ?? 'Failed to fetch user') + return ( + (data?.users ?? []) + .map(mapUser) + .find((u) => u.email.toLowerCase() === email.toLowerCase()) ?? null + ) + }) + ) + return results.filter((u): u is AdminUser => u !== null) +} + +/** Resolves each email to its exact-match user; unmatched emails are dropped. */ +export function useAdminUsersByEmails(emails: string[]) { + return useQuery({ + queryKey: adminUserKeys.byEmails(emails), + queryFn: ({ signal }) => fetchAdminUsersByEmails(emails, signal), + enabled: emails.length > 0, + staleTime: ADMIN_USER_LIST_STALE_TIME, + }) +} + export function useAdminUsers(offset: number, limit: number, searchQuery: string) { return useQuery({ queryKey: adminUserKeys.list(offset, limit, searchQuery), diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 71d4c54ba23..12146e10c5f 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations' import { environmentKeys } from '@/hooks/queries/environment' import { useExecutionStore } from '@/stores/execution' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' @@ -50,8 +51,7 @@ export async function clearUserData(): Promise { try { resetAllStores() - // Clear localStorage except for essential app settings - const keysToKeep = ['next-favicon', 'theme'] + const keysToKeep = ['next-favicon', 'theme', RECENT_IMPERSONATIONS_STORAGE_KEY] const keysToRemove = Object.keys(localStorage).filter((key) => !keysToKeep.includes(key)) keysToRemove.forEach((key) => localStorage.removeItem(key)) From 39a46b90796a722f28b7b061aa29426c86a12ad0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 17 Jul 2026 15:11:26 -0700 Subject: [PATCH 2/4] improvement(admin): align user table with settings list idiom --- .../[workspaceId]/settings/components/admin/admin.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 24313aa2639..5a2daaa70ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -29,7 +29,7 @@ import { clearUserData } from '@/stores' const PAGE_SIZE = 20 as const const USER_TABLE_HEADER = ( -
+
Name Email Role @@ -174,13 +174,7 @@ export function Admin() { ]) const renderUserRow = (u: AdminUser) => ( -
+
{u.name || '—'} {u.email} From a636c3dfbfdd7d190d28b414d4eed59541ac520f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 17 Jul 2026 15:31:03 -0700 Subject: [PATCH 3/4] fix(admin): address review findings on recent impersonations --- .../components/admin/use-recent-impersonations.ts | 10 ++++++---- apps/sim/hooks/queries/admin-users.ts | 2 +- apps/sim/stores/index.ts | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts index 6975c233ec4..4a1eecb92e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts @@ -1,7 +1,5 @@ import { useCallback, useState } from 'react' - -/** Survives clearUserData via the keysToKeep allowlist in @/stores. */ -export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations' +import { RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores' const MAX_RECENT = 5 @@ -24,7 +22,11 @@ export function useRecentImpersonations() { const recordImpersonation = useCallback((email: string) => { const next = [email, ...readRecentEmails().filter((e) => e !== email)].slice(0, MAX_RECENT) - localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, JSON.stringify(next)) + try { + localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, JSON.stringify(next)) + } catch { + /* best-effort: recording must never block the impersonation transition */ + } setRecentEmails(next) }, []) diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index d2339ce3f44..eab2821abf0 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -91,7 +91,7 @@ async function fetchAdminUsersByEmails( const { data, error } = await client.admin.listUsers( { query: { - limit: 5, + limit: 20, searchField: 'email', searchValue: email, searchOperator: 'contains', diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 12146e10c5f..529cb891a55 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger' import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations' import { environmentKeys } from '@/hooks/queries/environment' import { useExecutionStore } from '@/stores/execution' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' @@ -13,6 +12,9 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('Stores') +/** localStorage key for the admin recent-impersonations list; kept through clearUserData. */ +export const RECENT_IMPERSONATIONS_STORAGE_KEY = 'recent-impersonations' + /** * Reset all Zustand stores and React Query caches to initial state. */ From 24803be7ed2f4ea38894c2aad07762f36badf190 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 17 Jul 2026 15:42:44 -0700 Subject: [PATCH 4/4] fix(admin): exact server-side email filter for recent impersonation lookup --- apps/sim/hooks/queries/admin-users.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index eab2821abf0..afa3fa7c893 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -91,20 +91,17 @@ async function fetchAdminUsersByEmails( const { data, error } = await client.admin.listUsers( { query: { - limit: 20, - searchField: 'email', - searchValue: email, - searchOperator: 'contains', + limit: 1, + filterField: 'email', + filterValue: email, + filterOperator: 'eq', }, }, { signal } ) if (error) throw new Error(error.message ?? 'Failed to fetch user') - return ( - (data?.users ?? []) - .map(mapUser) - .find((u) => u.email.toLowerCase() === email.toLowerCase()) ?? null - ) + const user = (data?.users ?? [])[0] + return user ? mapUser(user) : null }) ) return results.filter((u): u is AdminUser => u !== null)