feat(team): 团管重置成员密码 — 新 API + 严格权限矩阵 + 成员管理页按钮

权限矩阵(plan §G,服务端硬校验,前端按钮只是 UX 不算数):
| 操作者          | 可重置                  | 不可重置             |
|----------------|------------------------|---------------------|
| 主管(owner=T)  | 同团队副管 + 成员       | 其他主管 / 自己      |
| 副管(admin=T)  | 同团队成员              | 副管 / 主管 / 自己   |

后端:
- 新 view team_reset_member_password_view (POST /api/v1/team/members/<id>/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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-05-12 18:36:16 +08:00
parent c53144b2ac
commit ed67a27399
4 changed files with 146 additions and 0 deletions

View File

@ -62,6 +62,7 @@ urlpatterns = [
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
path('team/members/<int:member_id>/role', views.team_member_role_view, name='team_member_role'),
path('team/members/<int:member_id>/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'),

View File

@ -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/<id>/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
# ──────────────────────────────────────────────

View File

@ -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<{

View File

@ -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() {
}}></button>
)
)}
{canResetPasswordFor(m) && (
<button className={styles.editBtn} onClick={() => handleResetPassword(m)}>
</button>
)}
<button
className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmMember(m)}
@ -267,6 +302,50 @@ export function TeamMembersPage() {
</div>
</div>
)}
{/* 重置密码结果 modal — 显示一次新密码 + 复制按钮(关闭后再也看不到了) */}
{resetResult && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--color-overlay-strong)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10001 }}
onClick={() => setResetResult(null)}>
<div style={{ background: 'var(--color-bg-modal)', borderRadius: 12, padding: 24,
width: 380, border: '1px solid var(--color-border-modal)',
boxShadow: '0 8px 24px var(--color-shadow-modal)' }}
onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 12px', color: 'var(--color-text-light)', fontSize: 16 }}>
</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 16, lineHeight: 1.6 }}>
<strong style={{ color: 'var(--color-text-light)' }}>{resetResult.username}</strong>
,
</div>
<div style={{ background: 'var(--color-bg-elevated)', borderRadius: 8, padding: 14,
fontFamily: "'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace",
fontSize: 16, color: 'var(--color-text-light)', letterSpacing: 1.5,
textAlign: 'center', marginBottom: 12 }}>
{resetResult.newPassword}
</div>
<div style={{ fontSize: 11, color: 'var(--color-danger)', marginBottom: 16, lineHeight: 1.5 }}>
,
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button onClick={handleCopyPassword}
style={{ padding: '6px 16px', borderRadius: 6,
border: '1px solid var(--color-border-modal)',
background: 'var(--color-bg-elevated)',
color: 'var(--color-text-light)', cursor: 'pointer', fontSize: 13 }}>
</button>
<button onClick={() => setResetResult(null)}
style={{ padding: '6px 16px', borderRadius: 6, border: 'none',
background: 'var(--color-primary)', color: 'var(--color-on-primary)',
cursor: 'pointer', fontSize: 13 }}>
</button>
</div>
</div>
</div>
)}
</div>
);
}