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>/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>/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>/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 ──
|
# ── Team Admin: Consumption Records ──
|
||||||
path('team/records', views.team_records_view, name='team_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
|
# Profile: User's own consumption data
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@ -370,6 +370,9 @@ export const teamApi = {
|
|||||||
setMemberRole: (memberId: number, isTeamAdmin: boolean) =>
|
setMemberRole: (memberId: number, isTeamAdmin: boolean) =>
|
||||||
api.patch(`/team/members/${memberId}/role`, { is_team_admin: isTeamAdmin }),
|
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
|
// Content Assets
|
||||||
getAssetsOverview: () =>
|
getAssetsOverview: () =>
|
||||||
api.get<{
|
api.get<{
|
||||||
|
|||||||
@ -28,6 +28,36 @@ export function TeamMembersPage() {
|
|||||||
const [editMonthly, setEditMonthly] = useState('');
|
const [editMonthly, setEditMonthly] = useState('');
|
||||||
const [editSpendingLimit, setEditSpendingLimit] = 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 () => {
|
const fetchMembers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -184,6 +214,11 @@ export function TeamMembersPage() {
|
|||||||
}}>设为副管理员</button>
|
}}>设为副管理员</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{canResetPasswordFor(m) && (
|
||||||
|
<button className={styles.editBtn} onClick={() => handleResetPassword(m)}>
|
||||||
|
重置密码
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`}
|
className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`}
|
||||||
onClick={() => setConfirmMember(m)}
|
onClick={() => setConfirmMember(m)}
|
||||||
@ -267,6 +302,50 @@ export function TeamMembersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user