Skip to content

Commit 1b2e8bf

Browse files
fix: add email confirmation screen and auth callback redirect (#208) (#218)
Co-authored-by: Ona <no-reply@ona.com>
1 parent e8135b0 commit 1b2e8bf

8 files changed

Lines changed: 242 additions & 11 deletions

File tree

e2e/auth.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,30 @@ test.describe("Authentication", () => {
1919
await expect(page.getByRole("button", { name: /sign up/i })).toBeVisible();
2020
});
2121

22+
test("sign-in page shows confirmation message with confirmed=true param", async ({
23+
page,
24+
}) => {
25+
await page.goto("/sign-in?confirmed=true");
26+
await expect(
27+
page.getByText(/email confirmed/i),
28+
).toBeVisible();
29+
});
30+
31+
test("sign-in page does not show confirmation message without param", async ({
32+
page,
33+
}) => {
34+
await page.goto("/sign-in");
35+
await expect(
36+
page.getByText(/email confirmed/i),
37+
).not.toBeVisible();
38+
});
39+
40+
test("auth callback redirects to sign-in without code", async ({ page }) => {
41+
await page.goto("/auth/callback");
42+
await page.waitForURL(/sign-in/, { timeout: 10_000 });
43+
expect(page.url()).toContain("/sign-in");
44+
});
45+
2246
test("unauthenticated user is redirected to sign-in", async ({ page }) => {
2347
// Try to access an authenticated route
2448
await page.goto("/test-workspace");

src/app/(auth)/sign-in/page.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import userEvent from "@testing-library/user-event";
55

66
// Mock next/navigation
77
const mockPush = vi.fn();
8+
let mockSearchParams = new URLSearchParams();
89
vi.mock("next/navigation", () => ({
910
useRouter: () => ({ push: mockPush }),
11+
useSearchParams: () => mockSearchParams,
1012
}));
1113

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

5052
beforeEach(() => {
5153
vi.clearAllMocks();
54+
mockSearchParams = new URLSearchParams();
5255
});
5356

5457
describe("SignInPage", () => {
@@ -207,4 +210,21 @@ describe("SignInPage", () => {
207210
).toBeDisabled();
208211
});
209212
});
213+
214+
it("shows confirmation success message when confirmed=true query param is present", () => {
215+
mockSearchParams = new URLSearchParams("confirmed=true");
216+
render(<SignInPage />);
217+
218+
expect(
219+
screen.getByText(/email confirmed/i),
220+
).toBeInTheDocument();
221+
});
222+
223+
it("does not show confirmation message without query param", () => {
224+
render(<SignInPage />);
225+
226+
expect(
227+
screen.queryByText(/email confirmed/i),
228+
).not.toBeInTheDocument();
229+
});
210230
});

src/app/(auth)/sign-in/page.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

3-
import { useState } from "react";
4-
import { useRouter } from "next/navigation";
3+
import { Suspense, useState } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
55
import Link from "next/link";
66
import { createClient } from "@/lib/supabase/client";
77
import { Button } from "@/components/ui/button";
@@ -16,8 +16,10 @@ import {
1616
} from "@/components/ui/card";
1717
import { OAuthButtons } from "@/components/auth/oauth-buttons";
1818

19-
export default function SignInPage() {
19+
function SignInForm() {
2020
const router = useRouter();
21+
const searchParams = useSearchParams();
22+
const confirmed = searchParams.get("confirmed") === "true";
2123
const [email, setEmail] = useState("");
2224
const [password, setPassword] = useState("");
2325
const [error, setError] = useState<string | null>(null);
@@ -99,6 +101,11 @@ export default function SignInPage() {
99101
minLength={6}
100102
/>
101103
</div>
104+
{confirmed && (
105+
<p className="text-xs text-accent">
106+
Email confirmed — you can now sign in.
107+
</p>
108+
)}
102109
{error && <p className="text-xs text-destructive">{error}</p>}
103110
<Button type="submit" disabled={loading} className="mt-1">
104111
{loading ? "Signing in…" : "Sign in"}
@@ -123,3 +130,11 @@ export default function SignInPage() {
123130
</Card>
124131
);
125132
}
133+
134+
export default function SignInPage() {
135+
return (
136+
<Suspense>
137+
<SignInForm />
138+
</Suspense>
139+
);
140+
}

src/app/(auth)/sign-up/page.test.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ describe("SignUpPage", () => {
104104
});
105105
});
106106

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

