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:
seaislee1209 2026-03-21 01:33:02 +08:00
parent daa82aee76
commit 3d2b332657
7 changed files with 175 additions and 38 deletions

View File

@ -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;

View File

@ -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;">

View File

@ -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') },
],
},
]

View File

@ -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 }
})

View 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>

View File

@ -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 {

View 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>