fix(invite): route register vs sign-in + prefill email (public lookup) #24
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,61 @@
|
|||||||
"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; }
|
||||||
|
if (!isShellConfigured()) { try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ } router.replace("/portal/register"); return; }
|
||||||
|
if (!ready || started.current) return;
|
||||||
|
started.current = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ }
|
try { sessionStorage.setItem("invite_token", token); } catch { /* ignore */ }
|
||||||
|
|
||||||
// No Shell (local/mock) → just go register.
|
// Look the invitation up (pre-auth) to get the email + account state.
|
||||||
if (!isShellConfigured()) { router.replace("/portal/register"); return; }
|
let info: InviteInfo = { ok: false };
|
||||||
if (!ready) return;
|
try {
|
||||||
|
const res = await fetch(`/api/invite/lookup?token=${encodeURIComponent(token)}`, { cache: "no-store" });
|
||||||
|
info = await res.json();
|
||||||
|
} catch { /* treat as unavailable below */ }
|
||||||
|
|
||||||
let cancelled = false;
|
if (!info.ok) {
|
||||||
(async () => {
|
const msg = info.status === "expired" ? "This invitation has expired. Ask for a new one."
|
||||||
if (status === "unauthenticated") {
|
: info.status === "accepted" ? "This invitation has already been used."
|
||||||
router.replace("/portal/register"); // sign up first; finish() redeems the token
|
: 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 */ } }
|
||||||
|
|
||||||
|
// Signed in already: accept if registered, else finish onboarding first.
|
||||||
|
if (status === "authenticated") {
|
||||||
try {
|
try {
|
||||||
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
|
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
|
||||||
if (!st?.registered) {
|
if (!st?.registered) {
|
||||||
@@ -45,16 +64,19 @@ export default function InvitePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch { /* fall through to accept */ }
|
} catch { /* fall through to accept */ }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sdk.command("crm.team.invitation.accept", { token });
|
await sdk.command("crm.team.invitation.accept", { token });
|
||||||
try { sessionStorage.removeItem("invite_token"); } catch { /* ignore */ }
|
try { sessionStorage.removeItem("invite_token"); sessionStorage.removeItem("invite_email"); } catch { /* ignore */ }
|
||||||
router.replace("/dashboard");
|
router.replace("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("We couldn't accept this invitation — it may have expired or already been used.");
|
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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user