Add admin tools: delete publisher and reset password

- DELETE /api/v1/admin/publishers/<id> - delete publisher with optional tool deletion
- POST /api/v1/admin/publishers/<id>/reset-password - generate temporary password

Safety features:
- Cannot delete yourself or other admins
- Delete requires typing username to confirm
- Reset password shows temp password in modal with copy button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-13 23:56:06 -04:00
parent a4ed9ed730
commit eacfd0d74a
2 changed files with 200 additions and 1 deletions

View File

@ -2488,6 +2488,78 @@ def create_app() -> Flask:
return jsonify({"data": {"status": "updated", "publisher_id": publisher_id, "role": new_role}})
@app.route("/api/v1/admin/publishers/<int:publisher_id>", methods=["DELETE"])
@require_admin
def admin_delete_publisher(publisher_id: int) -> Response:
"""Delete a publisher and optionally their tools."""
delete_tools = request.args.get("delete_tools", "false").lower() == "true"
publisher = query_one(g.db, "SELECT * FROM publishers WHERE id = ?", [publisher_id])
if not publisher:
return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404)
# Don't allow deleting yourself
if publisher_id == g.current_publisher["id"]:
return error_response("CANNOT_DELETE_SELF", "Cannot delete your own account", 400)
# Don't allow deleting other admins
if publisher["role"] == "admin":
return error_response("CANNOT_DELETE_ADMIN", "Cannot delete admin accounts", 400)
slug = publisher["slug"]
if delete_tools:
# Delete all tools owned by this publisher
g.db.execute("DELETE FROM tools WHERE publisher_id = ?", [publisher_id])
# Revoke all tokens
g.db.execute("UPDATE api_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE publisher_id = ?", [publisher_id])
# Delete the publisher
g.db.execute("DELETE FROM publishers WHERE id = ?", [publisher_id])
g.db.commit()
log_audit("delete_publisher", "publisher", str(publisher_id), {
"slug": slug,
"delete_tools": delete_tools,
})
return jsonify({"data": {"status": "deleted", "publisher_id": publisher_id, "slug": slug}})
@app.route("/api/v1/admin/publishers/<int:publisher_id>/reset-password", methods=["POST"])
@require_admin
def admin_reset_password(publisher_id: int) -> Response:
"""Generate a temporary password for a publisher."""
import secrets
from werkzeug.security import generate_password_hash
publisher = query_one(g.db, "SELECT * FROM publishers WHERE id = ?", [publisher_id])
if not publisher:
return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404)
# Generate a temporary password
temp_password = secrets.token_urlsafe(12)
password_hash = generate_password_hash(temp_password)
g.db.execute(
"UPDATE publishers SET password_hash = ? WHERE id = ?",
[password_hash, publisher_id],
)
g.db.commit()
log_audit("reset_password", "publisher", str(publisher_id), {
"slug": publisher["slug"],
})
return jsonify({
"data": {
"status": "reset",
"publisher_id": publisher_id,
"slug": publisher["slug"],
"temporary_password": temp_password,
}
})
@app.route("/api/v1/admin/reports", methods=["GET"])
@require_moderator
def admin_list_reports() -> Response:

View File

