232 lines
9.3 KiB
Vue
232 lines
9.3 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<Transition
|
|
enter-active-class="transition duration-300 ease-out"
|
|
enter-from-class="opacity-0 scale-95"
|
|
enter-to-class="opacity-100 scale-100"
|
|
leave-active-class="transition duration-200 ease-in"
|
|
leave-from-class="opacity-100 scale-100"
|
|
leave-to-class="opacity-0 scale-95"
|
|
>
|
|
<div
|
|
v-if="show"
|
|
class="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
|
|
@click="$emit('close')"
|
|
>
|
|
<div
|
|
class="bg-bg-secondary border border-border-secondary w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
|
|
@click.stop
|
|
>
|
|
<div
|
|
class="p-8 pb-4 flex items-center justify-between border-b border-border-secondary-50"
|
|
>
|
|
<div>
|
|
<h3
|
|
class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3"
|
|
>
|
|
<div
|
|
class="w-10 h-10 rounded-xl bg-success-10 flex items-center justify-center text-success"
|
|
>
|
|
<Link :size="20" />
|
|
</div>
|
|
{{ locale.oauthBindingsTitle }}
|
|
</h3>
|
|
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.oauthBindingsDesc }}</p>
|
|
</div>
|
|
<button
|
|
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all"
|
|
@click="$emit('close')"
|
|
>
|
|
<X :size="20" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto p-8 pt-6 custom-scrollbar">
|
|
<div
|
|
v-if="identities.length === 0"
|
|
class="py-16 flex flex-col items-center justify-center gap-3 text-text-disabled"
|
|
>
|
|
<Link :size="28" class="opacity-60" />
|
|
<p class="text-xs font-medium">{{ locale.oauthBindingsEmpty }}</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<div
|
|
v-for="(identity, index) in identities"
|
|
:key="identity.id ?? `${identity.provider}-${identity.providerUserId}-${index}`"
|
|
class="p-5 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-4"
|
|
>
|
|
<div class="flex items-center gap-4">
|
|
<div
|
|
class="w-11 h-11 rounded-xl bg-bg-primary border border-border-secondary flex items-center justify-center text-text-secondary shrink-0 overflow-hidden"
|
|
>
|
|
<AuthProvidersGitHubIcon
|
|
v-if="identity.provider === 'github'"
|
|
class="w-5 h-5"
|
|
/>
|
|
<AuthProvidersCasdoorIcon
|
|
v-else-if="identity.provider === 'casdoor'"
|
|
class="w-5 h-5"
|
|
/>
|
|
<AuthProvidersGoogleIcon
|
|
v-else-if="identity.provider === 'google'"
|
|
class="w-5 h-5"
|
|
/>
|
|
<Icon
|
|
v-else-if="isAggregateProvider(identity.provider)"
|
|
:name="
|
|
getAggregateOAuthLoginTypeIcon(getAggregateLoginType(identity.provider))
|
|
"
|
|
:size="22"
|
|
/>
|
|
<Fingerprint v-else-if="identity.provider === 'webauthn'" :size="20" />
|
|
<ShieldCheck v-else-if="identity.provider === 'totp'" :size="20" />
|
|
<Link v-else :size="20" />
|
|
</div>
|
|
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-black text-text-primary truncate">
|
|
{{ getOAuthProviderName(identity.provider) }}
|
|
</div>
|
|
<div class="text-xs text-text-tertiary mt-0.5 truncate">
|
|
{{ getIdentityDisplayName(identity) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="w-12 h-12 rounded-xl bg-bg-primary border border-border-secondary overflow-hidden flex items-center justify-center text-text-secondary shrink-0"
|
|
>
|
|
<img
|
|
v-if="identity.avatar && !failedAvatarIds.includes(identity.id)"
|
|
:src="identity.avatar"
|
|
:alt="getIdentityDisplayName(identity)"
|
|
class="w-full h-full object-cover"
|
|
loading="lazy"
|
|
@error="markAvatarFailed(identity.id)"
|
|
>
|
|
<Icon v-else name="user" :size="20" class="text-text-disabled" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
|
|
<div
|
|
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
|
|
>
|
|
{{ locale.oauthProvider }}
|
|
</div>
|
|
<div class="text-xs font-bold text-text-secondary mt-1 break-all">
|
|
{{ getOAuthProviderName(identity.provider) }}
|
|
</div>
|
|
</div>
|
|
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
|
|
<div
|
|
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
|
|
>
|
|
{{ locale.oauthUsername }}
|
|
</div>
|
|
<div class="text-xs font-bold text-text-secondary mt-1 break-all">
|
|
{{ getIdentityDisplayName(identity) }}
|
|
</div>
|
|
</div>
|
|
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
|
|
<div
|
|
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
|
|
>
|
|
{{ locale.oauthUserId }}
|
|
</div>
|
|
<div class="text-xs font-bold text-text-secondary mt-1 break-all">
|
|
{{ getIdentityUserId(identity) }}
|
|
</div>
|
|
</div>
|
|
<div class="p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30">
|
|
<div
|
|
class="text-[10px] font-black text-text-disabled uppercase tracking-tighter"
|
|
>
|
|
{{ locale.oauthBoundAt }}
|
|
</div>
|
|
<div class="text-xs font-bold text-text-secondary mt-1">
|
|
{{ formatDateTime(identity.createdAt) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref } from 'vue'
|
|
import { Fingerprint, Link, ShieldCheck, X } from '@lucide/vue'
|
|
import Icon from '~/components/UI/Icon.vue'
|
|
import AuthProvidersCasdoorIcon from '~/components/Auth/Providers/Casdoor/Icon.vue'
|
|
import AuthProvidersGitHubIcon from '~/components/Auth/Providers/GitHub/Icon.vue'
|
|
import AuthProvidersGoogleIcon from '~/components/Auth/Providers/Google/Icon.vue'
|
|
import { getAggregateOAuthLoginTypeIcon, getOAuthProviderName } from '~/utils/oauth'
|
|
import { useLocale } from '~/utils/locale'
|
|
|
|
defineProps({
|
|
show: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
identities: {
|
|
type: Array,
|
|
default: () => []
|
|
}
|
|
})
|
|
|
|
defineEmits(['close'])
|
|
|
|
const { admin, currentLocale } = useLocale()
|
|
const locale = computed(() => admin.value?.userManager?.detail || {})
|
|
const failedAvatarIds = ref([])
|
|
|
|
const isAggregateProvider = (provider) =>
|
|
String(provider || '')
|
|
.toLowerCase()
|
|
.startsWith('aggregate:')
|
|
const getAggregateLoginType = (provider) => String(provider || '').replace(/^aggregate:/i, '')
|
|
|
|
const getIdentityDisplayName = (identity) => {
|
|
if (identity.provider === 'webauthn') {
|
|
try {
|
|
const data = JSON.parse(identity.providerUsername)
|
|
return data?.label || locale.value.oauthUnknownDevice || '未知设备'
|
|
} catch {
|
|
return locale.value.oauthUnknownDevice || '未知设备'
|
|
}
|
|
}
|
|
if (identity.provider === 'totp') return locale.value.oauthTotpLabel || '双重认证'
|
|
return identity.providerUsername || locale.value.oauthNoUsername || '未提供用户名'
|
|
}
|
|
|
|
const getIdentityUserId = (identity) => {
|
|
if (identity.provider === 'totp') return locale.value.oauthIdHidden || '已隐藏'
|
|
return identity.providerUserId || locale.value.oauthNoId || '未提供 ID'
|
|
}
|
|
|
|
const markAvatarFailed = (id) => {
|
|
if (id == null || failedAvatarIds.value.includes(id)) return
|
|
failedAvatarIds.value = [...failedAvatarIds.value, id]
|
|
}
|
|
|
|
const formatDateTime = (value) => {
|
|
if (!value) return locale.value.oauthUnknownTime || '未知时间'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return locale.value.oauthUnknownTime || '未知时间'
|
|
return new Intl.DateTimeFormat(currentLocale.value, {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
}).format(date)
|
|
}
|
|
</script>
|