From ef7432a338a6249e8274f400ab190e3fc9e5d490 Mon Sep 17 00:00:00 2001 From: Marc Klose Date: Sun, 5 Apr 2026 03:59:25 +0300 Subject: [PATCH] Implement team invitations flow, RLS bypass, and E2E tests --- .gitignore | 4 + app/api/invite/accept/route.ts | 67 +++++++++++ app/auth/sign-up/page.tsx | 6 +- app/dashboard/layout.tsx | 13 +++ app/dashboard/loading.tsx | 21 ++++ app/dashboard/page.tsx | 8 ++ app/dashboard/settings/team/actions.ts | 81 +++++++++++++ app/dashboard/settings/team/page.tsx | 156 +++++++++++++++++++++++++ app/onboarding/actions.ts | 46 ++++++++ app/onboarding/page.tsx | 100 ++++++++++++++++ app/page.tsx | 57 +-------- app/protected/layout.tsx | 55 --------- app/protected/page.tsx | 43 ------- components/sidebar.tsx | 142 ++++++++++++++++++++++ components/sign-up-form.tsx | 7 +- components/ui/skeleton.tsx | 15 +++ e2e/invitation.spec.ts | 137 ++++++++++++++++++++++ lib/supabase/proxy.ts | 67 ++++++----- package-lock.json | 78 +++++++++++++ package.json | 2 + playwright.config.ts | 38 ++++++ 21 files changed, 957 insertions(+), 186 deletions(-) create mode 100644 app/api/invite/accept/route.ts create mode 100644 app/dashboard/layout.tsx create mode 100644 app/dashboard/loading.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/dashboard/settings/team/actions.ts create mode 100644 app/dashboard/settings/team/page.tsx create mode 100644 app/onboarding/actions.ts create mode 100644 app/onboarding/page.tsx delete mode 100644 app/protected/layout.tsx delete mode 100644 app/protected/page.tsx create mode 100644 components/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 e2e/invitation.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index f1631b0..0d890f4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ # testing /coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ # next.js /.next/ diff --git a/app/api/invite/accept/route.ts b/app/api/invite/accept/route.ts new file mode 100644 index 0000000..f877e0b --- /dev/null +++ b/app/api/invite/accept/route.ts @@ -0,0 +1,67 @@ +import { createClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; +import { createClient as createAdminClient } from "@supabase/supabase-js"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const token = searchParams.get("token"); + + if (!token) { + return NextResponse.json({ error: "No token provided" }, { status: 400 }); + } + + const supabase = await createClient(); + + const supabaseAdmin = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + // Validate the token + const { data: invite, error: inviteError } = await supabaseAdmin + .from("invitations") + .select("id, organization_id, status, expires_at, email") + .eq("token", token) + .single(); + + if (inviteError || !invite) { + return NextResponse.redirect(new URL("/?error=invalid_token", request.url)); + } + + if (invite.status !== "pending") { + return NextResponse.redirect(new URL("/?error=already_accepted", request.url)); + } + + if (new Date(invite.expires_at) < new Date()) { + return NextResponse.redirect(new URL("/?error=expired", request.url)); + } + + const { data: userData } = await supabase.auth.getUser(); + + if (!userData?.user) { + // Not logged in -> Store token in cookies and redirect to sign up + const response = NextResponse.redirect(new URL(`/auth/sign-up?email=${encodeURIComponent(invite.email)}`, request.url)); + response.cookies.set("invite_token", token, { + maxAge: 60 * 60 * 24, // 1 day + path: "/", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + }); + return response; + } + + const userId = userData.user.id; + + await supabase + .from("profiles") + .update({ organization_id: invite.organization_id, role: "member" }) + .eq("id", userId); + + await supabaseAdmin + .from("invitations") + .update({ status: "accepted" }) + .eq("id", invite.id); + + return NextResponse.redirect(new URL("/dashboard", request.url)); +} diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index d5dca78..03eeb67 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -1,10 +1,12 @@ import { SignUpForm } from "@/components/sign-up-form"; -export default function Page() { +export default async function Page({ searchParams }: { searchParams: Promise<{ email?: string }> }) { + const email = (await searchParams).email || ""; + return (
- +
); diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..81a6fa6 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,13 @@ +import { Sidebar } from "@/components/sidebar"; +import { ReactNode } from "react"; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/app/dashboard/loading.tsx b/app/dashboard/loading.tsx new file mode 100644 index 0000000..7efeaa8 --- /dev/null +++ b/app/dashboard/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function DashboardLoading() { + return ( +
+
+ + +
+ +
+ +
+ + + +
+
+
+ ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..86f09ab --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,8 @@ +export default function DashboardOverview() { + return ( +
+

Overview

+

Welcome to your dashboard. This is your organization's home.

+
+ ); +} diff --git a/app/dashboard/settings/team/actions.ts b/app/dashboard/settings/team/actions.ts new file mode 100644 index 0000000..fde7362 --- /dev/null +++ b/app/dashboard/settings/team/actions.ts @@ -0,0 +1,81 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; + +export async function inviteUser(formData: FormData) { + const email = formData.get("email") as string; + if (!email) { + throw new Error("Email is required"); + } + + const supabase = await createClient(); + const { data: userData, error: userError } = await supabase.auth.getUser(); + + if (userError || !userData.user) { + throw new Error("Unauthorized"); + } + + const { data: profile } = await supabase + .from("profiles") + .select("organization_id, role") + .eq("id", userData.user.id) + .single(); + + if (!profile || profile.role !== "owner" || !profile.organization_id) { + throw new Error("Only organization owners can invite new members"); + } + + const { error: inviteError } = await supabase + .from("invitations") + .insert([ + { + email, + organization_id: profile.organization_id, + invited_by: userData.user.id, + }, + ]); + + if (inviteError) { + console.error("Error creating invitation:", inviteError); + throw new Error("Failed to create invitation"); + } + + revalidatePath("/dashboard/settings/team"); +} + +export async function revokeInvitation(formData: FormData) { + const id = formData.get("id") as string; + if (!id) { + throw new Error("Invitation ID is required"); + } + + const supabase = await createClient(); + const { data: userData, error: userError } = await supabase.auth.getUser(); + + if (userError || !userData.user) { + throw new Error("Unauthorized"); + } + + const { data: profile } = await supabase + .from("profiles") + .select("organization_id, role") + .eq("id", userData.user.id) + .single(); + + if (!profile || profile.role !== "owner" || !profile.organization_id) { + throw new Error("Only organization owners can revoke invitations"); + } + + const { error: deleteError } = await supabase + .from("invitations") + .delete() + .match({ id, organization_id: profile.organization_id }); + + if (deleteError) { + console.error("Error revoking invitation:", deleteError); + throw new Error("Failed to revoke invitation"); + } + + revalidatePath("/dashboard/settings/team"); +} diff --git a/app/dashboard/settings/team/page.tsx b/app/dashboard/settings/team/page.tsx new file mode 100644 index 0000000..92ca70f --- /dev/null +++ b/app/dashboard/settings/team/page.tsx @@ -0,0 +1,156 @@ +import { createClient } from "@/lib/supabase/server"; +import { inviteUser, revokeInvitation } from "./actions"; +import { Suspense } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Trash2 } from "lucide-react"; + +async function TeamSettingsContent() { + const supabase = await createClient(); + const { data: userData } = await supabase.auth.getUser(); + const userId = userData?.user?.id; + + if (!userId) return null; + + // Get profile + const { data: profile } = await supabase + .from("profiles") + .select("organization_id, role") + .eq("id", userId) + .single(); + + if (!profile?.organization_id) return null; + + // Get organization members + const { data: members } = await supabase + .from("profiles") + .select("id, email, role") + .eq("organization_id", profile.organization_id); + + // Get pending invitations + const { data: invitations } = await supabase + .from("invitations") + .select("id, email, token, status, expires_at") + .eq("organization_id", profile.organization_id) + .eq("status", "pending") + .gt("expires_at", new Date().toISOString()); + + return ( +
+
+

Team Management

+

Manage your organization's members and invitations.

+
+ +
+

Members

+
+
    + {members?.map((m) => ( +
  • +
    +
    {m.email}
    +
    {m.role}
    +
    +
  • + ))} +
+
+
+ + {profile.role === "owner" && ( +
+

Invite New Member

+
+
+ + +
+
+
+ )} + + {profile.role === "owner" && invitations && invitations.length > 0 && ( +
+

Pending Invitations

+
+
    + {invitations.map((inv) => ( +
  • +
    +
    {inv.email}
    +
    +
    +
    Pending
    +
    + + +
    +
    +
  • + ))} +
+
+
+ )} +
+ ); +} + +function TeamSettingsSkeleton() { + return ( +
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ + +
+
+
+ ); +} + +export default function TeamSettingsPage() { + return ( + }> + + + ); +} + diff --git a/app/onboarding/actions.ts b/app/onboarding/actions.ts new file mode 100644 index 0000000..d640e01 --- /dev/null +++ b/app/onboarding/actions.ts @@ -0,0 +1,46 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { redirect } from "next/navigation"; + +export async function createOrganization(formData: FormData) { + const name = formData.get("name") as string; + if (!name) { + throw new Error("Organization name is required"); + } + + const supabase = await createClient(); + const { data: userData, error: userError } = await supabase.auth.getUser(); + + if (userError || !userData.user) { + redirect("/auth/login"); + } + + const userId = userData.user.id; + + // Insert organization + const { data: orgData, error: orgError } = await supabase + .from("organizations") + .insert([{ name, owner_id: userId }]) + .select("id") + .single(); + + if (orgError || !orgData) { + console.error("Error creating organization:", orgError); + throw new Error("Failed to create organization."); + } + + // Update profile + const { error: profileError } = await supabase + .from("profiles") + .update({ organization_id: orgData.id, role: "owner" }) + .eq("id", userId); + + if (profileError) { + console.error("Error updating profile:", profileError); + throw new Error("Failed to assign user to organization."); + } + + // Redirect to dashboard + redirect("/dashboard"); +} diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx new file mode 100644 index 0000000..ceadec2 --- /dev/null +++ b/app/onboarding/page.tsx @@ -0,0 +1,100 @@ +import { createClient } from "@/lib/supabase/server"; +import { redirect } from "next/navigation"; +import { createClient as createAdminClient } from "@supabase/supabase-js"; +import { cookies } from "next/headers"; +import { createOrganization } from "./actions"; +import { Suspense } from "react"; + +async function OnboardingContent() { + const supabase = await createClient(); + const { data } = await supabase.auth.getUser(); + + if (!data?.user) { + redirect("/auth/login"); + } + + // If already in an org, redirect to dashboard + const { data: profile } = await supabase + .from("profiles") + .select("organization_id") + .eq("id", data.user.id) + .single(); + + if (profile?.organization_id) { + redirect("/dashboard"); + } + + // Check for invite token cookie + const cookieStore = await cookies(); + const token = cookieStore.get("invite_token")?.value; + + if (token) { + const supabaseAdmin = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + // Attempt to redeem it automatically + const { data: invite } = await supabaseAdmin + .from("invitations") + .select("*") + .eq("token", token) + .eq("status", "pending") + .single(); + + if (invite && new Date(invite.expires_at) > new Date()) { + await supabase.from("profiles").update({ organization_id: invite.organization_id, role: "member" }).eq("id", data.user.id); + await supabaseAdmin.from("invitations").update({ status: "accepted" }).eq("id", invite.id); + // Optional: Clear the cookie, but next redirect will naturally ignore it since organization_id is set + redirect("/dashboard"); + } + } + + return ( +
+
+
+

Welcome

+

+ Let's get started by creating an organization for your team. +

+
+ +
+
+ + +
+ + +
+ +
+ Waiting for an invite? Ask your team owner to send you a link. +
+
+
+ ); +} + +export default function OnboardingPage() { + return ( + Loading...}> + + + ); +} diff --git a/app/page.tsx b/app/page.tsx index ac33981..a74cb27 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,58 +1,5 @@ -import { DeployButton } from "@/components/deploy-button"; -import { EnvVarWarning } from "@/components/env-var-warning"; -import { AuthButton } from "@/components/auth-button"; -import { Hero } from "@/components/hero"; -import { ThemeSwitcher } from "@/components/theme-switcher"; -import { ConnectSupabaseSteps } from "@/components/tutorial/connect-supabase-steps"; -import { SignUpUserSteps } from "@/components/tutorial/sign-up-user-steps"; -import { hasEnvVars } from "@/lib/utils"; -import Link from "next/link"; -import { Suspense } from "react"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- -
- -
-

