diff --git a/src/app/portal/invite/page.tsx b/src/app/portal/invite/page.tsx new file mode 100644 index 0000000..f011fa6 --- /dev/null +++ b/src/app/portal/invite/page.tsx @@ -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 ( + + + + + + + + + {error ? ( + <> + Invitation problem + {error} + router.replace("/portal/login")}> + Go to sign in + + > + ) : ( + + + Accepting your invitation… + Setting up your team access. + + )} + + + + + + + ); +} diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 5f90f0d..c818e60 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -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 = { + 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 - {NAV_GROUPS.map((g, gi) => ( + {visibleGroups.map((g, gi) => ( {g.title} {g.items.map((it) => ( diff --git a/src/components/dashboard/team-management.tsx b/src/components/dashboard/team-management.tsx index 6400cdb..41d22b4 100644 --- a/src/components/dashboard/team-management.tsx +++ b/src/components/dashboard/team-management.tsx @@ -301,6 +301,13 @@ export function TeamManagement() { + {inv.token && ( + { + 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 + )} { 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 }); } diff --git a/src/components/portal/register-flow.tsx b/src/components/portal/register-flow.tsx index 6dd6318..095a6a7 100644 --- a/src/components/portal/register-flow.tsx +++ b/src/components/portal/register-flow.tsx @@ -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: { )} - - Your role - p.setRelationship(e.target.value)}> - {relationshipOptions.map((r) => {r})} - - - - {p.isSelf ? ( - {p.relationship === "Owner" ? "Owner" : "Customer"} account — you own this property. - ) : (() => { - 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)[p.relationship] - ?? { banner: "Registering on behalf of the property owner.", title: "Property Owner Details", idLabel: "Owner / Property ID", idPh: "Alphanumeric ID" }; - return ( - - {cfg.banner} - - - {cfg.title} - {p.alloeNo && (p.alloeIdOk ? Valid : Checking…)} - - - {cfg.idLabel} - p.setAlloeNo(e.target.value.toUpperCase())} placeholder={cfg.idPh} /> - - {!isEmployee && ( - - Owner first name p.setAlloeFirst(e.target.value)} /> - Owner last name p.setAlloeLast(e.target.value)} /> - - )} - - - ); - })()} - p.setTermsOk(e.target.checked)} /> diff --git a/src/lib/access.ts b/src/lib/access.ts new file mode 100644 index 0000000..53056b9 --- /dev/null +++ b/src/lib/access.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useQuery } from "@abe-kap/appshell-sdk/react"; +import { isShellConfigured } from "./appshell"; + +/** + * The signed-in user's effective CRM access, from crm.account.me. Drives navigation + * gating: a user with no membership/permissions sees only Dashboard + Profile; members + * see the areas their permissions allow. + * + * Every CRM permission id, granted to superadmins / owners. Used as the mock default + * when the Shell isn't configured (local demo shows everything). + */ +export const ALL_CRM_PERMISSIONS = [ + "leads.manage", "pipeline.manage", "estimates.create", "dispatch.manage", + "reports.view", "billing.view", "team.manage", "roles.manage", "settings.manage", +] as const; + +export interface MyAccess { + registered: boolean; + isMember: boolean; + roleSlugs: string[]; + permissions: string[]; + isSuperadmin: boolean; + /** True while the access query is still resolving (nav stays minimal until then). */ + loading: boolean; + can: (permission: string) => boolean; +} + +interface MeDTO { + registered: boolean; + isMember: boolean; + roleSlugs: string[]; + permissions: string[]; + isSuperadmin: boolean; +} + +function useLiveAccess(): MyAccess { + const { data, loading } = useQuery("crm.account.me"); + const permissions = data?.permissions ?? []; + return { + registered: data?.registered ?? false, + isMember: data?.isMember ?? false, + roleSlugs: data?.roleSlugs ?? [], + permissions, + isSuperadmin: data?.isSuperadmin ?? false, + loading, + can: (p: string) => permissions.includes(p), + }; +} + +function useMockAccess(): MyAccess { + // No Shell (local demo): show everything so the mock UI is fully browsable. + const permissions = [...ALL_CRM_PERMISSIONS]; + return { + registered: true, + isMember: true, + roleSlugs: ["superadmin"], + permissions, + isSuperadmin: true, + loading: false, + can: () => true, + }; +} + +export const useMyAccess: () => MyAccess = isShellConfigured() ? useLiveAccess : useMockAccess; diff --git a/src/lib/team-api.ts b/src/lib/team-api.ts index 029ed33..168d128 100644 --- a/src/lib/team-api.ts +++ b/src/lib/team-api.ts @@ -38,6 +38,8 @@ export interface UiMember { } export interface UiInvite { id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string; + /** Accept token for the invite link (pending invites only; no email delivery yet). */ + token?: string; } export interface TeamData { @@ -70,7 +72,7 @@ const initialsOf = (name: string) => interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean } interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number } interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number } -interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string } +interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string; token?: string } const relTime = (iso: string): string => { const then = Date.parse(iso); @@ -169,7 +171,7 @@ function useLiveTeam(): TeamData { const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({ id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id), - invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), + invitedBy: i.invitedBy, sentAt: relTime(i.createdAt), token: i.token, })), [invitesQ.data]); return {
{error}
Setting up your team access.