@ -72,7 +72,9 @@
{% else %}
<button onclick="banPublisher({{ pub.id }}, '{{ pub.slug }}')" class="text-red-600 hover:text-red-900 mr-2">Ban</button>
{% endif %}
<button onclick="changeRole({{ pub.id }}, '{{ pub.slug }}', '{{ pub.role }}')" class="text-indigo-600 hover:text-indigo-900">Role</button>
<button onclick="changeRole({{ pub.id }}, '{{ pub.slug }}', '{{ pub.role }}')" class="text-indigo-600 hover:text-indigo-900 mr-2">Role</button>
<button onclick="resetPassword({{ pub.id }}, '{{ pub.slug }}')" class="text-yellow-600 hover:text-yellow-900 mr-2">Reset PW</button>
<button onclick="deletePublisher({{ pub.id }}, '{{ pub.slug }}', {{ pub.tool_count }})" class="text-gray-600 hover:text-gray-900">Delete</button>
{% endif %}
</td>
</tr>
@ -138,6 +140,49 @@
</div>
</div>
<!-- Temp Password Modal -->
<div id="temp-password-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-gray-900 mb-2">Password Reset</h3>
<p class="text-sm text-gray-500 mb-4">Temporary password for <strong id="temp-password-slug"></strong>:</p>
<div class="bg-gray-100 p-3 rounded-md font-mono text-center text-lg select-all" id="temp-password-value"></div>
<p class="text-xs text-gray-500 mt-2">Share this password securely with the user. They should change it after logging in.</p>
<div class="mt-4 flex justify-end space-x-3">
<button onclick="copyTempPassword()" class="px-4 py-2 text-sm font-medium text-indigo-600 hover:text-indigo-800">Copy</button>
<button onclick="closeTempPasswordModal()" class="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700">Done</button>
</div>
</div>
</div>
</div>
<!-- Delete Modal -->
<div id="delete-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-medium text-red-600 mb-2">Delete Publisher</h3>
<p class="text-sm text-gray-600 mb-4">
You are about to permanently delete <strong id="delete-publisher-name"></strong>.
This action cannot be undone.
</p>
<div id="delete-tools-option" class="mb-4">
<label class="flex items-center text-sm">
<input type="checkbox" id="delete-tools-checkbox" class="mr-2 h-4 w-4 text-red-600 border-gray-300 rounded">
Also delete their <span id="delete-tool-count" class="font-medium mx-1"></span> tools
</label>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Type the username to confirm:</label>
<input type="text" id="delete-confirm-input" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-red-500" placeholder="username">
</div>
<div class="mt-4 flex justify-end space-x-3">
<button onclick="closeDeleteModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</button>
<button onclick="confirmDelete()" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Delete</button>
</div>
</div>
</div>
</div>
<script>
let currentPublisherId = null;
@ -233,5 +278,87 @@ async function confirmRole() {
alert('Network error');
}
}
async function resetPassword(pubId, slug) {
if (!confirm(`Reset password for @${slug}? A temporary password will be generated.`)) return;
try {
const response = await fetch(`/api/v1/admin/publishers/${pubId}/reset-password`, {
method: 'POST',
headers: {
'Authorization': 'Bearer {{ session.get("auth_token") }}',
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
const tempPw = data.data.temporary_password;
// Show modal with temporary password
document.getElementById('temp-password-value').textContent = tempPw;
document.getElementById('temp-password-slug').textContent = slug;
document.getElementById('temp-password-modal').classList.remove('hidden');
} else {
const data = await response.json();
alert(data.error?.message || 'Failed to reset password');
}
} catch (error) {
alert('Network error');
}
}
function closeTempPasswordModal() {
document.getElementById('temp-password-modal').classList.add('hidden');
}
function copyTempPassword() {
const pw = document.getElementById('temp-password-value').textContent;
navigator.clipboard.writeText(pw).then(() => {
alert('Password copied to clipboard');
});
}
let deletePublisherData = null;
function deletePublisher(pubId, slug, toolCount) {
deletePublisherData = { pubId, slug, toolCount };
document.getElementById('delete-publisher-name').textContent = `@${slug}`;
document.getElementById('delete-tool-count').textContent = toolCount;
document.getElementById('delete-tools-option').style.display = toolCount > 0 ? 'block' : 'none';
document.getElementById('delete-tools-checkbox').checked = false;
document.getElementById('delete-modal').classList.remove('hidden');
}
function closeDeleteModal() {
document.getElementById('delete-modal').classList.add('hidden');
deletePublisherData = null;
}
async function confirmDelete() {
if (!deletePublisherData) return;
const deleteTools = document.getElementById('delete-tools-checkbox').checked;
const confirmText = document.getElementById('delete-confirm-input').value;
if (confirmText !== deletePublisherData.slug) {
alert('Please type the username correctly to confirm');
return;
}
try {
const url = `/api/v1/admin/publishers/${deletePublisherData.pubId}?delete_tools=${deleteTools}`;
const response = await fetch(url, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer {{ session.get("auth_token") }}'
}
});
if (response.ok) {
location.reload();
} else {
const data = await response.json();
alert(data.error?.message || 'Failed to delete publisher');
}
} catch (error) {
alert('Network error');
}
}
</script>
{% endblock %}