diff --git a/web/apps/photos/src/pages/index.tsx b/web/apps/photos/src/pages/index.tsx
index 8573e1cdd1..ba444dcba7 100644
--- a/web/apps/photos/src/pages/index.tsx
+++ b/web/apps/photos/src/pages/index.tsx
@@ -144,11 +144,13 @@ export default function LandingPage() {
- {showLogin ? (
-
- ) : (
-
- )}
+
+ {showLogin ? (
+
+ ) : (
+
+ )}
+
>
diff --git a/web/packages/accounts/components/Login.tsx b/web/packages/accounts/components/Login.tsx
index 8dd8fad6e3..82784844bd 100644
--- a/web/packages/accounts/components/Login.tsx
+++ b/web/packages/accounts/components/Login.tsx
@@ -1,4 +1,5 @@
import { FormPaperFooter, FormPaperTitle } from "@/base/components/FormPaper";
+import { isMuseumHTTPError } from "@/base/http";
import log from "@/base/log";
import LinkButton from "@ente/shared/components/LinkButton";
import SingleInputForm, {
@@ -10,7 +11,7 @@ import { t } from "i18next";
import { useRouter } from "next/router";
import { PAGES } from "../constants/pages";
import { getSRPAttributes } from "../services/srp-remote";
-import { sendOtt } from "../services/user";
+import { sendOTT } from "../services/user";
interface LoginProps {
signUp: () => void;
@@ -26,26 +27,30 @@ export const Login: React.FC = ({ signUp, host }) => {
setFieldError,
) => {
try {
- await setLSUser({ email });
const srpAttributes = await getSRPAttributes(email);
log.debug(() => ["srpAttributes", JSON.stringify(srpAttributes)]);
if (!srpAttributes || srpAttributes.isEmailMFAEnabled) {
- await sendOtt(email);
+ try {
+ await sendOTT(email, "login");
+ } catch (e) {
+ if (
+ await isMuseumHTTPError(e, 404, "USER_NOT_REGISTERED")
+ ) {
+ setFieldError(t("email_not_registered"));
+ return;
+ }
+ throw e;
+ }
+ await setLSUser({ email });
void router.push(PAGES.VERIFY);
} else {
+ await setLSUser({ email });
setData(LS_KEYS.SRP_ATTRIBUTES, srpAttributes);
void router.push(PAGES.CREDENTIALS);
}
} catch (e) {
- if (e instanceof Error) {
- setFieldError(
- `${t("generic_error_retry")} (reason:${e.message})`,
- );
- } else {
- setFieldError(
- `${t("generic_error_retry")} (reason:${JSON.stringify(e)})`,
- );
- }
+ log.error("Login failed", e);
+ setFieldError(t("generic_error"));
}
};
diff --git a/web/packages/accounts/components/SignUp.tsx b/web/packages/accounts/components/SignUp.tsx
index ea91b5fdc6..97bdd98dc2 100644
--- a/web/packages/accounts/components/SignUp.tsx
+++ b/web/packages/accounts/components/SignUp.tsx
@@ -1,5 +1,6 @@
import { FormPaperFooter, FormPaperTitle } from "@/base/components/FormPaper";
import { LoadingButton } from "@/base/components/mui/LoadingButton";
+import { isMuseumHTTPError } from "@/base/http";
import log from "@/base/log";
import { LS_KEYS, setLSUser } from "@ente/shared//storage/localStorage";
import { VerticallyCentered } from "@ente/shared/components/Container";
@@ -37,7 +38,7 @@ import { Trans } from "react-i18next";
import * as Yup from "yup";
import { PAGES } from "../constants/pages";
import { generateKeyAndSRPAttributes } from "../services/srp";
-import { sendOtt } from "../services/user";
+import { sendOTT } from "../services/user";
import { isWeakPassword } from "../utils/password";
import { PasswordStrengthHint } from "./PasswordStrength";
@@ -81,15 +82,18 @@ export const SignUp: React.FC = ({ router, login, host }) => {
}
setLoading(true);
try {
- await setLSUser({ email });
setLocalReferralSource(referral);
- await sendOtt(email);
+ await sendOTT(email, "signup");
+ await setLSUser({ email });
} catch (e) {
- const message = e instanceof Error ? e.message : "";
- setFieldError(
- "confirm",
- `${t("generic_error_retry")} ${message}`,
- );
+ log.error("Signup failed", e);
+ if (
+ await isMuseumHTTPError(e, 409, "USER_ALREADY_REGISTERED")
+ ) {
+ setFieldError("email", t("email_already_registered"));
+ } else {
+ setFieldError("email", t("generic_error"));
+ }
throw e;
}
try {
diff --git a/web/packages/accounts/pages/change-email.tsx b/web/packages/accounts/pages/change-email.tsx
index db07369fbd..c1262a622d 100644
--- a/web/packages/accounts/pages/change-email.tsx
+++ b/web/packages/accounts/pages/change-email.tsx
@@ -4,6 +4,7 @@ import {
FormPaperTitle,
} from "@/base/components/FormPaper";
import { LoadingButton } from "@/base/components/mui/LoadingButton";
+import { isHTTPErrorWithStatus } from "@/base/http";
import log from "@/base/log";
import { VerticallyCentered } from "@ente/shared/components/Container";
import LinkButton from "@ente/shared/components/LinkButton";
@@ -16,7 +17,7 @@ import { useEffect, useState } from "react";
import { Trans } from "react-i18next";
import * as Yup from "yup";
import { appHomeRoute } from "../services/redirect";
-import { changeEmail, sendOTTForEmailChange } from "../services/user";
+import { changeEmail, sendOTT } from "../services/user";
import type { PageProps } from "../types/page";
const Page: React.FC = () => {
@@ -60,7 +61,7 @@ const ChangeEmailForm: React.FC = () => {
) => {
try {
setLoading(true);
- await sendOTTForEmailChange(email);
+ await sendOTT(email, "change");
setEmail(email);
setShowOttInputVisibility(true);
setShowMessage(true);
@@ -71,7 +72,12 @@ const ChangeEmailForm: React.FC = () => {
// }, 250);
} catch (e) {
log.error(e);
- setFieldError("email", t("email_already_taken"));
+ setFieldError(
+ "email",
+ isHTTPErrorWithStatus(e, 403)
+ ? t("email_already_taken")
+ : t("generic_error"),
+ );
}
setLoading(false);
};
diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx
index aff92ecee5..cb1a368fa2 100644
--- a/web/packages/accounts/pages/recover.tsx
+++ b/web/packages/accounts/pages/recover.tsx
@@ -1,5 +1,5 @@
import { PAGES } from "@/accounts/constants/pages";
-import { sendOtt } from "@/accounts/services/user";
+import { sendOTT } from "@/accounts/services/user";
import {
FormPaper,
FormPaperFooter,
@@ -48,7 +48,7 @@ const Page: React.FC = ({ appContext }) => {
return;
}
if (!user?.encryptedToken && !user?.token) {
- void sendOtt(user.email);
+ void sendOTT(user.email, undefined);
stashRedirect(PAGES.RECOVER);
void router.push(PAGES.VERIFY);
return;
diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx
index dfbeb387d4..c9bcb1e283 100644
--- a/web/packages/accounts/pages/verify.tsx
+++ b/web/packages/accounts/pages/verify.tsx
@@ -42,7 +42,7 @@ import { configureSRP } from "../services/srp";
import type { SRPAttributes, SRPSetupAttributes } from "../services/srp-remote";
import { getSRPAttributes } from "../services/srp-remote";
import type { UserVerificationResponse } from "../services/user";
-import { putAttributes, sendOtt, verifyOtt } from "../services/user";
+import { putAttributes, sendOTT, verifyOtt } from "../services/user";
import type { PageProps } from "../types/page";
const Page: React.FC = ({ appContext }) => {
@@ -170,7 +170,7 @@ const Page: React.FC = ({ appContext }) => {
const resendEmail = async () => {
setResend(1);
- await sendOtt(email);
+ await sendOTT(email, undefined);
setResend(2);
setTimeout(() => setResend(0), 3000);
};
diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts
index 3ed5e7c257..a6f4c2672f 100644
--- a/web/packages/accounts/services/user.ts
+++ b/web/packages/accounts/services/user.ts
@@ -1,6 +1,9 @@
-import { appName } from "@/base/app";
import type { B64EncryptionResult } from "@/base/crypto/libsodium";
-import { authenticatedRequestHeaders, ensureOk } from "@/base/http";
+import {
+ authenticatedRequestHeaders,
+ ensureOk,
+ publicRequestHeaders,
+} from "@/base/http";
import { apiURL } from "@/base/origins";
import HTTPService from "@ente/shared/network/HTTPService";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
@@ -54,12 +57,31 @@ export interface RecoveryKey {
recoveryKeyDecryptionNonce: string;
}
-export const sendOtt = async (email: string) => {
- return HTTPService.post(await apiURL("/users/ott"), {
- email,
- client: appName == "auth" ? "totp" : "web",
- });
-};
+/**
+ * Ask remote to send a OTP / OTT to the given email to verify that the user has
+ * access to it. Subsequent the app will pass this OTT back via the
+ * {@link verifyOTT} method.
+ *
+ * @param email The email to verify.
+ *
+ * @param purpose In which context is the email being verified. Remote applies
+ * additional business rules depending on this. For example, passing the purpose
+ * "login" ensures that the OTT is only sent to an already registered email.
+ *
+ * In cases where the purpose is ambiguous (e.g. we're not sure if it is an
+ * existing login or a new signup), the purpose can be set to `undefined`.
+ */
+export const sendOTT = async (
+ email: string,
+ purpose: "change" | "signup" | "login" | undefined,
+) =>
+ ensureOk(
+ await fetch(await apiURL("/users/ott"), {
+ method: "POST",
+ headers: publicRequestHeaders(),
+ body: JSON.stringify({ email, purpose }),
+ }),
+ );
export const verifyOtt = async (
email: string,
@@ -171,14 +193,6 @@ export const changeEmail = async (email: string, ott: string) => {
);
};
-export const sendOTTForEmailChange = async (email: string) => {
- await HTTPService.post(await apiURL("/users/ott"), {
- email,
- client: "web",
- purpose: "change",
- });
-};
-
export const setupTwoFactor = async () => {
const resp = await HTTPService.post(
await apiURL("/users/two-factor/setup"),
diff --git a/web/packages/base/http.ts b/web/packages/base/http.ts
index f1f11d56fb..0b9850ce4e 100644
--- a/web/packages/base/http.ts
+++ b/web/packages/base/http.ts
@@ -1,6 +1,8 @@
import { retryAsyncOperation } from "@/utils/promise";
+import { z } from "zod";
import { clientPackageName } from "./app";
import { ensureAuthToken } from "./local-user";
+import log from "./log";
/**
* Return headers that should be passed alongwith (almost) all authenticated
@@ -101,6 +103,12 @@ export const ensureOk = (res: Response) => {
if (!res.ok) throw new HTTPError(res);
};
+/**
+ * Return true if this is a HTTP error with the given {@link httpStatus}.
+ */
+export const isHTTPErrorWithStatus = (e: unknown, httpStatus: number) =>
+ e instanceof HTTPError && e.res.status == httpStatus;
+
/**
* Return true if this is a HTTP "client" error.
*
@@ -120,6 +128,36 @@ export const isHTTP4xxError = (e: unknown) =>
export const isHTTP401Error = (e: unknown) =>
e instanceof HTTPError && e.res.status == 401;
+/**
+ * Return `true` if this is an error because of a HTTP failure response returned
+ * by museum with the given "code" and HTTP status.
+ *
+ * For some known set of errors, museum returns a payload of the form
+ *
+ * {"code":"USER_NOT_REGISTERED","message":"User is not registered"}
+ *
+ * where the code can be used to match a specific reason for the HTTP request
+ * failing. This function can be used as a predicate to check both the HTTP
+ * status code and the "code" within the payload.
+ */
+export const isMuseumHTTPError = async (
+ e: unknown,
+ httpStatus: number,
+ code: string,
+) => {
+ if (e instanceof HTTPError && e.res.status == httpStatus) {
+ try {
+ const payload = z
+ .object({ code: z.string() })
+ .parse(await e.res.json());
+ return payload.code == code;
+ } catch (e) {
+ log.warn("Ignoring error when parsing error payload", e);
+ return false;
+ }
+ }
+ return false;
+};
/**
* A helper function to adapt {@link retryAsyncOperation} for HTTP fetches.
*
diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json
index 0608e4a78b..256aaab9bd 100644
--- a/web/packages/base/locales/en-US/translation.json
+++ b/web/packages/base/locales/en-US/translation.json
@@ -12,6 +12,8 @@
"ENTER_EMAIL": "Enter email address",
"EMAIL_ERROR": "Enter a valid email",
"required": "required",
+ "email_not_registered": "Email not registered",
+ "email_already_registered": "Email already registered",
"EMAIL_SENT": "Verification code sent to {{email}}",
"CHECK_INBOX": "Please check your inbox (and spam) to complete verification",
"ENTER_OTT": "Verification code",