[web] Accounts DB cleanup (#6447)

This commit is contained in:
Manav Rathi
2025-07-02 19:32:05 +05:30
committed by GitHub
10 changed files with 256 additions and 110 deletions

View File

@@ -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();

View File

@@ -14,9 +14,9 @@ import {
Typography,
} from "@mui/material";
import {
saveJustSignedUp,
setData,
setJustSignedUp,
setLocalReferralSource,
stashReferralSource,
} from "ente-accounts/services/accounts-db";
import {
generateSRPSetupAttributes,
@@ -107,7 +107,8 @@ export const SignUpContents: React.FC<SignUpContentsProps> = ({
}
try {
setLocalReferralSource(referral);
const cleanedReferral = referral.trim();
if (cleanedReferral) stashReferralSource(cleanedReferral);
try {
await sendOTT(email, "signup");
@@ -154,7 +155,7 @@ export const SignUpContents: React.FC<SignUpContentsProps> = ({
);
await saveMasterKeyInSessionAndSafeStore(masterKey);
setJustSignedUp(true);
saveJustSignedUp();
void router.push("/verify");
} catch (e) {
log.error("Signup failed", e);

View File

@@ -4,7 +4,6 @@ import {
AccountsPageFooter,
AccountsPageTitle,
} from "ente-accounts/components/layouts/centered-paper";
import { getData, setData } from "ente-accounts/services/accounts-db";
import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect";
import {
changePassword,
@@ -31,6 +30,9 @@ const Page: React.FC = () => {
const router = useRouter();
// We're invoked with the "?op=reset" query parameter in the recovery flow.
const isReset = router.query.op == "reset";
useEffect(() => {
const user = localUser();
if (user) {
@@ -41,38 +43,41 @@ const Page: React.FC = () => {
}
}, [router]);
return user ? <PageContents {...{ user }} /> : <LoadingIndicator />;
return user ? (
<PageContents {...{ user, isReset }} />
) : (
<LoadingIndicator />
);
};
export default Page;
interface PageContentsProps {
user: LocalUser;
/**
* True if the password is being reset during the account recovery flow.
*/
isReset: boolean;
}
const PageContents: React.FC<PageContentsProps> = ({ user }) => {
const PageContents: React.FC<PageContentsProps> = ({ user, isReset }) => {
const router = useRouter();
const redirectToAppHome = useCallback(() => {
setData("showBackButton", { value: true });
void router.push(appHomeRoute);
}, [router]);
const handleSubmit: NewPasswordFormProps["onSubmit"] = async (
password,
setPasswordsFieldError,
) =>
changePassword(password)
.then(redirectToAppHome)
.catch((e: unknown) => {
log.error("Could not change password", e);
setPasswordsFieldError(
e instanceof Error &&
e.message == deriveKeyInsufficientMemoryErrorMessage
? t("password_generation_failed")
: t("generic_error"),
);
});
const handleSubmit: NewPasswordFormProps["onSubmit"] = useCallback(
async (password, setPasswordsFieldError) =>
changePassword(password)
.then(() => void router.push(appHomeRoute))
.catch((e: unknown) => {
log.error("Could not change password", e);
setPasswordsFieldError(
e instanceof Error &&
e.message == deriveKeyInsufficientMemoryErrorMessage
? t("password_generation_failed")
: t("generic_error"),
);
}),
[router],
);
return (
<AccountsPageContents>
@@ -82,7 +87,7 @@ const PageContents: React.FC<PageContentsProps> = ({ user }) => {
submitButtonTitle={t("change_password")}
onSubmit={handleSubmit}
/>
{(getData("showBackButton")?.value ?? true) && (
{!isReset && (
<>
<Divider sx={{ mt: 1 }} />
<AccountsPageFooter>

View File

@@ -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,

View File

@@ -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);

View File

@@ -3,7 +3,7 @@ import {
AccountsPageFooter,
AccountsPageTitle,
} from "ente-accounts/components/layouts/centered-paper";
import { getData, setData } from "ente-accounts/services/accounts-db";
import { getData } from "ente-accounts/services/accounts-db";
import { recoveryKeyFromMnemonic } from "ente-accounts/services/recovery-key";
import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect";
import type { KeyAttributes, User } from "ente-accounts/services/user";
@@ -72,8 +72,7 @@ const Page: React.FC = () => {
await saveMasterKeyInSessionAndSafeStore(masterKey);
await decryptAndStoreToken(keyAttr, masterKey);
setData("showBackButton", { value: false });
void router.push("/change-password");
void router.push("/change-password?op=reset");
} catch (e) {
log.error("password recovery failed", e);
setFieldError(t("incorrect_recovery_key"));

View File

@@ -9,10 +9,10 @@ import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice"
import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice";
import {
getData,
getLocalReferralSource,
saveIsFirstLogin,
setData,
setIsFirstLogin,
setLSUser,
unstashReferralSource,
} from "ente-accounts/services/accounts-db";
import {
openPasskeyVerificationURL,
@@ -83,8 +83,7 @@ const Page: React.FC = () => {
setFieldError,
) => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
const referralSource = getLocalReferralSource()?.trim();
const referralSource = unstashReferralSource();
const cleanedReferral = referralSource
? `web:${referralSource}`
: undefined;
@@ -107,12 +106,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 +119,7 @@ const Page: React.FC = () => {
twoFactorSessionID,
isTwoFactorEnabled: true,
});
setIsFirstLogin(true);
saveIsFirstLogin();
void router.push("/two-factor/verify");
} else {
await setLSUser({
@@ -147,7 +141,7 @@ const Page: React.FC = () => {
}
await unstashAndUseSRPSetupAttributes(setupSRP);
}
setIsFirstLogin(true);
saveIsFirstLogin();
const redirectURL = unstashRedirect();
if (keyAttributes?.encryptedKey) {
clearSessionStorage();
@@ -196,7 +190,7 @@ const Page: React.FC = () => {
return (
<VerifyingPasskey
email={email}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
passkeySessionID={passkeyVerificationData.passkeySessionID}
onRetry={() =>
openPasskeyVerificationURL(passkeyVerificationData)
}

View File

@@ -1,24 +1,17 @@
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.
| "keyAttributes"
| "originalKeyAttributes"
| "isFirstLogin"
| "justSignedUp"
| "showBackButton"
// Moved to ente-accounts
// "srpSetupAttributes"
| "srpAttributes"
| "referralSource";
export const setData = (key: LocalStorageKey, value: object) =>
localStorage.setItem(key, JSON.stringify(value));
export const removeData = (key: LocalStorageKey) =>
localStorage.removeItem(key);
| "srpAttributes";
/**
* [Note: Accounts DB]
@@ -54,6 +47,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.
@@ -126,27 +122,178 @@ export const isLocalStorageAndIndexedDBMismatch = async () => {
);
};
/**
* Return the user's {@link KeyAttributes} if they are present in local storage.
*
* The key attributes are stored in the browser's localStorage. Thus, this
* function only works from the main thread, not from web workers (local storage
* is not accessible to web workers).
*/
export const savedKeyAttributes = (): KeyAttributes | undefined => {
const jsonString = localStorage.getItem("keyAttributes");
if (!jsonString) return undefined;
return RemoteKeyAttributes.parse(JSON.parse(jsonString));
};
/**
* Save the user's {@link KeyAttributes} in local storage.
*
* Use {@link savedKeyAttributes} to retrieve them.
*/
export const saveKeyAttributes = (keyAttributes: KeyAttributes) =>
localStorage.setItem("keyAttributes", JSON.stringify(keyAttributes));
export const getToken = (): string => {
const token = getData("user")?.token;
return token;
};
export const isFirstLogin = () => getData("isFirstLogin")?.status ?? false;
/**
* Zod schema for the legacy format in which the {@link savedIsFirstLogin} and
* {@link savedJustSignedUp} flags were saved in local storage.
*
* Starting 1.7.15-beta (July 2025), we started saving the booleans directly,
* but when reading we fallback to the old format if needed. This fallback can
* be removed, and soonish, since these are transient flags that are saved
* during the login / signup sequence and wouldn't be expected to remain in the
* user's local storage for long anyway. (tag: Migration).
*/
const LocalLegacyBooleanFlag = 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;
try {
return z.boolean().parse(JSON.parse(jsonString)) ?? false;
} catch {
return (
LocalLegacyBooleanFlag.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 = () => {
// Completely unrelated, but since this code runs on each /gallery load, use
// this as a chance to remove the unused "showBackButton" property saved in
// local storage. This code was added 1.7.15-beta (July 2025) and can be
// removed after a while, soonish (tag: Migration).
localStorage.removeItem("showBackButton");
export function getLocalReferralSource() {
return getData("referralSource")?.source;
}
const result = savedIsFirstLogin();
localStorage.removeItem("isFirstLogin");
return result;
};
export function setLocalReferralSource(source: string) {
setData("referralSource", { source });
}
/**
* 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;
try {
return z.boolean().parse(JSON.parse(jsonString)) ?? false;
} catch {
return (
LocalLegacyBooleanFlag.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;
};
/**
* Zod schema for the format in which the {@link stashReferralSource} used to
* saved the referral source in local storage.
*
* Starting 1.7.15-beta (July 2025), we started saving the string directly, but
* when reading we fallback to the old format if needed. This fallback can be
* removed, and soonish, since these is a transient value that isn't expected to
* remain in the user's local storage for long anyway. (tag: Migration).
*/
const LocalLegacyReferralSource = z.object({ source: z.string() });
/**
* Save the referral source entered by the user on the signup screen in local
* storage.
*
* The saved value can be retrieved post email verification using
* {@link unstashReferralSource}.
*/
export const stashReferralSource = (referralSource: string) => {
localStorage.setItem("referralSource", referralSource);
};
/**
* Retrieve the previously saved referral source (using
* {@link stashReferralSource}), returning the saved value and also clearing it
* from local storage.
*/
export const unstashReferralSource = () => {
const jsonString = localStorage.getItem("referralSource");
if (!jsonString) return undefined;
localStorage.removeItem("referralSource");
try {
// Try the old format first. The trim is also a legacy expectation and
// can be removed when we remove this fallfront.
return LocalLegacyReferralSource.parse(
JSON.parse(jsonString),
).source.trim();
} catch {
// Otherwise try the new format.
return jsonString;
}
};

View File

@@ -8,7 +8,8 @@ import {
toHex,
} from "ente-base/crypto";
import { ensureMasterKeyFromSession } from "ente-base/session";
import { putUserRecoveryKeyAttributes, saveKeyAttributes } from "./user";
import { saveKeyAttributes } from "./accounts-db";
import { putUserRecoveryKeyAttributes } from "./user";
// Mobile client library only supports English.
bip39.setDefaultWordlist("english");

View File

@@ -1,4 +1,9 @@
import { getData, setLSUser } from "ente-accounts/services/accounts-db";
import {
getData,
savedKeyAttributes,
saveKeyAttributes,
setLSUser,
} from "ente-accounts/services/accounts-db";
import {
generateSRPSetupAttributes,
getSRPAttributes,
@@ -350,19 +355,6 @@ export const RemoteKeyAttributes = z.object({
recoveryKeyDecryptionNonce: z.string().nullish().transform(nullToUndefined),
});
/**
* Return the user's {@link KeyAttributes} if they are present in local storage.
*
* The key attributes are stored in the browser's localStorage. Thus, this
* function only works from the main thread, not from web workers (local storage
* is not accessible to web workers).
*/
export const savedKeyAttributes = (): KeyAttributes | undefined => {
const jsonString = localStorage.getItem("keyAttributes");
if (!jsonString) return undefined;
return RemoteKeyAttributes.parse(JSON.parse(jsonString));
};
/**
* A variant of {@link savedKeyAttributes} that throws if keyAttributes are not
* present in local storage.
@@ -370,14 +362,6 @@ export const savedKeyAttributes = (): KeyAttributes | undefined => {
export const ensureSavedKeyAttributes = (): KeyAttributes =>
ensureExpectedLoggedInValue(savedKeyAttributes());
/**
* Save the user's {@link KeyAttributes} in local storage.
*
* Use {@link savedKeyAttributes} to retrieve them.
*/
export const saveKeyAttributes = (keyAttributes: KeyAttributes) =>
localStorage.setItem("keyAttributes", JSON.stringify(keyAttributes));
export interface GenerateKeysAndAttributesResult {
masterKey: string;
kek: string;