Compare commits
2 Commits
4f774893fa
...
5ec6df4af8
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ec6df4af8 | |||
| 5c2c135940 |
23
src/api/oauth.ts
Normal file
23
src/api/oauth.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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,7 +19,9 @@ const service: AxiosInstance = axios.create({
|
|||||||
|
|
||||||
service.interceptors.request.use((config) => {
|
service.interceptors.request.use((config) => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token) {
|
const requestUrl = String(config.url || '')
|
||||||
|
const isLoginRequest = requestUrl.endsWith('/auth/login') || requestUrl === '/auth/login'
|
||||||
|
if (token && !isLoginRequest) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,15 +2,16 @@ 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)
|
cookie.set(TOKEN_KEY, token, TOKEN_OPTIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeToken(): void {
|
export function removeToken(): void {
|
||||||
cookie.remove(TOKEN_KEY)
|
cookie.remove(TOKEN_KEY, TOKEN_OPTIONS)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
<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,13 +76,14 @@
|
|||||||
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 { useRouter } from 'vue-router'
|
import { useRoute, 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)
|
||||||
@ -157,6 +158,11 @@ 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
|
||||||
@ -247,12 +253,59 @@ 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>
|
||||||
|
|||||||
429
src/pages/OauthClientsPage.vue
Normal file
429
src/pages/OauthClientsPage.vue
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
<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>
|
||||||
273
src/pages/OauthConsentPage.vue
Normal file
273
src/pages/OauthConsentPage.vue
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
<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,6 +10,11 @@ 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'),
|
||||||
@ -22,6 +27,7 @@ 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') },
|
||||||
@ -62,6 +68,13 @@ 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