Next steps

- {hasEnvVars ? : } -
-
- - -
-
- ); + redirect("/dashboard"); } diff --git a/app/protected/layout.tsx b/app/protected/layout.tsx deleted file mode 100644 index 99b0966..0000000 --- a/app/protected/layout.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { DeployButton } from "@/components/deploy-button"; -import { EnvVarWarning } from "@/components/env-var-warning"; -import { AuthButton } from "@/components/auth-button"; -import { ThemeSwitcher } from "@/components/theme-switcher"; -import { hasEnvVars } from "@/lib/utils"; -import Link from "next/link"; -import { Suspense } from "react"; - -export default function ProtectedLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
-
- -
- {children} -
- - -
-
- ); -} diff --git a/app/protected/page.tsx b/app/protected/page.tsx deleted file mode 100644 index 4f6caa7..0000000 --- a/app/protected/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { redirect } from "next/navigation"; - -import { createClient } from "@/lib/supabase/server"; -import { InfoIcon } from "lucide-react"; -import { FetchDataSteps } from "@/components/tutorial/fetch-data-steps"; -import { Suspense } from "react"; - -async function UserDetails() { - const supabase = await createClient(); - const { data, error } = await supabase.auth.getClaims(); - - if (error || !data?.claims) { - redirect("/auth/login"); - } - - return JSON.stringify(data.claims, null, 2); -} - -export default function ProtectedPage() { - return ( -
-
-
- - This is a protected page that you can only see as an authenticated - user -
-
-
-

Your user details

-
-          
-            
-          
-        
-
-
-

Next steps

- -
-
- ); -} diff --git a/components/sidebar.tsx b/components/sidebar.tsx new file mode 100644 index 0000000..72f6db2 --- /dev/null +++ b/components/sidebar.tsx @@ -0,0 +1,142 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { FC, useState, useEffect } from "react"; +import { LayoutDashboard, Users, ChevronLeft, ChevronRight, User, LogOut } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { createClient } from "@/lib/supabase/client"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +const navItems = [ + { href: "/dashboard", icon: LayoutDashboard, label: "Overview" }, + { href: "/dashboard/settings/team", icon: Users, label: "Team" }, +]; + +export const Sidebar: FC = () => { + const pathname = usePathname(); + const router = useRouter(); + const [collapsed, setCollapsed] = useState(false); + const [user, setUser] = useState<{ id: string; email: string } | null>(null); + const [organizationName, setOrganizationName] = useState("Organization"); + const supabase = createClient(); + + useEffect(() => { + const fetchOrg = async (userId: string) => { + const { data: profile } = await supabase.from("profiles").select("organization_id").eq("id", userId).single(); + if (profile?.organization_id) { + const { data: org } = await supabase.from("organizations").select("name").eq("id", profile.organization_id).single(); + if (org?.name) { + setOrganizationName(org.name); + } + } + }; + + supabase.auth.getUser().then(({ data: { user: currentUser } }) => { + if (currentUser) { + setUser({ id: currentUser.id, email: currentUser.email ?? "" }); + fetchOrg(currentUser.id); + } + }); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { + if (session?.user) { + setUser({ id: session.user.id, email: session.user.email ?? "" }); + fetchOrg(session.user.id); + } else { + setUser(null); + setOrganizationName("Organization"); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [supabase]); + + const handleLogout = async () => { + await supabase.auth.signOut(); + router.refresh(); + }; + + return ( + + ); +}; diff --git a/components/sign-up-form.tsx b/components/sign-up-form.tsx index 6c4b369..f558c9c 100644 --- a/components/sign-up-form.tsx +++ b/components/sign-up-form.tsx @@ -18,9 +18,10 @@ import { useState } from "react"; export function SignUpForm({ className, + initialEmail = "", ...props -}: React.ComponentPropsWithoutRef<"div">) { - const [email, setEmail] = useState(""); +}: React.ComponentPropsWithoutRef<"div"> & { initialEmail?: string }) { + const [email, setEmail] = useState(initialEmail); const [password, setPassword] = useState(""); const [repeatPassword, setRepeatPassword] = useState(""); const [error, setError] = useState(null); @@ -75,6 +76,8 @@ export function SignUpForm({ required value={email} onChange={(e) => setEmail(e.target.value)} + readOnly={!!initialEmail} + className={initialEmail ? "bg-muted text-muted-foreground" : ""} />
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx new file mode 100644 index 0000000..d7e45f7 --- /dev/null +++ b/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/e2e/invitation.spec.ts b/e2e/invitation.spec.ts new file mode 100644 index 0000000..8a06bd4 --- /dev/null +++ b/e2e/invitation.spec.ts @@ -0,0 +1,137 @@ +import { test, expect } from '@playwright/test'; +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://127.0.0.1:54321'; +const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || ''; + +const supabase = createClient(supabaseUrl, supabaseKey); + +const ownerEmail = `owner_${Date.now()}@example.com`; +const ownerPassword = 'testpassword123'; +const inviteeEmail = `invitee_${Date.now()}@example.com`; +const inviteePassword = 'testpassword123'; + +test.describe('Team Invitation Flow', () => { + test('Should process creating, revoking and accepting invitations', async ({ browser }) => { + // We use isolated browser contexts to simulate two users + const ownerContext = await browser.newContext(); + const ownerPage = await ownerContext.newPage(); + + // Step 1: Sign up owner + await ownerPage.goto('/auth/sign-up'); + await ownerPage.fill('input[type="email"]', ownerEmail); + await ownerPage.fill('input[id="password"]', ownerPassword); + await ownerPage.fill('input[id="repeat-password"]', ownerPassword); + await ownerPage.click('button[type="submit"]'); + + // NEW: Auto-confirm the owner email using Service Role Key + await ownerPage.waitForTimeout(1000); + const { data: { users: ownerUsers } } = await supabase.auth.admin.listUsers(); + const ownerUser = ownerUsers.find(u => u.email === ownerEmail); + if (ownerUser) { + await supabase.auth.admin.updateUserById(ownerUser.id, { email_confirm: true }); + } + + // Wait for the auth redirect + await ownerPage.waitForURL('**/auth/sign-up-success**', { timeout: 10000 }).catch(() => null); + + // Some implementations redirect to sign-up-success then require manual login + if (ownerPage.url().includes('sign-up-success')) { + await ownerPage.goto('/auth/login'); + await ownerPage.fill('input[type="email"]', ownerEmail); + await ownerPage.fill('input[type="password"]', ownerPassword); + await ownerPage.click('button[type="submit"]'); + } + + // Onboarding + await ownerPage.waitForURL('**/onboarding'); + await ownerPage.fill('input[name="name"]', 'Test E2E Organization'); + await ownerPage.click('button[type="submit"]'); + + // Dashboard + await ownerPage.waitForURL('**/dashboard'); + + // Step 2: Go to Team Settings + await ownerPage.goto('/dashboard/settings/team'); + + // Send Invite to be revoked + await ownerPage.fill('input[name="email"]', 'revokeme@example.com'); + await ownerPage.click('button:has-text("Send Invite")'); + + // Verify it appears in pending + await expect(ownerPage.locator(`text=revokeme@example.com`)).toBeVisible(); + + // Step 3: Revoke Invite + await ownerPage.locator('li').filter({ hasText: 'revokeme@example.com' }).locator('button[title="Revoke Invitation"]').click(); + + // Verify it's gone + await expect(ownerPage.locator(`text=revokeme@example.com`)).not.toBeVisible(); + + // Step 4: Create real invite + await ownerPage.fill('input[name="email"]', inviteeEmail); + await ownerPage.click('button:has-text("Send Invite")'); + await expect(ownerPage.locator(`text=${inviteeEmail}`)).toBeVisible(); + + // Fetch the token directly from database + let token = ''; + + // Wait to ensure database has processed the insert + await ownerPage.waitForTimeout(2000); + + const { data: invitations } = await supabase + .from('invitations') + .select('token') + .eq('email', inviteeEmail) + .eq('status', 'pending'); + + token = invitations?.[0]?.token; + expect(token).toBeTruthy(); + + // Step 5: Invitee flow + const inviteeContext = await browser.newContext(); + const inviteePage = await inviteeContext.newPage(); + + // Access the invite link + await inviteePage.goto(`/api/invite/accept?token=${token}`); + + // Should be redirected to sign up (if not logged in) + await inviteePage.waitForURL('**/auth/sign-up**'); + + // Verify email is pre-filled and locked + const emailInput = inviteePage.locator('input[type="email"]'); + await expect(emailInput).toHaveValue(inviteeEmail); + await expect(emailInput).toHaveAttribute('readOnly', ''); + + // Sign up invitee + await inviteePage.fill('input[id="password"]', inviteePassword); + await inviteePage.fill('input[id="repeat-password"]', inviteePassword); + await inviteePage.click('button[type="submit"]'); + + // NEW: Auto-confirm the invitee email using Service Role Key + await inviteePage.waitForTimeout(1000); + const { data: { users: inviteeUsers } } = await supabase.auth.admin.listUsers(); + const inviteeUser = inviteeUsers.find(u => u.email === inviteeEmail); + if (inviteeUser) { + await supabase.auth.admin.updateUserById(inviteeUser.id, { email_confirm: true }); + } + + // Login if needed + if (inviteePage.url().includes('sign-up-success')) { + await inviteePage.goto('/auth/login'); + await inviteePage.fill('input[type="email"]', inviteeEmail); + await inviteePage.fill('input[type="password"]', inviteePassword); + await inviteePage.click('button[type="submit"]'); + } + + // Should automatically skip onboarding and land in dashboard + await inviteePage.waitForURL('**/dashboard'); + + // Step 6: Verify Invitee is in Team Settings + await inviteePage.goto('/dashboard/settings/team'); + + // Invitee should see themselves in Members list (and the owner) + await expect(inviteePage.locator('h2:has-text("Members")')).toBeVisible(); + await expect(inviteePage.locator(`li`, { hasText: inviteeEmail })).toBeVisible(); + await expect(inviteePage.locator(`li`, { hasText: ownerEmail })).toBeVisible(); + }); +}); diff --git a/lib/supabase/proxy.ts b/lib/supabase/proxy.ts index c705a81..7252670 100644 --- a/lib/supabase/proxy.ts +++ b/lib/supabase/proxy.ts @@ -7,14 +7,10 @@ export async function updateSession(request: NextRequest) { request, }); - // If the env vars are not set, skip proxy check. You can remove this - // once you setup the project. if (!hasEnvVars) { return supabaseResponse; } - // With Fluid compute, don't put this client in a global environment - // variable. Always create a new one on each request. const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, @@ -38,39 +34,52 @@ export async function updateSession(request: NextRequest) { }, ); - // Do not run code between createServerClient and - // supabase.auth.getClaims(). A simple mistake could make it very hard to debug - // issues with users being randomly logged out. - - // IMPORTANT: If you remove getClaims() and you use server-side rendering - // with the Supabase client, your users may be randomly logged out. const { data } = await supabase.auth.getClaims(); const user = data?.claims; - if ( - request.nextUrl.pathname !== "/" && - !user && - !request.nextUrl.pathname.startsWith("/login") && - !request.nextUrl.pathname.startsWith("/auth") - ) { - // no user, potentially respond by redirecting the user to the login page + // Paths that do not require auth OR do not enforce organization check + const isAuthPath = request.nextUrl.pathname.startsWith("/auth") || request.nextUrl.pathname.startsWith("/login"); + const isApiPath = request.nextUrl.pathname.startsWith("/api"); + // The sign up callback or standard paths like favicon Next.js ignores, but just in case + const isPublicResource = request.nextUrl.pathname.startsWith("/_next") || request.nextUrl.pathname.includes("."); + + if (isPublicResource) { + return supabaseResponse; + } + + if (!user && !isAuthPath && !isApiPath) { + // potentially respond by redirecting the user to the login page const url = request.nextUrl.clone(); url.pathname = "/auth/login"; return NextResponse.redirect(url); } - // IMPORTANT: You *must* return the supabaseResponse object as it is. - // If you're creating a new response object with NextResponse.next() make sure to: - // 1. Pass the request in it, like so: - // const myNewResponse = NextResponse.next({ request }) - // 2. Copy over the cookies, like so: - // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) - // 3. Change the myNewResponse object to fit your needs, but avoid changing - // the cookies! - // 4. Finally: - // return myNewResponse - // If this is not done, you may be causing the browser and server to go out - // of sync and terminate the user's session prematurely! + if (user && !isAuthPath && !isApiPath) { + // Logged in, check if they are going to an app route + // Fetch profile to see if they have an organization + const { data: profile } = await supabase + .from('profiles') + .select('organization_id') + .eq('id', user.sub) + .single(); + + const hasOrg = !!profile?.organization_id; + const isOnboarding = request.nextUrl.pathname.startsWith("/onboarding"); + + if (!hasOrg && !isOnboarding) { + // Force user to onboarding if they have no organization + const url = request.nextUrl.clone(); + url.pathname = "/onboarding"; + return NextResponse.redirect(url); + } + + if (hasOrg && isOnboarding) { + // If they already have an organization, they shouldn't be in onboarding + const url = request.nextUrl.clone(); + url.pathname = "/dashboard"; + return NextResponse.redirect(url); + } + } return supabaseResponse; } diff --git a/package-lock.json b/package-lock.json index 96c7e39..681ad8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,10 +22,12 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.59.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.4.20", + "dotenv": "^17.4.0", "eslint": "^9", "eslint-config-next": "15.3.1", "postcss": "^8", @@ -1024,6 +1026,22 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3319,6 +3337,19 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", + "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5637,6 +5668,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index d5b1804..bc85ae3 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,12 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.59.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "autoprefixer": "^10.4.20", + "dotenv": "^17.4.0", "eslint": "^9", "eslint-config-next": "15.3.1", "postcss": "^8", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5760f4b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +require('dotenv').config({ path: path.resolve(__dirname, '.env.local') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +});