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",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("/pages/organizations/trustCenter/TrustCenterAccessTab")
|
||||
() => import("/pages/organizations/trustCenter/TrustCenterAccessTab/TrustCenterAccessTab")
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user