What changed, and why

A returning player — someone who signed up before, so their email already belongs to an account — couldn't sign in. The form took their email, then dead-ended on "sign-in restore is coming soon."

The cause is that sign-in had only one move. Every visitor is already an anonymous Supabase user, so SignInForm attached the email to that user with updateUser. For a new player that's exactly right: the account upgrades in place, same id, runs carried over. For an already-registered email there's nothing to upgrade — Supabase rejects it with email_exists — and the form had no second move (a known P2).

This adds the second move. A small utils/sign-in.ts decides which path applies, and SignInForm calls it. Returning players now get a magic link into the account they already have. The return side is unchanged — the link still lands on /play, where the handler from the previous PR finalizes it.

The decision — utils/sign-in.ts

The form can't tell a new email from a returning one before it tries, so the logic tries the in-place upgrade first and reads the failure. The contract names just the two client methods it needs and the slice of the Supabase error it branches on, so the util stays free of Nuxt/Supabase runtime imports and unit-tests in plain node.

Two methods, and the error fields (.code) the decision reads.

utils/sign-in.ts · 79 lines
utils/sign-in.ts79 lines · TypeScript
⋯ 19 lines hidden (lines 1–19)
1/**
2 * Requesting a sign-in link for the run-store account.
3 *
4 * A visitor is already an anonymous Supabase user, which splits sign-in into two
5 * cases the form can't tell apart up front:
6 * - NEW player: attach the email to their anonymous user (`updateUser`) so the
7 * account upgrades in place — same id, runs carried over.
8 * - RETURNING player: the email already belongs to an account, so that in-place
9 * upgrade can't apply — Supabase rejects it with `email_exists`. We fall back to
10 * a magic-link sign-in into that existing account (`signInWithOtp`,
11 * `shouldCreateUser: false`, so we never mint a duplicate).
12 *
13 * Either path emails a link back to /play, where `utils/auth-return.ts` finalizes
14 * it. Signing into an existing account adopts that account's balance; anonymous
15 * runs accrued on this device are left behind (sign-in switches accounts — merging
16 * the two balances would be a separate change).
17 */
18 
19/** The slice of a Supabase `AuthError` we branch on. */
20export interface AuthErrorLike {
21 code?: string
22 status?: number
23 message?: string
25 
26/** The slice of the Supabase auth client this needs — kept local so the util
27 * carries no Nuxt/Supabase runtime imports and unit-tests in the node env. */
28export interface SignInClient {
29 updateUser(
30 attributes: { email: string },
31 options: { emailRedirectTo?: string }
32 ): Promise<{ error: AuthErrorLike | null }>
33 signInWithOtp(
34 credentials: { email: string; options: { emailRedirectTo?: string; shouldCreateUser: boolean } }
35 ): Promise<{ error: AuthErrorLike | null }>
⋯ 43 lines hidden (lines 37–79)
37 
38export type SignInResult =
39 | { status: 'sent' }
40 | { status: 'error'; message: string }
41 
42/** Error codes that all mean "this email already belongs to an account". The set
43 * spans the variants Supabase has used so a version bump can't silently strand a
44 * returning player back on the failure path. */
45const EMAIL_TAKEN_CODES = new Set(['email_exists', 'identity_already_exists', 'user_already_exists'])
46 
47export const isEmailTakenError = (e: AuthErrorLike | null | undefined): boolean =>
48 Boolean(e && EMAIL_TAKEN_CODES.has(e.code ?? ''))
49 
50const RATE_LIMITED = 'Too many requests just now — give it a minute, then try again.'
51const SEND_FAILED = 'Could not send the sign-in link. Please try again.'
52 
53const messageFor = (e: AuthErrorLike): string =>
54 e.code === 'over_email_send_rate_limit' ? RATE_LIMITED : SEND_FAILED
55 
56/**
57 * Sends a sign-in link: upgrades the anonymous user in place when the email is new,
58 * and signs into the existing account when the email is already taken. Returns
59 * whether the link went out, or a player-facing error message.
60 */
61export async function requestSignInLink(
62 email: string,
63 emailRedirectTo: string | undefined,
64 client: SignInClient
65): Promise<SignInResult> {
66 const { error: upgradeError } = await client.updateUser({ email }, { emailRedirectTo })
67 if (!upgradeError) return { status: 'sent' }
68 
69 // Returning player: send a magic link to the account that already owns this email.
70 if (isEmailTakenError(upgradeError)) {
71 const { error: signInError } = await client.signInWithOtp({
72 email,
73 options: { emailRedirectTo, shouldCreateUser: false }
74 })
75 return signInError ? { status: 'error', message: messageFor(signInError) } : { status: 'sent' }
76 }
77 
78 return { status: 'error', message: messageFor(upgradeError) }

"Already registered" has carried more than one error code across Supabase versions, so the check matches a small set rather than a single string — a version bump can't silently strand a returning player back on the failure path.

updateUser first; on a conflict code, sign into the existing account.

utils/sign-in.ts · 79 lines
utils/sign-in.ts79 lines · TypeScript
⋯ 43 lines hidden (lines 1–43)
1/**
2 * Requesting a sign-in link for the run-store account.
3 *
4 * A visitor is already an anonymous Supabase user, which splits sign-in into two
5 * cases the form can't tell apart up front:
6 * - NEW player: attach the email to their anonymous user (`updateUser`) so the
7 * account upgrades in place — same id, runs carried over.
8 * - RETURNING player: the email already belongs to an account, so that in-place
9 * upgrade can't apply — Supabase rejects it with `email_exists`. We fall back to
10 * a magic-link sign-in into that existing account (`signInWithOtp`,
11 * `shouldCreateUser: false`, so we never mint a duplicate).
12 *
13 * Either path emails a link back to /play, where `utils/auth-return.ts` finalizes
14 * it. Signing into an existing account adopts that account's balance; anonymous
15 * runs accrued on this device are left behind (sign-in switches accounts — merging
16 * the two balances would be a separate change).
17 */
18 
19/** The slice of a Supabase `AuthError` we branch on. */
20export interface AuthErrorLike {
21 code?: string
22 status?: number
23 message?: string
25 
26/** The slice of the Supabase auth client this needs — kept local so the util
27 * carries no Nuxt/Supabase runtime imports and unit-tests in the node env. */
28export interface SignInClient {
29 updateUser(
30 attributes: { email: string },
31 options: { emailRedirectTo?: string }
32 ): Promise<{ error: AuthErrorLike | null }>
33 signInWithOtp(
34 credentials: { email: string; options: { emailRedirectTo?: string; shouldCreateUser: boolean } }
35 ): Promise<{ error: AuthErrorLike | null }>
37 
38export type SignInResult =
39 | { status: 'sent' }
40 | { status: 'error'; message: string }
41 
42/** Error codes that all mean "this email already belongs to an account". The set
43 * spans the variants Supabase has used so a version bump can't silently strand a
44 * returning player back on the failure path. */
45const EMAIL_TAKEN_CODES = new Set(['email_exists', 'identity_already_exists', 'user_already_exists'])
46 
47export const isEmailTakenError = (e: AuthErrorLike | null | undefined): boolean =>
48 Boolean(e && EMAIL_TAKEN_CODES.has(e.code ?? ''))
49 
50const RATE_LIMITED = 'Too many requests just now — give it a minute, then try again.'
51const SEND_FAILED = 'Could not send the sign-in link. Please try again.'
52 
53const messageFor = (e: AuthErrorLike): string =>
54 e.code === 'over_email_send_rate_limit' ? RATE_LIMITED : SEND_FAILED
55 
56/**
57 * Sends a sign-in link: upgrades the anonymous user in place when the email is new,
58 * and signs into the existing account when the email is already taken. Returns
59 * whether the link went out, or a player-facing error message.
60 */
61export async function requestSignInLink(
62 email: string,
63 emailRedirectTo: string | undefined,
64 client: SignInClient
65): Promise<SignInResult> {
66 const { error: upgradeError } = await client.updateUser({ email }, { emailRedirectTo })
67 if (!upgradeError) return { status: 'sent' }
68 
69 // Returning player: send a magic link to the account that already owns this email.
70 if (isEmailTakenError(upgradeError)) {
71 const { error: signInError } = await client.signInWithOtp({
72 email,
73 options: { emailRedirectTo, shouldCreateUser: false }
74 })
75 return signInError ? { status: 'error', message: messageFor(signInError) } : { status: 'sent' }
76 }
77 
78 return { status: 'error', message: messageFor(upgradeError) }

One form, both players — SignInForm.vue

The component keeps a single email field and a single "Send link" button; nothing in the template changes. send hands the work to requestSignInLink, passing thin adapters over the live Supabase client, and renders the result: the "check your email" confirmation on success, or the returned message on failure.

The orchestration moved to the util; the component just wires + renders.

components/SignInForm.vue · 60 lines
components/SignInForm.vue60 lines · Vue
⋯ 41 lines hidden (lines 1–41)
1<template>
2 <form data-testid="signin-form" @submit.prevent="send">
3 <p v-if="sent" data-testid="signin-sent" class="rv-muted text-sm leading-relaxed">
4 <span class="rv-ok" aria-hidden="true"></span> Check your email — a sign-in link is on its way to
5 <span class="rv-fg">{{ email }}</span>.
6 </p>
7 <template v-else>
8 <p v-if="hint" class="rv-faint text-xs leading-relaxed mb-2">{{ hint }}</p>
9 <div class="flex gap-2">
10 <input v-model="email" type="email" required autocomplete="email" placeholder="you@example.com"
11 data-testid="signin-email" class="rv-input flex-1 min-w-0" :disabled="sending" />
12 <button type="submit" data-testid="signin-send" class="rv-btn shrink-0" :disabled="sending || !email">
13 {{ sending ? 'Sending…' : 'Send link' }}
14 </button>
15 </div>
16 <p v-if="error" data-testid="signin-error" class="text-xs rv-bad mt-1.5">{{ error }}</p>
17 </template>
18 </form>
19</template>
20 
21<script setup lang="ts">
22/**
23 * Magic-link sign-in, for new and returning players alike. The visitor is already
24 * an anonymous Supabase user: a NEW player's email attaches to that user so the
25 * account upgrades in place (same id, runs carried over); a RETURNING player's
26 * email already belongs to an account, so we instead sign them into it. The form
27 * doesn't ask which — requestSignInLink picks the right path (see utils/sign-in.ts).
28 * Either way a link is emailed; clicking it lands on /play, which finalizes the
29 * sign-in (see utils/auth-return.ts).
30 */
31import { ref } from 'vue'
32import { requestSignInLink } from '~/utils/sign-in'
33 
34defineProps<{ hint?: string }>()
35 
36const supabase = useSupabaseClient()
37const email = ref('')
38const sending = ref(false)
39const sent = ref(false)
40const error = ref<string | null>(null)
41 
42async function send() {
43 if (!email.value || sending.value) return
44 sending.value = true
45 error.value = null
46 try {
47 const emailRedirectTo = typeof window !== 'undefined' ? `${window.location.origin}/play` : undefined
48 const result = await requestSignInLink(email.value, emailRedirectTo, {
49 updateUser: (attributes, options) => supabase.auth.updateUser(attributes, options),
50 signInWithOtp: (credentials) => supabase.auth.signInWithOtp(credentials)
51 })
52 if (result.status === 'sent') sent.value = true
53 else error.value = result.message
54 } catch {
55 error.value = 'Could not send the sign-in link. Please try again.'
56 } finally {
57 sending.value = false
58 }
⋯ 1 line hidden (lines 60–60)
60</script>

Verification

The decision is pure over its two injected methods, so the test drives every branch directly: a new player (upgrade only, OTP untouched), a returning player (each conflict code falls back to the sign-in), the rate-limit message, and a non-conflict error that must NOT trigger a fallback.

New vs returning, plus the no-fallback-on-other-errors guard.

tests/unit/utils/sign-in.spec.ts · 75 lines
tests/unit/utils/sign-in.spec.ts75 lines · TypeScript
⋯ 17 lines hidden (lines 1–17)
1import { describe, it, expect, vi } from 'vitest'
2import { requestSignInLink, isEmailTakenError } from '../../../utils/sign-in'
3 
4describe('isEmailTakenError', () => {
5 it('is true for the email-conflict codes', () => {
6 expect(isEmailTakenError({ code: 'email_exists' })).toBe(true)
7 expect(isEmailTakenError({ code: 'identity_already_exists' })).toBe(true)
8 expect(isEmailTakenError({ code: 'user_already_exists' })).toBe(true)
9 })
10 
11 it('is false for unrelated / missing codes', () => {
12 expect(isEmailTakenError({ code: 'over_email_send_rate_limit' })).toBe(false)
13 expect(isEmailTakenError({ message: 'boom' })).toBe(false)
14 expect(isEmailTakenError(null)).toBe(false)
15 })
16})
17 
18describe('requestSignInLink (new vs returning player)', () => {
19 const REDIRECT = 'https://app.test/play'
20 
21 const client = (over: Record<string, unknown> = {}) => ({
22 updateUser: vi.fn().mockResolvedValue({ error: null }),
23 signInWithOtp: vi.fn().mockResolvedValue({ error: null }),
24 ...over
25 })
26 
27 it('new player: attaches the email in place, never touches OTP', async () => {
28 const c = client()
29 const r = await requestSignInLink('new@x.co', REDIRECT, c)
30 expect(r).toEqual({ status: 'sent' })
31 expect(c.updateUser).toHaveBeenCalledWith({ email: 'new@x.co' }, { emailRedirectTo: REDIRECT })
32 expect(c.signInWithOtp).not.toHaveBeenCalled()
33 })
34 
35 it('returning player: on email_exists, signs into the existing account', async () => {
36 const c = client({ updateUser: vi.fn().mockResolvedValue({ error: { code: 'email_exists' } }) })
37 const r = await requestSignInLink('back@x.co', REDIRECT, c)
38 expect(r).toEqual({ status: 'sent' })
39 expect(c.signInWithOtp).toHaveBeenCalledWith({
40 email: 'back@x.co',
41 options: { emailRedirectTo: REDIRECT, shouldCreateUser: false }
42 })
43 })
44 
45 it('returning player: falls back on the other conflict codes too', async () => {
46 const c = client({ updateUser: vi.fn().mockResolvedValue({ error: { code: 'identity_already_exists' } }) })
47 const r = await requestSignInLink('back@x.co', REDIRECT, c)
48 expect(r).toEqual({ status: 'sent' })
⋯ 27 lines hidden (lines 49–75)
49 expect(c.signInWithOtp).toHaveBeenCalledOnce()
50 })
51 
52 it('surfaces a friendly message when the sign-in email is rate-limited', async () => {
53 const c = client({
54 updateUser: vi.fn().mockResolvedValue({ error: { code: 'email_exists' } }),
55 signInWithOtp: vi.fn().mockResolvedValue({ error: { code: 'over_email_send_rate_limit' } })
56 })
57 const r = await requestSignInLink('back@x.co', REDIRECT, c)
58 expect(r.status).toBe('error')
59 if (r.status === 'error') expect(r.message).toMatch(/minute/i)
60 })
61 
62 it('a non-conflict updateUser error is surfaced, without an OTP fallback', async () => {
63 const c = client({ updateUser: vi.fn().mockResolvedValue({ error: { code: 'validation_failed' } }) })
64 const r = await requestSignInLink('bad', REDIRECT, c)
65 expect(r.status).toBe('error')
66 expect(c.signInWithOtp).not.toHaveBeenCalled()
67 })
68 
69 it('rate-limited on the initial upgrade also reads as the friendly message', async () => {
70 const c = client({ updateUser: vi.fn().mockResolvedValue({ error: { code: 'over_email_send_rate_limit' } }) })
71 const r = await requestSignInLink('new@x.co', REDIRECT, c)
72 expect(r.status).toBe('error')
73 if (r.status === 'error') expect(r.message).toMatch(/minute/i)
74 })
75})