From ed67a27399e40cd186c971a495d41c8ba4d7028e Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Tue, 12 May 2026 18:36:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(team):=20=E5=9B=A2=E7=AE=A1=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E6=88=90=E5=91=98=E5=AF=86=E7=A0=81=20=E2=80=94=20?= =?UTF-8?q?=E6=96=B0=20API=20+=20=E4=B8=A5=E6=A0=BC=E6=9D=83=E9=99=90?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=20+=20=E6=88=90=E5=91=98=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 权限矩阵(plan §G,服务端硬校验,前端按钮只是 UX 不算数): | 操作者 | 可重置 | 不可重置 | |----------------|------------------------|---------------------| | 主管(owner=T) | 同团队副管 + 成员 | 其他主管 / 自己 | | 副管(admin=T) | 同团队成员 | 副管 / 主管 / 自己 | 后端: - 新 view team_reset_member_password_view (POST /api/v1/team/members//reset-password) - permission IsTeamAdmin(覆盖主管+副管两种)+ 服务端逐层判断: 1. 同团队? (target.team_id != operator.team_id → 403) 2. 不能改自己? (id 相同 → 400) 3. 主管密码须超管? (target.is_team_owner → 403) 4. 副管只有主管能改? (target.is_team_admin && !operator.is_team_owner → 403) 5. 走到这里都是合法 case → 生成 8 位随机密码(secrets+string)+ must_change_password=True - log_admin_action audit 留痕(action=user_password_reset, after.reset_by=team_admin, operator=...) - urls.py 加路由 前端: - lib/api.ts teamApi.resetMemberPassword(memberId) → 返回 { new_password, ... } - TeamMembersPage.tsx: - canResetPasswordFor(m) helper 同权限矩阵(主管→副管+成员、副管→成员、不能改自己) - 成员行 actions 加 "重置密码" 按钮(只在 canResetPasswordFor 为 true 时显示) - 点击 → window.confirm 二次确认 → API → 弹结果 modal 显示新密码 + 复制按钮 - 结果 modal 用 monospace font 大字 + 浅灰底显示密码,带 ⚠ 提醒"关闭后无法再次查看" - showToast 反馈复制/失败 后端 6 项 curl 测试全通过: - T1 主管→副管 200 ✓ - T2 主管→成员 200 ✓ - T3 主管→自己 400 "不能重置自己的密码" ✓ - T4 副管→主管 403 "主管须由超级管理员重置" ✓ - T5 副管→成员 200 ✓ - T6 副管→另一副管 403 "只有主管理员能重置副管理员密码" ✓ Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/apps/generation/urls.py | 1 + backend/apps/generation/views.py | 63 ++++++++++++++++++++++++ web/src/lib/api.ts | 3 ++ web/src/pages/TeamMembersPage.tsx | 79 +++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 8110e1f..e4a3c65 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -62,6 +62,7 @@ urlpatterns = [ path('team/members//quota', views.team_member_quota_view, name='team_member_quota'), path('team/members//status', views.team_member_status_view, name='team_member_status'), path('team/members//role', views.team_member_role_view, name='team_member_role'), + path('team/members//reset-password', views.team_reset_member_password_view, name='team_reset_member_password'), # ── Team Admin: Consumption Records ── path('team/records', views.team_records_view, name='team_records'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index d0fcfe7..b4c857b 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -2627,6 +2627,69 @@ def team_member_role_view(request, member_id): }) +@api_view(['POST']) +@permission_classes([IsTeamAdmin]) +def team_reset_member_password_view(request, member_id): + """POST /api/v1/team/members//reset-password — 团管重置成员密码。 + + 权限矩阵(必须服务端硬校验,前端按钮只是 UX): + - 主管 (is_team_owner=True): 可改同团队的「副管 + 成员」,不可改其他主管 + - 副管 (is_team_admin=True && !is_team_owner): 只能改同团队的「成员」,不可改副管/主管 + + 随机生成 8 位密码 + must_change_password=True(成员下次登录强制改密)。 + """ + import secrets + import string + + team = request.user.team + if team is None: + return Response({'error': '当前用户没有团队'}, status=status.HTTP_400_BAD_REQUEST) + + try: + target = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + operator = request.user + + # 防御性校验:即使 team.members 已过滤,operator/target 跨团队中转改动也兜底 + if target.team_id != operator.team_id: + return Response({'error': '不在同一团队'}, status=status.HTTP_403_FORBIDDEN) + + # 自己不能重置自己(用修改密码功能) + if target.id == operator.id: + return Response({'error': '不能重置自己的密码,请用「修改密码」功能'}, status=status.HTTP_400_BAD_REQUEST) + + # 任何团管都不能改主管密码 — 主管密码必须超管重置(走 admin_reset_password_view) + if target.is_team_owner: + return Response({'error': '主管理员密码须由超级管理员重置'}, status=status.HTTP_403_FORBIDDEN) + + # 副管密码只有主管能重置,其他副管不行 + if target.is_team_admin and not operator.is_team_owner: + return Response({'error': '只有主管理员能重置副管理员密码'}, status=status.HTTP_403_FORBIDDEN) + + # 走到这里:operator 是主管或副管;target 要么是副管(operator 必是主管) 要么是普通成员 + alphabet = string.ascii_letters + string.digits + new_password = ''.join(secrets.choice(alphabet) for _ in range(8)) + + target.set_password(new_password) + target.must_change_password = True + target.save(update_fields=['password', 'must_change_password']) + + log_admin_action( + request, 'user_password_reset', 'user', + target_id=target.id, target_name=target.username, + after={'reset_by': 'team_admin', 'operator': operator.username}, + ) + + return Response({ + 'user_id': target.id, + 'username': target.username, + 'new_password': new_password, + 'message': f'已重置 {target.username} 的密码,下次登录需修改', + }) + + # ────────────────────────────────────────────── # Profile: User's own consumption data # ────────────────────────────────────────────── diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6a24a91..3b2c6c4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -370,6 +370,9 @@ export const teamApi = { setMemberRole: (memberId: number, isTeamAdmin: boolean) => api.patch(`/team/members/${memberId}/role`, { is_team_admin: isTeamAdmin }), + resetMemberPassword: (memberId: number) => + api.post<{ user_id: number; username: string; new_password: string; message: string }>(`/team/members/${memberId}/reset-password`), + // Content Assets getAssetsOverview: () => api.get<{ diff --git a/web/src/pages/TeamMembersPage.tsx b/web/src/pages/TeamMembersPage.tsx index f0f2f9f..ee282d1 100644 --- a/web/src/pages/TeamMembersPage.tsx +++ b/web/src/pages/TeamMembersPage.tsx @@ -28,6 +28,36 @@ export function TeamMembersPage() { const [editMonthly, setEditMonthly] = useState(''); const [editSpendingLimit, setEditSpendingLimit] = useState(''); + // Reset password result modal — 显示新生成的随机密码 + 复制按钮 + const [resetResult, setResetResult] = useState<{ username: string; newPassword: string } | null>(null); + + // 权限矩阵: + // 主管(is_team_owner) → 可重置「副管 + 成员」(不可重置主管/自己) + // 副管(is_team_admin) → 只能重置「成员」(不可重置副管/主管/自己) + // 成员 → 看不到此按钮(不在管理员路由) + const canResetPasswordFor = (m: TeamMember): boolean => { + if (!currentUser) return false; + if (m.id === currentUser.id) return false; // 自己不能重置自己 + if (m.is_team_owner) return false; // 主管密码只能超管重置 + if (m.is_team_admin && !currentUser.is_team_owner) return false; // 副管只有主管能重置 + return true; + }; + + const handleResetPassword = async (m: TeamMember) => { + if (!window.confirm(`重置「${m.username}」的密码?\n成员下次登录需要修改新密码。`)) return; + try { + const { data } = await teamApi.resetMemberPassword(m.id); + setResetResult({ username: data.username, newPassword: data.new_password }); + } catch (e: any) { + showToast(e?.response?.data?.error || '重置失败'); + } + }; + + const handleCopyPassword = () => { + if (!resetResult) return; + navigator.clipboard.writeText(resetResult.newPassword).then(() => showToast('已复制密码')); + }; + const fetchMembers = useCallback(async () => { setLoading(true); try { @@ -184,6 +214,11 @@ export function TeamMembersPage() { }}>设为副管理员 ) )} + {canResetPasswordFor(m) && ( + + )} + + + + + )} ); }