diff --git a/web/apps/auth/src/components/Navbar.tsx b/web/apps/auth/src/components/Navbar.tsx index 293d7fc16..87614d643 100644 --- a/web/apps/auth/src/components/Navbar.tsx +++ b/web/apps/auth/src/components/Navbar.tsx @@ -1,4 +1,3 @@ -import { logoutUser } from "@ente/accounts/services/user"; import { HorizontalFlex } from "@ente/shared/components/Container"; import { EnteLogo } from "@ente/shared/components/EnteLogo"; import NavbarBase from "@ente/shared/components/Navbar/base"; @@ -11,7 +10,7 @@ import { AppContext } from "pages/_app"; import React from "react"; export default function AuthNavbar() { - const { isMobile } = React.useContext(AppContext); + const { isMobile, logout } = React.useContext(AppContext); return ( @@ -25,7 +24,7 @@ export default function AuthNavbar() { } - onClick={logoutUser} + onClick={logout} > {t("LOGOUT")} diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index a5aa55f98..1ce3f3a5f 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -4,6 +4,7 @@ import { logStartupBanner, logUnhandledErrorsAndRejections, } from "@/next/log-web"; +import { accountLogout } from "@ente/accounts/services/logout"; import { APPS, APP_TITLES, @@ -44,6 +45,7 @@ type AppContextType = { setThemeColor: SetTheme; somethingWentWrong: () => void; setDialogBoxAttributesV2: SetDialogBoxAttributesV2; + logout: () => void; }; export const AppContext = createContext(null); @@ -128,6 +130,11 @@ export default function App({ Component, pageProps }: AppProps) { content: t("UNKNOWN_ERROR"), }); + const logout = async () => { + await accountLogout(); + router.push(PAGES.ROOT); + }; + const title = isI18nReady ? t("TITLE", { context: APPS.AUTH }) : APP_TITLES.get(APPS.AUTH); @@ -162,6 +169,7 @@ export default function App({ Component, pageProps }: AppProps) { setThemeColor, somethingWentWrong, setDialogBoxAttributesV2, + logout, }} > {(loading || !isI18nReady) && ( diff --git a/web/apps/photos/src/components/DeleteAccountModal.tsx b/web/apps/photos/src/components/DeleteAccountModal.tsx index 744fbf312..d6eb3a037 100644 --- a/web/apps/photos/src/components/DeleteAccountModal.tsx +++ b/web/apps/photos/src/components/DeleteAccountModal.tsx @@ -1,5 +1,4 @@ import log from "@/next/log"; -import { logoutUser } from "@ente/accounts/services/user"; import DialogBoxV2 from "@ente/shared/components/DialogBoxV2"; import EnteButton from "@ente/shared/components/EnteButton"; import { DELETE_ACCOUNT_EMAIL } from "@ente/shared/constants/urls"; @@ -43,7 +42,8 @@ const getReasonOptions = (): DropdownOption[] => { }; const DeleteAccountModal = ({ open, onClose }: Iprops) => { - const { setDialogBoxAttributesV2, isMobile } = useContext(AppContext); + const { setDialogBoxAttributesV2, isMobile, logout } = + useContext(AppContext); const { authenticateUser } = useContext(GalleryContext); const [loading, setLoading] = useState(false); const deleteAccountChallenge = useRef(); @@ -145,7 +145,7 @@ const DeleteAccountModal = ({ open, onClose }: Iprops) => { ); const { reason, feedback } = reasonAndFeedbackRef.current; await deleteAccount(decryptedChallenge, reason, feedback); - logoutUser(); + logout(); } catch (e) { log.error("solveChallengeAndDeleteAccount failed", e); somethingWentWrong(); diff --git a/web/apps/photos/src/components/Sidebar/ExitSection.tsx b/web/apps/photos/src/components/Sidebar/ExitSection.tsx index 6f9492b77..272f2c572 100644 --- a/web/apps/photos/src/components/Sidebar/ExitSection.tsx +++ b/web/apps/photos/src/components/Sidebar/ExitSection.tsx @@ -1,13 +1,11 @@ -import { t } from "i18next"; -import { useContext, useState } from "react"; - -import { logoutUser } from "@ente/accounts/services/user"; import DeleteAccountModal from "components/DeleteAccountModal"; import { EnteMenuItem } from "components/Menu/EnteMenuItem"; +import { t } from "i18next"; import { AppContext } from "pages/_app"; +import { useContext, useState } from "react"; export default function ExitSection() { - const { setDialogMessage } = useContext(AppContext); + const { setDialogMessage, logout } = useContext(AppContext); const [deleteAccountModalView, setDeleteAccountModalView] = useState(false); @@ -19,7 +17,7 @@ export default function ExitSection() { title: t("LOGOUT_MESSAGE"), proceed: { text: t("LOGOUT"), - action: logoutUser, + action: logout, variant: "critical", }, close: { text: t("CANCEL") }, diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 77e724d29..eacc713eb 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -53,6 +53,7 @@ import { createContext, useEffect, useRef, useState } from "react"; import LoadingBar from "react-top-loading-bar"; import DownloadManager from "services/download"; import exportService, { resumeExportsIfNeeded } from "services/export"; +import { photosLogout } from "services/logout"; import { getMLSearchConfig, updateMLSearchConfig, @@ -100,6 +101,7 @@ type AppContextType = { setDialogBoxAttributesV2: SetDialogBoxAttributesV2; isCFProxyDisabled: boolean; setIsCFProxyDisabled: (disabled: boolean) => void; + logout: () => Promise; }; export const AppContext = createContext(null); @@ -336,6 +338,11 @@ export default function App({ Component, pageProps }: AppProps) { content: t("UNKNOWN_ERROR"), }); + const logout = async () => { + await photosLogout(); + router.push(PAGES.ROOT); + }; + const title = isI18nReady ? t("TITLE", { context: APPS.PHOTOS }) : APP_TITLES.get(APPS.PHOTOS); @@ -394,6 +401,7 @@ export default function App({ Component, pageProps }: AppProps) { updateMapEnabled, isCFProxyDisabled, setIsCFProxyDisabled, + logout, }} > {(loading || !isI18nReady) && ( diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index f870dfb76..1821441bc 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -3,6 +3,7 @@ import { APPS } from "@ente/shared/apps/constants"; import { CenteredFlex } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages"; +import { getRecoveryKey } from "@ente/shared/crypto/helpers"; import { CustomError } from "@ente/shared/error"; import { useFileInput } from "@ente/shared/hooks/useFileInput"; import useMemoSingleThreaded from "@ente/shared/hooks/useMemoSingleThreaded"; @@ -93,11 +94,7 @@ import { getLocalFiles, syncFiles } from "services/fileService"; import locationSearchService from "services/locationSearchService"; import { getLocalTrashedFiles, syncTrash } from "services/trashService"; import uploadManager from "services/upload/uploadManager"; -import { - isTokenValid, - syncMapEnabled, - validateKey, -} from "services/userService"; +import { isTokenValid, syncMapEnabled } from "services/userService"; import { Collection, CollectionSummaries } from "types/collection"; import { EnteFile } from "types/file"; import { @@ -249,8 +246,13 @@ export default function Gallery() { const [tempHiddenFileIds, setTempHiddenFileIds] = useState>( new Set(), ); - const { startLoading, finishLoading, setDialogMessage, ...appContext } = - useContext(AppContext); + const { + startLoading, + finishLoading, + setDialogMessage, + logout, + ...appContext + } = useContext(AppContext); const [collectionSummaries, setCollectionSummaries] = useState(); const [hiddenCollectionSummaries, setHiddenCollectionSummaries] = @@ -319,6 +321,19 @@ export default function Gallery() { const [isClipSearchResult, setIsClipSearchResult] = useState(false); + // Ensure that the keys in local storage are not malformed by verifying that + // the recoveryKey can be decrypted with the masterKey. + // Note: This is not bullet-proof. + const validateKey = async () => { + try { + await getRecoveryKey(); + return true; + } catch (e) { + await logout(); + return false; + } + }; + useEffect(() => { appContext.showNavBar(true); const key = getKey(SESSION_KEYS.ENCRYPTION_KEY); diff --git a/web/apps/photos/src/pages/shared-albums/index.tsx b/web/apps/photos/src/pages/shared-albums/index.tsx index ab35b23fa..d26e93ead 100644 --- a/web/apps/photos/src/pages/shared-albums/index.tsx +++ b/web/apps/photos/src/pages/shared-albums/index.tsx @@ -1,5 +1,4 @@ import log from "@/next/log"; -import { logoutUser } from "@ente/accounts/services/user"; import { APPS } from "@ente/shared/apps/constants"; import { CenteredFlex, @@ -185,7 +184,7 @@ export default function PublicCollectionGallery() { nonClosable: true, proceed: { text: t("LOGIN"), - action: logoutUser, + action: () => router.push(PAGES.ROOT), variant: "accent", }, }); diff --git a/web/apps/photos/src/services/clip-service.ts b/web/apps/photos/src/services/clip-service.ts index aa724b4d5..010f4eb40 100644 --- a/web/apps/photos/src/services/clip-service.ts +++ b/web/apps/photos/src/services/clip-service.ts @@ -87,14 +87,15 @@ class CLIPService { return isElectron(); }; - private logoutHandler = async () => { + async logout() { if (this.embeddingExtractionInProgress) { this.embeddingExtractionInProgress.abort(); } if (this.onFileUploadedHandler) { await this.removeOnFileUploadListener(); } - }; + } + setupOnFileUploadListener = async () => { try { diff --git a/web/apps/photos/src/services/logout.ts b/web/apps/photos/src/services/logout.ts new file mode 100644 index 000000000..298875459 --- /dev/null +++ b/web/apps/photos/src/services/logout.ts @@ -0,0 +1,38 @@ +import log from "@/next/log"; +import { accountLogout } from "@ente/accounts/services/logout"; +import { Events, eventBus } from "@ente/shared/events"; + +/** + * Logout sequence for the photos app. + * + * This function is guaranteed not to throw any errors. + * + * See: [Note: Do not throw during logout]. + */ +export const photosLogout = async () => { + await accountLogout(); + + const electron = globalThis.electron; + if (electron) { + try { + await electron.watch.reset(); + } catch (e) { + log.error("Ignoring error when resetting native folder watches", e); + } + try { + await electron.clearConvertToMP4Results(); + } catch (e) { + log.error("Ignoring error when clearing convert-to-mp4 results", e); + } + try { + await electron.clearStores(); + } catch (e) { + log.error("Ignoring error when clearing native stores", e); + } + } + try { + eventBus.emit(Events.LOGOUT); + } catch (e) { + log.error("Ignoring error in event-bus logout handlers", e); + } +}; diff --git a/web/apps/photos/src/services/userService.ts b/web/apps/photos/src/services/userService.ts index 95b1b95c9..9b831522a 100644 --- a/web/apps/photos/src/services/userService.ts +++ b/web/apps/photos/src/services/userService.ts @@ -233,19 +233,6 @@ export const deleteAccount = async ( } }; -// Ensure that the keys in local storage are not malformed by verifying that the -// recoveryKey can be decrypted with the masterKey. -// Note: This is not bullet-proof. -export const validateKey = async () => { - try { - await getRecoveryKey(); - return true; - } catch (e) { - await logoutUser(); - return false; - } -}; - export const getFaceSearchEnabledStatus = async () => { try { const token = getToken(); diff --git a/web/packages/accounts/api/user.ts b/web/packages/accounts/api/user.ts index 7a072064e..7e313b38e 100644 --- a/web/packages/accounts/api/user.ts +++ b/web/packages/accounts/api/user.ts @@ -43,7 +43,7 @@ export const putAttributes = (token: string, keyAttributes: KeyAttributes) => }, ); -export const _logout = async () => { +export const logout = async () => { try { const token = getToken(); await HTTPService.post(`${ENDPOINT}/users/logout`, null, undefined, { diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 1e93809c9..cf09c07fe 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -45,12 +45,12 @@ import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { getSRPAttributes } from "../api/srp"; import { PAGES } from "../constants/pages"; +import { logoutUser } from "../services/logout"; import { configureSRP, generateSRPSetupAttributes, loginViaSRP, } from "../services/srp"; -import { logoutUser } from "../services/user"; import { SRPAttributes } from "../types/srp"; export default function Credentials({ appContext, appName }: PageProps) { diff --git a/web/packages/accounts/pages/generate.tsx b/web/packages/accounts/pages/generate.tsx index fb92edb14..033445234 100644 --- a/web/packages/accounts/pages/generate.tsx +++ b/web/packages/accounts/pages/generate.tsx @@ -1,7 +1,7 @@ import log from "@/next/log"; import { putAttributes } from "@ente/accounts/api/user"; +import { logoutUser } from "@ente/accounts/services/logout"; import { configureSRP } from "@ente/accounts/services/srp"; -import { logoutUser } from "@ente/accounts/services/user"; import { generateKeyAndSRPAttributes } from "@ente/accounts/utils/srp"; import { generateAndSaveIntermediateKeyAttributes, diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index 150bd47de..213b1732a 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -2,7 +2,7 @@ import log from "@/next/log"; import { recoverTwoFactor, removeTwoFactor } from "@ente/accounts/api/user"; import { PAGES } from "@ente/accounts/constants/pages"; import { TwoFactorType } from "@ente/accounts/constants/twofactor"; -import { logoutUser } from "@ente/accounts/services/user"; +import { logoutUser } from "@ente/accounts/services/logout"; import { PageProps } from "@ente/shared/apps/types"; import { VerticallyCentered } from "@ente/shared/components/Container"; import { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index 5498211ae..18f371433 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -3,7 +3,7 @@ import VerifyTwoFactor, { VerifyTwoFactorCallback, } from "@ente/accounts/components/two-factor/VerifyForm"; import { PAGES } from "@ente/accounts/constants/pages"; -import { logoutUser } from "@ente/accounts/services/user"; +import { logoutUser } from "@ente/accounts/services/logout"; import type { PageProps } from "@ente/shared/apps/types"; import { VerticallyCentered } from "@ente/shared/components/Container"; import FormPaper from "@ente/shared/components/Form/FormPaper"; diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 6515a96b7..1f2760aa9 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -29,8 +29,8 @@ import { HttpStatusCode } from "axios"; import { useRouter } from "next/router"; import { putAttributes, sendOtt, verifyOtt } from "../api/user"; import { PAGES } from "../constants/pages"; +import { logoutUser } from "../services/logout"; import { configureSRP } from "../services/srp"; -import { logoutUser } from "../services/user"; import { SRPSetupAttributes } from "../types/srp"; export default function VerifyPage({ appContext, appName }: PageProps) { diff --git a/web/packages/accounts/services/logout.ts b/web/packages/accounts/services/logout.ts new file mode 100644 index 000000000..04ada94f2 --- /dev/null +++ b/web/packages/accounts/services/logout.ts @@ -0,0 +1,50 @@ +import { clearCaches } from "@/next/blob-cache"; +import log from "@/next/log"; +import InMemoryStore from "@ente/shared/storage/InMemoryStore"; +import { clearFiles } from "@ente/shared/storage/localForage"; +import { clearData } from "@ente/shared/storage/localStorage"; +import { clearKeys } from "@ente/shared/storage/sessionStorage"; +import { logout as remoteLogout } from "../api/user"; + +/** + * Logout sequence common to all apps that rely on the accounts package. + * + * [Note: Do not throw during logout] + * + * This function is guaranteed to not thrown any errors, and will try to + * independently complete all the steps in the sequence that can be completed. + * This allows the user to logout and start again even if somehow their account + * gets in an unexpected state. + */ +export const accountLogout = async () => { + try { + await remoteLogout(); + } catch (e) { + log.error("Ignoring error during POST /users/logout", e); + } + try { + InMemoryStore.clear(); + } catch (e) { + log.error("Ignoring error when clearing in-memory store", e); + } + try { + clearKeys(); + } catch (e) { + log.error("Ignoring error when clearing keys", e); + } + try { + clearData(); + } catch (e) { + log.error("Ignoring error when clearing data", e); + } + try { + await clearCaches(); + } catch (e) { + log.error("Ignoring error when clearing caches", e); + } + try { + await clearFiles(); + } catch (e) { + log.error("Ignoring error when clearing files", e); + } +}; diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts deleted file mode 100644 index fdbfc770b..000000000 --- a/web/packages/accounts/services/user.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { clearCaches } from "@/next/blob-cache"; -import log from "@/next/log"; -import { Events, eventBus } from "@ente/shared/events"; -import InMemoryStore from "@ente/shared/storage/InMemoryStore"; -import { clearFiles } from "@ente/shared/storage/localForage/helpers"; -import { clearData } from "@ente/shared/storage/localStorage"; -import { clearKeys } from "@ente/shared/storage/sessionStorage"; -import router from "next/router"; -import { _logout } from "../api/user"; -import { PAGES } from "../constants/pages"; - -export const logoutUser = async () => { - try { - await _logout(); - } catch (e) { - log.error("Ignoring error during POST /users/logout", e); - } - try { - InMemoryStore.clear(); - } catch (e) { - log.error("Ignoring error when clearing in-memory store", e); - } - try { - clearKeys(); - } catch (e) { - log.error("Ignoring error when clearing keys", e); - } - try { - clearData(); - } catch (e) { - log.error("Ignoring error when clearing data", e); - } - try { - await clearCaches(); - } catch (e) { - log.error("Ignoring error when clearing caches", e); - } - try { - await clearFiles(); - } catch (e) { - log.error("Ignoring error when clearing files", e); - } - const electron = globalThis.electron; - if (electron) { - try { - await electron.watch.reset(); - } catch (e) { - log.error("Ignoring error when resetting native folder watches", e); - } - try { - await electron.clearConvertToMP4Results(); - } catch (e) { - log.error("Ignoring error when clearing convert-to-mp4 results", e); - } - try { - await electron.clearStores(); - } catch (e) { - log.error("Ignoring error when clearing native stores", e); - } - } - try { - eventBus.emit(Events.LOGOUT); - } catch (e) { - log.error("Ignoring error in event-bus logout handlers", e); - } - router.push(PAGES.ROOT); -}; diff --git a/web/packages/next/types/ipc.ts b/web/packages/next/types/ipc.ts index d91f7bf3f..b0eb7fb24 100644 --- a/web/packages/next/types/ipc.ts +++ b/web/packages/next/types/ipc.ts @@ -69,14 +69,14 @@ export interface Electron { * This is a coarse single shot cleanup, meant for use in clearing any * persisted Electron side state during logout. */ - clearStores: () => void; + clearStores: () => Promise; /** * Clear an state corresponding to in-flight convert-to-mp4 requests. * * This is meant for use during logout. */ - clearConvertToMP4Results: () => void; + clearConvertToMP4Results: () => Promise; /** * Return the previously saved encryption key from persistent safe storage. diff --git a/web/packages/shared/storage/localForage/index.ts b/web/packages/shared/storage/localForage.ts similarity index 76% rename from web/packages/shared/storage/localForage/index.ts rename to web/packages/shared/storage/localForage.ts index a3bb4442d..6c6da257f 100644 --- a/web/packages/shared/storage/localForage/index.ts +++ b/web/packages/shared/storage/localForage.ts @@ -11,3 +11,7 @@ if (haveWindow()) { } export default localForage; + +export const clearFiles = async () => { + await localForage.clear(); +}; diff --git a/web/packages/shared/storage/localForage/helpers.ts b/web/packages/shared/storage/localForage/helpers.ts deleted file mode 100644 index 913b9f52f..000000000 --- a/web/packages/shared/storage/localForage/helpers.ts +++ /dev/null @@ -1,5 +0,0 @@ -import localForage from "."; - -export const clearFiles = async () => { - await localForage.clear(); -};