From be4bd5f41d40eaa01c795c9a08c1a836900d2c55 Mon Sep 17 00:00:00 2001 From: tanweer919 Date: Mon, 13 Jul 2026 20:25:25 +0530 Subject: [PATCH] fix(invite): look up the invitation and route correctly (register vs sign in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invite link is token-only, so the landing page couldn't tell the invited email or whether an account existed — it dumped everyone on an empty register screen. Now it calls a public lookup (/api/invite/lookup → be-crm) and: - first-time invitee → register with the email prefilled + locked; - email already registered → sign in with the email prefilled; - signed-in + registered → accept immediately; signed-in + no profile → onboarding. Login now redeems the pending invite after sign-in (password/OTP/OAuth), so an existing user's invite is accepted on login (not only when already logged in). --- src/app/api/invite/lookup/route.ts | 28 ++++++++ src/app/portal/invite/page.tsx | 86 ++++++++++++++++--------- src/components/portal/login-flow.tsx | 39 ++++++++--- src/components/portal/register-flow.tsx | 21 ++++-- 4 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 src/app/api/invite/lookup/route.ts diff --git a/src/app/api/invite/lookup/route.ts b/src/app/api/invite/lookup/route.ts new file mode 100644 index 0000000..612b57e --- /dev/null +++ b/src/app/api/invite/lookup/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; + +// Server-side proxy to be-crm's public invitation lookup, so the invite landing page +// can learn the invited email + roles and whether an account exists — before the +// invitee has a session. Server-to-server avoids CORS. No secrets involved (the token +// is the only credential, and it was emailed to the invitee). + +export const runtime = "nodejs"; + +const CRM_BASE_URL = (process.env.CRM_BASE_URL ?? "https://crm.lynkedup.cloud").replace(/\/$/, ""); + +export async function GET(request: Request) { + const token = new URL(request.url).searchParams.get("token") ?? ""; + if (token.length < 16 || token.length > 256) { + return NextResponse.json({ ok: false, status: "invalid" }, { status: 400 }); + } + try { + const res = await fetch(`${CRM_BASE_URL}/public/invitations/lookup?token=${encodeURIComponent(token)}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 }); + const data = await res.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json({ ok: false, status: "unavailable" }, { status: 502 }); + } +} diff --git a/src/app/portal/invite/page.tsx b/src/app/portal/invite/page.tsx index f011fa6..dfb3022 100644 --- a/src/app/portal/invite/page.tsx +++ b/src/app/portal/invite/page.tsx @@ -1,60 +1,82 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react"; import { PortalAside, PanelBrand } from "@/components/portal/parts"; import { CookieBanner, Spinner } from "@/components/portal/bits"; import { isShellConfigured } from "@/lib/appshell"; +interface InviteInfo { ok: boolean; status?: string; email?: string; roleNames?: string[]; hasAccount?: boolean } + /** - * Team invitation landing page (/portal/invite?token=…). Redeems the invite: - * - not signed in → send to registration; it redeems the stashed token on finish. - * - signed in but no CRM profile → send to onboarding; it redeems on finish. - * - signed in + registered → accept now (creates the membership with the invited role). - * The token (a 128-hex secret emailed to the invitee) is the authorization. + * Team invitation landing page (/portal/invite?token=…). It looks the token up + * (public, pre-auth) to learn the invited email and whether an account exists, then: + * - signed in + registered → accept now (creates the membership with the invited role); + * - signed in, no CRM profile → onboarding, which redeems the token on finish; + * - not signed in + email already has an account → sign in (email prefilled); + * - not signed in + first-time invitee → register (email prefilled + locked). + * The register/login/onboarding flows redeem the stashed token on completion. */ export default function InvitePage() { const router = useRouter(); const { status, getUserEmail } = useAuth(); const { ready, sdk } = useAppShell(); const [error, setError] = useState(""); + const started = useRef(false); useEffect(() => { let token = ""; try { token = new URLSearchParams(window.location.search).get("token") ?? ""; } catch { /* ignore */ } if (!token) { setError("This invitation link is invalid or incomplete."); return; } - try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } + if (!isShellConfigured()) { try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } router.replace("/portal/register"); return; } + if (!ready || started.current) return; + started.current = true; - // No Shell (local/mock) → just go register. - if (!isShellConfigured()) { router.replace("/portal/register"); return; } - if (!ready) return; - - let cancelled = false; (async () => { - if (status === "unauthenticated") { - router.replace("/portal/register"); // sign up first; finish() redeems the token + try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } + + // Look the invitation up (pre-auth) to get the email + account state. + let info: InviteInfo = { ok: false }; + try { + const res = await fetch(`/api/invite/lookup?token=${encodeURIComponent(token)}`, { cache: "no-store" }); + info = await res.json(); + } catch { /* treat as unavailable below */ } + + if (!info.ok) { + const msg = info.status === "expired" ? "This invitation has expired. Ask for a new one." + : info.status === "accepted" ? "This invitation has already been used." + : info.status === "revoked" ? "This invitation was revoked." + : "This invitation link is invalid."; + setError(msg); + try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ } return; } - // Authenticated: registered users accept immediately; profile-less users onboard. - try { - const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus"); - if (!st?.registered) { - try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ } - router.replace("/portal/onboarding"); - return; - } - } catch { /* fall through to accept */ } + if (info.email) { try { sessionStorage.setItem("invite_email", info.email); } catch { /* ignore */ } } - try { - await sdk.command("crm.team.invitation.accept", { token }); - try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ } - router.replace("/dashboard"); - } catch { - if (!cancelled) setError("We couldn't accept this invitation — it may have expired or already been used."); + // Signed in already: accept if registered, else finish onboarding first. + if (status === "authenticated") { + try { + const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus"); + if (!st?.registered) { + try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ } + router.replace("/portal/onboarding"); + return; + } + } catch { /* fall through to accept */ } + try { + await sdk.command("crm.team.invitation.accept", { token }); + try { sessionStorage.removeItem("invite_token"); sessionStorage.removeItem("invite_email"); } catch { /* ignore */ } + router.replace("/dashboard"); + } catch { + setError("We couldn't accept this invitation — it may have expired or already been used."); + } + return; } + + // Not signed in: existing account → sign in; first-time invitee → register. + router.replace(info.hasAccount ? "/portal/login" : "/portal/register"); })(); - return () => { cancelled = true; }; }, [ready, status, router, sdk, getUserEmail]); return ( @@ -77,8 +99,8 @@ export default function InvitePage() { ) : (
-

Accepting your invitation…

-

Setting up your team access.

+

Checking your invitation…

+

One moment while we set things up.

)} diff --git a/src/components/portal/login-flow.tsx b/src/components/portal/login-flow.tsx index b899a25..d037880 100644 --- a/src/components/portal/login-flow.tsx +++ b/src/components/portal/login-flow.tsx @@ -34,6 +34,25 @@ export function LoginFlow() { const [remember, setRemember] = useState(false); const [flash, setFlash] = useState(""); const [otpChannel, setOtpChannel] = useState<"email" | "sms">("email"); + const [invited, setInvited] = useState(false); + + // Arriving from a team invite for an already-registered email → prefill it. + useEffect(() => { + try { const e = sessionStorage.getItem("invite_email"); if (e) { setEmail(e); setInvited(true); } } catch { /* ignore */ } + }, []); + + // After a successful sign-in, redeem a pending team invite (if any), then go to the dashboard. + async function acceptPendingInviteThenDashboard() { + try { + const t = sessionStorage.getItem("invite_token"); + if (t) { + await sdk.command("crm.team.invitation.accept", { token: t }); + sessionStorage.removeItem("invite_token"); + sessionStorage.removeItem("invite_email"); + } + } catch { /* invite expired/used — proceed to the dashboard anyway */ } + router.replace("/dashboard"); + } // Minimal account stand-in for passwordless entry points (Shell mode). function blankAccount(): Account { @@ -87,7 +106,7 @@ export function LoginFlow() { registered = !!st?.registered; } catch { registered = false; } window.history.replaceState({}, "", "/portal/login"); - if (registered) { router.replace("/dashboard"); return; } + if (registered) { await acceptPendingInviteThenDashboard(); return; } // Capture the verified email now (session is fresh) so onboarding prefills it. try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ } router.replace("/portal/onboarding"); @@ -154,7 +173,7 @@ export function LoginFlow() { {step === "identify" && ( <> {flash &&
{flash}
} - router.push("/portal/register")} /> + router.push("/portal/register")} invited={invited} /> )} @@ -201,11 +220,11 @@ export function LoginFlow() { )} {step === "password" && account && ( - router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} /> + push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} /> )} {step === "otp" && account && ( - afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} /> + afterAuth("otp")} onAuthenticated={acceptPendingInviteThenDashboard} /> )} {step === "another" && account && ( @@ -269,17 +288,17 @@ export function LoginFlow() { /* ============================ screens ============================ */ -function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: { +function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister, invited }: { email: string; setEmail: (v: string) => void; - onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void; + onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void; invited?: boolean; }) { - const [showEmail, setShowEmail] = useState(false); + const [showEmail, setShowEmail] = useState(!!invited); const valid = EMAIL_RE.test(email); return (
-
Welcome back
-

Sign in to LynkedUp

-

Drone inspections, AI estimates and insurance-ready reports — all in one place.

+
{invited ? "You're invited" : "Welcome back"}
+

{invited ? "Sign in to join the team" : "Sign in to LynkedUp"}

+

{invited ? "You already have an account — sign in to accept your invitation." : "Drone inspections, AI estimates and insurance-ready reports — all in one place."}

diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx index 095a6a7..e50efc3 100644 --- a/src/components/portal/register-flow.tsx +++ b/src/components/portal/register-flow.tsx @@ -53,6 +53,13 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa // verify step (email is trusted without an OTP — see verifyValid below) const [phoneVerified, setPhoneVerified] = useState(false); + // When arriving from a team invite, the email is fixed to the invited address. + const [invitedEmail, setInvitedEmail] = useState(""); + + useEffect(() => { + if (onboard) return; + try { const e = sessionStorage.getItem("invite_email"); if (e) { setEmail(e); setInvitedEmail(e); } } catch { /* ignore */ } + }, [onboard]); // Onboarding: prefill the verified email — from the session-storage hint the login // page stashed at OAuth time, then confirmed via the live Supabase session. @@ -138,6 +145,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa if (inviteToken) { await sdk.command("crm.team.invitation.accept", { token: inviteToken }); sessionStorage.removeItem("invite_token"); + sessionStorage.removeItem("invite_email"); } } catch { /* invitation expired/used — they can still be invited again */ } } @@ -154,7 +162,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa <> {submitErr &&
{submitErr}
} void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number]; pw: string; setPw: (v: string) => void; termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void; - valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; + valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; invited?: boolean; }) { // Onboarding (OAuth) starts on the profile step — email is already known + verified. const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso"); @@ -203,21 +211,22 @@ function StepAccount(p: { return (
-

Create your account

-

Enter your email to get started.

+

{p.invited ? "Accept your invitation" : "Create your account"}

+

{p.invited ? "You've been invited to the team. Set up your account to join." : "Enter your email to get started."}

{ e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
- p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus /> + p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!p.invited} disabled={p.invited} readOnly={p.invited} />
+ {p.invited && This is the address you were invited with.}
-

Already registered?

+ {!p.invited &&

Already registered?

}
); }