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}})
|
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"])
|
@app.route("/api/v1/admin/reports", methods=["GET"])
|
||||||
@require_moderator
|
@require_moderator
|
||||||
def admin_list_reports() -> Response:
|
def admin_list_reports() -> Response:
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,9 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<button onclick="banPublisher({{ pub.id }}, '{{ pub.slug }}')" class="text-red-600 hover:text-red-900 mr-2">Ban</button>
|
<button onclick="banPublisher({{ pub.id }}, '{{ pub.slug }}')" class="text-red-600 hover:text-red-900 mr-2">Ban</button>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -138,6 +140,49 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
let currentPublisherId = null;
|
let currentPublisherId = null;
|
||||||
|
|
||||||
|
|
@ -233,5 +278,87 @@ async function confirmRole() {
|
||||||
alert('Network error');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue