@@ -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'
1314import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
1415import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1516import {
17+ type AdminUser ,
1618 useAdminUsers ,
19+ useAdminUsersByEmails ,
1720 useBanUser ,
1821 useImpersonateUser ,
1922 useSetUserRole ,
@@ -25,6 +28,16 @@ import { clearUserData } from '@/stores'
2528
2629const 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+
2841const 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 >
0 commit comments