diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 9b87ece27c..46579debcf 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -15,11 +15,9 @@ import { Sidebar } from "components/Sidebar"; import { Upload } from "components/Upload"; import { sessionExpiredDialogAttributes } from "ente-accounts/components/utils/dialog"; import { + getAndClearIsFirstLogin, + getAndClearJustSignedUp, getData, - isFirstLogin, - justSignedUp, - setIsFirstLogin, - setJustSignedUp, } from "ente-accounts/services/accounts-db"; import { stashRedirect } from "ente-accounts/services/redirect"; import { isSessionInvalid } from "ente-accounts/services/session"; @@ -299,16 +297,27 @@ const Page: React.FC = () => { // We are logged in and everything looks fine. Proceed with page // load initialization. - initSettings(); + + // One time inits. preloadImage("/images/subscription-card-background"); + initSettings(); await initUserDetailsOrTriggerPull(); setupSelectAllKeyBoardShortcutHandler(); + + // Show the initial state while the rest of the sequence proceeds. dispatch({ type: "showAll" }); - setIsFirstLoad(isFirstLogin()); - if (justSignedUp()) { + + // If this is the user's first login on this client, then show them + // a message informing the that the initial load might take time. + setIsFirstLoad(getAndClearIsFirstLogin()); + + // If the user created a new account on this client, show them the + // plan options. + if (getAndClearJustSignedUp()) { showPlanSelector(); } - setIsFirstLogin(false); + + // Initialize the reducer. const user = getData("user"); // TODO: Pass entire snapshot to reducer? const familyData = userDetailsSnapshot()?.familyData; @@ -320,13 +329,19 @@ const Page: React.FC = () => { collectionFiles: await savedCollectionFiles(), trashItems: await savedTrashItems(), }); + + // Fetch data from remote. await remotePull(); + + // Clear the first load message if needed. setIsFirstLoad(false); - setJustSignedUp(false); + + // Start the interval that does a periodic pull. syncIntervalID = setInterval( () => remotePull({ silent: true }), 5 * 60 * 1000 /* 5 minutes */, ); + if (electron) { electron.onMainWindowFocus(() => remotePull({ silent: true })); if (await shouldShowWhatsNew(electron)) showWhatsNew(); diff --git a/web/packages/accounts/components/SignUpContents.tsx b/web/packages/accounts/components/SignUpContents.tsx index df6c935bf8..d87b5fa4e3 100644 --- a/web/packages/accounts/components/SignUpContents.tsx +++ b/web/packages/accounts/components/SignUpContents.tsx @@ -14,8 +14,8 @@ import { Typography, } from "@mui/material"; import { + saveJustSignedUp, setData, - setJustSignedUp, setLocalReferralSource, } from "ente-accounts/services/accounts-db"; import { @@ -154,7 +154,7 @@ export const SignUpContents: React.FC = ({ ); await saveMasterKeyInSessionAndSafeStore(masterKey); - setJustSignedUp(true); + saveJustSignedUp(); void router.push("/verify"); } catch (e) { log.error("Signup failed", e); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index bcd91f6bf8..62317d1b59 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -17,9 +17,9 @@ import { import { getData, getToken, - isFirstLogin, + savedIsFirstLogin, + saveIsFirstLogin, setData, - setIsFirstLogin, setLSUser, } from "ente-accounts/services/accounts-db"; import { @@ -98,7 +98,7 @@ const Page: React.FC = () => { setData("srpAttributes", session.updatedSRPAttributes); // Set a flag that causes new interactive key attributes to // be generated. - setIsFirstLogin(true); + saveIsFirstLogin(); // This should be a rare occurrence, instead of building the // scaffolding to update all the in-memory state, just // reload everything. @@ -191,7 +191,7 @@ const Page: React.FC = () => { await userVerificationResultAfterResolvingSecondFactorChoice( await verifySRP(srpAttributes!, kek), ); - setIsFirstLogin(true); + saveIsFirstLogin(); if (passkeySessionID) { await stashKeyEncryptionKeyInSessionStore(kek); @@ -246,7 +246,7 @@ const Page: React.FC = () => { const handleVerifyMasterPassword: VerifyMasterPasswordFormProps["onVerify"] = (key, kek, keyAttributes, password) => { void (async () => { - const updatedKeyAttributes = isFirstLogin() + const updatedKeyAttributes = savedIsFirstLogin() ? await generateAndSaveInteractiveKeyAttributes( password, keyAttributes, diff --git a/web/packages/accounts/pages/generate.tsx b/web/packages/accounts/pages/generate.tsx index af3b4da6ea..a40ef0ca53 100644 --- a/web/packages/accounts/pages/generate.tsx +++ b/web/packages/accounts/pages/generate.tsx @@ -7,8 +7,8 @@ import { import { RecoveryKey } from "ente-accounts/components/RecoveryKey"; import { getData, - justSignedUp, - setJustSignedUp, + savedJustSignedUp, + saveJustSignedUp, } from "ente-accounts/services/accounts-db"; import { appHomeRoute } from "ente-accounts/services/redirect"; import { @@ -54,7 +54,7 @@ const Page: React.FC = () => { if (!user?.token) { void router.push("/"); } else if (haveCredentialsInSession()) { - if (justSignedUp()) { + if (savedJustSignedUp()) { setOpenRecoveryKey(true); setLoading(false); } else { @@ -82,7 +82,7 @@ const Page: React.FC = () => { masterKey, ); await saveMasterKeyInSessionAndSafeStore(masterKey); - setJustSignedUp(true); + saveJustSignedUp(); setOpenRecoveryKey(true); } catch (e) { log.error("failed to generate password", e); diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 66933f36db..a14f005ab1 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -10,8 +10,8 @@ import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/se import { getData, getLocalReferralSource, + saveIsFirstLogin, setData, - setIsFirstLogin, setLSUser, } from "ente-accounts/services/accounts-db"; import { @@ -107,12 +107,7 @@ const Page: React.FC = () => { isTwoFactorEnabled: true, isTwoFactorPasskeysEnabled: true, }); - // TODO: This is not the first login though if they already have - // 2FA. Does this flag mean first login on this device? - // - // Update: This flag causes the interactive encryption key to be - // generated, so it has a functional impact we need. - setIsFirstLogin(true); + saveIsFirstLogin(); const url = passkeyVerificationRedirectURL( accountsUrl!, passkeySessionID, @@ -125,7 +120,7 @@ const Page: React.FC = () => { twoFactorSessionID, isTwoFactorEnabled: true, }); - setIsFirstLogin(true); + saveIsFirstLogin(); void router.push("/two-factor/verify"); } else { await setLSUser({ @@ -147,7 +142,7 @@ const Page: React.FC = () => { } await unstashAndUseSRPSetupAttributes(setupSRP); } - setIsFirstLogin(true); + saveIsFirstLogin(); const redirectURL = unstashRedirect(); if (keyAttributes?.encryptedKey) { clearSessionStorage(); diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index f02f4e2678..a9793f3168 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -1,7 +1,8 @@ import { getKVS, removeKV, setKV } from "ente-base/kv"; import log from "ente-base/log"; +import { nullToUndefined } from "ente-utils/transform"; +import { z } from "zod/v4"; import { RemoteKeyAttributes, type KeyAttributes } from "./user"; - export type LocalStorageKey = | "user" // See also savedKeyAttributes. @@ -15,12 +16,6 @@ export type LocalStorageKey = | "srpAttributes" | "referralSource"; -export const setData = (key: LocalStorageKey, value: object) => - localStorage.setItem(key, JSON.stringify(value)); - -export const removeData = (key: LocalStorageKey) => - localStorage.removeItem(key); - /** * [Note: Accounts DB] * @@ -55,6 +50,9 @@ export const getData = (key: LocalStorageKey) => { } }; +export const setData = (key: LocalStorageKey, value: object) => + localStorage.setItem(key, JSON.stringify(value)); + // TODO: Migrate this to `local-user.ts`, with (a) more precise optionality // indication of the constituent fields, (b) moving any fields that need to be // accessed from web workers to KV DB. @@ -153,17 +151,88 @@ export const getToken = (): string => { return token; }; -export const isFirstLogin = () => getData("isFirstLogin")?.status ?? false; +const LocalIsFirstLogin = z.object({ + status: z.boolean().nullish().transform(nullToUndefined), +}); -export function setIsFirstLogin(status: boolean) { - setData("isFirstLogin", { status }); -} +/** + * Return `true` if it is the user's first login on this client. + * + * The {@link savedIsFirstLogin} flag is saved in local storage (using + * {@link saveIsFirstLogin}) if we determine during the login flow that it a + * fresh login on this client. If so, we + * + * - Generate interactive key attributes for them, and + * + * - Display them a special indicator post login to notify them that the first + * load might take extra time. At this point, we also clear the flag (the read + * and clear is done by the same {@link getAndClearIsFirstLogin} function). + */ +export const savedIsFirstLogin = () => { + const jsonString = localStorage.getItem("isFirstLogin"); + if (!jsonString) return false; + return LocalIsFirstLogin.parse(JSON.parse(jsonString)).status ?? false; +}; -export const justSignedUp = () => getData("justSignedUp")?.status ?? false; +/** + * Save a flag in local storage to indicate that this is the user's first login + * on this client. + * + * This is the setter corresponding to {@link savedIsFirstLogin}. + */ +export const saveIsFirstLogin = () => { + localStorage.setItem("isFirstLogin", JSON.stringify({ status: true })); +}; -export function setJustSignedUp(status: boolean) { - setData("justSignedUp", { status }); -} +/** + * Get the saved value of the local storage flag that indicates that this is the + * user' first login on this client. Also remove the flag after reading. + * + * The flag can be set by using {@link saveIsFirstLogin}, and can be read + * without clearing it by using {@link savedIsFirstLogin}. + */ +export const getAndClearIsFirstLogin = () => { + const result = savedIsFirstLogin(); + localStorage.removeItem("isFirstLogin"); + return result; +}; + +const LocalJustSignedUp = z.object({ + status: z.boolean().nullish().transform(nullToUndefined), +}); + +/** + * Return `true` if the user created a new account on this client during the + * current (in-progress) or just completed login / signup sequence. + */ +export const savedJustSignedUp = () => { + const jsonString = localStorage.getItem("justSignedUp"); + if (!jsonString) return false; + return LocalJustSignedUp.parse(JSON.parse(jsonString)).status ?? false; +}; + +/** + * Save a flag in local storage to indicate that the user signed up for a + * new Ente account during the current login / signup sequence. + * + * This is the setter corresponding to {@link savedJustSignedUp}. + */ +export const saveJustSignedUp = () => { + localStorage.setItem("justSignedUp", JSON.stringify({ status: true })); +}; + +/** + * Get the saved value of the local storage flag that indicates that the user just + * signed up. Also remove the flag from local storage after reading. + * + * The flag can be set by using {@link saveJustSignedUp}, and can be read + * without clearing it by using {@link savedJustSignedUp}. + */ +export const getAndClearJustSignedUp = () => { + const result = savedJustSignedUp(); + localStorage.removeItem("justSignedUp"); + return result; +}; export function getLocalReferralSource() { return getData("referralSource")?.source;