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