import React, { useState, useEffect } from "react"; import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw, Search, Pencil, Folder, Copy, Trash2, Plus, Lock, } from "lucide-react"; import { motion } from "framer-motion"; import { Link } from "react-router-dom"; import { usePartnersQuery } from "../../hooks/use-partners-query"; import { useInvitePartnerMutation, useUpdatePartnerMutation, useResendInviteMutation } from "../../hooks/use-partners-mutation"; import { getAssets, getAssetGroups } from "../../services/assets-api"; import type { AssetGroup } from "../../services/assets-api"; import { getLegalDocuments } from "../../services/legal-api"; import type { Asset } from "../../types/assets"; import type { LegalDocument } from "../../services/legal-api"; import PageHeader from "../../components/ui/PageHeader"; import Button from "../../components/ui/Button"; import Modal from "../../components/ui/Modal"; import { useToast } from "../../hooks/use-toast"; import { PageLayout } from "../../components/layout/PageLayout"; const getFileIcon = (type: string, title: string) => { const lowerType = type.toLowerCase(); const lowerTitle = title.toLowerCase(); if (lowerType.includes('pdf') || lowerTitle.endsWith('.pdf')) { return (
PDF
); } if (lowerType.includes('zip') || lowerType.includes('tar') || lowerTitle.endsWith('.zip') || lowerTitle.endsWith('.rar')) { return (
ZIP
); } if (lowerType.includes('word') || lowerTitle.endsWith('.docx') || lowerTitle.endsWith('.doc')) { return (
DOC
); } if (lowerType.includes('presentation') || lowerTitle.endsWith('.pptx') || lowerTitle.endsWith('.ppt')) { return (
PPT
); } if (lowerType.includes('code') || lowerType.includes('javascript') || lowerType.includes('typescript') || lowerTitle.includes('codebase') || lowerTitle.includes('repository')) { return (
CODE
); } return (
FILE
); }; const STATUS_CONFIG: Record< string, { label: string; color: string; bg: string; border: string } > = { PENDING_ASSETS: { label: "Pending Assets", color: "text-amber-600 bg-amber-50", bg: "bg-amber-50", border: "border-amber-200", }, PENDING_ONBOARDING: { label: "Pending Onboarding", color: "text-ink-500", bg: "bg-ink-50", border: "border-ink-200", }, PENDING_APPROVAL: { label: "Awaiting Approval", color: "text-ink-0 bg-ink-900", bg: "bg-ink-900", border: "border-ink-800", }, APPROVED: { label: "Active", color: "text-ink-900 font-extrabold", bg: "bg-ink-100", border: "border-ink-300", }, }; const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? { label: status, color: "text-ink-500", bg: "bg-ink-100", border: "border-ink-200", }; export const DirectoryPage: React.FC = () => { const { success, error } = useToast(); const [email, setEmail] = useState(""); const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string; } | null>(null); const [isInviteOpen, setIsInviteOpen] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); const ITEMS_PER_PAGE = 10; // New assignment states const [allAssets, setAllAssets] = useState([]); const [allDocs, setAllDocs] = useState([]); const [partnerGroup, setPartnerGroup] = useState(""); const [assignedNdaId, setAssignedNdaId] = useState(""); const [assignedMsaId, setAssignedMsaId] = useState(""); const [mfaRequired, setMfaRequired] = useState(true); const [selectedAssetIds, setSelectedAssetIds] = useState([]); const [inviteAssetSearch, setInviteAssetSearch] = useState(""); // Editing partner states const [editingPartner, setEditingPartner] = useState(null); const [editGroup, setEditGroup] = useState(""); const [editNdaId, setEditNdaId] = useState(""); const [editMsaId, setEditMsaId] = useState(""); const [editMfaEnabled, setEditMfaEnabled] = useState(true); const [editAssetIds, setEditAssetIds] = useState([]); const [editAssetSearch, setEditAssetSearch] = useState(""); const [isEditOpen, setIsEditOpen] = useState(false); const [assetGroups, setAssetGroups] = useState([]); const [selectedPartnerForAssets, setSelectedPartnerForAssets] = useState(null); const [isPartnerAssetsOpen, setIsPartnerAssetsOpen] = useState(false); const [isAddingPartnerAssets, setIsAddingPartnerAssets] = useState(false); const [partnerAssetSearchQuery, setPartnerAssetSearchQuery] = useState(""); const [selectedNewAssetIds, setSelectedNewAssetIds] = useState([]); const [partnerAssetToRemove, setPartnerAssetToRemove] = useState<{ id: string, title: string } | null>(null); useEffect(() => { getAssets().then((assets) => { setAllAssets(assets); setSelectedAssetIds(assets.map(a => a.id)); }).catch(console.error); getLegalDocuments().then(setAllDocs).catch(console.error); getAssetGroups().then(setAssetGroups).catch(console.error); }, []); const renderAssetSelector = ( selectedIds: string[], setSelectedIds: React.Dispatch>, searchQuery: string, setSearchQuery: React.Dispatch> ) => { const filtered = allAssets.filter(asset => asset.title.toLowerCase().includes(searchQuery.toLowerCase()) ); const toggleAll = () => { if (selectedIds.length === allAssets.length) { setSelectedIds([]); } else { setSelectedIds(allAssets.map(a => a.id)); } }; return (
setSearchQuery(e.target.value)} placeholder="Search catalog assets..." className="w-full pl-8 pr-3 py-1.5 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 font-semibold" />
{assetGroups.length > 0 && (
Apply Group: {assetGroups.map((g) => { const hasAll = g.assets.length > 0 && g.assets.every(a => selectedIds.includes(a.id)); return ( ); })}
)}
{filtered.map((asset) => { const isSelected = selectedIds.includes(asset.id); return (
{ if (isSelected) { setSelectedIds(selectedIds.filter((id) => id !== asset.id)); } else { setSelectedIds([...selectedIds, asset.id]); } }} className={`flex items-center justify-between p-2.5 hover:bg-ink-50 cursor-pointer transition-all ${isSelected ? "bg-ink-50/50" : "" }`} >
{getFileIcon(asset.type || "", asset.title)}

{asset.title}

{asset.type.split('/').pop()?.toUpperCase() || 'UNKNOWN'}

); })} {filtered.length === 0 && (

No matching assets found.

)}
); }; const { data: partners = [], isLoading: loadingPartners, refetch: fetchPartners, } = usePartnersQuery(); const inviteMutation = useInvitePartnerMutation(); const updateMutation = useUpdatePartnerMutation(); const resendInviteMutation = useResendInviteMutation(); const [resendingPartnerId, setResendingPartnerId] = useState(null); const handleRemoveAssetFromPartner = (assetId: string, assetTitle: string) => { setPartnerAssetToRemove({ id: assetId, title: assetTitle }); }; const confirmRemoveAssetFromPartner = async () => { if (!selectedPartnerForAssets || !partnerAssetToRemove) return; const assetId = partnerAssetToRemove.id; const assetTitle = partnerAssetToRemove.title; const currentAssetIds = selectedPartnerForAssets.sharedAssets?.map((sa: any) => sa.assetId) || []; const newAssetIds = currentAssetIds.filter((id: string) => id !== assetId); try { const updated = await updateMutation.mutateAsync({ partnerId: selectedPartnerForAssets.id, params: { sharedAssetIds: newAssetIds, partnerGroup: selectedPartnerForAssets.partnerGroup || undefined, assignedNdaId: selectedPartnerForAssets.assignedNdaId === null ? "NONE" : selectedPartnerForAssets.assignedNdaId || undefined, assignedMsaId: selectedPartnerForAssets.assignedMsaId === null ? "NONE" : selectedPartnerForAssets.assignedMsaId || undefined, mfaEnabled: selectedPartnerForAssets.mfaEnabled, } }); success("Asset removed", `"${assetTitle}" is no longer shared with this partner.`); setPartnerAssetToRemove(null); setSelectedPartnerForAssets(updated); } catch (err: any) { error("Failed to remove asset", err.response?.data?.error || err.message || "Something went wrong."); } }; const handleAddAssetsToPartner = async () => { if (!selectedPartnerForAssets || selectedNewAssetIds.length === 0) return; const currentAssetIds = selectedPartnerForAssets.sharedAssets?.map((sa: any) => sa.assetId) || []; const newAssetIds = [...currentAssetIds, ...selectedNewAssetIds]; try { const updated = await updateMutation.mutateAsync({ partnerId: selectedPartnerForAssets.id, params: { sharedAssetIds: newAssetIds, partnerGroup: selectedPartnerForAssets.partnerGroup || undefined, assignedNdaId: selectedPartnerForAssets.assignedNdaId === null ? "NONE" : selectedPartnerForAssets.assignedNdaId || undefined, assignedMsaId: selectedPartnerForAssets.assignedMsaId === null ? "NONE" : selectedPartnerForAssets.assignedMsaId || undefined, mfaEnabled: selectedPartnerForAssets.mfaEnabled, } }); success("Assets added", `Successfully shared ${selectedNewAssetIds.length} new assets with this partner.`); setSelectedNewAssetIds([]); setIsAddingPartnerAssets(false); setSelectedPartnerForAssets(updated); } catch (err: any) { error("Failed to add assets", err.response?.data?.error || err.message || "Something went wrong."); } }; const currentlySharedIds = new Set(selectedPartnerForAssets?.sharedAssets?.map((sa: any) => sa.assetId) || []); const addablePartnerAssets = allAssets.filter(a => !currentlySharedIds.has(a.id)); const filteredAddablePartnerAssets = addablePartnerAssets.filter(a => a.title.toLowerCase().includes(partnerAssetSearchQuery.toLowerCase()) ); const handleResendInvite = async (partnerId: string, partnerEmail: string) => { setResendingPartnerId(partnerId); resendInviteMutation.mutate(partnerId, { onSuccess: () => { success("Invitation sent", `Onboarding email invitation successfully sent to ${partnerEmail}`); setResendingPartnerId(null); }, onError: (err: any) => { const errMsg = err.response?.data?.error || "Failed to resend invite"; error("Failed to resend invite", errMsg); setResendingPartnerId(null); fetchPartners(); } }); }; const handleInvite = async (e: React.FormEvent) => { e.preventDefault(); setInviteResult(null); inviteMutation.mutate({ email, partnerGroup: partnerGroup || undefined, assignedNdaId: assignedNdaId || undefined, assignedMsaId: assignedMsaId || undefined, sharedAssetIds: selectedAssetIds, mfaEnabled: mfaRequired, }, { onSuccess: (data) => { setInviteResult({ token: data.token }); if (data.emailSent === false) { success("Partner invitation created", `Invitation link generated, but the email failed to send: ${data.emailError || "SMTP connection issue"}. You can manually copy the link or click 'Resend Invite' from the partner directory.`); } else { success("Invitation generated successfully", `A secure onboarding link has been created for ${email}.`); } setEmail(""); setPartnerGroup(""); setAssignedNdaId(""); setAssignedMsaId(""); setMfaRequired(true); setSelectedAssetIds([]); fetchPartners(); }, onError: (err: any) => { const errMsg = err.response?.data?.error || "Failed to send invite"; setInviteResult({ error: errMsg, }); error("Invitation failed", errMsg); }, }); }; const handleUpdatePartner = async (e: React.FormEvent) => { e.preventDefault(); if (!editingPartner) return; updateMutation.mutate({ partnerId: editingPartner.id, params: { partnerGroup: editGroup || null as any, assignedNdaId: editNdaId || null, assignedMsaId: editMsaId || null, sharedAssetIds: editAssetIds, mfaEnabled: editMfaEnabled, } }, { onSuccess: () => { success("Partner updated successfully", "The partner information and asset assignments have been saved."); setIsEditOpen(false); setEditingPartner(null); fetchPartners(); }, onError: (err: any) => { const errMsg = err.response?.data?.error || "Failed to update partner"; error("Update failed", errMsg); } }); }; const counts = { total: partners.length, active: partners.filter((p) => p.onboardingStatus === "APPROVED").length, pendingOnboarding: partners.filter( (p) => p.onboardingStatus === "PENDING_ONBOARDING", ).length, pendingAssets: partners.filter( (p) => p.onboardingStatus === "PENDING_ASSETS", ).length, awaitingApproval: partners.filter( (p) => p.onboardingStatus === "PENDING_APPROVAL", ).length, }; const statCards = [ { label: "Total Partners", value: counts.total, icon: Users, accentClass: "", }, { label: "Active", value: counts.active, icon: ShieldCheck, accentClass: "", }, { label: "Pending Onboarding", value: counts.pendingOnboarding, icon: Clock, accentClass: "", }, ]; if (counts.pendingAssets > 0) { statCards.push({ label: "Pending Assets", value: counts.pendingAssets, icon: Clock, accentClass: "border-amber-200/50 bg-amber-50/20", }); } if (counts.awaitingApproval > 0) { statCards.push({ label: "Awaiting Approval", value: counts.awaitingApproval, icon: AlertCircle, accentClass: "border-ink-900/30 bg-ink-900/5 shadow-[0_0_15px_rgba(0,0,0,0.01)] border-dashed animate-pulse", }); } const filteredPartners = partners.filter((partner) => { const matchesSearch = partner.email .toLowerCase() .includes(searchTerm.toLowerCase()); const matchesStatus = statusFilter === "ALL" || partner.onboardingStatus === statusFilter; return matchesSearch && matchesStatus; }); const totalItems = filteredPartners.length; const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1; const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems); const paginatedPartners = filteredPartners.slice(startIndex, endIndex); // Header component const headerNode = ( ); // Toolbar component const toolbarNode = (
{/* Search/Filter & Actions Toolbar */}
{ setSearchTerm(e.target.value); setCurrentPage(1); }} placeholder="Search partners by email..." className="w-full pl-9 pr-4 py-1.5 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold" />
{/* Dynamic Real-time Approval Notification Banner */} {counts.awaitingApproval > 0 && (

Partner approvals pending

There are {counts.awaitingApproval} partners awaiting document review and access authorization.

)}
); // Footer / Pagination component const footerNode = totalItems > 0 ? (

Showing{" "} {startIndex + 1}-{endIndex} {" "} of{" "} {totalItems}{" "} partners

{Array.from({ length: totalPages }).map((_, idx) => { const pageNum = idx + 1; return ( ); })}
) : undefined; return ( {/* Scrollable interior wrapper */}
{/* Stats Row */}
{statCards.map((stat, i) => (

{stat.label}

{stat.value}

))}
{/* Partner Table */}
{loadingPartners ? ( ) : paginatedPartners.length === 0 ? ( ) : ( paginatedPartners.map((partner) => { const sc = getStatusConfig(partner.onboardingStatus); const pGroup = partner.partnerGroup; return ( ); }) )}
Partner Group Assigned Legal Shared Assets Status MFA Joined Actions

No partners yet

Use the invite button to add your first partner.

{partner.email.charAt(0).toUpperCase()}
{partner.email}
{partner.partnerGroup || "Default"}
NDA: {partner.assignedNda?.version || "Active Default"} MSA: {partner.assignedMsa?.version || "Active Default"}
{sc.label} {partner.mfaEnabled ? ( Enabled ) : ( Disabled )} {new Date(partner.createdAt).toLocaleDateString()} {["PENDING_ONBOARDING", "PENDING_ASSETS"].includes(partner.onboardingStatus) && ( <> {partner.inviteToken && ( )} )}
{/* Shared Assets Details Modal */} { setIsPartnerAssetsOpen(false); setSelectedPartnerForAssets(null); setIsAddingPartnerAssets(false); setSelectedNewAssetIds([]); setPartnerAssetSearchQuery(''); }} title="Shared Assets Details" subtitle={`Assets currently shared with ${selectedPartnerForAssets?.email}`} size="lg" >
{/* Header Action */}

