feat(team+auth): live multi-role team management + email/SMS OTP login

- team-management: new useTeamData() data layer serves live be-crm data door
  (crm.team.member.search/role.list/invitation.list + set-roles/perm/invite
  commands) or the mock fallback, gated on isShellConfigured(). A member can
  hold MANY roles: role chips + multi-select picker in Edit/Invite modals.
- login OTP: OtpVerify wired to real Supabase email + SMS OTP (passwordless)
  via appshell sendEmailOtp/verifyEmailOtp + sendPhoneOtp/verifyPhoneOtp.
- bump @abe-kap/appshell-sdk ^0.2.0 (phone OTP).
This commit is contained in:
tanweer919
2026-07-11 04:44:54 +05:30
parent 1d37593102
commit 565019a75c
5 changed files with 462 additions and 217 deletions
+202
View File
@@ -0,0 +1,202 @@
"use client";
// Team-management data layer. Serves EITHER the local mock (when the Shell isn't
// configured — the polished demo keeps working) OR the live be-crm data door
// (crm.team.*), behind one interface so the component is mode-agnostic.
//
// Live contract (be-crm, multi-role):
// query crm.team.member.search { perPage } -> { items: MemberDTO[], meta }
// query crm.team.role.list { includeEmpty } -> { items: RoleDTO[], meta }
// query crm.team.invitation.list { status } -> { items: InvitationDTO[], meta }
// cmd crm.team.member.setRoles { id, roleIds }
// cmd crm.team.member.update { id, jobTitle?, roleIds? }
// cmd crm.team.member.remove { id }
// cmd crm.team.invitation.create/resend/revoke
// cmd crm.team.role.addPermission/removePermission { id, permissionId }
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import {
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
type Member as SeedMember, type Role as SeedRole, type Invite as SeedInvite,
} from "@/components/dashboard/team-data";
/* ---- UI-facing shapes (multi-role; superset of the mock) ---------------- */
export type UiStatus = "active" | "away" | "offline";
export interface UiRole {
id: string; slug: string; name: string; color: string;
description: string; system: boolean; isOwner: boolean;
perms: string[]; attire: string[]; overlap: string; memberCount?: number;
}
export interface UiMember {
id: string; principalId?: string; name: string; initials: string; email: string;
title: string; roleIds: string[]; gradient: string; status: UiStatus;
lastActive: string; deals: number; joined: string; isOwner: boolean; isYou: boolean;
}
export interface UiInvite {
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
}
export interface TeamData {
live: boolean; loading: boolean; error: string | null;
members: UiMember[]; roles: UiRole[]; invites: UiInvite[];
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
removeMember: (id: string) => Promise<void>;
invite: (email: string, roleIds: string[]) => Promise<void>;
resendInvite: (id: string) => Promise<void>;
revokeInvite: (id: string) => Promise<void>;
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
refetch: () => void;
}
/* ---- helpers ------------------------------------------------------------ */
const GRADIENTS = [
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)",
"linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)",
"linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#f59e0b,#ef4444)",
"linear-gradient(135deg,#8b5cf6,#d946ef)", "linear-gradient(135deg,#14b8a6,#0ea5e9)",
];
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
/* ---- be-crm DTO types (subset used here) -------------------------------- */
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 }
const relTime = (iso: string): string => {
const then = Date.parse(iso);
if (Number.isNaN(then)) return iso;
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
};
/* ---- mock → UI ---------------------------------------------------------- */
const mockRole = (r: SeedRole): UiRole => ({
id: r.id, slug: r.id, name: r.name, color: r.color, description: r.description,
system: r.system, isOwner: r.id === "owner", perms: r.perms, attire: r.attire, overlap: r.overlap,
});
const mockMember = (m: SeedMember): UiMember => ({
id: m.id, name: m.name, initials: m.initials, email: m.email, title: m.title,
roleIds: [m.roleId], gradient: m.gradient, status: m.status, lastActive: m.lastActive,
deals: m.deals, joined: m.joined, isOwner: m.roleId === "owner", isYou: m.roleId === "owner",
});
const mockInvite = (i: SeedInvite): UiInvite => ({
id: i.id, email: i.email, roleIds: [i.roleId], invitedBy: i.invitedBy, sentAt: i.sentAt,
});
/* ======================================================================== */
/* Mock implementation (no Shell configured) */
/* ======================================================================== */
function useMockTeam(): TeamData {
const [members, setMembers] = useState<UiMember[]>(() => seedMembers.map(mockMember));
const [roles, setRoles] = useState<UiRole[]>(() => seedRoles.map(mockRole));
const [invites, setInvites] = useState<UiInvite[]>(() => seedInvites.map(mockInvite));
const setMemberRoles = useCallback(async (id: string, roleIds: string[]) => {
setMembers((l) => l.map((m) => (m.id === id ? { ...m, roleIds } : m)));
}, []);
const updateMember = useCallback(async (id: string, patch: { title?: string; roleIds?: string[] }) => {
setMembers((l) => l.map((m) => (m.id === id
? { ...m, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}) }
: m)));
}, []);
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
const invite = useCallback(async (email: string, roleIds: string[]) => {
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
}, []);
const resendInvite = useCallback(async () => {}, []);
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
...r, perms: granted ? [...new Set([...r.perms, permId])] : r.perms.filter((p) => p !== permId),
})));
}, []);
return { live: false, loading: false, error: null, members, roles, invites,
setMemberRoles, updateMember, removeMember, invite, resendInvite, revokeInvite, setPermission, refetch: () => {} };
}
/* ======================================================================== */
/* Live implementation (be-crm data door) */
/* ======================================================================== */
function useLiveTeam(): TeamData {
const { sdk } = useAppShell();
const { user } = useAuth();
const meId = user?.id;
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
const rolesQ = useQuery<{ items: RoleDTO[] }>("crm.team.role.list", { includeEmpty: true });
const invitesQ = useQuery<{ items: InvitationDTO[] }>("crm.team.invitation.list", { status: "pending" });
const refetch = useCallback(() => { membersQ.refetch(); rolesQ.refetch(); invitesQ.refetch(); }, [membersQ, rolesQ, invitesQ]);
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
await sdk.command(action, variables);
refetch();
}, [sdk, refetch]);
const roles: UiRole[] = useMemo(() => (rolesQ.data?.items ?? []).map((r) => ({
id: r.id, slug: r.slug, name: r.name, color: r.color ?? "var(--text-2)",
description: r.description ?? "", system: r.isSystem, isOwner: r.isOwnerRole,
perms: r.permissions, attire: [], overlap: "", memberCount: r.memberCount,
})), [rolesQ.data]);
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
const isYou = !!meId && m.principalId === meId;
const name = isYou && user?.displayName
? user.displayName
: (m.jobTitle?.trim() || `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`);
return {
id: m.id, principalId: m.principalId, name, initials: initialsOf(name),
email: isYou && user?.email ? user.email : m.principalId,
title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id),
status: (m.status === "active" ? "active" : "offline") as UiStatus,
lastActive: m.status === "active" ? "Active" : "—",
deals: m.openDeals, joined: relTime(m.joinedAt),
isOwner: m.roles.some((r) => r.isOwnerRole), isYou,
};
}), [membersQ.data, meId, user]);
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),
})), [invitesQ.data]);
return {
live: true,
loading: membersQ.loading || rolesQ.loading || invitesQ.loading,
error: (membersQ.error ?? rolesQ.error ?? invitesQ.error)?.message ?? null,
members, roles, invites,
setMemberRoles: (id, roleIds) => cmd("crm.team.member.setRoles", { id, roleIds }),
updateMember: (id, patch) => cmd("crm.team.member.update", {
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
}),
removeMember: (id) => cmd("crm.team.member.remove", { id }),
invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }),
resendInvite: (id) => cmd("crm.team.invitation.resend", { id }),
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
setPermission: (roleId, permId, granted) =>
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
refetch,
};
}
/* ---- public hook: pick the implementation at module-config time --------- */
const SHELL = isShellConfigured();
export function useTeamData(): TeamData {
// `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time),
// so the same hook path runs every render — Rules-of-Hooks safe.
return SHELL ? useLiveTeam() : useMockTeam();
}