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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user