be4bd5f41d
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).
114 lines
4.9 KiB
TypeScript
114 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
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=…). 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; }
|
|
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 */ }
|
|
|
|
// 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;
|
|
}
|
|
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 {
|
|
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");
|
|
})();
|
|
}, [ready, status, router, sdk, getUserEmail]);
|
|
|
|
return (
|
|
<main className="portal-main">
|
|
<span className="portal-grid" />
|
|
<div className="portal-split anim-in">
|
|
<PortalAside />
|
|
<section className="portal-panel">
|
|
<div style={{ width: "100%", maxWidth: 420 }}>
|
|
<PanelBrand />
|
|
<div className="card anim-fade-up" style={{ textAlign: "center" }}>
|
|
{error ? (
|
|
<>
|
|
<h1>Invitation problem</h1>
|
|
<p className="sub">{error}</p>
|
|
<button className="btn btn-primary" style={{ marginTop: 16 }} onClick={() => router.replace("/portal/login")}>
|
|
Go to sign in
|
|
</button>
|
|
</>
|
|
) : (
|
|
<div className="interstitial">
|
|
<Spinner lg />
|
|
<h1 style={{ fontSize: 20, marginTop: 8 }}>Checking your invitation…</h1>
|
|
<p className="sub">One moment while we set things up.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<CookieBanner />
|
|
</main>
|
|
);
|
|
}
|