Split TC access tab in several files

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-09 11:18:03 +01:00
parent 30011f4eb2
commit e52282e0f5
7 changed files with 726 additions and 707 deletions

View 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[];
};

View 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;
};

View File

@@ -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>
);
}

View File

@@ -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>
)
}

View File

@@ -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)} />}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -64,7 +64,7 @@ export const trustCenterRoutes = [
path: "access",
Fallback: LinkCardSkeleton,
Component: lazy(
() => import("/pages/organizations/trustCenter/TrustCenterAccessTab")
() => import("/pages/organizations/trustCenter/TrustCenterAccessTab/TrustCenterAccessTab")
),
},
],