From a2f054b7781771e1c0874e3547524d38a7dbc9aa Mon Sep 17 00:00:00 2001 From: Eddylin03 Date: Thu, 19 Mar 2026 20:07:46 +0800 Subject: [PATCH 1/3] feat: add delete post to Agent Plaza (admin + author) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add DELETE /api/plaza/posts/{post_id} endpoint - platform_admin and org_admin can delete any post - Post author can delete their own post - Returns 403 if neither condition is met - Cascade delete handled by existing SQLAlchemy relationship Frontend: - Add deletePost mutation with proper Bearer token auth - Show 🗑 delete button on posts visible to admins or the post author - Confirm dialog before deletion to prevent accidental deletes - Button styled with red hover state, unobtrusive by default - Invalidates plaza-posts and plaza-stats queries on success Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/plaza.py | 21 ++++++++++++- frontend/src/pages/Plaza.tsx | 59 +++++++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/backend/app/api/plaza.py b/backend/app/api/plaza.py index c1de3285..91f51d05 100644 --- a/backend/app/api/plaza.py +++ b/backend/app/api/plaza.py @@ -3,12 +3,14 @@ import uuid from datetime import datetime, timezone -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select, update, func, desc +from app.api.auth import get_current_user from app.database import async_session from app.models.plaza import PlazaPost, PlazaComment, PlazaLike +from app.models.user import User router = APIRouter(prefix="/api/plaza", tags=["plaza"]) @@ -161,6 +163,23 @@ async def get_post(post_id: uuid.UUID): return PostDetail(**data) +@router.delete("/posts/{post_id}") +async def delete_post(post_id: uuid.UUID, current_user: User = Depends(get_current_user)): + """Delete a plaza post. Admins can delete any post; authors can delete their own.""" + async with async_session() as db: + result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) + post = result.scalar_one_or_none() + if not post: + raise HTTPException(404, "Post not found") + is_admin = current_user.role in ("platform_admin", "org_admin") + is_author = post.author_id == current_user.id + if not is_admin and not is_author: + raise HTTPException(403, "Not allowed to delete this post") + await db.delete(post) + await db.commit() + return {"deleted": True} + + @router.post("/posts/{post_id}/comments", response_model=CommentOut) async def create_comment(post_id: uuid.UUID, body: CommentCreate): """Add a comment to a post.""" diff --git a/frontend/src/pages/Plaza.tsx b/frontend/src/pages/Plaza.tsx index 9e58a66c..79b02d04 100644 --- a/frontend/src/pages/Plaza.tsx +++ b/frontend/src/pages/Plaza.tsx @@ -356,6 +356,20 @@ export default function Plaza() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plaza-posts'] }), }); + const deletePost = useMutation({ + mutationFn: (postId: string) => + fetch(`/api/plaza/posts/${postId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, + }).then(r => { if (!r.ok) throw new Error('Delete failed'); return r.json(); }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['plaza-posts'] }); + queryClient.invalidateQueries({ queryKey: ['plaza-stats'] }); + }, + }); + + const isAdmin = user?.role === 'platform_admin' || user?.role === 'org_admin'; + const timeAgo = (dateStr: string) => { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); @@ -536,18 +550,41 @@ export default function Plaza() { {/* Actions */}
- 0 ? Icons.heartFilled : Icons.heart} - label={post.likes_count || 0} - active={post.likes_count > 0} - onClick={() => likePost.mutate(post.id)} - /> - setExpandedPost(expandedPost === post.id ? null : post.id)} - /> +
+ 0 ? Icons.heartFilled : Icons.heart} + label={post.likes_count || 0} + active={post.likes_count > 0} + onClick={() => likePost.mutate(post.id)} + /> + setExpandedPost(expandedPost === post.id ? null : post.id)} + /> +
+ {(isAdmin || post.author_id === user?.id) && ( + + )}
{/* Comments */} From 09efac203b9e720554dd72e0e14c39c59457f098 Mon Sep 17 00:00:00 2001 From: Eddylin03 Date: Thu, 19 Mar 2026 21:37:01 +0800 Subject: [PATCH 2/3] feat: add admin password reset in User Management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #134 Backend: - Add POST /users/{user_id}/reset-password endpoint - platform_admin and org_admin can reset any user's password - Users can also reset their own password via the same endpoint - Minimum 6-character password validation - Tenant isolation: admins cannot reset passwords outside their org - Reuses existing hash_password() utility from core.security Frontend: - Add 🔑 Reset Password button next to Edit in each user row - Modal with password input (autofocus, min 6 chars enforced) - Confirm button disabled until password meets minimum length - Toast notification on success or failure - Bilingual support (Chinese / English) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/users.py | 35 +++++++++++++- frontend/src/pages/UserManagement.tsx | 67 ++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/backend/app/api/users.py b/backend/app/api/users.py index f776220b..fae4ab11 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -7,7 +7,7 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import get_current_user +from app.core.security import get_current_user, hash_password from app.database import get_db from app.models.agent import Agent from app.models.user import User @@ -15,6 +15,10 @@ router = APIRouter(prefix="/users", tags=["users"]) +class PasswordReset(BaseModel): + new_password: str + + class UserQuotaUpdate(BaseModel): quota_message_limit: int | None = None quota_message_period: str | None = None @@ -98,6 +102,35 @@ async def list_users( return out +@router.post("/{user_id}/reset-password") +async def reset_user_password( + user_id: uuid.UUID, + data: PasswordReset, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Reset a user's password. Admins can reset any user; users can reset their own.""" + is_admin = current_user.role in ("platform_admin", "org_admin") + is_self = current_user.id == user_id + if not is_admin and not is_self: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed") + + if len(data.new_password) < 6: + raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if is_admin and not is_self and user.tenant_id != current_user.tenant_id: + raise HTTPException(status_code=403, detail="Cannot modify users outside your organization") + + user.password_hash = hash_password(data.new_password) + await db.commit() + return {"success": True} + + @router.patch("/{user_id}/quota", response_model=UserOut) async def update_user_quota( user_id: uuid.UUID, diff --git a/frontend/src/pages/UserManagement.tsx b/frontend/src/pages/UserManagement.tsx index 8093ea12..6e5a615a 100644 --- a/frontend/src/pages/UserManagement.tsx +++ b/frontend/src/pages/UserManagement.tsx @@ -58,6 +58,10 @@ export default function UserManagement() { }); const [saving, setSaving] = useState(false); const [toast, setToast] = useState(''); + const [resetUserId, setResetUserId] = useState(null); + const [resetUsername, setResetUsername] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [resetting, setResetting] = useState(false); // Search, sort & pagination const [searchQuery, setSearchQuery] = useState(''); @@ -107,6 +111,25 @@ export default function UserManagement() { setSaving(false); }; + const handleResetPassword = async () => { + if (!resetUserId || newPassword.length < 6) return; + setResetting(true); + try { + await fetchJson(`/users/${resetUserId}/reset-password`, { + method: 'POST', + body: JSON.stringify({ new_password: newPassword }), + }); + setToast(isChinese ? '✅ 密码已重置' : '✅ Password reset successfully'); + setTimeout(() => setToast(''), 2000); + setResetUserId(null); + setNewPassword(''); + } catch (e: any) { + setToast(`❌ ${e.message}`); + setTimeout(() => setToast(''), 3000); + } + setResetting(false); + }; + const periodLabel = (period: string) => { if (isChinese) { const map: Record = { permanent: '永久', daily: '每天', weekly: '每周', monthly: '每月' }; @@ -251,7 +274,7 @@ export default function UserManagement() { / {user.quota_max_agents}
{user.quota_agent_ttl_hours}h
-
+
+
@@ -370,6 +400,41 @@ export default function UserManagement() { )} )} + + {/* Reset Password Modal */} + {resetUserId && ( +
+
+

+ 🔑 {isChinese ? `重置 ${resetUsername} 的密码` : `Reset password for ${resetUsername}`} +

+ setNewPassword(e.target.value)} + style={{ width: '100%', marginBottom: '16px' }} + autoFocus + /> +
+ + +
+
+
+ )} ); } From 2a159292b141701ea73c8ec1bcf9b00becde8233 Mon Sep 17 00:00:00 2001 From: Yutai Lin Date: Sat, 21 Mar 2026 00:25:09 +0800 Subject: [PATCH 3/3] fix: address PR review feedback for admin password reset - Remove plaza post deletion (will be submitted as separate PR) - Require old password verification when user resets their own password - Remove emoji from UI (buttons, modal title, toast messages) - Use toastType state instead of emoji prefix for success/error styling Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/plaza.py | 21 +--------- backend/app/api/users.py | 11 ++++- frontend/src/pages/Plaza.tsx | 59 +++++---------------------- frontend/src/pages/UserManagement.tsx | 17 ++++---- 4 files changed, 31 insertions(+), 77 deletions(-) diff --git a/backend/app/api/plaza.py b/backend/app/api/plaza.py index 91f51d05..c1de3285 100644 --- a/backend/app/api/plaza.py +++ b/backend/app/api/plaza.py @@ -3,14 +3,12 @@ import uuid from datetime import datetime, timezone -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select, update, func, desc -from app.api.auth import get_current_user from app.database import async_session from app.models.plaza import PlazaPost, PlazaComment, PlazaLike -from app.models.user import User router = APIRouter(prefix="/api/plaza", tags=["plaza"]) @@ -163,23 +161,6 @@ async def get_post(post_id: uuid.UUID): return PostDetail(**data) -@router.delete("/posts/{post_id}") -async def delete_post(post_id: uuid.UUID, current_user: User = Depends(get_current_user)): - """Delete a plaza post. Admins can delete any post; authors can delete their own.""" - async with async_session() as db: - result = await db.execute(select(PlazaPost).where(PlazaPost.id == post_id)) - post = result.scalar_one_or_none() - if not post: - raise HTTPException(404, "Post not found") - is_admin = current_user.role in ("platform_admin", "org_admin") - is_author = post.author_id == current_user.id - if not is_admin and not is_author: - raise HTTPException(403, "Not allowed to delete this post") - await db.delete(post) - await db.commit() - return {"deleted": True} - - @router.post("/posts/{post_id}/comments", response_model=CommentOut) async def create_comment(post_id: uuid.UUID, body: CommentCreate): """Add a comment to a post.""" diff --git a/backend/app/api/users.py b/backend/app/api/users.py index fae4ab11..00a70169 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -7,7 +7,7 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import get_current_user, hash_password +from app.core.security import get_current_user, hash_password, verify_password from app.database import get_db from app.models.agent import Agent from app.models.user import User @@ -17,6 +17,7 @@ class PasswordReset(BaseModel): new_password: str + old_password: str | None = None class UserQuotaUpdate(BaseModel): @@ -118,11 +119,19 @@ async def reset_user_password( if len(data.new_password) < 6: raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + if is_self and not is_admin: + if not data.old_password: + raise HTTPException(status_code=400, detail="Current password is required") + result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=404, detail="User not found") + if is_self and not is_admin: + if not verify_password(data.old_password, user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect") + if is_admin and not is_self and user.tenant_id != current_user.tenant_id: raise HTTPException(status_code=403, detail="Cannot modify users outside your organization") diff --git a/frontend/src/pages/Plaza.tsx b/frontend/src/pages/Plaza.tsx index 79b02d04..9e58a66c 100644 --- a/frontend/src/pages/Plaza.tsx +++ b/frontend/src/pages/Plaza.tsx @@ -356,20 +356,6 @@ export default function Plaza() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plaza-posts'] }), }); - const deletePost = useMutation({ - mutationFn: (postId: string) => - fetch(`/api/plaza/posts/${postId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }, - }).then(r => { if (!r.ok) throw new Error('Delete failed'); return r.json(); }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['plaza-posts'] }); - queryClient.invalidateQueries({ queryKey: ['plaza-stats'] }); - }, - }); - - const isAdmin = user?.role === 'platform_admin' || user?.role === 'org_admin'; - const timeAgo = (dateStr: string) => { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); @@ -550,41 +536,18 @@ export default function Plaza() { {/* Actions */}
-
- 0 ? Icons.heartFilled : Icons.heart} - label={post.likes_count || 0} - active={post.likes_count > 0} - onClick={() => likePost.mutate(post.id)} - /> - setExpandedPost(expandedPost === post.id ? null : post.id)} - /> -
- {(isAdmin || post.author_id === user?.id) && ( - - )} + 0 ? Icons.heartFilled : Icons.heart} + label={post.likes_count || 0} + active={post.likes_count > 0} + onClick={() => likePost.mutate(post.id)} + /> + setExpandedPost(expandedPost === post.id ? null : post.id)} + />
{/* Comments */} diff --git a/frontend/src/pages/UserManagement.tsx b/frontend/src/pages/UserManagement.tsx index 6e5a615a..50285811 100644 --- a/frontend/src/pages/UserManagement.tsx +++ b/frontend/src/pages/UserManagement.tsx @@ -58,6 +58,7 @@ export default function UserManagement() { }); const [saving, setSaving] = useState(false); const [toast, setToast] = useState(''); + const [toastType, setToastType] = useState<'success' | 'error'>('success'); const [resetUserId, setResetUserId] = useState(null); const [resetUsername, setResetUsername] = useState(''); const [newPassword, setNewPassword] = useState(''); @@ -100,12 +101,12 @@ export default function UserManagement() { method: 'PATCH', body: JSON.stringify(editForm), }); - setToast(isChinese ? '✅ 配额已更新' : '✅ Quota updated'); + setToast(isChinese ? '配额已更新' : 'Quota updated'); setToastType('success'); setTimeout(() => setToast(''), 2000); setEditingUserId(null); loadUsers(); } catch (e: any) { - setToast(`❌ ${e.message}`); + setToast(e.message); setToastType('error'); setTimeout(() => setToast(''), 3000); } setSaving(false); @@ -119,12 +120,12 @@ export default function UserManagement() { method: 'POST', body: JSON.stringify({ new_password: newPassword }), }); - setToast(isChinese ? '✅ 密码已重置' : '✅ Password reset successfully'); + setToast(isChinese ? '密码已重置' : 'Password reset successfully'); setToastType('success'); setTimeout(() => setToast(''), 2000); setResetUserId(null); setNewPassword(''); } catch (e: any) { - setToast(`❌ ${e.message}`); + setToast(e.message); setToastType('error'); setTimeout(() => setToast(''), 3000); } setResetting(false); @@ -175,7 +176,7 @@ export default function UserManagement() { {toast && (
{toast} @@ -280,14 +281,14 @@ export default function UserManagement() { style={{ padding: '4px 10px', fontSize: '11px' }} onClick={() => editingUserId === user.id ? setEditingUserId(null) : startEdit(user)} > - {editingUserId === user.id ? t('common.cancel') : '✏️ Edit'} + {editingUserId === user.id ? t('common.cancel') : 'Edit'}
@@ -409,7 +410,7 @@ export default function UserManagement() { }}>

- 🔑 {isChinese ? `重置 ${resetUsername} 的密码` : `Reset password for ${resetUsername}`} + {isChinese ? `重置 ${resetUsername} 的密码` : `Reset password for ${resetUsername}`}