diff --git a/src/localization/en.json b/src/localization/en.json index 268ab2be..4f4c7394 100644 --- a/src/localization/en.json +++ b/src/localization/en.json @@ -2612,7 +2612,9 @@ "account_removed": "Account removed.", "auto_login_success": "Automatically logged in.", "auto_login_failed": "Failed to login automatically.", - "offline": "You're offline." + "offline": "You're offline.", + "login_network_issue_hint_title": "Having trouble signing in?", + "login_network_issue_hint_description": "Several recent sign-in attempts failed. If your credentials are correct, this may be caused by a network, proxy, VPN, or regional access issue." }, "vrcx_updater": { "failed": "Failed to check for update, {message}", diff --git a/src/shared/constants/link.js b/src/shared/constants/link.js index 0bf88c93..da2c2723 100644 --- a/src/shared/constants/link.js +++ b/src/shared/constants/link.js @@ -1,5 +1,7 @@ const links = { wiki: 'https://github.com/vrcx-team/VRCX/wiki', + troubleshootingAuthUserConnectionIssues: + 'https://github.com/vrcx-team/VRCX/wiki/Troubleshooting#401-authuser--connection-issues', github: 'https://github.com/vrcx-team/VRCX', discord: 'https://vrcx.app/discord' }; diff --git a/src/stores/__tests__/authLoginFailureToast.test.js b/src/stores/__tests__/authLoginFailureToast.test.js new file mode 100644 index 00000000..7efe0599 --- /dev/null +++ b/src/stores/__tests__/authLoginFailureToast.test.js @@ -0,0 +1,467 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { nextTick, reactive } from 'vue'; + +import { links } from '../../shared/constants/link'; + +const mockWatchState = reactive({ + isLoggedIn: false, + isFriendsLoaded: false, + isFavoritesLoaded: false +}); + +const mocks = vi.hoisted(() => ({ + toast: { + dismiss: vi.fn(), + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + }, + request: vi.fn(), + authRequest: { + getConfig: vi.fn() + }, + runLoginSuccessFlow: vi.fn(), + runLogoutFlow: vi.fn(), + runHandleAutoLoginFlow: vi.fn(), + getCurrentUser: vi.fn(), + initWebsocket: vi.fn(), + advancedSettingsStore: { + enablePrimaryPassword: false, + setEnablePrimaryPassword: vi.fn(), + setEnablePrimaryPasswordConfigRepository: vi.fn(), + runAvatarAutoCleanup: vi.fn() + }, + generalSettingsStore: { + autoLoginDelayEnabled: false, + autoLoginDelaySeconds: 0 + }, + modalStore: { + prompt: vi.fn(), + otpPrompt: vi.fn(), + confirm: vi.fn(), + alert: vi.fn() + }, + updateLoopStore: { + setNextCurrentUserRefresh: vi.fn(), + setIpcTimeout: vi.fn() + }, + userStore: { + currentUser: { + id: 'usr_me', + displayName: 'Tester' + }, + setUserDialogVisible: vi.fn() + }, + vrcxStore: { + waitForDatabaseInit: vi.fn().mockResolvedValue(true) + }, + configRepository: { + getString: vi.fn(), + getBool: vi.fn(), + setString: vi.fn(), + setBool: vi.fn(), + remove: vi.fn() + }, + webApiService: { + clearCookies: vi.fn().mockResolvedValue(undefined), + getCookies: vi.fn().mockResolvedValue([]), + setCookies: vi.fn().mockResolvedValue(undefined) + }, + security: { + encrypt: vi.fn(), + decrypt: vi.fn() + }, + notyShow: vi.fn(), + appDebug: { + endpointDomain: '', + websocketDomain: '', + endpointDomainVrchat: 'https://vrchat.com', + websocketDomainVrchat: 'wss://pubsub.vrchat.com', + errorNoty: null + } +})); + +vi.mock('vue-sonner', () => ({ + toast: mocks.toast +})); + +vi.mock('noty', () => ({ + default: vi.fn().mockImplementation(function NotyMock() { + this.show = (...args) => mocks.notyShow(...args); + }) +})); + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key) => key + }) +})); + +vi.mock('../../services/request', () => ({ + request: (...args) => mocks.request(...args) +})); + +vi.mock('../../api', () => ({ + authRequest: mocks.authRequest +})); + +vi.mock('../../coordinators/authCoordinator', () => ({ + runLoginSuccessFlow: (...args) => mocks.runLoginSuccessFlow(...args), + runLogoutFlow: (...args) => mocks.runLogoutFlow(...args) +})); + +vi.mock('../../coordinators/authAutoLoginCoordinator', () => ({ + runHandleAutoLoginFlow: (...args) => mocks.runHandleAutoLoginFlow(...args) +})); + +vi.mock('../../coordinators/userCoordinator', () => ({ + getCurrentUser: (...args) => mocks.getCurrentUser(...args) +})); + +vi.mock('../../services/appConfig', () => ({ + AppDebug: mocks.appDebug, + isApiLogSuppressed: vi.fn(() => true), + logWebRequest: vi.fn() +})); + +vi.mock('../../shared/utils', () => ({ + escapeTag: (value) => value +})); + +vi.mock('../../services/database', () => ({ + database: new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === '__esModule') return false; + return vi.fn().mockResolvedValue(undefined); + } + } + ) +})); + +vi.mock('../../services/security', () => ({ + default: mocks.security +})); + +vi.mock('../../services/webapi', () => ({ + default: mocks.webApiService +})); + +vi.mock('../../services/watchState', () => ({ + watchState: mockWatchState +})); + +vi.mock('../../services/config', () => ({ + default: mocks.configRepository +})); + +vi.mock('../../services/websocket', () => ({ + initWebsocket: (...args) => mocks.initWebsocket(...args), + closeWebSocket: vi.fn() +})); + +vi.mock('../../stores/settings/advanced', () => ({ + useAdvancedSettingsStore: () => mocks.advancedSettingsStore +})); + +vi.mock('../../stores/settings/general', () => ({ + useGeneralSettingsStore: () => mocks.generalSettingsStore +})); + +vi.mock('../../stores/modal', () => ({ + useModalStore: () => mocks.modalStore +})); + +vi.mock('../../stores/updateLoop', () => ({ + useUpdateLoopStore: () => mocks.updateLoopStore +})); + +vi.mock('../../stores/user', () => ({ + useUserStore: () => mocks.userStore +})); + +vi.mock('../../stores/vrcx', () => ({ + useVrcxStore: () => mocks.vrcxStore +})); + +vi.mock('worker-timers', () => ({ + setTimeout: vi.fn() +})); + +function flushPromises() { + return Promise.resolve().then(() => Promise.resolve()); +} + +function makeAuthError(message, status = 401) { + const err = new Error(message); + err.status = status; + err.endpoint = 'auth/user'; + return err; +} + +async function createAuthStore() { + setActivePinia(createPinia()); + const { useAuthStore } = await import('../auth'); + const store = useAuthStore(); + await flushPromises(); + return store; +} + +async function failManualLogin(store, error) { + mocks.authRequest.getConfig.mockResolvedValueOnce({ json: {} }); + mocks.request.mockRejectedValueOnce(error); + await store.login().catch(() => {}); + await flushPromises(); +} + +async function failSavedAccountLogin(store, error, overrides = {}) { + const savedUser = { + user: { id: 'usr_saved', displayName: 'Saved User' }, + loginParams: { + username: 'saved@example.com', + password: 'password', + endpoint: '', + websocket: '', + ...overrides + } + }; + mocks.authRequest.getConfig.mockResolvedValueOnce({ json: {} }); + mocks.request.mockRejectedValueOnce(error); + await store.relogin(savedUser).catch(() => {}); + await flushPromises(); +} + +async function succeedManualLogin(store) { + mocks.authRequest.getConfig.mockResolvedValueOnce({ json: {} }); + mocks.request.mockResolvedValueOnce({ + id: 'usr_me', + displayName: 'Tester' + }); + await store.login().catch(() => {}); + mockWatchState.isLoggedIn = true; + await nextTick(); + await flushPromises(); +} + +describe('useAuthStore login failure toast policy', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-23T00:00:00.000Z')); + vi.clearAllMocks(); + + mocks.configRepository.getString.mockImplementation( + (_key, defaultValue = '') => Promise.resolve(defaultValue) + ); + mocks.configRepository.getBool.mockImplementation( + (_key, defaultValue = false) => Promise.resolve(defaultValue) + ); + mocks.configRepository.setString.mockResolvedValue(undefined); + mocks.configRepository.setBool.mockResolvedValue(undefined); + mocks.configRepository.remove.mockResolvedValue(undefined); + mocks.authRequest.getConfig.mockResolvedValue({ json: {} }); + mocks.request.mockReset(); + mocks.request.mockResolvedValue({}); + mocks.toast.warning.mockReturnValue('login-network-issue-toast'); + mocks.runLoginSuccessFlow.mockReset(); + mocks.runLogoutFlow.mockReset(); + mocks.runHandleAutoLoginFlow.mockReset(); + mocks.getCurrentUser.mockReset(); + mocks.initWebsocket.mockReset(); + mocks.notyShow.mockReset(); + mocks.toast.warning.mockReset(); + mocks.toast.warning.mockReturnValue('login-network-issue-toast'); + mocks.toast.success.mockReset(); + mocks.toast.error.mockReset(); + mocks.toast.info.mockReset(); + mocks.toast.dismiss.mockReset(); + mocks.advancedSettingsStore.enablePrimaryPassword = false; + mockWatchState.isLoggedIn = false; + mockWatchState.isFriendsLoaded = false; + mockWatchState.isFavoritesLoaded = false; + mocks.userStore.currentUser = { + id: 'usr_me', + displayName: 'Tester' + }; + mocks.vrcxStore.waitForDatabaseInit.mockResolvedValue(true); + + globalThis.AppApi = { + CheckGameRunning: vi.fn(), + FlashWindow: vi.fn(), + IPCAnnounceStart: vi.fn(), + OpenLink: vi.fn() + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('shows a single warning toast on the third manual login failure inside 90 seconds', async () => { + const store = await createAuthStore(); + store.loginForm.username = 'tester@example.com'; + store.loginForm.password = 'password'; + + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:00:30.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.warning).not.toHaveBeenCalled(); + + vi.setSystemTime(new Date('2026-03-23T00:01:00.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.warning).toHaveBeenCalledTimes(1); + expect(mocks.toast.warning).toHaveBeenCalledWith( + 'message.auth.login_network_issue_hint_title', + expect.objectContaining({ + description: 'message.auth.login_network_issue_hint_description', + duration: Infinity, + action: expect.objectContaining({ + label: 'common.actions.open', + onClick: expect.any(Function) + }) + }) + ); + mocks.toast.warning.mock.calls[0][1].action.onClick(); + expect(globalThis.AppApi.OpenLink).toHaveBeenCalledWith( + links.troubleshootingAuthUserConnectionIssues + ); + }); + + test('does not count explicit password errors toward the warning threshold', async () => { + const store = await createAuthStore(); + store.loginForm.username = 'tester@example.com'; + store.loginForm.password = 'password'; + + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:00:20.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:00:40.000Z')); + await failManualLogin( + store, + makeAuthError('Invalid Username/Email or Password', 401) + ); + + expect(mocks.toast.warning).not.toHaveBeenCalled(); + }); + + test('does not trigger the warning during auto-login retries', async () => { + const store = await createAuthStore(); + store.setAttemptingAutoLogin(true); + + const savedUser = { + user: { id: 'usr_me', displayName: 'Tester' }, + loginParams: { + username: 'tester@example.com', + password: 'password', + endpoint: '', + websocket: '' + } + }; + + mocks.authRequest.getConfig.mockResolvedValue({ json: {} }); + mocks.request.mockRejectedValueOnce(makeAuthError('Unauthorized', 401)); + await store.relogin(savedUser).catch(() => {}); + await flushPromises(); + + expect(mocks.toast.warning).not.toHaveBeenCalled(); + expect(mocks.runHandleAutoLoginFlow).not.toHaveBeenCalled(); + }); + + test('counts saved-account relogin failures together with manual login failures', async () => { + const store = await createAuthStore(); + store.loginForm.username = 'tester@example.com'; + store.loginForm.password = 'password'; + + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:00:30.000Z')); + await failSavedAccountLogin(store, makeAuthError('Unauthorized', 401), { + username: 'tester@example.com' + }); + + expect(mocks.toast.warning).not.toHaveBeenCalled(); + + vi.setSystemTime(new Date('2026-03-23T00:01:00.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.warning).toHaveBeenCalledTimes(1); + }); + + test('resets the failure window after a successful login', async () => { + const store = await createAuthStore(); + store.loginForm.username = 'tester@example.com'; + store.loginForm.password = 'password'; + + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:00:30.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:01:00.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.warning).toHaveBeenCalledTimes(1); + + vi.setSystemTime(new Date('2026-03-23T00:01:05.000Z')); + await succeedManualLogin(store); + expect(mocks.toast.dismiss).toHaveBeenCalledWith( + 'login-network-issue-toast' + ); + + mocks.toast.warning.mockClear(); + mocks.toast.dismiss.mockClear(); + + mockWatchState.isLoggedIn = false; + await nextTick(); + + vi.setSystemTime(new Date('2026-03-23T00:01:10.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:01:30.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.warning).not.toHaveBeenCalled(); + }); + + test('resets the failure window when the user edits the login form', async () => { + const store = await createAuthStore(); + store.loginForm.username = 'tester@example.com'; + store.loginForm.password = 'password'; + + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:00:20.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + expect(mocks.toast.warning).not.toHaveBeenCalled(); + + store.loginForm.username = 'tester2@example.com'; + await nextTick(); + + vi.setSystemTime(new Date('2026-03-23T00:00:35.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.warning).not.toHaveBeenCalled(); + }); + + test('dismisses the sticky warning when credentials change before the next submit', async () => { + const store = await createAuthStore(); + store.loginForm.username = 'tester@example.com'; + store.loginForm.password = 'password'; + + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:00:30.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + vi.setSystemTime(new Date('2026-03-23T00:01:00.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.warning).toHaveBeenCalledTimes(1); + + store.loginForm.password = 'new-password'; + vi.setSystemTime(new Date('2026-03-23T00:01:10.000Z')); + await failManualLogin(store, makeAuthError('Unauthorized', 401)); + + expect(mocks.toast.dismiss).toHaveBeenCalledWith( + 'login-network-issue-toast' + ); + expect(mocks.toast.warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/stores/auth.js b/src/stores/auth.js index b8e04170..1b742b3c 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -13,6 +13,7 @@ import { AppDebug } from '../services/appConfig'; import { authRequest } from '../api'; import { database } from '../services/database'; import { escapeTag } from '../shared/utils'; +import { links } from '../shared/constants/link'; import { initWebsocket } from '../services/websocket'; import { request } from '../services/request'; import { runHandleAutoLoginFlow } from '../coordinators/authAutoLoginCoordinator'; @@ -73,12 +74,21 @@ export const useAuthStore = defineStore('Auth', () => { const enableCustomEndpoint = ref(false); const attemptingAutoLogin = ref(false); + const loginNetworkIssueHintState = reactive({ + hinted: false, + timestamps: [], + lastAttemptFingerprint: '' + }); + const loginNetworkIssueHintToastId = ref(null); + const loginNetworkIssueHintWindowMs = 90000; + const loginNetworkIssueHintThreshold = 3; watch( [() => watchState.isLoggedIn, () => userStore.currentUser], ([isLoggedIn, currentUser]) => { twoFactorAuthDialogVisible.value = false; if (isLoggedIn) { + resetLoginNetworkIssueHintState(); updateStoredUser(currentUser); new Noty({ type: 'success', @@ -91,6 +101,11 @@ export const useAuthStore = defineStore('Auth', () => { { flush: 'sync' } ); + watch( + [() => loginForm.value.username, () => loginForm.value.password], + resetLoginNetworkIssueHintState + ); + watch( () => watchState.isFriendsLoaded, (isFriendsLoaded) => { @@ -225,7 +240,9 @@ export const useAuthStore = defineStore('Auth', () => { ); if (user) { delete user.cookies; - await relogin(user); + await relogin(user, { + shouldTrackLoginNetworkIssueHint: false + }); } } } @@ -241,7 +258,9 @@ export const useAuthStore = defineStore('Auth', () => { if (user) { await webApiService.clearCookies(); delete user.cookies; - relogin(user).then(() => { + relogin(user, { + shouldTrackLoginNetworkIssueHint: false + }).then(() => { toast.success(t('message.auth.email_2fa_resent')); }); return; @@ -250,6 +269,102 @@ export const useAuthStore = defineStore('Auth', () => { toast.error(t('message.auth.email_2fa_no_credentials')); } + /** + * + */ + function resetLoginNetworkIssueHintState() { + if (loginNetworkIssueHintToastId.value) { + toast.dismiss(loginNetworkIssueHintToastId.value); + loginNetworkIssueHintToastId.value = null; + } + loginNetworkIssueHintState.hinted = false; + loginNetworkIssueHintState.timestamps = []; + loginNetworkIssueHintState.lastAttemptFingerprint = ''; + } + + /** + * + * @param {{ username?: string, password?: string, endpoint?: string, websocket?: string }} [params] + * @returns {string} + */ + function buildLoginNetworkIssueAttemptFingerprint(params = {}) { + return [ + params.username ?? '', + params.password ?? '', + params.endpoint ?? '', + params.websocket ?? '' + ].join('\u0000'); + } + + /** + * + * @param {{ username?: string, password?: string, endpoint?: string, websocket?: string }} params + */ + function resetLoginNetworkIssueHintStateIfCredentialsChanged(params) { + const fingerprint = buildLoginNetworkIssueAttemptFingerprint(params); + if ( + loginNetworkIssueHintState.lastAttemptFingerprint && + loginNetworkIssueHintState.lastAttemptFingerprint !== fingerprint + ) { + resetLoginNetworkIssueHintState(); + } + loginNetworkIssueHintState.lastAttemptFingerprint = fingerprint; + } + + /** + * + * @param {Error & {status?: number, endpoint?: string}} err + * @returns {boolean} + */ + function shouldCountLoginFailureForNetworkHint(err) { + if (!err || err.endpoint !== 'auth/user') { + return false; + } + if ( + typeof err.message === 'string' && + err.message.includes('Invalid Username/Email or Password') + ) { + return false; + } + return [401].includes(err.status); + } + + /** + * + */ + function maybeShowLoginNetworkIssueHint() { + const now = Date.now(); + loginNetworkIssueHintState.timestamps = + loginNetworkIssueHintState.timestamps.filter( + (timestamp) => timestamp > now - loginNetworkIssueHintWindowMs + ); + loginNetworkIssueHintState.timestamps.push(now); + if ( + loginNetworkIssueHintState.hinted || + loginNetworkIssueHintState.timestamps.length < + loginNetworkIssueHintThreshold + ) { + return; + } + loginNetworkIssueHintState.hinted = true; + loginNetworkIssueHintToastId.value = toast.warning( + t('message.auth.login_network_issue_hint_title'), + { + description: t( + 'message.auth.login_network_issue_hint_description' + ), + duration: Infinity, + action: { + label: t('common.actions.open'), + onClick: () => + AppApi.OpenLink( + links.troubleshootingAuthUserConnectionIssues + ) + } + } + ); + } + /** * */ @@ -471,7 +586,10 @@ export const useAuthStore = defineStore('Auth', () => { * * @param user */ - async function relogin(user) { + async function relogin( + user, + { shouldTrackLoginNetworkIssueHint = !attemptingAutoLogin.value } = {} + ) { const { loginParams } = user; if (user.cookies) { await webApiService.setCookies(user.cookies); @@ -488,6 +606,14 @@ export const useAuthStore = defineStore('Auth', () => { loginForm.value.loading = true; try { let password = loginParams.password; + if (shouldTrackLoginNetworkIssueHint) { + resetLoginNetworkIssueHintStateIfCredentialsChanged({ + username: loginParams.username, + password, + endpoint: loginParams.endpoint, + websocket: loginParams.websocket + }); + } if (advancedSettingsStore.enablePrimaryPassword) { try { password = await checkPrimaryPassword(loginParams); @@ -506,6 +632,12 @@ export const useAuthStore = defineStore('Auth', () => { websocket: loginParams.websocket }); } catch (err) { + if ( + shouldTrackLoginNetworkIssueHint && + shouldCountLoginFailureForNetworkHint(err) + ) { + maybeShowLoginNetworkIssueHint(); + } await handleLogoutEvent(); throw err; } @@ -549,6 +681,12 @@ export const useAuthStore = defineStore('Auth', () => { await webApiService.clearCookies(); if (!loginForm.value.loading) { loginForm.value.loading = true; + resetLoginNetworkIssueHintStateIfCredentialsChanged({ + username: loginForm.value.username, + password: loginForm.value.password, + endpoint: loginForm.value.endpoint, + websocket: loginForm.value.websocket + }); if (loginForm.value.endpoint) { AppDebug.endpointDomain = loginForm.value.endpoint; AppDebug.websocketDomain = loginForm.value.websocket; @@ -589,27 +727,43 @@ export const useAuthStore = defineStore('Auth', () => { loginForm.value.password, value ); - await authLogin({ - username: loginForm.value.username, - password: loginForm.value.password, - endpoint: loginForm.value.endpoint, - websocket: loginForm.value.websocket, - saveCredentials: - loginForm.value.saveCredentials, - cipher: pwd - }); + try { + await authLogin({ + username: loginForm.value.username, + password: loginForm.value.password, + endpoint: loginForm.value.endpoint, + websocket: loginForm.value.websocket, + saveCredentials: + loginForm.value.saveCredentials, + cipher: pwd + }); + } catch (err) { + if ( + shouldCountLoginFailureForNetworkHint(err) + ) { + maybeShowLoginNetworkIssueHint(); + } + throw err; + } } } catch { // prompt cancelled or crypto failed } } else { - await authLogin({ - username: loginForm.value.username, - password: loginForm.value.password, - endpoint: loginForm.value.endpoint, - websocket: loginForm.value.websocket, - saveCredentials: loginForm.value.saveCredentials - }); + try { + await authLogin({ + username: loginForm.value.username, + password: loginForm.value.password, + endpoint: loginForm.value.endpoint, + websocket: loginForm.value.websocket, + saveCredentials: loginForm.value.saveCredentials + }); + } catch (err) { + if (shouldCountLoginFailureForNetworkHint(err)) { + maybeShowLoginNetworkIssueHint(); + } + throw err; + } } } finally { loginForm.value.loading = false;