fix(frontend): preserve callback recovery state
This commit is contained in:
@@ -78,7 +78,7 @@ function simulateGuard(
|
|||||||
return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
|
return authState.isAdmin ? '/admin/dashboard' : '/dashboard'
|
||||||
}
|
}
|
||||||
if (authState.backendModeEnabled && !authState.isAuthenticated) {
|
if (authState.backendModeEnabled && !authState.isAuthenticated) {
|
||||||
const allowed = ['/login', '/key-usage', '/setup']
|
const allowed = ['/login', '/key-usage', '/setup', '/payment/result']
|
||||||
const callbackPaths = [
|
const callbackPaths = [
|
||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
'/auth/linuxdo/callback',
|
'/auth/linuxdo/callback',
|
||||||
@@ -127,7 +127,7 @@ function simulateGuard(
|
|||||||
if (authState.isAuthenticated && authState.isAdmin) {
|
if (authState.isAuthenticated && authState.isAdmin) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const allowed = ['/login', '/key-usage', '/setup']
|
const allowed = ['/login', '/key-usage', '/setup', '/payment/result']
|
||||||
const callbackPaths = [
|
const callbackPaths = [
|
||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
'/auth/linuxdo/callback',
|
'/auth/linuxdo/callback',
|
||||||
@@ -462,6 +462,18 @@ describe('路由守卫逻辑', () => {
|
|||||||
expect(redirect).toBeNull()
|
expect(redirect).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('unauthenticated: /payment/result is allowed', () => {
|
||||||
|
const authState: MockAuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isSimpleMode: false,
|
||||||
|
backendModeEnabled: true,
|
||||||
|
hasPendingAuthSession: false,
|
||||||
|
}
|
||||||
|
const redirect = simulateGuard('/payment/result', { requiresAuth: false }, authState)
|
||||||
|
expect(redirect).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('unauthenticated: /register is allowed when a pending auth session exists', () => {
|
it('unauthenticated: /register is allowed when a pending auth session exists', () => {
|
||||||
const authState: MockAuthState = {
|
const authState: MockAuthState = {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ let authInitialized = false
|
|||||||
const navigationLoading = useNavigationLoadingState()
|
const navigationLoading = useNavigationLoadingState()
|
||||||
// 延迟初始化预加载,传入 router 实例
|
// 延迟初始化预加载,传入 router 实例
|
||||||
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
|
let routePrefetch: ReturnType<typeof useRoutePrefetch> | null = null
|
||||||
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup']
|
const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/result']
|
||||||
const BACKEND_MODE_CALLBACK_PATHS = [
|
const BACKEND_MODE_CALLBACK_PATHS = [
|
||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
'/auth/linuxdo/callback',
|
'/auth/linuxdo/callback',
|
||||||
|
|||||||
@@ -603,6 +603,14 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (completion.auth_result === 'pending_session') {
|
||||||
|
needsInvitation.value = false
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
isProcessing.value = false
|
||||||
|
persistPendingAuthSession(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await finalizeCompletion(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,9 +620,9 @@ async function handleSubmitInvitation() {
|
|||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const tokenData = legacyPendingOAuthToken.value
|
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
|
||||||
? (
|
? (
|
||||||
await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
|
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
|
||||||
pending_oauth_token: legacyPendingOAuthToken.value,
|
pending_oauth_token: legacyPendingOAuthToken.value,
|
||||||
invitation_code: invitationCode.value.trim(),
|
invitation_code: invitationCode.value.trim(),
|
||||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
@@ -624,10 +632,7 @@ async function handleSubmitInvitation() {
|
|||||||
invitationCode.value.trim(),
|
invitationCode.value.trim(),
|
||||||
currentAdoptionDecision()
|
currentAdoptionDecision()
|
||||||
)
|
)
|
||||||
persistOAuthTokenContext(tokenData)
|
await finalizePendingAccountResponse(completion)
|
||||||
await authStore.setToken(tokenData.access_token)
|
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
|
||||||
await router.replace(redirectTo.value)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||||
invitationError.value =
|
invitationError.value =
|
||||||
|
|||||||
@@ -632,6 +632,14 @@ async function finalizePendingAccountResponse(completion: PendingOidcCompletion)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (completion.auth_result === 'pending_session') {
|
||||||
|
needsInvitation.value = false
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
isProcessing.value = false
|
||||||
|
persistPendingAuthSession(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await finalizeCompletion(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,9 +649,9 @@ async function handleSubmitInvitation() {
|
|||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const tokenData = legacyPendingOAuthToken.value
|
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
|
||||||
? (
|
? (
|
||||||
await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
|
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
|
||||||
pending_oauth_token: legacyPendingOAuthToken.value,
|
pending_oauth_token: legacyPendingOAuthToken.value,
|
||||||
invitation_code: invitationCode.value.trim(),
|
invitation_code: invitationCode.value.trim(),
|
||||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
@@ -653,10 +661,7 @@ async function handleSubmitInvitation() {
|
|||||||
invitationCode.value.trim(),
|
invitationCode.value.trim(),
|
||||||
currentAdoptionDecision()
|
currentAdoptionDecision()
|
||||||
)
|
)
|
||||||
persistOAuthTokenContext(tokenData)
|
await finalizePendingAccountResponse(completion)
|
||||||
await authStore.setToken(tokenData.access_token)
|
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
|
||||||
await router.replace(redirectTo.value)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||||
invitationError.value =
|
invitationError.value =
|
||||||
|
|||||||
@@ -840,6 +840,14 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (completion.auth_result === 'pending_session') {
|
||||||
|
needsInvitation.value = false
|
||||||
|
needsAdoptionConfirmation.value = false
|
||||||
|
isProcessing.value = false
|
||||||
|
persistPendingAuthSession(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await finalizeCompletion(completion, redirect)
|
await finalizeCompletion(completion, redirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,9 +857,9 @@ async function handleSubmitInvitation() {
|
|||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const tokenData = legacyPendingOAuthToken.value
|
const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value
|
||||||
? (
|
? (
|
||||||
await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', {
|
||||||
pending_oauth_token: legacyPendingOAuthToken.value,
|
pending_oauth_token: legacyPendingOAuthToken.value,
|
||||||
invitation_code: invitationCode.value.trim(),
|
invitation_code: invitationCode.value.trim(),
|
||||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||||
@@ -861,10 +869,7 @@ async function handleSubmitInvitation() {
|
|||||||
invitationCode.value.trim(),
|
invitationCode.value.trim(),
|
||||||
currentAdoptionDecision()
|
currentAdoptionDecision()
|
||||||
)
|
)
|
||||||
persistOAuthTokenContext(tokenData)
|
await finalizePendingAccountResponse(completion)
|
||||||
await authStore.setToken(tokenData.access_token)
|
|
||||||
appStore.showSuccess(t('auth.loginSuccess'))
|
|
||||||
await router.replace(redirectTo.value)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||||
invitationError.value =
|
invitationError.value =
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ function normalizeRedirectPath(path: string | null | undefined): string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendQueryParam(query: Record<string, string>, key: string, value: string) {
|
||||||
|
if (value) {
|
||||||
|
query[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function goBackToPayment() {
|
function goBackToPayment() {
|
||||||
void router.replace('/purchase')
|
void router.replace('/purchase')
|
||||||
}
|
}
|
||||||
@@ -102,12 +108,19 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resumeToken = readParam('wechat_resume_token')
|
const resumeToken = readParam('wechat_resume_token')
|
||||||
|
const openid = readParam('openid')
|
||||||
|
const state = readParam('state')
|
||||||
|
const scope = readParam('scope')
|
||||||
|
const paymentType = readParam('payment_type')
|
||||||
|
const amount = readParam('amount')
|
||||||
|
const orderType = readParam('order_type')
|
||||||
|
const planId = readParam('plan_id')
|
||||||
const redirectURL = new URL(
|
const redirectURL = new URL(
|
||||||
normalizeRedirectPath(readParam('redirect')),
|
normalizeRedirectPath(readParam('redirect')),
|
||||||
window.location.origin,
|
window.location.origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!resumeToken) {
|
if (!resumeToken && !openid) {
|
||||||
errorMessage.value = t('auth.wechatPayment.callbackMissingResumeToken')
|
errorMessage.value = t('auth.wechatPayment.callbackMissingResumeToken')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -115,7 +128,18 @@ onMounted(async () => {
|
|||||||
const query: Record<string, string> = {
|
const query: Record<string, string> = {
|
||||||
...Object.fromEntries(redirectURL.searchParams.entries()),
|
...Object.fromEntries(redirectURL.searchParams.entries()),
|
||||||
wechat_resume: '1',
|
wechat_resume: '1',
|
||||||
wechat_resume_token: resumeToken,
|
}
|
||||||
|
|
||||||
|
if (resumeToken) {
|
||||||
|
query.wechat_resume_token = resumeToken
|
||||||
|
} else {
|
||||||
|
query.openid = openid
|
||||||
|
appendQueryParam(query, 'state', state)
|
||||||
|
appendQueryParam(query, 'scope', scope)
|
||||||
|
appendQueryParam(query, 'payment_type', paymentType)
|
||||||
|
appendQueryParam(query, 'amount', amount)
|
||||||
|
appendQueryParam(query, 'order_type', orderType)
|
||||||
|
appendQueryParam(query, 'plan_id', planId)
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.replace({
|
await router.replace({
|
||||||
|
|||||||
@@ -409,6 +409,50 @@ describe('LinuxDoCallbackView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the oauth flow active when complete-registration returns another pending step', async () => {
|
||||||
|
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||||
|
error: 'invitation_required',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'LinuxDo Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||||
|
})
|
||||||
|
completeLinuxDoOAuthRegistration.mockResolvedValue({
|
||||||
|
auth_result: 'pending_session',
|
||||||
|
step: 'choose_account_action_required',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
email: 'fresh@example.com',
|
||||||
|
resolved_email: 'fresh@example.com',
|
||||||
|
force_email_on_signup: true,
|
||||||
|
adoption_required: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(LinuxDoCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(completeLinuxDoOAuthRegistration).toHaveBeenCalledWith('invite-code', {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: true
|
||||||
|
})
|
||||||
|
expect(setToken).not.toHaveBeenCalled()
|
||||||
|
expect(replace).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('auth.oauthFlow.bindExistingAccount')
|
||||||
|
expect(wrapper.text()).toContain('auth.oauthFlow.createNewAccount')
|
||||||
|
})
|
||||||
|
|
||||||
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
getPublicSettings.mockResolvedValue({
|
getPublicSettings.mockResolvedValue({
|
||||||
invitation_code_enabled: true,
|
invitation_code_enabled: true,
|
||||||
|
|||||||
@@ -385,6 +385,50 @@ describe('OidcCallbackView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the oauth flow active when complete-registration returns another pending step', 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({
|
||||||
|
auth_result: 'pending_session',
|
||||||
|
step: 'choose_account_action_required',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
email: 'fresh@example.com',
|
||||||
|
resolved_email: 'fresh@example.com',
|
||||||
|
force_email_on_signup: true,
|
||||||
|
adoption_required: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(OidcCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: true
|
||||||
|
})
|
||||||
|
expect(setToken).not.toHaveBeenCalled()
|
||||||
|
expect(replace).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('auth.oauthFlow.bindExistingAccount')
|
||||||
|
expect(wrapper.text()).toContain('auth.oauthFlow.createNewAccount')
|
||||||
|
})
|
||||||
|
|
||||||
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
|
||||||
getPublicSettings.mockResolvedValue({
|
getPublicSettings.mockResolvedValue({
|
||||||
oidc_oauth_provider_name: 'ExampleID',
|
oidc_oauth_provider_name: 'ExampleID',
|
||||||
|
|||||||
@@ -517,6 +517,50 @@ describe('WechatCallbackView', () => {
|
|||||||
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the oauth flow active when complete-registration returns another pending step', async () => {
|
||||||
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
|
error: 'invitation_required',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
adoption_required: true,
|
||||||
|
suggested_display_name: 'WeChat Nick',
|
||||||
|
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||||
|
})
|
||||||
|
completeWeChatOAuthRegistrationMock.mockResolvedValue({
|
||||||
|
auth_result: 'pending_session',
|
||||||
|
step: 'choose_account_action_required',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
email: 'fresh@example.com',
|
||||||
|
resolved_email: 'fresh@example.com',
|
||||||
|
force_email_on_signup: true,
|
||||||
|
adoption_required: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(WechatCallbackView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AuthLayout: { template: '<div><slot /></div>' },
|
||||||
|
Icon: true,
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
transition: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(completeWeChatOAuthRegistrationMock).toHaveBeenCalledWith('invite-code', {
|
||||||
|
adoptDisplayName: true,
|
||||||
|
adoptAvatar: true,
|
||||||
|
})
|
||||||
|
expect(setTokenMock).not.toHaveBeenCalled()
|
||||||
|
expect(replaceMock).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.get('[data-testid="wechat-choice-bind-existing"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.get('[data-testid="wechat-choice-create-account"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('offers existing-account email collection during invitation flow', async () => {
|
it('offers existing-account email collection during invitation flow', async () => {
|
||||||
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
exchangePendingOAuthCompletionMock.mockResolvedValue({
|
||||||
error: 'invitation_required',
|
error: 'invitation_required',
|
||||||
|
|||||||
@@ -79,6 +79,29 @@ describe('WechatPaymentCallbackView', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('redirects legacy openid callback payloads back to purchase while preserving resume context', async () => {
|
||||||
|
locationState.current.hash =
|
||||||
|
'#openid=openid-123&state=oauth-state&scope=snsapi_base&payment_type=wxpay_direct&amount=128&order_type=subscription&plan_id=7&redirect=%2Fpayment%3Ffrom%3Dwechat'
|
||||||
|
|
||||||
|
mount(WechatPaymentCallbackView)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith({
|
||||||
|
path: '/purchase',
|
||||||
|
query: {
|
||||||
|
from: 'wechat',
|
||||||
|
wechat_resume: '1',
|
||||||
|
openid: 'openid-123',
|
||||||
|
state: 'oauth-state',
|
||||||
|
scope: 'snsapi_base',
|
||||||
|
payment_type: 'wxpay_direct',
|
||||||
|
amount: '128',
|
||||||
|
order_type: 'subscription',
|
||||||
|
plan_id: '7',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('shows an error when the callback payload is missing the resume token', async () => {
|
it('shows an error when the callback payload is missing the resume token', async () => {
|
||||||
locationState.current.hash = '#payment_type=wxpay'
|
locationState.current.hash = '#payment_type=wxpay'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user