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:
parent
c53144b2ac
commit
ed67a27399
@ -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'),
|
||||
|
||||
@ -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
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user