Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
95 changes: 82 additions & 13 deletions apps/shell/src/auth/AuthWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> = { 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) {
Expand All @@ -425,7 +449,7 @@ export function AuthWizard({ mode, onClose, embedded = false }: AuthWizardProps)
cancelled = true;
clearTimeout(timer);
};
}, [handle, hosting]);
}, [handle, hosting, email]);

useEffect(() => {
if (!usesSupabaseHostedAuth()) {
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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.",
);
Comment on lines +1499 to +1508
return false;
}
} else if (!adminUrl.trim() || !adminToken.trim()) {
Expand Down Expand Up @@ -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<string, string> = { 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({
Expand Down
20 changes: 18 additions & 2 deletions apps/shell/src/auth/hostedAccount.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string, string> = { 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`). */
Expand Down
41 changes: 41 additions & 0 deletions apps/shell/src/auth/signupHandle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Comment on lines +8 to +17

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=");
});
});
42 changes: 42 additions & 0 deletions apps/shell/src/auth/signupHandle.ts
Original file line number Diff line number Diff line change
@@ -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()}`;
}
Loading