From 47104042fddccf08667dd10c6f325f3d687f2407 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 18:30:32 +0300 Subject: [PATCH] feat(join): /join/[token] page + signup/login invite redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 7 of the multi-user accounts series. Closes the invite loop — PR 6 lets admins generate links, this PR is what the recipient sees when they click one. What lands - `src/app/join/layout.tsx` — minimal centered dark shell. Sits outside both `(auth)` and `(dashboard)` route groups because the page is hybrid: it renders for anonymous AND signed-in visitors. Reusing either neighbouring layout would funnel one audience through the wrong middleware redirect. - `src/app/join/[token]/page.tsx` — client component. Calls /api/invitations/[token]/peek + supabase.auth.getUser() in parallel on mount and routes through four UI states: peek | auth | render --------------|--------------|---------------------------------- loading | — | spinner ok:false (any)| — | friendly error + signup/signin CTAs ok:true | signed out | "Sign up & join" + "Sign in" links ok:true | signed in | "Accept invitation" button → redeem Redeem is intentionally manual — auto-accepting on mount would race with the signup → email-verification → return flow and hide what's being accepted from the visitor. AuthContext doesn't reach this route group (AuthProvider lives inside (dashboard)), so we call Supabase directly here the same way /login and /signup already do. After a successful accept, the page does a full reload to /dashboard (not router.push) so AuthProvider re-fetches the profile with the new account_id and account_role. - `src/app/(auth)/signup/page.tsx`: - Reads `?invite=` via useSearchParams. - Passes emailRedirectTo: `${origin}/join/` to supabase.auth.signUp so the verification email returns the user to the redeem step. - Re-skins the card header for invite signups ("Create account & join", different icon). - Preserves the token through the "Sign in" link and the "Back to sign in" button on the success screen. - Default export now wraps the form in `` — Next 16 refuses to prerender pages calling useSearchParams without it. - `src/app/(auth)/login/page.tsx`: - Reads `?invite=`. - On successful sign-in, routes to /join/ instead of /dashboard. - Re-skins the card header for invite logins. - Preserves the token through the "Create account" link. - Default export wrapped in `` for the same prerender reason. - `src/middleware.ts`: - When an authenticated user hits /login?invite= or /signup?invite=, redirect to /join/ instead of the default /dashboard. Without this, a forwarded invite link landing on an already-signed-in browser would silently drop on /dashboard and the visitor would never see the accept prompt. - Both pre-existing redirect cases (no invite present, OR /forgot-password with an invite — which doesn't make sense) fall through to /dashboard as before. The search string is cleared on both paths so we don't leak the invite token into the dashboard URL bar. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass - `npm run build` → succeeds: ƒ /join/[token] — dynamic (peek + auth fetch) ○ /login — static, hydrates the form ○ /signup — static, hydrates the form Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(auth)/login/page.tsx | 51 +++++- src/app/(auth)/signup/page.tsx | 60 +++++++- src/app/join/[token]/page.tsx | 274 +++++++++++++++++++++++++++++++++ src/app/join/layout.tsx | 26 ++++ src/middleware.ts | 20 ++- 5 files changed, 414 insertions(+), 17 deletions(-) create mode 100644 src/app/join/[token]/page.tsx create mode 100644 src/app/join/layout.tsx 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) }