feat: add troubleshooting hint after multiple 401 login failures

This commit is contained in:
pa
2026-03-23 21:15:06 +09:00
parent 12b7423716
commit 1c346d82bc
4 changed files with 645 additions and 20 deletions

View File

@@ -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}",

View File

@@ -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'
};

View File

@@ -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);
});
});

View File

@@ -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;