{isAddingPartnerAssets ? "Select Assets to Share" : "Currently Shared Assets"}

{isAddingPartnerAssets ? ( /* Add Assets Section */
setPartnerAssetSearchQuery(e.target.value)} placeholder="Search catalog assets..." className="w-full bg-ink-50 border border-ink-200 rounded-lg pl-9 pr-4 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold font-sans" />
{filteredAddablePartnerAssets.length === 0 ? (
No new assets available to share
) : ( filteredAddablePartnerAssets.map(asset => { const isSelected = selectedNewAssetIds.includes(asset.id); return (
{ setSelectedNewAssetIds(prev => prev.includes(asset.id) ? prev.filter(x => x !== asset.id) : [...prev, asset.id] ); }} className={`flex items-center gap-3 p-3 text-xs font-semibold cursor-pointer transition-all hover:bg-ink-50 ${ isSelected ? 'bg-ink-50/70' : '' }`} >
{isSelected && }
{getFileIcon(asset.type || '', asset.title)}

{asset.title}

{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'External Link' : asset.categoryId || 'General'}
); }) )}
) : ( /* Current Shared Assets List */ (() => { const matchedGroups = selectedPartnerForAssets?.partnerGroup ? selectedPartnerForAssets.partnerGroup.split(',').map((s: string) => s.trim().toLowerCase()) : []; // Map inherited assets and keep track of which group name they belong to const inheritedAssetsMap = new Map(); assetGroups .filter(g => matchedGroups.includes(g.name.trim().toLowerCase())) .forEach(g => { g.assets.forEach(asset => { if (!inheritedAssetsMap.has(asset.id)) { inheritedAssetsMap.set(asset.id, { asset, groupName: g.name }); } }); }); const inheritedAssets = Array.from(inheritedAssetsMap.values()); const directAssets = selectedPartnerForAssets?.sharedAssets || []; const totalCount = directAssets.length + inheritedAssets.length; if (totalCount === 0) { return (

No assets shared

There are no assets currently assigned to this partner.

); } return (
{/* Group Inherited Assets */} {inheritedAssets.map(({ asset, groupName }) => (
{getFileIcon(asset.type || '', asset.title)}

{asset.title}

{asset.categoryId || 'General'} Inherited ({groupName})
))} {/* Direct Shared Assets */} {directAssets.map((item: any) => { const asset = item.asset; if (!asset) return null; return (
{getFileIcon(asset.type || '', asset.title)}

{asset.title}

{asset.categoryId || 'General'} {asset.subcategory && ( {asset.subcategory} )}
); })}
); })() )}
{/* Remove Partner Asset Share Confirmation Modal */} setPartnerAssetToRemove(null)} title="Remove Shared Asset" subtitle="Confirm you want to stop sharing this resource." size="sm" >

