mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-18 22:33:50 +02:00
feat: add troubleshooting hint after multiple 401 login failures
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
467
src/stores/__tests__/authLoginFailureToast.test.js
Normal file
467
src/stores/__tests__/authLoginFailureToast.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user