feat: password management for admin and sub-accounts

- Admin: set sub-account AirGate login password via dropdown menu
- Admin: toggle sub-account login enabled/disabled
- Sub-account: change own password (sidebar "修改密码")
- Sub-account: auto-redirect to login page after password change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-21 15:54:35 +08:00
parent 33c8963d46
commit fac5e1b541
6 changed files with 184 additions and 0 deletions

View File

@ -16,4 +16,5 @@ urlpatterns = [
path('iam/me/', views.iam_me_view),
path('iam/my-keys/', views.iam_my_keys_view),
path('iam/my-keys/<int:pk>/reveal/', views.iam_my_key_reveal_view),
path('iam/change-password/', views.iam_change_password_view),
]

View File

@ -350,3 +350,59 @@ def iam_my_key_reveal_view(request, pk):
'key_name': key.key_name,
'project_name': key.project_name,
})
@api_view(['POST'])
@authentication_classes([])
@permission_classes([AllowAny])
def iam_change_password_view(request):
"""子账号修改自己的 AirGate 登录密码"""
import jwt
from django.conf import settings
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return Response({'error': 'unauthorized'}, status=status.HTTP_401_UNAUTHORIZED)
token = auth_header.split(' ', 1)[1]
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return Response({'error': 'invalid_token'}, status=status.HTTP_401_UNAUTHORIZED)
if payload.get('role') != 'iam_user':
return Response({'error': 'not_iam_user'}, status=status.HTTP_403_FORBIDDEN)
from apps.monitor.models import IAMUser
try:
iam_user = IAMUser.objects.get(pk=payload['iam_user_id'])
except IAMUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
old_password = request.data.get('old_password', '')
new_password = request.data.get('new_password', '')
if not old_password or not new_password:
return Response({'error': 'missing', 'message': '请输入原密码和新密码'},
status=status.HTTP_400_BAD_REQUEST)
if not iam_user.check_login_password(old_password):
return Response({'error': 'wrong_password', 'message': '原密码错误'},
status=status.HTTP_400_BAD_REQUEST)
if len(new_password) < 6:
return Response({'error': 'weak_password', 'message': '密码至少6位'},
status=status.HTTP_400_BAD_REQUEST)
iam_user.set_login_password(new_password)
iam_user.save(update_fields=['login_password_hash'])
from apps.monitor.models import AlertRecord
AlertRecord.objects.create(
iam_user=iam_user,
alert_type=AlertRecord.AlertType.MANUAL,
title=f"子账号 {iam_user.username} 修改 AirGate 密码",
content=f"操作人: {iam_user.username}(自行修改)",
)
return Response({'message': '密码修改成功,请重新登录'})

View File

@ -43,6 +43,10 @@
<el-icon><Key /></el-icon>
<span>我的 API Key</span>
</el-menu-item>
<el-menu-item index="/my-password">
<el-icon><Lock /></el-icon>
<span>修改密码</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>

View File

@ -28,6 +28,7 @@ const routes = [
{ path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') },
// IAM user (sub-account) routes
{ path: 'my-keys', name: 'MyKeys', component: () => import('../views/portal/MyKeysView.vue') },
{ path: 'my-password', name: 'MyPassword', component: () => import('../views/portal/MyPasswordView.vue') },
],
},
]

View File

@ -80,6 +80,7 @@
<el-dropdown-item @click="openConfig(row)">监控配置</el-dropdown-item>
<el-dropdown-item @click="openPolicies(row)">权限策略</el-dropdown-item>
<el-dropdown-item @click="openQuotaHistory(row)">划拨记录</el-dropdown-item>
<el-dropdown-item @click="openSetLogin(row)">登录密码</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'active'" divided
@click="handleDisable(row)" style="color:#f56c6c;">停用账号</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'disabled'" divided
@ -237,6 +238,25 @@
</template>
</el-dialog>
<!-- Set Login Password Dialog -->
<el-dialog v-model="loginPwdVisible" :title="`设置 ${loginPwdUser?.username} 的 AirGate 登录密码`"
width="90%" style="max-width: 450px;">
<el-form label-width="100px">
<el-form-item label="登录状态">
<el-switch v-model="loginPwdEnabled"
active-text="允许登录" inactive-text="禁止登录" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="loginPwdValue" type="password" show-password
placeholder="至少6位留空则不修改密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="loginPwdVisible = false">取消</el-button>
<el-button type="primary" @click="handleSetLogin" :loading="loginPwdSaving">保存</el-button>
</template>
</el-dialog>
<!-- Quota History Dialog -->
<el-dialog v-model="historyVisible" :title="`${historyUser?.username} 额度划拨记录`" width="90%" style="max-width: 800px;">
<el-table :data="quotaHistory" stripe v-loading="historyLoading" empty-text="暂无划拨记录">
@ -651,6 +671,42 @@ async function saveConfig() {
}
// --- Quota History ---
// === Set Login Password ===
const loginPwdVisible = ref(false)
const loginPwdUser = ref(null)
const loginPwdValue = ref('')
const loginPwdEnabled = ref(false)
const loginPwdSaving = ref(false)
function openSetLogin(row) {
loginPwdUser.value = row
loginPwdValue.value = ''
loginPwdEnabled.value = row.login_enabled || false
loginPwdVisible.value = true
}
async function handleSetLogin() {
const payload = { login_enabled: loginPwdEnabled.value }
if (loginPwdValue.value) {
if (loginPwdValue.value.length < 6) {
ElMessage.warning('密码至少6位')
return
}
payload.password = loginPwdValue.value
}
loginPwdSaving.value = true
try {
const { data } = await api.post(`/api/v1/iam-users/${loginPwdUser.value.id}/set-login/`, payload)
ElMessage.success(data.message)
loginPwdVisible.value = false
await loadUsers()
} catch (e) {
ElMessage.error(e.response?.data?.message || '操作失败')
} finally {
loginPwdSaving.value = false
}
}
async function openQuotaHistory(row) {
historyUser.value = row
historyVisible.value = true

View File

@ -0,0 +1,66 @@
<template>
<div style="max-width: 500px; margin: 0 auto;">
<h2 style="margin-bottom: 16px;">修改密码</h2>
<el-card>
<el-form label-width="100px">
<el-form-item label="原密码">
<el-input v-model="form.old_password" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="form.new_password" type="password" show-password
placeholder="至少6位" />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="form.confirm_password" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChange" :loading="loading">修改密码</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import api from '../../api'
const router = useRouter()
const auth = useAuthStore()
const form = ref({ old_password: '', new_password: '', confirm_password: '' })
const loading = ref(false)
async function handleChange() {
if (!form.value.old_password || !form.value.new_password) {
ElMessage.warning('请填写完整')
return
}
if (form.value.new_password !== form.value.confirm_password) {
ElMessage.warning('两次密码不一致')
return
}
if (form.value.new_password.length < 6) {
ElMessage.warning('密码至少6位')
return
}
loading.value = true
try {
const { data } = await api.post('/api/v1/auth/iam/change-password/', {
old_password: form.value.old_password,
new_password: form.value.new_password,
})
ElMessage.success(data.message)
setTimeout(() => {
auth.logout()
router.push('/login')
}, 1500)
} catch (e) {
ElMessage.error(e.response?.data?.message || '修改失败')
} finally {
loading.value = false
}
}
</script>