Skip to content

Commit 8898181

Browse files
PttCodingManclaude
andcommitted
feat: admin trash UI for deleted users.
Splits the Users panel into Active/Deleted tabs so admins can restore soft-deleted accounts. When the original username is taken, the restore button surfaces a prompt for a replacement and retries once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f8571f2 commit 8898181

1 file changed

Lines changed: 163 additions & 46 deletions

File tree

frontend/src/pages/Admin.jsx

Lines changed: 163 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,13 @@ function ExportSection() {
8888
}
8989

9090
function 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

Comments
 (0)