Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
03aa318
Initial plan
Copilot Mar 7, 2026
7dbdadf
feat: migrate Signin.tsx to Better Auth native auth + passkey endpoints
Copilot Mar 7, 2026
49e5b28
Initial plan
Copilot Mar 7, 2026
3c1b820
Initial plan
Copilot Mar 7, 2026
919d0ae
refactor: use Better Auth get-session as auth source of truth in Auth…
Copilot Mar 7, 2026
cdddc80
Migrate Signup.tsx to Better Auth signup + passkey registration flow
Copilot Mar 7, 2026
420c30c
Remove package-lock.json artifact from PR
Copilot Mar 7, 2026
aced66f
feat: session-first fetchUser, add Signin.test.tsx, fix input ids for…
Copilot Mar 7, 2026
bf3de29
Revert .gitignore changes
Copilot Mar 8, 2026
48affbb
Merge PR #36: Better Auth signin + passkey migration
benhalverson Mar 8, 2026
2dac67a
Merge PR #38: AuthContext session source-of-truth refactor
benhalverson Mar 8, 2026
79ad0be
Merge PR #37: Better Auth signup + passkey registration flow
benhalverson Mar 8, 2026
590e312
fix(auth): migrate profile passkey calls to /api/auth/passkey
benhalverson Mar 8, 2026
1883649
fix(passkey): include credential aliases for verify-registration payload
benhalverson Mar 8, 2026
88719d4
fix(passkey): send full credential object in verify-registration resp…
benhalverson Mar 8, 2026
7f6dc4e
refactor(passkey): send minimal verify-registration payload
benhalverson Mar 8, 2026
7c4a7c0
fix(auth): address PR review follow-ups
benhalverson Mar 8, 2026
0187f0d
style: resolve biome findings
benhalverson Mar 8, 2026
e325449
style: apply biome formatting to auth changes
benhalverson Mar 8, 2026
3394932
test: stabilize ColorPicker loading assertion
benhalverson Mar 8, 2026
aea9e40
test: fix product test OOM and serialize test runs
benhalverson Mar 8, 2026
7344362
fixed passkey issues
benhalverson Mar 8, 2026
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
15 changes: 12 additions & 3 deletions src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,20 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {

const fetchUser = async () => {
try {
const res = await fetch(`${BASE_URL}/profile`, {
const sessionRes = await fetch(`${BASE_URL}/api/auth/get-session`, {
credentials: "include",
});
if (res.ok) {
const data: any = await res.json();
const sessionData = sessionRes.ok ? await sessionRes.json() : null;

if (!sessionData?.session) {
setUser(null);
return;
}
Comment thread
benhalverson marked this conversation as resolved.
Outdated
const profileRes = await fetch(`${BASE_URL}/profile`, {
credentials: "include",
});
if (profileRes.ok) {
const data: any = await profileRes.json();
setUser(data);
} else {
setUser(null);
Expand Down
42 changes: 33 additions & 9 deletions src/pages/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,40 @@ function isObject(val: unknown): val is Record<string, unknown> {
return typeof val === "object" && val !== null;
}

function parsePaymentIntentResponse(obj: unknown): PaymentIntentResponse | null {
function parsePaymentIntentResponse(
obj: unknown,
): PaymentIntentResponse | null {
if (!isObject(obj)) return null;
const record = obj as Record<string, unknown>;
const checkout_url = typeof record.checkout_url === "string" ? record.checkout_url : undefined;
const clientSecret = typeof record.clientSecret === "string" ? record.clientSecret : undefined;
const client_secret = typeof record.client_secret === "string" ? record.client_secret : undefined;
const checkout_url =
typeof record.checkout_url === "string" ? record.checkout_url : undefined;
const clientSecret =
typeof record.clientSecret === "string" ? record.clientSecret : undefined;
const client_secret =
typeof record.client_secret === "string" ? record.client_secret : undefined;
const amount = typeof record.amount === "number" ? record.amount : undefined;
const currency = typeof record.currency === "string" ? record.currency : undefined;
const currency =
typeof record.currency === "string" ? record.currency : undefined;
const orderId =
typeof record.orderId === "string" || typeof record.orderId === "number"
? (record.orderId as string | number)
: undefined;
if (checkout_url || clientSecret || client_secret || amount || currency || orderId)
return { checkout_url, clientSecret, client_secret, amount, currency, orderId };
if (
checkout_url ||
clientSecret ||
client_secret ||
amount ||
currency ||
orderId
)
return {
checkout_url,
clientSecret,
client_secret,
amount,
currency,
orderId,
};
return null;
}

Expand Down Expand Up @@ -216,7 +236,9 @@ export default function Checkout() {
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Payment intent request failed (${res.status}): ${text}`);
throw new Error(
`Payment intent request failed (${res.status}): ${text}`,
);
}
const dataJson: unknown = await res.json();
const data = parsePaymentIntentResponse(dataJson);
Expand All @@ -240,7 +262,9 @@ export default function Checkout() {
navigate(`/order/${data.orderId}`);
}
} catch (err: unknown) {
setCartError(err instanceof Error ? err.message : "Payment intent failed");
setCartError(
err instanceof Error ? err.message : "Payment intent failed",
);
} finally {
setCartLoading(false);
}
Expand Down
15 changes: 10 additions & 5 deletions src/pages/OrderComplete.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
import { Link } from "react-router-dom";
import { useEffect } from "react";
import { Link } from "react-router-dom";

export default function OrderComplete() {
useEffect(() => {
try {
localStorage.removeItem("cartId");
} catch (e) {
// ignore localStorage errors in some environments
console.log('Could not clear cartId from localStorage', e);
console.log("Could not clear cartId from localStorage", e);
}
}, []);

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12">
<div className="max-w-xl w-full bg-white shadow rounded-lg p-8 text-center">
<h1 className="text-2xl font-semibold mb-4">Thank you — your order is complete</h1>
<h1 className="text-2xl font-semibold mb-4">
Thank you — your order is complete
</h1>
<p className="text-gray-600 mb-6">
We received your payment. You will receive an email confirmation shortly.
We received your payment. You will receive an email confirmation
shortly.
</p>
<div className="flex justify-center gap-4">
<Link to="/" className="rounded-md bg-indigo-600 text-white px-4 py-2">
<Link
to="/"
className="rounded-md bg-indigo-600 text-white px-4 py-2">
Back to shop
</Link>
<Link to="/profile" className="rounded-md border px-4 py-2">
Expand Down
12 changes: 5 additions & 7 deletions src/pages/Payment.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { loadStripe, type PaymentIntentResult } from "@stripe/stripe-js";
import {
Elements,
PaymentElement,
useStripe,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe, type PaymentIntentResult } from "@stripe/stripe-js";
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";

const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY as string;
const stripePromise = loadStripe(publishableKey);
Expand Down Expand Up @@ -79,9 +79,7 @@ export default function PaymentPage() {
}

if (!clientSecret) {
return (
<div className="p-8 text-center">No payment session available.</div>
);
return <div className="p-8 text-center">No payment session available.</div>;
}

const options = useMemo(() => ({ clientSecret }), [clientSecret]);
Expand Down
201 changes: 201 additions & 0 deletions src/pages/Signin.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { vi } from "vitest";
import SigninPage from "./Signin";

vi.mock("../config", () => ({
BASE_URL: "http://test.local",
DOMAIN: "test.local",
}));

const mockFetchUser = vi.fn();
vi.mock("../context/AuthContext", () => ({
useAuth: () => ({ fetchUser: mockFetchUser }),
}));

vi.mock("react-hot-toast", () => {
const toast = Object.assign(vi.fn(), {
loading: vi.fn(() => "toast-id"),
success: vi.fn(),
error: vi.fn(),
});
return { default: toast };
});

const mockNavigate = vi.fn();
vi.mock("react-router-dom", async (importOriginal) => {
const actual =
await importOriginal<typeof import("react-router-dom")>();
return { ...actual, useNavigate: () => mockNavigate };
});

function renderSignin() {
const router = createMemoryRouter([{ path: "/", element: <SigninPage /> }], {
initialEntries: ["/"],
});
render(<RouterProvider router={router} />);
}

describe("Signin – password tab", () => {
afterEach(() => {
vi.restoreAllMocks();
mockFetchUser.mockReset();
mockNavigate.mockReset();
});

it("renders email and password inputs", () => {
renderSignin();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});

it("calls /auth/signin, fetches user, and navigates on success", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
json: async () => ({}),
} as Response);
mockFetchUser.mockResolvedValueOnce(undefined);

renderSignin();
const user = userEvent.setup();

await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in with password/i }));

Comment on lines +62 to +67

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

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

This line exceeds the configured Biome formatter line width (80) and will likely be reformatted/flagged by biome check. Running Biome formatting on this file (or wrapping this call across multiple lines) should keep CI/style checks consistent.

Copilot uses AI. Check for mistakes.
await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1));
expect(mockNavigate).toHaveBeenCalledWith("/profile");
});

it("shows backend error message on failed sign-in", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: false,
json: async () => ({ message: "Invalid credentials" }),
} as Response);

const toast = await import("react-hot-toast");

renderSignin();
const user = userEvent.setup();

await user.type(screen.getByLabelText(/email/i), "bad@example.com");
await user.type(screen.getByLabelText(/password/i), "wrongpassword");
await user.click(screen.getByRole("button", { name: /sign in with password/i }));

Comment on lines +83 to +88

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

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

This click assertion line is over the Biome formatter line width and is likely to be reformatted/flagged by biome check. Consider wrapping the getByRole call onto multiple lines (or run pnpm biome format).

Copilot uses AI. Check for mistakes.
await waitFor(() =>
expect(toast.default.error).toHaveBeenCalledWith(
expect.stringContaining("Invalid credentials"),
expect.anything(),
),
);
expect(mockNavigate).not.toHaveBeenCalled();
});
});

describe("Signin – passkey tab", () => {
beforeEach(() => {
Object.defineProperty(globalThis.navigator, "credentials", {
value: { get: vi.fn() },
configurable: true,
writable: true,
});
});

afterEach(() => {
vi.restoreAllMocks();
mockFetchUser.mockReset();
mockNavigate.mockReset();
});

async function switchToPasskeyTab() {
renderSignin();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: /passkey/i }));
return user;
}

it("renders the passkey button after switching tabs", async () => {
await switchToPasskeyTab();
expect(
screen.getByRole("button", { name: /passkey login/i }),
).toBeInTheDocument();
});

it("shows error when generate-authenticate-options request fails", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: false,
json: async () => ({ message: "No passkey registered" }),
} as Response);

const toast = await import("react-hot-toast");
const user = await switchToPasskeyTab();

await user.click(screen.getByRole("button", { name: /passkey login/i }));

await waitFor(() =>
expect(toast.default.error).toHaveBeenCalledWith(
expect.stringContaining("No passkey registered"),
expect.anything(),
),
);
});

it("sends correct payload to verify-authentication and navigates on success", async () => {
const fakeOptions = {
challenge: "dGVzdC1jaGFsbGVuZ2U",
allowCredentials: [{ id: "Y3JlZC1pZA", type: "public-key" }],
timeout: 60000,
};

const fakeCredential = {
id: "cred-id-base64url",
rawId: new Uint8Array([1, 2, 3]).buffer,
type: "public-key",
response: {
authenticatorData: new Uint8Array([4, 5, 6]).buffer,
clientDataJSON: new Uint8Array([7, 8, 9]).buffer,
signature: new Uint8Array([10, 11, 12]).buffer,
userHandle: null,
},
getClientExtensionResults: () => ({}),
} as unknown as PublicKeyCredential;

const fetchSpy = vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({
ok: true,
json: async () => fakeOptions,
} as Response)
.mockResolvedValueOnce({
ok: true,
json: async () => ({ verified: true }),
} as Response);

(navigator.credentials.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
fakeCredential,
);
mockFetchUser.mockResolvedValueOnce(undefined);

const user = await switchToPasskeyTab();
await user.click(screen.getByRole("button", { name: /passkey login/i }));

await waitFor(() => expect(mockFetchUser).toHaveBeenCalledTimes(1));

// Verify the verify-authentication request body structure
const verifyCall = fetchSpy.mock.calls[1];
expect(verifyCall[0]).toContain("/api/auth/passkey/verify-authentication");
const body = JSON.parse(verifyCall[1]?.body as string);
expect(body).toMatchObject({
id: "cred-id-base64url",
type: "public-key",
response: {
authenticatorData: expect.any(String),
clientDataJSON: expect.any(String),
signature: expect.any(String),
},
clientExtensionResults: {},
});

expect(mockNavigate).toHaveBeenCalledWith("/profile");
});
});
Loading