@@ -88,10 +88,13 @@ function ExportSection() {
8888}
8989
9090function UsersSection ( ) {
91+ const [ tab , setTab ] = useState ( 'active' )
9192 const [ users , setUsers ] = useState ( [ ] )
93+ const [ deletedUsers , setDeletedUsers ] = useState ( [ ] )
9294 const [ showCreate , setShowCreate ] = useState ( false )
9395 const [ form , setForm ] = useState ( { username : '' , password : '' , role : 'editor' } )
9496 const [ error , setError ] = useState ( '' )
97+ const [ busyId , setBusyId ] = useState ( null )
9598
9699 const loadUsers = async ( ) => {
97100 try {
@@ -100,6 +103,13 @@ function UsersSection() {
100103 } catch { /* ignore */ }
101104 }
102105
106+ const loadDeleted = async ( ) => {
107+ try {
108+ const res = await api . get ( '/users/deleted' )
109+ setDeletedUsers ( Array . isArray ( res . data ) ? res . data : [ ] )
110+ } catch { /* ignore */ }
111+ }
112+
103113 useEffect ( ( ) => {
104114 let cancelled = false
105115 api . get ( '/users' )
@@ -108,6 +118,10 @@ function UsersSection() {
108118 return ( ) => { cancelled = true }
109119 } , [ ] )
110120
121+ useEffect ( ( ) => {
122+ if ( tab === 'deleted' ) loadDeleted ( )
123+ } , [ tab ] )
124+
111125 const handleCreate = async ( e ) => {
112126 e . preventDefault ( )
113127 setError ( '' )
@@ -121,11 +135,19 @@ function UsersSection() {
121135 }
122136 }
123137
124- const handleDelete = async ( userId ) => {
125- if ( ! confirm ( 'Delete this user?' ) ) return
138+ const handleDelete = async ( u ) => {
139+ const label = u . username
140+ if ( ! confirm (
141+ `Delete user "${ label } "?\n\nThis is a soft-delete — the account is deactivated, ` +
142+ `the username "${ label } " is freed for reuse, and pages they authored keep their ` +
143+ `authorship. You can restore the account from the Deleted tab.`
144+ ) ) return
126145 try {
127- await api . delete ( `/users/${ userId } ` )
146+ await api . delete ( `/users/${ u . id } ` )
128147 loadUsers ( )
148+ // Keep the deleted list in sync even if it hasn't been opened yet,
149+ // so switching tabs later shows the fresh row without a flash.
150+ loadDeleted ( )
129151 } catch ( err ) {
130152 alert ( err ?. response ?. data ?. detail || 'Failed to delete user' )
131153 }
@@ -140,19 +162,69 @@ function UsersSection() {
140162 }
141163 }
142164
165+ // Try restoring to the original username; if that slot is now occupied,
166+ // backend replies 409 — we prompt the admin for a replacement and retry once.
167+ const handleRestore = async ( u ) => {
168+ setBusyId ( u . id )
169+ try {
170+ await api . post ( `/users/${ u . id } /restore` , { } )
171+ await Promise . all ( [ loadUsers ( ) , loadDeleted ( ) ] )
172+ } catch ( err ) {
173+ if ( err ?. response ?. status === 409 ) {
174+ const suggestion = `${ u . original_username || 'user' } -restored`
175+ const alternative = prompt (
176+ `Username "${ u . original_username } " is already in use. ` +
177+ `Enter a different username to restore the account under:` ,
178+ suggestion ,
179+ )
180+ if ( ! alternative || ! alternative . trim ( ) ) {
181+ setBusyId ( null )
182+ return
183+ }
184+ try {
185+ await api . post ( `/users/${ u . id } /restore` , { username : alternative . trim ( ) } )
186+ await Promise . all ( [ loadUsers ( ) , loadDeleted ( ) ] )
187+ } catch ( inner ) {
188+ alert ( inner ?. response ?. data ?. detail || 'Restore failed' )
189+ }
190+ } else {
191+ alert ( err ?. response ?. data ?. detail || 'Restore failed' )
192+ }
193+ } finally {
194+ setBusyId ( null )
195+ }
196+ }
197+
198+ const tabClass = ( name ) =>
199+ `px-3 py-1.5 rounded-lg text-sm transition ${
200+ tab === name
201+ ? 'bg-primary text-primary-text'
202+ : 'bg-surface-hover border border-border text-text hover:bg-surface-active'
203+ } `
204+
143205 return (
144206 < div className = "bg-surface rounded-xl shadow-sm border border-border p-6" >
145- < div className = "flex items-center justify-between mb-4" >
207+ < div className = "flex items-center justify-between mb-4 gap-3 flex-wrap " >
146208 < h2 className = "text-lg font-semibold text-text" > Users</ h2 >
147- < button
148- onClick = { ( ) => setShowCreate ( ! showCreate ) }
149- className = "px-3 py-1.5 bg-primary text-primary-text rounded-lg text-sm hover:bg-primary-hover"
150- >
151- + Add User
152- </ button >
209+ < div className = "flex items-center gap-2 flex-wrap" >
210+ < button type = "button" onClick = { ( ) => setTab ( 'active' ) } className = { tabClass ( 'active' ) } >
211+ Active { users . length > 0 && < span className = "text-xs opacity-70" > ({ users . length } )</ span > }
212+ </ button >
213+ < button type = "button" onClick = { ( ) => setTab ( 'deleted' ) } className = { tabClass ( 'deleted' ) } >
214+ Deleted { deletedUsers . length > 0 && < span className = "text-xs opacity-70" > ({ deletedUsers . length } )</ span > }
215+ </ button >
216+ { tab === 'active' && (
217+ < button
218+ onClick = { ( ) => setShowCreate ( ! showCreate ) }
219+ className = "px-3 py-1.5 bg-primary text-primary-text rounded-lg text-sm hover:bg-primary-hover"
220+ >
221+ + Add User
222+ </ button >
223+ ) }
224+ </ div >
153225 </ div >
154226
155- { showCreate && (
227+ { tab === 'active' && showCreate && (
156228 < form onSubmit = { handleCreate } className = "mb-4 p-4 bg-surface-hover rounded-lg border border-border" >
157229 < div className = "flex flex-col sm:flex-row gap-3" >
158230 < input
@@ -188,42 +260,87 @@ function UsersSection() {
188260 </ form >
189261 ) }
190262
191- < div className = "overflow-x-auto" >
192- < table className = "w-full text-sm" >
193- < thead >
194- < tr className = "border-b border-border" >
195- < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Username</ th >
196- < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Role</ th >
197- < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Created</ th >
198- < th className = "text-right py-2 px-3 text-text-secondary font-medium" > Actions</ th >
199- </ tr >
200- </ thead >
201- < tbody >
202- { users . map ( ( u ) => (
203- < tr key = { u . id } className = "border-b border-border" >
204- < td className = "py-2 px-3 text-text" > { u . username } </ td >
205- < td className = "py-2 px-3" >
206- < select
207- value = { u . role }
208- onChange = { ( e ) => handleRoleChange ( u . id , e . target . value ) }
209- className = "text-sm px-2 py-1 border border-border rounded bg-surface text-text"
210- >
211- < option value = "editor" > Editor</ option >
212- < option value = "viewer" > Viewer</ option >
213- < option value = "admin" > Admin</ option >
214- </ select >
215- </ td >
216- < td className = "py-2 px-3 text-text-secondary" > { u . created_at ? new Date ( u . created_at ) . toLocaleDateString ( ) : '-' } </ td >
217- < td className = "py-2 px-3 text-right" >
218- < button onClick = { ( ) => handleDelete ( u . id ) } className = "text-red-500 hover:text-red-700 text-sm" >
219- Delete
220- </ button >
221- </ td >
263+ { tab === 'active' && (
264+ < div className = "overflow-x-auto" >
265+ < table className = "w-full text-sm" >
266+ < thead >
267+ < tr className = "border-b border-border" >
268+ < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Username</ th >
269+ < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Role</ th >
270+ < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Created</ th >
271+ < th className = "text-right py-2 px-3 text-text-secondary font-medium" > Actions</ th >
222272 </ tr >
223- ) ) }
224- </ tbody >
225- </ table >
226- </ div >
273+ </ thead >
274+ < tbody >
275+ { users . map ( ( u ) => (
276+ < tr key = { u . id } className = "border-b border-border" >
277+ < td className = "py-2 px-3 text-text" > { u . username } </ td >
278+ < td className = "py-2 px-3" >
279+ < select
280+ value = { u . role }
281+ onChange = { ( e ) => handleRoleChange ( u . id , e . target . value ) }
282+ className = "text-sm px-2 py-1 border border-border rounded bg-surface text-text"
283+ >
284+ < option value = "editor" > Editor</ option >
285+ < option value = "viewer" > Viewer</ option >
286+ < option value = "admin" > Admin</ option >
287+ </ select >
288+ </ td >
289+ < td className = "py-2 px-3 text-text-secondary" > { u . created_at ? new Date ( u . created_at ) . toLocaleDateString ( ) : '-' } </ td >
290+ < td className = "py-2 px-3 text-right" >
291+ < button onClick = { ( ) => handleDelete ( u ) } className = "text-red-500 hover:text-red-700 text-sm" >
292+ Delete
293+ </ button >
294+ </ td >
295+ </ tr >
296+ ) ) }
297+ </ tbody >
298+ </ table >
299+ </ div >
300+ ) }
301+
302+ { tab === 'deleted' && (
303+ deletedUsers . length === 0 ? (
304+ < div className = "text-center py-8 text-text-secondary" >
305+ No deleted users.
306+ </ div >
307+ ) : (
308+ < div className = "overflow-x-auto" >
309+ < table className = "w-full text-sm" >
310+ < thead >
311+ < tr className = "border-b border-border" >
312+ < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Original username</ th >
313+ < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Display name</ th >
314+ < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Role</ th >
315+ < th className = "text-left py-2 px-3 text-text-secondary font-medium" > Deleted</ th >
316+ < th className = "text-right py-2 px-3 text-text-secondary font-medium" > Actions</ th >
317+ </ tr >
318+ </ thead >
319+ < tbody >
320+ { deletedUsers . map ( ( u ) => (
321+ < tr key = { u . id } className = "border-b border-border" >
322+ < td className = "py-2 px-3 text-text" > { u . original_username || `user #${ u . id } ` } </ td >
323+ < td className = "py-2 px-3 text-text-secondary" > { u . display_name || '—' } </ td >
324+ < td className = "py-2 px-3 text-text-secondary" > { u . role } </ td >
325+ < td className = "py-2 px-3 text-text-secondary" >
326+ { u . deleted_at ? new Date ( u . deleted_at ) . toLocaleString ( ) : '—' }
327+ </ td >
328+ < td className = "py-2 px-3 text-right" >
329+ < button
330+ onClick = { ( ) => handleRestore ( u ) }
331+ disabled = { busyId === u . id }
332+ className = "text-sm text-primary hover:underline disabled:opacity-50"
333+ >
334+ { busyId === u . id ? 'Restoring…' : 'Restore' }
335+ </ button >
336+ </ td >
337+ </ tr >
338+ ) ) }
339+ </ tbody >
340+ </ table >
341+ </ div >
342+ )
343+ ) }
227344 </ div >
228345 )
229346}
0 commit comments