Commit e9de839d authored by IanShaw027's avatar IanShaw027
Browse files

feat: rebuild auth identity foundation flow

parent fbd0a2e3
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import OidcCallbackView from '../OidcCallbackView.vue'
const replace = vi.fn()
const showSuccess = vi.fn()
const showError = vi.fn()
const setToken = vi.fn()
const exchangePendingOAuthCompletion = vi.fn()
const completeOIDCOAuthRegistration = vi.fn()
const getPublicSettings = vi.fn()
vi.mock('vue-router', () => ({
useRoute: () => ({
query: {}
}),
useRouter: () => ({
replace
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string>) => {
if (!params?.providerName) {
return key
}
return `${key}:${params.providerName}`
}
})
}
})
vi.mock('@/stores', () => ({
useAuthStore: () => ({
setToken
}),
useAppStore: () => ({
showSuccess,
showError
})
}))
vi.mock('@/api/auth', () => ({
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
}))
describe('OidcCallbackView', () => {
beforeEach(() => {
replace.mockReset()
showSuccess.mockReset()
showError.mockReset()
setToken.mockReset()
exchangePendingOAuthCompletion.mockReset()
completeOIDCOAuthRegistration.mockReset()
getPublicSettings.mockReset()
getPublicSettings.mockResolvedValue({
oidc_oauth_provider_name: 'ExampleID'
})
})
it('does not send adoption decisions during the initial exchange', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
redirect: '/dashboard',
adoption_required: true
})
setToken.mockResolvedValue({})
mount(OidcCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
})
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
exchangePendingOAuthCompletion
.mockResolvedValueOnce({
redirect: '/dashboard',
adoption_required: true,
suggested_display_name: 'OIDC Nick',
suggested_avatar_url: 'https://cdn.example/oidc.png'
})
.mockResolvedValueOnce({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
redirect: '/dashboard'
})
setToken.mockResolvedValue({})
const wrapper = mount(OidcCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('OIDC Nick')
expect(setToken).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
await checkboxes[0].setValue(false)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2)
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(1)
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
adoptDisplayName: false,
adoptAvatar: true
})
expect(setToken).toHaveBeenCalledWith('access-token')
expect(replace).toHaveBeenCalledWith('/dashboard')
})
it('renders adoption choices for invitation flow and submits the selected values', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'invitation_required',
redirect: '/dashboard',
adoption_required: true,
suggested_display_name: 'OIDC Nick',
suggested_avatar_url: 'https://cdn.example/oidc.png'
})
completeOIDCOAuthRegistration.mockResolvedValue({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 3600,
token_type: 'Bearer'
})
setToken.mockResolvedValue({})
const wrapper = mount(OidcCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('OIDC Nick')
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(2)
await checkboxes[1].setValue(false)
await wrapper.find('input[type="text"]').setValue('invite-code')
await wrapper.find('button').trigger('click')
expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', {
adoptDisplayName: true,
adoptAvatar: false
})
})
})
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
const {
postMock,
replaceMock,
setTokenMock,
showSuccessMock,
showErrorMock,
routeState,
} = vi.hoisted(() => ({
postMock: vi.fn(),
replaceMock: vi.fn(),
setTokenMock: vi.fn(),
showSuccessMock: vi.fn(),
showErrorMock: vi.fn(),
routeState: {
query: {} as Record<string, unknown>,
},
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({
replace: replaceMock,
}),
}))
vi.mock('vue-i18n', () => ({
createI18n: () => ({
global: {
t: (key: string) => key,
},
}),
useI18n: () => ({
t: (key: string, params?: Record<string, string>) => {
if (key === 'auth.oidc.callbackTitle') {
return `Signing you in with ${params?.providerName ?? ''}`.trim()
}
if (key === 'auth.oidc.callbackProcessing') {
return `Completing login with ${params?.providerName ?? ''}`.trim()
}
if (key === 'auth.oidc.invitationRequired') {
return `${params?.providerName ?? ''} invitation required`.trim()
}
if (key === 'auth.oidc.completeRegistration') {
return 'Complete registration'
}
if (key === 'auth.oidc.completing') {
return 'Completing'
}
if (key === 'auth.oidc.backToLogin') {
return 'Back to login'
}
if (key === 'auth.invitationCodePlaceholder') {
return 'Invitation code'
}
if (key === 'auth.loginSuccess') {
return 'Login success'
}
if (key === 'auth.loginFailed') {
return 'Login failed'
}
if (key === 'auth.oidc.callbackHint') {
return 'Callback hint'
}
if (key === 'auth.oidc.callbackMissingToken') {
return 'Missing login token'
}
if (key === 'auth.oidc.completeRegistrationFailed') {
return 'Complete registration failed'
}
return key
},
}),
}))
vi.mock('@/stores', () => ({
useAuthStore: () => ({
setToken: setTokenMock,
}),
useAppStore: () => ({
showSuccess: showSuccessMock,
showError: showErrorMock,
}),
}))
vi.mock('@/api/client', () => ({
apiClient: {
post: postMock,
},
}))
describe('WechatCallbackView', () => {
beforeEach(() => {
postMock.mockReset()
replaceMock.mockReset()
setTokenMock.mockReset()
showSuccessMock.mockReset()
showErrorMock.mockReset()
routeState.query = {}
localStorage.clear()
})
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,
},
})
setTokenMock.mockResolvedValue({})
mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
expect(postMock).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {})
expect(postMock).toHaveBeenCalledTimes(1)
})
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
postMock
.mockResolvedValueOnce({
data: {
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',
},
})
setTokenMock.mockResolvedValue({})
const wrapper = mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('WeChat Nick')
expect(setTokenMock).not.toHaveBeenCalled()
expect(replaceMock).not.toHaveBeenCalled()
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(2)
await checkboxes[1].setValue(false)
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(1)
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(setTokenMock).toHaveBeenCalledWith('wechat-access-token')
expect(replaceMock).toHaveBeenCalledWith('/dashboard')
expect(localStorage.getItem('refresh_token')).toBe('wechat-refresh-token')
})
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',
},
})
const wrapper = mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('WeChat Nick')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes).toHaveLength(2)
await checkboxes[0].setValue(false)
await wrapper.get('input[type="text"]').setValue(' INVITE-CODE ')
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(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
})
})
......@@ -23,20 +23,7 @@
:order-type="paymentState.orderType"
@done="onPaymentDone"
@success="onPaymentSuccess"
/>
</template>
<template v-else-if="paymentPhase === 'stripe'">
<StripePaymentInline
:order-id="paymentState.orderId"
:amount="paymentState.amount"
:client-secret="paymentState.clientSecret"
:order-type="paymentState.orderType || undefined"
:publishable-key="checkout.stripe_publishable_key"
:pay-amount="paymentState.payAmount"
@success="onPaymentSuccess"
@done="onStripeDone"
@back="resetPayment"
@redirect="onStripeRedirect"
@settled="onPaymentSettled"
/>
</template>
<!-- Tab content (select phase) -->
......@@ -265,7 +252,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { usePaymentStore } from '@/stores/payment'
import { useSubscriptionStore } from '@/stores/subscriptions'
......@@ -273,20 +260,30 @@ import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device'
import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment'
import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderType } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { METHOD_ORDER, POPUP_WINDOW_FEATURES, STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import {
PAYMENT_RECOVERY_STORAGE_KEY,
clearPaymentRecoverySnapshot,
decidePaymentLaunch,
getVisibleMethods,
normalizeVisibleMethod,
readPaymentRecoverySnapshot,
type PaymentRecoverySnapshot,
writePaymentRecoverySnapshot,
} from '@/components/payment/paymentFlow'
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
import Icon from '@/components/icons/Icon.vue'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const paymentStore = usePaymentStore()
const subscriptionStore = useSubscriptionStore()
......@@ -309,23 +306,41 @@ const selectedMethod = ref('')
const selectedPlan = ref<SubscriptionPlan | null>(null)
const previewImage = ref('')
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
const paymentState = ref<{
orderId: number
amount: number
qrCode: string
expiresAt: string
paymentType: string
payUrl: string
clientSecret: string
payAmount: number
orderType: OrderType | ''
}>({ orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
const paymentPhase = ref<'select' | 'paying'>('select')
function emptyPaymentState(): PaymentRecoverySnapshot {
return {
orderId: 0,
amount: 0,
qrCode: '',
expiresAt: '',
paymentType: '',
payUrl: '',
clientSecret: '',
payAmount: 0,
orderType: '',
paymentMode: '',
resumeToken: '',
createdAt: 0,
}
}
const paymentState = ref<PaymentRecoverySnapshot>(emptyPaymentState())
function persistRecoverySnapshot(snapshot: PaymentRecoverySnapshot) {
if (typeof window === 'undefined' || !snapshot.orderId) return
writePaymentRecoverySnapshot(window.localStorage, snapshot, PAYMENT_RECOVERY_STORAGE_KEY)
}
function removeRecoverySnapshot() {
if (typeof window === 'undefined') return
clearPaymentRecoverySnapshot(window.localStorage, PAYMENT_RECOVERY_STORAGE_KEY)
}
function resetPayment() {
paymentPhase.value = 'select'
paymentState.value = { orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
paymentState.value = emptyPaymentState()
removeRecoverySnapshot()
}
function onPaymentDone() {
......@@ -338,24 +353,15 @@ function onPaymentDone() {
}
function onPaymentSuccess() {
removeRecoverySnapshot()
authStore.refreshUser()
if (paymentState.value.orderType === 'subscription') {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onStripeDone() {
const wasSubscription = paymentState.value.orderType === 'subscription'
resetPayment()
selectedPlan.value = null
if (wasSubscription) {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
}
function onStripeRedirect(orderId: number, payUrl: string) {
paymentState.value = { ...paymentState.value, orderId, payUrl, qrCode: '' }
paymentPhase.value = 'paying'
function onPaymentSettled() {
removeRecoverySnapshot()
}
// All checkout data from single API call
......@@ -371,7 +377,8 @@ const tabs = computed(() => {
return result
})
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
const visibleMethods = computed(() => getVisibleMethods(checkout.value.methods))
const enabledMethods = computed(() => Object.keys(visibleMethods.value))
const validAmount = computed(() => amount.value ?? 0)
const balanceRechargeMultiplier = computed(() => {
const multiplier = checkout.value.balance_recharge_multiplier
......@@ -389,23 +396,33 @@ const planGridClass = computed(() => {
// Check if an amount fits a method's [min, max]. 0 = no limit.
function amountFitsMethod(amt: number, methodType: string): boolean {
if (amt <= 0) return true
const ml = checkout.value.methods[methodType]
const ml = visibleMethods.value[methodType]
if (!ml) return false
if (ml.single_min > 0 && amt < ml.single_min) return false
if (ml.single_max > 0 && amt > ml.single_max) return false
return true
}
// Global range for AmountInput (union of all methods, precomputed by backend)
const globalMinAmount = computed(() => checkout.value.global_min)
const globalMaxAmount = computed(() => checkout.value.global_max)
// Visible methods decide the amount range shown to users.
const globalMinAmount = computed(() => {
const limits = Object.values(visibleMethods.value)
if (limits.length === 0) return 0
if (limits.some(limit => limit.single_min <= 0)) return 0
return Math.min(...limits.map(limit => limit.single_min))
})
const globalMaxAmount = computed(() => {
const limits = Object.values(visibleMethods.value)
if (limits.length === 0) return 0
if (limits.some(limit => limit.single_max <= 0)) return 0
return Math.max(...limits.map(limit => limit.single_max))
})
// Selected method's limits (for validation and error messages)
const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])
const selectedLimit = computed(() => visibleMethods.value[selectedMethod.value])
const methodOptions = computed<PaymentMethodOption[]>(() =>
enabledMethods.value.map((type) => {
const ml = checkout.value.methods[type]
const ml = visibleMethods.value[type]
return {
type,
fee_rate: ml?.fee_rate ?? 0,
......@@ -451,7 +468,7 @@ const canSubmit = computed(() =>
const subMethodOptions = computed<PaymentMethodOption[]>(() => {
const planPrice = selectedPlan.value?.price ?? 0
return enabledMethods.value.map((type) => {
const ml = checkout.value.methods[type]
const ml = visibleMethods.value[type]
return {
type,
fee_rate: ml?.fee_rate ?? 0,
......@@ -551,55 +568,58 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
payment_type: selectedMethod.value,
order_type: orderType,
plan_id: planId,
})
const openWindow = (url: string) => {
const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
}) as CreateOrderResult & { resume_token?: string }
const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
const win = window.open(url, 'paymentPopup', features)
if (!win || win.closed) {
window.location.href = url
}
}
if (result.client_secret) {
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
paymentState.value = {
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: '',
clientSecret: result.client_secret, payAmount: result.pay_amount,
orderType,
}
paymentPhase.value = 'stripe'
} else if (isMobileDevice() && result.pay_url) {
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
paymentState.value = {
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
window.location.href = result.pay_url
return
} else if (result.qr_code) {
// QR mode: show QR code inline
paymentState.value = {
orderId: result.order_id, amount: result.amount, qrCode: result.qr_code,
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
} else if (result.pay_url) {
// Redirect/popup mode: open payment URL, show waiting state inline
openWindow(result.pay_url)
paymentState.value = {
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0,
orderType,
}
paymentPhase.value = 'paying'
} else {
const visibleMethod = normalizeVisibleMethod(selectedMethod.value) || selectedMethod.value
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
? router.resolve({
path: '/payment/stripe',
query: {
order_id: String(result.order_id),
client_secret: result.client_secret,
method: stripeMethod,
resume_token: result.resume_token || undefined,
},
}).href
: ''
const decision = decidePaymentLaunch(result, {
visibleMethod,
orderType,
isMobile: isMobileDevice(),
stripePopupUrl: stripeRouteUrl,
stripeRouteUrl,
})
if (decision.kind === 'unhandled') {
errorMessage.value = t('payment.result.failed')
appStore.showError(errorMessage.value)
return
}
paymentState.value = decision.paymentState
paymentPhase.value = 'paying'
persistRecoverySnapshot(decision.recovery)
if (decision.kind === 'stripe_popup') {
openWindow(decision.paymentState.payUrl, STRIPE_POPUP_WINDOW_FEATURES)
return
}
if (decision.kind === 'stripe_route') {
window.location.href = decision.paymentState.payUrl
return
}
if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
if (isMobileDevice()) {
window.location.href = decision.paymentState.payUrl
return
}
openWindow(decision.paymentState.payUrl)
}
} catch (err: unknown) {
const apiErr = err as Record<string, unknown>
......@@ -630,6 +650,25 @@ onMounted(async () => {
})
selectedMethod.value = sorted[0]
}
if (typeof window !== 'undefined') {
const routeResumeToken = typeof route.query.resume_token === 'string'
? route.query.resume_token
: undefined
const restored = readPaymentRecoverySnapshot(
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
{ resumeToken: routeResumeToken },
)
if (restored) {
paymentState.value = restored
paymentPhase.value = 'paying'
const restoredMethod = normalizeVisibleMethod(restored.paymentType)
if (restoredMethod) {
selectedMethod.value = restoredMethod
}
} else {
removeRecoverySnapshot()
}
}
if (checkout.value.balance_disabled) {
activeTab.value = 'subscription'
}
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment