feat: add sub-account portal (login + my keys view)
- Login page: toggle between admin/sub-account login - Auth store: isAdmin/isIamUser computed properties - MainLayout: role-based sidebar (admin sees all, sub-account sees only my keys) - HomeRedirect: auto-redirect based on role - MyKeysView: sub-account can view/reveal their own API Keys - Portal is completely isolated from admin functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
daa82aee76
commit
3d2b332657
@ -5,7 +5,7 @@ server {
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://airgate-backend:8100;
|
||||
proxy_pass http://backend:8100;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@ -4,41 +4,56 @@
|
||||
<div class="logo">AirGate</div>
|
||||
<el-menu :default-active="route.path" router background-color="#1d1e2c"
|
||||
text-color="#a0a3bd" active-text-color="#fff">
|
||||
<el-menu-item index="/">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/iam-users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>子账号管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/ark-keys">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>API Key 管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/billing">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<span>消费监控</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>告警记录</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin">
|
||||
<el-icon><Tools /></el-icon>
|
||||
<span>系统管理</span>
|
||||
</el-menu-item>
|
||||
|
||||
<!-- Admin menus -->
|
||||
<template v-if="auth.isAdmin">
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/iam-users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>子账号管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/ark-keys">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>API Key 管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/billing">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<span>消费监控</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/alerts">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>告警记录</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin">
|
||||
<el-icon><Tools /></el-icon>
|
||||
<span>系统管理</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<!-- IAM User (sub-account) menus -->
|
||||
<template v-else>
|
||||
<el-menu-item index="/my-keys">
|
||||
<el-icon><Key /></el-icon>
|
||||
<span>我的 API Key</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header style="display: flex; align-items: center; justify-content: flex-end;
|
||||
background: #fff; border-bottom: 1px solid #eee; height: 56px;">
|
||||
<span style="margin-right: 16px; color: #666;">{{ auth.user?.username }}</span>
|
||||
<el-tag v-if="auth.isIamUser" type="info" size="small" style="margin-right: 12px;">子账号</el-tag>
|
||||
<span style="margin-right: 16px; color: #666;">
|
||||
{{ auth.user?.display_name || auth.user?.username }}
|
||||
</span>
|
||||
<el-button text @click="handleLogout">退出登录</el-button>
|
||||
</el-header>
|
||||
<el-main style="background: #f5f7fa; padding: 24px;">
|
||||
|
||||
@ -12,13 +12,22 @@ const routes = [
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') },
|
||||
// Dynamic home: admin sees Dashboard, iam_user sees MyKeys
|
||||
{
|
||||
path: '',
|
||||
name: 'Home',
|
||||
component: () => import('../views/HomeRedirect.vue'),
|
||||
},
|
||||
// Admin routes
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('../views/dashboard/DashboardView.vue') },
|
||||
{ path: 'iam-users', name: 'IAMUsers', component: () => import('../views/iam/IAMUserList.vue') },
|
||||
{ path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') },
|
||||
{ path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') },
|
||||
{ path: 'ark-keys', name: 'ArkKeys', component: () => import('../views/ark/ArkKeysView.vue') },
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') },
|
||||
{ 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') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -7,13 +7,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(JSON.parse(localStorage.getItem('airgate_user') || 'null'))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => user.value?.role !== 'iam_user')
|
||||
const isIamUser = computed(() => user.value?.role === 'iam_user')
|
||||
|
||||
function setAuth(data) {
|
||||
token.value = data.access
|
||||
refreshToken.value = data.refresh
|
||||
refreshToken.value = data.refresh || ''
|
||||
user.value = data.user
|
||||
localStorage.setItem('airgate_token', data.access)
|
||||
localStorage.setItem('airgate_refresh', data.refresh)
|
||||
localStorage.setItem('airgate_refresh', data.refresh || '')
|
||||
localStorage.setItem('airgate_user', JSON.stringify(data.user))
|
||||
}
|
||||
|
||||
@ -26,5 +28,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('airgate_user')
|
||||
}
|
||||
|
||||
return { token, refreshToken, user, isLoggedIn, setAuth, logout }
|
||||
return { token, refreshToken, user, isLoggedIn, isAdmin, isIamUser, setAuth, logout }
|
||||
})
|
||||
|
||||
20
frontend/src/views/HomeRedirect.vue
Normal file
20
frontend/src/views/HomeRedirect.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isIamUser) {
|
||||
router.replace('/my-keys')
|
||||
} else {
|
||||
router.replace('/dashboard')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
@ -5,9 +5,14 @@
|
||||
<h1>AirGate</h1>
|
||||
<p>火山引擎 IAM 子账号管控平台</p>
|
||||
</div>
|
||||
|
||||
<el-segmented v-model="loginMode" :options="loginModes" block
|
||||
style="margin-bottom: 24px;" />
|
||||
|
||||
<el-form :model="form" @submit.prevent="handleLogin" label-position="top">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="admin" size="large" />
|
||||
<el-input v-model="form.username" :placeholder="loginMode === 'admin' ? 'admin' : '子账号用户名'"
|
||||
size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="密码" size="large"
|
||||
@ -32,6 +37,12 @@ import api from '../api'
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loginMode = ref('admin')
|
||||
const loginModes = [
|
||||
{ label: '管理员登录', value: 'admin' },
|
||||
{ label: '子账号登录', value: 'iam' },
|
||||
]
|
||||
|
||||
const form = ref({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
@ -42,11 +53,14 @@ async function handleLogin() {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api/v1/auth/login/', form.value)
|
||||
const url = loginMode.value === 'admin'
|
||||
? '/api/v1/auth/login/'
|
||||
: '/api/v1/auth/iam/login/'
|
||||
const { data } = await api.post(url, form.value)
|
||||
auth.setAuth(data)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
ElMessage.error(err.response?.data?.message || '登录失败')
|
||||
ElMessage.error(err.response?.data?.message || '登录失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -65,7 +79,7 @@ async function handleLogin() {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 48px 40px;
|
||||
width: 400px;
|
||||
width: 420px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.login-header {
|
||||
|
||||
77
frontend/src/views/portal/MyKeysView.vue
Normal file
77
frontend/src/views/portal/MyKeysView.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div style="max-width: 1000px; margin: 0 auto;">
|
||||
<h2 style="margin-bottom: 16px;">我的 API Key</h2>
|
||||
|
||||
<el-table :data="keys" stripe v-loading="loading" style="width: 100%;"
|
||||
empty-text="暂无 API Key,请联系管理员分配">
|
||||
<el-table-column prop="project_name" label="所属项目" min-width="150" />
|
||||
<el-table-column prop="key_name" label="名称/用途" min-width="180" />
|
||||
<el-table-column label="API Key" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<template v-if="revealedKeys[row.id]">
|
||||
<code style="font-size: 13px; color: #409eff; word-break: break-all;">
|
||||
{{ revealedKeys[row.id] }}
|
||||
</code>
|
||||
<el-button size="small" text @click="copyKey(revealedKeys[row.id])"
|
||||
style="margin-left: 4px;">复制</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<code style="color: #999;">{{ row.api_key_hint }}</code>
|
||||
<el-button size="small" text type="primary" @click="handleReveal(row)"
|
||||
style="margin-left: 4px;">查看</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../../api'
|
||||
|
||||
const keys = ref([])
|
||||
const loading = ref(false)
|
||||
const revealedKeys = reactive({})
|
||||
|
||||
async function loadKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/auth/iam/my-keys/')
|
||||
keys.value = data
|
||||
} catch (e) {
|
||||
keys.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(row) {
|
||||
try {
|
||||
const { data } = await api.get(`/api/v1/auth/iam/my-keys/${row.id}/reveal/`)
|
||||
revealedKeys[row.id] = data.api_key
|
||||
} catch (e) {
|
||||
ElMessage.error('获取 Key 失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyKey(key) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(key)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadKeys)
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user