diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 2818994..1a53226 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; +import { Suspense, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; import { Button } from "@/components/ui/button"; @@ -14,9 +14,28 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { MessageSquare } from "lucide-react"; +import { MessageSquare, UsersRound } from "lucide-react"; +// `useSearchParams` opts the component out of static prerendering +// unless it sits under a Suspense boundary. We split the form into +// a child component so the outer page can prerender the chrome +// (background, card frame) while the form hydrates with the query +// string on the client. export default function LoginPage() { + return ( + + + + ); +} + +function LoginPageInner() { + const searchParams = useSearchParams(); + // Forwarded from `/join/` when the visitor already has an + // account. After a successful sign-in we send them to the join + // page to accept rather than to /dashboard. + const inviteToken = searchParams.get("invite"); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); @@ -40,7 +59,11 @@ export default function LoginPage() { return; } - router.push("/dashboard"); + if (inviteToken) { + router.push(`/join/${encodeURIComponent(inviteToken)}`); + } else { + router.push("/dashboard"); + } }; return ( @@ -48,11 +71,19 @@ export default function LoginPage() {
- + {inviteToken ? ( + + ) : ( + + )}
- Welcome back + + {inviteToken ? "Sign in to accept" : "Welcome back"} + - Sign in to your account + {inviteToken + ? "Sign in and we'll take you to the invitation." + : "Sign in to your account"}
@@ -113,7 +144,11 @@ export default function LoginPage() {

Don't have an account?{" "} Create account diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 8683d47..c431fe9 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { Suspense, useState } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -13,9 +14,27 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { MessageSquare, CheckCircle } from "lucide-react"; +import { MessageSquare, CheckCircle, UsersRound } from "lucide-react"; +// `useSearchParams` opts the component out of static prerendering +// unless wrapped in Suspense — same pattern as /login. export default function SignupPage() { + return ( + + + + ); +} + +function SignupPageInner() { + const searchParams = useSearchParams(); + // When the user lands here from `/join/` we carry the + // invite token in the query so it survives the signup → email + // verification → redirect round-trip. `emailRedirectTo` below + // points back at /join/ so the user lands on the redeem + // step after verifying instead of being dropped on /dashboard. + const inviteToken = searchParams.get("invite"); + const [fullName, setFullName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -41,6 +60,14 @@ export default function SignupPage() { setLoading(true); + // If we have an invite token, point Supabase's verification + // email back at the join page so the user can accept after + // verifying. Without a token, Supabase uses its default + // redirect (the app root). + const emailRedirectTo = inviteToken + ? `${window.location.origin}/join/${encodeURIComponent(inviteToken)}` + : undefined; + const { error } = await supabase.auth.signUp({ email, password, @@ -48,6 +75,7 @@ export default function SignupPage() { data: { full_name: fullName, }, + ...(emailRedirectTo ? { emailRedirectTo } : {}), }, }); @@ -79,7 +107,13 @@ export default function SignupPage() { - + + + + + + + + ); + } + + // ----- Peek OK ----- + const inviteHeader = ( + +

+ +
+ + You're invited to{' '} + {peek.account_name} + + + You'll join as{' '} + + + {ROLE_LABEL[peek.role]} + + . Link valid until{' '} + {new Date(peek.expires_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} + . + + + ); + + // ----- Authed: show Accept button ----- + if (authedUserId) { + return ( + + {inviteHeader} + + +

+ Accepting moves your login into{' '} + {peek.account_name}. Your + empty personal account from signup will be cleaned up. +

+
+
+ ); + } + + // ----- Not authed: prompt to sign up or sign in ----- + return ( + + {inviteHeader} + + + + + + + + + + ); +} diff --git a/src/app/join/layout.tsx b/src/app/join/layout.tsx new file mode 100644 index 0000000..24b9660 --- /dev/null +++ b/src/app/join/layout.tsx @@ -0,0 +1,26 @@ +// ============================================================ +// /join/[token] layout — minimal full-bleed dark shell. +// +// The route group sits outside both `(auth)` and `(dashboard)` +// because it's hybrid: the page must render for anonymous +// visitors (to show "Sign up to join Acme") *and* for signed-in +// users (to show "Accept invite"). Reusing `(auth)`'s layout +// would funnel signed-in users through the middleware's auth- +// page redirect; reusing `(dashboard)` would funnel anonymous +// visitors through its login redirect. A dedicated layout +// avoids both. +// +// Styling matches the login / signup pages — centered card on a +// slate-950 background — so the join experience feels like a +// natural step in the auth funnel rather than a foreign page. +// ============================================================ + +import type { ReactNode } from 'react'; + +export default function JoinLayout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/middleware.ts b/src/middleware.ts index 2d49875..c8056d3 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -25,14 +25,30 @@ export async function middleware(request: NextRequest) { const { data: { user } } = await supabase.auth.getUser() - // Auth pages - redirect to dashboard if already logged in + // Auth pages - redirect to dashboard if already logged in. + // Exception: when an invite token is in the query string we + // send the already-signed-in user to /join/ instead so + // they can accept the invitation in one click. Without this, + // a forwarded invite link to someone who's already signed in + // would silently drop them on /dashboard. if (user && ( request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/signup' || request.nextUrl.pathname === '/forgot-password' )) { const url = request.nextUrl.clone() - url.pathname = '/dashboard' + const inviteToken = request.nextUrl.searchParams.get('invite') + if ( + inviteToken && + (request.nextUrl.pathname === '/login' || + request.nextUrl.pathname === '/signup') + ) { + url.pathname = `/join/${encodeURIComponent(inviteToken)}` + url.search = '' + } else { + url.pathname = '/dashboard' + url.search = '' + } return NextResponse.redirect(url) }