diff --git a/backend/app/api/users.py b/backend/app/api/users.py index f776220b..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 +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 @@ -15,6 +15,11 @@ router = APIRouter(prefix="/users", tags=["users"]) +class PasswordReset(BaseModel): + new_password: str + old_password: str | None = None + + class UserQuotaUpdate(BaseModel): quota_message_limit: int | None = None quota_message_period: str | None = None @@ -98,6 +103,43 @@ 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") + + 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") + + 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..50285811 100644 --- a/frontend/src/pages/UserManagement.tsx +++ b/frontend/src/pages/UserManagement.tsx @@ -58,6 +58,11 @@ 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(''); + const [resetting, setResetting] = useState(false); // Search, sort & pagination const [searchQuery, setSearchQuery] = useState(''); @@ -96,17 +101,36 @@ 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); }; + 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'); setToastType('success'); + setTimeout(() => setToast(''), 2000); + setResetUserId(null); + setNewPassword(''); + } catch (e: any) { + setToast(e.message); setToastType('error'); + setTimeout(() => setToast(''), 3000); + } + setResetting(false); + }; + const periodLabel = (period: string) => { if (isChinese) { const map: Record = { permanent: '永久', daily: '每天', weekly: '每周', monthly: '每月' }; @@ -152,7 +176,7 @@ export default function UserManagement() { {toast && (
{toast} @@ -251,13 +275,20 @@ export default function UserManagement() { / {user.quota_max_agents}
{user.quota_agent_ttl_hours}h
-
+
+
@@ -370,6 +401,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 + /> +
+ + +
+
+
+ )} ); }