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
24 changes: 24 additions & 0 deletions e2e/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ test.describe("Authentication", () => {
await expect(page.getByRole("button", { name: /sign up/i })).toBeVisible();
});

test("sign-in page shows confirmation message with confirmed=true param", async ({
page,
}) => {
await page.goto("/sign-in?confirmed=true");
await expect(
page.getByText(/email confirmed/i),
).toBeVisible();
});

test("sign-in page does not show confirmation message without param", async ({
page,
}) => {
await page.goto("/sign-in");
await expect(
page.getByText(/email confirmed/i),
).not.toBeVisible();
});

test("auth callback redirects to sign-in without code", async ({ page }) => {
await page.goto("/auth/callback");
await page.waitForURL(/sign-in/, { timeout: 10_000 });
expect(page.url()).toContain("/sign-in");
});

test("unauthenticated user is redirected to sign-in", async ({ page }) => {
// Try to access an authenticated route
await page.goto("/test-workspace");
Expand Down
20 changes: 20 additions & 0 deletions src/app/(auth)/sign-in/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import userEvent from "@testing-library/user-event";

// Mock next/navigation
const mockPush = vi.fn();
let mockSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
}));

// Mock next/link as a simple anchor
Expand Down Expand Up @@ -49,6 +51,7 @@ import SignInPage from "./page";

beforeEach(() => {
vi.clearAllMocks();
mockSearchParams = new URLSearchParams();
});

describe("SignInPage", () => {
Expand Down Expand Up @@ -207,4 +210,21 @@ describe("SignInPage", () => {
).toBeDisabled();
});
});

it("shows confirmation success message when confirmed=true query param is present", () => {
mockSearchParams = new URLSearchParams("confirmed=true");
render(<SignInPage />);

expect(
screen.getByText(/email confirmed/i),
).toBeInTheDocument();
});

it("does not show confirmation message without query param", () => {
render(<SignInPage />);

expect(
screen.queryByText(/email confirmed/i),
).not.toBeInTheDocument();
});
});
21 changes: 18 additions & 3 deletions src/app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
Expand All @@ -16,8 +16,10 @@ import {
} from "@/components/ui/card";
import { OAuthButtons } from "@/components/auth/oauth-buttons";

