Skip to content

Allow starting over from verification page - #9445

Closed
nc225 wants to merge 51 commits into
developfrom
web/bug/signupchangeemail
Closed

Allow starting over from verification page#9445
nc225 wants to merge 51 commits into
developfrom
web/bug/signupchangeemail

Conversation

@nc225

@nc225 nc225 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes #9092

A few notes:

1. No email address shown

The linked issue suggests that we

Display email address on that page,

However, here we do not display the email address, because email is not currently available via auto.proto file. If we make email available from auth_pb.js alongside flowToken, then we can eventually incorporate email address here as well.

2. Prior verification link
If someone clicks the link to start over, their prior verification token/email is still valid so they could end up with two distinct accounts if they somehow have access to both emails. LMK if we want to invalidate the token as well, though I don't see it posing a major issue.

3. Handling incomplete signups

new signups must not be blocked if there's an existing incomplete signup with that same email

Keeping the current behavior here: if a new signup tries to use the email address of an existing incomplete signup, there is a message to "Please check your email for a link to continue signing up" and an email is sent with a link that picks up to wherever they left off in the signup process.

4. Suggestion for revised SignupFlow
Finally, I do think signup flow could benefit from a "back" button (What if you decide you want to change your Hosting Status before submitting? This is easy to do once your account is active, but still.)

Testing

Tested locally in a few different scenarios, seems to work fine. I wasn't sure which automated tests to add so I haven't made any but happy to talk about those.

Backend checklist

  • Added tests for any new code or added a regression test if fixing a bug
  • Run the backend locally and it works
  • Added migrations if there are any database changes, rebased onto develop if necessary for linear migration history

Web frontend checklist

  • There are no console warnings when running the app
  • Added tests where relevant
  • Clicked around my changes running locally and it works
  • Checked Desktop, Mobile and Tablet screen sizes

For maintainers

  • Maintainers can push commits to my branch
  • Maintainers can merge this PR for me

@CouchersBot

CouchersBot commented Aug 3, 2026

Copy link
Copy Markdown
Member

Mobile

Install the Dev Tool (iOS via TestFlight, iOS Simulator, or Android .apk) here.

Scan the QR with your phone camera, or tap Open in Dev Tool on the device, to open this branch in the installed Dev Tool dev client.

    iOS         Android    
    QR to open the iOS build         QR to open the Android build    
    Open in Dev Tool         Open in Dev Tool    
Deep links

iOS

couchers-devtool://expo-development-client/?url=https%3A%2F%2Fcbd9a5c0--ota.preview.couchershq.org%2Fios%2Fmanifest

Android

couchers-devtool://expo-development-client/?url=https%3A%2F%2Fcbd9a5c0--ota.preview.couchershq.org%2Fandroid%2Fmanifest

Web (Vercel)

View the Vercel web preview for this branch.

Backend

Schema Schema diff Sample emails SQL query log
+89/-88 query shapes · 75 tests changed

Other

Protos

@nc225
nc225 marked this pull request as ready for review August 4, 2026 01:29
Comment thread app/web/features/auth/signup/ResendVerificationEmailForm.tsx Outdated
Comment thread app/web/.env.development
Comment thread app/web/features/auth/signup/ResendVerificationEmailForm.tsx Outdated
Comment thread app/backend/src/couchers/servicers/auth.py Outdated
Comment thread app/web/features/auth/signup/ResendVerificationEmailForm.tsx Outdated
@couchersbot

couchersbot Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @nc225's task in 3m 17s —— View job


Migration Review

One of our most common issues is migrations that don't work with existing data in production. This is very hard to catch with CI, so we have this bot to check for potential problems.

Overall Risk: HIGH RISK — the migration file has unresolved merge conflict markers committed in it, so nothing runs at all.

  • Fetch and read the PR diff
  • Read the migration in full, verify the revision chain
  • Cross-check models/rest.py against the migration
  • Check production schema in schema.sql
  • Evaluate against the migration-safety checklist
  • Post inline comments and summary

Files Reviewed

  • 0187_allow_starting_over_from_verification_.py (new) — adds signup_flows.signup_cancelled, drops UNIQUE (username) and replaces it with a partial unique index (username IS NOT NULL AND signup_cancelled = false)
  • models/rest.py — matching model changes (new column, unique=True dropped from username, partial Index in __table_args__)
  • servicers/auth.py — sets signup_cancelled on cancel; now filters it on four lookups

Findings

  • Unresolved merge conflict — HIGH (blocker) (inline): lines 60–65 of the migration still contain <<<<<<< HEAD / ======= / >>>>>>> fa81c79b8 (update upgrade and downgrade to add signup_cancelled). The file isn't valid Python, so Alembic raises SyntaxError while importing the versions directory — every alembic command fails and the backend won't start. This is the only file in the repo with conflict markers.

  • Downgrade ordering — MEDIUM (same inline): whichever conflict side is kept, op.execute("DELETE FROM signup_flows WHERE signup_cancelled") sits after create_unique_constraint. Re-adding the full UNIQUE (username) fails on duplicate usernames — precisely the state this feature creates — so the constraint blows up before the cleanup ever runs. The DELETE needs to come first. The suggestion in the inline comment fixes this and the conflict together.

  • Lock contention — LOW: create_index is non-concurrent, so it takes a lock on signup_flows. That table only holds in-flight signups, so this is fine as-is; noting for completeness.

Confirmed safe against existing data ✅

  • NOT NULL backfill is now correct: server_default=sa.false() on add_column, then alter_column(..., server_default=None) after the index swap. Existing rows get false, and dropping the default afterwards keeps the schema matching the model's Python-side default=False.
  • The index swap is safe: production has uq_signup_flows_username UNIQUE (username) (schema.sql:2681), and the new partial index is strictly weaker, so no existing row can violate it. Dropping the constraint frees the name for the index.
  • Revision chain is clean — 0187 is a single head off 0186 (the stray duplicate 0187_add_signup_cancelled.py from earlier pushes is gone).
  • Operation ordering within upgrade() is correct (backfill → drop constraint → create index → drop default).

Outside migration safety

  • New regression on this push: cancel + re-signup with the same email is now a 500 (inline). Adding .where(SignupFlow.signup_cancelled == False) to the by-email lookup (auth.py:240-242) closes the resurrection hole — but uq_signup_flows_email is still a full UNIQUE constraint (schema.sql:2672); only username was made partial. So the cancelled row keeps that email reserved while being invisible to the lookup, and re-submitting the same address falls through to SignupFlow(... email=...) at L268 → IntegrityError on uq_signup_flows_email at the L275 flush. There's no IntegrityError handling in auth.py, so that's an unhandled internal error where the pre-PR code aborted cleanly with signup_flow_email_started_signup. restartSignup() clears both flowState and signupEmail and drops the user back on BasicForm, so nothing discourages re-entering the same address.

    session.delete(flow) in the cancel branch would close this and remove the need for the column, the partial index, and all four signup_cancelled filters. Keeping the flag works too, but then the email constraint needs the same partial treatment.

  • make format needed: auth.py:221, 241, 281 and 523 are all >120 chars (limit is 120 in pyproject.toml), so ruff format will rewrap them — CI's format check will fail until then.

  • No backend tests for the cancel path — nothing in src/tests/ references cancel_signup or signup_cancelled. "Cancel, then re-signup with the same email" and "cancel, then a second flow claims the freed username" are exactly the cases that would have caught the item above.

  • Minor: cancel_signup is silently ignored on the email_token branch (auth.py:201-224); it's only honoured in the else at L280. An explicit abort reads better than a no-op.

  • The rest of the earlier review items look addressed: the unused state in ResendVerificationEmailForm.tsx is gone, sign_up_completed_prompt_noemail handles the null-signupEmail case, req.setEmailToken(null) is gone, and .env.development is no longer in the diff.

Summary

The upgrade path is now genuinely safe against existing production data — the NOT NULL backfill is correct and the partial index is strictly weaker than the constraint it replaces. But the migration file has committed merge conflict markers, which breaks Alembic outright and must be resolved, and the downgrade's DELETE runs after the constraint it's meant to unblock. Outside the migration, adding the signup_cancelled filter to the by-email lookup without freeing the email turns cancel-then-same-email into an unhandled IntegrityError.
· branch web/bug/signupchangeemail

).scalar_one_or_none()
if not flow:
context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token")
if request.cancel_signup:

@nc225 nc225 Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I am adding the signup_cancelled flag when a signup is cancelled. Another option would be to use session.delete() but it seems we generally prefer adding info to destroying it - happy to go with session.delete() though since adding the flag this requires adding a few lines elsewhere too. Also, I thought it was a bit confusing to use the same name for the proto and the postgres variables so I called one signup_cancelled and the other cancel_signup but if this, too, is confusing, I will standardize.

Comment on lines +51 to +65
def downgrade() -> None:
op.drop_index(
"uq_signup_flows_username",
table_name="signup_flows",
postgresql_where=sa.text("username IS NOT NULL AND signup_cancelled = false"),
)
op.create_unique_constraint(
op.f("uq_signup_flows_username"), "signup_flows", ["username"], postgresql_nulls_not_distinct=False
)
<<<<<<< HEAD
op.drop_column("signup_flows", "signup_cancelled")
=======
op.execute("DELETE FROM signup_flows WHERE signup_cancelled")
op.drop_column("signup_flows", "signup_cancelled")
>>>>>>> fa81c79b8 (update upgrade and downgrade to add signup_cancelled)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH — blocker: unresolved merge conflict markers are committed in this file.

Lines 60–65 still contain <<<<<<< HEAD / ======= / >>>>>>> fa81c79b8. The module is not valid Python, so it raises SyntaxError the moment Alembic imports the versions directory — every alembic command fails (including upgrade head on deploy) and the backend won't start.

Also, whichever side you keep, the DELETE needs to run before create_unique_constraint, not after: re-adding the full UNIQUE (username) fails on duplicate usernames, and duplicates are exactly the state this feature creates (a cancelled flow plus a new flow reusing the same username). The current placement means the constraint blows up before the cleanup ever runs.

This resolves both:

Suggested change
def downgrade() -> None:
op.drop_index(
"uq_signup_flows_username",
table_name="signup_flows",
postgresql_where=sa.text("username IS NOT NULL AND signup_cancelled = false"),
)
op.create_unique_constraint(
op.f("uq_signup_flows_username"), "signup_flows", ["username"], postgresql_nulls_not_distinct=False
)
<<<<<<< HEAD
op.drop_column("signup_flows", "signup_cancelled")
=======
op.execute("DELETE FROM signup_flows WHERE signup_cancelled")
op.drop_column("signup_flows", "signup_cancelled")
>>>>>>> fa81c79b8 (update upgrade and downgrade to add signup_cancelled)
def downgrade() -> None:
op.drop_index(
"uq_signup_flows_username",
table_name="signup_flows",
postgresql_where=sa.text("username IS NOT NULL AND signup_cancelled = false"),
)
op.execute("DELETE FROM signup_flows WHERE signup_cancelled")
op.create_unique_constraint(
op.f("uq_signup_flows_username"), "signup_flows", ["username"], postgresql_nulls_not_distinct=False
)
op.drop_column("signup_flows", "signup_cancelled")

context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_taken")
existing_flow = session.execute(
select(SignupFlow).where(SignupFlow.email == request.basic.email)
select(SignupFlow).where(SignupFlow.email == request.basic.email).where(SignupFlow.signup_cancelled == False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — new regression on this push: cancel + re-signup with the same email is now a 500.

Filtering signup_cancelled here closes the resurrection hole (good), but signup_flows.email still carries a full UNIQUE constraint (schema.sql:2672 — only username was made partial). So:

  1. User signs up with a@b.com, clicks "start over" → the row stays with signup_cancelled = true, email = 'a@b.com'.
  2. They re-submit a@b.com (they mistyped their name, or realised the address was fine, or just retype it out of habit).
  3. This lookup no longer matches the cancelled row, so we fall through to SignupFlow(... email=request.basic.email ...) at L268 and session.flush() at L275 → IntegrityError on uq_signup_flows_email.

There's no IntegrityError handling anywhere in auth.py, so the user gets an unhandled internal error instead of the clean signup_flow_email_started_signup abort they'd have got before this PR. The FE makes this very reachable: restartSignup() clears flowState and signupEmail and drops the user straight back on BasicForm, so nothing stops them re-entering the same address.

Two ways out:

  • session.delete(flow) in the cancel branch instead of the soft flag. Cancellation becomes genuinely terminal, both the email and the username are freed, all four .where(SignupFlow.signup_cancelled == False) filters become unnecessary, and the migration (new column + partial index swap) isn't needed at all.
  • Keep the flag, but make uq_signup_flows_email partial too (same signup_cancelled = false predicate), so the insert succeeds.

Either way this deserves a regression test — "cancel, then re-signup with the same email" and "cancel, then a second flow takes the freed username" are exactly the cases that would have caught it. Nothing in src/tests/ touches cancel_signup yet.

Two smaller things on this hunk: this line and L221, L281, L523 are all >120 chars, so ruff format will rewrap them and CI's format check fails until make format is run. And cancel_signup is silently ignored on the email_token branch (L201–224) — it's only honoured in the else at L280; an explicit abort would beat a no-op.

@nc225

nc225 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

(I am deleting a previous comment bc I figured out the problem -- I somehow ended up with duplicate migration files and that made the backend unhappy.)

I've got this working in some form but I'd like to add more tests to the BE section before restoring from drafts. One BE question that I marked in the code too: when a signup is cancelled due to bad email, should we

  • add a signup_cancelled flag to Signup_Flows and check for that as needed, or
  • use session.delete() to just drop the Signup_Flow as we do after successful completion? or
  • some other third option?

I've gone with the first one because it seems a bit clearer, but it also means checking for signup_cancelled a few different places, and I could see going either way.

The PR is a bit complicated right now, and I may explore whether a different path (say, letting the user change their email at/after the verification screen instead of restarting) may work better.

<<<<<<< HEAD
op.drop_column("signup_flows", "signup_cancelled")
=======
op.execute("DELETE FROM signup_flows WHERE signup_cancelled")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this will drop any signup_cancelled rows from the database upon downgrade so that username remains unique

@papeldeorigami

Copy link
Copy Markdown
Contributor

@nc225 I think it's fine to continue the work here as a draft, but I would recommend closing it and splitting the BE and FE work before you ask for a new round of reviews so people can take a specialized look.

@nc225

nc225 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Since we have to make BE changes anyhow to prevent mistyped emails from blocking usernames in SignupFlow, I am reworking this to use option 3 from this comment instead of option 2 as I had prior. Here are a couple screenshots in development of what the front end might look like on the email verification screen - open to any design tips. (Also forgive the still-misspelled Mistyped) I will also split this into FE and BE PRs.

Separate convo: do we want to limit the number of times a Signup can change its email address?

Screenshot From 2026-08-25 00-56-14 Screenshot From 2026-08-25 00-56-31

@nabramow

nabramow commented Aug 25, 2026

Copy link
Copy Markdown
Member

hey @nc225 just popping on here as these go in my email - FYI we normally try to break frontend and backend changes into separate PRs for easier review.

@nc225

nc225 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Since this involves BE changes, closing in favor of these two PRs:
FE: #9626
BE: #9627

@nc225 nc225 closed this Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug tool User reported with bug reporting tool.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend/signup: Can't change a bad email address

5 participants