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:
@@ -6,6 +6,7 @@ import { ChevronsUpDown, LogOut } from "lucide-react";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { Icon } from "./ui";
|
||||
import { user } from "./account-data";
|
||||
import { useMyAccess } from "@/lib/access";
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
@@ -67,10 +68,45 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
||||
|
||||
// Nav visibility by CRM permission. Dashboard + Profile are always visible (even to a
|
||||
// brand-new user with no membership); every other item requires membership, and the
|
||||
// items mapped here additionally require the given permission. Unmapped items are
|
||||
// shown to any member. This is UX only — be-crm still enforces every action.
|
||||
const ALWAYS_VISIBLE = new Set(["dashboard", "profile"]);
|
||||
const NAV_PERMISSION: Record<string, string | undefined> = {
|
||||
team: "team.manage",
|
||||
people: "team.manage",
|
||||
leads: "leads.manage",
|
||||
verify: "leads.manage",
|
||||
pipeline: "pipeline.manage",
|
||||
estimates: "estimates.create",
|
||||
procanvas: "estimates.create",
|
||||
dispatch: "dispatch.manage",
|
||||
schedule: "dispatch.manage",
|
||||
storm: "dispatch.manage",
|
||||
territory: "dispatch.manage",
|
||||
leaderboard: "reports.view",
|
||||
settings: "settings.manage",
|
||||
};
|
||||
|
||||
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
|
||||
const router = useRouter();
|
||||
const { user: me, logout, context } = useAuth();
|
||||
const access = useMyAccess();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// A new user with no membership sees only Dashboard + Profile. Members see the areas
|
||||
// their permissions allow. While access is still loading, keep it minimal to avoid
|
||||
// flashing items the user can't actually use.
|
||||
const canSee = (key: string): boolean => {
|
||||
if (ALWAYS_VISIBLE.has(key)) return true;
|
||||
if (access.loading || !access.isMember) return false;
|
||||
const perm = NAV_PERMISSION[key];
|
||||
return perm ? access.can(perm) : true;
|
||||
};
|
||||
const visibleGroups = NAV_GROUPS
|
||||
.map((g) => ({ ...g, items: g.items.filter((it) => canSee(it.key)) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
// Real signed-in identity from the App Context Envelope; fall back to the static
|
||||
// demo user only when the Shell isn't wired.
|
||||
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
|
||||
@@ -92,7 +128,7 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
|
||||
</div>
|
||||
|
||||
<nav className="dash-nav">
|
||||
{NAV_GROUPS.map((g, gi) => (
|
||||
{visibleGroups.map((g, gi) => (
|
||||
<div className="nav-section" key={gi}>
|
||||
<div className="nav-group">{g.title}</div>
|
||||
{g.items.map((it) => (
|
||||
|
||||
@@ -301,6 +301,13 @@ export function TeamManagement() {
|
||||
</div>
|
||||
<RoleChips roleIds={inv.roleIds} roleById={roleById} />
|
||||
<div className="tm-invite-actions">
|
||||
{inv.token && (
|
||||
<Btn variant="soft" size="sm" icon="copy" onClick={async () => {
|
||||
const link = `${window.location.origin}/portal/invite?token=${inv.token}`;
|
||||
try { await navigator.clipboard.writeText(link); toast.push({ tone: "success", title: "Invite link copied", desc: `Send it to ${inv.email} to join.` }); }
|
||||
catch { toast.push({ tone: "info", title: "Invite link", desc: link }); }
|
||||
}}>Copy link</Btn>
|
||||
)}
|
||||
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
|
||||
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` }); }
|
||||
catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PasswordStrength, LegalModal,
|
||||
} from "./bits";
|
||||
import {
|
||||
countryCodes, relationshipOptions, addressCountries,
|
||||
countryCodes, addressCountries,
|
||||
TERMS, PRIVACY, passwordStrength, type AddrCountry,
|
||||
} from "./data";
|
||||
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
@@ -48,10 +48,6 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
||||
const [cc, setCc] = useState("+1");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [pw, setPw] = useState("");
|
||||
const [relationship, setRelationship] = useState("Customer");
|
||||
const [alloeNo, setAlloeNo] = useState("");
|
||||
const [alloeFirst, setAlloeFirst] = useState("");
|
||||
const [alloeLast, setAlloeLast] = useState("");
|
||||
const [termsOk, setTermsOk] = useState(false);
|
||||
const [privacyOk, setPrivacyOk] = useState(false);
|
||||
|
||||
@@ -69,16 +65,13 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
||||
|
||||
const STEP_LABELS = onboard ? ["Profile", "Verify", "Address"] : STEPS;
|
||||
|
||||
const isSelf = relationship === "Customer" || relationship === "Owner";
|
||||
const isEmployee = relationship === "Employee";
|
||||
const country = countryCodes.find((c) => c.code === cc)!;
|
||||
const phoneOk = phone.replace(/\D/g, "").length === country.digits;
|
||||
const pwOk = sso ? true : passwordStrength(pw).score >= 3;
|
||||
const alloeIdOk = /^(?=.*[a-zA-Z])(?=.*\d).{4,}$/.test(alloeNo);
|
||||
|
||||
const step0Valid =
|
||||
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk &&
|
||||
termsOk && privacyOk && (isSelf || (alloeIdOk && (isEmployee || (alloeFirst.trim() && alloeLast.trim()))));
|
||||
termsOk && privacyOk;
|
||||
|
||||
// Email is already trusted in both flows (registration: Supabase auto-confirms on
|
||||
// signup; onboarding: Google-verified), so the Verify step only gates on the phone.
|
||||
@@ -107,8 +100,6 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
||||
name: `${first} ${last}`.trim(),
|
||||
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
|
||||
email: finalEmail,
|
||||
isAllottee: isSelf,
|
||||
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
|
||||
};
|
||||
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
|
||||
|
||||
@@ -121,17 +112,15 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
||||
if (onboard && pw) {
|
||||
try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
|
||||
}
|
||||
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona,
|
||||
// allottee, both addresses, consent). Non-fatal: the auth account exists either way.
|
||||
// 2) Persist the CRM registration payload to be-crm (name, phone, addresses,
|
||||
// consent). No role/persona — a new user has no permissions until invited.
|
||||
// Non-fatal: the auth account exists either way.
|
||||
try {
|
||||
const mailing = mailingSame ? regAddr : mailAddr;
|
||||
await sdk.command("crm.account.register", {
|
||||
email: finalEmail,
|
||||
firstName: first, lastName: last,
|
||||
phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""),
|
||||
persona: relationship,
|
||||
isAllottee: isSelf,
|
||||
...(isSelf ? {} : { allotteeId: alloeNo, allotteeFirstName: alloeFirst, allotteeLastName: alloeLast }),
|
||||
registeredAddress: regAddr,
|
||||
mailingAddress: mailing,
|
||||
mailingSameAsRegistered: mailingSame,
|
||||
@@ -141,6 +130,16 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
||||
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; }
|
||||
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
|
||||
}
|
||||
|
||||
// 3) If the user arrived from a team invitation link, redeem it now — this
|
||||
// creates their membership with the invited role(s). Non-fatal.
|
||||
try {
|
||||
const inviteToken = sessionStorage.getItem("invite_token");
|
||||
if (inviteToken) {
|
||||
await sdk.command("crm.team.invitation.accept", { token: inviteToken });
|
||||
sessionStorage.removeItem("invite_token");
|
||||
}
|
||||
} catch { /* invitation expired/used — they can still be invited again */ }
|
||||
}
|
||||
// Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
|
||||
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ }
|
||||
@@ -160,9 +159,6 @@ export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboa
|
||||
first={first} setFirst={setFirst} last={last} setLast={setLast}
|
||||
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
|
||||
pw={pw} setPw={setPw}
|
||||
relationship={relationship} setRelationship={setRelationship} isSelf={isSelf}
|
||||
alloeNo={alloeNo} setAlloeNo={setAlloeNo} alloeIdOk={alloeIdOk}
|
||||
alloeFirst={alloeFirst} setAlloeFirst={setAlloeFirst} alloeLast={alloeLast} setAlloeLast={setAlloeLast}
|
||||
termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
|
||||
valid={!!step0Valid}
|
||||
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")}
|
||||
@@ -195,9 +191,6 @@ function StepAccount(p: {
|
||||
first: string; setFirst: (v: string) => void; last: string; setLast: (v: string) => void;
|
||||
cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number];
|
||||
pw: string; setPw: (v: string) => void;
|
||||
relationship: string; setRelationship: (v: string) => void; isSelf: boolean;
|
||||
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
|
||||
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
|
||||
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
|
||||
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean;
|
||||
}) {
|
||||
@@ -287,47 +280,6 @@ function StepAccount(p: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field" style={{ marginTop: 14 }}>
|
||||
<label className="label">Your role</label>
|
||||
<select className="input" value={p.relationship} onChange={(e) => p.setRelationship(e.target.value)}>
|
||||
{relationshipOptions.map((r) => <option key={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{p.isSelf ? (
|
||||
<div style={{ marginTop: 12 }}><FlashNote tone="success">{p.relationship === "Owner" ? "Owner" : "Customer"} account — you own this property.</FlashNote></div>
|
||||
) : (() => {
|
||||
const isEmployee = p.relationship === "Employee";
|
||||
const cfg = ({
|
||||
Employee: { banner: "Registering as a LynkedUp Pro team member.", title: "Employee details", idLabel: "Employee ID", idPh: "EMP-12345" },
|
||||
Contractor:{ banner: "Registering on behalf of the property owner.", title: "Contractor details", idLabel: "Contractor ID", idPh: "Alphanumeric ID" },
|
||||
"Sub-Con": { banner: "Registering on behalf of the property owner.", title: "Sub-contractor details", idLabel: "Sub-contractor ID", idPh: "Alphanumeric ID" },
|
||||
Vendor: { banner: "Registering on behalf of the property owner.", title: "Vendor details", idLabel: "Vendor ID", idPh: "Alphanumeric ID" },
|
||||
} as Record<string, { banner: string; title: string; idLabel: string; idPh: string }>)[p.relationship]
|
||||
?? { banner: "Registering on behalf of the property owner.", title: "Property Owner Details", idLabel: "Owner / Property ID", idPh: "Alphanumeric ID" };
|
||||
return (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<FlashNote tone="info">{cfg.banner}</FlashNote>
|
||||
<div className="dashed-block" style={{ marginTop: 12 }}>
|
||||
<div className="row between" style={{ marginBottom: 12 }}>
|
||||
<strong style={{ fontSize: 13.5 }}>{cfg.title}</strong>
|
||||
{p.alloeNo && (p.alloeIdOk ? <Badge tone="green"><Icon name="check" size={12} /> Valid</Badge> : <Badge tone="gray">Checking…</Badge>)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">{cfg.idLabel}</label>
|
||||
<input className="input" value={p.alloeNo} onChange={(e) => p.setAlloeNo(e.target.value.toUpperCase())} placeholder={cfg.idPh} />
|
||||
</div>
|
||||
{!isEmployee && (
|
||||
<div className="row gap-3" style={{ marginTop: 12 }}>
|
||||
<div className="field grow"><label className="label">Owner first name</label><input className="input" value={p.alloeFirst} onChange={(e) => p.setAlloeFirst(e.target.value)} /></div>
|
||||
<div className="field grow"><label className="label">Owner last name</label><input className="input" value={p.alloeLast} onChange={(e) => p.setAlloeLast(e.target.value)} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="col gap-2" style={{ marginTop: 16 }}>
|
||||
<label className="check-row">
|
||||
<input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} />
|
||||
|
||||
Reference in New Issue
Block a user