diff --git a/apps/shell/src/auth/AuthWizard.tsx b/apps/shell/src/auth/AuthWizard.tsx index 93d8cbe..c410b2f 100644 --- a/apps/shell/src/auth/AuthWizard.tsx +++ b/apps/shell/src/auth/AuthWizard.tsx @@ -36,6 +36,7 @@ import { shouldShowRegisterChooser, type ChooserAction, } from "./accountChooser.js"; +import { buildHandleCheckUrl, friendlyHandleWriteError } from "./signupHandle.js"; import { saveAccountType } from "../accountType.js"; import { AccountTypeSelection } from "./accountTypeSelection.js"; import { @@ -400,19 +401,42 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) setHandleStatus(validationError); return; } + const emailForCheck = email.trim().toLowerCase(); + if (!emailForCheck.includes("@")) { + setHandleStatus("Enter your email before choosing a username."); + return; + } + // Clear stale “available” immediately when handle or email changes (diff F-1). + setHandleStatus("Checking username…"); let cancelled = false; const timer = setTimeout(() => { void (async () => { try { - const resp = await fetch( - `${CONTROL_PLANE_URL.replace(/\/$/, "")}/handles/check?handle=${encodeURIComponent(normalizeOwnerHandle(handle))}`, - ); - const data = (await resp.json()) as { available?: boolean; handle?: string }; + const url = buildHandleCheckUrl({ + controlPlaneBase: CONTROL_PLANE_URL, + handle: normalizeOwnerHandle(handle), + email: emailForCheck, + }); + const headers: Record = { Accept: "application/json" }; + try { + const { data } = await getSupabaseClient().auth.getSession(); + const token = data.session?.access_token; + if (token) headers.Authorization = `Bearer ${token}`; + } catch { + /* pre-session Profile step */ + } + const resp = await fetch(url, { headers }); + const data = (await resp.json()) as { + available?: boolean; + handle?: string; + error?: string; + code?: string; + }; if (cancelled) return; if (data.available) { setHandleStatus(`${data.handle ?? normalizeOwnerHandle(handle)} is available`); } else { - setHandleStatus("That username is already taken"); + setHandleStatus(data.error ?? "That username is already taken"); } } catch { if (!cancelled) { @@ -425,7 +449,7 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) cancelled = true; clearTimeout(timer); }; - }, [handle, hosting]); + }, [handle, hosting, email]); useEffect(() => { if (!usesSupabaseHostedAuth()) { @@ -1219,8 +1243,11 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) accountType: selection.primaryAccountType(), handle, }); - } catch { - /* handle uniqueness may wait until bootstrap */ + } catch (err) { + setError( + friendlyHandleWriteError(err instanceof Error ? err.message : String(err)), + ); + return; } if (pendingLane === "standard" || pendingLane === "byok") { goTo("pay"); @@ -1242,8 +1269,11 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) accountType: selection.primaryAccountType(), handle, }); - } catch { - /* optional until session stable */ + } catch (err) { + setError( + friendlyHandleWriteError(err instanceof Error ? err.message : String(err)), + ); + return; } if (pendingLane === "standard" || pendingLane === "byok") { goTo("pay"); @@ -1466,8 +1496,16 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) return false; } } - if (handleStatus?.includes("taken")) { - setError("Choose a different handle."); + if (!handleStatus?.includes("is available")) { + setError( + !handleStatus || handleStatus === "Checking username…" + ? "Wait for the username check to finish." + : handleStatus.includes("Enter your email") + ? handleStatus + : handleStatus.includes("Couldn’t check") + ? handleStatus + : "Choose a different handle.", + ); return false; } } else if (!adminUrl.trim() || !adminToken.trim()) { @@ -1541,13 +1579,44 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps) const planReadiness = locked ? businessHostingDefaults().readinessSkuId : clampReadinessForAccount(selection.primaryAccountType(), readinessSkuId); + + // SIGNUP-HANDLE-01: re-check handle vs register email before writing profile / Checkout. + if (handle.trim() && email.trim().includes("@")) { + try { + const url = buildHandleCheckUrl({ + controlPlaneBase: CONTROL_PLANE_URL, + handle: normalizeOwnerHandle(handle), + email: email.trim(), + }); + const headers: Record = { Accept: "application/json" }; + try { + const { data } = await getSupabaseClient().auth.getSession(); + const token = data.session?.access_token; + if (token) headers.Authorization = `Bearer ${token}`; + } catch { + /* ignore */ + } + const resp = await fetch(url, { headers }); + const data = (await resp.json()) as { available?: boolean; error?: string }; + if (!data.available) { + setError(data.error ?? "That username is already taken"); + return; + } + } catch { + setError("Couldn’t check that username right now — try again in a moment."); + return; + } + } + try { await persistSignupProfileIntent({ accountType: selection.primaryAccountType(), handle, }); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError( + friendlyHandleWriteError(err instanceof Error ? err.message : String(err)), + ); return; } savePendingHostedAuth({ diff --git a/apps/shell/src/auth/hostedAccount.ts b/apps/shell/src/auth/hostedAccount.ts index e29ad50..e2e4679 100644 --- a/apps/shell/src/auth/hostedAccount.ts +++ b/apps/shell/src/auth/hostedAccount.ts @@ -1,6 +1,7 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { SUPABASE_ANON_KEY, SUPABASE_URL, isSupabaseConfigured } from "../hostConfig.js"; import { bareOwnerHandle } from "../ownerHandle.js"; +import { friendlyHandleWriteError, maySetProfileHandle } from "./signupHandle.js"; let client: SupabaseClient | null = null; @@ -389,13 +390,28 @@ export async function persistSignupProfileIntent(input: { const supabase = getSupabaseClient(); const { data: auth, error: authError } = await supabase.auth.getUser(); if (authError || !auth.user?.id) throw new Error("Sign in required"); + const { data: existing, error: readError } = await supabase + .from("profiles") + .select("handle") + .eq("id", auth.user.id) + .maybeSingle(); + if (readError) throw new Error(friendlyHandleWriteError(readError.message)); + const patch: Record = { account_type: input.accountType }; const handle = input.handle?.trim(); if (handle) { - patch.handle = bareOwnerHandle(handle); + const gate = maySetProfileHandle({ + existingHandle: existing?.handle ? String(existing.handle) : null, + requestedHandle: handle, + }); + if (!gate.ok) throw new Error(friendlyHandleWriteError("handle_immutable")); + const existingBare = (existing?.handle ? String(existing.handle) : "").trim(); + if (!existingBare) { + patch.handle = bareOwnerHandle(handle); + } } const { error } = await supabase.from("profiles").update(patch).eq("id", auth.user.id); - if (error) throw new Error(error.message); + if (error) throw new Error(friendlyHandleWriteError(error.message)); } /** True only when CP reports Stripe-backed provisionable (ignore bare `active`). */ diff --git a/apps/shell/src/auth/signupHandle.test.ts b/apps/shell/src/auth/signupHandle.test.ts new file mode 100644 index 0000000..df98a79 --- /dev/null +++ b/apps/shell/src/auth/signupHandle.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + buildHandleCheckUrl, + friendlyHandleWriteError, + maySetProfileHandle, +} from "./signupHandle.js"; + +describe("maySetProfileHandle", () => { + it("blocks overwrite luke → testb", () => { + const r = maySetProfileHandle({ existingHandle: "luke", requestedHandle: "testb" }); + expect(r.ok).toBe(false); + }); + + it("allows first set", () => { + expect(maySetProfileHandle({ existingHandle: null, requestedHandle: "fresh" }).ok).toBe(true); + }); +}); + +describe("friendlyHandleWriteError", () => { + it("maps postgres unique to taken copy", () => { + expect( + friendlyHandleWriteError('duplicate key value violates unique constraint "profiles_handle_key"'), + ).toBe("That username is already taken"); + }); + + it("maps immutable", () => { + expect(friendlyHandleWriteError("handle_immutable")).toBe("Your username can’t be changed."); + }); +}); + +describe("buildHandleCheckUrl", () => { + it("includes email and handle", () => { + const url = buildHandleCheckUrl({ + controlPlaneBase: "https://cp.example", + handle: "@TestB", + email: "Support@Qwixl.com", + }); + expect(url).toContain("email=support%40qwixl.com"); + expect(url).toContain("handle="); + }); +}); diff --git a/apps/shell/src/auth/signupHandle.ts b/apps/shell/src/auth/signupHandle.ts new file mode 100644 index 0000000..0006c43 --- /dev/null +++ b/apps/shell/src/auth/signupHandle.ts @@ -0,0 +1,42 @@ +import { bareOwnerHandle } from "../ownerHandle.js"; + +/** SIGNUP-HANDLE-01 — mirror CP maySetProfileHandle. */ +export function maySetProfileHandle(opts: { + existingHandle: string | null | undefined; + requestedHandle: string; +}): { ok: true } | { ok: false; code: "handle_immutable" } { + const existing = (opts.existingHandle ?? "").trim(); + if (!existing) return { ok: true }; + if (bareOwnerHandle(existing) === bareOwnerHandle(opts.requestedHandle)) { + return { ok: true }; + } + return { ok: false, code: "handle_immutable" }; +} + +export function friendlyHandleWriteError(message: string): string { + const lower = message.toLowerCase(); + if (lower.includes("handle_immutable") || lower.includes("p0001")) { + return "Your username can’t be changed."; + } + if ( + lower.includes("profiles_handle_key") || + lower.includes("duplicate key") || + lower.includes("already taken") + ) { + return "That username is already taken"; + } + return message; +} + +export function buildHandleCheckUrl(opts: { + controlPlaneBase: string; + handle: string; + email: string; +}): string { + const base = opts.controlPlaneBase.replace(/\/$/, ""); + const params = new URLSearchParams({ + handle: opts.handle, + email: opts.email.trim().toLowerCase(), + }); + return `${base}/handles/check?${params.toString()}`; +}