Files
webui4frpc/web/src/views/UsersView.vue
JianFeeeee f90627f542 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	internal/httpapi/dist/assets/index-CL42Ur3_.css
#	internal/httpapi/dist/assets/index-CbqC9m-j.js
#	internal/httpapi/dist/assets/index-RFHYrCiX.css
#	internal/httpapi/dist/assets/index-jvouiCgh.css
#	internal/httpapi/dist/index.html
#	internal/store/store.go
#	web/src/api.ts
#	web/src/views/ClusterView.vue
2026-08-24 23:30:19 +08:00

366 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
UsersView account + API key management (admin only).
Two tables: built-in/manual users (system rows are read-only, managed by
-user/-password flags) and scoped API keys (plaintext shown once at create).
Reached only when isAdmin; App.vue hides the nav entry for non-admins.
-->
<template>
<div class="users-view">
<!-- ---- accounts ---- -->
<section class="card">
<header class="card-h">
<h3>账号</h3>
<div class="h-actions">
<el-button size="small" @click="exportCsv('users')"> 导出 CSV</el-button>
<el-button type="primary" size="small" @click="openUserCreate"> 新建账号</el-button>
</div>
</header>
<el-table :data="users" size="small" stripe empty-text="暂无账号">
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="角色" width="130">
<template #default="{ row }">
<el-tag size="small" :type="row.role === 'superadmin' ? 'danger' : row.role === 'admin' ? 'warning' : 'info'">
{{ row.role === 'superadmin' ? '超级管理员' : row.role === 'admin' ? '管理员' : '审计(viewer)' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.enabled ? 'success' : 'warning'">
{{ row.enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源" width="110">
<template #default="{ row }">
<el-tag v-if="row.system" size="small" type="warning">内置(flag)</el-tag>
<span v-else class="muted">手动</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最后登录" width="170">
<template #default="{ row }">{{ row.lastLoginAt ? fmt(row.lastLoginAt) : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openUserEdit(row)" :disabled="row.system">编辑</el-button>
<el-button size="small" type="danger" plain @click="onDeleteUser(row)" :disabled="row.system">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- ---- api keys ---- -->
<section class="card">
<header class="card-h">
<h3>API 密钥</h3>
<div class="h-actions">
<el-button size="small" @click="exportCsv('apikeys')"> 导出 CSV</el-button>
<el-button type="primary" size="small" @click="openKeyCreate"> 新建密钥</el-button>
</div>
</header>
<el-table :data="apiKeys" size="small" stripe empty-text="暂无密钥">
<el-table-column prop="label" label="标签" min-width="140" />
<el-table-column label="作用域" width="100">
<template #default="{ row }">
<el-tag size="small" :type="scopeType(row.scope)">{{ row.scope }}</el-tag>
</template>
</el-table-column>
<el-table-column label="归属用户" width="140">
<template #default="{ row }">{{ userName(row.userId) }}</template>
</el-table-column>
<el-table-column prop="prefix" label="前缀" width="120" />
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ fmt(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="最近使用" width="170">
<template #default="{ row }">{{ row.lastUsedAt ? fmt(row.lastUsedAt) : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="110" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="onDeleteKey(row)">吊销</el-button>
</template>
</el-table-column>
</el-table>
</section>
<!-- create / edit user dialog -->
<el-dialog v-model="userDialog" :title="editing ? '编辑账号' : '新建账号'" width="420px">
<el-form label-width="86px" @submit.prevent>
<el-form-item label="用户名" v-if="!editing">
<el-input v-model="userForm.username" autocomplete="off" />
</el-form-item>
<el-form-item label="用户名" v-else>
<span class="muted">{{ userForm.username }}</span>
</el-form-item>
<el-form-item :label="editing ? '新密码' : '密码'">
<el-input v-model="userForm.password" type="password" show-password
:placeholder="editing ? '留空则不改' : ''" autocomplete="new-password" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
<el-option label="超级管理员 (superadmin)" value="superadmin" />
<el-option label="管理员 (admin)" value="admin" />
<el-option label="审计 (viewer)" value="viewer" />
</el-select>
</el-form-item>
<el-form-item label="启用" v-if="editing">
<el-switch v-model="userForm.enabled" :disabled="editing?.system" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="userDialog = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="userSaving">保存</el-button>
</template>
</el-dialog>
<!-- create key dialog -->
<el-dialog v-model="keyDialog" title="新建 API 密钥" width="440px">
<el-form label-width="86px" @submit.prevent v-if="!createdKey">
<el-form-item label="归属用户">
<el-select v-model="keyForm.userId" placeholder="选择用户">
<el-option v-for="u in users" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="keyForm.label" placeholder="如 ci-deploy" />
</el-form-item>
<el-form-item label="作用域">
<el-select v-model="keyForm.scope">
<el-option label="read — 只读 + 导出" value="read" />
<el-option label="write — 改转发/worker/设置" value="write" />
<el-option label="admin — 全量 (含账号管理)" value="admin" />
</el-select>
</el-form-item>
</el-form>
<div v-else class="key-result">
<p class="warn">密钥仅显示一次请立即复制保存</p>
<el-input v-model="createdKey" readonly>
<template #append>
<el-button @click="copyKey">复制</el-button>
</template>
</el-input>
<p class="hint">使用方式请求头 <code>Authorization: Bearer &lt;key&gt;</code></p>
</div>
<template #footer>
<el-button @click="keyDialog = false">{{ createdKey ? '关闭' : '取消' }}</el-button>
<el-button v-if="!createdKey" type="primary" @click="submitKey" :loading="keySaving">生成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { api, downloadAuditCsv } from '../api'
import type { ApiKey, ApiKeyCreated, User } from '../types'
const users = ref<User[]>([])
const apiKeys = ref<ApiKey[]>([])
// exportCsv streams one of the audit CSV endpoints to a file. Read-level:
// auditors pull these without any write permission.
const exportCsv = async (kind: 'users' | 'apikeys') => {
try {
await downloadAuditCsv(kind)
ElMessage.success('已导出 CSV')
} catch (e: any) {
ElMessage.error('导出失败: ' + (e?.message || e))
}
}
const loadUsers = async () => {
const r = await api.listUsers()
users.value = r.users
}
const loadKeys = async () => {
const r = await api.listApiKeys()
apiKeys.value = r.apiKeys
}
onMounted(async () => {
await Promise.all([loadUsers(), loadKeys()])
})
const fmt = (ts: number) => {
if (!ts) return '—'
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
const userName = (id: number) => users.value.find((u) => u.id === id)?.username ?? `#${id}`
const scopeType = (s: string) => (s === 'admin' ? 'danger' : s === 'write' ? 'warning' : 'info')
// ---- user create/edit ----
const userDialog = ref(false)
const editing = ref<User | null>(null)
const userSaving = ref(false)
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer' | 'superadmin', enabled: true })
const openUserCreate = () => {
editing.value = null
userForm.value = { username: '', password: '', role: 'viewer', enabled: true }
userDialog.value = true
}
const openUserEdit = (u: User) => {
editing.value = u
userForm.value = { username: u.username, password: '', role: u.role, enabled: u.enabled }
userDialog.value = true
}
const submitUser = async () => {
userSaving.value = true
try {
if (editing.value) {
const patch: { password?: string; role: 'admin' | 'viewer' | 'superadmin'; enabled: boolean } = {
role: userForm.value.role,
enabled: userForm.value.enabled,
}
if (userForm.value.password) patch.password = userForm.value.password
await api.updateUser(editing.value.username, patch)
ElMessage.success('已更新')
} else {
if (!userForm.value.username || !userForm.value.password) {
ElMessage.warning('用户名和密码必填')
userSaving.value = false
return
}
await api.createUser(userForm.value.username, userForm.value.password, userForm.value.role)
ElMessage.success('已创建')
}
userDialog.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error('失败: ' + (e.message || e))
} finally {
userSaving.value = false
}
}
const onDeleteUser = async (u: User) => {
try {
await ElMessageBox.confirm(`确认删除账号 ${u.username}`, '删除账号', { type: 'warning' })
} catch {
return
}
try {
await api.deleteUser(u.username)
ElMessage.success('已删除')
await loadUsers()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.message || e))
}
}
// ---- api key create ----
const keyDialog = ref(false)
const keySaving = ref(false)
const createdKey = ref('')
const keyForm = ref({ userId: 0 as number, label: '', scope: 'read' as 'read' | 'write' | 'admin' })
const openKeyCreate = () => {
// default to the first non-system user (or first user)
keyForm.value = {
userId: users.value.find((u) => !u.system)?.id ?? users.value[0]?.id ?? 0,
label: '',
scope: 'read',
}
createdKey.value = ''
keyDialog.value = true
}
const submitKey = async () => {
if (!keyForm.value.userId) {
ElMessage.warning('请选择归属用户')
return
}
keySaving.value = true
try {
const r: ApiKeyCreated = await api.createApiKey(keyForm.value.userId, keyForm.value.label, keyForm.value.scope)
createdKey.value = r.key
await loadKeys()
} catch (e: any) {
ElMessage.error('创建失败: ' + (e.message || e))
} finally {
keySaving.value = false
}
}
const copyKey = async () => {
try {
await navigator.clipboard.writeText(createdKey.value)
ElMessage.success('已复制')
} catch {
ElMessage.warning('复制失败,请手动选择')
}
}
const onDeleteKey = async (k: ApiKey) => {
try {
await ElMessageBox.confirm(`确认吊销密钥 ${k.label} (${k.prefix}…)?`, '吊销密钥', { type: 'warning' })
} catch {
return
}
try {
await api.deleteApiKey(k.id)
ElMessage.success('已吊销')
await loadKeys()
} catch (e: any) {
ElMessage.error('吊销失败: ' + (e.message || e))
}
}
</script>
<style scoped>
.users-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.card {
background: var(--w4f-card);
border: 1px solid var(--w4f-line);
border-radius: 16px;
padding: 16px 18px;
box-shadow: var(--w4f-sh-sm);
}
.card-h {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-h h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.muted {
color: var(--w4f-muted);
}
.key-result {
display: flex;
flex-direction: column;
gap: 10px;
}
.key-result .warn {
color: var(--w4f-primary-h, #d97706);
font-weight: 600;
margin: 0;
}
.key-result .hint {
margin: 0;
font-size: 12px;
color: var(--w4f-muted);
}
.key-result code {
background: var(--w4f-card-2);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
}
</style>