feature/team-invitations #1
32 changed files with 1472 additions and 189 deletions
|
|
@ -2,3 +2,12 @@
|
|||
# https://app.supabase.com/project/_/settings/api
|
||||
NEXT_PUBLIC_SUPABASE_URL=your-project-url
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# Your public app URL (used for Unipile webhook + redirect URLs)
|
||||
NEXT_PUBLIC_APP_URL=https://yourapp.com
|
||||
|
||||
# Unipile LinkedIn Integration
|
||||
# Get these from https://app.unipile.com
|
||||
UNIPILE_API_KEY=your-unipile-api-key
|
||||
UNIPILE_DSN=api4.unipile.com:13441
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,6 +12,10 @@
|
|||
|
||||
# testing
|
||||
/coverage
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
|
|
|||
67
app/api/invite/accept/route.ts
Normal file
67
app/api/invite/accept/route.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
46
app/api/linkedin/callback/route.ts
Normal file
46
app/api/linkedin/callback/route.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// This endpoint is called by Unipile (unauthenticated), so we use the service-role client.
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Unipile sends: { account_id, name, status, ... }
|
||||
const { account_id, name: userId, status } = body as {
|
||||
account_id?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
if (!account_id || !userId) {
|
||||
console.error("Unipile callback missing account_id or name:", body);
|
||||
return NextResponse.json(
|
||||
{ error: "Missing account_id or user identifier" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
);
|
||||
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
unipile_account_id: account_id,
|
||||
unipile_account_status: status ?? "CONNECTED",
|
||||
})
|
||||
.eq("id", userId);
|
||||
|
||||
if (error) {
|
||||
console.error("Error saving Unipile account_id:", error);
|
||||
return NextResponse.json({ error: "DB update failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
67
app/api/linkedin/connect/route.ts
Normal file
67
app/api/linkedin/connect/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function POST() {
|
||||
const supabase = await createClient();
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
|
||||
if (userError || !userData.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.UNIPILE_API_KEY;
|
||||
const dsn = process.env.UNIPILE_DSN;
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||
|
||||
if (!apiKey || !dsn || !appUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unipile is not configured. Please add UNIPILE_API_KEY, UNIPILE_DSN, and NEXT_PUBLIC_APP_URL to your environment." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already has an account (reconnect flow)
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("unipile_account_id")
|
||||
.eq("id", userData.user.id)
|
||||
.single();
|
||||
|
||||
const type = profile?.unipile_account_id ? "reconnect" : "create";
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
type,
|
||||
providers: ["LINKEDIN"],
|
||||
notify_url: `${appUrl}/api/linkedin/callback`,
|
||||
success_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=connected`,
|
||||
failure_redirect_url: `${appUrl}/dashboard/settings/account?linkedin=failed`,
|
||||
// 'name' is returned as-is in the webhook payload, used to map account_id → user
|
||||
name: userData.user.id,
|
||||
};
|
||||
|
||||
// For reconnect we need the existing account_id
|
||||
if (type === "reconnect" && profile?.unipile_account_id) {
|
||||
body.account_id = profile.unipile_account_id;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://${dsn}/api/v1/hosted/accounts/link`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-KEY": apiKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error("Unipile connect error:", text);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate LinkedIn connection link" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json({ url: data.url });
|
||||
}
|
||||
58
app/api/linkedin/disconnect/route.ts
Normal file
58
app/api/linkedin/disconnect/route.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function DELETE() {
|
||||
const supabase = await createClient();
|
||||
const { data: userData, error: userError } = await supabase.auth.getUser();
|
||||
|
||||
if (userError || !userData.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("unipile_account_id")
|
||||
.eq("id", userData.user.id)
|
||||
.single();
|
||||
|
||||
if (!profile?.unipile_account_id) {
|
||||
return NextResponse.json({ error: "No LinkedIn account connected" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.UNIPILE_API_KEY;
|
||||
const dsn = process.env.UNIPILE_DSN;
|
||||
|
||||
// Try to remove from Unipile (best-effort; don't block DB cleanup if it fails)
|
||||
if (apiKey && dsn) {
|
||||
const unipileRes = await fetch(
|
||||
`https://${dsn}/api/v1/accounts/${profile.unipile_account_id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "X-API-KEY": apiKey },
|
||||
}
|
||||
);
|
||||
if (!unipileRes.ok) {
|
||||
console.warn(
|
||||
"Unipile delete returned non-OK:",
|
||||
unipileRes.status,
|
||||
await unipileRes.text()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Always clear from our DB
|
||||
const { error: dbError } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
unipile_account_id: null,
|
||||
unipile_account_status: null,
|
||||
})
|
||||
.eq("id", userData.user.id);
|
||||
|
||||
if (dbError) {
|
||||
console.error("Error clearing Unipile account from profile:", dbError);
|
||||
return NextResponse.json({ error: "DB update failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<SignUpForm />
|
||||
<SignUpForm initialEmail={email} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
13
app/dashboard/layout.tsx
Normal file
13
app/dashboard/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Sidebar } from "@/components/sidebar";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto w-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/dashboard/loading.tsx
Normal file
21
app/dashboard/loading.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-64 mb-2" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm p-4 space-y-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/dashboard/page.tsx
Normal file
8
app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export default function DashboardOverview() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-semibold mb-4 text-foreground">Overview</h1>
|
||||
<p className="text-muted-foreground">Welcome to your dashboard. This is your organization's home.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
app/dashboard/settings/account/page.tsx
Normal file
120
app/dashboard/settings/account/page.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { Suspense } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CheckCircle2, XCircle, Linkedin } from "lucide-react";
|
||||
import { ConnectLinkedInButton } from "@/components/connect-linkedin-button";
|
||||
import { DisconnectLinkedInButton } from "@/components/disconnect-linkedin-button";
|
||||
|
||||
async function AccountSettingsContent() {
|
||||
const supabase = await createClient();
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const userId = userData?.user?.id;
|
||||
|
||||
if (!userId) return null;
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("unipile_account_id, unipile_account_status")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
const isConnected = !!profile?.unipile_account_id;
|
||||
const accountId = profile?.unipile_account_id as string | null;
|
||||
const status = profile?.unipile_account_status as string | null;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-1 text-foreground">Account Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your personal account and integrations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* LinkedIn Integration */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2 flex items-center gap-2">
|
||||
<Linkedin className="h-5 w-5 text-[#0077B5]" />
|
||||
LinkedIn Integration
|
||||
</h2>
|
||||
|
||||
<div className="bg-card border border-border rounded-lg p-6 shadow-sm space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
LinkedIn Account
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect your LinkedIn profile to enable automated outreach campaigns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isConnected ? (
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2.5 py-1 rounded-full shrink-0">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground bg-muted px-2.5 py-1 rounded-full shrink-0">
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Not connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className="bg-muted/50 rounded-md p-3 text-xs font-mono text-muted-foreground break-all">
|
||||
<span className="text-foreground/60 mr-2">Account ID:</span>
|
||||
{accountId}
|
||||
{status && status !== "CONNECTED" && (
|
||||
<span className="ml-2 text-amber-500">({status})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
{isConnected ? (
|
||||
<DisconnectLinkedInButton />
|
||||
) : (
|
||||
<ConnectLinkedInButton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountSettingsSkeleton() {
|
||||
return (
|
||||
<div className="p-8 max-w-2xl mx-auto space-y-8">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="border-b border-border pb-2">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-6 shadow-sm space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AccountSettingsSkeleton />}>
|
||||
<AccountSettingsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
81
app/dashboard/settings/team/actions.ts
Normal file
81
app/dashboard/settings/team/actions.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
156
app/dashboard/settings/team/page.tsx
Normal file
156
app/dashboard/settings/team/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="p-8 max-w-4xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold mb-1 text-foreground">Team Management</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage your organization's members and invitations.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2">Members</h2>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
|
||||
<ul className="divide-y divide-border">
|
||||
{members?.map((m) => (
|
||||
<li key={m.id} className="p-4 flex items-center justify-between hover:bg-muted/50 transition-colors">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{m.email}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{m.role}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.role === "owner" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2">Invite New Member</h2>
|
||||
<div className="bg-card border border-border rounded-lg p-4 shadow-sm">
|
||||
<form action={inviteUser} className="flex gap-4">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email address"
|
||||
required
|
||||
className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-primary text-primary-foreground px-4 py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors flex-shrink-0"
|
||||
>
|
||||
Send Invite
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.role === "owner" && invitations && invitations.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium text-foreground border-b border-border pb-2">Pending Invitations</h2>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
|
||||
<ul className="divide-y divide-border">
|
||||
{invitations.map((inv) => (
|
||||
<li key={inv.id} className="p-4 flex items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="truncate flex-1">
|
||||
<div className="text-sm font-medium">{inv.email}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">Pending</div>
|
||||
<form action={revokeInvitation}>
|
||||
<input type="hidden" name="id" value={inv.id} />
|
||||
<button type="submit" className="text-muted-foreground hover:text-destructive transition-colors p-1" title="Revoke Invitation">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamSettingsSkeleton() {
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto space-y-8">
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-b border-border pb-2">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-b border-border pb-2">
|
||||
<Skeleton className="h-6 w-36" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-4 shadow-sm flex gap-4">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
<Skeleton className="h-10 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamSettingsPage() {
|
||||
return (
|
||||
<Suspense fallback={<TeamSettingsSkeleton />}>
|
||||
<TeamSettingsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
46
app/onboarding/actions.ts
Normal file
46
app/onboarding/actions.ts
Normal file
|
|
@ -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 LinkedIn connect step
|
||||
redirect("/onboarding/linkedin");
|
||||
}
|
||||
34
app/onboarding/linkedin/page.tsx
Normal file
34
app/onboarding/linkedin/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { createClient } from "@/lib/supabase/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { OnboardingLinkedInStep } from "@/components/onboarding-linkedin-step";
|
||||
|
||||
async function LinkedInOnboardingGuard() {
|
||||
const supabase = await createClient();
|
||||
const { data } = await supabase.auth.getUser();
|
||||
|
||||
if (!data?.user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
// Must have completed org step first
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id")
|
||||
.eq("id", data.user.id)
|
||||
.single();
|
||||
|
||||
if (!profile?.organization_id) {
|
||||
redirect("/onboarding");
|
||||
}
|
||||
|
||||
return <OnboardingLinkedInStep />;
|
||||
}
|
||||
|
||||
export default function LinkedInOnboardingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">Loading…</div>}>
|
||||
<LinkedInOnboardingGuard />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
100
app/onboarding/page.tsx
Normal file
100
app/onboarding/page.tsx
Normal file
|
|
@ -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);
|
||||
// Send invitees through the LinkedIn connect step as well
|
||||
redirect("/onboarding/linkedin");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="w-full max-w-md p-8 bg-card rounded-lg border border-border shadow-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-semibold mb-2">Welcome</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let's get started by creating an organization for your team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={createOrganization} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Organization Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="E.g. Acme Corp"
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-primary text-primary-foreground py-2 rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Create Organization
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Waiting for an invite? Ask your team owner to send you a link.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">Loading...</div>}>
|
||||
<OnboardingContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
57
app/page.tsx
57
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 (
|
||||
<main className="min-h-screen flex flex-col items-center">
|
||||
<div className="flex-1 w-full flex flex-col gap-20 items-center">
|
||||
<nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
|
||||
<div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm">
|
||||
<div className="flex gap-5 items-center font-semibold">
|
||||
<Link href={"/"}>Next.js Supabase Starter</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeployButton />
|
||||
</div>
|
||||
</div>
|
||||
{!hasEnvVars ? (
|
||||
<EnvVarWarning />
|
||||
) : (
|
||||
<Suspense>
|
||||
<AuthButton />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="flex-1 flex flex-col gap-20 max-w-5xl p-5">
|
||||
<Hero />
|
||||
<main className="flex-1 flex flex-col gap-6 px-4">
|
||||
<h2 className="font-medium text-xl mb-4">Next steps</h2>
|
||||
{hasEnvVars ? <SignUpUserSteps /> : <ConnectSupabaseSteps />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16">
|
||||
<p>
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs"
|
||||
target="_blank"
|
||||
className="font-bold hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Supabase
|
||||
</a>
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<main className="min-h-screen flex flex-col items-center">
|
||||
<div className="flex-1 w-full flex flex-col gap-20 items-center">
|
||||
<nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
|
||||
<div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm">
|
||||
<div className="flex gap-5 items-center font-semibold">
|
||||
<Link href={"/"}>Next.js Supabase Starter</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeployButton />
|
||||
</div>
|
||||
</div>
|
||||
{!hasEnvVars ? (
|
||||
<EnvVarWarning />
|
||||
) : (
|
||||
<Suspense>
|
||||
<AuthButton />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="flex-1 flex flex-col gap-20 max-w-5xl p-5">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<footer className="w-full flex items-center justify-center border-t mx-auto text-center text-xs gap-8 py-16">
|
||||
<p>
|
||||
Powered by{" "}
|
||||
<a
|
||||
href="https://supabase.com/?utm_source=create-next-app&utm_medium=template&utm_term=nextjs"
|
||||
target="_blank"
|
||||
className="font-bold hover:underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Supabase
|
||||
</a>
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex-1 w-full flex flex-col gap-12">
|
||||
<div className="w-full">
|
||||
<div className="bg-accent text-sm p-3 px-5 rounded-md text-foreground flex gap-3 items-center">
|
||||
<InfoIcon size="16" strokeWidth={2} />
|
||||
This is a protected page that you can only see as an authenticated
|
||||
user
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<h2 className="font-bold text-2xl mb-4">Your user details</h2>
|
||||
<pre className="text-xs font-mono p-3 rounded border max-h-32 overflow-auto">
|
||||
<Suspense>
|
||||
<UserDetails />
|
||||
</Suspense>
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-2xl mb-4">Next steps</h2>
|
||||
<FetchDataSteps />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
components/connect-linkedin-button.tsx
Normal file
45
components/connect-linkedin-button.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Linkedin, Loader2 } from "lucide-react";
|
||||
|
||||
export function ConnectLinkedInButton() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/linkedin/connect", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Something went wrong");
|
||||
return;
|
||||
}
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
setError("Failed to initiate connection. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 bg-[#0077B5] hover:bg-[#005983] text-white px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Linkedin className="h-4 w-4" />
|
||||
)}
|
||||
{loading ? "Redirecting…" : "Connect LinkedIn"}
|
||||
</button>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
components/disconnect-linkedin-button.tsx
Normal file
48
components/disconnect-linkedin-button.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Unlink } from "lucide-react";
|
||||
|
||||
export function DisconnectLinkedInButton() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!confirm("Are you sure you want to disconnect your LinkedIn account?")) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/linkedin/disconnect", { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Something went wrong");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Failed to disconnect. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleDisconnect}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 border border-destructive text-destructive hover:bg-destructive/10 px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Unlink className="h-4 w-4" />
|
||||
)}
|
||||
{loading ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ export function LoginForm({
|
|||
});
|
||||
if (error) throw error;
|
||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||
router.push("/protected");
|
||||
router.push("/dashboard");
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "An error occurred");
|
||||
} finally {
|
||||
|
|
|
|||
80
components/onboarding-linkedin-step.tsx
Normal file
80
components/onboarding-linkedin-step.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Linkedin, Loader2, ArrowRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function OnboardingLinkedInStep() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleConnect = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/linkedin/connect", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Something went wrong. You can connect later in Settings.");
|
||||
return;
|
||||
}
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
setError("Could not connect to LinkedIn. You can try later from Settings.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<div className="w-full max-w-md p-8 bg-card rounded-lg border border-border shadow-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-[#0077B5]/10 rounded-full p-3">
|
||||
<Linkedin className="h-8 w-8 text-[#0077B5]" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-2">Connect LinkedIn</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect your LinkedIn account to start automated outreach campaigns. You
|
||||
can also do this later from your Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={loading}
|
||||
className="w-full inline-flex items-center justify-center gap-2 bg-[#0077B5] hover:bg-[#005983] text-white py-2.5 rounded-md text-sm font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Linkedin className="h-4 w-4" />
|
||||
)}
|
||||
{loading ? "Redirecting to LinkedIn…" : "Connect LinkedIn Account"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={loading}
|
||||
className="w-full inline-flex items-center justify-center gap-2 text-muted-foreground hover:text-foreground text-sm transition-colors py-2"
|
||||
>
|
||||
Skip for now
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 text-xs text-destructive text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
components/sidebar.tsx
Normal file
147
components/sidebar.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"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, Settings } 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<string>("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 (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col h-screen border-r border-border bg-background transition-all duration-300",
|
||||
collapsed ? "w-16" : "w-64"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-border h-16">
|
||||
{!collapsed && <span className="font-semibold text-sm truncate">{organizationName}</span>}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn("p-1 rounded-md hover:bg-muted transition-colors flex-shrink-0", collapsed && "mx-auto")}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-2 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
// Exact match for the root dashboard, prefix match for others to keep them highlighted if nested
|
||||
const isActive = item.href === "/dashboard" ? pathname === "/dashboard" : pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
isActive ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
collapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} className="shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-2 border-t border-border mt-auto">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={cn(
|
||||
"flex items-center gap-3 w-full p-2 rounded-md hover:bg-muted text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-muted",
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
)}>
|
||||
<div className="flex items-center justify-center bg-accent text-accent-foreground rounded-full w-8 h-8 shrink-0 overflow-hidden">
|
||||
<User size={16} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="truncate flex-1 text-left font-medium">
|
||||
{user?.email || "User"}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={collapsed ? "center" : "end"} side="right" sideOffset={8} className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Account</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user?.email || "Unknown email"}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push("/dashboard/settings/account")}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive focus:bg-destructive/10" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<string | null>(null);
|
||||
|
|
@ -44,7 +45,7 @@ export function SignUpForm({
|
|||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/protected`,
|
||||
emailRedirectTo: `${window.location.origin}/dashboard`,
|
||||
},
|
||||
});
|
||||
if (error) throw error;
|
||||
|
|
@ -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" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
|
|||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
|
@ -34,7 +34,7 @@ export function UpdatePasswordForm({
|
|||
const { error } = await supabase.auth.updateUser({ password });
|
||||
if (error) throw error;
|
||||
// Update this route to redirect to an authenticated route. The user already has an active session.
|
||||
router.push("/protected");
|
||||
router.push("/dashboard");
|
||||
} catch (error: unknown) {
|
||||
setError(error instanceof Error ? error.message : "An error occurred");
|
||||
} finally {
|
||||
|
|
|
|||
137
e2e/invitation.spec.ts
Normal file
137
e2e/invitation.spec.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
78
package-lock.json
generated
78
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
38
playwright.config.ts
Normal file
38
playwright.config.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue