Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
099c1ad
Allow starting over from verification page
Aug 3, 2026
dd7551c
Allow starting over from verification page
Aug 3, 2026
38b5bac
reverting unnecessary changes to useAuthStore
Aug 3, 2026
88ffc26
Format frontend
couchersbot[bot] Aug 3, 2026
a11a8ce
Merge branch 'develop' into web/bug/signupchangeemail
nc225 Aug 3, 2026
98f4ddb
specifically clearing flowToken on restart
Aug 3, 2026
910ee53
Format frontend
couchersbot[bot] Aug 3, 2026
296cec6
use function signupFlowRestartSignup
Aug 3, 2026
aacce90
Format frontend
couchersbot[bot] Aug 3, 2026
3d2a518
add more error handling
Aug 4, 2026
29a63a3
add more error handling
Aug 4, 2026
4953dd6
Format frontend
couchersbot[bot] Aug 4, 2026
f7e846e
add more error handling
Aug 4, 2026
bf27d5c
Format frontend
couchersbot[bot] Aug 4, 2026
9fac54b
add type for e
Aug 4, 2026
0bde6e8
Merge branch 'develop' into web/bug/signupchangeemail
nc225 Aug 5, 2026
f9f936d
Merge branch 'develop' into web/bug/signupchangeemail
nc225 Aug 7, 2026
8284bb2
changed 2-->resendLink
Aug 19, 2026
9cf499d
use new link reference system
Aug 19, 2026
0bf8c49
use new link reference system
Aug 19, 2026
d91faf9
use new link reference system
Aug 19, 2026
15a62af
Format frontend
couchersbot[bot] Aug 19, 2026
cf0bd19
Merge branch 'develop' into web/bug/signupchangeemail
nc225 Aug 20, 2026
c0935c8
allow signup restart by deleting flowtoken
Aug 21, 2026
ee7a25e
Format frontend
couchersbot[bot] Aug 21, 2026
a184337
updating signuptest to show test email
Aug 21, 2026
dc1b505
Format frontend
couchersbot[bot] Aug 21, 2026
20a7e15
simplifying resendVerification functions
Aug 21, 2026
ab3a37e
Format frontend
couchersbot[bot] Aug 21, 2026
e45fd1b
using testEmail variable
Aug 21, 2026
e4b3c7b
revise restartSignup process to use authStore
Aug 22, 2026
cbf2d14
Format frontend
couchersbot[bot] Aug 22, 2026
a9d1321
clean up and use clearer names
Aug 22, 2026
f16f5d5
changing signup_flows: adding signup_cancelled and adjusting username…
Aug 24, 2026
aeabf2b
Generate migrations
couchersbot[bot] Aug 24, 2026
f720035
Format backend
couchersbot[bot] Aug 24, 2026
f88240c
adding signup_cancelled
Aug 24, 2026
f1c3642
adding signup_cancelled
Aug 24, 2026
c254b07
Format backend
couchersbot[bot] Aug 24, 2026
00e80bd
Format frontend
couchersbot[bot] Aug 24, 2026
e3a0bf0
update migration files
Aug 24, 2026
5cf0bab
Format backend
couchersbot[bot] Aug 24, 2026
3950d0d
update upgrade and downgrade to add signup_cancelled
Aug 24, 2026
68ae9d1
add signup_cancelled to auth flow for correct error handling
Aug 24, 2026
f747dcf
fall back to old email message if email is not stored
Aug 24, 2026
5d471d0
fall back to old email message if email is not stored
Aug 24, 2026
c605f7f
remove email_token invalidation here since this happens in auth.py in…
Aug 24, 2026
f5fc917
rework to prevent blocking usernames
Aug 25, 2026
528576c
rework to prevent blocking usernames
Aug 25, 2026
cbd9a5c
draft new approach to email change during signup
Aug 25, 2026
f7b98a1
Format backend
couchersbot[bot] Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion app/backend/src/couchers/servicers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
9 changes: 9 additions & 0 deletions app/proto/auth.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -284,3 +288,8 @@ message GetInviteCodeInfoRes {
string avatar_url = 3;
string url = 4;
}

message ChangeSignupEmailReq {
string flow_token = 1;
string new_email = 2;
}
17 changes: 9 additions & 8 deletions app/web/.env.development
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 10 additions & 1 deletion app/web/features/auth/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <strong>{{email}}</strong>.",
"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": {
Expand Down Expand Up @@ -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 <resendLink>here to resend the verification link</resendLink>.",
"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: <strong>{{providedEmailAddress}}</strong>. 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",
Expand Down
1 change: 1 addition & 0 deletions app/web/features/auth/signup/BasicForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
98 changes: 98 additions & 0 deletions app/web/features/auth/signup/ChangeSignupEmail.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);

const { handleSubmit, register, reset: resetForm } = useForm<ChangeSignupEmailFormData>();
const onSubmit = handleSubmit(({ newSignupEmail }) => {
const sanitizedEmail = lowercaseAndTrimField(newSignupEmail);
setChangedEmail(true);
changeSignupEmail({ newSignupEmail: sanitizedEmail });
});

const {
error: changeSignupEmailError,
isPending: isChangeSignupEmailLoading,
isSuccess: isChangeSignupEmailSuccess,
mutate: changeSignupEmail,
} = useMutation<Empty, RpcError, ChangeSignupEmailFormData>({
mutationFn: async ({ newSignupEmail }) => {
await service.auth.signupFlowChangeEmail(authState.flowState!.flowToken, lowercaseAndTrimField(newSignupEmail));
},
onSuccess: (_, { newSignupEmail }) => {
const sanitizedEmail = lowercaseAndTrimField(newSignupEmail);
authActions.assignSignupEmail(sanitizedEmail);
resetForm();
},
});

return (
<div className={className}>
<Typography variant="body1" gutterBottom>
{!changedEmail ? t("Mistype your email address? Change it here:") : ""}
</Typography>
<>
{changeSignupEmailError && <Alert severity="error">{changeSignupEmailError.message}</Alert>}
{isChangeSignupEmailSuccess && (
<Alert severity="success">{t("auth:change_signup_email_form.success_message")}</Alert>
)}
{!changedEmail ? (
<StyledForm onSubmit={onSubmit}>
<TextField
id="newSignupEmail"
{...register("newSignupEmail", { required: true })}
label={t("auth:change_signup_email_form.new_email")}
name="newSignupEmail"
fullWidth
/>
<Button fullWidth={!isMdOrWider} loading={isChangeSignupEmailLoading} type="submit">
{t("auth:change_signup_email_form.signup_change_email")}
</Button>
</StyledForm>
) : (
<></>
)}
</>
</div>
);
}
17 changes: 14 additions & 3 deletions app/web/features/auth/signup/ResendVerificationEmailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,9 +27,18 @@ export default function ResendVerificationEmailForm() {
<>
{mutation.error && <Alert severity="error">{mutation.error.message || ""}</Alert>}
<Typography variant="body1" gutterBottom>
{t("auth:sign_up_completed_prompt")}
</Typography>
<Typography variant="body1">
<Typography variant="body1" gutterBottom>
{authState.signupEmail ? (
<Trans
i18nKey="auth:sign_up_completed_prompt"
values={{
providedEmailAddress: authState.signupEmail,
}}
/>
) : (
<Trans i18nKey="auth:sign_up_completed_prompt_noemail" />
)}
</Typography>
{!resent ? (
<Trans
i18nKey="auth:sign_up_resend_verification_email_help"
Expand All @@ -48,6 +58,7 @@ export default function ResendVerificationEmailForm() {
<>{t("auth:sign_up_resend_verification_done")}</>
)}
</Typography>
<ChangeSignupEmail />
</>
);
}
17 changes: 15 additions & 2 deletions app/web/features/auth/signup/Signup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<View />, { 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 () => {
Expand Down
10 changes: 8 additions & 2 deletions app/web/features/auth/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default function useAuthStore() {
const [userId, setUserId] = usePersistedState<number | null>("auth.userId", null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [signupEmail, setSignupEmail] = usePersistedState<string | null>("auth.signupEmail", null);
const [flowState, setFlowState] = usePersistedState<SignupFlowRes.AsObject | null>("auth.flowState", null);

//this is used to set the current user in the user cache
Expand Down Expand Up @@ -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);
Comment thread
nc225 marked this conversation as resolved.
},
async firstLogin(res: AuthRes.AsObject) {
setError(null);
setUserId(res.userId);
Expand Down Expand Up @@ -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 {
Expand All @@ -202,6 +207,7 @@ export default function useAuthStore() {
error,
jailed,
loading,
signupEmail,
userId,
flowState,
},
Expand Down
8 changes: 8 additions & 0 deletions app/web/service/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading