The page headed "Accept your invite" has a Google button that makes accepting the invite impossible
PubTrivia's top plan is five host accounts on one venue, which is the hosts and venues feature: an owner invites their other quizmasters by email, and each one can run a night in their own right. The invite is a row in v
PubTrivia's top plan is five host accounts on one venue, which is the hosts and venues feature: an owner invites their other quizmasters by email, and each one can run a night in their own right. The invite is a row in venue_members and a link to /auth/invite?token=<hex>.
An account can be created two ways, and both of them write the same three rows.
The password path, a server action in app/auth/actions.ts:
await db.insert(usersTable).values({
id: signUpData.user.id, name: data.name, email: signUpData.user.email!,
stripe_id: stripeID, plan: null,
})
const [venue] = await db.insert(venuesTable).values({
owner_user_id: signUpData.user.id,
name: `${data.name}'s Venue`,
subscription_status: 'trial', plan_type: 'trial', trial_used: false,
stripe_customer_id: stripeID,
}).returning({ id: venuesTable.id })
await db.insert(venueMembersTable).values({
venue_id: venue.id, user_id: signUpData.user.id,
invited_email: signUpData.user.email!,
role: 'owner', status: 'active', invited_by: signUpData.user.id,
})
The OAuth path, a route handler in app/auth/callback/route.ts, after the code exchange:
const existingUser = await db.select({ id: usersTable.id }).from(usersTable)
.where(eq(usersTable.email, user!.email!))
if (existingUser.length === 0) {
const stripeID = await createStripeCustomer(...)
const name = user!.user_metadata.full_name ?? user!.email!
await db.insert(usersTable).values({ ... })
const [venue] = await db.insert(venuesTable).values({ ... }).returning({ id: venuesTable.id })
await db.insert(venueMembersTable).values({ ... })
}
Same three inserts, same order, same literals, two files. The duplication is the setup. The payload is what one of the copies knows that the other does not.
The branch that only exists in one file
The password path has this, and the OAuth path has no equivalent:
if (data.inviteToken) {
// Invited user, only create the user row. They will join the inviting
// venue when they accept the invite after confirming their email.
// No venue or venue_members row is created here.
await db.insert(usersTable).values({ ... })
} else {
// Regular signup, insert user + venue sequentially, then record the owner
That branch is load bearing, because of a rule enforced at the other end of the flow. acceptInvite refuses anybody who already owns a venue:
// Make sure this user doesn't already own or belong to a different venue
const [existingVenueOwner] = await db.select({ id: venuesTable.id })
.from(venuesTable)
.where(eq(venuesTable.owner_user_id, user.id))
if (existingVenueOwner) {
return {
success: false,
error: 'You already own a venue. A host account cannot also be a venue owner.',
}
}
A host must not be an owner. So an invited person must finish signup without a venue row, which is exactly what the invite branch arranges, and exactly what the OAuth callback does not.
Now look at the page
Click the link in an invite email while signed out and /auth/invite sends you here:
if (!user) {
// Not logged in, send them to signup, preserving the token so they're
// redirected back here after confirming their email.
const url = new URL(`${PUBLIC_URL}/signup`)
url.searchParams.set('invite', token)
return NextResponse.redirect(url.toString())
}
/signup?invite=<token> renders with a heading that knows why you are there:
{hasInvite ? 'Accept your invite' : 'Create your account'}
and then, in this order: the Google button, a divider, and the email form.
<ProviderSigninBlock />
...
<SignupForm inviteToken={invite} />
The form carries the token into the action in a hidden field, with a comment that says so:
{/* Forward invite token so the server action can skip venue creation */}
{inviteToken && (
<input type="hidden" name="invite_token" value={inviteToken} />
)}
The Google button carries nothing:
await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${PUBLIC_URL}/auth/callback` },
})
So on the page titled "Accept your invite", the most prominent control runs the copy of the bootstrap that has never heard of invites. It creates the user, creates a venue called "Their Name's Venue", and makes them its owner. They land on the dashboard looking like a successful signup.
Then they go back to the email and click the link again, which is the natural thing to do, and this time they are authenticated, so acceptInvite runs and tells them:
You already own a venue. A host account cannot also be a venue owner.
Which is true, and is now permanent. The token is still pending, the row is still waiting, and the one account that could have accepted it has disqualified itself. The sentence explains a rule they did not know they were breaking, on a screen they reached by pressing the biggest button on a page that said it was for accepting invites.
The other half: three inserts, no transaction
Both copies write the three rows sequentially with no db.transaction. If the venue insert fails after the user insert lands, you get a user row with no venue, which the dashboard reports as "No venue found for user" on the tables screen, and the password path returns a flat message:
} catch (err) {
console.error("Error in signup:", err instanceof Error ? err.message : err)
return { message: "Failed to setup user account", success: false }
}
The Supabase auth user was created before that try, so the account exists. Signing up again hits "An account with this email already exists", and there is no path in the product that completes the half-built account. It is not a theoretical gap: the same repository reaches for db.transaction in four places elsewhere, so the tool was to hand and the signup path is the one that did not use it.
The fix is one function, not two patches
Both problems are the same problem. There should be one bootstrapAccount({ userId, email, name, stripeId, inviteToken }) that both paths call, which takes the invite into account and wraps all three inserts in a transaction. Then the invite branch cannot exist in one entry point and not the other, because there is only one entry point.
Until then the small version is still worth shipping: ProviderSigninBlock takes the token and passes redirectTo: .../auth/callback?next=/auth/invite?token=..., properly encoded, and the callback skips venue creation when its next is an invite acceptance. That makes the Google button correct on the page it is currently wrong on.
If you want to see the invite side from the owner's end, the plan that includes extra hosts is where the seats come from, and the feature page describes what a second host can do once they are in. The free trial is a single account, which is the honest reason this bug survived: the people most likely to hit it are the ones who have already paid.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.