export default function SignInPage() {
function SignInForm() {
const router = useRouter();
const searchParams = useSearchParams();
const confirmed = searchParams.get("confirmed") === "true";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -99,6 +101,11 @@ export default function SignInPage() {
minLength={6}
/>
</div>
{confirmed && (
<p className="text-xs text-accent">
Email confirmed — you can now sign in.
</p>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="mt-1">
{loading ? "Signing in…" : "Sign in"}
Expand All @@ -123,3 +130,11 @@ export default function SignInPage() {
</Card>
);
}

export default function SignInPage() {
return (
<Suspense>
<SignInForm />
</Suspense>
);
}
39 changes: 34 additions & 5 deletions src/app/(auth)/sign-up/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ describe("SignUpPage", () => {
});
});

it("calls supabase.auth.signUp with correct parameters", async () => {
it("calls supabase.auth.signUp with correct parameters including emailRedirectTo", async () => {
mockSignUp.mockResolvedValue({
data: { user: { id: "user-1" } },
data: { user: { id: "user-1", identities: [{ id: "id-1" }] } },
error: null,
});

Expand Down Expand Up @@ -137,14 +137,43 @@ describe("SignUpPage", () => {
data: {
display_name: "Jane Doe",
},
emailRedirectTo: "http://localhost:3000/auth/callback",
},
});
});
});

it("redirects to workspace after successful sign-up", async () => {
it("shows confirmation screen when email confirmation is required", async () => {
mockSignUp.mockResolvedValue({
data: { user: { id: "user-1" } },
data: { user: { id: "user-1", identities: [] } },
error: null,
});

const user = userEvent.setup();
render(<SignUpPage />);

await user.type(screen.getByLabelText("Display name"), "Jane Doe");
await user.type(screen.getByLabelText("Email"), "jane@example.com");
await user.type(screen.getByLabelText("Password"), "password123");

const form = screen.getByRole("button", { name: /sign up/i })
.closest("form")!;
form.requestSubmit();

await waitFor(() => {
expect(screen.getByText("Check your inbox")).toBeInTheDocument();
expect(screen.getByText("jane@example.com")).toBeInTheDocument();
});

// Form should no longer be visible
expect(screen.queryByLabelText("Email")).not.toBeInTheDocument();
// Should not have attempted a redirect
expect(mockPush).not.toHaveBeenCalled();
});

it("redirects to workspace after successful sign-up without email confirmation", async () => {
mockSignUp.mockResolvedValue({
data: { user: { id: "user-1", identities: [{ id: "id-1" }] } },
error: null,
});

Expand Down Expand Up @@ -174,7 +203,7 @@ describe("SignUpPage", () => {

it("redirects to root when no workspace found after sign-up", async () => {
mockSignUp.mockResolvedValue({
data: { user: { id: "user-1" } },
data: { user: { id: "user-1", identities: [{ id: "id-1" }] } },
error: null,
});

Expand Down
48 changes: 46 additions & 2 deletions src/app/(auth)/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,24 @@ export default function SignUpPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [confirmationPending, setConfirmationPending] = useState(false);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);

const supabase = createClient();
const siteUrl =
typeof window !== "undefined" ? window.location.origin : "";
const { data, error: signUpError } = await supabase.auth.signUp({
email,
password,
options: {
data: {
display_name: displayName,
},
emailRedirectTo: `${siteUrl}/auth/callback`,
},
});

Expand All @@ -46,8 +50,20 @@ export default function SignUpPage() {
return;
}

// After sign-up, the handle_new_user trigger creates a personal workspace.
// Fetch it so we can redirect there.
// When email confirmation is required, Supabase returns a user with
// an empty identities array. Show the confirmation screen instead of
// attempting to redirect.
if (
data.user &&
(!data.user.identities || data.user.identities.length === 0)
) {
setConfirmationPending(true);
setLoading(false);
return;
}

// If email confirmation is disabled, the user is immediately active.
// Fetch their workspace and redirect.
if (data.user) {
const { data: membership } = await supabase
.from("members")
Expand All @@ -67,6 +83,34 @@ export default function SignUpPage() {
router.push("/");
}

if (confirmationPending) {
return (
<Card>
<CardHeader>
<CardTitle className="text-2xl font-semibold">
Check your inbox
</CardTitle>
<CardDescription>
We sent a confirmation link to{" "}
<span className="font-medium text-foreground">{email}</span>.
Click the link to activate your account.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Already confirmed?{" "}
<Link
href="/sign-in"
className="text-accent underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</CardContent>
</Card>
);
}

return (
<Card>
<CardHeader>
Expand Down
69 changes: 69 additions & 0 deletions src/app/auth/callback/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";

const mockExchangeCodeForSession = vi.fn();
const mockSignOut = vi.fn();

vi.mock("@/lib/supabase/server", () => ({
createClient: vi.fn().mockResolvedValue({
auth: {
exchangeCodeForSession: (...args: unknown[]) =>
mockExchangeCodeForSession(...args),
signOut: (...args: unknown[]) => mockSignOut(...args),
},
}),
}));

import { GET } from "./route";

beforeEach(() => {
vi.clearAllMocks();
});

describe("GET /auth/callback", () => {
it("exchanges code for session and redirects to sign-in with confirmed=true", async () => {
mockExchangeCodeForSession.mockResolvedValue({ error: null });
mockSignOut.mockResolvedValue({ error: null });

const request = new NextRequest(
"http://localhost:3000/auth/callback?code=test-auth-code",
);
const response = await GET(request);

expect(mockExchangeCodeForSession).toHaveBeenCalledWith("test-auth-code");
expect(mockSignOut).toHaveBeenCalled();
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/sign-in?confirmed=true",
);
});

it("redirects to sign-in without confirmed param when code exchange fails", async () => {
mockExchangeCodeForSession.mockResolvedValue({
error: { message: "Invalid code" },
});

const request = new NextRequest(
"http://localhost:3000/auth/callback?code=bad-code",
);
const response = await GET(request);

expect(mockExchangeCodeForSession).toHaveBeenCalledWith("bad-code");
expect(mockSignOut).not.toHaveBeenCalled();
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/sign-in",
);
});

it("redirects to sign-in when no code param is provided", async () => {
const request = new NextRequest("http://localhost:3000/auth/callback");
const response = await GET(request);

expect(mockExchangeCodeForSession).not.toHaveBeenCalled();
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/sign-in",
);
});
});
30 changes: 30 additions & 0 deletions src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";

/**
* Handles the email confirmation redirect from Supabase Auth.
* Supabase appends `?code=<auth_code>` to the callback URL.
* This route exchanges the code for a session, then redirects
* the user to the sign-in page with a confirmed=true param.
*/
export async function GET(request: NextRequest) {
const { searchParams, origin } = request.nextUrl;
const code = searchParams.get("code");

if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);

if (!error) {
// Sign the user out so they land on the sign-in page with a fresh
// session prompt. The confirmation was successful — they can now
// sign in with their credentials.
await supabase.auth.signOut();
return NextResponse.redirect(`${origin}/sign-in?confirmed=true`);
}
}

// If the code is missing or exchange failed, redirect to sign-in
// without the success message so the user can try again.
return NextResponse.redirect(`${origin}/sign-in`);
}
2 changes: 1 addition & 1 deletion src/lib/supabase/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

// Routes that unauthenticated users can access
const PUBLIC_ROUTES = ["/sign-in", "/sign-up", "/invite"];
const PUBLIC_ROUTES = ["/sign-in", "/sign-up", "/invite", "/auth/callback"];

export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
Expand Down
Loading