diff --git a/app/backend/src/couchers/servicers/auth.py b/app/backend/src/couchers/servicers/auth.py index dc488c0207..bdc9ca5564 100644 --- a/app/backend/src/couchers/servicers/auth.py +++ b/app/backend/src/couchers/servicers/auth.py @@ -353,7 +353,6 @@ def SignupFlow( signup_guidelines_accepted_counter.inc() flow.accepted_community_guidelines = GUIDELINES_VERSION session.flush() - # send verification email if needed if not flow.email_sent or request.resend_verification_email: send_signup_email(context, session, flow) @@ -813,3 +812,73 @@ def GetInviteCodeInfo( avatar_url=avatar_upload.thumbnail_url if avatar_upload else None, url=urls.invite_code_link(code=request.code), ) + + def SignupFlowChangeEmail( + self, + request: auth_pb2.ChangeSignupEmailReq, + context: CouchersContext, + session: Session, + ) -> auth_pb2.SignupFlowRes: + flow = session.execute( + select(SignupFlow).where(SignupFlow.flow_token == request.flow_token) + ).scalar_one_or_none() + + if not flow: + context.abort_with_error_code( + grpc.StatusCode.NOT_FOUND, + "invalid_token", + ) + + new_email = request.new_email.strip().lower() + + if not is_valid_email(new_email): + context.abort_with_error_code( + grpc.StatusCode.INVALID_ARGUMENT, + "invalid_email", + ) + + existing_user = session.execute(select(User).where(User.email == new_email)).scalar_one_or_none() + + if existing_user: + if not existing_user.is_visible: + context.abort_with_error_code( + grpc.StatusCode.FAILED_PRECONDITION, + "signup_email_cannot_be_used", + ) + + context.abort_with_error_code( + grpc.StatusCode.FAILED_PRECONDITION, + "signup_flow_email_taken", + ) + existing_signup = session.execute( + select(SignupFlow).where( + SignupFlow.email == new_email, + SignupFlow.id != flow.id, + ) + ).scalar_one_or_none() + + if existing_signup: + context.abort_with_error_code( + grpc.StatusCode.FAILED_PRECONDITION, + "signup_flow_email_taken", + ) + + flow.email = new_email + + # Invalidate the old verification token. + flow.email_token = None + flow.email_token_expiry = None + flow.email_sent = False + + send_signup_email(context, session, flow) + + session.flush() + + return auth_pb2.SignupFlowRes( + flow_token=flow.flow_token, + need_account=not flow.account_is_filled, + need_feedback=False, + need_verify_email=True, + need_accept_community_guidelines=(flow.accepted_community_guidelines < GUIDELINES_VERSION), + need_motivations=not flow.filled_motivations, + ) diff --git a/app/proto/auth.proto b/app/proto/auth.proto index f253401c4f..a84b763e59 100644 --- a/app/proto/auth.proto +++ b/app/proto/auth.proto @@ -49,6 +49,10 @@ service Auth { // * Once the flow completes, the user is logged in and the signup flow is destroyed } + rpc SignupFlowChangeEmail(ChangeSignupEmailReq) returns (SignupFlowRes) { + // Change the email address of an incomplete signup flow. + } + rpc UsernameValid(UsernameValidReq) returns (UsernameValidRes) { // Check whether the username is valid and available } @@ -284,3 +288,8 @@ message GetInviteCodeInfoRes { string avatar_url = 3; string url = 4; } + +message ChangeSignupEmailReq { + string flow_token = 1; + string new_email = 2; +} diff --git a/app/web/.env.development b/app/web/.env.development index c9a6905dc5..218e7b050e 100644 --- a/app/web/.env.development +++ b/app/web/.env.development @@ -1,12 +1,13 @@ -NEXT_PUBLIC_COUCHERS_ENV=preview -NEXT_PUBLIC_BASE_URL="https://next.couchershq.org" -NEXT_PUBLIC_API_BASE_URL="https://next.couchershq.org/api" -NEXT_PUBLIC_MEDIA_BASE_URL="https://dev-user-media.couchershq.org" -NEXT_PUBLIC_CONSOLE_BASE_URL="https://next-console.couchershq.org" +NEXT_PUBLIC_COUCHERS_ENV=dev +NEXT_PUBLIC_BASE_URL="/" +NEXT_PUBLIC_API_BASE_URL="http://localhost:8888" +NEXT_PUBLIC_MEDIA_BASE_URL="http://localhost:5001" +NEXT_PUBLIC_CONSOLE_BASE_URL="http://localhost:10027" +NEXT_PUBLIC_IS_POST_BETA_ENABLED=true NEXT_PUBLIC_NOMINATIM_URL="https://nominatim.openstreetmap.org/" +NEXT_PUBLIC_IS_VERIFICATION_ENABLED=true +NEXT_PUBLIC_IS_COMMUNITIES_PART2_ENABLED=true NEXT_PUBLIC_STRIPE_KEY="pk_test_51KEzByIfR5z29g5khFE5samD8XKOGLcCrM1lhCkfOomGPUFAEYOw8uAqI2Nkv33wYdPM2FgTQNTC07IiNfHY1kLJ00Jqm8Ppai" -NEXT_PUBLIC_GLOBAL_MESSAGE_URL="https://gm.couchershq.org/next.json" +NEXT_PUBLIC_GLOBAL_MESSAGE_URL="https://gm.couchershq.org/localdev.json" NEXT_PUBLIC_GROWTHBOOK_API_HOST="https://gbapi.couchershq.org" NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY="sdk-f8lwseEODN02p" -# When enabled, flags resolve from app/web/feature-flags.dev.json instead of GrowthBook (dev/testing) -NEXT_PUBLIC_FEATURE_FLAGS_OVERRIDE="1" diff --git a/app/web/features/auth/locales/en.json b/app/web/features/auth/locales/en.json index e3b8240f16..5957b93b6b 100644 --- a/app/web/features/auth/locales/en.json +++ b/app/web/features/auth/locales/en.json @@ -8,6 +8,13 @@ "hide_current_password": "Hide current password", "show_current_password": "Show current password" }, + "change_signup_email_form": { + "title": "Change signup email", + "current_email_message": "Your email address is currently {{email}}.", + "success_message": "Your email change has been received. Check your new email to confirm your email.", + "new_email": "Changed email address", + "signup_change_email": "Change your email address" + }, "do_not_email": { "title": "Do not email me", "status": { @@ -176,7 +183,9 @@ "sign_up_need_verification_title": "One last thing: confirm your email.", "sign_up_resend_verification_email_help": "Didn't receive the email? Click here to resend the verification link.", "sign_up_resend_verification_done": "Done! We've sent you another email.", - "sign_up_completed_prompt": "We have sent an email with a verification link to your email address. Please click the link to activate your account.", + "sign_up_change_email": "Mistyped your email address? Enter your correct email address here:", + "sign_up_completed_prompt": "We have sent an email with a verification link to your email address: {{providedEmailAddress}}. Please click the link to activate your account.", + "sign_up_completed_prompt_noemail": "We have sent an email with a verification link to your email address. Please click the link to activate your account.", "sign_up_confirmed_prompt": "You're all done! If you are not redirected, try logging in.", "unhandled_sign_up_state": "Error: unhandled signup flow state.", "login_prompt": "Click here to log in", diff --git a/app/web/features/auth/signup/BasicForm.tsx b/app/web/features/auth/signup/BasicForm.tsx index 7f443f3fda..00e56f46f2 100644 --- a/app/web/features/auth/signup/BasicForm.tsx +++ b/app/web/features/auth/signup/BasicForm.tsx @@ -45,6 +45,7 @@ export default function BasicForm({ submitText, successCallback, inviteCode }: B mutationFn: async (data) => { const sanitizedEmail = lowercaseAndTrimField(data.email); const sanitizedName = data.name.trim(); + authActions.assignSignupEmail(sanitizedEmail); const state = await service.auth.startSignup(sanitizedName, sanitizedEmail, inviteCode); doAntibot("signup"); return authActions.updateSignupState(state); diff --git a/app/web/features/auth/signup/ChangeSignupEmail.tsx b/app/web/features/auth/signup/ChangeSignupEmail.tsx new file mode 100644 index 0000000000..2eaf5afd7f --- /dev/null +++ b/app/web/features/auth/signup/ChangeSignupEmail.tsx @@ -0,0 +1,98 @@ +import { styled, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { useMutation } from "@tanstack/react-query"; +import Alert from "components/Alert"; +import Button from "components/Button"; +import TextField from "components/TextField"; +import { useAuthContext } from "features/auth/AuthProvider"; +import { Empty } from "google-protobuf/google/protobuf/empty_pb"; +import { RpcError } from "grpc-web"; +import { useTranslation } from "i18n"; +import { AUTH, GLOBAL } from "i18n/namespaces"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { service } from "service"; +import { lowercaseAndTrimField } from "utils/validation"; + +const StyledForm = styled("form")(({ theme }) => ({ + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + alignItems: "flex-start", + width: "100%", + [theme.breakpoints.up("md")]: { + width: "15.5rem", + }, +})); + +interface ChangeSignupEmailFormData { + newSignupEmail: string; +} + +interface ChangeSignupEmailProps { + email: string; + className?: string; +} + +export default function ChangeSignupEmail({ className }: ChangeSignupEmailProps) { + const { t } = useTranslation([AUTH, GLOBAL]); + const { authActions, authState } = useAuthContext(); + const theme = useTheme(); + const isMdOrWider = useMediaQuery(theme.breakpoints.up("md")); + + const [changedEmail, setChangedEmail] = useState(false); + + const { handleSubmit, register, reset: resetForm } = useForm(); + const onSubmit = handleSubmit(({ newSignupEmail }) => { + const sanitizedEmail = lowercaseAndTrimField(newSignupEmail); + setChangedEmail(true); + changeSignupEmail({ newSignupEmail: sanitizedEmail }); + }); + + const { + error: changeSignupEmailError, + isPending: isChangeSignupEmailLoading, + isSuccess: isChangeSignupEmailSuccess, + mutate: changeSignupEmail, + } = useMutation({ + mutationFn: async ({ newSignupEmail }) => { + await service.auth.signupFlowChangeEmail(authState.flowState!.flowToken, lowercaseAndTrimField(newSignupEmail)); + }, + onSuccess: (_, { newSignupEmail }) => { + const sanitizedEmail = lowercaseAndTrimField(newSignupEmail); + authActions.assignSignupEmail(sanitizedEmail); + resetForm(); + }, + }); + + return ( +
+ + {!changedEmail ? t("Mistype your email address? Change it here:") : ""} + + <> + {changeSignupEmailError && {changeSignupEmailError.message}} + {isChangeSignupEmailSuccess && ( + {t("auth:change_signup_email_form.success_message")} + )} + {!changedEmail ? ( + + + + + ) : ( + <> + )} + +
+ ); +} diff --git a/app/web/features/auth/signup/ResendVerificationEmailForm.tsx b/app/web/features/auth/signup/ResendVerificationEmailForm.tsx index 1f2eb7a647..69f8bd71e3 100644 --- a/app/web/features/auth/signup/ResendVerificationEmailForm.tsx +++ b/app/web/features/auth/signup/ResendVerificationEmailForm.tsx @@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/react-query"; import Alert from "components/Alert"; import StyledLink from "components/StyledLink"; import { useAuthContext } from "features/auth/AuthProvider"; +import ChangeSignupEmail from "features/auth/signup/ChangeSignupEmail"; import { Trans, useTranslation } from "i18n"; import { AUTH, GLOBAL } from "i18n/namespaces"; import { useState } from "react"; @@ -26,9 +27,18 @@ export default function ResendVerificationEmailForm() { <> {mutation.error && {mutation.error.message || ""}} - {t("auth:sign_up_completed_prompt")} - - + + {authState.signupEmail ? ( + + ) : ( + + )} + {!resent ? ( {t("auth:sign_up_resend_verification_done")} )} + ); } diff --git a/app/web/features/auth/signup/Signup.test.tsx b/app/web/features/auth/signup/Signup.test.tsx index 4b44345371..d8d59b8ae0 100644 --- a/app/web/features/auth/signup/Signup.test.tsx +++ b/app/web/features/auth/signup/Signup.test.tsx @@ -446,9 +446,22 @@ describe("Signup", () => { needVerifyEmail: true, flowToken: "token", }; - window.localStorage.setItem("auth.flowState", JSON.stringify(state)); + + const testEmail = "test@example.com"; + + localStorage.setItem("auth.flowState", JSON.stringify(state)); + localStorage.setItem("auth.signupEmail", JSON.stringify(testEmail)); + render(, { wrapper }); - expect(screen.getByText(t("auth:sign_up_completed_prompt"))).toBeVisible(); + + expect( + await screen.findByText((_, element) => { + return ( + element?.textContent === + `We have sent an email with a verification link to your email address: ${testEmail}. Please click the link to activate your account.` + ); + }), + ).toBeVisible(); }); it("displays the redirect message when nothing is pending and has authRes", async () => { diff --git a/app/web/features/auth/useAuthStore.ts b/app/web/features/auth/useAuthStore.ts index c9d296443c..d66d94d134 100644 --- a/app/web/features/auth/useAuthStore.ts +++ b/app/web/features/auth/useAuthStore.ts @@ -47,6 +47,7 @@ export default function useAuthStore() { const [userId, setUserId] = usePersistedState("auth.userId", null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [signupEmail, setSignupEmail] = usePersistedState("auth.signupEmail", null); const [flowState, setFlowState] = usePersistedState("auth.flowState", null); //this is used to set the current user in the user cache @@ -139,10 +140,14 @@ export default function useAuthStore() { setFlowState(state); if (state.authRes) { setFlowState(null); + setSignupEmail(null); authActions.firstLogin(state.authRes!); return; } }, + assignSignupEmail(email: string) { + setSignupEmail(email); + }, async firstLogin(res: AuthRes.AsObject) { setError(null); setUserId(res.userId); @@ -190,9 +195,9 @@ export default function useAuthStore() { setLoading(false); }, }), - //note: there should be no dependenices on the state or t, or + //note: there should be no dependencies on the state or t, or //some useEffects will break. Eg. the token login in Login.tsx - [setAuthenticated, setJailed, setUserId, setFlowState, queryClient], + [setAuthenticated, setJailed, setUserId, setFlowState, setSignupEmail, queryClient], ); return { @@ -202,6 +207,7 @@ export default function useAuthStore() { error, jailed, loading, + signupEmail, userId, flowState, }, diff --git a/app/web/service/auth.ts b/app/web/service/auth.ts index 7fcd4f1f50..fc6ce9d423 100644 --- a/app/web/service/auth.ts +++ b/app/web/service/auth.ts @@ -2,6 +2,7 @@ import { BoolValue } from "google-protobuf/google/protobuf/wrappers_pb"; import { HostingStatus } from "proto/api_pb"; import { AntiBotReq, + ChangeSignupEmailReq, ConfirmDeleteAccountReq, ContributorForm as ContributorFormPb, GetInviteCodeInfoReq, @@ -144,6 +145,13 @@ export async function signupFlowResendVerificationEmail(flowToken: string) { return res.toObject(); } +export function signupFlowChangeEmail(flowToken: string, newEmail: string) { + const req = new ChangeSignupEmailReq(); + req.setNewEmail(newEmail); + req.setFlowToken(flowToken); + return client.auth.signupFlowChangeEmail(req); +} + export async function validateUsername(username: string) { const req = new UsernameValidReq(); req.setUsername(username);