Skip to content

Commit

Permalink
[web] Use purpose to distinguish signup / login (#4421)
Browse files Browse the repository at this point in the history
  • Loading branch information
mnvr authored Dec 17, 2024
2 parents a9545c3 + 75456c1 commit 431ad61
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 48 deletions.
12 changes: 7 additions & 5 deletions web/apps/photos/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,13 @@ export default function LandingPage() {
</MobileBox>
<DesktopBox>
<SideBox>
{showLogin ? (
<Login {...{ signUp, host }} />
) : (
<SignUp {...{ router, login, host }} />
)}
<Box sx={{ maxWidth: "320px" }}>
{showLogin ? (
<Login {...{ signUp, host }} />
) : (
<SignUp {...{ router, login, host }} />
)}
</Box>
</SideBox>
</DesktopBox>
</>
Expand Down
29 changes: 17 additions & 12 deletions web/packages/accounts/components/Login.tsx
Original file line number Diff line number Diff line change
@@ -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, {
Expand All @@ -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;
Expand All @@ -26,26 +27,30 @@ export const Login: React.FC<LoginProps> = ({ 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"));
}
};

Expand Down
20 changes: 12 additions & 8 deletions web/packages/accounts/components/SignUp.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -81,15 +82,18 @@ export const SignUp: React.FC<SignUpProps> = ({ 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 {
Expand Down
12 changes: 9 additions & 3 deletions web/packages/accounts/pages/change-email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<PageProps> = () => {
Expand Down Expand Up @@ -60,7 +61,7 @@ const ChangeEmailForm: React.FC = () => {
) => {
try {
setLoading(true);
await sendOTTForEmailChange(email);
await sendOTT(email, "change");
setEmail(email);
setShowOttInputVisibility(true);
setShowMessage(true);
Expand All @@ -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);
};
Expand Down
4 changes: 2 additions & 2 deletions web/packages/accounts/pages/recover.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -48,7 +48,7 @@ const Page: React.FC<PageProps> = ({ 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;
Expand Down
4 changes: 2 additions & 2 deletions web/packages/accounts/pages/verify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PageProps> = ({ appContext }) => {
Expand Down Expand Up @@ -170,7 +170,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {

const resendEmail = async () => {
setResend(1);
await sendOtt(email);
await sendOTT(email, undefined);
setResend(2);
setTimeout(() => setResend(0), 3000);
};
Expand Down
46 changes: 30 additions & 16 deletions web/packages/accounts/services/user.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
38 changes: 38 additions & 0 deletions web/packages/base/http.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
*
Expand All @@ -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.
*
Expand Down
2 changes: 2 additions & 0 deletions web/packages/base/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a>{{email}}</a>",
"CHECK_INBOX": "Please check your inbox (and spam) to complete verification",
"ENTER_OTT": "Verification code",
Expand Down

0 comments on commit 431ad61

Please sign in to comment.