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

Merged
tanweer919 merged 1 commits from tanweer919/lynkeduppro-crm:feat/login-methods into goutamnextflow 2026-07-13 10:56:17 +00:00
6 changed files with 220 additions and 66 deletions
Showing only changes of commit 103f3ae3a1 - Show all commits
+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>
);
}
+37 -1
View File
@@ -6,6 +6,7 @@ import { ChevronsUpDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react"; import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui"; import { Icon } from "./ui";
import { user } from "./account-data"; import { user } from "./account-data";
import { useMyAccess } from "@/lib/access";
function initialsOf(name: string): string { function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; 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); 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 }) { export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
const router = useRouter(); const router = useRouter();
const { user: me, logout, context } = useAuth(); const { user: me, logout, context } = useAuth();
const access = useMyAccess();
const [menuOpen, setMenuOpen] = useState(false); 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 // Real signed-in identity from the App Context Envelope; fall back to the static
// demo user only when the Shell isn't wired. // 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) : ""; 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> </div>
<nav className="dash-nav"> <nav className="dash-nav">
{NAV_GROUPS.map((g, gi) => ( {visibleGroups.map((g, gi) => (
<div className="nav-section" key={gi}> <div className="nav-section" key={gi}>
<div className="nav-group">{g.title}</div> <div className="nav-group">{g.title}</div>
{g.items.map((it) => ( {g.items.map((it) => (
@@ -301,6 +301,13 @@ export function TeamManagement() {
</div> </div>
<RoleChips roleIds={inv.roleIds} roleById={roleById} /> <RoleChips roleIds={inv.roleIds} roleById={roleById} />
<div className="tm-invite-actions"> <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 () => { <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}.` }); } 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 }); } catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
+15 -63
View File
@@ -8,7 +8,7 @@ import {
PasswordStrength, LegalModal, PasswordStrength, LegalModal,
} from "./bits"; } from "./bits";
import { import {
countryCodes, relationshipOptions, addressCountries, countryCodes, addressCountries,
TERMS, PRIVACY, passwordStrength, type AddrCountry, TERMS, PRIVACY, passwordStrength, type AddrCountry,
} from "./data"; } from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react"; 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 [cc, setCc] = useState("+1");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [pw, setPw] = 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 [termsOk, setTermsOk] = useState(false);
const [privacyOk, setPrivacyOk] = 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 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 country = countryCodes.find((c) => c.code === cc)!;
const phoneOk = phone.replace(/\D/g, "").length === country.digits; const phoneOk = phone.replace(/\D/g, "").length === country.digits;
const pwOk = sso ? true : passwordStrength(pw).score >= 3; const pwOk = sso ? true : passwordStrength(pw).score >= 3;
const alloeIdOk = /^(?=.*[a-zA-Z])(?=.*\d).{4,}$/.test(alloeNo);
const step0Valid = const step0Valid =
first.trim() && last.trim() && EMAIL_RE.test(sso?.email || email) && phoneOk && pwOk && 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 // 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. // 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(), name: `${first} ${last}`.trim(),
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(), initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
email: finalEmail, email: finalEmail,
isAllottee: isSelf,
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
}; };
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ } 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) { if (onboard && pw) {
try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ } try { await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
} }
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona, // 2) Persist the CRM registration payload to be-crm (name, phone, addresses,
// allottee, both addresses, consent). Non-fatal: the auth account exists either way. // consent). No role/persona — a new user has no permissions until invited.
// Non-fatal: the auth account exists either way.
try { try {
const mailing = mailingSame ? regAddr : mailAddr; const mailing = mailingSame ? regAddr : mailAddr;
await sdk.command("crm.account.register", { await sdk.command("crm.account.register", {
email: finalEmail, email: finalEmail,
firstName: first, lastName: last, firstName: first, lastName: last,
phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""), phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""),
persona: relationship,
isAllottee: isSelf,
...(isSelf ? {} : { allotteeId: alloeNo, allotteeFirstName: alloeFirst, allotteeLastName: alloeLast }),
registeredAddress: regAddr, registeredAddress: regAddr,
mailingAddress: mailing, mailingAddress: mailing,
mailingSameAsRegistered: mailingSame, 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; } 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. // 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. // Consume the OAuth→onboarding handoff so a stale hint can't re-open onboarding.
try { sessionStorage.removeItem("onboard_email"); } catch { /* ignore */ } 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} first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country} cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
pw={pw} setPw={setPw} 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} termsOk={termsOk} setTermsOk={setTermsOk} privacyOk={privacyOk} setPrivacyOk={setPrivacyOk}
valid={!!step0Valid} valid={!!step0Valid}
onContinue={leaveAccountStep} toLogin={() => router.push("/portal/login")} 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; 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]; cc: string; setCc: (v: string) => void; phone: string; setPhone: (v: string) => void; phoneOk: boolean; country: typeof countryCodes[number];
pw: string; setPw: (v: string) => void; 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; termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean; valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean; creating?: boolean;
}) { }) {
@@ -287,47 +280,6 @@ function StepAccount(p: {
</div> </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 }}> <div className="col gap-2" style={{ marginTop: 16 }}>
<label className="check-row"> <label className="check-row">
<input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} /> <input type="checkbox" checked={p.termsOk} disabled={!reviewed.terms} onChange={(e) => p.setTermsOk(e.target.checked)} />
+66
View File
@@ -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<MeDTO>("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;
+4 -2
View File
@@ -38,6 +38,8 @@ export interface UiMember {
} }
export interface UiInvite { export interface UiInvite {
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string; 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 { 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 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 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 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 relTime = (iso: string): string => {
const then = Date.parse(iso); const then = Date.parse(iso);
@@ -169,7 +171,7 @@ function useLiveTeam(): TeamData {
const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({ const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({
id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id), 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]); })), [invitesQ.data]);
return { return {