diff --git a/.env.example b/.env.example index 91cdb14..18ae6b7 100644 --- a/.env.example +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/app/api/linkedin/callback/route.ts b/app/api/linkedin/callback/route.ts new file mode 100644 index 0000000..48a08ea --- /dev/null +++ b/app/api/linkedin/callback/route.ts @@ -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 }); +} diff --git a/app/api/linkedin/connect/route.ts b/app/api/linkedin/connect/route.ts new file mode 100644 index 0000000..c65e9b4 --- /dev/null +++ b/app/api/linkedin/connect/route.ts @@ -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 = { + 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 }); +} diff --git a/app/api/linkedin/disconnect/route.ts b/app/api/linkedin/disconnect/route.ts new file mode 100644 index 0000000..9cf74c8 --- /dev/null +++ b/app/api/linkedin/disconnect/route.ts @@ -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 }); +} diff --git a/app/dashboard/settings/account/page.tsx b/app/dashboard/settings/account/page.tsx new file mode 100644 index 0000000..0d1bf9c --- /dev/null +++ b/app/dashboard/settings/account/page.tsx @@ -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 ( +
+
+

Account Settings

+

+ Manage your personal account and integrations. +

+
+ + {/* LinkedIn Integration */} +
+

+ + LinkedIn Integration +

+ +
+
+
+

+ LinkedIn Account +

+

+ Connect your LinkedIn profile to enable automated outreach campaigns. +

+
+ + {isConnected ? ( + + + Connected + + ) : ( + + + Not connected + + )} +
+ + {isConnected && ( +
+ Account ID: + {accountId} + {status && status !== "CONNECTED" && ( + ({status}) + )} +
+ )} + +
+ {isConnected ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +function AccountSettingsSkeleton() { + return ( +
+
+ + +
+
+
+ +
+
+
+
+ + +
+ +
+ +
+
+
+ ); +} + +export default function AccountSettingsPage() { + return ( + }> + + + ); +} diff --git a/app/onboarding/actions.ts b/app/onboarding/actions.ts index d640e01..8b3ae8a 100644 --- a/app/onboarding/actions.ts +++ b/app/onboarding/actions.ts @@ -41,6 +41,6 @@ export async function createOrganization(formData: FormData) { throw new Error("Failed to assign user to organization."); } - // Redirect to dashboard - redirect("/dashboard"); + // Redirect to LinkedIn connect step + redirect("/onboarding/linkedin"); } diff --git a/app/onboarding/linkedin/page.tsx b/app/onboarding/linkedin/page.tsx new file mode 100644 index 0000000..671df6b --- /dev/null +++ b/app/onboarding/linkedin/page.tsx @@ -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 ; +} + +export default function LinkedInOnboardingPage() { + return ( + Loading…}> + + + ); +} diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index ceadec2..8d7111f 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -45,8 +45,8 @@ async function OnboardingContent() { 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"); + // Send invitees through the LinkedIn connect step as well + redirect("/onboarding/linkedin"); } } diff --git a/components/connect-linkedin-button.tsx b/components/connect-linkedin-button.tsx new file mode 100644 index 0000000..c95bf3e --- /dev/null +++ b/components/connect-linkedin-button.tsx @@ -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(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 ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/components/disconnect-linkedin-button.tsx b/components/disconnect-linkedin-button.tsx new file mode 100644 index 0000000..f606e0f --- /dev/null +++ b/components/disconnect-linkedin-button.tsx @@ -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(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 ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/components/login-form.tsx b/components/login-form.tsx index 23040a0..a0d65df 100644 --- a/components/login-form.tsx +++ b/components/login-form.tsx @@ -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 { diff --git a/components/onboarding-linkedin-step.tsx b/components/onboarding-linkedin-step.tsx new file mode 100644 index 0000000..9abd76a --- /dev/null +++ b/components/onboarding-linkedin-step.tsx @@ -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(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 ( +
+
+
+
+
+ +
+
+

Connect LinkedIn

+

+ Connect your LinkedIn account to start automated outreach campaigns. You + can also do this later from your Settings. +

+
+ +
+ + + +
+ + {error && ( +

{error}

+ )} +
+
+ ); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 72f6db2..f57a550 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -3,7 +3,7 @@ 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 { LayoutDashboard, Users, ChevronLeft, ChevronRight, User, LogOut, Settings } from "lucide-react"; import { cn } from "@/lib/utils"; import { createClient } from "@/lib/supabase/client"; import { @@ -130,6 +130,11 @@ export const Sidebar: FC = () => { + router.push("/dashboard/settings/account")}> + + Settings + + Log out diff --git a/components/sign-up-form.tsx b/components/sign-up-form.tsx index f558c9c..b51e7de 100644 --- a/components/sign-up-form.tsx +++ b/components/sign-up-form.tsx @@ -45,7 +45,7 @@ export function SignUpForm({ email, password, options: { - emailRedirectTo: `${window.location.origin}/protected`, + emailRedirectTo: `${window.location.origin}/dashboard`, }, }); if (error) throw error; diff --git a/components/update-password-form.tsx b/components/update-password-form.tsx index 821af68..bbc48f2 100644 --- a/components/update-password-form.tsx +++ b/components/update-password-form.tsx @@ -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 {