Administrator 8117958bd6 feat: add user center with RBAC, OAuth2 multi-mode and collapsible sidebar
- Add user management with roles and permissions (RBAC)
- Implement OAuth2 service provider supporting 4 grant types:
  authorization_code, password, client_credentials, refresh_token
- Add JWT authentication with 7-day expiry
- Add admin API for users, roles and OAuth clients management
- Add CLI tool for user management (scripts/user-cli.js)
- Add collapsible sidebar layout with login dialog
- Add user management page and OAuth client management page
- Add server middleware for auth token verification
- Add seed script for initial data (admin/admin123)
2026-03-19 17:19:57 +08:00

355 lines
9.4 KiB
Vue

<template>
<div class="oauth-container">
<el-card>
<template #header>
<div class="card-header">
<span>OAuth 客户端管理</span>
<el-button type="primary" @click="showAddDialog = true">创建客户端</el-button>
</div>
</template>
<el-table :data="clients" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="clientName" label="应用名称" />
<el-table-column prop="clientId" label="Client ID" width="200">
<template #default="{ row }">
<el-tooltip :content="row.clientId" placement="top">
<code class="client-id">{{ row.clientId }}</code>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="{ row }">
<el-tag>{{ getPlatformText(row.platform) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="grantTypes" label="授权模式" width="200">
<template #default="{ row }">
<el-tag
v-for="grant in row.grantTypes"
:key="grant"
size="small"
class="grant-tag"
>
{{ getGrantText(grant) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="isActive" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.isActive"
@change="toggleStatus(row)"
:disabled="row.platform === 'web'"
/>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ new Date(row.createdAt).toLocaleString('zh-CN') }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small" @click="deleteClient(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="showAddDialog" title="创建 OAuth 客户端" width="600px">
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="应用名称" prop="clientName">
<el-input v-model="form.clientName" placeholder="请输入应用名称" />
</el-form-item>
<el-form-item label="平台" prop="platform">
<el-radio-group v-model="form.platform">
<el-radio value="web">网页应用</el-radio>
<el-radio value="mobile">移动应用</el-radio>
<el-radio value="desktop">桌面应用</el-radio>
<el-radio value="other">其他</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="授权模式" prop="grantTypes">
<el-checkbox-group v-model="form.grantTypes">
<el-checkbox value="authorization_code">授权码模式</el-checkbox>
<el-checkbox value="password">密码模式</el-checkbox>
<el-checkbox value="client_credentials">客户端凭据模式</el-checkbox>
<el-checkbox value="refresh_token">刷新令牌</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="重定向 URI" prop="redirectUris">
<el-input
v-model="redirectUrisText"
type="textarea"
:rows="3"
placeholder="多个 URI 用换行分隔"
/>
<div class="form-tip">每行一个 URI</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="submitForm">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="showSecretDialog" title="Client Secret" width="500px">
<el-alert
title="请妥善保存 Client Secret"
type="warning"
description="Client Secret 只会显示一次,请立即复制保存"
:closable="false"
show-icon
/>
<div class="secret-display">
<p><strong>Client ID:</strong></p>
<code class="secret-value">{{ newClient.clientId }}</code>
<p><strong>Client Secret:</strong></p>
<code class="secret-value">{{ newClient.clientSecret }}</code>
<el-button type="primary" class="copy-btn" @click="copySecret">复制到剪贴板</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'default'
})
const clients = ref<any[]>([])
const loading = ref(false)
const showAddDialog = ref(false)
const showSecretDialog = ref(false)
const redirectUrisText = ref('')
const formRef = ref()
const form = ref({
clientName: '',
platform: 'web',
grantTypes: ['authorization_code'] as string[]
})
const newClient = ref({
clientId: '',
clientSecret: ''
})
const rules = {
clientName: [
{ required: true, message: '请输入应用名称', trigger: 'blur' }
],
grantTypes: [
{ required: true, message: '请至少选择一种授权模式', trigger: 'change' }
]
}
const getPlatformText = (platform: string) => {
const texts: Record<string, string> = {
web: '网页',
mobile: '移动',
desktop: '桌面',
other: '其他'
}
return texts[platform] || platform
}
const getGrantText = (grant: string) => {
const texts: Record<string, string> = {
authorization_code: '授权码',
password: '密码',
client_credentials: '客户端',
refresh_token: '刷新'
}
return texts[grant] || grant
}
const loadClients = async () => {
const token = localStorage.getItem('token')
if (!token) {
ElMessage.warning('请先登录')
navigateTo('/')
return
}
loading.value = true
try {
const res = await $fetch('/api/admin/oauth', {
headers: { Authorization: `Bearer ${token}` }
})
clients.value = res.data
} catch (error: any) {
ElMessage.error(error.data?.message || '加载失败')
} finally {
loading.value = false
}
}
const toggleStatus = async (client: any) => {
const token = localStorage.getItem('token')
if (!token) return
try {
await $fetch(`/api/admin/oauth?clientId=${client.clientId}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: { isActive: client.isActive }
})
ElMessage.success('状态更新成功')
} catch (error: any) {
ElMessage.error(error.data?.message || '更新失败')
loadClients()
}
}
const deleteClient = async (client: any) => {
const token = localStorage.getItem('token')
if (!token) return
try {
await ElMessageBox.confirm(`确定删除客户端 "${client.clientName}" 吗?`, '提示', {
type: 'warning'
})
await $fetch(`/api/admin/oauth?clientId=${client.clientId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
})
ElMessage.success('删除成功')
loadClients()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.data?.message || '删除失败')
}
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
const token = localStorage.getItem('token')
if (!token) return
const redirectUris = redirectUrisText.value
.split('\n')
.map(uri => uri.trim())
.filter(uri => uri)
if (redirectUris.length === 0) {
ElMessage.error('请至少输入一个重定向 URI')
return
}
try {
const res = await $fetch('/api/admin/oauth', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: {
...form.value,
redirectUris,
allowedScopes: ['read', 'write']
}
})
newClient.value = {
clientId: res.data.clientId,
clientSecret: res.data.clientSecret
}
showAddDialog.value = false
showSecretDialog.value = true
resetForm()
loadClients()
} catch (error: any) {
ElMessage.error(error.data?.message || '创建失败')
}
})
}
const resetForm = () => {
form.value = {
clientName: '',
platform: 'web',
grantTypes: ['authorization_code']
}
redirectUrisText.value = ''
formRef.value?.resetFields()
}
const copySecret = () => {
const text = `Client ID: ${newClient.value.clientId}\nClient Secret: ${newClient.value.clientSecret}`
navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
}
onMounted(() => {
loadClients()
})
</script>
<style scoped>
.oauth-container {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.client-id {
font-size: 12px;
color: #409eff;
cursor: pointer;
}
.grant-tag {
margin-right: 4px;
margin-bottom: 2px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.secret-display {
margin-top: 20px;
}
.secret-display p {
margin: 16px 0 8px;
font-size: 14px;
}
.secret-value {
display: block;
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
word-break: break-all;
font-size: 13px;
}
.copy-btn {
margin-top: 20px;
width: 100%;
}
@media (max-width: 900px) {
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>