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