11'use client'
22
33import { useEffect , useMemo , useRef , useState } from 'react'
4- import { Badge , Button , Chip , ChipInput , ChipSelect , cn , Label , Search , Switch } from '@sim/emcn'
4+ import {
5+ Badge ,
6+ Button ,
7+ Chip ,
8+ ChipConfirmModal ,
9+ ChipInput ,
10+ ChipModalField ,
11+ ChipSelect ,
12+ Label ,
13+ Search ,
14+ Switch ,
15+ } from '@sim/emcn'
516import { getErrorMessage } from '@sim/utils/errors'
617import { useQueryStates } from 'nuqs'
718import type { MothershipEnvironment } from '@/lib/api/contracts'
@@ -12,6 +23,7 @@ import {
1223 adminUrlKeys ,
1324} from '@/app/workspace/[workspaceId]/settings/components/admin/search-params'
1425import { useRecentImpersonations } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations'
26+ import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
1527import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
1628import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
1729import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -37,10 +49,13 @@ const USER_TABLE_HEADER = (
3749 < span className = 'flex-1' > Email</ span >
3850 < span className = 'w-[60px]' > Role</ span >
3951 < span className = 'w-[55px]' > Status</ span >
40- < span className = 'w-[300px ] text-right' > Actions</ span >
52+ < span className = 'w-[150px ] text-right' > Actions</ span >
4153 </ div >
4254)
4355
56+ /** The row action awaiting confirmation in {@link ChipConfirmModal}. */
57+ type PendingUserAction = { type : 'ban' ; user : AdminUser } | { type : 'role' ; user : AdminUser }
58+
4459const MOTHERSHIP_ENV_OPTIONS : { value : MothershipEnvironment ; label : string } [ ] = [
4560 { value : 'default' , label : 'Default' } ,
4661 { value : 'dev' , label : 'Dev' } ,
@@ -72,7 +87,7 @@ export function Admin() {
7287 )
7388
7489 const [ searchInput , setSearchInput ] = useState ( searchQuery )
75- const [ banUserId , setBanUserId ] = useState < string | null > ( null )
90+ const [ pendingAction , setPendingAction ] = useState < PendingUserAction | null > ( null )
7691 const [ banReason , setBanReason ] = useState ( '' )
7792 const [ impersonatingUserId , setImpersonatingUserId ] = useState < string | null > ( null )
7893 const [ impersonationGuardError , setImpersonationGuardError ] = useState < string | null > ( null )
@@ -155,6 +170,38 @@ export function Admin() {
155170 )
156171 }
157172
173+ const isDemotion = pendingAction ?. user . role === 'admin'
174+
175+ const closePendingAction = ( ) => {
176+ setPendingAction ( null )
177+ setBanReason ( '' )
178+ }
179+
180+ const handleConfirmBan = ( ) => {
181+ if ( pendingAction ?. type !== 'ban' ) return
182+ const trimmedReason = banReason . trim ( )
183+ banUser . reset ( )
184+ banUser . mutate (
185+ {
186+ userId : pendingAction . user . id ,
187+ ...( trimmedReason ? { banReason : trimmedReason } : { } ) ,
188+ } ,
189+ { onSuccess : closePendingAction }
190+ )
191+ }
192+
193+ const handleConfirmRoleChange = ( ) => {
194+ if ( pendingAction ?. type !== 'role' ) return
195+ setUserRole . reset ( )
196+ setUserRole . mutate (
197+ {
198+ userId : pendingAction . user . id ,
199+ role : pendingAction . user . role === 'admin' ? 'user' : 'admin' ,
200+ } ,
201+ { onSuccess : closePendingAction }
202+ )
203+ }
204+
158205 const pendingUserIds = useMemo ( ( ) => {
159206 const ids = new Set < string > ( )
160207 if ( setUserRole . isPending && ( setUserRole . variables as { userId ?: string } ) ?. userId )
@@ -183,7 +230,7 @@ export function Admin() {
183230 impersonatingUserId ,
184231 ] )
185232
186- /** Confirms the send in place , since nothing about the user row changes. */
233+ /** Confirms the send on the menu item itself , since nothing about the user row changes. */
187234 const resetPasswordLabel = ( userId : string ) => {
188235 if ( sendPasswordReset . variables ?. userId !== userId ) return 'Reset password'
189236 if ( sendPasswordReset . isPending ) return 'Sending...'
@@ -192,126 +239,70 @@ export function Admin() {
192239 }
193240
194241 const renderUserRow = ( u : AdminUser ) => (
195- < div key = { u . id } className = 'flex flex-col gap-2 px-3 py-2 text-small' >
196- < div className = 'flex items-center gap-3' >
197- < span className = 'w-[170px] truncate text-[var(--text-primary)]' > { u . name || '—' } </ span >
198- < span className = 'flex-1 truncate text-[var(--text-secondary)]' > { u . email } </ span >
199- < span className = 'w-[60px]' >
200- < Badge variant = { u . role === 'admin' ? 'blue' : 'gray' } > { u . role || 'user' } </ Badge >
201- </ span >
202- < span className = 'w-[55px]' >
203- { u . banned ? < Badge variant = 'red' > Banned</ Badge > : < Badge variant = 'green' > Active</ Badge > }
204- </ span >
205- < span className = 'flex w-[300px] justify-end gap-1' >
206- { u . id !== session ?. user ?. id && (
207- < >
208- < Button
209- variant = 'active'
210- className = 'h-[28px] px-2 text-caption'
211- onClick = { ( ) => {
212- setProvisionWarning ( null )
213- sendPasswordReset . reset ( )
214- sendPasswordReset . mutate ( { userId : u . id , email : u . email } )
215- } }
216- disabled = { pendingUserIds . has ( u . id ) }
217- >
218- { resetPasswordLabel ( u . id ) }
219- </ Button >
220- < Button
221- variant = 'active'
222- className = 'h-[28px] px-2 text-caption'
223- onClick = { ( ) => handleImpersonate ( u . id , u . email ) }
224- disabled = { pendingUserIds . has ( u . id ) }
225- >
226- { impersonatingUserId === u . id ||
227- ( impersonateUser . isPending &&
228- ( impersonateUser . variables as { userId ?: string } | undefined ) ?. userId === u . id )
229- ? 'Switching...'
230- : 'Impersonate' }
231- </ Button >
232- < Button
233- variant = 'active'
234- className = 'h-[28px] px-2 text-caption'
235- onClick = { ( ) => {
236- setUserRole . reset ( )
237- setUserRole . mutate ( {
238- userId : u . id ,
239- role : u . role === 'admin' ? 'user' : 'admin' ,
240- } )
241- } }
242- disabled = { pendingUserIds . has ( u . id ) }
243- >
244- { u . role === 'admin' ? 'Demote' : 'Promote' }
245- </ Button >
246- { u . banned ? (
247- < Button
248- variant = 'active'
249- className = 'h-[28px] px-2 text-caption'
250- onClick = { ( ) => {
251- unbanUser . reset ( )
252- unbanUser . mutate ( { userId : u . id } )
253- } }
254- disabled = { pendingUserIds . has ( u . id ) }
255- >
256- Unban
257- </ Button >
258- ) : (
259- < Button
260- variant = 'active'
261- className = { cn (
262- 'h-[28px] px-2 text-caption' ,
263- banUserId === u . id ? 'text-[var(--text-primary)]' : 'text-[var(--text-error)]'
264- ) }
265- onClick = { ( ) => {
266- if ( banUserId === u . id ) {
267- setBanUserId ( null )
268- setBanReason ( '' )
269- } else {
270- setBanUserId ( u . id )
271- setBanReason ( '' )
272- }
273- } }
274- disabled = { pendingUserIds . has ( u . id ) }
275- >
276- { banUserId === u . id ? 'Cancel' : 'Ban' }
277- </ Button >
278- ) }
279- </ >
280- ) }
281- </ span >
282- </ div >
283- { banUserId === u . id && ! u . banned && (
284- < div className = 'flex items-center gap-2 pl-[170px]' >
285- < ChipInput
286- value = { banReason }
287- onChange = { ( e ) => setBanReason ( e . target . value ) }
288- placeholder = 'Reason (optional)'
289- className = 'flex-1'
290- />
291- < Button
292- variant = 'primary'
293- className = 'h-[28px] px-3 text-caption'
294- onClick = { ( ) => {
295- banUser . reset ( )
296- banUser . mutate (
242+ < div key = { u . id } className = 'flex items-center gap-3 px-3 py-2 text-small' >
243+ < span className = 'w-[170px] truncate text-[var(--text-primary)]' > { u . name || '—' } </ span >
244+ < span className = 'flex-1 truncate text-[var(--text-secondary)]' > { u . email } </ span >
245+ < span className = 'w-[60px]' >
246+ < Badge variant = { u . role === 'admin' ? 'blue' : 'gray' } > { u . role || 'user' } </ Badge >
247+ </ span >
248+ < span className = 'w-[55px]' >
249+ { u . banned ? < Badge variant = 'red' > Banned</ Badge > : < Badge variant = 'green' > Active</ Badge > }
250+ </ span >
251+ < span className = 'flex w-[150px] items-center justify-end gap-1' >
252+ { u . id !== session ?. user ?. id && (
253+ < >
254+ < Button
255+ variant = 'active'
256+ className = 'h-[28px] px-2 text-caption'
257+ onClick = { ( ) => handleImpersonate ( u . id , u . email ) }
258+ disabled = { pendingUserIds . has ( u . id ) }
259+ >
260+ { impersonatingUserId === u . id ||
261+ ( impersonateUser . isPending &&
262+ ( impersonateUser . variables as { userId ?: string } | undefined ) ?. userId === u . id )
263+ ? 'Switching...'
264+ : 'Impersonate' }
265+ </ Button >
266+ < RowActionsMenu
267+ label = { `Actions for ${ u . email } ` }
268+ actions = { [
297269 {
298- userId : u . id ,
299- ...( banReason . trim ( ) ? { banReason : banReason . trim ( ) } : { } ) ,
270+ label : resetPasswordLabel ( u . id ) ,
271+ onSelect : ( ) => {
272+ setProvisionWarning ( null )
273+ sendPasswordReset . reset ( )
274+ sendPasswordReset . mutate ( { userId : u . id , email : u . email } )
275+ } ,
276+ disabled : pendingUserIds . has ( u . id ) ,
300277 } ,
301278 {
302- onSuccess : ( ) => {
303- setBanUserId ( null )
304- setBanReason ( '' )
305- } ,
306- }
307- )
308- } }
309- disabled = { pendingUserIds . has ( u . id ) }
310- >
311- Confirm Ban
312- </ Button >
313- </ div >
314- ) }
279+ label : u . role === 'admin' ? 'Demote' : 'Promote' ,
280+ onSelect : ( ) => setPendingAction ( { type : 'role' , user : u } ) ,
281+ disabled : pendingUserIds . has ( u . id ) ,
282+ } ,
283+ u . banned
284+ ? {
285+ label : 'Unban' ,
286+ onSelect : ( ) => {
287+ unbanUser . reset ( )
288+ unbanUser . mutate ( { userId : u . id } )
289+ } ,
290+ disabled : pendingUserIds . has ( u . id ) ,
291+ }
292+ : {
293+ label : 'Ban' ,
294+ onSelect : ( ) => {
295+ setBanReason ( '' )
296+ setPendingAction ( { type : 'ban' , user : u } )
297+ } ,
298+ destructive : true ,
299+ disabled : pendingUserIds . has ( u . id ) ,
300+ } ,
301+ ] }
302+ />
303+ </ >
304+ ) }
305+ </ span >
315306 </ div >
316307 )
317308
@@ -503,6 +494,66 @@ export function Admin() {
503494 ) }
504495 </ div >
505496 </ SettingsSection >
497+ < ChipConfirmModal
498+ open = { pendingAction ?. type === 'ban' }
499+ onOpenChange = { ( open ) => {
500+ if ( ! open ) closePendingAction ( )
501+ } }
502+ srTitle = 'Ban user'
503+ title = 'Ban user'
504+ text = { [
505+ 'Banning ' ,
506+ { text : pendingAction ?. user . email ?? 'this user' , bold : true } ,
507+ ' ' ,
508+ {
509+ text : 'signs them out everywhere and blocks them from signing back in.' ,
510+ error : true ,
511+ } ,
512+ ' You can unban them later.' ,
513+ ] }
514+ confirm = { {
515+ label : 'Ban' ,
516+ onClick : handleConfirmBan ,
517+ pending : banUser . isPending ,
518+ pendingLabel : 'Banning...' ,
519+ } }
520+ >
521+ < ChipModalField
522+ type = 'input'
523+ title = 'Reason'
524+ value = { banReason }
525+ onChange = { setBanReason }
526+ placeholder = 'Optional'
527+ disabled = { banUser . isPending }
528+ />
529+ </ ChipConfirmModal >
530+
531+ < ChipConfirmModal
532+ open = { pendingAction ?. type === 'role' }
533+ onOpenChange = { ( open ) => {
534+ if ( ! open ) closePendingAction ( )
535+ } }
536+ srTitle = { isDemotion ? 'Demote user' : 'Promote user' }
537+ title = { isDemotion ? 'Demote user' : 'Promote user' }
538+ text = { [
539+ isDemotion ? 'Demoting ' : 'Promoting ' ,
540+ { text : pendingAction ?. user . email ?? 'this user' , bold : true } ,
541+ ' ' ,
542+ isDemotion
543+ ? { text : 'revokes their platform admin access.' , error : true }
544+ : {
545+ text : 'grants full platform admin access, including impersonating any user.' ,
546+ error : true ,
547+ } ,
548+ ] }
549+ confirm = { {
550+ label : isDemotion ? 'Demote' : 'Promote' ,
551+ onClick : handleConfirmRoleChange ,
552+ pending : setUserRole . isPending ,
553+ pendingLabel : isDemotion ? 'Demoting...' : 'Promoting...' ,
554+ } }
555+ />
556+
506557 < AddUserModal
507558 open = { isAddUserOpen }
508559 onOpenChange = { setIsAddUserOpen }
@@ -513,7 +564,7 @@ export function Admin() {
513564 setAdminParams ( { q : user . email , offset : null } )
514565 setProvisionWarning (
515566 resetEmailError
516- ? `Created ${ user . email } , but the password reset email failed to send (${ resetEmailError } ). Use Reset password on their row to try again.`
567+ ? `Created ${ user . email } , but the password reset email failed to send (${ resetEmailError } ). Use Reset password in that row's actions menu to try again.`
517568 : null
518569 )
519570 } }
0 commit comments