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..5a2daaa70ca 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,119 @@ 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 +404,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 +446,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..4a1eecb92e3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from 'react' +import { RECENT_IMPERSONATIONS_STORAGE_KEY } from '@/stores' + +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) + try { + localStorage.setItem(RECENT_IMPERSONATIONS_STORAGE_KEY, JSON.stringify(next)) + } catch { + /* best-effort: recording must never block the impersonation transition */ + } + 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..afa3fa7c893 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,41 @@ 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: 1, + filterField: 'email', + filterValue: email, + filterOperator: 'eq', + }, + }, + { signal } + ) + if (error) throw new Error(error.message ?? 'Failed to fetch user') + const user = (data?.users ?? [])[0] + return user ? mapUser(user) : 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..529cb891a55 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -12,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. */ @@ -50,8 +53,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))