Spaces:
Running
Running
Commit ·
f1f2b70
1
Parent(s): fbac631
Add superadmin user edit endpoint and inline UI
Browse filesAdds POST /admin/api/users/{id}/update for superadmin-only edits of full_name, role, and is_active, with guards against changing your own role or deactivating yourself. Surfaces an Edit button on each row in admin/users.html that swaps the row into an inline editor (name input, role select, active checkbox, Save/Cancel) so deactivated users can be reactivated and promoted without going through delete-and-recreate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- app/routes/admin_routes.py +25 -0
- app/templates/admin/users.html +63 -10
app/routes/admin_routes.py
CHANGED
|
@@ -291,6 +291,31 @@ async def toggle_user(user_id: int,
|
|
| 291 |
db.commit()
|
| 292 |
return JSONResponse({"success": True, "is_active": user.is_active if user else False})
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
@router.post("/api/users/{user_id}/reset-password")
|
| 295 |
async def admin_reset_password(user_id: int, request: Request,
|
| 296 |
current_user: User = Depends(require_admin),
|
|
|
|
| 291 |
db.commit()
|
| 292 |
return JSONResponse({"success": True, "is_active": user.is_active if user else False})
|
| 293 |
|
| 294 |
+
|
| 295 |
+
@router.post("/api/users/{user_id}/update")
|
| 296 |
+
async def update_user(user_id: int, request: Request,
|
| 297 |
+
current_user: User = Depends(require_superadmin),
|
| 298 |
+
db: Session = Depends(get_db)):
|
| 299 |
+
"""Superadmin-only: update name, role, active state of an existing user."""
|
| 300 |
+
body = await request.json()
|
| 301 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 302 |
+
if not user:
|
| 303 |
+
return JSONResponse({"error": "User not found"}, status_code=404)
|
| 304 |
+
if user.id == current_user.id and body.get("role") and body["role"] != current_user.role:
|
| 305 |
+
return JSONResponse({"error": "Cannot change your own role"}, status_code=400)
|
| 306 |
+
|
| 307 |
+
if "full_name" in body and body["full_name"]:
|
| 308 |
+
user.full_name = body["full_name"].strip()
|
| 309 |
+
if "role" in body and body["role"] in ("user", "admin", "superadmin"):
|
| 310 |
+
user.role = body["role"]
|
| 311 |
+
if "is_active" in body:
|
| 312 |
+
if user.id == current_user.id and not body["is_active"]:
|
| 313 |
+
return JSONResponse({"error": "Cannot deactivate yourself"}, status_code=400)
|
| 314 |
+
user.is_active = bool(body["is_active"])
|
| 315 |
+
db.commit()
|
| 316 |
+
return JSONResponse({"success": True, "id": user.id, "role": user.role,
|
| 317 |
+
"full_name": user.full_name, "is_active": user.is_active})
|
| 318 |
+
|
| 319 |
@router.post("/api/users/{user_id}/reset-password")
|
| 320 |
async def admin_reset_password(user_id: int, request: Request,
|
| 321 |
current_user: User = Depends(require_admin),
|
app/templates/admin/users.html
CHANGED
|
@@ -58,27 +58,54 @@
|
|
| 58 |
{% for u in users %}
|
| 59 |
<tr class="{{ 'bg-gray-50' if loop.index % 2 == 0 }} {{ 'opacity-50' if not u.is_active }} user-row" data-active="{{ 'true' if u.is_active else 'false' }}"
|
| 60 |
{% if not u.is_active %}style="display: none;"{% endif %}>
|
| 61 |
-
<td class="px-4 py-3 font-medium">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
<td class="px-4 py-3 text-gray-600">{{ u.email }}</td>
|
| 63 |
<td class="px-4 py-3 text-center">
|
| 64 |
-
<span class="px-2 py-0.5 rounded text-xs font-semibold
|
| 65 |
-
{{ 'bg-purple-100 text-purple-700' if u.role == 'superadmin' else 'bg-blue-100 text-blue-700' if u.role == 'admin' else 'bg-gray-100 text-gray-600' }}"
|
|
|
|
| 66 |
{{ u.role }}
|
| 67 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
</td>
|
| 69 |
<td class="px-4 py-3 text-center">
|
| 70 |
-
<span class="text-xs {{ 'text-green-600' if u.is_active else 'text-red-500' }}">
|
| 71 |
{{ 'Active' if u.is_active else 'Inactive' }}
|
| 72 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
</td>
|
| 74 |
<td class="px-4 py-3 text-center space-x-2">
|
| 75 |
{% if u.id != user.id %}
|
| 76 |
-
<
|
| 77 |
-
{
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
{% else %}
|
| 83 |
<span class="text-gray-300 text-xs">You</span>
|
| 84 |
{% endif %}
|
|
@@ -127,6 +154,32 @@ async function toggleUser(id) {
|
|
| 127 |
await fetch('/admin/api/users/' + id + '/toggle', { method: 'POST', headers: {'Authorization': 'Bearer ' + authToken} });
|
| 128 |
location.reload();
|
| 129 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
async function resetPassword(id, email) {
|
| 131 |
if (!confirm('Send password reset email to ' + email + '?')) return;
|
| 132 |
const resp = await fetch('/admin/api/users/' + id + '/reset-password', {
|
|
|
|
| 58 |
{% for u in users %}
|
| 59 |
<tr class="{{ 'bg-gray-50' if loop.index % 2 == 0 }} {{ 'opacity-50' if not u.is_active }} user-row" data-active="{{ 'true' if u.is_active else 'false' }}"
|
| 60 |
{% if not u.is_active %}style="display: none;"{% endif %}>
|
| 61 |
+
<td class="px-4 py-3 font-medium">
|
| 62 |
+
<span class="view-mode" id="name_view_{{ u.id }}">{{ u.full_name }}</span>
|
| 63 |
+
<input type="text" class="edit-mode hidden w-full border rounded px-2 py-1 text-sm"
|
| 64 |
+
id="name_edit_{{ u.id }}" value="{{ u.full_name }}">
|
| 65 |
+
</td>
|
| 66 |
<td class="px-4 py-3 text-gray-600">{{ u.email }}</td>
|
| 67 |
<td class="px-4 py-3 text-center">
|
| 68 |
+
<span class="view-mode px-2 py-0.5 rounded text-xs font-semibold
|
| 69 |
+
{{ 'bg-purple-100 text-purple-700' if u.role == 'superadmin' else 'bg-blue-100 text-blue-700' if u.role == 'admin' else 'bg-gray-100 text-gray-600' }}"
|
| 70 |
+
id="role_view_{{ u.id }}">
|
| 71 |
{{ u.role }}
|
| 72 |
</span>
|
| 73 |
+
{% if user.role == 'superadmin' and u.id != user.id %}
|
| 74 |
+
<select class="edit-mode hidden border rounded px-1 py-0.5 text-xs" id="role_edit_{{ u.id }}">
|
| 75 |
+
<option value="user" {% if u.role == 'user' %}selected{% endif %}>user</option>
|
| 76 |
+
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
|
| 77 |
+
<option value="superadmin" {% if u.role == 'superadmin' %}selected{% endif %}>superadmin</option>
|
| 78 |
+
</select>
|
| 79 |
+
{% endif %}
|
| 80 |
</td>
|
| 81 |
<td class="px-4 py-3 text-center">
|
| 82 |
+
<span class="view-mode text-xs {{ 'text-green-600' if u.is_active else 'text-red-500' }}" id="active_view_{{ u.id }}">
|
| 83 |
{{ 'Active' if u.is_active else 'Inactive' }}
|
| 84 |
</span>
|
| 85 |
+
{% if user.role == 'superadmin' and u.id != user.id %}
|
| 86 |
+
<label class="edit-mode hidden text-xs inline-flex items-center space-x-1">
|
| 87 |
+
<input type="checkbox" id="active_edit_{{ u.id }}" {% if u.is_active %}checked{% endif %}>
|
| 88 |
+
<span>Active</span>
|
| 89 |
+
</label>
|
| 90 |
+
{% endif %}
|
| 91 |
</td>
|
| 92 |
<td class="px-4 py-3 text-center space-x-2">
|
| 93 |
{% if u.id != user.id %}
|
| 94 |
+
<span class="view-mode space-x-2">
|
| 95 |
+
{% if user.role == 'superadmin' %}
|
| 96 |
+
<button onclick="startEdit({{ u.id }})" class="text-green-700 hover:underline text-xs">Edit</button>
|
| 97 |
+
{% endif %}
|
| 98 |
+
<button onclick="toggleUser({{ u.id }})" class="text-blue-600 hover:underline text-xs">
|
| 99 |
+
{{ 'Deactivate' if u.is_active else 'Activate' }}
|
| 100 |
+
</button>
|
| 101 |
+
<button onclick="resetPassword({{ u.id }}, '{{ u.email }}')" class="text-amber-600 hover:underline text-xs">
|
| 102 |
+
Reset PW
|
| 103 |
+
</button>
|
| 104 |
+
</span>
|
| 105 |
+
<span class="edit-mode hidden space-x-2">
|
| 106 |
+
<button onclick="saveEdit({{ u.id }})" class="text-green-700 hover:underline text-xs font-semibold">Save</button>
|
| 107 |
+
<button onclick="cancelEdit({{ u.id }})" class="text-gray-500 hover:underline text-xs">Cancel</button>
|
| 108 |
+
</span>
|
| 109 |
{% else %}
|
| 110 |
<span class="text-gray-300 text-xs">You</span>
|
| 111 |
{% endif %}
|
|
|
|
| 154 |
await fetch('/admin/api/users/' + id + '/toggle', { method: 'POST', headers: {'Authorization': 'Bearer ' + authToken} });
|
| 155 |
location.reload();
|
| 156 |
}
|
| 157 |
+
function rowEls(id) {
|
| 158 |
+
return document.querySelector(`tr.user-row [id="name_view_${id}"]`).closest('tr');
|
| 159 |
+
}
|
| 160 |
+
function startEdit(id) {
|
| 161 |
+
const row = rowEls(id);
|
| 162 |
+
row.querySelectorAll('.view-mode').forEach(el => el.classList.add('hidden'));
|
| 163 |
+
row.querySelectorAll('.edit-mode').forEach(el => el.classList.remove('hidden'));
|
| 164 |
+
}
|
| 165 |
+
function cancelEdit(id) {
|
| 166 |
+
const row = rowEls(id);
|
| 167 |
+
row.querySelectorAll('.edit-mode').forEach(el => el.classList.add('hidden'));
|
| 168 |
+
row.querySelectorAll('.view-mode').forEach(el => el.classList.remove('hidden'));
|
| 169 |
+
}
|
| 170 |
+
async function saveEdit(id) {
|
| 171 |
+
const payload = {
|
| 172 |
+
full_name: document.getElementById('name_edit_' + id).value,
|
| 173 |
+
role: document.getElementById('role_edit_' + id).value,
|
| 174 |
+
is_active: document.getElementById('active_edit_' + id).checked,
|
| 175 |
+
};
|
| 176 |
+
const resp = await fetch('/admin/api/users/' + id + '/update', {
|
| 177 |
+
method: 'POST', headers: authHeaders, body: JSON.stringify(payload),
|
| 178 |
+
});
|
| 179 |
+
const data = await resp.json();
|
| 180 |
+
if (data.success) location.reload();
|
| 181 |
+
else alert(data.error || 'Failed to save');
|
| 182 |
+
}
|
| 183 |
async function resetPassword(id, email) {
|
| 184 |
if (!confirm('Send password reset email to ' + email + '?')) return;
|
| 185 |
const resp = await fetch('/admin/api/users/' + id + '/reset-password', {
|