feat: add profile auth identity binding flow
This commit is contained in:
@@ -12,6 +12,8 @@ describe('oauth adoption auth api', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: {} })
|
||||
localStorage.clear()
|
||||
document.cookie = 'oauth_bind_access_token=; Max-Age=0; path=/'
|
||||
})
|
||||
|
||||
it('posts adoption decisions when exchanging pending oauth completion', async () => {
|
||||
@@ -57,4 +59,43 @@ describe('oauth adoption auth api', () => {
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('posts wechat invitation completion with adoption decisions', async () => {
|
||||
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeWeChatOAuthRegistration('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies oauth completion results as login or bind', async () => {
|
||||
const { getOAuthCompletionKind } = await import('@/api/auth')
|
||||
|
||||
expect(getOAuthCompletionKind({ access_token: 'access-token' })).toBe('login')
|
||||
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
|
||||
})
|
||||
|
||||
it('prepares an oauth bind access token cookie before redirect binding', async () => {
|
||||
localStorage.setItem('auth_token', 'access-token-value')
|
||||
const setCookie = vi.fn()
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
configurable: true,
|
||||
get: () => '',
|
||||
set: setCookie
|
||||
})
|
||||
|
||||
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
|
||||
|
||||
prepareOAuthBindAccessTokenCookie()
|
||||
|
||||
expect(setCookie).toHaveBeenCalledTimes(1)
|
||||
expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,11 +186,14 @@ export interface RefreshTokenResponse {
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface PendingOAuthExchangeResponse {
|
||||
access_token?: string
|
||||
export interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse> {
|
||||
redirect?: string
|
||||
error?: string
|
||||
adoption_required?: boolean
|
||||
@@ -198,6 +201,8 @@ export interface PendingOAuthExchangeResponse {
|
||||
suggested_avatar_url?: string
|
||||
}
|
||||
|
||||
export type OAuthCompletionKind = 'login' | 'bind'
|
||||
|
||||
export interface OAuthAdoptionDecision {
|
||||
adoptDisplayName?: boolean
|
||||
adoptAvatar?: boolean
|
||||
@@ -218,6 +223,56 @@ function serializeOAuthAdoptionDecision(
|
||||
return payload
|
||||
}
|
||||
|
||||
export function isOAuthLoginCompletion(
|
||||
completion: Partial<OAuthTokenResponse>
|
||||
): completion is OAuthTokenResponse {
|
||||
return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0
|
||||
}
|
||||
|
||||
export function getOAuthCompletionKind(
|
||||
completion: Partial<OAuthTokenResponse>
|
||||
): OAuthCompletionKind {
|
||||
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
|
||||
}
|
||||
|
||||
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
|
||||
if (tokens.refresh_token) {
|
||||
setRefreshToken(tokens.refresh_token)
|
||||
}
|
||||
if (tokens.expires_in) {
|
||||
setTokenExpiresAt(tokens.expires_in)
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareOAuthBindAccessTokenCookie(): void {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
const path = resolveOAuthBindCookiePath()
|
||||
document.cookie =
|
||||
`oauth_bind_access_token=${encodeURIComponent(token)}; Path=${path}/auth/oauth; Max-Age=600; SameSite=Lax${secure}`
|
||||
}
|
||||
|
||||
function resolveOAuthBindCookiePath(): string {
|
||||
const apiBase = ((import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1').replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
return new URL(apiBase, window.location.origin).pathname.replace(/\/$/, '') || '/api/v1'
|
||||
} catch {
|
||||
if (apiBase.startsWith('/')) {
|
||||
return apiBase
|
||||
}
|
||||
return '/api/v1'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token
|
||||
* @returns New token pair
|
||||
@@ -375,13 +430,8 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
|
||||
export async function completeLinuxDoOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/linuxdo/complete-registration', {
|
||||
): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
@@ -396,13 +446,19 @@ export async function completeLinuxDoOAuthRegistration(
|
||||
export async function completeOIDCOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/oidc/complete-registration', {
|
||||
): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function completeWeChatOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
@@ -444,7 +500,8 @@ export const authAPI = {
|
||||
revokeAllSessions,
|
||||
exchangePendingOAuthCompletion,
|
||||
completeLinuxDoOAuthRegistration,
|
||||
completeOIDCOAuthRegistration
|
||||
completeOIDCOAuthRegistration,
|
||||
completeWeChatOAuthRegistration
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
|
||||
import { prepareOAuthBindAccessTokenCookie } from './auth'
|
||||
import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types'
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
@@ -83,6 +84,49 @@ export async function toggleNotifyEmail(email: string, disabled: boolean): Promi
|
||||
return data
|
||||
}
|
||||
|
||||
export type BindableOAuthProvider = Exclude<UserAuthProvider, 'email'>
|
||||
|
||||
interface BuildOAuthBindingStartURLOptions {
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function resolveWeChatOAuthMode(): 'open' | 'mp' {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'open'
|
||||
}
|
||||
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
|
||||
}
|
||||
|
||||
export function buildOAuthBindingStartURL(
|
||||
provider: BindableOAuthProvider,
|
||||
options: BuildOAuthBindingStartURLOptions = {}
|
||||
): string {
|
||||
const redirectTo = options.redirectTo?.trim() || '/profile'
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const params = new URLSearchParams({
|
||||
redirect: redirectTo,
|
||||
intent: 'bind_current_user'
|
||||
})
|
||||
|
||||
if (provider === 'wechat') {
|
||||
params.set('mode', resolveWeChatOAuthMode())
|
||||
}
|
||||
|
||||
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||
}
|
||||
|
||||
export function startOAuthBinding(
|
||||
provider: BindableOAuthProvider,
|
||||
options: BuildOAuthBindingStartURLOptions = {}
|
||||
): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
prepareOAuthBindAccessTokenCookie()
|
||||
window.location.href = buildOAuthBindingStartURL(provider, options)
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
@@ -90,7 +134,9 @@ export const userAPI = {
|
||||
sendNotifyEmailCode,
|
||||
verifyNotifyEmail,
|
||||
removeNotifyEmail,
|
||||
toggleNotifyEmail
|
||||
toggleNotifyEmail,
|
||||
buildOAuthBindingStartURL,
|
||||
startOAuthBinding
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="rounded-2xl border border-gray-100 bg-gray-50/80 p-4 dark:border-dark-700 dark:bg-dark-900/30">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('profile.authBindings.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('profile.authBindings.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="item in providerItems"
|
||||
:key="item.provider"
|
||||
class="flex items-center justify-between gap-3 rounded-xl bg-white/80 px-3 py-2.5 dark:bg-dark-800/70"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
:data-testid="`profile-binding-${item.provider}-status`"
|
||||
:class="['badge', item.bound ? 'badge-success' : 'badge-gray']"
|
||||
>
|
||||
{{
|
||||
item.bound
|
||||
? t('profile.authBindings.status.bound')
|
||||
: t('profile.authBindings.status.notBound')
|
||||
}}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="item.canBind"
|
||||
:data-testid="`profile-binding-${item.provider}-action`"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="startBinding(item.provider)"
|
||||
>
|
||||
{{ t('profile.authBindings.bindAction', { providerName: item.label }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { startOAuthBinding } from '@/api/user'
|
||||
import type { User, UserAuthBindingStatus, UserAuthProvider } from '@/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user: User | null
|
||||
linuxdoEnabled?: boolean
|
||||
oidcEnabled?: boolean
|
||||
oidcProviderName?: string
|
||||
wechatEnabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
linuxdoEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: 'OIDC',
|
||||
wechatEnabled: false,
|
||||
}
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null {
|
||||
if (typeof binding === 'boolean') {
|
||||
return binding
|
||||
}
|
||||
if (!binding) {
|
||||
return null
|
||||
}
|
||||
if (typeof binding.bound === 'boolean') {
|
||||
return binding.bound
|
||||
}
|
||||
return Boolean(binding.provider_subject || binding.issuer || binding.provider_key)
|
||||
}
|
||||
|
||||
function getBindingStatus(provider: UserAuthProvider): boolean {
|
||||
const currentUser = props.user
|
||||
|
||||
if (provider === 'email') {
|
||||
return typeof currentUser?.email_bound === 'boolean'
|
||||
? currentUser.email_bound
|
||||
: Boolean(currentUser?.email)
|
||||
}
|
||||
|
||||
const directFlag = currentUser?.[`${provider}_bound` as keyof User]
|
||||
if (typeof directFlag === 'boolean') {
|
||||
return directFlag
|
||||
}
|
||||
|
||||
const nested = currentUser?.auth_bindings?.[provider] ?? currentUser?.identity_bindings?.[provider]
|
||||
const normalized = normalizeBindingStatus(nested)
|
||||
return normalized ?? false
|
||||
}
|
||||
|
||||
const providerItems = computed(() => [
|
||||
{
|
||||
provider: 'email' as const,
|
||||
label: t('profile.authBindings.providers.email'),
|
||||
bound: getBindingStatus('email'),
|
||||
canBind: false,
|
||||
},
|
||||
{
|
||||
provider: 'linuxdo' as const,
|
||||
label: t('profile.authBindings.providers.linuxdo'),
|
||||
bound: getBindingStatus('linuxdo'),
|
||||
canBind: props.linuxdoEnabled && !getBindingStatus('linuxdo'),
|
||||
},
|
||||
{
|
||||
provider: 'oidc' as const,
|
||||
label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||
bound: getBindingStatus('oidc'),
|
||||
canBind: props.oidcEnabled && !getBindingStatus('oidc'),
|
||||
},
|
||||
{
|
||||
provider: 'wechat' as const,
|
||||
label: t('profile.authBindings.providers.wechat'),
|
||||
bound: getBindingStatus('wechat'),
|
||||
canBind: props.wechatEnabled && !getBindingStatus('wechat'),
|
||||
},
|
||||
])
|
||||
|
||||
function startBinding(provider: UserAuthProvider): void {
|
||||
if (provider === 'email') {
|
||||
return
|
||||
}
|
||||
startOAuthBinding(provider, {
|
||||
redirectTo: route.fullPath || '/profile',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -4,11 +4,16 @@
|
||||
class="border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
|
||||
class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
|
||||
>
|
||||
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
:alt="displayName"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span v-else>{{ avatarInitial }}</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
|
||||
@@ -41,18 +46,163 @@
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sourceHints.length"
|
||||
class="mt-4 grid gap-2 rounded-2xl border border-gray-100 bg-gray-50/80 p-3 text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-900/30 dark:text-gray-400"
|
||||
>
|
||||
<div
|
||||
v-for="hint in sourceHints"
|
||||
:key="hint.key"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<Icon name="link" size="sm" class="mt-0.5 text-gray-400 dark:text-gray-500" />
|
||||
<span>{{ hint.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileIdentityBindingsSection
|
||||
class="mt-4"
|
||||
:user="user"
|
||||
:linuxdo-enabled="linuxdoEnabled"
|
||||
:oidc-enabled="oidcEnabled"
|
||||
:oidc-provider-name="oidcProviderName"
|
||||
:wechat-enabled="wechatEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { User } from '@/types'
|
||||
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue'
|
||||
import type { User, UserAuthProvider, UserProfileSourceContext } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
user: User | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
user: User | null
|
||||
linuxdoEnabled?: boolean
|
||||
oidcEnabled?: boolean
|
||||
oidcProviderName?: string
|
||||
wechatEnabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
linuxdoEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: 'OIDC',
|
||||
wechatEnabled: false,
|
||||
}
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
||||
email: t('profile.authBindings.providers.email'),
|
||||
linuxdo: t('profile.authBindings.providers.linuxdo'),
|
||||
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
|
||||
wechat: t('profile.authBindings.providers.wechat'),
|
||||
}))
|
||||
|
||||
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
|
||||
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || 'User')
|
||||
const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
|
||||
|
||||
function normalizeProvider(value: string): UserAuthProvider | null {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') {
|
||||
return normalized
|
||||
}
|
||||
if (normalized === 'oidc' || normalized.startsWith('oidc:') || normalized.startsWith('oidc/')) {
|
||||
return 'oidc'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function readObjectString(source: Record<string, unknown>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveThirdPartySource(
|
||||
rawSource: string | UserProfileSourceContext | null | undefined
|
||||
): { provider: UserAuthProvider; label: string } | null {
|
||||
if (!rawSource) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof rawSource === 'string') {
|
||||
const provider = normalizeProvider(rawSource)
|
||||
if (!provider || provider === 'email') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
label: providerLabels.value[provider],
|
||||
}
|
||||
}
|
||||
|
||||
const sourceRecord = rawSource as Record<string, unknown>
|
||||
const provider = normalizeProvider(
|
||||
readObjectString(sourceRecord, 'provider', 'source', 'provider_type', 'auth_provider')
|
||||
)
|
||||
if (!provider || provider === 'email') {
|
||||
return null
|
||||
}
|
||||
|
||||
const explicitLabel = readObjectString(
|
||||
sourceRecord,
|
||||
'provider_label',
|
||||
'label',
|
||||
'provider_name',
|
||||
'providerName'
|
||||
)
|
||||
|
||||
return {
|
||||
provider,
|
||||
label: explicitLabel || providerLabels.value[provider],
|
||||
}
|
||||
}
|
||||
|
||||
const sourceHints = computed(() => {
|
||||
const currentUser = props.user
|
||||
if (!currentUser) {
|
||||
return []
|
||||
}
|
||||
|
||||
const hints: Array<{ key: string; text: string }> = []
|
||||
const avatarSource = resolveThirdPartySource(
|
||||
currentUser.profile_sources?.avatar ?? currentUser.avatar_source
|
||||
)
|
||||
const usernameSource = resolveThirdPartySource(
|
||||
currentUser.profile_sources?.username ??
|
||||
currentUser.profile_sources?.display_name ??
|
||||
currentUser.profile_sources?.nickname ??
|
||||
currentUser.display_name_source ??
|
||||
currentUser.username_source ??
|
||||
currentUser.nickname_source
|
||||
)
|
||||
|
||||
if (avatarSource) {
|
||||
hints.push({
|
||||
key: 'avatar',
|
||||
text: t('profile.authBindings.source.avatar', { providerName: avatarSource.label }),
|
||||
})
|
||||
}
|
||||
|
||||
if (usernameSource) {
|
||||
hints.push({
|
||||
key: 'username',
|
||||
text: t('profile.authBindings.source.username', { providerName: usernameSource.label }),
|
||||
})
|
||||
}
|
||||
|
||||
return hints
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
fullPath: '/profile',
|
||||
}))
|
||||
|
||||
const locationState = vi.hoisted(() => ({
|
||||
current: { href: 'http://localhost/profile' } as { href: string },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'profile.authBindings.title') return 'Connected sign-in methods'
|
||||
if (key === 'profile.authBindings.description') return 'Manage bound providers'
|
||||
if (key === 'profile.authBindings.status.bound') return 'Bound'
|
||||
if (key === 'profile.authBindings.status.notBound') return 'Not bound'
|
||||
if (key === 'profile.authBindings.providers.email') return 'Email'
|
||||
if (key === 'profile.authBindings.providers.linuxdo') return 'LinuxDo'
|
||||
if (key === 'profile.authBindings.providers.wechat') return 'WeChat'
|
||||
if (key === 'profile.authBindings.providers.oidc') return params?.providerName || 'OIDC'
|
||||
if (key === 'profile.authBindings.bindAction') return `Bind ${params?.providerName || ''}`.trim()
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
function createUser(overrides: Partial<User> = {}): User {
|
||||
return {
|
||||
id: 7,
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
role: 'user',
|
||||
balance: 10,
|
||||
concurrency: 2,
|
||||
status: 'active',
|
||||
allowed_groups: null,
|
||||
balance_notify_enabled: true,
|
||||
balance_notify_threshold: null,
|
||||
balance_notify_extra_emails: [],
|
||||
created_at: '2026-04-20T00:00:00Z',
|
||||
updated_at: '2026-04-20T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProfileIdentityBindingsSection', () => {
|
||||
beforeEach(() => {
|
||||
routeState.fullPath = '/profile'
|
||||
locationState.current = { href: 'http://localhost/profile' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: locationState.current,
|
||||
})
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('renders provider binding states and provider-specific bind actions', () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
props: {
|
||||
user: createUser({
|
||||
auth_bindings: {
|
||||
email: { bound: true },
|
||||
linuxdo: { bound: true },
|
||||
oidc: { bound: false },
|
||||
wechat: false,
|
||||
},
|
||||
}),
|
||||
linuxdoEnabled: true,
|
||||
oidcEnabled: true,
|
||||
oidcProviderName: 'ExampleID',
|
||||
wechatEnabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Bound')
|
||||
expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Bound')
|
||||
expect(wrapper.get('[data-testid="profile-binding-oidc-status"]').text()).toBe('Not bound')
|
||||
expect(wrapper.get('[data-testid="profile-binding-oidc-action"]').text()).toBe(
|
||||
'Bind ExampleID'
|
||||
)
|
||||
expect(wrapper.get('[data-testid="profile-binding-wechat-action"]').text()).toBe('Bind WeChat')
|
||||
})
|
||||
|
||||
it('starts the WeChat bind flow for the current profile page', async () => {
|
||||
const wrapper = mount(ProfileIdentityBindingsSection, {
|
||||
props: {
|
||||
user: createUser(),
|
||||
linuxdoEnabled: false,
|
||||
oidcEnabled: false,
|
||||
wechatEnabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
|
||||
expect(locationState.current.href).toContain('mode=open')
|
||||
expect(locationState.current.href).toContain('intent=bind_current_user')
|
||||
expect(locationState.current.href).toContain('redirect=%2Fprofile')
|
||||
})
|
||||
})
|
||||
@@ -940,6 +940,26 @@ export default {
|
||||
maxEmailsReached: 'Maximum number of notification emails reached',
|
||||
unverified: 'Unverified',
|
||||
verified: 'Verified',
|
||||
},
|
||||
authBindings: {
|
||||
title: 'Connected Sign-In Methods',
|
||||
description: 'View current bindings and connect another provider to this account.',
|
||||
bindAction: 'Bind {providerName}',
|
||||
bindSuccess: 'Account linked successfully',
|
||||
status: {
|
||||
bound: 'Bound',
|
||||
notBound: 'Not bound',
|
||||
},
|
||||
providers: {
|
||||
email: 'Email',
|
||||
linuxdo: 'LinuxDo',
|
||||
oidc: '{providerName}',
|
||||
wechat: 'WeChat',
|
||||
},
|
||||
source: {
|
||||
avatar: 'Avatar is currently synced from {providerName}',
|
||||
username: 'Nickname is currently synced from {providerName}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -944,6 +944,26 @@ export default {
|
||||
maxEmailsReached: '已达到通知邮箱数量上限',
|
||||
unverified: '未验证',
|
||||
verified: '已验证',
|
||||
},
|
||||
authBindings: {
|
||||
title: '登录方式绑定',
|
||||
description: '查看当前绑定状态,并将更多第三方登录方式关联到这个账号。',
|
||||
bindAction: '绑定 {providerName}',
|
||||
bindSuccess: '账号绑定成功',
|
||||
status: {
|
||||
bound: '已绑定',
|
||||
notBound: '未绑定',
|
||||
},
|
||||
providers: {
|
||||
email: '邮箱',
|
||||
linuxdo: 'LinuxDo',
|
||||
oidc: '{providerName}',
|
||||
wechat: '微信',
|
||||
},
|
||||
source: {
|
||||
avatar: '头像当前来自 {providerName}',
|
||||
username: '昵称当前来自 {providerName}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -34,10 +34,47 @@ export interface NotifyEmailEntry {
|
||||
|
||||
// ==================== User & Auth Types ====================
|
||||
|
||||
export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
|
||||
|
||||
export interface UserAuthBindingStatus {
|
||||
bound?: boolean
|
||||
provider?: UserAuthProvider | string
|
||||
provider_key?: string | null
|
||||
provider_subject?: string | null
|
||||
issuer?: string | null
|
||||
label?: string | null
|
||||
provider_label?: string | null
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UserProfileSourceContext {
|
||||
provider?: UserAuthProvider | string
|
||||
source?: string | null
|
||||
label?: string | null
|
||||
provider_label?: string | null
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar_url?: string | null
|
||||
avatar_source?: string | UserProfileSourceContext | null
|
||||
username_source?: string | UserProfileSourceContext | null
|
||||
display_name_source?: string | UserProfileSourceContext | null
|
||||
nickname_source?: string | UserProfileSourceContext | null
|
||||
profile_sources?: {
|
||||
avatar?: string | UserProfileSourceContext | null
|
||||
username?: string | UserProfileSourceContext | null
|
||||
display_name?: string | UserProfileSourceContext | null
|
||||
nickname?: string | UserProfileSourceContext | null
|
||||
}
|
||||
auth_bindings?: Partial<Record<UserAuthProvider, boolean | UserAuthBindingStatus>>
|
||||
identity_bindings?: Partial<Record<UserAuthProvider, boolean | UserAuthBindingStatus>>
|
||||
email_bound?: boolean
|
||||
linuxdo_bound?: boolean
|
||||
oidc_bound?: boolean
|
||||
wechat_bound?: boolean
|
||||
role: 'admin' | 'user' // User role for authorization
|
||||
balance: number // User balance for API usage
|
||||
concurrency: number // Allowed concurrent requests
|
||||
|
||||
@@ -136,6 +136,9 @@ import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
completeLinuxDoOAuthRegistration,
|
||||
exchangePendingOAuthCompletion,
|
||||
getOAuthCompletionKind,
|
||||
isOAuthLoginCompletion,
|
||||
persistOAuthTokenContext,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
@@ -162,6 +165,7 @@ const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
@@ -209,18 +213,19 @@ function hasSuggestedProfile(completion: {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||
appStore.showSuccess(bindSuccessMessage)
|
||||
await router.replace(bindRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOAuthLoginCompletion(completion)) {
|
||||
throw new Error(t('auth.linuxdo.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
persistOAuthTokenContext(completion)
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
@@ -236,12 +241,7 @@ async function handleSubmitInvitation() {
|
||||
invitationCode.value.trim(),
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
}
|
||||
if (tokenData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||
}
|
||||
persistOAuthTokenContext(tokenData)
|
||||
await authStore.setToken(tokenData.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
@@ -258,7 +258,7 @@ async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeLogin(completion, redirectTo.value)
|
||||
await finalizeCompletion(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
@@ -305,7 +305,7 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
await finalizeCompletion(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
|
||||
@@ -145,7 +145,10 @@ import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
completeOIDCOAuthRegistration,
|
||||
exchangePendingOAuthCompletion,
|
||||
getOAuthCompletionKind,
|
||||
getPublicSettings,
|
||||
isOAuthLoginCompletion,
|
||||
persistOAuthTokenContext,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
@@ -172,6 +175,7 @@ const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
@@ -231,18 +235,19 @@ function hasSuggestedProfile(completion: {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||
appStore.showSuccess(bindSuccessMessage)
|
||||
await router.replace(bindRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOAuthLoginCompletion(completion)) {
|
||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
persistOAuthTokenContext(completion)
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
@@ -258,12 +263,7 @@ async function handleSubmitInvitation() {
|
||||
invitationCode.value.trim(),
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
}
|
||||
if (tokenData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||
}
|
||||
persistOAuthTokenContext(tokenData)
|
||||
await authStore.setToken(tokenData.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
@@ -280,7 +280,7 @@ async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeLogin(completion, redirectTo.value)
|
||||
await finalizeCompletion(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
@@ -329,7 +329,7 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
await finalizeCompletion(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
|
||||
@@ -140,27 +140,16 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
interface PendingOAuthExchangeResponse {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
redirect?: string
|
||||
error?: string
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}
|
||||
import {
|
||||
completeWeChatOAuthRegistration,
|
||||
exchangePendingOAuthCompletion,
|
||||
getOAuthCompletionKind,
|
||||
isOAuthLoginCompletion,
|
||||
persistOAuthTokenContext,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -182,6 +171,7 @@ const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||
|
||||
const providerName = 'WeChat'
|
||||
|
||||
@@ -200,10 +190,10 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
return path
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): Record<string, boolean> {
|
||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||
return {
|
||||
adopt_display_name: adoptDisplayName.value,
|
||||
adopt_avatar: adoptAvatar.value,
|
||||
adoptDisplayName: adoptDisplayName.value,
|
||||
adoptAvatar: adoptAvatar.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,49 +214,35 @@ function hasSuggestedProfile(completion: PendingOAuthExchangeResponse): boolean
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function exchangePendingOAuthCompletion(): Promise<PendingOAuthExchangeResponse> {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>('/auth/oauth/pending/exchange', {})
|
||||
return data
|
||||
}
|
||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||
appStore.showSuccess(bindSuccessMessage)
|
||||
await router.replace(bindRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
if (!isOAuthLoginCompletion(completion)) {
|
||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
persistOAuthTokenContext(completion)
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function completeWeChatOAuthRegistration(invitation: string): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: invitation,
|
||||
...currentAdoptionDecision(),
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeWeChatOAuthRegistration(invitationCode.value.trim())
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
}
|
||||
if (tokenData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||
}
|
||||
const tokenData = await completeWeChatOAuthRegistration(
|
||||
invitationCode.value.trim(),
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
persistOAuthTokenContext(tokenData)
|
||||
await authStore.setToken(tokenData.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
@@ -282,11 +258,8 @@ async function handleSubmitInvitation() {
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>(
|
||||
'/auth/oauth/pending/exchange',
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
await finalizeLogin(data, redirectTo.value)
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeCompletion(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
@@ -333,7 +306,7 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
await finalizeCompletion(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
|
||||
@@ -39,10 +39,14 @@ vi.mock('@/stores', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
|
||||
}))
|
||||
vi.mock('@/api/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||
return {
|
||||
...actual,
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
|
||||
}
|
||||
})
|
||||
|
||||
describe('LinuxDoCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
@@ -132,6 +136,64 @@ describe('LinuxDoCallbackView', () => {
|
||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('treats a completion without token as bind success and returns to profile', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({})
|
||||
|
||||
mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(showSuccess).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||
expect(replace).toHaveBeenCalledWith('/profile')
|
||||
})
|
||||
|
||||
it('supports bind completion after adoption confirmation', async () => {
|
||||
exchangePendingOAuthCompletion
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'LinuxDo Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/profile/security'
|
||||
})
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: true
|
||||
})
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(showSuccess).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||
expect(replace).toHaveBeenCalledWith('/profile/security')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
|
||||
@@ -45,11 +45,15 @@ vi.mock('@/stores', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
|
||||
}))
|
||||
vi.mock('@/api/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||
return {
|
||||
...actual,
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
|
||||
}
|
||||
})
|
||||
|
||||
describe('OidcCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
@@ -143,6 +147,43 @@ describe('OidcCallbackView', () => {
|
||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('supports bind completion after adoption confirmation', async () => {
|
||||
exchangePendingOAuthCompletion
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/profile'
|
||||
})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: true
|
||||
})
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(showSuccess).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||
expect(replace).toHaveBeenCalledWith('/profile')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
|
||||
@@ -3,14 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
||||
|
||||
const {
|
||||
postMock,
|
||||
exchangePendingOAuthCompletionMock,
|
||||
completeWeChatOAuthRegistrationMock,
|
||||
replaceMock,
|
||||
setTokenMock,
|
||||
showSuccessMock,
|
||||
showErrorMock,
|
||||
routeState,
|
||||
} = vi.hoisted(() => ({
|
||||
postMock: vi.fn(),
|
||||
exchangePendingOAuthCompletionMock: vi.fn(),
|
||||
completeWeChatOAuthRegistrationMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
setTokenMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
@@ -86,15 +88,19 @@ vi.mock('@/stores', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post: postMock,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/api/auth', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/auth')>('@/api/auth')
|
||||
return {
|
||||
...actual,
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletionMock(...args),
|
||||
completeWeChatOAuthRegistration: (...args: any[]) => completeWeChatOAuthRegistrationMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
describe('WechatCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
postMock.mockReset()
|
||||
exchangePendingOAuthCompletionMock.mockReset()
|
||||
completeWeChatOAuthRegistrationMock.mockReset()
|
||||
replaceMock.mockReset()
|
||||
setTokenMock.mockReset()
|
||||
showSuccessMock.mockReset()
|
||||
@@ -104,14 +110,12 @@ describe('WechatCallbackView', () => {
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
postMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
},
|
||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
})
|
||||
setTokenMock.mockResolvedValue({})
|
||||
|
||||
@@ -128,28 +132,24 @@ describe('WechatCallbackView', () => {
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {})
|
||||
expect(postMock).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledWith()
|
||||
expect(exchangePendingOAuthCompletionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||
postMock
|
||||
exchangePendingOAuthCompletionMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
},
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'wechat-access-token',
|
||||
refresh_token: 'wechat-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
access_token: 'wechat-access-token',
|
||||
refresh_token: 'wechat-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
redirect: '/dashboard',
|
||||
})
|
||||
setTokenMock.mockResolvedValue({})
|
||||
|
||||
@@ -179,35 +179,67 @@ describe('WechatCallbackView', () => {
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenNthCalledWith(1, '/auth/oauth/pending/exchange', {})
|
||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/pending/exchange', {
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false,
|
||||
expect(exchangePendingOAuthCompletionMock).toHaveBeenNthCalledWith(1)
|
||||
expect(exchangePendingOAuthCompletionMock).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false,
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-access-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/dashboard')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('wechat-refresh-token')
|
||||
})
|
||||
|
||||
it('supports bind completion after adoption confirmation', async () => {
|
||||
exchangePendingOAuthCompletionMock
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/profile/connections',
|
||||
})
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletionMock).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: true,
|
||||
})
|
||||
expect(setTokenMock).not.toHaveBeenCalled()
|
||||
expect(showSuccessMock).toHaveBeenCalledWith('profile.authBindings.bindSuccess')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/profile/connections')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
error: 'invitation_required',
|
||||
redirect: '/subscriptions',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'wechat-invite-token',
|
||||
refresh_token: 'wechat-invite-refresh',
|
||||
expires_in: 600,
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
})
|
||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
redirect: '/subscriptions',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
})
|
||||
completeWeChatOAuthRegistrationMock.mockResolvedValue({
|
||||
access_token: 'wechat-invite-token',
|
||||
refresh_token: 'wechat-invite-refresh',
|
||||
expires_in: 600,
|
||||
token_type: 'Bearer',
|
||||
})
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
@@ -230,10 +262,9 @@ describe('WechatCallbackView', () => {
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'INVITE-CODE',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true,
|
||||
expect(completeWeChatOAuthRegistrationMock).toHaveBeenCalledWith('INVITE-CODE', {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true,
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
||||
|
||||
@@ -2,18 +2,53 @@
|
||||
<AppLayout>
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<StatCard :title="t('profile.accountBalance')" :value="formatCurrency(user?.balance || 0)" :icon="WalletIcon" icon-variant="success" />
|
||||
<StatCard :title="t('profile.concurrencyLimit')" :value="user?.concurrency || 0" :icon="BoltIcon" icon-variant="warning" />
|
||||
<StatCard :title="t('profile.memberSince')" :value="formatDate(user?.created_at || '', { year: 'numeric', month: 'long' })" :icon="CalendarIcon" icon-variant="primary" />
|
||||
<StatCard
|
||||
:title="t('profile.accountBalance')"
|
||||
:value="formatCurrency(user?.balance || 0)"
|
||||
:icon="WalletIcon"
|
||||
icon-variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
:title="t('profile.concurrencyLimit')"
|
||||
:value="user?.concurrency || 0"
|
||||
:icon="BoltIcon"
|
||||
icon-variant="warning"
|
||||
/>
|
||||
<StatCard
|
||||
:title="t('profile.memberSince')"
|
||||
:value="formatDate(user?.created_at || '', { year: 'numeric', month: 'long' })"
|
||||
:icon="CalendarIcon"
|
||||
icon-variant="primary"
|
||||
/>
|
||||
</div>
|
||||
<ProfileInfoCard :user="user" />
|
||||
<div v-if="contactInfo" class="card border-primary-200 bg-primary-50 dark:bg-primary-900/20 p-6">
|
||||
|
||||
<ProfileInfoCard
|
||||
:user="user"
|
||||
:linuxdo-enabled="linuxdoOAuthEnabled"
|
||||
:oidc-enabled="oidcOAuthEnabled"
|
||||
:oidc-provider-name="oidcOAuthProviderName"
|
||||
:wechat-enabled="wechatOAuthEnabled"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="contactInfo"
|
||||
class="card border-primary-200 bg-primary-50 p-6 dark:bg-primary-900/20"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary-100 rounded-xl text-primary-600"><Icon name="chat" size="lg" /></div>
|
||||
<div><h3 class="font-semibold text-primary-800 dark:text-primary-200">{{ t('common.contactSupport') }}</h3><p class="text-sm font-medium">{{ contactInfo }}</p></div>
|
||||
<div class="rounded-xl bg-primary-100 p-3 text-primary-600">
|
||||
<Icon name="chat" size="lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-primary-800 dark:text-primary-200">
|
||||
{{ t('common.contactSupport') }}
|
||||
</h3>
|
||||
<p class="text-sm font-medium">{{ contactInfo }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||
|
||||
<ProfileBalanceNotifyCard
|
||||
v-if="user && balanceLowNotifyEnabled"
|
||||
:enabled="user.balance_notify_enabled ?? true"
|
||||
@@ -22,6 +57,7 @@
|
||||
:system-default-threshold="systemDefaultThreshold"
|
||||
:user-email="user.email"
|
||||
/>
|
||||
|
||||
<ProfilePasswordForm />
|
||||
<ProfileTotpCard />
|
||||
</div>
|
||||
@@ -29,26 +65,78 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'; import { formatDate } from '@/utils/format'
|
||||
import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { authAPI } from '@/api'
|
||||
import { Icon } from '@/components/icons'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatDate } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||
const contactInfo = ref('')
|
||||
const balanceLowNotifyEnabled = ref(false)
|
||||
const systemDefaultThreshold = ref(0)
|
||||
const linuxdoOAuthEnabled = ref(false)
|
||||
const wechatOAuthEnabled = ref(false)
|
||||
const oidcOAuthEnabled = ref(false)
|
||||
const oidcOAuthProviderName = ref('OIDC')
|
||||
|
||||
const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) }
|
||||
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
|
||||
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
|
||||
const WalletIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]
|
||||
)
|
||||
}
|
||||
const BoltIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]
|
||||
)
|
||||
}
|
||||
const CalendarIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || ''; balanceLowNotifyEnabled.value = s.balance_low_notify_enabled ?? false; systemDefaultThreshold.value = s.balance_low_notify_threshold ?? 0 } catch (error) { console.error('Failed to load settings:', error) } })
|
||||
const formatCurrency = (v: number) => `$${v.toFixed(2)}`
|
||||
</script>
|
||||
onMounted(async () => {
|
||||
const profileRefresh = authStore.refreshUser().catch((error) => {
|
||||
console.error('Failed to refresh profile:', error)
|
||||
})
|
||||
|
||||
const settingsLoad = authAPI.getPublicSettings()
|
||||
.then((settings) => {
|
||||
contactInfo.value = settings.contact_info || ''
|
||||
balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false
|
||||
systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled ?? false
|
||||
wechatOAuthEnabled.value = settings.wechat_oauth_enabled ?? false
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled ?? false
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load settings:', error)
|
||||
})
|
||||
|
||||
await Promise.all([profileRefresh, settingsLoad])
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => `$${value.toFixed(2)}`
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user