1433 lines
60 KiB
TypeScript
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;
|