diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 71ef3812a..1f563963d 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -4,6 +4,7 @@ import qs from 'qs'; import { z } from 'zod'; import i18n from '/@/i18n/i18n'; +import { authenticationFailure } from '/@/renderer/api/utils'; import { useAuthStore } from '/@/renderer/store'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; @@ -12,6 +13,8 @@ import { toast } from '/@/shared/components/toast/toast'; import { ServerListItemWithCredential } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; +const SUBSONIC_AUTH_ERROR_CODE = 40; + const c = initContract(); export const contract = c.router({ @@ -392,17 +395,25 @@ axiosClient.interceptors.response.use( if (data['subsonic-response'].status !== 'ok') { // Suppress code related to non-linked lastfm or spotify from Navidrome if (data['subsonic-response'].error.code !== 0) { - const isAuthenticated = Boolean(useAuthStore.getState().currentServer?.credential); + const currentServer = useAuthStore.getState().currentServer; + const isAuthenticated = Boolean(currentServer?.credential); + const errorCode = data['subsonic-response'].error.code; + const errorMessage = data['subsonic-response'].error.message as string | undefined; + // Servers may return code as string ("40") — coerce before comparing + const numericCode = Number(errorCode); + const isAuthError = numericCode === SUBSONIC_AUTH_ERROR_CODE; - if (isAuthenticated) { + if (isAuthenticated && isAuthError) { + authenticationFailure(currentServer, errorMessage); + } else if (isAuthenticated) { toast.error({ - message: data['subsonic-response'].error.message, + message: errorMessage, title: i18n.t('error.genericError') as string, }); } // Since we do status === 200, override this value with the error code - response.status = data['subsonic-response'].error.code; + response.status = numericCode || errorCode; } } diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 0185926e7..01155e7b3 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -1,17 +1,44 @@ +import isElectron from 'is-electron'; + +import i18n from '/@/i18n/i18n'; import { useAuthStore } from '/@/renderer/store'; import { toast } from '/@/shared/components/toast/toast'; import { ServerListItem } from '/@/shared/types/types'; -export const authenticationFailure = (currentServer: null | ServerListItem) => { +const localSettings = isElectron() ? window.api.localSettings : null; + +const AUTH_FAILURE_TOAST_ID = 'auth-failure'; + +export const authenticationFailure = ( + currentServer: null | ServerListItem, + message?: string, +) => { + const store = useAuthStore.getState(); + const serverId = currentServer?.id ?? store.currentServer?.id; + toast.error({ - message: 'Your session has expired.', + id: AUTH_FAILURE_TOAST_ID, + message: message ?? (i18n.t('error.sessionExpiredError') as string), }); - if (currentServer) { - const serverId = currentServer.id; - const token = currentServer.ndCredential; - console.error(`token is expired: ${token}`); - useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined }); - useAuthStore.getState().actions.setCurrentServer(null); + if (!serverId) { + return; + } + + console.error( + `token is expired: ${currentServer?.ndCredential ?? store.currentServer?.ndCredential}`, + ); + localSettings?.passwordRemove(serverId); + + // logout() clears credentials on the server list entry and sets currentServer to null. + // If there is no current server, still clear the matching server list entry. + if (store.currentServer) { + store.actions.logout(); + } else { + store.actions.updateServer(serverId, { + credential: '', + ndCredential: undefined, + savePassword: false, + }); } }; diff --git a/src/renderer/features/action-required/components/server-required.tsx b/src/renderer/features/action-required/components/server-required.tsx index 3f85f66fb..d01afdfe5 100644 --- a/src/renderer/features/action-required/components/server-required.tsx +++ b/src/renderer/features/action-required/components/server-required.tsx @@ -92,7 +92,9 @@ function ServerSelector() { const isNavidromeExpired = server.type === ServerType.NAVIDROME && !server.ndCredential; const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; - const isSessionExpired = isNavidromeExpired || isJellyfinExpired; + const isSubsonicExpired = server.type === ServerType.SUBSONIC && !server.credential; + const isSessionExpired = + isNavidromeExpired || isJellyfinExpired || isSubsonicExpired; const logo = server.type === ServerType.NAVIDROME @@ -113,7 +115,11 @@ function ServerSelector() { return handleCredentialsModal(server); }} size="lg" - variant={server.id === currentServer?.id ? 'filled' : 'default'} + variant={ + server.id === currentServer?.id && !isSessionExpired + ? 'filled' + : 'default' + } > diff --git a/src/renderer/features/sidebar/components/server-selector-items.tsx b/src/renderer/features/sidebar/components/server-selector-items.tsx index 1b296e2e9..9e04003b6 100644 --- a/src/renderer/features/sidebar/components/server-selector-items.tsx +++ b/src/renderer/features/sidebar/components/server-selector-items.tsx @@ -114,7 +114,9 @@ export const ServerSelectorItems = () => { const isNavidromeExpired = server.type === ServerType.NAVIDROME && !server.ndCredential; const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; - const isSessionExpired = isNavidromeExpired || isJellyfinExpired; + const isSubsonicExpired = server.type === ServerType.SUBSONIC && !server.credential; + const isSessionExpired = + isNavidromeExpired || isJellyfinExpired || isSubsonicExpired; const logo = server.type === ServerType.NAVIDROME @@ -125,7 +127,7 @@ export const ServerSelectorItems = () => { return ( } onClick={() => { diff --git a/src/renderer/router/app-outlet.tsx b/src/renderer/router/app-outlet.tsx index f97a8f134..54de24d92 100644 --- a/src/renderer/router/app-outlet.tsx +++ b/src/renderer/router/app-outlet.tsx @@ -6,13 +6,17 @@ import { normalizeServerUrl } from '/@/renderer/features/action-required/utils/s import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import { AppRoute } from '/@/renderer/router/routes'; import { useAuthStore, useAuthStoreActions } from '/@/renderer/store'; +import { ServerType } from '/@/shared/types/domain-types'; export const AppOutlet = () => { const currentServer = useAuthStore( (state) => state.currentServer ? { + credential: state.currentServer.credential, id: state.currentServer.id, + ndCredential: state.currentServer.ndCredential, + type: state.currentServer.type, url: state.currentServer.url, } : null, @@ -31,6 +35,12 @@ export const AppOutlet = () => { return configuredUrl !== persistedUrl; }, [currentServer]); + const hasMissingCredentials = Boolean( + currentServer && + (!currentServer.credential || + (currentServer.type === ServerType.NAVIDROME && !currentServer.ndCredential)), + ); + useEffect(() => { if (hasServerLockMismatch && currentServer && window.SERVER_URL) { updateServer(currentServer.id, { @@ -40,7 +50,14 @@ export const AppOutlet = () => { } }, [currentServer, hasServerLockMismatch, setCurrentServer, updateServer]); - const isActionsRequired = !currentServer || hasServerLockMismatch; + // Clear selection when credentials were wiped but currentServer was left set + useEffect(() => { + if (hasMissingCredentials) { + setCurrentServer(null); + } + }, [currentServer?.id, currentServer?.type, hasMissingCredentials, setCurrentServer]); + + const isActionsRequired = !currentServer || hasServerLockMismatch || hasMissingCredentials; if (isActionsRequired) { return ;