feat: add admin social login bindings
This commit is contained in:
@@ -44,3 +44,28 @@ export async function reportAdminPasskeyClientError(payload = {}) {
|
||||
const { data } = await api.post('/admin/passkeys/client-error', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminSocialBindings() {
|
||||
const { data } = await api.get('/admin/social-bindings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAdminSocialLoginUrl(payload = {}) {
|
||||
const { data } = await api.post('/admin/social-login-url', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function pollAdminSocialLogin(payload = {}) {
|
||||
const { data } = await api.post('/admin/social-poll', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function bindAdminSocialCallback(provider, payload = {}) {
|
||||
const { data } = await api.post(`/admin/social-bindings/${encodeURIComponent(provider)}/callback`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function unbindAdminSocial(provider) {
|
||||
const { data } = await api.delete(`/admin/social-bindings/${encodeURIComponent(provider)}`)
|
||||
return data
|
||||
}
|
||||
|
||||
73
admin-frontend/src/pages/AdminSocialBindCallbackPage.vue
Normal file
73
admin-frontend/src/pages/AdminSocialBindCallbackPage.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { bindAdminSocialCallback } from '../api/admin'
|
||||
|
||||
const route = useRoute()
|
||||
const statusText = ref('正在完成绑定')
|
||||
|
||||
onMounted(async () => {
|
||||
const hashQuery = String(window.location.hash || '').split('?')[1] || ''
|
||||
const params = new URLSearchParams(window.location.search || hashQuery)
|
||||
const provider = String(params.get('provider') || params.get('type') || '').trim().toLowerCase()
|
||||
const code = String(params.get('code') || '').trim()
|
||||
const routeProvider = String(route.query?.provider || route.query?.type || '').trim().toLowerCase()
|
||||
const routeCode = String(route.query?.code || '').trim()
|
||||
const finalProvider = provider || routeProvider
|
||||
const finalCode = code || routeCode
|
||||
|
||||
if (!finalProvider || !finalCode) {
|
||||
ElMessage.error('快捷登录回调参数不完整')
|
||||
window.location.replace('/yuyx/admin#/settings')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await bindAdminSocialCallback(finalProvider, { provider: finalProvider, code: finalCode })
|
||||
ElMessage.success('管理员快捷登录已绑定')
|
||||
window.location.replace('/yuyx/admin#/settings')
|
||||
} catch (error) {
|
||||
const payload = error?.response?.data
|
||||
statusText.value = payload?.error || '快捷登录绑定失败'
|
||||
ElMessage.error(statusText.value)
|
||||
window.setTimeout(() => {
|
||||
window.location.replace('/yuyx/admin#/settings')
|
||||
}, 1200)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="callback-wrap">
|
||||
<el-card shadow="never" class="callback-card">
|
||||
<el-skeleton :rows="3" animated />
|
||||
<div class="callback-text">{{ statusText }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.callback-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: #f6f7fb;
|
||||
}
|
||||
|
||||
.callback-card {
|
||||
width: min(420px, 94vw);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.callback-text {
|
||||
margin-top: 12px;
|
||||
color: var(--app-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
import {
|
||||
createAdminSocialLoginUrl,
|
||||
createAdminPasskeyOptions,
|
||||
createAdminPasskeyVerify,
|
||||
fetchAdminSocialBindings,
|
||||
deleteAdminPasskey,
|
||||
fetchAdminPasskeys,
|
||||
logout,
|
||||
pollAdminSocialLogin,
|
||||
reportAdminPasskeyClientError,
|
||||
unbindAdminSocial,
|
||||
updateAdminPassword,
|
||||
updateAdminUsername,
|
||||
} from '../api/admin'
|
||||
@@ -26,6 +31,23 @@ const passkeyItems = ref([])
|
||||
const passkeyRegisterOptions = ref(null)
|
||||
const passkeyRegisterOptionsAt = ref(0)
|
||||
const PASSKEY_OPTIONS_PREFETCH_MAX_AGE_MS = 240000
|
||||
const socialBindingsLoading = ref(false)
|
||||
const socialBindLoadingProvider = ref('')
|
||||
const socialBindings = ref([])
|
||||
const socialEnabled = ref(false)
|
||||
const qrOpen = ref(false)
|
||||
const qrValue = ref('')
|
||||
const qrProvider = ref('wx')
|
||||
let socialPollTimer = null
|
||||
let socialPollStartedAt = 0
|
||||
|
||||
const providerLabels = {
|
||||
qq: 'QQ',
|
||||
wx: '微信',
|
||||
alipay: '支付宝',
|
||||
}
|
||||
|
||||
const visibleSocialBindings = computed(() => socialBindings.value.filter((item) => providerLabels[item.provider]))
|
||||
|
||||
function validateStrongPassword(value) {
|
||||
const text = String(value || '')
|
||||
@@ -233,8 +255,137 @@ async function removePasskey(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function socialBindRedirectUri() {
|
||||
const url = new URL(window.location.href)
|
||||
url.pathname = '/yuyx/admin-social-bind-callback'
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function socialProviderIcon(provider) {
|
||||
if (provider === 'wx') return '微'
|
||||
if (provider === 'qq') return 'Q'
|
||||
return '支'
|
||||
}
|
||||
|
||||
function socialQrPrompt(provider) {
|
||||
if (provider === 'wx') return '请使用微信扫描二维码完成绑定'
|
||||
if (provider === 'qq') return '请使用 QQ 扫描二维码完成绑定'
|
||||
return '请使用支付宝扫描二维码完成绑定'
|
||||
}
|
||||
|
||||
function stopSocialPolling() {
|
||||
if (socialPollTimer) {
|
||||
window.clearTimeout(socialPollTimer)
|
||||
socialPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function closeSocialQr() {
|
||||
stopSocialPolling()
|
||||
qrOpen.value = false
|
||||
qrValue.value = ''
|
||||
}
|
||||
|
||||
function scheduleSocialPoll(provider, state, intervalSeconds) {
|
||||
stopSocialPolling()
|
||||
socialPollStartedAt = Date.now()
|
||||
const tick = async () => {
|
||||
if (Date.now() - socialPollStartedAt > 5 * 60 * 1000) {
|
||||
closeSocialQr()
|
||||
ElMessage.warning('二维码已过期,请重新获取')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await pollAdminSocialLogin({ provider, state })
|
||||
if (result?.status === 'authorized' && result?.url) {
|
||||
closeSocialQr()
|
||||
window.location.assign(result.url)
|
||||
return
|
||||
}
|
||||
socialPollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
|
||||
} catch (error) {
|
||||
closeSocialQr()
|
||||
const data = error?.response?.data
|
||||
ElMessage.error(data?.error || '扫码状态获取失败,请重新尝试')
|
||||
}
|
||||
}
|
||||
socialPollTimer = window.setTimeout(tick, Math.max(Number(intervalSeconds || 2), 2) * 1000)
|
||||
}
|
||||
|
||||
async function loadSocialBindings() {
|
||||
socialBindingsLoading.value = true
|
||||
try {
|
||||
const data = await fetchAdminSocialBindings()
|
||||
socialEnabled.value = Boolean(data?.enabled)
|
||||
socialBindings.value = Array.isArray(data?.items) ? data.items : []
|
||||
} catch {
|
||||
socialEnabled.value = false
|
||||
socialBindings.value = []
|
||||
} finally {
|
||||
socialBindingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bindSocial(item) {
|
||||
const provider = String(item?.provider || '').trim()
|
||||
if (!provider || socialBindLoadingProvider.value) return
|
||||
socialBindLoadingProvider.value = provider
|
||||
try {
|
||||
const data = await createAdminSocialLoginUrl({
|
||||
provider,
|
||||
redirect_uri: socialBindRedirectUri(),
|
||||
})
|
||||
if (provider !== 'wx') {
|
||||
window.location.assign(data.url)
|
||||
return
|
||||
}
|
||||
const value = data.scan_url || data.qrcode || data.url
|
||||
if (!value || !data.scan_state) {
|
||||
ElMessage.error('微信二维码获取失败')
|
||||
return
|
||||
}
|
||||
qrProvider.value = provider
|
||||
qrValue.value = value
|
||||
qrOpen.value = true
|
||||
scheduleSocialPoll(provider, data.scan_state, data.scan_poll_interval || 2)
|
||||
} catch (error) {
|
||||
const data = error?.response?.data
|
||||
ElMessage.error(data?.error || '获取聚合登录地址失败')
|
||||
} finally {
|
||||
socialBindLoadingProvider.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function unbindSocial(item) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定解绑${item?.provider_label || '快捷登录'}吗?`, '解绑快捷登录', {
|
||||
confirmButtonText: '解绑',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await unbindAdminSocial(item.provider)
|
||||
ElMessage.success('已解绑')
|
||||
await loadSocialBindings()
|
||||
} catch (error) {
|
||||
const data = error?.response?.data
|
||||
ElMessage.error(data?.error || '解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPasskeys()
|
||||
loadSocialBindings()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopSocialPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -331,6 +482,66 @@ onMounted(() => {
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
||||
<div class="section-head">
|
||||
<h3 class="section-title">快捷登录绑定</h3>
|
||||
<el-tag v-if="socialEnabled" size="small" type="success">已启用</el-tag>
|
||||
<el-tag v-else size="small" type="info">未启用</el-tag>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="!socialEnabled"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="聚合登录未启用,请先在系统配置中填写并启用 Space 聚合登录。"
|
||||
show-icon
|
||||
class="help-alert"
|
||||
/>
|
||||
|
||||
<div v-loading="socialBindingsLoading">
|
||||
<el-empty v-if="visibleSocialBindings.length === 0" description="暂无可绑定的快捷登录方式" />
|
||||
<div v-else class="social-list">
|
||||
<div v-for="item in visibleSocialBindings" :key="item.provider" class="social-row">
|
||||
<div class="social-provider">
|
||||
<span class="social-icon" :class="`provider-${item.provider}`">{{ socialProviderIcon(item.provider) }}</span>
|
||||
<div class="social-info">
|
||||
<strong>{{ item.provider_label }}</strong>
|
||||
<span v-if="item.bound">{{ item.nickname || '已绑定' }}</span>
|
||||
<span v-else>未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="social-actions">
|
||||
<el-button
|
||||
v-if="item.bound"
|
||||
type="danger"
|
||||
text
|
||||
@click="unbindSocial(item)"
|
||||
>
|
||||
解绑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!socialEnabled"
|
||||
:loading="socialBindLoadingProvider === item.provider"
|
||||
@click="bindSocial(item)"
|
||||
>
|
||||
绑定
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="qrOpen" :title="`${providerLabels[qrProvider]}绑定`" width="min(340px, 92vw)" @close="closeSocialQr">
|
||||
<div class="social-qr-box">
|
||||
<QrcodeVue v-if="qrValue" :value="qrValue" :size="220" level="M" />
|
||||
<div class="social-qr-prompt">{{ socialQrPrompt(qrProvider) }}</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -356,6 +567,18 @@ onMounted(() => {
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-head .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.help {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
@@ -365,4 +588,104 @@ onMounted(() => {
|
||||
.help-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.social-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.social-row {
|
||||
min-height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--app-border);
|
||||
border-radius: 10px;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
}
|
||||
|
||||
.social-provider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.provider-wx {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.provider-qq {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.provider-alipay {
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
.social-info {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.social-info strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.social-info span {
|
||||
max-width: min(52vw, 360px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--app-muted);
|
||||
}
|
||||
|
||||
.social-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.social-qr-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.social-qr-prompt {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.social-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.social-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,8 +11,10 @@ const EmailPage = () => import('../pages/EmailPage.vue')
|
||||
const SecurityPage = () => import('../pages/SecurityPage.vue')
|
||||
const SystemPage = () => import('../pages/SystemPage.vue')
|
||||
const SettingsPage = () => import('../pages/SettingsPage.vue')
|
||||
const AdminSocialBindCallbackPage = () => import('../pages/AdminSocialBindCallbackPage.vue')
|
||||
|
||||
const routes = [
|
||||
{ path: '/social-bind-callback', name: 'admin_social_bind_callback', component: AdminSocialBindCallbackPage },
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
|
||||
Reference in New Issue
Block a user