Tech4biz-channel/Channel-Frontend/src/pages/admin/DirectoryPage.tsx

1433 lines
60 KiB
TypeScript

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 (
<div className="w-8 h-8 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center justify-center text-red-650 shrink-0">
<span className="text-[10px] font-bold">PDF</span>
</div>
);
}
if (lowerType.includes('zip') || lowerType.includes('tar') || lowerTitle.endsWith('.zip') || lowerTitle.endsWith('.rar')) {
return (
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-600 shrink-0">
<span className="text-[10px] font-bold">ZIP</span>
</div>
);
}
if (lowerType.includes('word') || lowerTitle.endsWith('.docx') || lowerTitle.endsWith('.doc')) {
return (
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-650 shrink-0">
<span className="text-[10px] font-bold">DOC</span>
</div>
);
}
if (lowerType.includes('presentation') || lowerTitle.endsWith('.pptx') || lowerTitle.endsWith('.ppt')) {
return (
<div className="w-8 h-8 rounded-lg bg-orange-500/10 border border-orange-500/20 flex items-center justify-center text-orange-650 shrink-0">
<span className="text-[10px] font-bold">PPT</span>
</div>
);
}
if (lowerType.includes('code') || lowerType.includes('javascript') || lowerType.includes('typescript') || lowerTitle.includes('codebase') || lowerTitle.includes('repository')) {
return (
<div className="w-8 h-8 rounded-lg bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-650 shrink-0">
<span className="text-[10px] font-bold">CODE</span>
</div>
);
}
return (
<div className="w-8 h-8 rounded-lg bg-ink-100 border border-ink-200 flex items-center justify-center text-ink-650 shrink-0">
<span className="text-[10px] font-bold">FILE</span>
</div>
);
};
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<Asset[]>([]);
const [allDocs, setAllDocs] = useState<LegalDocument[]>([]);
const [partnerGroup, setPartnerGroup] = useState("");
const [assignedNdaId, setAssignedNdaId] = useState("");
const [assignedMsaId, setAssignedMsaId] = useState("");
const [mfaRequired, setMfaRequired] = useState(true);
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
const [inviteAssetSearch, setInviteAssetSearch] = useState("");
// Editing partner states
const [editingPartner, setEditingPartner] = useState<any>(null);
const [editGroup, setEditGroup] = useState("");
const [editNdaId, setEditNdaId] = useState("");
const [editMsaId, setEditMsaId] = useState("");
const [editMfaEnabled, setEditMfaEnabled] = useState(true);
const [editAssetIds, setEditAssetIds] = useState<string[]>([]);
const [editAssetSearch, setEditAssetSearch] = useState("");
const [isEditOpen, setIsEditOpen] = useState(false);
const [assetGroups, setAssetGroups] = useState<AssetGroup[]>([]);
const [selectedPartnerForAssets, setSelectedPartnerForAssets] = useState<any | null>(null);
const [isPartnerAssetsOpen, setIsPartnerAssetsOpen] = useState(false);
const [isAddingPartnerAssets, setIsAddingPartnerAssets] = useState(false);
const [partnerAssetSearchQuery, setPartnerAssetSearchQuery] = useState("");
const [selectedNewAssetIds, setSelectedNewAssetIds] = useState<string[]>([]);
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<React.SetStateAction<string[]>>,
searchQuery: string,
setSearchQuery: React.Dispatch<React.SetStateAction<string>>
) => {
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 (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="w-3.5 h-3.5 text-ink-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
<button
type="button"
onClick={toggleAll}
className="text-[10px] font-bold text-ink-900 hover:underline px-2.5 py-1.5 border border-ink-200 rounded-lg bg-ink-0 shadow-sm shrink-0 cursor-pointer"
>
{selectedIds.length === allAssets.length ? "Deselect All" : "Select All"}
</button>
</div>
{assetGroups.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center bg-ink-50/50 p-2 rounded-lg border border-ink-200">
<span className="text-[9px] font-bold uppercase tracking-wider text-ink-500 shrink-0">
Apply Group:
</span>
{assetGroups.map((g) => {
const hasAll = g.assets.length > 0 && g.assets.every(a => selectedIds.includes(a.id));
return (
<button
key={g.id}
type="button"
onClick={() => {
const groupAssetIds = g.assets.map(a => a.id);
if (hasAll) {
setSelectedIds(prev => prev.filter(id => !groupAssetIds.includes(id)));
} else {
setSelectedIds(prev => {
const newIds = [...prev];
groupAssetIds.forEach(id => {
if (!newIds.includes(id)) newIds.push(id);
});
return newIds;
});
}
}}
className={`px-2 py-0.5 rounded text-[9px] font-bold transition-all border cursor-pointer ${hasAll
? "bg-ink-900 text-ink-0 border-ink-900 shadow-sm"
: "bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-100"
}`}
>
{g.name}
</button>
);
})}
</div>
)}
<div className="border border-ink-200 rounded-lg bg-ink-0 overflow-hidden shadow-sm">
<div className="max-h-48 overflow-y-auto divide-y divide-ink-100" id="assets-selection-scroll-container">
{filtered.map((asset) => {
const isSelected = selectedIds.includes(asset.id);
return (
<div
key={asset.id}
onClick={() => {
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" : ""
}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{getFileIcon(asset.type || "", asset.title)}
<div className="min-w-0">
<p className="text-xs font-bold text-ink-900 truncate font-sans">
{asset.title}
</p>
<p className="text-[10px] text-ink-400 font-semibold truncate font-sans mt-0.5">
{asset.type.split('/').pop()?.toUpperCase() || 'UNKNOWN'}
</p>
</div>
</div>
<div className="flex items-center shrink-0 pl-3">
<input
type="checkbox"
checked={isSelected}
readOnly
className="rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 w-4 h-4 cursor-pointer"
/>
</div>
</div>
);
})}
{filtered.length === 0 && (
<div className="p-6 text-center">
<p className="text-xs font-semibold text-ink-400 font-sans">
No matching assets found.
</p>
</div>
)}
</div>
</div>
</div>
);
};
const {
data: partners = [],
isLoading: loadingPartners,
refetch: fetchPartners,
} = usePartnersQuery();
const inviteMutation = useInvitePartnerMutation();
const updateMutation = useUpdatePartnerMutation();
const resendInviteMutation = useResendInviteMutation();
const [resendingPartnerId, setResendingPartnerId] = useState<string | null>(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 = (
<PageHeader
title="Partner Directory"
subtitle="Manage your network and invite new partners to the platform."
/>
);
// Toolbar component
const toolbarNode = (
<div className="flex flex-col space-y-3 shrink-0">
{/* Search/Filter & Actions Toolbar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3.5 p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-1 max-w-xl">
<div className="relative flex-1">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchTerm}
onChange={(e) => {
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"
/>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setCurrentPage(1);
}}
className="bg-ink-50 border border-ink-200 rounded-lg px-2.5 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-700 font-sans"
>
<option value="ALL">All Statuses</option>
<option value="APPROVED">Active</option>
<option value="PENDING_ASSETS">Pending Assets</option>
<option value="PENDING_ONBOARDING">Pending Onboarding</option>
<option value="PENDING_APPROVAL">Awaiting Approval</option>
</select>
</div>
<div className="flex items-center gap-2 shrink-0 justify-end w-full sm:w-auto">
<Button
onClick={() => {
setInviteResult(null);
setEmail("");
setPartnerGroup("");
setAssignedNdaId("");
setAssignedMsaId("");
setMfaRequired(true);
setSelectedAssetIds(allAssets.map(a => a.id));
setInviteAssetSearch("");
setIsInviteOpen(true);
}}
variant="primary"
size="sm"
icon={<UserPlus className="w-4 h-4" />}
>
Invite Partner
</Button>
<Button
onClick={() => {
fetchPartners();
}}
variant="ghost"
size="sm"
icon={<RefreshCw className="w-4 h-4" />}
title="Refresh"
>
Refresh
</Button>
</div>
</div>
{/* Dynamic Real-time Approval Notification Banner */}
{counts.awaitingApproval > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between p-4 bg-ink-900 border border-ink-800 rounded-xl shadow-sm text-ink-0 relative overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
<div className="flex items-center gap-3 relative z-10">
<div className="w-8 h-8 rounded-lg bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
<AlertCircle className="w-4 h-4 animate-bounce" />
</div>
<div>
<h4 className="font-bold text-sm text-ink-0">
Partner approvals pending
</h4>
<p className="text-xs text-ink-300 mt-0.5">
There are {counts.awaitingApproval} partners awaiting document
review and access authorization.
</p>
</div>
</div>
<Link to="/admin/approvals" className="relative z-10 shrink-0">
<Button
variant="secondary"
size="sm"
icon={<ChevronRight className="w-3.5 h-3.5 order-last" />}
>
Review Queue
</Button>
</Link>
</motion.div>
)}
</div>
);
// Footer / Pagination component
const footerNode = totalItems > 0 ? (
<div className="px-4 py-3 bg-ink-50 border border-ink-200 rounded-xl flex flex-col sm:flex-row justify-between items-center gap-4 text-xs font-semibold text-ink-500 shadow-sm">
<p>
Showing{" "}
<span className="text-ink-900 font-extrabold">
{startIndex + 1}-{endIndex}
</span>{" "}
of{" "}
<span className="text-ink-900 font-extrabold">{totalItems}</span>{" "}
partners
</p>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
variant="ghost"
size="xs"
>
Previous
</Button>
{Array.from({ length: totalPages }).map((_, idx) => {
const pageNum = idx + 1;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-7 h-7 rounded-md flex items-center justify-center font-bold transition-all cursor-pointer ${currentPage === pageNum
? "bg-ink-900 text-ink-0 shadow-sm"
: "border border-ink-200 bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 text-xs"
}`}
>
{pageNum}
</button>
);
})}
<Button
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages}
variant="ghost"
size="xs"
>
Next
</Button>
</div>
</div>
) : undefined;
return (
<PageLayout header={headerNode} toolbar={toolbarNode} footer={footerNode}>
{/* Scrollable interior wrapper */}
<div className="p-5 space-y-6 flex flex-col min-h-0 flex-1">
{/* Stats Row */}
<div
className={`grid gap-4 shrink-0 ${statCards.length === 3
? "grid-cols-1 md:grid-cols-3"
: statCards.length === 4
? "grid-cols-2 md:grid-cols-4"
: "grid-cols-2 md:grid-cols-5"
}`}
>
{statCards.map((stat, i) => (
<div
key={i}
className={`bg-ink-0 rounded-xl border p-4 shadow-sm transition-all duration-300 ${stat.accentClass || "border-ink-200"}`}
>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">
{stat.label}
</p>
<stat.icon
className={`w-4 h-4 ${stat.accentClass ? "text-ink-900 animate-pulse" : "text-ink-400"}`}
/>
</div>
<p className="text-xl font-bold tracking-tight text-ink-900">
{stat.value}
</p>
</div>
))}
</div>
{/* Partner Table */}
<div className="flex-1 min-h-0 w-full overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs sticky top-0 z-10">
<tr>
<th className="px-5 py-3">Partner</th>
<th className="px-5 py-3">Group</th>
<th className="px-5 py-3">Assigned Legal</th>
<th className="px-5 py-3">Shared Assets</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3 hidden sm:table-cell">MFA</th>
<th className="px-5 py-3 hidden sm:table-cell">Joined</th>
<th className="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200 bg-ink-0">
{loadingPartners ? (
<tr>
<td colSpan={8} className="px-5 py-12 text-center">
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
</td>
</tr>
) : paginatedPartners.length === 0 ? (
<tr>
<td colSpan={8} className="px-5 py-12 text-center">
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3 border border-ink-200">
<Users className="w-6 h-6 text-ink-400" />
</div>
<p className="text-sm font-bold text-ink-900">
No partners yet
</p>
<p className="text-xs text-ink-500 mt-1">
Use the invite button to add your first partner.
</p>
</td>
</tr>
) : (
paginatedPartners.map((partner) => {
const sc = getStatusConfig(partner.onboardingStatus);
const pGroup = partner.partnerGroup;
return (
<tr
key={partner.id}
className="hover:bg-ink-50 transition-colors"
>
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-ink-900 text-sm">
{partner.email}
</span>
</div>
</td>
<td className="px-5 py-4">
<span className="text-xs px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-ink-700 font-bold">
{partner.partnerGroup || "Default"}
</span>
</td>
<td className="px-5 py-4">
<div className="text-xs font-semibold text-ink-600 flex flex-col gap-0.5">
<span>NDA: {partner.assignedNda?.version || "Active Default"}</span>
<span>MSA: {partner.assignedMsa?.version || "Active Default"}</span>
</div>
</td>
<td className="px-5 py-4">
<button
onClick={() => {
setSelectedPartnerForAssets(partner);
setIsPartnerAssetsOpen(true);
}}
className="text-xs font-bold text-ink-700 hover:text-ink-950 hover:underline cursor-pointer focus:outline-none flex flex-col items-start gap-0.5"
>
<span>
{(() => {
const directCount = partner.sharedAssets?.length || 0;
if (!pGroup) return directCount;
const pGroupNames = pGroup.split(',').map(s => s.trim().toLowerCase());
const uniqueGroupAssetIds = new Set<string>();
assetGroups
.filter(g => pGroupNames.includes(g.name.trim().toLowerCase()))
.forEach(g => g.assets.forEach(a => uniqueGroupAssetIds.add(a.id)));
return directCount + uniqueGroupAssetIds.size;
})()} assets
</span>
{pGroup && assetGroups.some(g => pGroup.split(',').map(s => s.trim().toLowerCase()).includes(g.name.trim().toLowerCase())) && (
<span className="text-[9px] text-amber-600 font-extrabold uppercase tracking-wide">
Includes {pGroup}
</span>
)}
</button>
</td>
<td className="px-5 py-4">
<span
className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}
>
{sc.label}
</span>
</td>
<td className="px-5 py-4 hidden sm:table-cell">
{partner.mfaEnabled ? (
<span className="text-xs font-bold text-ink-900">
Enabled
</span>
) : (
<span className="text-xs font-bold text-ink-400">
Disabled
</span>
)}
</td>
<td className="px-5 py-4 text-xs text-ink-500 font-medium hidden sm:table-cell">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
<td className="px-5 py-4 text-right flex items-center justify-end gap-1">
{["PENDING_ONBOARDING", "PENDING_ASSETS"].includes(partner.onboardingStatus) && (
<>
<button
onClick={() => handleResendInvite(partner.id, partner.email)}
disabled={resendingPartnerId === partner.id}
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer disabled:opacity-55 disabled:cursor-not-allowed"
title="Resend Invitation Email"
>
{resendingPartnerId === partner.id ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Mail className="w-4 h-4" />
)}
</button>
{partner.inviteToken && (
<button
onClick={() => {
const link = `${window.location.origin}/invite?token=${partner.inviteToken}`;
navigator.clipboard.writeText(link);
success("Link copied", "Onboarding invitation link copied to clipboard!");
}}
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
title="Copy Invitation Link"
>
<Copy className="w-4 h-4" />
</button>
)}
</>
)}
<button
onClick={() => {
setEditingPartner(partner);
setEditGroup(partner.partnerGroup || "");
setEditNdaId(partner.assignedNdaId === null ? "NONE" : partner.assignedNdaId || "");
setEditMsaId(partner.assignedMsaId === null ? "NONE" : partner.assignedMsaId || "");
setEditMfaEnabled(partner.mfaEnabled);
setEditAssetIds(partner.sharedAssets?.map((sa: any) => sa.assetId) || []);
setIsEditOpen(true);
}}
className="p-1.5 text-ink-600 hover:text-ink-950 hover:bg-ink-100 rounded-lg transition-colors cursor-pointer"
>
<Pencil className="w-4 h-4" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Shared Assets Details Modal */}
<Modal
isOpen={isPartnerAssetsOpen}
onClose={() => {
setIsPartnerAssetsOpen(false);
setSelectedPartnerForAssets(null);
setIsAddingPartnerAssets(false);
setSelectedNewAssetIds([]);
setPartnerAssetSearchQuery('');
}}
title="Shared Assets Details"
subtitle={`Assets currently shared with ${selectedPartnerForAssets?.email}`}
size="lg"
>
<div className="space-y-4 font-sans">
{/* Header Action */}
<div className="flex justify-between items-center">
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-500">
{isAddingPartnerAssets ? "Select Assets to Share" : "Currently Shared Assets"}
</h4>
<Button
onClick={() => {
setIsAddingPartnerAssets(!isAddingPartnerAssets);
setSelectedNewAssetIds([]);
setPartnerAssetSearchQuery('');
}}
variant={isAddingPartnerAssets ? "secondary" : "primary"}
size="xs"
className="flex items-center gap-1 font-sans cursor-pointer"
>
{isAddingPartnerAssets ? (
<span>Back to Shared List</span>
) : (
<>
<Plus className="w-3 h-3" />
<span>Share Assets</span>
</>
)}
</Button>
</div>
{isAddingPartnerAssets ? (
/* Add Assets Section */
<div className="space-y-4">
<div className="relative">
<Search className="w-4 h-4 text-ink-450 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={partnerAssetSearchQuery}
onChange={e => 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"
/>
</div>
<div className="border border-ink-200 rounded-xl overflow-hidden max-h-[300px] overflow-y-auto divide-y divide-ink-200 bg-ink-0">
{filteredAddablePartnerAssets.length === 0 ? (
<div className="p-8 text-center text-xs text-ink-400 font-medium font-sans">
No new assets available to share
</div>
) : (
filteredAddablePartnerAssets.map(asset => {
const isSelected = selectedNewAssetIds.includes(asset.id);
return (
<div
key={asset.id}
onClick={() => {
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' : ''
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? 'border-ink-900 bg-ink-900 text-ink-0'
: 'border-ink-200 bg-ink-50'
}`}>
{isSelected && <CheckCircle className="w-3 h-3 stroke-[3]" />}
</div>
{getFileIcon(asset.type || '', asset.title)}
<div className="min-w-0 flex-1 font-sans">
<p className="truncate text-ink-900">{asset.title}</p>
<span className="text-[9px] text-ink-450 uppercase tracking-wider font-sans">
{asset.type === 'case_study' ? 'Case Study' : asset.type === 'url' ? 'External Link' : asset.categoryId || 'General'}
</span>
</div>
</div>
);
})
)}
</div>
<div className="flex gap-3 justify-end font-sans">
<Button
onClick={() => {
setIsAddingPartnerAssets(false);
setSelectedNewAssetIds([]);
setPartnerAssetSearchQuery('');
}}
variant="secondary"
size="sm"
>
Cancel
</Button>
<Button
onClick={handleAddAssetsToPartner}
variant="primary"
size="sm"
disabled={selectedNewAssetIds.length === 0}
>
Share {selectedNewAssetIds.length} Asset{selectedNewAssetIds.length !== 1 ? 's' : ''}
</Button>
</div>
</div>
) : (
/* 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<string, { asset: any, groupName: string }>();
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 (
<div className="text-center py-10 bg-ink-50/50 border border-dashed border-ink-200 rounded-xl">
<Folder className="w-10 h-10 text-ink-300 mx-auto mb-2" />
<p className="text-xs font-bold text-ink-900 font-sans">No assets shared</p>
<p className="text-[10px] text-ink-450 mt-1 font-sans">There are no assets currently assigned to this partner.</p>
</div>
);
}
return (
<div className="border border-ink-200 rounded-xl overflow-hidden divide-y divide-ink-200 bg-ink-0 max-h-[350px] overflow-y-auto">
{/* Group Inherited Assets */}
{inheritedAssets.map(({ asset, groupName }) => (
<div key={`group-asset-${asset.id}`} className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors font-sans">
<div className="flex items-center gap-3 min-w-0">
{getFileIcon(asset.type || '', asset.title)}
<div className="min-w-0 font-sans">
<p className="text-xs font-bold text-ink-900 truncate">{asset.title}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-100 border border-ink-200 text-ink-600 font-bold uppercase tracking-wider">
{asset.categoryId || 'General'}
</span>
<span className="text-[8px] px-1.5 py-0.2 rounded bg-amber-500/10 border border-amber-500/20 text-amber-700 font-extrabold uppercase tracking-wider">
Inherited ({groupName})
</span>
</div>
</div>
</div>
<div className="p-1.5 text-ink-400 cursor-not-allowed shrink-0 ml-4" title={`Inherited via ${groupName} permissions (Read-only)`}>
<Lock className="w-4 h-4 text-ink-400" />
</div>
</div>
))}
{/* Direct Shared Assets */}
{directAssets.map((item: any) => {
const asset = item.asset;
if (!asset) return null;
return (
<div key={item.assetId} className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors font-sans">
<div className="flex items-center gap-3 min-w-0">
{getFileIcon(asset.type || '', asset.title)}
<div className="min-w-0 font-sans">
<p className="text-xs font-bold text-ink-900 truncate">{asset.title}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-100 border border-ink-200 text-ink-600 font-bold uppercase tracking-wider">
{asset.categoryId || 'General'}
</span>
{asset.subcategory && (
<span className="text-[8px] px-1.5 py-0.2 rounded bg-ink-50 border border-ink-150 text-ink-500 font-semibold">
{asset.subcategory}
</span>
)}
</div>
</div>
</div>
<button
onClick={() => handleRemoveAssetFromPartner(item.assetId, asset.title)}
className="p-1.5 text-red-500 hover:text-red-750 hover:bg-red-500/10 rounded-lg transition-colors cursor-pointer shrink-0 ml-4"
title="Remove Share"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
})}
</div>
);
})()
)}
</div>
</Modal>
{/* Remove Partner Asset Share Confirmation Modal */}
<Modal
isOpen={partnerAssetToRemove !== null}
onClose={() => setPartnerAssetToRemove(null)}
title="Remove Shared Asset"
subtitle="Confirm you want to stop sharing this resource."
size="sm"
>
<div className="space-y-4 font-sans">
<p className="text-xs text-ink-600 leading-relaxed">
Are you sure you want to stop sharing <span className="font-bold text-ink-900">"{partnerAssetToRemove?.title}"</span> with this partner?
</p>
<div className="flex gap-3 justify-end pt-2">
<Button
onClick={() => setPartnerAssetToRemove(null)}
variant="secondary"
size="sm"
>
Cancel
</Button>
<Button
onClick={confirmRemoveAssetFromPartner}
variant="danger"
size="sm"
>
Stop Sharing
</Button>
</div>
</div>
</Modal>
{/* Invite Modal Overlay */}
<Modal
isOpen={isInviteOpen}
onClose={() => setIsInviteOpen(false)}
title="Invite Partner"
subtitle="Generate a secure invitation link for a new partner."
size="md"
>
{!inviteResult?.token ? (
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Email Address
</label>
<div className="relative">
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Partner Group / Category
</label>
<input
type="text"
value={partnerGroup}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Assign NDA Version
</label>
<select
value={assignedNdaId}
onChange={(e) => setAssignedNdaId(e.target.value)}
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-700 font-semibold"
>
<option value="">Active Default version</option>
<option value="NONE">None (No agreement required)</option>
{allDocs
.filter((d) => d.type === "NDA")
.map((d) => (
<option key={d.id} value={d.id}>
v{d.version} ({d.pdfUrl ? "PDF" : "Text"})
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Assign MSA Version
</label>
<select
value={assignedMsaId}
onChange={(e) => setAssignedMsaId(e.target.value)}
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-700 font-semibold"
>
<option value="">Active Default version</option>
<option value="NONE">None (No agreement required)</option>
{allDocs
.filter((d) => d.type === "MSA")
.map((d) => (
<option key={d.id} value={d.id}>
v{d.version} ({d.pdfUrl ? "PDF" : "Text"})
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
MFA Required
</label>
<select
value={mfaRequired ? "true" : "false"}
onChange={(e) => setMfaRequired(e.target.value === "true")}
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-700 font-semibold"
>
<option value="true">Yes (Enabled)</option>
<option value="false">No (Disabled)</option>
</select>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-xs font-semibold text-ink-500">
Select Assets to Share
</label>
{selectedAssetIds.length === 0 && (
<span className="text-[10px] font-bold text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full border border-amber-200">
Invitation won't be sent until at least 1 asset is shared
</span>
)}
</div>
{renderAssetSelector(selectedAssetIds, setSelectedAssetIds, inviteAssetSearch, setInviteAssetSearch)}
</div>
{inviteResult?.error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertCircle className="w-4 h-4 text-red-650 shrink-0 mt-0.5" />
<p className="text-xs font-bold text-red-650">
{inviteResult.error}
</p>
</div>
)}
<div className="flex items-center gap-3 pt-2">
<Button
type="button"
onClick={() => setIsInviteOpen(false)}
variant="ghost"
size="sm"
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={inviteMutation.isPending || !email}
variant="primary"
size="sm"
className="flex-1"
icon={<ChevronRight className="w-4 h-4 order-last" />}
>
{inviteMutation.isPending ? "Generating..." : "Generate"}
</Button>
</div>
</form>
) : (
<div className="space-y-6">
<div className="p-3 bg-ink-100 border border-ink-300 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900">
Invite Created!
</span>
</div>
<p className="text-xs text-ink-500 mb-2 font-medium">
{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)."}
</p>
<div className="p-2.5 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900 select-all">
{window.location.origin}/invite?token={inviteResult.token}
</div>
</div>
<Button
type="button"
onClick={() => {
setIsInviteOpen(false);
setEmail("");
setInviteResult(null);
}}
variant="primary"
size="sm"
className="w-full"
>
Done
</Button>
</div>
)}
</Modal>
{/* Edit Partner Modal Overlay */}
<Modal
isOpen={isEditOpen}
onClose={() => {
setIsEditOpen(false);
setEditingPartner(null);
}}
title="Edit Partner Assignment"
subtitle={`Update settings and asset access for ${editingPartner?.email || ""}`}
size="md"
>
<form onSubmit={handleUpdatePartner} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Partner Group / Category
</label>
<input
type="text"
value={editGroup}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Assign NDA Version
</label>
<select
value={editNdaId}
onChange={(e) => setEditNdaId(e.target.value)}
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-700 font-semibold"
>
<option value="">Active Default version</option>
<option value="NONE">None (No agreement required)</option>
{allDocs
.filter((d) => d.type === "NDA")
.map((d) => (
<option key={d.id} value={d.id}>
v{d.version} ({d.pdfUrl ? "PDF" : "Text"})
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Assign MSA Version
</label>
<select
value={editMsaId}
onChange={(e) => setEditMsaId(e.target.value)}
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-700 font-semibold"
>
<option value="">Active Default version</option>
<option value="NONE">None (No agreement required)</option>
{allDocs
.filter((d) => d.type === "MSA")
.map((d) => (
<option key={d.id} value={d.id}>
v{d.version} ({d.pdfUrl ? "PDF" : "Text"})
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
MFA Required
</label>
<select
value={editMfaEnabled ? "true" : "false"}
onChange={(e) => setEditMfaEnabled(e.target.value === "true")}
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-700 font-semibold"
>
<option value="true">Yes (Enabled)</option>
<option value="false">No (Disabled)</option>
</select>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-xs font-semibold text-ink-500">
Shared Assets
</label>
{editingPartner?.onboardingStatus === "PENDING_ASSETS" && (
<span className="text-[10px] font-bold text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-full border border-amber-200 animate-pulse">
Assigning at least 1 asset will trigger SMTP invitation email!
</span>
)}
</div>
{renderAssetSelector(editAssetIds, setEditAssetIds, editAssetSearch, setEditAssetSearch)}
</div>
<div className="flex items-center gap-3 pt-2">
<Button
type="button"
onClick={() => {
setIsEditOpen(false);
setEditingPartner(null);
}}
variant="ghost"
size="sm"
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={updateMutation.isPending}
variant="primary"
size="sm"
className="flex-1"
>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
</Modal>
</PageLayout>
);
};
export default DirectoryPage;