From a51299e81809bdfbeef7bd0fa63bbd391048e777 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 18 Jul 2026 21:31:47 -0700 Subject: [PATCH] add logout button --- src/i18n/locales/en.json | 1 + src/renderer/api/subsonic/subsonic-api.ts | 17 ++++++-- .../components/server-selector-items.tsx | 42 +++++++++++++++---- src/renderer/store/auth.store.ts | 18 ++++++++ 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0fc71d493..4cd4fe0c5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -502,6 +502,7 @@ "goBack": "Go back", "goForward": "Go forward", "manageServers": "Manage servers", + "logout": "Logout", "privateModeOff": "Turn off private mode", "privateModeOn": "Turn on private mode", "openBrowserDevtools": "Open browser devtools", diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 2badcf356..71ef3812a 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 { useAuthStore } from '/@/renderer/store'; import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { hasFeature } from '/@/shared/api/utils'; @@ -391,10 +392,14 @@ 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) { - toast.error({ - message: data['subsonic-response'].error.message, - title: i18n.t('error.genericError') as string, - }); + const isAuthenticated = Boolean(useAuthStore.getState().currentServer?.credential); + + if (isAuthenticated) { + toast.error({ + message: data['subsonic-response'].error.message, + 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; @@ -470,6 +475,10 @@ export const ssApiClient = (args: { return initClient(contract, { api: async ({ body, headers, method, path, rawQuery }) => { + if (server && !server.credential) { + throw new Error('Not authenticated'); + } + let baseUrl: string | undefined; const authParams: Record = {}; diff --git a/src/renderer/features/sidebar/components/server-selector-items.tsx b/src/renderer/features/sidebar/components/server-selector-items.tsx index 30fd9d38c..1b296e2e9 100644 --- a/src/renderer/features/sidebar/components/server-selector-items.tsx +++ b/src/renderer/features/sidebar/components/server-selector-items.tsx @@ -1,5 +1,6 @@ import { openModal } from '@mantine/modals'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; @@ -17,12 +18,14 @@ import { Icon } from '/@/shared/components/icon/icon'; import { ServerListItemWithCredential, ServerType } from '/@/shared/types/domain-types'; import { ServerFeature } from '/@/shared/types/features-types'; +const localSettings = isElectron() ? window.api.localSettings : null; + export const ServerSelectorItems = () => { const { t } = useTranslation(); const navigate = useNavigate(); const currentServer = useCurrentServer(); const serverList = useServerList(); - const { setCurrentServer, setMusicFolderId } = useAuthStoreActions(); + const { logout, setCurrentServer, setMusicFolderId } = useAuthStoreActions(); const { data: musicFolders } = useQuery( currentServer @@ -89,6 +92,21 @@ export const ServerSelectorItems = () => { }); }; + const handleLogout = async () => { + const serverId = currentServer.id; + + // Cancel in-flight requests before clearing credentials so they don't + // retry/refetch with an empty token and surface auth error toasts. + await queryClient.cancelQueries(); + localSettings?.passwordRemove(serverId); + logout(); + + // Defer cache clear until after authenticated routes unmount. + setTimeout(() => { + queryClient.clear(); + }, 0); + }; + return ( <> {t('page.appMenu.selectServer')} @@ -121,13 +139,23 @@ export const ServerSelectorItems = () => { ); })} {!isServerLock() && ( - } - onClick={handleManageServersModal} - > - {t('page.appMenu.manageServers')} - + <> + + } + onClick={handleManageServersModal} + > + {t('page.appMenu.manageServers')} + + } + onClick={handleLogout} + > + {t('page.appMenu.logout')} + + )} + {!isServerLock() && <>} {musicFolders && musicFolders.items.length > 0 && ( <> diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 09dc25e6a..2a5365778 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -12,6 +12,7 @@ export interface AuthSlice extends AuthState { addServer: (args: ServerListItemWithCredential) => void; deleteServer: (id: string) => void; getServer: (id: string) => null | ServerListItemWithCredential; + logout: () => void; setCurrentServer: (server: null | ServerListItemWithCredential) => void; setMusicFolderId: (musicFolderId: string[] | undefined) => void; updateServer: (id: string, args: Partial) => void; @@ -48,6 +49,23 @@ export const useAuthStore = createWithEqualityFn()( if (server) return server; return null; }, + logout: () => { + set((state) => { + const currentServer = state.currentServer; + if (!currentServer) { + return; + } + + const server = state.serverList[currentServer.id]; + if (server) { + server.credential = ''; + server.ndCredential = undefined; + server.savePassword = false; + } + + state.currentServer = null; + }); + }, setCurrentServer: (server) => { set((state) => { state.currentServer = server;