diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index e89f17a4e9..74bee64e0a 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -25,7 +25,7 @@ test.describe('Registration', () => { // login await expect(page).toHaveTitle(/Login/); - await page.goto('/auth/login'); + await page.goto('/auth/login?autoLaunch=0'); await page.getByLabel('Email').fill('admin@immich.app'); await page.getByLabel('Password').fill('password'); await page.getByRole('button', { name: 'Login' }).click(); @@ -59,7 +59,7 @@ test.describe('Registration', () => { await context.clearCookies(); // login - await page.goto('/auth/login'); + await page.goto('/auth/login?autoLaunch=0'); await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Password').fill('password'); await page.getByRole('button', { name: 'Login' }).click(); @@ -72,7 +72,7 @@ test.describe('Registration', () => { await page.getByRole('button', { name: 'Change password' }).click(); // login with new password - await expect(page).toHaveURL('/auth/login'); + await expect(page).toHaveURL('/auth/login?autoLaunch=0'); await page.getByLabel('Email').fill('user@immich.cloud'); await page.getByLabel('Password').fill('new-password'); await page.getByRole('button', { name: 'Login' }).click(); diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index f7f9b877f3..90f6b3c55b 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -10,11 +10,13 @@ import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import { AppRoute } from '$lib/constants'; + import { authManager } from '$lib/stores/auth-manager.svelte'; + import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { featureFlags } from '$lib/stores/server-config.store'; + import { sidebarStore } from '$lib/stores/sidebar.svelte'; import { user } from '$lib/stores/user.store'; import { userInteraction } from '$lib/stores/user.svelte'; - import { handleLogout } from '$lib/utils/auth'; - import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk'; + import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk'; import { Button, IconButton } from '@immich/ui'; import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js'; import { onMount } from 'svelte'; @@ -23,8 +25,6 @@ import ThemeButton from '../theme-button.svelte'; import UserAvatar from '../user-avatar.svelte'; import AccountInfoPanel from './account-info-panel.svelte'; - import { sidebarStore } from '$lib/stores/sidebar.svelte'; - import { mobileDevice } from '$lib/stores/mobile-device.svelte'; interface Props { showUploadButton?: boolean; @@ -38,11 +38,6 @@ let shouldShowHelpPanel = $state(false); let innerWidth: number = $state(0); - const onLogout = async () => { - const { redirectUri } = await logout(); - await handleLogout(redirectUri); - }; - let info: ServerAboutResponseDto | undefined = $state(); onMount(async () => { @@ -183,7 +178,7 @@ {/if} {#if shouldShowAccountInfoPanel} - <AccountInfoPanel {onLogout} /> + <AccountInfoPanel onLogout={() => authManager.logout()} /> {/if} </div> </section> diff --git a/web/src/lib/stores/auth-manager.svelte.ts b/web/src/lib/stores/auth-manager.svelte.ts new file mode 100644 index 0000000000..72c966df0b --- /dev/null +++ b/web/src/lib/stores/auth-manager.svelte.ts @@ -0,0 +1,33 @@ +import { goto } from '$app/navigation'; +import { AppRoute } from '$lib/constants'; +import { eventManager } from '$lib/stores/event-manager.svelte'; +import { logout } from '@immich/sdk'; + +class AuthManager { + async logout() { + let redirectUri; + + try { + const response = await logout(); + if (response.redirectUri) { + redirectUri = response.redirectUri; + } + } catch (error) { + console.log('Error logging out:', error); + } + + redirectUri = redirectUri ?? AppRoute.AUTH_LOGIN; + + try { + if (redirectUri.startsWith('/')) { + await goto(redirectUri); + } else { + globalThis.location.href = redirectUri; + } + } finally { + eventManager.emit('auth.logout'); + } + } +} + +export const authManager = new AuthManager(); diff --git a/web/src/lib/stores/event-manager.svelte.ts b/web/src/lib/stores/event-manager.svelte.ts new file mode 100644 index 0000000000..09e9b45c3c --- /dev/null +++ b/web/src/lib/stores/event-manager.svelte.ts @@ -0,0 +1,54 @@ +type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void; + +class EventManager<EventMap extends Record<string, unknown[]>> { + private listeners: { + [K in keyof EventMap]?: { + listener: Listener<EventMap, K>; + once?: boolean; + }[]; + } = {}; + + on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) { + return this.addListener(key, listener, false); + } + + once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) { + return this.addListener(key, listener, true); + } + + off<K extends keyof EventMap>(key: K, listener: Listener<EventMap, K>) { + if (this.listeners[key]) { + this.listeners[key] = this.listeners[key].filter((item) => item.listener !== listener); + } + + return this; + } + + emit<T extends keyof EventMap>(key: T, ...params: EventMap[T]) { + if (!this.listeners[key]) { + return; + } + + for (const { listener } of this.listeners[key]) { + listener(...params); + } + + // remove one time listeners + this.listeners[key] = this.listeners[key].filter((item) => !item.once); + } + + private addListener<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void, once: boolean) { + if (!this.listeners[key]) { + this.listeners[key] = []; + } + + this.listeners[key].push({ listener, once }); + + return this; + } +} + +export const eventManager = new EventManager<{ + 'user.login': []; + 'auth.logout': []; +}>(); diff --git a/web/src/lib/stores/folders.svelte.ts b/web/src/lib/stores/folders.svelte.ts index fb59687a38..c6fc7808b2 100644 --- a/web/src/lib/stores/folders.svelte.ts +++ b/web/src/lib/stores/folders.svelte.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import { getAssetsByOriginalPath, getUniqueOriginalPaths, @@ -16,6 +17,10 @@ class FoldersStore { uniquePaths = $state<string[]>([]); assets = $state<AssetCache>({}); + constructor() { + eventManager.on('auth.logout', () => this.clearCache()); + } + async fetchUniquePaths() { if (this.initialized) { return; diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index 7173b43d06..ef3f87a3aa 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import { asLocalTimeISO } from '$lib/utils/date-time'; import { type AssetResponseDto, @@ -24,6 +25,10 @@ export type MemoryAsset = MemoryIndex & { }; class MemoryStoreSvelte { + constructor() { + eventManager.on('auth.logout', () => this.clearCache()); + } + memories = $state<MemoryResponseDto[]>([]); private initialized = false; private memoryAssets = $derived.by(() => { diff --git a/web/src/lib/stores/search.svelte.ts b/web/src/lib/stores/search.svelte.ts index 7d012922ca..f334f53460 100644 --- a/web/src/lib/stores/search.svelte.ts +++ b/web/src/lib/stores/search.svelte.ts @@ -1,7 +1,13 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; + class SearchStore { savedSearchTerms = $state<string[]>([]); isSearchEnabled = $state(false); + constructor() { + eventManager.on('auth.logout', () => this.clearCache()); + } + clearCache() { this.savedSearchTerms = []; this.isSearchEnabled = false; diff --git a/web/src/lib/stores/user.store.ts b/web/src/lib/stores/user.store.ts index 5bffc08b80..fe2288c252 100644 --- a/web/src/lib/stores/user.store.ts +++ b/web/src/lib/stores/user.store.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk'; import { writable } from 'svelte/store'; @@ -14,3 +15,5 @@ export const resetSavedUser = () => { preferences.set(undefined as unknown as UserPreferencesResponseDto); purchaseStore.setPurchaseStatus(false); }; + +eventManager.on('auth.logout', () => resetSavedUser()); diff --git a/web/src/lib/stores/user.svelte.ts b/web/src/lib/stores/user.svelte.ts index 71b2cdd847..093d90e4b5 100644 --- a/web/src/lib/stores/user.svelte.ts +++ b/web/src/lib/stores/user.svelte.ts @@ -1,3 +1,4 @@ +import { eventManager } from '$lib/stores/event-manager.svelte'; import type { AlbumResponseDto, ServerAboutResponseDto, @@ -19,8 +20,10 @@ const defaultUserInteraction: UserInteractions = { serverInfo: undefined, }; -export const resetUserInteraction = () => { +export const userInteraction = $state<UserInteractions>(defaultUserInteraction); + +const reset = () => { Object.assign(userInteraction, defaultUserInteraction); }; -export const userInteraction = $state<UserInteractions>(defaultUserInteraction); +eventManager.on('auth.logout', () => reset()); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index d398ca52a9..90228a5cbd 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,5 +1,4 @@ -import { AppRoute } from '$lib/constants'; -import { handleLogout } from '$lib/utils/auth'; +import { authManager } from '$lib/stores/auth-manager.svelte'; import { createEventEmitter } from '$lib/utils/eventemitter'; import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; @@ -50,7 +49,7 @@ websocket .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) - .on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN)) + .on('on_session_delete', () => authManager.logout()) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index 22b92dd988..9b78c345e2 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,11 +1,7 @@ import { browser } from '$app/environment'; -import { goto } from '$app/navigation'; -import { foldersStore } from '$lib/stores/folders.svelte'; -import { memoryStore } from '$lib/stores/memory.store.svelte'; import { purchaseStore } from '$lib/stores/purchase.store'; -import { searchStore } from '$lib/stores/search.svelte'; -import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; -import { resetUserInteraction, userInteraction } from '$lib/stores/user.svelte'; +import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; +import { userInteraction } from '$lib/stores/user.svelte'; import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { DateTime } from 'luxon'; @@ -91,19 +87,3 @@ export const getAccountAge = (): number => { return Number(accountAge); }; - -export const handleLogout = async (redirectUri: string) => { - try { - if (redirectUri.startsWith('/')) { - await goto(redirectUri); - } else { - globalThis.location.href = redirectUri; - } - } finally { - resetSavedUser(); - resetUserInteraction(); - foldersStore.clearCache(); - memoryStore.clearCache(); - searchStore.clearCache(); - } -}; diff --git a/web/src/routes/auth/change-password/+page.svelte b/web/src/routes/auth/change-password/+page.svelte index 33d354552e..16a6ffc677 100644 --- a/web/src/routes/auth/change-password/+page.svelte +++ b/web/src/routes/auth/change-password/+page.svelte @@ -1,9 +1,8 @@ <script lang="ts"> - import { goto } from '$app/navigation'; import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; - import { AppRoute } from '$lib/constants'; - import { resetSavedUser, user } from '$lib/stores/user.store'; - import { logout, updateMyUser } from '@immich/sdk'; + import { authManager } from '$lib/stores/auth-manager.svelte'; + import { user } from '$lib/stores/user.store'; + import { updateMyUser } from '@immich/sdk'; import { Alert, Button, Field, HelperText, PasswordInput, Stack, Text } from '@immich/ui'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -25,9 +24,7 @@ } await updateMyUser({ userUpdateMeDto: { password } }); - await goto(AppRoute.AUTH_LOGIN); - resetSavedUser(); - await logout(); + await authManager.logout(); }; </script>