Compare commits
No commits in common. "5ec6df4af80985626653f2e8591ba5e2699696f8" and "4f774893fa25b143a4b582710a7069ed1e74dbdb" have entirely different histories.
5ec6df4af8
...
4f774893fa
@ -1,23 +0,0 @@
|
|||||||
import request from '@/axios'
|
|
||||||
|
|
||||||
export const oauthApi = {
|
|
||||||
clients(params: Record<string, unknown> = {}) {
|
|
||||||
return request.get('/oauth/clients', { params })
|
|
||||||
},
|
|
||||||
createClient(data: Record<string, unknown>) {
|
|
||||||
return request.post('/oauth/clients', data)
|
|
||||||
},
|
|
||||||
updateClient(id: number, data: Record<string, unknown>) {
|
|
||||||
return request.put(`/oauth/clients/${id}`, data)
|
|
||||||
},
|
|
||||||
removeClient(id: number) {
|
|
||||||
return request.delete(`/oauth/clients/${id}`)
|
|
||||||
},
|
|
||||||
resetClientSecret(id: number) {
|
|
||||||
return request.post(`/oauth/clients/${id}/reset-secret`)
|
|
||||||
},
|
|
||||||
|
|
||||||
authorizeDecision(data: Record<string, unknown>) {
|
|
||||||
return request.post('/oauth/authorize/decision', data)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -19,9 +19,7 @@ const service: AxiosInstance = axios.create({
|
|||||||
|
|
||||||
service.interceptors.request.use((config) => {
|
service.interceptors.request.use((config) => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const requestUrl = String(config.url || '')
|
if (token) {
|
||||||
const isLoginRequest = requestUrl.endsWith('/auth/login') || requestUrl === '/auth/login'
|
|
||||||
if (token && !isLoginRequest) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,15 @@ import { useCookies } from "@vueuse/integrations/useCookies"
|
|||||||
|
|
||||||
const TOKEN_KEY = "token"
|
const TOKEN_KEY = "token"
|
||||||
const cookie = useCookies()
|
const cookie = useCookies()
|
||||||
const TOKEN_OPTIONS = { path: "/" }
|
|
||||||
|
|
||||||
export function getToken(): string | undefined {
|
export function getToken(): string | undefined {
|
||||||
return cookie.get(TOKEN_KEY)
|
return cookie.get(TOKEN_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token: string): void {
|
export function setToken(token: string): void {
|
||||||
cookie.set(TOKEN_KEY, token, TOKEN_OPTIONS)
|
cookie.set(TOKEN_KEY, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeToken(): void {
|
export function removeToken(): void {
|
||||||
cookie.remove(TOKEN_KEY, TOKEN_OPTIONS)
|
cookie.remove(TOKEN_KEY)
|
||||||
}
|
}
|
||||||
@ -38,7 +38,6 @@
|
|||||||
<el-menu-item v-if='hasPermission("platform.servers.view") || hasPermission("resource.servers.use")' index='/servers'><el-icon><Monitor /></el-icon>服务器资源</el-menu-item>
|
<el-menu-item v-if='hasPermission("platform.servers.view") || hasPermission("resource.servers.use")' index='/servers'><el-icon><Monitor /></el-icon>服务器资源</el-menu-item>
|
||||||
<el-menu-item v-if='hasPermission("platform.accounts.view")' index='/accounts'><el-icon><Key /></el-icon>堡垒机账号</el-menu-item>
|
<el-menu-item v-if='hasPermission("platform.accounts.view")' index='/accounts'><el-icon><Key /></el-icon>堡垒机账号</el-menu-item>
|
||||||
<el-menu-item v-if='hasPermission("platform.logs.view")' index='/logs'><el-icon><Document /></el-icon>访问日志</el-menu-item>
|
<el-menu-item v-if='hasPermission("platform.logs.view")' index='/logs'><el-icon><Document /></el-icon>访问日志</el-menu-item>
|
||||||
<el-menu-item v-if='hasPermission("platform.oauth_clients.view")' index='/oauth-clients'><el-icon><Key /></el-icon>OAuth 客户端</el-menu-item>
|
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-main class='main'>
|
<el-main class='main'>
|
||||||
|
|||||||
@ -76,14 +76,13 @@
|
|||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
import { getToken } from '@/composables/token'
|
import { getToken } from '@/composables/token'
|
||||||
import { removeToken, setToken } from '@/composables/token'
|
import { removeToken, setToken } from '@/composables/token'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@ -158,11 +157,6 @@ async function handleLogin(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
const returnTo = resolveReturnTo()
|
|
||||||
if (returnTo) {
|
|
||||||
redirectToOAuthFlow(returnTo)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await router.replace('/')
|
await router.replace('/')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
||||||
@ -253,59 +247,12 @@ onMounted(async () => {
|
|||||||
forcePasswordDialogVisible.value = true
|
forcePasswordDialogVisible.value = true
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
const returnTo = resolveReturnTo()
|
|
||||||
if (returnTo) {
|
|
||||||
redirectToOAuthFlow(returnTo)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await router.replace('/')
|
await router.replace('/')
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// ignore: invalid token will be handled by route guard
|
// ignore: invalid token will be handled by route guard
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function redirectToOAuthFlow(returnTo: string): void {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
router.replace('/').catch(() => null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetUrl = returnTo
|
|
||||||
try {
|
|
||||||
const url = new URL(returnTo, window.location.origin)
|
|
||||||
url.searchParams.set('access_token', token)
|
|
||||||
targetUrl = url.toString()
|
|
||||||
} catch (_error) {
|
|
||||||
// keep original value
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = targetUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReturnTo(): string {
|
|
||||||
const fromRoute = typeof route.query.return_to === 'string' ? route.query.return_to : ''
|
|
||||||
if (fromRoute) {
|
|
||||||
return fromRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromSearch = new URLSearchParams(window.location.search).get('return_to') || ''
|
|
||||||
if (fromSearch) {
|
|
||||||
return fromSearch
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = window.location.hash || ''
|
|
||||||
const queryIndex = hash.indexOf('?')
|
|
||||||
if (queryIndex >= 0) {
|
|
||||||
const fromHash = new URLSearchParams(hash.slice(queryIndex + 1)).get('return_to') || ''
|
|
||||||
if (fromHash) {
|
|
||||||
return fromHash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -1,429 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-card>
|
|
||||||
<template #header>
|
|
||||||
<div class='flex justify-between items-center'>
|
|
||||||
<span class='font-700'>OAuth 客户端管理</span>
|
|
||||||
<el-button v-if='hasPermission("platform.oauth_clients.manage")' type='primary' @click='openCreate'>新增客户端</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-table :data='rows' v-loading='loading'>
|
|
||||||
<el-table-column prop='id' label='ID' width='80' sortable />
|
|
||||||
<el-table-column prop='name' label='名称' min-width='160' sortable />
|
|
||||||
<el-table-column prop='client_id' label='Client ID' min-width='240' />
|
|
||||||
<el-table-column label='UserInfo 字段' min-width='220'>
|
|
||||||
<template #default='{ row }'>
|
|
||||||
<div class='btn-gap-8'>
|
|
||||||
<el-tag v-for='field in row.allowed_userinfo_fields || []' :key='`userinfo-${row.id}-${field}`' type='info'>{{ field }}</el-tag>
|
|
||||||
<span v-if='!(row.allowed_userinfo_fields || []).length' class='text-gray-400'>无</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label='回调域名' min-width='220'>
|
|
||||||
<template #default='{ row }'>
|
|
||||||
<div>{{ formatDomains(row.redirect_uris || []) }}</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop='is_active' label='状态' width='100' sortable>
|
|
||||||
<template #default='{ row }'>
|
|
||||||
<el-tag :type='row.is_active ? "success" : "info"'>{{ row.is_active ? '启用' : '停用' }}</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column v-if='hasPermission("platform.oauth_clients.manage")' label='操作' min-width='260'>
|
|
||||||
<template #default='{ row }'>
|
|
||||||
<div class='btn-gap-8 btn-gap-8--nowrap'>
|
|
||||||
<el-button size='small' @click='openEdit(row)'>编辑</el-button>
|
|
||||||
<el-button size='small' type='warning' @click='resetSecret(row)'>重置密钥</el-button>
|
|
||||||
<el-button size='small' type='danger' @click='removeRow(row)'>删除</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-pagination
|
|
||||||
class='mt-4'
|
|
||||||
layout='total, sizes, prev, pager, next'
|
|
||||||
:total='total'
|
|
||||||
:current-page='page'
|
|
||||||
:page-size='perPage'
|
|
||||||
:page-sizes='[10, 20, 50, 100]'
|
|
||||||
@current-change='handlePageChange'
|
|
||||||
@size-change='handleSizeChange'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-dialog v-model='dialogVisible' :title='editingId ? "编辑 OAuth 客户端" : "新增 OAuth 客户端"' width='760px'>
|
|
||||||
<el-form :model='form' label-width='120px'>
|
|
||||||
<el-form-item label='客户端名称'>
|
|
||||||
<el-input v-model='form.name' />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label='Logo URL'>
|
|
||||||
<el-input v-model='form.logo_url' placeholder='可选' />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label='回调地址'>
|
|
||||||
<el-input
|
|
||||||
v-model='form.redirect_uris_text'
|
|
||||||
type='textarea'
|
|
||||||
:rows='4'
|
|
||||||
placeholder='每行一个 URI,例如 https://app.example.com/callback'
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label='UserInfo 字段'>
|
|
||||||
<el-select v-model='form.allowed_userinfo_fields' multiple filterable allow-create default-first-option class='w-full'>
|
|
||||||
<el-option v-for='field in userinfoFieldOptions' :key='field' :label='field' :value='field' />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label='高级设置'>
|
|
||||||
<div class='w-full'>
|
|
||||||
<el-collapse>
|
|
||||||
<el-collapse-item title='字段别名映射(兼容客户端字段名)' name='claim-alias'>
|
|
||||||
<div class='claim-remap-panel'>
|
|
||||||
<div class='claim-remap-tip'>将平台字段映射为客户端需要的字段名,例如 <code>nickname -> username</code>。</div>
|
|
||||||
<div v-if='!form.allowed_userinfo_fields.length' class='claim-remap-empty'>
|
|
||||||
请先在上方选择至少一个 UserInfo 字段。
|
|
||||||
</div>
|
|
||||||
<div v-for='(pair, index) in form.remap_pairs' :key='`remap-${index}`' class='claim-remap-row'>
|
|
||||||
<el-select
|
|
||||||
v-model='pair.from'
|
|
||||||
placeholder='选择原字段'
|
|
||||||
class='claim-remap-from'
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for='field in availableFromFields(pair.from)'
|
|
||||||
:key='`from-${index}-${field}`'
|
|
||||||
:label='field'
|
|
||||||
:value='field'
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<span class='claim-remap-arrow'>→</span>
|
|
||||||
<el-input v-model='pair.to' placeholder='客户端字段名(如 username)' class='claim-remap-to' />
|
|
||||||
<el-button type='danger' plain class='claim-remap-remove' @click='removeRemapPair(index)'>删除</el-button>
|
|
||||||
</div>
|
|
||||||
<el-button
|
|
||||||
class='claim-remap-add'
|
|
||||||
type='primary'
|
|
||||||
plain
|
|
||||||
:disabled='cannotAddRemap'
|
|
||||||
@click='addRemapPair'
|
|
||||||
>
|
|
||||||
添加映射
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-collapse-item>
|
|
||||||
</el-collapse>
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label='启用'>
|
|
||||||
<el-switch v-model='form.is_active' />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click='dialogVisible = false'>取消</el-button>
|
|
||||||
<el-button type='primary' :loading='saving' @click='submit'>提交</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<el-dialog v-model='secretDialogVisible' title='客户端密钥' width='560px'>
|
|
||||||
<div class='mb-2'>请立即保存该密钥,关闭后将无法再次查看。</div>
|
|
||||||
<el-input :model-value='latestClientId' readonly class='mb-2'>
|
|
||||||
<template #prepend>Client ID</template>
|
|
||||||
</el-input>
|
|
||||||
<el-input :model-value='latestSecret' readonly>
|
|
||||||
<template #prepend>Client Secret</template>
|
|
||||||
</el-input>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click='copySecret'>复制密钥</el-button>
|
|
||||||
<el-button type='primary' @click='secretDialogVisible = false'>关闭</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang='ts'>
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
||||||
import { oauthApi } from '@/api/oauth'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
interface RemapPair {
|
|
||||||
from: string
|
|
||||||
to: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const { hasPermission } = authStore
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const secretDialogVisible = ref(false)
|
|
||||||
const editingId = ref<number | null>(null)
|
|
||||||
const rows = ref<any[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const page = ref(1)
|
|
||||||
const perPage = ref(20)
|
|
||||||
const latestClientId = ref('')
|
|
||||||
const latestSecret = ref('')
|
|
||||||
const userinfoFieldOptions = ['sub', 'nickname', 'email', 'phone', 'username']
|
|
||||||
|
|
||||||
const form = reactive<any>({
|
|
||||||
name: '',
|
|
||||||
logo_url: '',
|
|
||||||
redirect_uris_text: '',
|
|
||||||
allowed_userinfo_fields: ['sub', 'nickname', 'email'],
|
|
||||||
remap_pairs: [] as RemapPair[],
|
|
||||||
is_active: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
async function fetchList(): Promise<void> {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const clientsResponse: any = await oauthApi.clients({ page: page.value, per_page: perPage.value })
|
|
||||||
|
|
||||||
rows.value = clientsResponse.data.data || []
|
|
||||||
total.value = Number(clientsResponse.data.total || 0)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageChange(nextPage: number): void {
|
|
||||||
page.value = nextPage
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSizeChange(nextSize: number): void {
|
|
||||||
perPage.value = nextSize
|
|
||||||
page.value = 1
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate(): void {
|
|
||||||
editingId.value = null
|
|
||||||
Object.assign(form, {
|
|
||||||
name: '',
|
|
||||||
logo_url: '',
|
|
||||||
redirect_uris_text: '',
|
|
||||||
allowed_userinfo_fields: ['sub', 'nickname', 'email'],
|
|
||||||
remap_pairs: [],
|
|
||||||
is_active: true,
|
|
||||||
})
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row: any): void {
|
|
||||||
editingId.value = row.id
|
|
||||||
const remap = row.userinfo_claim_remap || {}
|
|
||||||
Object.assign(form, {
|
|
||||||
name: row.name,
|
|
||||||
logo_url: row.logo_url || '',
|
|
||||||
redirect_uris_text: (row.redirect_uris || []).join('\n'),
|
|
||||||
allowed_userinfo_fields: row.allowed_userinfo_fields || ['sub'],
|
|
||||||
remap_pairs: Object.entries(remap).map(([from, to]) => ({ from: String(from), to: String(to) })),
|
|
||||||
is_active: Boolean(row.is_active),
|
|
||||||
})
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRedirectUris(): string[] {
|
|
||||||
return String(form.redirect_uris_text || '')
|
|
||||||
.split('\n')
|
|
||||||
.map((item: string) => item.trim())
|
|
||||||
.filter((item: string) => item.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRemapObject(): Record<string, string> {
|
|
||||||
const remap: Record<string, string> = {}
|
|
||||||
for (const pair of form.remap_pairs as RemapPair[]) {
|
|
||||||
const from = String(pair.from || '').trim()
|
|
||||||
const to = String(pair.to || '').trim()
|
|
||||||
if (!from || !to) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remap[from] = to
|
|
||||||
}
|
|
||||||
|
|
||||||
return remap
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRemapPair(): void {
|
|
||||||
const candidates = availableFromFields('')
|
|
||||||
if (!candidates.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
;(form.remap_pairs as RemapPair[]).push({ from: candidates[0], to: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRemapPair(index: number | string): void {
|
|
||||||
const targetIndex = Number(index)
|
|
||||||
if (!Number.isInteger(targetIndex) || targetIndex < 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
;(form.remap_pairs as RemapPair[]).splice(targetIndex, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function availableFromFields(currentFrom: string): string[] {
|
|
||||||
const selectedFields = (form.allowed_userinfo_fields || []) as string[]
|
|
||||||
const used = new Set(
|
|
||||||
(form.remap_pairs as RemapPair[])
|
|
||||||
.map((pair) => pair.from)
|
|
||||||
.filter((from) => from && from !== currentFrom),
|
|
||||||
)
|
|
||||||
|
|
||||||
return selectedFields.filter((field) => field === currentFrom || !used.has(field))
|
|
||||||
}
|
|
||||||
|
|
||||||
const cannotAddRemap = computed(() => {
|
|
||||||
const selectedFields = ((form.allowed_userinfo_fields || []) as string[]).filter((field) => field !== 'sub')
|
|
||||||
if (!selectedFields.length) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const used = new Set(
|
|
||||||
(form.remap_pairs as RemapPair[])
|
|
||||||
.map((pair) => pair.from)
|
|
||||||
.filter((from) => from && from !== 'sub'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return used.size >= selectedFields.length
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => form.allowed_userinfo_fields,
|
|
||||||
(nextFields) => {
|
|
||||||
const allowed = new Set((nextFields || []) as string[])
|
|
||||||
form.remap_pairs = (form.remap_pairs as RemapPair[])
|
|
||||||
.filter((pair) => pair.from && allowed.has(pair.from))
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
name: form.name,
|
|
||||||
logo_url: form.logo_url || null,
|
|
||||||
redirect_uris: parseRedirectUris(),
|
|
||||||
allowed_userinfo_fields: form.allowed_userinfo_fields || [],
|
|
||||||
userinfo_claim_remap: buildRemapObject(),
|
|
||||||
is_active: Boolean(form.is_active),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingId.value) {
|
|
||||||
await oauthApi.updateClient(editingId.value, payload)
|
|
||||||
ElMessage.success('更新成功')
|
|
||||||
} else {
|
|
||||||
const response: any = await oauthApi.createClient(payload)
|
|
||||||
latestClientId.value = String(response.data.client?.client_id || '')
|
|
||||||
latestSecret.value = String(response.data.client_secret || '')
|
|
||||||
secretDialogVisible.value = latestSecret.value !== ''
|
|
||||||
ElMessage.success('创建成功')
|
|
||||||
}
|
|
||||||
dialogVisible.value = false
|
|
||||||
await fetchList()
|
|
||||||
} catch (error: any) {
|
|
||||||
const first = error?.errors ? (Object.values(error.errors)[0] as string[])?.[0] : null
|
|
||||||
ElMessage.error(first || error?.message || '提交失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRow(row: any): Promise<void> {
|
|
||||||
await ElMessageBox.confirm(`确认删除客户端 ${row.name} 吗?`, '提示', { type: 'warning' })
|
|
||||||
await oauthApi.removeClient(row.id)
|
|
||||||
ElMessage.success('删除成功')
|
|
||||||
await fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetSecret(row: any): Promise<void> {
|
|
||||||
await ElMessageBox.confirm(`确认重置客户端 ${row.name} 的密钥吗?`, '提示', { type: 'warning' })
|
|
||||||
const response: any = await oauthApi.resetClientSecret(row.id)
|
|
||||||
latestClientId.value = String(response.data.client_id || '')
|
|
||||||
latestSecret.value = String(response.data.client_secret || '')
|
|
||||||
secretDialogVisible.value = latestSecret.value !== ''
|
|
||||||
ElMessage.success('密钥已重置')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copySecret(): Promise<void> {
|
|
||||||
if (!latestSecret.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await navigator.clipboard.writeText(latestSecret.value)
|
|
||||||
ElMessage.success('已复制')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDomains(redirectUris: string[]): string {
|
|
||||||
const domains = redirectUris
|
|
||||||
.map((uri) => {
|
|
||||||
try {
|
|
||||||
return new URL(uri).host
|
|
||||||
} catch (_error) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((host) => host.length > 0)
|
|
||||||
|
|
||||||
return [...new Set(domains)].join(',')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchList)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.claim-remap-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-tip {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-empty {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(180px, 220px) 28px 1fr auto;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-from,
|
|
||||||
.claim-remap-to {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-arrow {
|
|
||||||
text-align: center;
|
|
||||||
color: #64748b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-remove {
|
|
||||||
min-width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-add {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.claim-remap-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.claim-remap-arrow {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class='oauth-consent-page'>
|
|
||||||
<el-card class='oauth-consent-card' shadow='never'>
|
|
||||||
<div class='brand-line'>OAuth2 安全授权</div>
|
|
||||||
<div class='header'>
|
|
||||||
<img v-if='clientLogo' :src='clientLogo' class='logo' alt='client logo' />
|
|
||||||
<div class='header-copy'>
|
|
||||||
<h2 class='title'>授权 {{ clientName || '第三方应用' }}</h2>
|
|
||||||
<p class='subtitle'>该应用将按服务端配置读取你的用户信息</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-alert v-if='errorMessage' :title='errorMessage' type='error' show-icon :closable='false' class='mb-3' />
|
|
||||||
|
|
||||||
<div class='section'>
|
|
||||||
<div class='label'>将返回给应用的 UserInfo 字段</div>
|
|
||||||
<div class='value'>
|
|
||||||
<el-tag v-for='field in userinfoFields' :key='field' effect='plain' type='info' class='mr-2 mb-2'>{{ field }}</el-tag>
|
|
||||||
<span v-if='!userinfoFields.length' class='text-gray-500'>仅返回 `sub`</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if='remapPairs.length' class='section'>
|
|
||||||
<div class='label'>字段映射规则</div>
|
|
||||||
<div class='value remap-grid'>
|
|
||||||
<div v-for='item in remapPairs' :key='`${item.from}-${item.to}`' class='remap-item'>
|
|
||||||
<span class='from'>{{ item.from }}</span>
|
|
||||||
<span class='arrow'>→</span>
|
|
||||||
<span class='to'>{{ item.to }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class='actions btn-gap-8 btn-gap-8--end'>
|
|
||||||
<el-button class='reject-btn' @click='submitDecision(false)'>拒绝</el-button>
|
|
||||||
<el-button type='primary' class='approve-btn' @click='submitDecision(true)'>同意并继续</el-button>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang='ts'>
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { getToken } from '@/composables/token'
|
|
||||||
|
|
||||||
interface RemapPair {
|
|
||||||
from: string
|
|
||||||
to: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
const clientName = computed(() => String(route.query.client_name || ''))
|
|
||||||
const clientLogo = computed(() => String(route.query.client_logo || ''))
|
|
||||||
const clientId = computed(() => String(route.query.client_id || ''))
|
|
||||||
const redirectUri = computed(() => String(route.query.redirect_uri || ''))
|
|
||||||
const returnTo = computed(() => String(route.query.return_to || ''))
|
|
||||||
const state = computed(() => String(route.query.state || ''))
|
|
||||||
const nonce = computed(() => String(route.query.nonce || ''))
|
|
||||||
const userinfoFields = computed<string[]>(() => {
|
|
||||||
const encoded = String(route.query.userinfo_fields || '')
|
|
||||||
if (!encoded) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(atob(encoded))
|
|
||||||
return Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter((item) => item.length > 0) : []
|
|
||||||
} catch (_error) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const remapPairs = computed<RemapPair[]>(() => {
|
|
||||||
const encoded = String(route.query.userinfo_remap || '')
|
|
||||||
if (!encoded) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(atob(encoded))
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(parsed)
|
|
||||||
.map(([from, to]) => ({ from: String(from || '').trim(), to: String(to || '').trim() }))
|
|
||||||
.filter((item) => item.from.length > 0 && item.to.length > 0)
|
|
||||||
} catch (_error) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
|
||||||
if (import.meta.env.VITE_STATE === 'dev') {
|
|
||||||
return String(import.meta.env.VITE_DEV_API_URL || 'api')
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(import.meta.env.VITE_PROD_API_URL || '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitDecision(approve: boolean): void {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
if (returnTo.value) {
|
|
||||||
window.location.href = returnTo.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
errorMessage.value = '登录已过期,请从授权端点重新发起授权。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!clientId.value || !redirectUri.value) {
|
|
||||||
errorMessage.value = '授权参数缺失,无法继续。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionBase = resolveApiBase().replace(/\/+$/, '')
|
|
||||||
const action = `${actionBase}/oauth/authorize/decision`
|
|
||||||
const form = document.createElement('form')
|
|
||||||
form.method = 'POST'
|
|
||||||
form.action = action
|
|
||||||
|
|
||||||
const payload: Record<string, string> = {
|
|
||||||
approve: approve ? '1' : '0',
|
|
||||||
client_id: clientId.value,
|
|
||||||
redirect_uri: redirectUri.value,
|
|
||||||
state: state.value,
|
|
||||||
nonce: nonce.value,
|
|
||||||
access_token: token,
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(payload).forEach(([key, value]) => {
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'hidden'
|
|
||||||
input.name = key
|
|
||||||
input.value = value
|
|
||||||
form.appendChild(input)
|
|
||||||
})
|
|
||||||
|
|
||||||
document.body.appendChild(form)
|
|
||||||
form.submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const token = getToken()
|
|
||||||
if (token) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const returnToValue = returnTo.value
|
|
||||||
if (!returnToValue) {
|
|
||||||
errorMessage.value = '缺少授权上下文,请从 OAuth 授权端点重新发起。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
router.replace({
|
|
||||||
path: '/login',
|
|
||||||
query: {
|
|
||||||
return_to: returnToValue,
|
|
||||||
},
|
|
||||||
}).catch(() => null)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.oauth-consent-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 28px 16px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
}
|
|
||||||
.oauth-consent-card {
|
|
||||||
width: min(620px, 100%);
|
|
||||||
border: 1px solid #dbeafe;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
.brand-line {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: #0284c7;
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 22px;
|
|
||||||
}
|
|
||||||
.header-copy {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
width: 54px;
|
|
||||||
height: 54px;
|
|
||||||
border-radius: 12px;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 1px solid #bae6fd;
|
|
||||||
box-shadow: 0 8px 20px rgba(3, 105, 161, 0.18);
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #0f172a;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
margin: 0;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.75);
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #0f172a;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.value {
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
.remap-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.remap-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #ecfeff;
|
|
||||||
border: 1px solid #bae6fd;
|
|
||||||
color: #0f172a;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.arrow {
|
|
||||||
color: #0ea5e9;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.reject-btn {
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
}
|
|
||||||
.approve-btn {
|
|
||||||
min-width: 136px;
|
|
||||||
}
|
|
||||||
:deep(.el-card__body) {
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.oauth-consent-page {
|
|
||||||
padding: 20px 12px;
|
|
||||||
}
|
|
||||||
:deep(.el-card__body) {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -10,11 +10,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/pages/LoginPage.vue'),
|
component: () => import('@/pages/LoginPage.vue'),
|
||||||
meta: { guest: true },
|
meta: { guest: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/oauth-consent',
|
|
||||||
component: () => import('@/pages/OauthConsentPage.vue'),
|
|
||||||
meta: { guest: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/layouts/MainLayout.vue'),
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
@ -27,7 +22,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'servers', component: () => import('@/pages/ServersPage.vue') },
|
{ path: 'servers', component: () => import('@/pages/ServersPage.vue') },
|
||||||
{ path: 'accounts', component: () => import('@/pages/AccountsPage.vue') },
|
{ path: 'accounts', component: () => import('@/pages/AccountsPage.vue') },
|
||||||
{ path: 'logs', component: () => import('@/pages/LogsPage.vue') },
|
{ path: 'logs', component: () => import('@/pages/LogsPage.vue') },
|
||||||
{ path: 'oauth-clients', component: () => import('@/pages/OauthClientsPage.vue') },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/:pathMatch(.*)*', component: () => import('@/pages/NotFoundPage.vue') },
|
{ path: '/:pathMatch(.*)*', component: () => import('@/pages/NotFoundPage.vue') },
|
||||||
@ -68,13 +62,6 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token && to.meta.guest) {
|
if (token && to.meta.guest) {
|
||||||
const returnTo = typeof to.query.return_to === 'string' ? to.query.return_to : ''
|
|
||||||
if (to.path === '/login' && returnTo) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (to.path === '/oauth-consent') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change)
|
const forcePasswordChange = Boolean((authStore.user as any)?.force_password_change)
|
||||||
if (forcePasswordChange) {
|
if (forcePasswordChange) {
|
||||||
if (to.path === '/login') {
|
if (to.path === '/login') {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user