feat(rbac): no role at registration; invitation-based membership; nav gating

- Registration no longer asks for a role/persona (removed the role select +
  allottee block); crm.account.register sends no persona.
- New users have no permissions → the sidebar now shows only Dashboard + Profile
  for them, gated on crm.account.me (membership + permissions). Members see the
  areas their permissions allow.
- Add /portal/invite?token=… : accepts the invite for a signed-in registered
  user, or routes an unregistered invitee through register/onboarding, which
  redeems the stashed token on completion (granting the invited role).
- Team Management: 'Copy link' on pending invites builds the invite link from
  the invitation token (no email delivery yet).
This commit is contained in:
tanweer919
2026-07-13 16:25:53 +05:30
parent 68bf137d11
commit 103f3ae3a1
6 changed files with 220 additions and 66 deletions
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useEffect, 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";
/**
* 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.
*/
export default function InvitePage() {
const router = useRouter();
const { status, getUserEmail } = useAuth();
const { ready, sdk } = useAppShell();
const [error, setError] = useState("");
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 */ }
// 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
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 */ }
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.");
}
})();
return () => { cancelled = true; };
}, [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 }}>Accepting your invitation</h1>
<p className="sub">Setting up your team access.</p>
</div>
)}
</div>
</div>
</section>
</div>
<CookieBanner />
</main>
);
}