2 Commits

Author SHA1 Message Date
tanweer919 0643b8c9b2 Merge pull request 'fix(invite): route register vs sign-in + prefill email (public lookup)' (#24) from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 14:55:33 +00:00
tanweer919 be4bd5f41d fix(invite): look up the invitation and route correctly (register vs sign in)
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).
2026-07-13 20:25:25 +05:30
4 changed files with 126 additions and 48 deletions
+28
View File
@@ -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 });
}
}
+54 -32
View File
@@ -1,60 +1,82 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react"; import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts"; import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { CookieBanner, Spinner } from "@/components/portal/bits"; import { CookieBanner, Spinner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell"; 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: * Team invitation landing page (/portal/invite?token=…). It looks the token up
* - not signed in → send to registration; it redeems the stashed token on finish. * (public, pre-auth) to learn the invited email and whether an account exists, then:
* - 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);
* - signed in + registered → accept now (creates the membership with the invited role). * - signed in, no CRM profile → onboarding, which redeems the token on finish;
* The token (a 128-hex secret emailed to the invitee) is the authorization. * - 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() { export default function InvitePage() {
const router = useRouter(); const router = useRouter();
const { status, getUserEmail } = useAuth(); const { status, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell(); const { ready, sdk } = useAppShell();
const [error, setError] = useState(""); const [error, setError] = useState("");
const started = useRef(false);
useEffect(() => { useEffect(() => {
let token = ""; let token = "";
try { token = new URLSearchParams(window.location.search).get("token") ?? ""; } catch { /* ignore */ } try { token = new URLSearchParams(window.location.search).get("token") ?? ""; } catch { /* ignore */ }
if (!token) { setError("This invitation link is invalid or incomplete."); return; } 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 () => { (async () => {
if (status === "unauthenticated") { try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ }
router.replace("/portal/register"); // sign up first; finish() redeems the token
// 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; return;
} }
// Authenticated: registered users accept immediately; profile-less users onboard. if (info.email) { try { sessionStorage.setItem("invite_email", info.email); } catch { /* ignore */ } }
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 { // Signed in already: accept if registered, else finish onboarding first.
await sdk.command("crm.team.invitation.accept", { token }); if (status === "authenticated") {
try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ } try {
router.replace("/dashboard"); const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
} catch { if (!st?.registered) {
if (!cancelled) setError("We couldn't accept this invitation — it may have expired or already been used."); 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]); }, [ready, status, router, sdk, getUserEmail]);
return ( return (
@@ -77,8 +99,8 @@ export default function InvitePage() {
) : ( ) : (
<div className="interstitial"> <div className="interstitial">
<Spinner lg /> <Spinner lg />
<h1 style={{ fontSize: 20, marginTop: 8 }}>Accepting your invitation</h1> <h1 style={{ fontSize: 20, marginTop: 8 }}>Checking your invitation</h1>
<p className="sub">Setting up your team access.</p> <p className="sub">One moment while we set things up.</p>
</div> </div>
)} )}
</div> </div>
+29 -10
View File
@@ -34,6 +34,25 @@ export function LoginFlow() {
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const [flash, setFlash] = useState<string>(""); const [flash, setFlash] = useState<string>("");
const [otpChannel, setOtpChannel] = useState<"email" | "sms">("email"); 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). // Minimal account stand-in for passwordless entry points (Shell mode).
function blankAccount(): Account { function blankAccount(): Account {
@@ -87,7 +106,7 @@ export function LoginFlow() {
registered = !!st?.registered; registered = !!st?.registered;
} catch { registered = false; } } catch { registered = false; }
window.history.replaceState({}, "", "/portal/login"); 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. // 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 */ } try { const em = await getUserEmail(); if (em) sessionStorage.setItem("onboard_email", em); } catch { /* ignore */ }
router.replace("/portal/onboarding"); router.replace("/portal/onboarding");
@@ -154,7 +173,7 @@ export function LoginFlow() {
{step === "identify" && ( {step === "identify" && (
<> <>
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>} {flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} /> <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} invited={invited} />
</> </>
)} )}
@@ -201,11 +220,11 @@ export function LoginFlow() {
)} )}
{step === "password" && account && ( {step === "password" && account && (
<Password account={account} email={email} login={login} onAuthenticated={() => 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")} /> <Password account={account} email={email} login={login} onAuthenticated={acceptPendingInviteThenDashboard} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
)} )}
{step === "otp" && account && ( {step === "otp" && account && (
<OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} /> <OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={acceptPendingInviteThenDashboard} />
)} )}
{step === "another" && account && ( {step === "another" && account && (
@@ -269,17 +288,17 @@ export function LoginFlow() {
/* ============================ screens ============================ */ /* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: { function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister, invited }: {
email: string; setEmail: (v: string) => void; 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); const valid = EMAIL_RE.test(email);
return ( return (
<div> <div>
<div className="kicker">Welcome back</div> <div className="kicker">{invited ? "You're invited" : "Welcome back"}</div>
<h1>Sign in to LynkedUp</h1> <h1>{invited ? "Sign in to join the team" : "Sign in to LynkedUp"}</h1>
<p className="sub">Drone inspections, AI estimates and insurance-ready reports all in one place.</p> <p className="sub">{invited ? "You already have an account — sign in to accept your invitation." : "Drone inspections, AI estimates and insurance-ready reports — all in one place."}</p>
<div style={{ marginTop: 22 }}> <div style={{ marginTop: 22 }}>
<SocialButtons onPick={onSocial} /> <SocialButtons onPick={onSocial} />
+15 -6
View File
@@ -53,6 +53,13 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
// verify step (email is trusted without an OTP — see verifyValid below) // verify step (email is trusted without an OTP — see verifyValid below)
const [phoneVerified, setPhoneVerified] = useState(false); 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 // Onboarding: prefill the verified email — from the session-storage hint the login
// page stashed at OAuth time, then confirmed via the live Supabase session. // 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) { if (inviteToken) {
await sdk.command("crm.team.invitation.accept", { token: inviteToken }); await sdk.command("crm.team.invitation.accept", { token: inviteToken });
sessionStorage.removeItem("invite_token"); sessionStorage.removeItem("invite_token");
sessionStorage.removeItem("invite_email");
} }
} catch { /* invitation expired/used — they can still be invited again */ } } catch { /* invitation expired/used — they can still be invited again */ }
} }
@@ -154,7 +162,7 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
<> <>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>} {submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<StepAccount <StepAccount
onboard={onboard} creating={creating} onboard={onboard} creating={creating} invited={!!invitedEmail}
sso={sso} setSso={setSso} email={email} setEmail={setEmail} sso={sso} setSso={setSso} email={email} setEmail={setEmail}
first={first} setFirst={setFirst} last={last} setLast={setLast} first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country} cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
@@ -192,7 +200,7 @@ function StepAccount(p: {
cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number]; cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number];
pw: string; setPw: (v: string) => void; pw: string; setPw: (v: string) => void;
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => 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. // Onboarding (OAuth) starts on the profile step — email is already known + verified.
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso"); const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
@@ -203,21 +211,22 @@ function StepAccount(p: {
return ( return (
<div> <div>
<StepBack onClick={p.toLogin} /> <StepBack onClick={p.toLogin} />
<h1>Create your account</h1> <h1>{p.invited ? "Accept your invitation" : "Create your account"}</h1>
<p className="sub">Enter your email to get started.</p> <p className="sub">{p.invited ? "You've been invited to the team. Set up your account to join." : "Enter your email to get started."}</p>
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}> <form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<div className="field"> <div className="field">
<label className="label">Email address</label> <label className="label">Email address</label>
<div className="input-wrap"> <div className="input-wrap">
<span className="input-ico"><Icon name="mail" size={17} /></span> <span className="input-ico"><Icon name="mail" size={17} /></span>
<input className="input" type="email" value={p.email} onChange={(e) => p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus /> <input className="input" type="email" value={p.email} onChange={(e) => p.setEmail(e.target.value)} placeholder="you@example.com" autoFocus={!p.invited} disabled={p.invited} readOnly={p.invited} />
</div> </div>
{p.invited && <span className="faint" style={{ fontSize: 12 }}>This is the address you were invited with.</span>}
</div> </div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>Continue <Icon name="arrowR" size={16} /></button> <button className="btn btn-primary" style={{ marginTop: 12 }} disabled={!valid}>Continue <Icon name="arrowR" size={16} /></button>
</form> </form>
</div> </div>
<p className="foot-note">Already registered? <button className="link" onClick={p.toLogin}>Sign in</button></p> {!p.invited && <p className="foot-note">Already registered? <button className="link" onClick={p.toLogin}>Sign in</button></p>}
</div> </div>
); );
} }