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:
parent
a4ed9ed730
commit
eacfd0d74a
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue