Add granular trust center access

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-09-25 14:48:40 +02:00
parent 60d1238534
commit 770a795cd3
27 changed files with 6650 additions and 806 deletions

View File

@@ -17,11 +17,13 @@ import {
IconTrashCan,
IconPencil,
IconCheckmark1,
IconCrossLargeX,
IconChevronDown,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { formatDate } from "@probo/helpers";
import { useOutletContext } from "react-router";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import z from "zod";
import {
useTrustCenterAccesses,
@@ -52,11 +54,12 @@ export default function TrustCenterAccessTab() {
const editSchema = z.object({
name: z.string().min(1, __("Name is required")).min(2, __("Name must be at least 2 characters long")),
active: z.boolean(),
});
const [createInvitation, isCreating] = useMutationWithToasts(createTrustCenterAccessMutation, {
successMessage: __("Access invitation sent successfully"),
errorMessage: __("Failed to send invitation. Please try again."),
successMessage: __("Access created successfully"),
errorMessage: __("Failed to create access. Please try again."),
});
const [updateInvitation, isUpdating] = useMutationWithToasts(updateTrustCenterAccessMutation, {
successMessage: __("Access updated successfully"),
@@ -69,19 +72,38 @@ export default function TrustCenterAccessTab() {
const dialogRef = useDialogRef();
const editDialogRef = useDialogRef();
const [editingAccess, setEditingAccess] = useState<{
id: string;
name: string;
} | null>(null);
const [editingAccess, setEditingAccess] = useState<AccessType | null>(null);
const [selectedDocumentAccesses, setSelectedDocumentAccesses] = useState<Set<string>>(new Set());
const [pendingEditEmail, setPendingEditEmail] = useState<string | null>(null);
const inviteForm = useFormWithSchema(inviteSchema, {
defaultValues: { name: "", email: "" },
});
const editForm = useFormWithSchema(editSchema, {
defaultValues: { name: "" },
defaultValues: { name: "", active: false },
});
type DocumentAccessType = {
id: string;
active: boolean;
document?: {
id: string;
title: string;
documentType: string;
} | null;
report?: {
id: string;
filename: string;
audit: {
id: string;
framework: {
name: string;
};
};
} | null;
};
type AccessType = {
id: string;
email: string;
@@ -89,39 +111,49 @@ export default function TrustCenterAccessTab() {
active: boolean;
hasAcceptedNonDisclosureAgreement: boolean;
createdAt: string;
documentAccesses: DocumentAccessType[];
};
const trustCenterData = useTrustCenterAccesses(organization.trustCenter?.id || "");
const { data: trustCenterData, loadMore, hasNext, isLoadingNext } = useTrustCenterAccesses(organization.trustCenter?.id || "");
const accesses: AccessType[] = trustCenterData?.node?.accesses?.edges?.map(edge => ({
const accesses: AccessType[] = trustCenterData?.node?.accesses?.edges?.map((edge: any) => ({
id: edge.node.id,
email: edge.node.email,
name: edge.node.name,
active: edge.node.active,
hasAcceptedNonDisclosureAgreement: edge.node.hasAcceptedNonDisclosureAgreement,
createdAt: edge.node.createdAt
createdAt: edge.node.createdAt,
documentAccesses: edge.node.documentAccesses?.edges?.map((docEdge: any) => ({
id: docEdge.node.id,
active: docEdge.node.active,
document: docEdge.node.document,
report: docEdge.node.report
})) ?? []
})) ?? [];
const handleInvite = inviteForm.handleSubmit(async (data) => {
if (!organization.trustCenter?.id) {
return;
}
const connectionId = trustCenterData?.node?.accesses?.__id;
const email = data.email.trim();
await createInvitation({
variables: {
input: {
trustCenterId: organization.trustCenter.id,
email: data.email.trim(),
email: email,
name: data.name.trim(),
active: true,
active: false,
},
connections: connectionId ? [connectionId] : [],
},
onSuccess: () => {
dialogRef.current?.close();
inviteForm.reset();
setPendingEditEmail(email);
},
});
});
@@ -137,36 +169,79 @@ export default function TrustCenterAccessTab() {
});
}, [deleteInvitation, trustCenterData]);
const handleToggleActive = useCallback(async (id: string, active: boolean) => {
await updateInvitation({
variables: {
input: { id, active },
},
successMessage: active ? __("Access activated") : __("Access deactivated"),
});
}, [updateInvitation, __]);
const getActiveDocumentIds = useCallback((access: AccessType) => {
return access.documentAccesses
.filter(docAccess => docAccess.active)
.map(docAccess => docAccess.document?.id || docAccess.report?.id)
.filter((id): id is string => id !== undefined);
}, []);
const handleEditAccess = useCallback((access: AccessType) => {
setEditingAccess({ id: access.id, name: access.name });
editForm.reset({ name: access.name });
setEditingAccess(access);
editForm.reset({ name: access.name, active: access.active });
setSelectedDocumentAccesses(new Set(getActiveDocumentIds(access)));
editDialogRef.current?.open();
}, [editDialogRef, editForm]);
}, [editDialogRef, editForm, getActiveDocumentIds]);
useEffect(() => {
if (pendingEditEmail && accesses.length > 0) {
const newAccess = accesses.find(access => access.email === pendingEditEmail);
if (newAccess) {
setPendingEditEmail(null);
setEditingAccess(newAccess);
editForm.reset({ name: newAccess.name, active: true });
setSelectedDocumentAccesses(new Set(getActiveDocumentIds(newAccess)));
editDialogRef.current?.open();
}
}
}, [accesses, pendingEditEmail, editForm, editDialogRef, getActiveDocumentIds]);
const handleToggleDocumentAccess = useCallback((documentId: string, active: boolean) => {
setSelectedDocumentAccesses(prev => {
const newSet = new Set(prev);
if (active) {
newSet.add(documentId);
} else {
newSet.delete(documentId);
}
return newSet;
});
}, []);
const handleUpdateName = editForm.handleSubmit(async (data) => {
if (!editingAccess) return;
const { documentIds, reportIds } = editingAccess.documentAccesses.reduce(
(acc, docAccess) => {
const id = docAccess.document?.id || docAccess.report?.id;
if (id && selectedDocumentAccesses.has(id)) {
if (docAccess.document?.id) {
acc.documentIds.push(docAccess.document.id);
} else if (docAccess.report?.id) {
acc.reportIds.push(docAccess.report.id);
}
}
return acc;
},
{ documentIds: [] as string[], reportIds: [] as string[] }
);
await updateInvitation({
variables: {
input: {
id: editingAccess.id,
name: data.name.trim(),
active: data.active,
documentIds,
reportIds,
},
},
successMessage: __("Name updated successfully"),
onSuccess: () => {
editDialogRef.current?.close();
setEditingAccess(null);
editForm.reset();
setSelectedDocumentAccesses(new Set());
},
});
});
@@ -185,7 +260,7 @@ export default function TrustCenterAccessTab() {
inviteForm.reset();
dialogRef.current?.open();
}}>
{__("Invite")}
{__("Add Access")}
</Button>
)}
</div>
@@ -200,56 +275,87 @@ export default function TrustCenterAccessTab() {
{__("No external access granted yet")}
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Email")}</Th>
<Th>{__("Date")}</Th>
<Th>{__("Active")}</Th>
<Th>{__("NDA")}</Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{accesses.map((access) => (
<Tr key={access.id}>
<Td className="font-medium">{access.name}</Td>
<Td>{access.email}</Td>
<Td>
{formatDate(access.createdAt)}
</Td>
<Td>
<Checkbox
checked={access.active}
onChange={(active) => handleToggleActive(access.id, active)}
/>
</Td>
<Td>
{access.hasAcceptedNonDisclosureAgreement && (
<IconCheckmark1 size={16} className="text-txt-success" />
)}
</Td>
<Td noLink width={160} className="text-end">
<div className="flex gap-2 justify-end">
<Button
variant="secondary"
onClick={() => handleEditAccess(access)}
disabled={isUpdating}
icon={IconPencil}
/>
<Button
variant="secondary"
onClick={() => handleDelete(access.id)}
disabled={isDeleting}
icon={IconTrashCan}
/>
</div>
</Td>
<>
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Email")}</Th>
<Th>{__("Date")}</Th>
<Th>{__("Active")}</Th>
<Th>{__("Documents")}</Th>
<Th>{__("NDA")}</Th>
<Th></Th>
</Tr>
))}
</Tbody>
</Table>
</Thead>
<Tbody>
{accesses.map((access) => {
const activeDocuments = access.documentAccesses.filter(doc => doc.active).length;
const totalDocuments = access.documentAccesses.length;
return (
<Tr
key={access.id}
onClick={() => handleEditAccess(access)}
className="cursor-pointer hover:bg-bg-secondary transition-colors"
>
<Td className="font-medium">{access.name}</Td>
<Td>{access.email}</Td>
<Td>
{formatDate(access.createdAt)}
</Td>
<Td>
{access.active ? (
<IconCheckmark1 size={16} className="text-txt-success" />
) : (
<IconCrossLargeX size={16} className="text-txt-danger" />
)}
</Td>
<Td>
{totalDocuments > 0 ? `${activeDocuments}/${totalDocuments}` : '0/0'}
</Td>
<Td>
{access.hasAcceptedNonDisclosureAgreement && (
<IconCheckmark1 size={16} className="text-txt-success" />
)}
</Td>
<Td noLink width={160} className="text-end">
<div
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="secondary"
onClick={() => handleEditAccess(access)}
disabled={isUpdating}
icon={IconPencil}
/>
<Button
variant="secondary"
onClick={() => handleDelete(access.id)}
disabled={isDeleting}
icon={IconTrashCan}
/>
</div>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
{hasNext && (
<Button
variant="tertiary"
onClick={loadMore}
disabled={isLoadingNext}
className="mt-3 mx-auto"
icon={IconChevronDown}
>
{isLoadingNext && <Spinner />}
{__("Show More")}
</Button>
)}
</>
)}
</Card>
@@ -284,7 +390,7 @@ export default function TrustCenterAccessTab() {
<DialogFooter>
<Button type="submit" disabled={isCreating}>
{isCreating && <Spinner />}
{__("Send Invitation")}
{__("Create Access")}
</Button>
</DialogFooter>
</form>
@@ -292,27 +398,122 @@ export default function TrustCenterAccessTab() {
<Dialog
ref={editDialogRef}
title={__("Edit Access Name")}
title={__("Edit Access")}
>
<form onSubmit={handleUpdateName}>
<DialogContent padded className="space-y-4">
<p className="text-txt-secondary text-sm">
{__("Update the display name for this access invitation")}
</p>
<DialogContent padded className="space-y-6">
<div>
<p className="text-txt-secondary text-sm mb-4">
{__("Update access settings and document permissions")}
</p>
<Field
label={__("Full Name")}
required
error={editForm.formState.errors.name?.message}
{...editForm.register("name")}
placeholder={__("John Doe")}
/>
<Field
label={__("Full Name")}
required
error={editForm.formState.errors.name?.message}
{...editForm.register("name")}
placeholder={__("John Doe")}
/>
<div className="flex items-center justify-between mt-6">
<div>
<label className="font-medium text-txt-primary">
{__("Active Status")}
</label>
<p className="text-sm text-txt-secondary">
{__("Enable or disable access for this user")}
</p>
</div>
<Checkbox
checked={editForm.watch("active")}
onChange={(checked) => editForm.setValue("active", checked)}
/>
</div>
</div>
{editingAccess && editingAccess.documentAccesses.length > 0 && (
<div>
<h4 className="font-medium text-txt-primary mb-4">
{__("Document Access Permissions")}
</h4>
<div className="bg-bg-secondary rounded-lg overflow-hidden">
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("Type")}</Th>
<Th>{__("Category")}</Th>
<Th>
<div className="flex justify-end">
{__("Access")}
</div>
</Th>
</Tr>
</Thead>
<Tbody>
{editingAccess.documentAccesses.map((docAccess) => {
const getDocumentInfo = () => {
const isDocument = !!docAccess.document;
return {
isDocument,
name: docAccess.document?.title || docAccess.report?.filename || __("Unknown Item"),
type: isDocument ? __("Document") : __("Report"),
category: isDocument
? docAccess.document?.documentType
: docAccess.report?.audit?.framework?.name || __("Compliance Report"),
id: docAccess.document?.id || docAccess.report?.id || ''
};
};
const { isDocument, name, type, category, id } = getDocumentInfo();
return (
<Tr key={docAccess.id}>
<Td>
<div className="font-medium text-txt-primary">
{name}
</div>
</Td>
<Td>
<div className="flex items-center space-x-2">
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
isDocument
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}>
{type}
</div>
</div>
</Td>
<Td>
<div className="text-txt-secondary">
{category || "-"}
</div>
</Td>
<Td>
<div className="flex justify-end">
<Checkbox
checked={selectedDocumentAccesses.has(id)}
onChange={(active) => {
if (id) handleToggleDocumentAccess(id, active);
}}
/>
</div>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</div>
</div>
)}
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isUpdating}>
{isUpdating && <Spinner />}
{__("Update Name")}
{__("Update Access")}
</Button>
</DialogFooter>
</form>