Are you sure you want to stop sharing "{partnerAssetToRemove?.title}" with this partner?

{/* Invite Modal Overlay */} setIsInviteOpen(false)} title="Invite Partner" subtitle="Generate a secure invitation link for a new partner." size="md" > {!inviteResult?.token ? (
setEmail(e.target.value)} placeholder="partner@company.com" required className="w-full pl-9 pr-4 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all font-semibold" />
setPartnerGroup(e.target.value)} placeholder="e.g. Gold Partner" className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold" />
{selectedAssetIds.length === 0 && ( Invitation won't be sent until at least 1 asset is shared )}
{renderAssetSelector(selectedAssetIds, setSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch)}
{inviteResult?.error && (

{inviteResult.error}

)}
) : (
Invite Created!

{selectedAssetIds.length > 0 ? "An onboarding email with the secure link has been sent via SMTP to the client!" : "Invite created! Email will not be triggered until assets are assigned (Pending Assets state)."}

{window.location.origin}/invite?token={inviteResult.token}
)}
{/* Edit Partner Modal Overlay */} { setIsEditOpen(false); setEditingPartner(null); }} title="Edit Partner Assignment" subtitle={`Update settings and asset access for ${editingPartner?.email || ""}`} size="md" >
setEditGroup(e.target.value)} placeholder="e.g. Gold Partner" className="w-full px-3 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold" />
{editingPartner?.onboardingStatus === "PENDING_ASSETS" && ( Assigning at least 1 asset will trigger SMTP invitation email! )}
{renderAssetSelector(editAssetIds, setEditAssetIds, editAssetSearch, setEditAssetSearch)}
); }; export default DirectoryPage;