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:
parent
33c8963d46
commit
fac5e1b541
@ -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),
|
||||
]
|
||||
|
||||
@ -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': '密码修改成功,请重新登录'})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
66
frontend/src/views/portal/MyPasswordView.vue
Normal file
66
frontend/src/views/portal/MyPasswordView.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user