@@ -137,14 +137,43 @@ describe("SignUpPage", () => {
137137
data: {
138138
display_name: "Jane Doe",
139139
},
140+
emailRedirectTo: "http://localhost:3000/auth/callback",
140141
},
141142
});
142143
});
143144
});
144145

145-
it("redirects to workspace after successful sign-up", async () => {
146+
it("shows confirmation screen when email confirmation is required", async () => {
146147
mockSignUp.mockResolvedValue({
147-
data: { user: { id: "user-1" } },
148+
data: { user: { id: "user-1", identities: [] } },
149+
error: null,
150+
});
151+
152+
const user = userEvent.setup();
153+
render(<SignUpPage />);
154+
155+
await user.type(screen.getByLabelText("Display name"), "Jane Doe");
156+
await user.type(screen.getByLabelText("Email"), "jane@example.com");
157+
await user.type(screen.getByLabelText("Password"), "password123");
158+
159+
const form = screen.getByRole("button", { name: /sign up/i })
160+
.closest("form")!;
161+
form.requestSubmit();
162+
163+
await waitFor(() => {
164+
expect(screen.getByText("Check your inbox")).toBeInTheDocument();
165+
expect(screen.getByText("jane@example.com")).toBeInTheDocument();
166+
});
167+
168+
// Form should no longer be visible
169+
expect(screen.queryByLabelText("Email")).not.toBeInTheDocument();
170+
// Should not have attempted a redirect
171+
expect(mockPush).not.toHaveBeenCalled();
172+
});
173+
174+
it("redirects to workspace after successful sign-up without email confirmation", async () => {
175+
mockSignUp.mockResolvedValue({
176+
data: { user: { id: "user-1", identities: [{ id: "id-1" }] } },
148177
error: null,
149178
});
150179

@@ -174,7 +203,7 @@ describe("SignUpPage", () => {
174203

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

src/app/(auth)/sign-up/page.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,24 @@ export default function SignUpPage() {
2323
const [password, setPassword] = useState("");
2424
const [error, setError] = useState<string | null>(null);
2525
const [loading, setLoading] = useState(false);
26+
const [confirmationPending, setConfirmationPending] = useState(false);
2627

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

3233
const supabase = createClient();
34+
const siteUrl =
35+
typeof window !== "undefined" ? window.location.origin : "";
3336
const { data, error: signUpError } = await supabase.auth.signUp({
3437
email,
3538
password,
3639
options: {
3740
data: {
3841
display_name: displayName,
3942
},
43+
emailRedirectTo: `${siteUrl}/auth/callback`,
4044
},
4145
});
4246

@@ -46,8 +50,20 @@ export default function SignUpPage() {
4650
return;
4751
}
4852

49-
// After sign-up, the handle_new_user trigger creates a personal workspace.
50-
// Fetch it so we can redirect there.
53+
// When email confirmation is required, Supabase returns a user with
54+
// an empty identities array. Show the confirmation screen instead of
55+
// attempting to redirect.
56+
if (
57+
data.user &&
58+
(!data.user.identities || data.user.identities.length === 0)
59+
) {
60+
setConfirmationPending(true);
61+
setLoading(false);
62+
return;
63+
}
64+
65+
// If email confirmation is disabled, the user is immediately active.
66+
// Fetch their workspace and redirect.
5167
if (data.user) {
5268
const { data: membership } = await supabase
5369
.from("members")
@@ -67,6 +83,34 @@ export default function SignUpPage() {
6783
router.push("/");
6884
}
6985

86+
if (confirmationPending) {
87+
return (
88+
<Card>
89+
<CardHeader>
90+
<CardTitle className="text-2xl font-semibold">
91+
Check your inbox
92+
</CardTitle>
93+
<CardDescription>
94+
We sent a confirmation link to{" "}
95+
<span className="font-medium text-foreground">{email}</span>.
96+
Click the link to activate your account.
97+
</CardDescription>
98+
</CardHeader>
99+
<CardContent>
100+
<p className="text-xs text-muted-foreground">
101+
Already confirmed?{" "}
102+
<Link
103+
href="/sign-in"
104+
className="text-accent underline-offset-4 hover:underline"
105+
>
106+
Sign in
107+
</Link>
108+
</p>
109+
</CardContent>
110+
</Card>
111+
);
112+
}
113+
70114
return (
71115
<Card>
72116
<CardHeader>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { NextRequest } from "next/server";
3+
4+
const mockExchangeCodeForSession = vi.fn();
5+
const mockSignOut = vi.fn();
6+
7+
vi.mock("@/lib/supabase/server", () => ({
8+
createClient: vi.fn().mockResolvedValue({
9+
auth: {
10+
exchangeCodeForSession: (...args: unknown[]) =>
11+
mockExchangeCodeForSession(...args),
12+
signOut: (...args: unknown[]) => mockSignOut(...args),
13+
},
14+
}),
15+
}));
16+
17+
import { GET } from "./route";
18+
19+
beforeEach(() => {
20+
vi.clearAllMocks();
21+
});
22+
23+
describe("GET /auth/callback", () => {
24+
it("exchanges code for session and redirects to sign-in with confirmed=true", async () => {
25+
mockExchangeCodeForSession.mockResolvedValue({ error: null });
26+
mockSignOut.mockResolvedValue({ error: null });
27+
28+
const request = new NextRequest(
29+
"http://localhost:3000/auth/callback?code=test-auth-code",
30+
);
31+
const response = await GET(request);
32+
33+
expect(mockExchangeCodeForSession).toHaveBeenCalledWith("test-auth-code");
34+
expect(mockSignOut).toHaveBeenCalled();
35+
expect(response.status).toBe(307);
36+
expect(response.headers.get("location")).toBe(
37+
"http://localhost:3000/sign-in?confirmed=true",
38+
);
39+
});
40+
41+
it("redirects to sign-in without confirmed param when code exchange fails", async () => {
42+
mockExchangeCodeForSession.mockResolvedValue({
43+
error: { message: "Invalid code" },
44+
});
45+
46+
const request = new NextRequest(
47+
"http://localhost:3000/auth/callback?code=bad-code",
48+
);
49+
const response = await GET(request);
50+
51+
expect(mockExchangeCodeForSession).toHaveBeenCalledWith("bad-code");
52+
expect(mockSignOut).not.toHaveBeenCalled();
53+
expect(response.status).toBe(307);
54+
expect(response.headers.get("location")).toBe(
55+
"http://localhost:3000/sign-in",
56+
);
57+
});
58+
59+
it("redirects to sign-in when no code param is provided", async () => {
60+
const request = new NextRequest("http://localhost:3000/auth/callback");
61+
const response = await GET(request);
62+
63+
expect(mockExchangeCodeForSession).not.toHaveBeenCalled();
64+
expect(response.status).toBe(307);
65+
expect(response.headers.get("location")).toBe(
66+
"http://localhost:3000/sign-in",
67+
);
68+
});
69+
});

src/app/auth/callback/route.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextResponse, type NextRequest } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
4+
/**
5+
* Handles the email confirmation redirect from Supabase Auth.
6+
* Supabase appends `?code=<auth_code>` to the callback URL.
7+
* This route exchanges the code for a session, then redirects
8+
* the user to the sign-in page with a confirmed=true param.
9+
*/
10+
export async function GET(request: NextRequest) {
11+
const { searchParams, origin } = request.nextUrl;
12+
const code = searchParams.get("code");
13+
14+
if (code) {
15+
const supabase = await createClient();
16+
const { error } = await supabase.auth.exchangeCodeForSession(code);
17+
18+
if (!error) {
19+
// Sign the user out so they land on the sign-in page with a fresh
20+
// session prompt. The confirmation was successful — they can now
21+
// sign in with their credentials.
22+
await supabase.auth.signOut();
23+
return NextResponse.redirect(`${origin}/sign-in?confirmed=true`);
24+
}
25+
}
26+
27+
// If the code is missing or exchange failed, redirect to sign-in
28+
// without the success message so the user can try again.
29+
return NextResponse.redirect(`${origin}/sign-in`);
30+
}

src/lib/supabase/proxy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createServerClient } from "@supabase/ssr";
22
import { NextResponse, type NextRequest } from "next/server";
33

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

77
export async function updateSession(request: NextRequest) {
88
let supabaseResponse = NextResponse.next({ request });

0 commit comments

Comments
 (0)