fix session invalidation on invalid credentials (#2230)

This commit is contained in:
jeffvli
2026-07-18 23:51:52 -07:00
parent a378699b12
commit 61eb998de1
5 changed files with 80 additions and 17 deletions
+15 -4
View File
@@ -4,6 +4,7 @@ import qs from 'qs';
import { z } from 'zod'; import { z } from 'zod';
import i18n from '/@/i18n/i18n'; import i18n from '/@/i18n/i18n';
import { authenticationFailure } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
import { ssType } from '/@/shared/api/subsonic/subsonic-types'; 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 { ServerListItemWithCredential } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
const SUBSONIC_AUTH_ERROR_CODE = 40;
const c = initContract(); const c = initContract();
export const contract = c.router({ export const contract = c.router({
@@ -392,17 +395,25 @@ axiosClient.interceptors.response.use(
if (data['subsonic-response'].status !== 'ok') { if (data['subsonic-response'].status !== 'ok') {
// Suppress code related to non-linked lastfm or spotify from Navidrome // Suppress code related to non-linked lastfm or spotify from Navidrome
if (data['subsonic-response'].error.code !== 0) { 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({ toast.error({
message: data['subsonic-response'].error.message, message: errorMessage,
title: i18n.t('error.genericError') as string, title: i18n.t('error.genericError') as string,
}); });
} }
// Since we do status === 200, override this value with the error code // Since we do status === 200, override this value with the error code
response.status = data['subsonic-response'].error.code; response.status = numericCode || errorCode;
} }
} }
+35 -8
View File
@@ -1,17 +1,44 @@
import isElectron from 'is-electron';
import i18n from '/@/i18n/i18n';
import { useAuthStore } from '/@/renderer/store'; import { useAuthStore } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { ServerListItem } from '/@/shared/types/types'; 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({ toast.error({
message: 'Your session has expired.', id: AUTH_FAILURE_TOAST_ID,
message: message ?? (i18n.t('error.sessionExpiredError') as string),
}); });
if (currentServer) { if (!serverId) {
const serverId = currentServer.id; return;
const token = currentServer.ndCredential; }
console.error(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined }); console.error(
useAuthStore.getState().actions.setCurrentServer(null); `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,
});
} }
}; };
@@ -92,7 +92,9 @@ function ServerSelector() {
const isNavidromeExpired = const isNavidromeExpired =
server.type === ServerType.NAVIDROME && !server.ndCredential; server.type === ServerType.NAVIDROME && !server.ndCredential;
const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; 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 = const logo =
server.type === ServerType.NAVIDROME server.type === ServerType.NAVIDROME
@@ -113,7 +115,11 @@ function ServerSelector() {
return handleCredentialsModal(server); return handleCredentialsModal(server);
}} }}
size="lg" size="lg"
variant={server.id === currentServer?.id ? 'filled' : 'default'} variant={
server.id === currentServer?.id && !isSessionExpired
? 'filled'
: 'default'
}
> >
<Group className={styles.serverRow} justify="space-between"> <Group className={styles.serverRow} justify="space-between">
<Group> <Group>
@@ -114,7 +114,9 @@ export const ServerSelectorItems = () => {
const isNavidromeExpired = const isNavidromeExpired =
server.type === ServerType.NAVIDROME && !server.ndCredential; server.type === ServerType.NAVIDROME && !server.ndCredential;
const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; 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 = const logo =
server.type === ServerType.NAVIDROME server.type === ServerType.NAVIDROME
@@ -125,7 +127,7 @@ export const ServerSelectorItems = () => {
return ( return (
<DropdownMenu.Item <DropdownMenu.Item
isSelected={currentServer?.id === server.id} isSelected={currentServer?.id === server.id && !isSessionExpired}
key={`server-${server.id}`} key={`server-${server.id}`}
leftSection={<img src={logo} style={{ height: '1rem', width: '1rem' }} />} leftSection={<img src={logo} style={{ height: '1rem', width: '1rem' }} />}
onClick={() => { onClick={() => {
+18 -1
View File
@@ -6,13 +6,17 @@ import { normalizeServerUrl } from '/@/renderer/features/action-required/utils/s
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties'; import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { useAuthStore, useAuthStoreActions } from '/@/renderer/store'; import { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
import { ServerType } from '/@/shared/types/domain-types';
export const AppOutlet = () => { export const AppOutlet = () => {
const currentServer = useAuthStore( const currentServer = useAuthStore(
(state) => (state) =>
state.currentServer state.currentServer
? { ? {
credential: state.currentServer.credential,
id: state.currentServer.id, id: state.currentServer.id,
ndCredential: state.currentServer.ndCredential,
type: state.currentServer.type,
url: state.currentServer.url, url: state.currentServer.url,
} }
: null, : null,
@@ -31,6 +35,12 @@ export const AppOutlet = () => {
return configuredUrl !== persistedUrl; return configuredUrl !== persistedUrl;
}, [currentServer]); }, [currentServer]);
const hasMissingCredentials = Boolean(
currentServer &&
(!currentServer.credential ||
(currentServer.type === ServerType.NAVIDROME && !currentServer.ndCredential)),
);
useEffect(() => { useEffect(() => {
if (hasServerLockMismatch && currentServer && window.SERVER_URL) { if (hasServerLockMismatch && currentServer && window.SERVER_URL) {
updateServer(currentServer.id, { updateServer(currentServer.id, {
@@ -40,7 +50,14 @@ export const AppOutlet = () => {
} }
}, [currentServer, hasServerLockMismatch, setCurrentServer, updateServer]); }, [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) { if (isActionsRequired) {
return <Navigate replace to={AppRoute.ACTION_REQUIRED} />; return <Navigate replace to={AppRoute.ACTION_REQUIRED} />;