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) => (
+ -
+
+
+ ))}
+
+
+
+
+ {profile.role === "owner" && (
+
+
Invite New Member
+
+
+
+
+ )}
+
+ {profile.role === "owner" && invitations && invitations.length > 0 && (
+
+
Pending Invitations
+
+
+ {invitations.map((inv) => (
+ -
+
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+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,
+ },
+});