Split TC access tab in several files
Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
14
apps/console/src/coredata/TrustCenterAccess.ts
Normal file
14
apps/console/src/coredata/TrustCenterAccess.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { TrustCenterDocumentAccess } from "./TrustCenterDocumentAccess";
|
||||||
|
|
||||||
|
export interface TrustCenterAccess {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
hasAcceptedNonDisclosureAgreement: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastTokenExpiresAt: string | null;
|
||||||
|
pendingRequestCount: number;
|
||||||
|
activeCount: number;
|
||||||
|
documentAccesses?: TrustCenterDocumentAccess[];
|
||||||
|
};
|
||||||
25
apps/console/src/coredata/TrustCenterDocumentAccess.ts
Normal file
25
apps/console/src/coredata/TrustCenterDocumentAccess.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type TrustCenterDocumentAccess = {
|
||||||
|
active: boolean;
|
||||||
|
status: string;
|
||||||
|
requested: boolean;
|
||||||
|
document?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
documentType: string;
|
||||||
|
} | null;
|
||||||
|
report?: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
audit?: {
|
||||||
|
id: string;
|
||||||
|
framework: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
trustCenterFile?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
Field,
|
|
||||||
Spinner,
|
|
||||||
Table,
|
|
||||||
Tbody,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
Thead,
|
|
||||||
Tr,
|
|
||||||
useDialogRef,
|
|
||||||
IconTrashCan,
|
|
||||||
IconPencil,
|
|
||||||
IconCheckmark1,
|
|
||||||
IconCrossLargeX,
|
|
||||||
IconChevronDown,
|
|
||||||
IconPlusLarge,
|
|
||||||
} from "@probo/ui";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
|
||||||
import { formatDate } from "@probo/helpers";
|
|
||||||
import { useOutletContext } from "react-router";
|
|
||||||
import { useState, useCallback, useEffect, useRef, use, useMemo } from "react";
|
|
||||||
import { useQueryLoader, usePreloadedQuery, type PreloadedQuery } from 'react-relay';
|
|
||||||
import z from "zod";
|
|
||||||
import {
|
|
||||||
useTrustCenterAccesses,
|
|
||||||
createTrustCenterAccessMutation,
|
|
||||||
updateTrustCenterAccessMutation,
|
|
||||||
deleteTrustCenterAccessMutation,
|
|
||||||
loadTrustCenterAccessDocumentAccessesQuery
|
|
||||||
} from "/hooks/graph/TrustCenterAccessGraph";
|
|
||||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
|
||||||
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
|
||||||
import { PermissionsContext } from "/providers/PermissionsContext";
|
|
||||||
import type { TrustCenterAccessGraphLoadDocumentAccessesQuery } from "/hooks/graph/__generated__/TrustCenterAccessGraphLoadDocumentAccessesQuery.graphql";
|
|
||||||
|
|
||||||
type ContextType = {
|
|
||||||
organization: {
|
|
||||||
id: string;
|
|
||||||
trustCenter?: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
documents?: {
|
|
||||||
edges: Array<{
|
|
||||||
node: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
documentType: string;
|
|
||||||
trustCenterVisibility: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
audits?: {
|
|
||||||
edges: Array<{
|
|
||||||
node: {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
trustCenterVisibility: string;
|
|
||||||
framework: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
trustCenterFiles?: {
|
|
||||||
edges: Array<{
|
|
||||||
node: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
trustCenterVisibility: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type DocumentAccessInfo = {
|
|
||||||
active: boolean;
|
|
||||||
status: string;
|
|
||||||
requested: boolean;
|
|
||||||
document?: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
documentType: string;
|
|
||||||
} | null;
|
|
||||||
report?: {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
audit?: {
|
|
||||||
id: string;
|
|
||||||
framework: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
trustCenterFile?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function DocumentAccessesLoader({
|
|
||||||
queryReference,
|
|
||||||
onDataLoaded
|
|
||||||
}: {
|
|
||||||
queryReference: PreloadedQuery<TrustCenterAccessGraphLoadDocumentAccessesQuery>;
|
|
||||||
onDataLoaded: (documentAccesses: DocumentAccessInfo[]) => void;
|
|
||||||
}) {
|
|
||||||
const data = usePreloadedQuery(loadTrustCenterAccessDocumentAccessesQuery, queryReference);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data && typeof data === 'object' && 'node' in data) {
|
|
||||||
const node = data.node;
|
|
||||||
if (node?.availableDocumentAccesses?.edges) {
|
|
||||||
const documentAccesses: DocumentAccessInfo[] = node.availableDocumentAccesses.edges.map((edge) => edge.node);
|
|
||||||
onDataLoaded(documentAccesses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [data, onDataLoaded]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TrustCenterAccessTab() {
|
|
||||||
const { __ } = useTranslate();
|
|
||||||
const { organization } = useOutletContext<ContextType>();
|
|
||||||
const { isAuthorized } = use(PermissionsContext);
|
|
||||||
const inviteSchema = z.object({
|
|
||||||
name: z.string().min(1, __("Name is required")).min(2, __("Name must be at least 2 characters long")),
|
|
||||||
email: z.string().min(1, __("Email is required")).email(__("Please enter a valid email address")),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 created successfully"),
|
|
||||||
errorMessage: __("Failed to create access"),
|
|
||||||
});
|
|
||||||
const [updateInvitation, isUpdating] = useMutationWithToasts(updateTrustCenterAccessMutation, {
|
|
||||||
successMessage: __("Access updated successfully"),
|
|
||||||
errorMessage: __("Failed to update access"),
|
|
||||||
});
|
|
||||||
const [deleteInvitation, isDeleting] = useMutationWithToasts(deleteTrustCenterAccessMutation, {
|
|
||||||
successMessage: __("Access deleted successfully"),
|
|
||||||
errorMessage: __("Failed to delete access"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialogRef = useDialogRef();
|
|
||||||
const editDialogRef = useDialogRef();
|
|
||||||
const [editingAccess, setEditingAccess] = useState<AccessType | null>(null);
|
|
||||||
const [editingDocumentAccesses, setEditingDocumentAccesses] = useState<DocumentAccessInfo[]>([]);
|
|
||||||
const [selectedDocumentAccesses, setSelectedDocumentAccesses] = useState<Set<string>>(new Set());
|
|
||||||
const [pendingEditEmail, setPendingEditEmail] = useState<string | null>(null);
|
|
||||||
const [documentAccessesQueryReference, loadDocumentAccessesQuery] = useQueryLoader<TrustCenterAccessGraphLoadDocumentAccessesQuery>(loadTrustCenterAccessDocumentAccessesQuery);
|
|
||||||
const loadedAccessIdRef = useRef<string | null>(null);
|
|
||||||
const [isLoadingDocumentAccesses, setIsLoadingDocumentAccesses] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingAccess?.id && loadedAccessIdRef.current !== editingAccess.id) {
|
|
||||||
loadedAccessIdRef.current = editingAccess.id;
|
|
||||||
setIsLoadingDocumentAccesses(true);
|
|
||||||
loadDocumentAccessesQuery({ accessId: editingAccess.id }, { fetchPolicy: 'network-only' });
|
|
||||||
}
|
|
||||||
}, [editingAccess?.id, loadDocumentAccessesQuery]);
|
|
||||||
|
|
||||||
const handleDocumentAccessesLoaded = useCallback((documentAccesses: DocumentAccessInfo[]) => {
|
|
||||||
setEditingDocumentAccesses(documentAccesses);
|
|
||||||
|
|
||||||
const activeIds = new Set<string>(
|
|
||||||
documentAccesses
|
|
||||||
.filter((docAccess) => docAccess.active)
|
|
||||||
.map((docAccess) => docAccess.document?.id || docAccess.report?.id || docAccess.trustCenterFile?.id)
|
|
||||||
.filter((id: unknown): id is string => typeof id === 'string')
|
|
||||||
);
|
|
||||||
setSelectedDocumentAccesses(activeIds);
|
|
||||||
setIsLoadingDocumentAccesses(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formattedDocumentAccesses: NonNullable<ReturnType<typeof getDocumentAccessInfo>>[] = editingDocumentAccesses
|
|
||||||
?.map((docAccess) => getDocumentAccessInfo(docAccess, __))
|
|
||||||
?.filter((info) => info !== null) ?? [];
|
|
||||||
|
|
||||||
const inviteForm = useFormWithSchema(inviteSchema, {
|
|
||||||
defaultValues: { name: "", email: "" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const editForm = useFormWithSchema(editSchema, {
|
|
||||||
defaultValues: { name: "", active: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
function getDocumentAccessInfo(
|
|
||||||
docAccess: DocumentAccessInfo,
|
|
||||||
__: (key: string) => string
|
|
||||||
) {
|
|
||||||
if (docAccess.document) {
|
|
||||||
return {
|
|
||||||
variant: "info" as const,
|
|
||||||
name: docAccess.document?.title,
|
|
||||||
type: __("Document"),
|
|
||||||
category: docAccess.document?.documentType,
|
|
||||||
id: docAccess.document?.id,
|
|
||||||
requested: docAccess.requested,
|
|
||||||
active: docAccess.active,
|
|
||||||
status: docAccess.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (docAccess.report) {
|
|
||||||
return {
|
|
||||||
variant: "success" as const,
|
|
||||||
name: docAccess.report?.filename,
|
|
||||||
type: __("Report"),
|
|
||||||
category: docAccess.report?.audit?.framework?.name,
|
|
||||||
id: docAccess.report?.id,
|
|
||||||
requested: docAccess.requested,
|
|
||||||
active: docAccess.active,
|
|
||||||
status: docAccess.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (docAccess.trustCenterFile) {
|
|
||||||
return {
|
|
||||||
variant: "highlight" as const,
|
|
||||||
name: docAccess.trustCenterFile?.name,
|
|
||||||
type: __("File"),
|
|
||||||
category: docAccess.trustCenterFile?.category,
|
|
||||||
id: docAccess.trustCenterFile?.id,
|
|
||||||
requested: docAccess.requested,
|
|
||||||
active: docAccess.active,
|
|
||||||
status: docAccess.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unknown trust center access document type");
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccessType = {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
active: boolean;
|
|
||||||
hasAcceptedNonDisclosureAgreement: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
lastTokenExpiresAt: string | null;
|
|
||||||
pendingRequestCount: number;
|
|
||||||
activeCount: number;
|
|
||||||
documentAccesses?: DocumentAccessInfo[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: trustCenterData, loadMore, hasNext, isLoadingNext } = useTrustCenterAccesses(organization.trustCenter?.id || "");
|
|
||||||
|
|
||||||
const accesses: AccessType[] = useMemo(
|
|
||||||
() => trustCenterData?.accesses?.edges.map((edge) => edge.node) ?? [], [trustCenterData?.accesses?.edges]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInvite = inviteForm.handleSubmit(async (data) => {
|
|
||||||
if (!organization.trustCenter?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionId = trustCenterData?.accesses?.__id;
|
|
||||||
const email = data.email.trim();
|
|
||||||
|
|
||||||
await createInvitation({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
trustCenterId: organization.trustCenter.id,
|
|
||||||
email: email,
|
|
||||||
name: data.name.trim(),
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
connections: connectionId ? [connectionId] : [],
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setPendingEditEmail(email);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDelete = useCallback(async (id: string) => {
|
|
||||||
const connectionId = trustCenterData?.accesses?.__id;
|
|
||||||
|
|
||||||
await deleteInvitation({
|
|
||||||
variables: {
|
|
||||||
input: { id },
|
|
||||||
connections: connectionId ? [connectionId] : [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [deleteInvitation, trustCenterData]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleEditAccess = useCallback((access: AccessType) => {
|
|
||||||
loadedAccessIdRef.current = null;
|
|
||||||
setEditingAccess(access);
|
|
||||||
setEditingDocumentAccesses([]);
|
|
||||||
setSelectedDocumentAccesses(new Set());
|
|
||||||
setIsLoadingDocumentAccesses(false);
|
|
||||||
editForm.reset({ name: access.name, active: access.active });
|
|
||||||
editDialogRef.current?.open();
|
|
||||||
}, [editDialogRef, editForm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (pendingEditEmail && accesses.length > 0) {
|
|
||||||
const newAccess = accesses.find(access => access.email === pendingEditEmail);
|
|
||||||
if (newAccess) {
|
|
||||||
setPendingEditEmail(null);
|
|
||||||
loadedAccessIdRef.current = null;
|
|
||||||
setEditingAccess(newAccess);
|
|
||||||
editForm.reset({ name: newAccess.name, active: true });
|
|
||||||
setEditingDocumentAccesses([]);
|
|
||||||
setSelectedDocumentAccesses(new Set());
|
|
||||||
editDialogRef.current?.open();
|
|
||||||
setTimeout(() => {
|
|
||||||
dialogRef.current?.close();
|
|
||||||
}, 50);
|
|
||||||
setTimeout(() => {
|
|
||||||
inviteForm.reset();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [accesses, pendingEditEmail, editForm, dialogRef, editDialogRef, inviteForm]);
|
|
||||||
|
|
||||||
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, trustCenterFileIds } = editingDocumentAccesses.reduce(
|
|
||||||
(acc, docAccess) => {
|
|
||||||
const id = docAccess.document?.id || docAccess.report?.id || docAccess.trustCenterFile?.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);
|
|
||||||
} else if (docAccess.trustCenterFile?.id) {
|
|
||||||
acc.trustCenterFileIds.push(docAccess.trustCenterFile.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ documentIds: [] as string[], reportIds: [] as string[], trustCenterFileIds: [] as string[] }
|
|
||||||
);
|
|
||||||
|
|
||||||
await updateInvitation({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
id: editingAccess.id,
|
|
||||||
name: data.name.trim(),
|
|
||||||
active: data.active,
|
|
||||||
documentIds,
|
|
||||||
reportIds,
|
|
||||||
trustCenterFileIds,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
editDialogRef.current?.close();
|
|
||||||
setEditingAccess(null);
|
|
||||||
editForm.reset();
|
|
||||||
setEditingDocumentAccesses([]);
|
|
||||||
setSelectedDocumentAccesses(new Set());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-medium">{__("External Access")}</h3>
|
|
||||||
<p className="text-sm text-txt-tertiary">
|
|
||||||
{__("Manage who can access your trust center with time-limited tokens")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{organization.trustCenter?.id && (
|
|
||||||
isAuthorized("TrustCenter", "createTrustCenterAccess") && (
|
|
||||||
<Button icon={IconPlusLarge} onClick={() => {
|
|
||||||
inviteForm.reset();
|
|
||||||
dialogRef.current?.open();
|
|
||||||
}}>
|
|
||||||
{__("Add Access")}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!organization.trustCenter?.id ? (
|
|
||||||
<Table>
|
|
||||||
<Tbody>
|
|
||||||
<Tr>
|
|
||||||
<Td className="text-center text-txt-tertiary py-8">
|
|
||||||
<Spinner />
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
) : accesses.length === 0 ? (
|
|
||||||
<Table>
|
|
||||||
<Tbody>
|
|
||||||
<Tr>
|
|
||||||
<Td className="text-center text-txt-tertiary py-8">
|
|
||||||
{__("No external access granted yet")}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table>
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th>{__("Name")}</Th>
|
|
||||||
<Th>{__("Email")}</Th>
|
|
||||||
<Th>{__("Date")}</Th>
|
|
||||||
<Th>{__("Expires")}</Th>
|
|
||||||
<Th className="text-center">{__("Active")}</Th>
|
|
||||||
<Th className="text-center">{__("Access")}</Th>
|
|
||||||
<Th className="text-center">{__("Requests")}</Th>
|
|
||||||
<Th className="text-center">{__("NDA")}</Th>
|
|
||||||
<Th></Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{accesses.map((access) => {
|
|
||||||
const isExpired = access.lastTokenExpiresAt ? new Date(access.lastTokenExpiresAt) < new Date() : false;
|
|
||||||
|
|
||||||
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 className={isExpired ? "text-txt-danger" : ""}>
|
|
||||||
{access.lastTokenExpiresAt ? formatDate(access.lastTokenExpiresAt) : "-"}
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
{access.active ? (
|
|
||||||
<IconCheckmark1 size={16} className="text-txt-success" />
|
|
||||||
) : (
|
|
||||||
<IconCrossLargeX size={16} className="text-txt-danger" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td className="text-center">
|
|
||||||
{access.activeCount}
|
|
||||||
</Td>
|
|
||||||
<Td className="text-center">
|
|
||||||
{access.pendingRequestCount > 0 ? access.pendingRequestCount : ""}
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
{access.hasAcceptedNonDisclosureAgreement && (
|
|
||||||
<IconCheckmark1 size={16} className="text-txt-success" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td noLink width={160} className="text-end">
|
|
||||||
<div
|
|
||||||
className="flex gap-2 justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{isAuthorized("TrustCenterAccess", "updateTrustCenterAccess") && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleEditAccess(access)}
|
|
||||||
disabled={isUpdating}
|
|
||||||
icon={IconPencil}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isAuthorized("TrustCenterAccess", "deleteTrustCenterAccess") && (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
ref={dialogRef}
|
|
||||||
title={__("Invite External Access")}
|
|
||||||
>
|
|
||||||
<form onSubmit={handleInvite}>
|
|
||||||
<DialogContent padded className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-txt-secondary text-sm mb-4">
|
|
||||||
{__("Send a 30-day access token to an external person to view your trust center")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
label={__("Full Name")}
|
|
||||||
required
|
|
||||||
error={inviteForm.formState.errors.name?.message}
|
|
||||||
{...inviteForm.register("name")}
|
|
||||||
placeholder={__("John Doe")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Field
|
|
||||||
label={__("Email Address")}
|
|
||||||
required
|
|
||||||
error={inviteForm.formState.errors.email?.message}
|
|
||||||
type="email"
|
|
||||||
{...inviteForm.register("email")}
|
|
||||||
placeholder={__("john@example.com")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isCreating}>
|
|
||||||
{isCreating && <Spinner />}
|
|
||||||
{__("Create Access")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
ref={editDialogRef}
|
|
||||||
title={__("Edit Access")}
|
|
||||||
>
|
|
||||||
{documentAccessesQueryReference && (
|
|
||||||
<DocumentAccessesLoader
|
|
||||||
queryReference={documentAccessesQueryReference}
|
|
||||||
onDataLoaded={handleDocumentAccessesLoaded}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<form onSubmit={handleUpdateName}>
|
|
||||||
<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")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h4 className="font-medium text-txt-primary">
|
|
||||||
{__("Document Access Permissions")}
|
|
||||||
</h4>
|
|
||||||
{!isLoadingDocumentAccesses && formattedDocumentAccesses.length > 0 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedDocumentAccesses.size === formattedDocumentAccesses.length) {
|
|
||||||
setSelectedDocumentAccesses(new Set());
|
|
||||||
} else {
|
|
||||||
const allIds = new Set(formattedDocumentAccesses.map(doc => doc.id).filter((id): id is string => !!id));
|
|
||||||
setSelectedDocumentAccesses(allIds);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-xs h-7 min-h-7"
|
|
||||||
>
|
|
||||||
{selectedDocumentAccesses.size === formattedDocumentAccesses.length
|
|
||||||
? __("Clear All")
|
|
||||||
: __("Select All")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoadingDocumentAccesses ? (
|
|
||||||
<div className="flex justify-center items-center py-12">
|
|
||||||
<Spinner />
|
|
||||||
</div>
|
|
||||||
) : formattedDocumentAccesses.length > 0 ? (
|
|
||||||
<div className="bg-bg-secondary rounded-lg overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th>{__("Name")}</Th>
|
|
||||||
<Th>{__("Type")}</Th>
|
|
||||||
<Th>{__("Category")}</Th>
|
|
||||||
<Th>
|
|
||||||
{__("Access")}
|
|
||||||
</Th>
|
|
||||||
<Th></Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{formattedDocumentAccesses.map((info) => {
|
|
||||||
const { variant, name, type, category, id, status } = info;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tr key={id}>
|
|
||||||
<Td>
|
|
||||||
<div className="font-medium text-txt-primary">
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Badge variant={variant}>
|
|
||||||
{type}
|
|
||||||
</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<div className="text-txt-secondary">
|
|
||||||
{category || "-"}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<Badge variant="info">
|
|
||||||
{status}
|
|
||||||
</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={() => handleToggleDocumentAccess(id, true)}>Grant</Button>
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-txt-tertiary py-8">
|
|
||||||
{__("No documents available")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit" disabled={isUpdating || isLoadingDocumentAccesses}>
|
|
||||||
{(isUpdating || isLoadingDocumentAccesses) && <Spinner />}
|
|
||||||
{__("Update Access")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
Field,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
Tbody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
Thead,
|
||||||
|
Tr,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { usePreloadedQuery, type PreloadedQuery, useQueryLoader } from "react-relay";
|
||||||
|
import type { TrustCenterAccessGraphLoadDocumentAccessesQuery } from "/hooks/graph/__generated__/TrustCenterAccessGraphLoadDocumentAccessesQuery.graphql";
|
||||||
|
import type { TrustCenterDocumentAccess } from "/coredata/TrustCenterDocumentAccess";
|
||||||
|
import { loadTrustCenterAccessDocumentAccessesQuery, updateTrustCenterAccessMutation } from "/hooks/graph/TrustCenterAccessGraph";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import z from "zod";
|
||||||
|
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||||
|
import type { TrustCenterAccess } from "/coredata/TrustCenterAccess";
|
||||||
|
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||||
|
import { Suspense, useEffect } from "react";
|
||||||
|
|
||||||
|
function getDocumentAccessInfo(
|
||||||
|
docAccess: TrustCenterDocumentAccess,
|
||||||
|
__: (key: string) => string
|
||||||
|
) {
|
||||||
|
if (docAccess.document) {
|
||||||
|
return {
|
||||||
|
variant: "info" as const,
|
||||||
|
name: docAccess.document?.title,
|
||||||
|
type: __("Document"),
|
||||||
|
category: docAccess.document?.documentType,
|
||||||
|
id: docAccess.document?.id,
|
||||||
|
requested: docAccess.requested,
|
||||||
|
active: docAccess.active,
|
||||||
|
status: docAccess.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (docAccess.report) {
|
||||||
|
return {
|
||||||
|
variant: "success" as const,
|
||||||
|
name: docAccess.report?.filename,
|
||||||
|
type: __("Report"),
|
||||||
|
category: docAccess.report?.audit?.framework?.name,
|
||||||
|
id: docAccess.report?.id,
|
||||||
|
requested: docAccess.requested,
|
||||||
|
active: docAccess.active,
|
||||||
|
status: docAccess.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (docAccess.trustCenterFile) {
|
||||||
|
return {
|
||||||
|
variant: "highlight" as const,
|
||||||
|
name: docAccess.trustCenterFile?.name,
|
||||||
|
type: __("File"),
|
||||||
|
category: docAccess.trustCenterFile?.category,
|
||||||
|
id: docAccess.trustCenterFile?.id,
|
||||||
|
requested: docAccess.requested,
|
||||||
|
active: docAccess.active,
|
||||||
|
status: docAccess.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unknown trust center access document type");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrustCenterAccessEditDialogProps {
|
||||||
|
access: TrustCenterAccess;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrustCenterAccessEditDialog(props: TrustCenterAccessEditDialogProps) {
|
||||||
|
const { access, onClose } = props;
|
||||||
|
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
|
const [queryRef, loadDocumentAccessesQuery] =
|
||||||
|
useQueryLoader<TrustCenterAccessGraphLoadDocumentAccessesQuery>(loadTrustCenterAccessDocumentAccessesQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDocumentAccessesQuery({
|
||||||
|
accessId: access.id
|
||||||
|
});
|
||||||
|
}, [access.id, loadDocumentAccessesQuery])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
defaultOpen={true}
|
||||||
|
title={__("Edit Access")}
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{queryRef &&
|
||||||
|
<Suspense>
|
||||||
|
<TrustCenterAccessEditForm
|
||||||
|
access={access}
|
||||||
|
queryRef={queryRef}
|
||||||
|
onSubmit={onClose}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrustCenterAccessEditFormProps {
|
||||||
|
access: TrustCenterAccess;
|
||||||
|
onSubmit: () => void;
|
||||||
|
queryRef: PreloadedQuery<TrustCenterAccessGraphLoadDocumentAccessesQuery>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrustCenterAccessEditForm(props: TrustCenterAccessEditFormProps) {
|
||||||
|
const { access, onSubmit, queryRef } = props;
|
||||||
|
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const data = usePreloadedQuery<TrustCenterAccessGraphLoadDocumentAccessesQuery>(
|
||||||
|
loadTrustCenterAccessDocumentAccessesQuery,
|
||||||
|
queryRef,
|
||||||
|
)
|
||||||
|
const documentAccesses: TrustCenterDocumentAccess[] = data.node.availableDocumentAccesses?.edges.map(edge => edge.node) ?? [];
|
||||||
|
|
||||||
|
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 editForm = useFormWithSchema(editSchema, {
|
||||||
|
defaultValues: { name: access.name, active: access.active },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateTrustCenterAccess, isUpdating] = useMutationWithToasts(updateTrustCenterAccessMutation, {
|
||||||
|
successMessage: __("Access updated successfully"),
|
||||||
|
errorMessage: __("Failed to update access"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = editForm.handleSubmit(async (data) => {
|
||||||
|
const { documentIds, reportIds, trustCenterFileIds } = documentAccesses.reduce(
|
||||||
|
(acc, docAccess) => {
|
||||||
|
// TODO status update
|
||||||
|
if (docAccess.document?.id) {
|
||||||
|
acc.documentIds.push(docAccess.document.id);
|
||||||
|
} else if (docAccess.report?.id) {
|
||||||
|
acc.reportIds.push(docAccess.report.id);
|
||||||
|
} else if (docAccess.trustCenterFile?.id) {
|
||||||
|
acc.trustCenterFileIds.push(docAccess.trustCenterFile.id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ documentIds: [] as string[], reportIds: [] as string[], trustCenterFileIds: [] as string[] }
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateTrustCenterAccess({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: access.id,
|
||||||
|
name: data.name.trim(),
|
||||||
|
active: data.active,
|
||||||
|
documentIds,
|
||||||
|
reportIds,
|
||||||
|
trustCenterFileIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
onSubmit();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<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")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<TrustCenterDocumentAccessList documentAccesses={documentAccesses} />
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isUpdating}>
|
||||||
|
{isUpdating && <Spinner />}
|
||||||
|
{__("Update Access")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrustCenterDocumentAccessList(props: {
|
||||||
|
documentAccesses: TrustCenterDocumentAccess[];
|
||||||
|
}) {
|
||||||
|
const { documentAccesses } = props;
|
||||||
|
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const formattedDocumentAccesses: NonNullable<ReturnType<typeof getDocumentAccessInfo>>[] = documentAccesses
|
||||||
|
?.map((docAccess) => getDocumentAccessInfo(docAccess, __)) ?? [];
|
||||||
|
|
||||||
|
const showGrantCTA = formattedDocumentAccesses.some(da => da.status !== "GRANTED");
|
||||||
|
const showRejectCTA = formattedDocumentAccesses.some(da => da.status !== "REJECTED" && da.status !== "REVOKED");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h4 className="font-medium text-txt-primary">
|
||||||
|
{__("Document Access Permissions")}
|
||||||
|
</h4>
|
||||||
|
{showGrantCTA &&
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="tertiary"
|
||||||
|
// TODO onClick
|
||||||
|
className="text-xs h-7 min-h-7"
|
||||||
|
>
|
||||||
|
{__("Grant All")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{showRejectCTA &&
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
// TODO onCLick
|
||||||
|
className="text-xs h-7 min-h-7"
|
||||||
|
>
|
||||||
|
{__("Reject All")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formattedDocumentAccesses.length > 0 ? (
|
||||||
|
<div className="bg-bg-secondary rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>{__("Name")}</Th>
|
||||||
|
<Th>{__("Type")}</Th>
|
||||||
|
<Th>{__("Category")}</Th>
|
||||||
|
<Th>
|
||||||
|
{__("Access")}
|
||||||
|
</Th>
|
||||||
|
<Th></Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{formattedDocumentAccesses.map((info) => {
|
||||||
|
const { variant, name, type, category, id, status } = info;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr key={id}>
|
||||||
|
<Td>
|
||||||
|
<div className="font-medium text-txt-primary">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge variant={variant}>
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="text-txt-secondary">
|
||||||
|
{category || "-"}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge variant="info">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* TODO DROPDOWN */}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-txt-tertiary py-8">
|
||||||
|
{__("No documents available")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Button, IconCheckmark1, IconCrossLargeX, IconPencil, IconTrashCan, Td, Tr } from "@probo/ui";
|
||||||
|
import type { TrustCenterAccess } from "/coredata/TrustCenterAccess";
|
||||||
|
import { formatDate } from "@probo/helpers";
|
||||||
|
import { use, useCallback, useState } from "react";
|
||||||
|
import { PermissionsContext } from "/providers/PermissionsContext";
|
||||||
|
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||||
|
import { deleteTrustCenterAccessMutation } from "/hooks/graph/TrustCenterAccessGraph";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { TrustCenterAccessEditDialog } from "./TrustCenterAccessEditDialog";
|
||||||
|
|
||||||
|
interface TrustCenterAccessItemProps {
|
||||||
|
openDialog: boolean,
|
||||||
|
access: TrustCenterAccess
|
||||||
|
connectionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrustCenterAccessItem(props: TrustCenterAccessItemProps) {
|
||||||
|
// TODO openDialog after access creation
|
||||||
|
const { access, connectionId } = props;
|
||||||
|
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { isAuthorized } = use(PermissionsContext);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<boolean>(false)
|
||||||
|
|
||||||
|
const [deleteInvitation, isDeleting] = useMutationWithToasts(deleteTrustCenterAccessMutation, {
|
||||||
|
successMessage: __("Access deleted successfully"),
|
||||||
|
errorMessage: __("Failed to delete access"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async (id: string) => {
|
||||||
|
await deleteInvitation({
|
||||||
|
variables: {
|
||||||
|
input: { id },
|
||||||
|
connections: connectionId ? [connectionId] : [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [deleteInvitation, connectionId]);
|
||||||
|
|
||||||
|
const isExpired = access.lastTokenExpiresAt ? new Date(access.lastTokenExpiresAt) < new Date() : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tr
|
||||||
|
key={access.id}
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
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 className={isExpired ? "text-txt-danger" : ""}>
|
||||||
|
{access.lastTokenExpiresAt ? formatDate(access.lastTokenExpiresAt) : "-"}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{access.active ? (
|
||||||
|
<IconCheckmark1 size={16} className="text-txt-success" />
|
||||||
|
) : (
|
||||||
|
<IconCrossLargeX size={16} className="text-txt-danger" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td className="text-center">
|
||||||
|
{access.activeCount}
|
||||||
|
</Td>
|
||||||
|
<Td className="text-center">
|
||||||
|
{access.pendingRequestCount > 0 ? access.pendingRequestCount : ""}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{access.hasAcceptedNonDisclosureAgreement && (
|
||||||
|
<IconCheckmark1 size={16} className="text-txt-success" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td noLink width={160} className="text-end">
|
||||||
|
<div
|
||||||
|
className="flex gap-2 justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{isAuthorized("TrustCenterAccess", "updateTrustCenterAccess") && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
icon={IconPencil}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isAuthorized("TrustCenterAccess", "deleteTrustCenterAccess") && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleDelete(access.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
icon={IconTrashCan}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
|
||||||
|
{dialogOpen && <TrustCenterAccessEditDialog access={access} onClose={() => setDialogOpen(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
Field,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
Tbody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
Thead,
|
||||||
|
Tr,
|
||||||
|
useDialogRef,
|
||||||
|
IconChevronDown,
|
||||||
|
IconPlusLarge,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { useState, useEffect, use, useMemo } from "react";
|
||||||
|
import z from "zod";
|
||||||
|
import {
|
||||||
|
useTrustCenterAccesses,
|
||||||
|
createTrustCenterAccessMutation,
|
||||||
|
} from "/hooks/graph/TrustCenterAccessGraph";
|
||||||
|
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||||
|
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||||
|
import { PermissionsContext } from "/providers/PermissionsContext";
|
||||||
|
import type { TrustCenterAccess } from "/coredata/TrustCenterAccess";
|
||||||
|
import { TrustCenterAccessItem } from "./TrustCenterAccessItem";
|
||||||
|
|
||||||
|
type ContextType = {
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
trustCenter?: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
documents?: {
|
||||||
|
edges: Array<{
|
||||||
|
node: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
documentType: string;
|
||||||
|
trustCenterVisibility: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
audits?: {
|
||||||
|
edges: Array<{
|
||||||
|
node: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
trustCenterVisibility: string;
|
||||||
|
framework: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
trustCenterFiles?: {
|
||||||
|
edges: Array<{
|
||||||
|
node: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
trustCenterVisibility: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TrustCenterAccessTab() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { organization } = useOutletContext<ContextType>();
|
||||||
|
const { isAuthorized } = use(PermissionsContext);
|
||||||
|
const inviteSchema = z.object({
|
||||||
|
name: z.string().min(1, __("Name is required")).min(2, __("Name must be at least 2 characters long")),
|
||||||
|
email: z.string().min(1, __("Email is required")).email(__("Please enter a valid email address")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [createInvitation, isCreating] = useMutationWithToasts(createTrustCenterAccessMutation, {
|
||||||
|
successMessage: __("Access created successfully"),
|
||||||
|
errorMessage: __("Failed to create access"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogRef = useDialogRef();
|
||||||
|
const [editingAccess, setEditingAccess] = useState<TrustCenterAccess | null>(null);
|
||||||
|
const [pendingEditEmail, setPendingEditEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const inviteForm = useFormWithSchema(inviteSchema, {
|
||||||
|
defaultValues: { name: "", email: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: trustCenterData, loadMore, hasNext, isLoadingNext } = useTrustCenterAccesses(organization.trustCenter?.id || "");
|
||||||
|
|
||||||
|
const accesses: TrustCenterAccess[] = useMemo(
|
||||||
|
() => trustCenterData?.accesses?.edges.map((edge) => edge.node) ?? [], [trustCenterData?.accesses?.edges]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInvite = inviteForm.handleSubmit(async (data) => {
|
||||||
|
if (!organization.trustCenter?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = trustCenterData?.accesses?.__id;
|
||||||
|
const email = data.email.trim();
|
||||||
|
|
||||||
|
await createInvitation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
trustCenterId: organization.trustCenter.id,
|
||||||
|
email: email,
|
||||||
|
name: data.name.trim(),
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
connections: connectionId ? [connectionId] : [],
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setPendingEditEmail(email);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingEditEmail && accesses.length > 0) {
|
||||||
|
const newAccess = accesses.find(access => access.email === pendingEditEmail);
|
||||||
|
if (newAccess) {
|
||||||
|
setPendingEditEmail(null);
|
||||||
|
setEditingAccess(newAccess);
|
||||||
|
setTimeout(() => {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}, 50);
|
||||||
|
setTimeout(() => {
|
||||||
|
inviteForm.reset();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [accesses, pendingEditEmail, dialogRef, inviteForm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium">{__("External Access")}</h3>
|
||||||
|
<p className="text-sm text-txt-tertiary">
|
||||||
|
{__("Manage who can access your trust center with time-limited tokens")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{organization.trustCenter?.id && (
|
||||||
|
isAuthorized("TrustCenter", "createTrustCenterAccess") && (
|
||||||
|
<Button icon={IconPlusLarge} onClick={() => {
|
||||||
|
inviteForm.reset();
|
||||||
|
dialogRef.current?.open();
|
||||||
|
}}>
|
||||||
|
{__("Add Access")}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!organization.trustCenter?.id ? (
|
||||||
|
<Table>
|
||||||
|
<Tbody>
|
||||||
|
<Tr>
|
||||||
|
<Td className="text-center text-txt-tertiary py-8">
|
||||||
|
<Spinner />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
) : accesses.length === 0 ? (
|
||||||
|
<Table>
|
||||||
|
<Tbody>
|
||||||
|
<Tr>
|
||||||
|
<Td className="text-center text-txt-tertiary py-8">
|
||||||
|
{__("No external access granted yet")}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>{__("Name")}</Th>
|
||||||
|
<Th>{__("Email")}</Th>
|
||||||
|
<Th>{__("Date")}</Th>
|
||||||
|
<Th>{__("Expires")}</Th>
|
||||||
|
<Th className="text-center">{__("Active")}</Th>
|
||||||
|
<Th className="text-center">{__("Access")}</Th>
|
||||||
|
<Th className="text-center">{__("Requests")}</Th>
|
||||||
|
<Th className="text-center">{__("NDA")}</Th>
|
||||||
|
<Th></Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{accesses.map((access) => (
|
||||||
|
<TrustCenterAccessItem
|
||||||
|
key={access.id}
|
||||||
|
access={access}
|
||||||
|
connectionId={trustCenterData?.accesses?.__id}
|
||||||
|
openDialog={editingAccess?.id === access.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
{hasNext && (
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={isLoadingNext}
|
||||||
|
className="mt-3 mx-auto"
|
||||||
|
icon={IconChevronDown}
|
||||||
|
>
|
||||||
|
{isLoadingNext && <Spinner />}
|
||||||
|
{__("Show More")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
title={__("Invite External Access")}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleInvite}>
|
||||||
|
<DialogContent padded className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-txt-secondary text-sm mb-4">
|
||||||
|
{__("Send a 30-day access token to an external person to view your trust center")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={__("Full Name")}
|
||||||
|
required
|
||||||
|
error={inviteForm.formState.errors.name?.message}
|
||||||
|
{...inviteForm.register("name")}
|
||||||
|
placeholder={__("John Doe")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Field
|
||||||
|
label={__("Email Address")}
|
||||||
|
required
|
||||||
|
error={inviteForm.formState.errors.email?.message}
|
||||||
|
type="email"
|
||||||
|
{...inviteForm.register("email")}
|
||||||
|
placeholder={__("john@example.com")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isCreating}>
|
||||||
|
{isCreating && <Spinner />}
|
||||||
|
{__("Create Access")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ export const trustCenterRoutes = [
|
|||||||
path: "access",
|
path: "access",
|
||||||
Fallback: LinkCardSkeleton,
|
Fallback: LinkCardSkeleton,
|
||||||
Component: lazy(
|
Component: lazy(
|
||||||
() => import("/pages/organizations/trustCenter/TrustCenterAccessTab")
|
() => import("/pages/organizations/trustCenter/TrustCenterAccessTab/TrustCenterAccessTab")
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user