Restructure console brand and overview pages

Decompose the brand page into profile, domains, visual identity, and
custom link sections, and move frameworks and the NDA card onto the
overview page as dedicated fragment components. Remove the standalone
domain page and redirect its route to brand, where domains now live.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-10 15:17:08 +02:00
parent db24d17455
commit de367f032f
23 changed files with 1786 additions and 1466 deletions

View File

@@ -18,6 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { safeOpenUrl } from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Badge, Button, IconBell2, IconCheckmark1, IconFolder2, IconMedal, IconPageTextLine, IconPencil, IconPeopleAdd, IconSettingsGear2, IconShield, IconStore, PageHeader, TabLink, Tabs } from "@probo/ui";
@@ -33,12 +34,10 @@ export const compliancePageLayoutQuery = graphql`
organization: node(id: $organizationId) {
__typename
... on Organization {
customDomain {
domain
}
compliancePage: trustCenter {
id
active
publicUrl
}
}
}
@@ -58,11 +57,7 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
throw new Error("invalid type for node");
}
const compliancePageUrl = organization.compliancePage?.id
? organization.customDomain?.domain
? `https://${organization.customDomain.domain}`
: `${window.location.origin}/trust/${organization.compliancePage.id}`
: null;
const compliancePageUrl = organization.compliancePage?.publicUrl || null;
return (
<div className="space-y-6">
@@ -78,12 +73,7 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
{organization.compliancePage?.active && compliancePageUrl && (
<Button
variant="secondary"
onClick={() =>
window.open(
compliancePageUrl,
"_blank",
"noopener,noreferrer",
)}
onClick={() => safeOpenUrl(compliancePageUrl)}
>
{__("Open")}
</Button>
@@ -99,10 +89,6 @@ export function CompliancePageLayout(props: { queryRef: PreloadedQuery<Complianc
<IconPencil className="size-4" />
{__("Brand")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/compliance-page/domain`}>
<IconStore className="size-4" />
{__("Domain")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/compliance-page/references`}>
<IconCheckmark1 className="size-4" />
{__("References")}

View File

@@ -18,61 +18,26 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { acceptImage } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
Dropzone,
FileButton,
IconTrashCan,
Label,
Spinner,
useToast,
} from "@probo/ui";
import { type ChangeEventHandler, useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageBrandPage_updateMutation } from "#/__generated__/core/CompliancePageBrandPage_updateMutation.graphql";
import type { CompliancePageBrandPageQuery } from "#/__generated__/core/CompliancePageBrandPageQuery.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CompliancePageExternalUrlsSection } from "../overview/_components/CompliancePageExternalUrlsSection";
import { CompliancePageFrameworkList } from "../overview/_components/CompliancePageFrameworkList";
import { CompliancePageCustomLinksSection } from "./_components/CompliancePageCustomLinksSection";
import { CompliancePageDomainsSection } from "./_components/CompliancePageDomainsSection";
import { CompliancePageProfileSection } from "./_components/CompliancePageProfileSection";
import { CompliancePageVisualIdentitySection } from "./_components/CompliancePageVisualIdentitySection";
export const compliancePageBrandPageQuery = graphql`
query CompliancePageBrandPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
__typename
... on Organization {
...CompliancePageDomainsSection_organizationFragment
compliancePage: trustCenter @required(action: THROW) {
id
logo {
downloadUrl
}
darkLogo {
downloadUrl
}
canUpdate: permission(action: "core:trust-center:update")
...CompliancePageFrameworkList_compliancePageFragment
...CompliancePageExternalUrlsSection_trustCenterFragment
}
}
}
}
`;
const updateTrustCenterBrandMutation = graphql`
mutation CompliancePageBrandPage_updateMutation($input: UpdateTrustCenterBrandInput!) {
updateTrustCenterBrand(input: $input) {
trustCenter {
id
logo {
downloadUrl
}
darkLogo {
downloadUrl
...CompliancePageProfileSection_compliancePageFragment
...CompliancePageVisualIdentitySection_compliancePageFragment
...CompliancePageCustomLinksSection_compliancePageFragment
}
}
}
@@ -82,290 +47,20 @@ const updateTrustCenterBrandMutation = graphql`
export function CompliancePageBrandPage(props: { queryRef: PreloadedQuery<CompliancePageBrandPageQuery> }) {
const { queryRef } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const { organization } = usePreloadedQuery<CompliancePageBrandPageQuery>(compliancePageBrandPageQuery, queryRef);
if (organization.__typename !== "Organization") {
throw new Error("invalid type for node");
}
const trustCenterId = organization.compliancePage.id;
const logoDownloadUrl = organization.compliancePage.logo?.downloadUrl;
const darkLogoDownloadUrl = organization.compliancePage.darkLogo?.downloadUrl;
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [darkLogoPreview, setDarkLogoPreview] = useState<string | null>(null);
const [updateBrand, isUpdating] = useMutationWithToasts<CompliancePageBrandPage_updateMutation>(
updateTrustCenterBrandMutation,
{
successMessage: __("Compliance page branding updated successfully"),
errorMessage: __("Failed to update compliance page branding"),
},
);
const disabled = isUpdating || !organization.compliancePage.canUpdate;
const processLogoFile = (file: File, setPreview: (url: string) => void) => {
const reader = new FileReader();
reader.onload = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
toast({
title: __("File size too large"),
description: __("The file size is too large. Please upload a file smaller than 5MB."),
variant: "error",
});
return;
}
processLogoFile(file, setLogoPreview);
void updateBrand({
variables: {
input: {
trustCenterId,
logoFile: null,
},
},
uploadables: {
"input.logoFile": file,
},
onCompleted: () => {
setLogoPreview(null);
},
});
};
const handleDarkLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
toast({
title: __("File size too large"),
description: __("The file size is too large. Please upload a file smaller than 5MB."),
variant: "error",
});
return;
}
processLogoFile(file, setDarkLogoPreview);
void updateBrand({
variables: {
input: {
trustCenterId,
darkLogoFile: null,
},
},
uploadables: {
"input.darkLogoFile": file,
},
onCompleted: () => {
setDarkLogoPreview(null);
},
});
};
const handleLogoDrop = (files: File[]) => {
const file = files[0];
if (!file) return;
processLogoFile(file, setLogoPreview);
void updateBrand({
variables: {
input: {
trustCenterId,
logoFile: null,
},
},
uploadables: {
"input.logoFile": file,
},
onCompleted: () => {
setLogoPreview(null);
},
});
};
const handleDarkLogoDrop = (files: File[]) => {
const file = files[0];
if (!file) return;
processLogoFile(file, setDarkLogoPreview);
void updateBrand({
variables: {
input: {
trustCenterId,
darkLogoFile: null,
},
},
uploadables: {
"input.darkLogoFile": file,
},
onCompleted: () => {
setDarkLogoPreview(null);
},
});
};
const handleRemoveLogo = async () => {
await updateBrand({
variables: {
input: {
trustCenterId,
logoFile: null,
},
},
onSuccess: () => {
setLogoPreview(null);
},
});
};
const handleRemoveDarkLogo = async () => {
await updateBrand({
variables: {
input: {
trustCenterId,
darkLogoFile: null,
},
},
onSuccess: () => {
setDarkLogoPreview(null);
},
});
};
const currentLogoUrl = logoPreview || logoDownloadUrl;
const currentDarkLogoUrl = darkLogoPreview || darkLogoDownloadUrl;
return (
<div className="space-y-8">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-medium">{__("Branding")}</h2>
{isUpdating && <Spinner />}
</div>
<CompliancePageProfileSection compliancePageRef={organization.compliancePage} />
<Card padded className="space-y-4">
<div className="flex gap-6 items-start">
<div className="flex-1">
<Label>{__("Logo")}</Label>
<p className="text-sm text-txt-tertiary mb-3">
{__("This logo will be displayed on your public compliance page.")}
</p>
<CompliancePageDomainsSection organizationRef={organization} />
{currentLogoUrl
? (
<div className="flex items-center gap-4">
<div className="border border-border-solid rounded-md p-4 bg-surface-secondary">
<img
src={currentLogoUrl}
alt={__("Compliance page logo")}
className="h-16 max-w-xs object-contain"
/>
</div>
<FileButton
disabled={disabled}
onChange={handleLogoChange}
variant="secondary"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
>
{isUpdating ? __("Uploading...") : __("Change logo")}
</FileButton>
<Button
type="button"
variant="quaternary"
icon={IconTrashCan}
onClick={() => void handleRemoveLogo()}
disabled={disabled}
aria-label={__("Remove logo")}
className="text-red-600 hover:text-red-700"
/>
</div>
)
: (
<Dropzone
description={__("Upload logo image (PNG, JPG, SVG, WEBP up to 5MB)")}
isUploading={isUpdating}
onDrop={handleLogoDrop}
accept={acceptImage}
maxSize={5}
/>
)}
</div>
<div className="flex-1">
<Label>{__("Dark mode logo")}</Label>
<p className="text-sm text-txt-tertiary mb-3">
{__("This logo will be used when dark mode is enabled.")}
</p>
<CompliancePageVisualIdentitySection compliancePageRef={organization.compliancePage} />
{currentDarkLogoUrl
? (
<div className="flex items-center gap-4">
<div className="border border-border-solid rounded-md p-4 bg-gray-900">
<img
src={currentDarkLogoUrl}
alt={__("Compliance page dark logo")}
className="h-16 max-w-xs object-contain"
/>
</div>
<FileButton
disabled={disabled}
onChange={handleDarkLogoChange}
variant="secondary"
accept="image/png,image/jpeg,image/jpg,image/svg+xml,image/webp"
>
{isUpdating ? __("Uploading...") : __("Change dark logo")}
</FileButton>
<Button
type="button"
variant="quaternary"
icon={IconTrashCan}
onClick={() => void handleRemoveDarkLogo()}
disabled={disabled}
aria-label={__("Remove dark logo")}
className="text-red-600 hover:text-red-700"
/>
</div>
)
: (
<Dropzone
description={__("Upload dark logo image (PNG, JPG, SVG, WEBP up to 5MB)")}
isUploading={isUpdating}
onDrop={handleDarkLogoDrop}
accept={acceptImage}
maxSize={5}
/>
)}
</div>
</div>
</Card>
</div>
<div className="space-y-4">
<div>
<h3 className="text-base font-medium">{__("Frameworks")}</h3>
<p className="text-sm text-txt-tertiary">
{__("Select which frameworks to display as badges on your compliance page")}
</p>
</div>
<CompliancePageFrameworkList compliancePageRef={organization.compliancePage} />
</div>
<CompliancePageExternalUrlsSection trustCenterRef={organization.compliancePage} />
<CompliancePageCustomLinksSection compliancePageRef={organization.compliancePage} />
</div>
);
}

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { detectSocialName } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Spinner,
useDialogRef,
} from "@probo/ui";
import { forwardRef, useImperativeHandle, useState } from "react";
import { ConnectionHandler, graphql, readInlineData } from "relay-runtime";
import { z } from "zod";
import type { CompliancePageCustomLinkDialog_createMutation } from "#/__generated__/core/CompliancePageCustomLinkDialog_createMutation.graphql";
import type { CompliancePageCustomLinkDialog_customLink$key } from "#/__generated__/core/CompliancePageCustomLinkDialog_customLink.graphql";
import type { CompliancePageCustomLinkDialog_updateMutation } from "#/__generated__/core/CompliancePageCustomLinkDialog_updateMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutation } from "#/lib/relay/useMutation";
const customLinkFragment = graphql`
fragment CompliancePageCustomLinkDialog_customLink on ComplianceCustomLink @inline {
id
name
url
}
`;
const createMutation = graphql`
mutation CompliancePageCustomLinkDialog_createMutation($input: CreateComplianceCustomLinkInput!) {
createComplianceCustomLink(input: $input) {
complianceCustomLinkEdge {
node {
id
name
url
rank
...CompliancePageCustomLinkListItem_customLink
...CompliancePageCustomLinkDialog_customLink
}
}
}
}
`;
const updateMutation = graphql`
mutation CompliancePageCustomLinkDialog_updateMutation($input: UpdateComplianceCustomLinkInput!) {
updateComplianceCustomLink(input: $input) {
complianceCustomLink {
id
name
url
rank
...CompliancePageCustomLinkListItem_customLink
...CompliancePageCustomLinkDialog_customLink
}
}
}
`;
export interface CompliancePageCustomLinkDialogRef {
openCreate: (compliancePageId: string, connectionId: string) => void;
openEdit: (customLinkKey: CompliancePageCustomLinkDialog_customLink$key) => void;
}
export const CompliancePageCustomLinkDialog = forwardRef<CompliancePageCustomLinkDialogRef>(
function CompliancePageCustomLinkDialog(_, ref) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [mode, setMode] = useState<"create" | "edit">("create");
const [compliancePageId, setCompliancePageId] = useState("");
const [connectionId, setConnectionId] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const schema = z.object({
name: z.string().min(1, __("Name is required")),
url: z.string().url(__("Please enter a valid URL")),
});
const [create, isCreating] = useMutation<CompliancePageCustomLinkDialog_createMutation>(
createMutation,
{ successMessage: __("Link added successfully."), errorToast: __("Failed to add link.") },
);
const [update, isUpdating] = useMutation<CompliancePageCustomLinkDialog_updateMutation>(
updateMutation,
{ successMessage: __("Link updated successfully."), errorToast: __("Failed to update link.") },
);
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useFormWithSchema(schema, {
defaultValues: { name: "", url: "" },
});
const [nameAutoDetected, setNameAutoDetected] = useState(false);
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const url = e.target.value;
const detected = detectSocialName(url);
if (detected && (nameAutoDetected || watch("name") === "")) {
setValue("name", detected, { shouldValidate: true });
setNameAutoDetected(true);
} else if (!detected && nameAutoDetected) {
setValue("name", "", { shouldValidate: false });
setNameAutoDetected(false);
}
};
useImperativeHandle(ref, () => ({
openCreate: (pageId, cId) => {
setMode("create");
setCompliancePageId(pageId);
setConnectionId(cId);
setEditId(null);
setNameAutoDetected(false);
reset({ name: "", url: "" });
dialogRef.current?.open();
},
openEdit: (customLinkKey) => {
const customLink = readInlineData(customLinkFragment, customLinkKey);
setMode("edit");
setEditId(customLink.id);
setNameAutoDetected(false);
reset({ name: customLink.name, url: customLink.url });
dialogRef.current?.open();
},
}));
const onSubmit = async (data: z.infer<typeof schema>) => {
if (mode === "create") {
await create({
variables: {
input: { trustCenterId: compliancePageId, name: data.name, url: data.url },
},
updater: (store) => {
const payload = store.getRootField("createComplianceCustomLink");
const edge = payload?.getLinkedRecord("complianceCustomLinkEdge");
if (!edge) return;
const connection = store.get(connectionId);
if (!connection) return;
ConnectionHandler.insertEdgeAfter(connection, edge);
},
});
} else if (editId) {
await update({
variables: { input: { id: editId, name: data.name, url: data.url } },
});
}
reset();
dialogRef.current?.close();
};
const isSubmitting = isCreating || isUpdating;
const title = mode === "create" ? __("Add link") : __("Edit link");
return (
<Dialog ref={dialogRef} title={title} onClose={() => reset()}>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-6">
<Field
{...register("url", { onChange: handleUrlChange })}
label={__("URL")}
type="url"
required
placeholder="https://example.com"
error={errors.url?.message}
/>
<Field
{...register("name")}
label={__("Name")}
type="text"
required
placeholder={__("e.g. Twitter, LinkedIn")}
error={errors.name?.message}
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? Spinner : undefined}>
{mode === "create" ? __("Add link") : __("Save changes")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
},
);

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Button, IconChevronRight, IconPlusLarge } from "@probo/ui";
import { useCallback, useRef, useState, useTransition } from "react";
import { useRefetchableFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageCustomLinkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageCustomLinkList_compliancePageFragment.graphql";
import type { CompliancePageCustomLinkList_compliancePageRefetchQuery } from "#/__generated__/core/CompliancePageCustomLinkList_compliancePageRefetchQuery.graphql";
import type { CompliancePageCustomLinkList_updateRankMutation } from "#/__generated__/core/CompliancePageCustomLinkList_updateRankMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
import { CompliancePageCustomLinkDialog, type CompliancePageCustomLinkDialogRef } from "./CompliancePageCustomLinkDialog";
import { CompliancePageCustomLinkListItem } from "./CompliancePageCustomLinkListItem";
const compliancePageFragment = graphql`
fragment CompliancePageCustomLinkList_compliancePageFragment on TrustCenter
@refetchable(queryName: "CompliancePageCustomLinkList_compliancePageRefetchQuery")
@argumentDefinitions(
first: { type: Int, defaultValue: 100 }
after: { type: CursorKey, defaultValue: null }
order: { type: ComplianceCustomLinkOrder, defaultValue: { field: RANK, direction: ASC } }
) {
id
canUpdate: permission(action: "compliance-portal:portal:update")
customLinks(first: $first, after: $after, orderBy: $order)
@connection(key: "CompliancePageCustomLinkList_customLinks", filters: ["orderBy"]) {
__id
edges {
node {
id
name
url
rank
...CompliancePageCustomLinkListItem_customLink
...CompliancePageCustomLinkDialog_customLink
}
}
}
}
`;
const updateRankMutation = graphql`
mutation CompliancePageCustomLinkList_updateRankMutation($input: UpdateComplianceCustomLinkInput!) {
updateComplianceCustomLink(input: $input) {
complianceCustomLink {
id
rank
}
}
}
`;
export interface CompliancePageCustomLinkListProps {
compliancePageRef: CompliancePageCustomLinkList_compliancePageFragment$key;
}
export function CompliancePageCustomLinkList(props: CompliancePageCustomLinkListProps) {
const { __ } = useTranslate();
const [, startTransition] = useTransition();
const dialogRef = useRef<CompliancePageCustomLinkDialogRef>(null);
const [compliancePage, refetch] = useRefetchableFragment<
CompliancePageCustomLinkList_compliancePageRefetchQuery,
CompliancePageCustomLinkList_compliancePageFragment$key
>(compliancePageFragment, props.compliancePageRef);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [updateRank] = useMutation<CompliancePageCustomLinkList_updateRankMutation>(
updateRankMutation,
{ successMessage: __("Order updated."), errorToast: __("Failed to update order.") },
);
const edges = compliancePage.customLinks.edges;
const readOnly = !compliancePage.canUpdate;
const connectionId = compliancePage.customLinks.__id;
const hasLinks = edges.length > 0;
const handleCreate = () => {
dialogRef.current?.openCreate(compliancePage.id, connectionId);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex !== index) setDragOverIndex(index);
};
const handleDrop = useCallback(
async (targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const draggedEdge = edges[draggedIndex];
const targetRank = edges[targetIndex].node.rank;
const draggedId = draggedEdge.node.id;
await updateRank({
variables: {
input: {
id: draggedId,
name: draggedEdge.node.name,
url: draggedEdge.node.url,
rank: targetRank,
},
},
updater: (store) => {
const connection = store.get(connectionId);
if (!connection) return;
const storeEdges = connection.getLinkedRecords("edges");
if (!storeEdges) return;
const fromIdx = storeEdges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === draggedId);
const toIdx = storeEdges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === edges[targetIndex].node.id);
if (fromIdx === -1 || toIdx === -1) return;
const reordered = [...storeEdges];
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
connection.setLinkedRecords(reordered, "edges");
},
onCompleted: (_, errors) => {
startTransition(() => {
refetch({}, { fetchPolicy: errors?.length ? "network-only" : "store-and-network" });
});
},
});
setDraggedIndex(null);
setDragOverIndex(null);
},
[draggedIndex, edges, connectionId, updateRank, refetch, startTransition],
);
return (
<div className="space-y-3">
{!readOnly && hasLinks && (
<div className="flex justify-end">
<Button icon={IconPlusLarge} onClick={handleCreate}>
{__("Add link")}
</Button>
</div>
)}
{edges.map(({ node }, index) => (
<CompliancePageCustomLinkListItem
key={node.id}
customLinkKey={node}
connectionId={connectionId}
readOnly={readOnly}
isDragging={draggedIndex === index}
isDropTarget={dragOverIndex === index && draggedIndex !== index}
onDragStart={() => setDraggedIndex(index)}
onDragOver={e => handleDragOver(e, index)}
onDrop={() => void handleDrop(index)}
onDragEnd={() => {
setDraggedIndex(null);
setDragOverIndex(null);
}}
onEdit={() => dialogRef.current?.openEdit(node)}
/>
))}
{!hasLinks && !readOnly && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border-solid px-4 py-8">
<p className="max-w-md text-center text-sm text-txt-tertiary">
{__(
"Add links to your social profiles, website, or other resources visitors can explore.",
)}
</p>
<Button iconAfter={IconChevronRight} onClick={handleCreate}>
{__("Add link")}
</Button>
</div>
)}
{edges.length > 1 && !readOnly && (
<p className="text-sm text-txt-tertiary">
{__("Drag and drop to change the displayed order")}
</p>
)}
<CompliancePageCustomLinkDialog ref={dialogRef} />
</div>
);
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { detectSocialName, safeOpenUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
IconArrowLink,
IconPencil,
IconTrashCan,
SocialIcon,
} from "@probo/ui";
import { useState } from "react";
import { useFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
import type { CompliancePageCustomLinkListItem_customLink$key } from "#/__generated__/core/CompliancePageCustomLinkListItem_customLink.graphql";
import type { CompliancePageCustomLinkListItem_deleteMutation } from "#/__generated__/core/CompliancePageCustomLinkListItem_deleteMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
const customLinkFragment = graphql`
fragment CompliancePageCustomLinkListItem_customLink on ComplianceCustomLink {
id
name
url
}
`;
const deleteMutation = graphql`
mutation CompliancePageCustomLinkListItem_deleteMutation($input: DeleteComplianceCustomLinkInput!) {
deleteComplianceCustomLink(input: $input) {
deletedComplianceCustomLinkId
}
}
`;
export interface CompliancePageCustomLinkListItemProps {
customLinkKey: CompliancePageCustomLinkListItem_customLink$key;
connectionId: string;
readOnly: boolean;
isDragging: boolean;
isDropTarget: boolean;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
onEdit: () => void;
}
export function CompliancePageCustomLinkListItem(props: CompliancePageCustomLinkListItemProps) {
const {
customLinkKey,
connectionId,
readOnly,
isDragging,
isDropTarget,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onEdit,
} = props;
const { __ } = useTranslate();
const [isMouseDown, setIsMouseDown] = useState(false);
const customLink = useFragment(customLinkFragment, customLinkKey);
const [deleteLink] = useMutation<CompliancePageCustomLinkListItem_deleteMutation>(
deleteMutation,
{ successMessage: __("Link removed."), errorToast: __("Failed to remove link.") },
);
const handleDelete = () => {
void deleteLink({
variables: { input: { id: customLink.id } },
updater: (store) => {
const connection = store.get(connectionId);
if (!connection) return;
ConnectionHandler.deleteNode(connection, customLink.id);
},
});
};
const draggable = !readOnly;
const className = [
isDragging && "opacity-50 cursor-grabbing",
!isDragging && draggable && !isMouseDown && "cursor-grab",
!isDragging && draggable && isMouseDown && "cursor-grabbing",
isDropTarget && "ring-2 ring-primary-500",
]
.filter(Boolean)
.join(" ");
return (
<div
draggable={draggable}
onDragStart={draggable ? onDragStart : undefined}
onDragOver={draggable ? onDragOver : undefined}
onDrop={draggable ? onDrop : undefined}
onDragEnd={draggable ? onDragEnd : undefined}
onMouseDown={draggable ? () => setIsMouseDown(true) : undefined}
onMouseUp={draggable ? () => setIsMouseDown(false) : undefined}
onMouseLeave={draggable ? () => setIsMouseDown(false) : undefined}
className={className}
>
<Card padded>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<SocialIcon
socialName={detectSocialName(customLink.url)}
size={16}
className="shrink-0"
/>
<span className="font-medium">{customLink.name}</span>
</div>
<p className="truncate text-sm text-txt-secondary">{customLink.url}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="secondary"
icon={IconArrowLink}
onClick={() => safeOpenUrl(customLink.url)}
aria-label={__("Open link")}
/>
{!readOnly && (
<>
<Button
variant="secondary"
icon={IconPencil}
onClick={onEdit}
aria-label={__("Edit link")}
/>
<Button
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
aria-label={__("Remove link")}
/>
</>
)}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageCustomLinksSection_compliancePageFragment$key } from "#/__generated__/core/CompliancePageCustomLinksSection_compliancePageFragment.graphql";
import { CompliancePageCustomLinkList } from "./CompliancePageCustomLinkList";
const compliancePageFragment = graphql`
fragment CompliancePageCustomLinksSection_compliancePageFragment on TrustCenter {
...CompliancePageCustomLinkList_compliancePageFragment
}
`;
export interface CompliancePageCustomLinksSectionProps {
compliancePageRef: CompliancePageCustomLinksSection_compliancePageFragment$key;
}
export function CompliancePageCustomLinksSection(props: CompliancePageCustomLinksSectionProps) {
const { __ } = useTranslate();
const compliancePage = useFragment(compliancePageFragment, props.compliancePageRef);
return (
<section className="space-y-4">
<div>
<h2 className="text-base font-medium">{__("Custom links")}</h2>
<p className="text-sm text-txt-tertiary">
{__("Social profiles and other links shown alongside your contact details.")}
</p>
</div>
<CompliancePageCustomLinkList compliancePageRef={compliancePage} />
</section>
);
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Button, IconChevronRight } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageDomainsSection_organizationFragment$key } from "#/__generated__/core/CompliancePageDomainsSection_organizationFragment.graphql";
import { CompliancePageDomainCard } from "../../domain/_components/CompliancePageDomainCard";
import { NewCompliancePageDomainDialog } from "../../domain/_components/NewCompliancePageDomainDialog";
const organizationFragment = graphql`
fragment CompliancePageDomainsSection_organizationFragment on Organization {
canCreateCustomDomain: permission(action: "compliance-portal:custom-domain:create")
compliancePage: trustCenter @required(action: THROW) {
id
defaultDomain {
id
...CompliancePageDomainCardFragment
}
customDomain {
id
...CompliancePageDomainCardFragment
}
}
}
`;
export function CompliancePageDomainsSection(props: {
organizationRef: CompliancePageDomainsSection_organizationFragment$key;
}) {
const { __ } = useTranslate();
const organization = useFragment(organizationFragment, props.organizationRef);
const compliancePageId = organization.compliancePage.id;
const defaultDomain = organization.compliancePage.defaultDomain;
const customDomain = organization.compliancePage.customDomain;
return (
<section className="space-y-4">
<div>
<h2 className="text-base font-medium">{__("Domains")}</h2>
<p className="text-sm text-txt-tertiary">
{__(
"Your compliance page is always available on its default probopage.com subdomain. You can also serve it on one custom domain of your own.",
)}
</p>
</div>
<div className="space-y-3">
{defaultDomain && (
<CompliancePageDomainCard
fKey={defaultDomain}
compliancePageId={compliancePageId}
/>
)}
{customDomain
? (
<CompliancePageDomainCard
fKey={customDomain}
compliancePageId={compliancePageId}
/>
)
: organization.canCreateCustomDomain && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border-solid px-4 py-8">
<p className="max-w-md text-center text-sm text-txt-tertiary">
{__(
"Use your own domain to make your compliance page feel more professional.",
)}
</p>
<NewCompliancePageDomainDialog compliancePageId={compliancePageId}>
<Button iconAfter={IconChevronRight}>{__("Configure")}</Button>
</NewCompliancePageDomainDialog>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Button, Card, Field, Label, Spinner, Textarea } from "@probo/ui";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { CompliancePageProfileSection_compliancePageFragment$key } from "#/__generated__/core/CompliancePageProfileSection_compliancePageFragment.graphql";
import { useUpdateCompliancePageMutation } from "#/hooks/graph/CompliancePageGraph";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const compliancePageFragment = graphql`
fragment CompliancePageProfileSection_compliancePageFragment on TrustCenter {
id
description
websiteUrl
email
headquarterAddress
canUpdate: permission(action: "compliance-portal:portal:update")
}
`;
const profileSchema = z.object({
description: z.string().optional(),
websiteUrl: z.string().optional(),
email: z.string().optional(),
headquarterAddress: z.string().optional(),
});
type ProfileFormData = z.infer<typeof profileSchema>;
export function CompliancePageProfileSection(props: {
compliancePageRef: CompliancePageProfileSection_compliancePageFragment$key;
}) {
const { __ } = useTranslate();
const { canUpdate, ...compliancePage } = useFragment(
compliancePageFragment,
props.compliancePageRef,
);
const [updateCompliancePage, isUpdating] = useUpdateCompliancePageMutation();
const { formState, handleSubmit, register } = useFormWithSchema(profileSchema, {
defaultValues: {
description: compliancePage.description || "",
websiteUrl: compliancePage.websiteUrl || "",
email: compliancePage.email || "",
headquarterAddress: compliancePage.headquarterAddress || "",
},
});
const readOnly = formState.isSubmitting || !canUpdate;
const onSubmit = handleSubmit(async (data: ProfileFormData) => {
await updateCompliancePage({
variables: {
input: {
trustCenterId: compliancePage.id,
description: data.description || null,
websiteUrl: data.websiteUrl || null,
email: data.email || null,
headquarterAddress: data.headquarterAddress || null,
},
},
});
});
return (
<form onSubmit={e => void onSubmit(e)} className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-medium">{__("General information")}</h2>
<p className="text-sm text-txt-tertiary">
{__("Description and contact details shown to visitors.")}
</p>
</div>
{formState.isSubmitting && <Spinner />}
</div>
<Card padded className="space-y-4">
<div>
<Label>{__("Description")}</Label>
<Textarea
{...register("description")}
readOnly={readOnly}
name="description"
placeholder={__("Brief description for visitors")}
rows={3}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field
{...register("websiteUrl")}
readOnly={readOnly}
name="websiteUrl"
type="url"
label={__("Website URL")}
placeholder={__("https://example.com")}
/>
<Field
{...register("email")}
readOnly={readOnly}
name="email"
type="email"
label={__("Email")}
placeholder={__("contact@example.com")}
/>
</div>
<Field
{...register("headquarterAddress")}
readOnly={readOnly}
name="headquarterAddress"
label={__("Headquarter Address")}
placeholder={__("123 Main St, City, Country")}
/>
{formState.isDirty && canUpdate && (
<div className="flex justify-end pt-6">
<Button type="submit" disabled={formState.isSubmitting || isUpdating}>
{formState.isSubmitting || isUpdating
? __("Updating...")
: __("Save changes")}
</Button>
</div>
)}
</Card>
</form>
);
}

View File

@@ -0,0 +1,308 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import {
Button,
Card,
FileButton,
IconTrashCan,
Label,
Spinner,
useToast,
} from "@probo/ui";
import { type ChangeEventHandler, useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageVisualIdentitySection_compliancePageFragment$key } from "#/__generated__/core/CompliancePageVisualIdentitySection_compliancePageFragment.graphql";
import type { CompliancePageVisualIdentitySection_updateMutation } from "#/__generated__/core/CompliancePageVisualIdentitySection_updateMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
const compliancePageFragment = graphql`
fragment CompliancePageVisualIdentitySection_compliancePageFragment on TrustCenter {
id
logo {
downloadUrl
}
darkLogo {
downloadUrl
}
canUpdate: permission(action: "compliance-portal:portal:update")
}
`;
const updateMutation = graphql`
mutation CompliancePageVisualIdentitySection_updateMutation($input: UpdateTrustCenterBrandInput!) {
updateTrustCenterBrand(input: $input) {
trustCenter {
id
logo {
downloadUrl
}
darkLogo {
downloadUrl
}
}
}
}
`;
const acceptImageMimeTypes = "image/png,image/jpeg,image/jpg,image/svg+xml,image/webp";
const maxLogoBytes = 5 * 1024 * 1024;
export interface CompliancePageVisualIdentitySectionProps {
compliancePageRef: CompliancePageVisualIdentitySection_compliancePageFragment$key;
}
export function CompliancePageVisualIdentitySection(props: CompliancePageVisualIdentitySectionProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const compliancePage = useFragment(compliancePageFragment, props.compliancePageRef);
const compliancePageId = compliancePage.id;
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [darkLogoPreview, setDarkLogoPreview] = useState<string | null>(null);
const [updateBrand, isUpdating] = useMutation<CompliancePageVisualIdentitySection_updateMutation>(
updateMutation,
{
successMessage: __("Compliance page branding updated successfully"),
errorToast: __("Failed to update compliance page branding"),
},
);
const disabled = isUpdating || !compliancePage.canUpdate;
const processLogoFile = (file: File, setPreview: (url: string) => void) => {
const reader = new FileReader();
reader.onload = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const isTooLarge = (file: File) => {
if (file.size > maxLogoBytes) {
toast({
title: __("File size too large"),
description: __("The file size is too large. Please upload a file smaller than 5MB."),
variant: "error",
});
return true;
}
return false;
};
const handleLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const file = e.target.files?.[0];
if (!file || isTooLarge(file)) return;
processLogoFile(file, setLogoPreview);
void updateBrand({
variables: {
input: {
trustCenterId: compliancePageId,
logoFile: null,
},
},
uploadables: {
"input.logoFile": file,
},
onCompleted: () => {
setLogoPreview(null);
},
});
};
const handleDarkLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const file = e.target.files?.[0];
if (!file || isTooLarge(file)) return;
processLogoFile(file, setDarkLogoPreview);
void updateBrand({
variables: {
input: {
trustCenterId: compliancePageId,
darkLogoFile: null,
},
},
uploadables: {
"input.darkLogoFile": file,
},
onCompleted: () => {
setDarkLogoPreview(null);
},
});
};
const handleRemoveLogo = async () => {
await updateBrand({
variables: {
input: {
trustCenterId: compliancePageId,
logoFile: null,
},
},
});
setLogoPreview(null);
};
const handleRemoveDarkLogo = async () => {
await updateBrand({
variables: {
input: {
trustCenterId: compliancePageId,
darkLogoFile: null,
},
},
});
setDarkLogoPreview(null);
};
const currentLogoUrl = logoPreview || compliancePage.logo?.downloadUrl;
const currentDarkLogoUrl = darkLogoPreview || compliancePage.darkLogo?.downloadUrl;
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-medium">{__("Visual identity")}</h2>
<p className="text-sm text-txt-tertiary">
{__("Logos displayed on your public compliance page.")}
</p>
</div>
{isUpdating && <Spinner />}
</div>
<Card padded className="space-y-4">
<div className="flex gap-6 items-start">
<div className="flex-1">
<Label>{__("Logo")}</Label>
<p className="text-sm text-txt-tertiary mb-3">
{__("This logo will be displayed on your public compliance page.")}
</p>
<div className="flex items-center gap-4">
{currentLogoUrl
? (
<div className="border border-border-solid rounded-md p-4 bg-surface-secondary">
<img
src={currentLogoUrl}
alt={__("Compliance page logo")}
className="h-16 max-w-xs object-contain"
/>
</div>
)
: (
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border border-dashed border-border-solid bg-surface-secondary text-xs text-txt-tertiary">
{__("No logo")}
</div>
)}
<div className="space-y-1">
<FileButton
disabled={disabled}
onChange={handleLogoChange}
variant="secondary"
accept={acceptImageMimeTypes}
>
{isUpdating
? __("Uploading...")
: currentLogoUrl
? __("Change logo")
: __("Upload logo")}
</FileButton>
{!currentLogoUrl && (
<p className="text-xs text-txt-tertiary">
{__("PNG, JPG, SVG, or WEBP up to 5MB")}
</p>
)}
</div>
{currentLogoUrl && (
<Button
type="button"
variant="quaternary"
icon={IconTrashCan}
onClick={() => void handleRemoveLogo()}
disabled={disabled}
aria-label={__("Remove logo")}
className="text-red-600 hover:text-red-700"
/>
)}
</div>
</div>
<div className="flex-1">
<Label>{__("Dark mode logo")}</Label>
<p className="text-sm text-txt-tertiary mb-3">
{__("This logo will be used when dark mode is enabled.")}
</p>
<div className="flex items-center gap-4">
{currentDarkLogoUrl
? (
<div className="border border-border-solid rounded-md p-4 bg-gray-900">
<img
src={currentDarkLogoUrl}
alt={__("Compliance page dark logo")}
className="h-16 max-w-xs object-contain"
/>
</div>
)
: (
<div className="flex h-16 w-28 shrink-0 items-center justify-center rounded-md border border-dashed border-border-solid bg-gray-900 text-xs text-txt-tertiary">
{__("No logo")}
</div>
)}
<div className="space-y-1">
<FileButton
disabled={disabled}
onChange={handleDarkLogoChange}
variant="secondary"
accept={acceptImageMimeTypes}
>
{isUpdating
? __("Uploading...")
: currentDarkLogoUrl
? __("Change dark logo")
: __("Upload dark logo")}
</FileButton>
{!currentDarkLogoUrl && (
<p className="text-xs text-txt-tertiary">
{__("PNG, JPG, SVG, or WEBP up to 5MB")}
</p>
)}
</div>
{currentDarkLogoUrl && (
<Button
type="button"
variant="quaternary"
icon={IconTrashCan}
onClick={() => void handleRemoveDarkLogo()}
disabled={disabled}
aria-label={__("Remove dark logo")}
className="text-red-600 hover:text-red-700"
/>
)}
</div>
</div>
</div>
</Card>
</section>
);
}

View File

@@ -1,90 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Button, Card, IconPlusLarge } from "@probo/ui";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageDomainPageQuery } from "#/__generated__/core/CompliancePageDomainPageQuery.graphql";
import { CompliancePageDomainCard } from "./_components/CompliancePageDomainCard";
import { NewCompliancePageDomainDialog } from "./_components/NewCompliancePageDomainDialog";
export const compliancePageDomainPageQuery = graphql`
query CompliancePageDomainPageQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
__typename
... on Organization {
canCreateCustomDomain: permission(action: "core:custom-domain:create")
customDomain {
...CompliancePageDomainCardFragment
}
}
}
}
`;
export function CompliancePageDomainPage(props: {
queryRef: PreloadedQuery<CompliancePageDomainPageQuery>;
}) {
const { queryRef } = props;
const { __ } = useTranslate();
const { organization } = usePreloadedQuery<CompliancePageDomainPageQuery>(
compliancePageDomainPageQuery,
queryRef,
);
if (organization.__typename !== "Organization") {
throw new Error("invalid type for node");
}
return (
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Custom Domain")}</h2>
{organization.customDomain
? (
<CompliancePageDomainCard fKey={organization.customDomain} />
)
: (
<Card padded>
<div className="text-center py-8">
<h3 className="text-lg font-semibold mb-2">
{__("No custom domain configured")}
</h3>
<p className="text-txt-tertiary mb-4">
{__(
"Add your own domain to make your compliance page more professional",
)}
</p>
<div className="flex justify-center">
{organization.canCreateCustomDomain && (
<NewCompliancePageDomainDialog>
<Button icon={IconPlusLarge}>{__("Add Domain")}</Button>
</NewCompliancePageDomainDialog>
)}
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -1,59 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { useEffect } from "react";
import { useQueryLoader } from "react-relay";
import type { CompliancePageDomainPageQuery } from "#/__generated__/core/CompliancePageDomainPageQuery.graphql";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
import {
CompliancePageDomainPage,
compliancePageDomainPageQuery,
} from "./CompliancePageDomainPage";
function CompliancePageDomainPageQueryLoader() {
const organizationId = useOrganizationId();
const [queryRef, loadQuery] = useQueryLoader<CompliancePageDomainPageQuery>(
compliancePageDomainPageQuery,
);
useEffect(() => {
loadQuery({
organizationId,
});
}, [loadQuery, organizationId]);
if (!queryRef) {
return <LinkCardSkeleton />;
}
return <CompliancePageDomainPage queryRef={queryRef} />;
}
export default function CompliancePageDomainPageLoader() {
return (
<CoreRelayProvider>
<CompliancePageDomainPageQueryLoader />
</CoreRelayProvider>
);
}

View File

@@ -34,54 +34,62 @@ import { DeleteCompliancePageDomainDialog } from "./DeleteCompliancePageDomainDi
const fragment = graphql`
fragment CompliancePageDomainCardFragment on CustomDomain {
id
domain
managed
sslStatus
provisioningError
canDelete: permission(action: "core:custom-domain:delete")
canDelete: permission(action: "compliance-portal:custom-domain:delete")
...CompliancePageDomainDialogFragment
}
`;
export function CompliancePageDomainCard(props: { fKey: CompliancePageDomainCardFragment$key }) {
const { fKey } = props;
export function CompliancePageDomainCard(props: {
fKey: CompliancePageDomainCardFragment$key;
compliancePageId: string;
}) {
const { fKey, compliancePageId } = props;
const { __ } = useTranslate();
const domain = useFragment<CompliancePageDomainCardFragment$key>(fragment, fKey);
return (
<Card>
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<div className="font-medium mb-1">{domain.domain}</div>
<div className="text-sm text-txt-secondary">
{domain.sslStatus === "ACTIVE"
? __("Verified")
: domain.provisioningError
? domain.provisioningError
: __("Pending verification")}
</div>
</div>
<Badge
variant={getCustomDomainStatusBadgeVariant(domain.sslStatus)}
>
<Card padded>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{domain.domain}</span>
{domain.managed && (
<Badge variant="neutral">{__("Managed")}</Badge>
)}
<Badge variant={getCustomDomainStatusBadgeVariant(domain.sslStatus)}>
{getCustomDomainStatusBadgeLabel(domain.sslStatus, __)}
</Badge>
</div>
<p className="text-sm text-txt-secondary">
{domain.sslStatus === "ACTIVE"
? __("Verified and serving traffic")
: domain.provisioningError
? domain.provisioningError
: __("Pending DNS verification")}
</p>
</div>
<div className="flex items-center gap-2">
<CompliancePageDomainDialog fKey={domain}>
<Button variant="secondary">{__("View Details")}</Button>
</CompliancePageDomainDialog>
<div className="flex shrink-0 items-center gap-2">
<CompliancePageDomainDialog fKey={domain}>
<Button variant="secondary">{__("View details")}</Button>
</CompliancePageDomainDialog>
{domain.canDelete && (
<DeleteCompliancePageDomainDialog domain={domain.domain}>
<Button variant="danger">{__("Delete")}</Button>
</DeleteCompliancePageDomainDialog>
)}
</div>
{domain.canDelete && (
<DeleteCompliancePageDomainDialog
domain={domain.domain}
customDomainId={domain.id}
compliancePageId={compliancePageId}
>
<Button variant="danger">{__("Delete")}</Button>
</DeleteCompliancePageDomainDialog>
)}
</div>
</div>
</Card>

View File

@@ -33,8 +33,7 @@ import { type PropsWithChildren, useState } from "react";
import { graphql } from "relay-runtime";
import type { DeleteCompliancePageDomainDialogMutation } from "#/__generated__/core/DeleteCompliancePageDomainDialogMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { useMutation } from "#/lib/relay/useMutation";
const deleteCustomDomainMutation = graphql`
mutation DeleteCompliancePageDomainDialogMutation($input: DeleteCustomDomainInput!) {
@@ -46,39 +45,36 @@ const deleteCustomDomainMutation = graphql`
type DeleteCompliancePageDomainDialogProps = PropsWithChildren<{
domain: string;
customDomainId: string;
compliancePageId: string;
}>;
export function DeleteCompliancePageDomainDialog(props: DeleteCompliancePageDomainDialogProps) {
const { children, domain } = props;
const { children, domain, customDomainId } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [inputValue, setInputValue] = useState("");
const [deleteCustomDomain, isDeleting]
= useMutationWithToasts<DeleteCompliancePageDomainDialogMutation>(
= useMutation<DeleteCompliancePageDomainDialogMutation>(
deleteCustomDomainMutation,
{
successMessage: __("Domain deleted successfully"),
errorMessage: __("Failed to delete domain"),
errorToast: __("Failed to delete domain"),
},
);
const handleDeleteDomain = async () => {
return deleteCustomDomain({
variables: {
input: { organizationId },
input: { customDomainId },
},
onCompleted: () => {
dialogRef.current?.close();
},
updater: (store) => {
// Update the cache by setting customDomain to null
const organizationRecord = store.get(organizationId);
if (organizationRecord) {
organizationRecord.setValue(null, "customDomain");
}
store.delete(customDomainId);
},
});
};

View File

@@ -34,8 +34,7 @@ import { z } from "zod";
import type { NewCompliancePageDomainDialogMutation } from "#/__generated__/core/NewCompliancePageDomainDialogMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { useMutation } from "#/lib/relay/useMutation";
const createCustomDomainMutation = graphql`
mutation NewCompliancePageDomainDialogMutation($input: CreateCustomDomainInput!) {
@@ -43,6 +42,7 @@ const createCustomDomainMutation = graphql`
customDomain {
id
domain
managed
sslStatus
dnsRecords {
type
@@ -54,7 +54,8 @@ const createCustomDomainMutation = graphql`
createdAt
updatedAt
sslExpiresAt
canDelete: permission(action: "core:custom-domain:delete")
canDelete: permission(action: "compliance-portal:custom-domain:delete")
...CompliancePageDomainCardFragment
}
}
}
@@ -70,10 +71,9 @@ const schema = z.object({
),
});
export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
const { children } = props;
export function NewCompliancePageDomainDialog(props: PropsWithChildren<{ compliancePageId: string }>) {
const { children, compliancePageId } = props;
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const dialogRef = useDialogRef();
@@ -87,11 +87,11 @@ export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
);
const [createCustomDomain, isCreating]
= useMutationWithToasts<NewCompliancePageDomainDialogMutation>(createCustomDomainMutation, {
= useMutation<NewCompliancePageDomainDialogMutation>(createCustomDomainMutation, {
successMessage: __(
"Domain added successfully. Configure the DNS records to verify and activate your domain.",
),
errorMessage: __("Failed to add domain"),
errorToast: __("Failed to add domain"),
});
const onSubmit = async (data: z.infer<typeof schema>) => {
@@ -104,30 +104,26 @@ export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
await createCustomDomain({
variables: {
input: {
organizationId,
trustCenterId: compliancePageId,
domain: normalizedDomain,
},
},
updater: (store, data) => {
// Update the cache by setting the new customDomain on the organization
const organizationRecord = store.get(organizationId);
if (organizationRecord && data?.createCustomDomain?.customDomain) {
const customDomainRecord = store.get(
data.createCustomDomain.customDomain.id,
);
if (customDomainRecord) {
organizationRecord.setLinkedRecord(
customDomainRecord,
"customDomain",
);
}
const newDomainId = data?.createCustomDomain?.customDomain?.id;
if (!newDomainId) {
return;
}
const compliancePageRecord = store.get(compliancePageId);
const newDomainRecord = store.get(newDomainId);
if (compliancePageRecord && newDomainRecord) {
compliancePageRecord.setLinkedRecord(newDomainRecord, "customDomain");
}
},
onSuccess: () => {
reset();
dialogRef.current?.close();
},
});
reset();
dialogRef.current?.close();
};
return (
@@ -160,7 +156,7 @@ export function NewCompliancePageDomainDialog(props: PropsWithChildren) {
<strong>{__("Examples:")}</strong>
{" "}
compliance.example.com,
trust.example.com
compliance.example.com
</p>
</div>
</DialogContent>

View File

@@ -23,6 +23,7 @@ import { graphql } from "relay-runtime";
import type { CompliancePageOverviewPageQuery } from "#/__generated__/core/CompliancePageOverviewPageQuery.graphql";
import { CompliancePageFrameworksSection } from "./_components/CompliancePageFrameworksSection";
import { CompliancePageNDASection } from "./_components/CompliancePageNDASection";
import { CompliancePageSlackSection } from "./_components/CompliancePageSlackSection";
import { CompliancePageStatusSection } from "./_components/CompliancePageStatusSection";
@@ -32,10 +33,11 @@ export const compliancePageOverviewPageQuery = graphql`
organization: node(id: $organizationId) {
... on Organization {
compliancePage: trustCenter {
canGetNDA: permission(action: "core:trust-center:get-nda")
canGetNDA: permission(action: "compliance-portal:portal:get-nda")
}
}
...CompliancePageStatusSectionFragment
...CompliancePageFrameworksSectionFragment
...CompliancePageNDASectionFragment
...CompliancePageSlackSectionFragment
}
@@ -54,6 +56,8 @@ export function CompliancePageOverviewPage(props: { queryRef: PreloadedQuery<Com
<div className="space-y-6">
<CompliancePageStatusSection fragmentRef={organization} />
<CompliancePageFrameworksSection fragmentRef={organization} />
{organization.compliancePage?.canGetNDA && (
<CompliancePageNDASection fragmentRef={organization} />
)}

View File

@@ -1,483 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { detectSocialName, safeOpenUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
IconArrowLink,
IconPencil,
IconPlusLarge,
IconTrashCan,
SocialIcon,
Spinner,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useDialogRef,
} from "@probo/ui";
import { forwardRef, useCallback, useImperativeHandle, useRef, useState, useTransition } from "react";
import { useRefetchableFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
import { z } from "zod";
import type { CompliancePageExternalUrlsSection_createMutation } from "#/__generated__/core/CompliancePageExternalUrlsSection_createMutation.graphql";
import type { CompliancePageExternalUrlsSection_deleteMutation } from "#/__generated__/core/CompliancePageExternalUrlsSection_deleteMutation.graphql";
import type { CompliancePageExternalUrlsSection_trustCenterFragment$key } from "#/__generated__/core/CompliancePageExternalUrlsSection_trustCenterFragment.graphql";
import type { CompliancePageExternalUrlsSection_trustCenterRefetchQuery } from "#/__generated__/core/CompliancePageExternalUrlsSection_trustCenterRefetchQuery.graphql";
import type { CompliancePageExternalUrlsSection_updateMutation } from "#/__generated__/core/CompliancePageExternalUrlsSection_updateMutation.graphql";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
const trustCenterFragment = graphql`
fragment CompliancePageExternalUrlsSection_trustCenterFragment on TrustCenter
@refetchable(queryName: "CompliancePageExternalUrlsSection_trustCenterRefetchQuery")
@argumentDefinitions(
first: { type: Int, defaultValue: 100 }
after: { type: CursorKey, defaultValue: null }
order: { type: ComplianceExternalURLOrder, defaultValue: { field: RANK, direction: ASC } }
) {
id
canUpdate: permission(action: "core:trust-center:update")
externalUrls(first: $first, after: $after, orderBy: $order)
@connection(key: "CompliancePageExternalUrlsSection_externalUrls", filters: ["orderBy"]) {
__id
edges {
node {
id
name
url
rank
}
}
}
}
`;
const createMutation = graphql`
mutation CompliancePageExternalUrlsSection_createMutation($input: CreateComplianceExternalURLInput!) {
createComplianceExternalURL(input: $input) {
complianceExternalUrlEdge {
node {
id
name
url
rank
}
}
}
}
`;
const updateMutation = graphql`
mutation CompliancePageExternalUrlsSection_updateMutation($input: UpdateComplianceExternalURLInput!) {
updateComplianceExternalURL(input: $input) {
complianceExternalUrl {
id
name
url
rank
}
}
}
`;
const deleteMutation = graphql`
mutation CompliancePageExternalUrlsSection_deleteMutation($input: DeleteComplianceExternalURLInput!) {
deleteComplianceExternalURL(input: $input) {
deletedComplianceExternalUrlId
}
}
`;
const urlSchema = z.object({
name: z.string().min(1, "Name is required"),
url: z.string().url("Please enter a valid URL"),
});
type UrlFormData = z.infer<typeof urlSchema>;
type UrlNode = { id: string; name: string; url: string; rank: number };
type ExternalUrlDialogRef = {
openCreate: (trustCenterId: string, connectionId: string) => void;
openEdit: (node: UrlNode) => void;
};
const ExternalUrlDialog = forwardRef<ExternalUrlDialogRef>(
function ExternalUrlDialog(_, ref) {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [mode, setMode] = useState<"create" | "edit">("create");
const [trustCenterId, setTrustCenterId] = useState("");
const [connectionId, setConnectionId] = useState("");
const [editNode, setEditNode] = useState<UrlNode | null>(null);
const [create, isCreating] = useMutationWithToasts<CompliancePageExternalUrlsSection_createMutation>(
createMutation,
{ successMessage: __("Link added successfully."), errorMessage: __("Failed to add link.") },
);
const [update, isUpdating] = useMutationWithToasts<CompliancePageExternalUrlsSection_updateMutation>(
updateMutation,
{ successMessage: __("Link updated successfully."), errorMessage: __("Failed to update link.") },
);
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useFormWithSchema(urlSchema, {
defaultValues: { name: "", url: "" },
});
const [nameAutoDetected, setNameAutoDetected] = useState(false);
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const url = e.target.value;
const detected = detectSocialName(url);
if (detected && (nameAutoDetected || watch("name") === "")) {
setValue("name", detected, { shouldValidate: true });
setNameAutoDetected(true);
} else if (!detected && nameAutoDetected) {
setValue("name", "", { shouldValidate: false });
setNameAutoDetected(false);
}
};
useImperativeHandle(ref, () => ({
openCreate: (tId, cId) => {
setMode("create");
setTrustCenterId(tId);
setConnectionId(cId);
setEditNode(null);
setNameAutoDetected(false);
reset({ name: "", url: "" });
dialogRef.current?.open();
},
openEdit: (node) => {
setMode("edit");
setEditNode(node);
setNameAutoDetected(false);
reset({ name: node.name, url: node.url });
dialogRef.current?.open();
},
}));
const onSubmit = async (data: UrlFormData) => {
if (mode === "create") {
await create({
variables: {
input: { trustCenterId, name: data.name, url: data.url },
},
updater: (store) => {
const payload = store.getRootField("createComplianceExternalURL");
const edge = payload?.getLinkedRecord("complianceExternalUrlEdge");
if (!edge) return;
const connection = store.get(connectionId);
if (!connection) return;
ConnectionHandler.insertEdgeAfter(connection, edge);
},
onCompleted: (_, errs) => {
if (!errs?.length) {
reset();
dialogRef.current?.close();
}
},
});
} else if (editNode) {
await update({
variables: { input: { id: editNode.id, name: data.name, url: data.url } },
onCompleted: (_, errs) => {
if (!errs?.length) {
reset();
dialogRef.current?.close();
}
},
});
}
};
const isSubmitting = isCreating || isUpdating;
const title = mode === "create" ? __("Add link") : __("Edit link");
return (
<Dialog ref={dialogRef} title={title} onClose={() => reset()}>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-6">
<Field
{...register("url", { onChange: handleUrlChange })}
label={__("URL")}
type="url"
required
placeholder="https://example.com"
error={errors.url?.message}
/>
<Field
{...register("name")}
label={__("Name")}
type="text"
required
placeholder={__("e.g. Twitter, LinkedIn")}
error={errors.name?.message}
/>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isSubmitting} icon={isSubmitting ? Spinner : undefined}>
{mode === "create" ? __("Add link") : __("Save changes")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
},
);
function ExternalUrlRow(props: {
node: UrlNode;
canEdit: boolean;
connectionId: string;
isDragging: boolean;
isDropTarget: boolean;
onDragStart: () => void;
onDragOver: (e: React.DragEvent) => void;
onDrop: () => void;
onDragEnd: () => void;
onEdit: (node: UrlNode) => void;
}) {
const {
node, canEdit, connectionId, isDragging, isDropTarget,
onDragStart, onDragOver, onDrop, onDragEnd, onEdit,
} = props;
const { __ } = useTranslate();
const [isMouseDown, setIsMouseDown] = useState(false);
const [deleteUrl] = useMutationWithToasts<CompliancePageExternalUrlsSection_deleteMutation>(
deleteMutation,
{ successMessage: __("Link removed."), errorMessage: __("Failed to remove link.") },
);
const handleDelete = () => {
void deleteUrl({
variables: { input: { id: node.id } },
updater: (store) => {
const connection = store.get(connectionId);
if (!connection) return;
ConnectionHandler.deleteNode(connection, node.id);
},
});
};
const className = [
isDragging && "opacity-50 cursor-grabbing",
!isDragging && !isMouseDown && "cursor-grab",
!isDragging && isMouseDown && "cursor-grabbing",
isDropTarget && "!bg-primary-50 border-y-2 border-primary-500",
]
.filter(Boolean)
.join(" ");
return (
<Tr
draggable={canEdit}
onDragStart={canEdit ? onDragStart : undefined}
onDragOver={canEdit ? onDragOver : undefined}
onDrop={canEdit ? onDrop : undefined}
onDragEnd={canEdit ? onDragEnd : undefined}
onMouseDown={canEdit ? () => setIsMouseDown(true) : undefined}
onMouseUp={canEdit ? () => setIsMouseDown(false) : undefined}
onMouseLeave={canEdit ? () => setIsMouseDown(false) : undefined}
className={className}
>
<Td>
<div className="flex items-center gap-3 text-txt-secondary">
<SocialIcon socialName={detectSocialName(node.url)} size={16} className="shrink-0" />
<span className="font-medium">{node.name}</span>
</div>
</Td>
<Td>
<span className="text-txt-secondary truncate">{node.url}</span>
</Td>
<Td noLink width={canEdit ? 144 : 56} className="text-end">
<div className="flex gap-2 justify-end">
<Button variant="secondary" icon={IconArrowLink} onClick={() => safeOpenUrl(node.url)} />
{canEdit && (
<>
<Button variant="secondary" icon={IconPencil} onClick={() => onEdit(node)} />
<Button variant="danger" icon={IconTrashCan} onClick={handleDelete} />
</>
)}
</div>
</Td>
</Tr>
);
}
export function CompliancePageExternalUrlsSection(props: {
trustCenterRef: CompliancePageExternalUrlsSection_trustCenterFragment$key;
}) {
const { __ } = useTranslate();
const [, startTransition] = useTransition();
const dialogRef = useRef<ExternalUrlDialogRef>(null);
const [trustCenter, refetch] = useRefetchableFragment<
CompliancePageExternalUrlsSection_trustCenterRefetchQuery,
CompliancePageExternalUrlsSection_trustCenterFragment$key
>(trustCenterFragment, props.trustCenterRef);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [updateRank] = useMutationWithToasts<CompliancePageExternalUrlsSection_updateMutation>(
updateMutation,
{ successMessage: __("Order updated."), errorMessage: __("Failed to update order.") },
);
const edges = trustCenter.externalUrls.edges;
const canEdit = trustCenter.canUpdate;
const connectionId = trustCenter.externalUrls.__id;
const handleCreate = () => {
dialogRef.current?.openCreate(trustCenter.id, connectionId);
};
const handleEdit = (node: UrlNode) => {
dialogRef.current?.openEdit(node);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex !== index) setDragOverIndex(index);
};
const handleDrop = useCallback(
async (targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const draggedEdge = edges[draggedIndex];
const targetRank = edges[targetIndex].node.rank;
const draggedId = draggedEdge.node.id;
await updateRank({
variables: {
input: {
id: draggedId,
name: draggedEdge.node.name,
url: draggedEdge.node.url,
rank: targetRank,
},
},
updater: (store) => {
const connection = store.get(connectionId);
if (!connection) return;
const storeEdges = connection.getLinkedRecords("edges");
if (!storeEdges) return;
const fromIdx = storeEdges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === draggedId);
const toIdx = storeEdges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === edges[targetIndex].node.id);
if (fromIdx === -1 || toIdx === -1) return;
const reordered = [...storeEdges];
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
connection.setLinkedRecords(reordered, "edges");
},
onCompleted: (_, errors) => {
startTransition(() => {
refetch({}, { fetchPolicy: errors?.length ? "network-only" : "store-and-network" });
});
},
});
setDraggedIndex(null);
setDragOverIndex(null);
},
[draggedIndex, edges, connectionId, updateRank, refetch, startTransition],
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-medium">{__("Custom links")}</h3>
<p className="text-sm text-txt-tertiary">
{__("Add external URLs to display on your compliance page")}
</p>
</div>
{canEdit && (
<Button icon={IconPlusLarge} onClick={handleCreate}>
{__("Add link")}
</Button>
)}
</div>
<Table>
<Thead>
<Tr>
<Th>{__("Name")}</Th>
<Th>{__("URL")}</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{edges.length === 0 && (
<Tr>
<Td colSpan={3} className="text-center text-txt-secondary">
{__("No custom links yet")}
</Td>
</Tr>
)}
{edges.map(({ node }, index) => (
<ExternalUrlRow
key={node.id}
node={node}
canEdit={canEdit}
connectionId={connectionId}
isDragging={draggedIndex === index}
isDropTarget={dragOverIndex === index && draggedIndex !== index}
onDragStart={() => setDraggedIndex(index)}
onDragOver={e => handleDragOver(e, index)}
onDrop={() => void handleDrop(index)}
onDragEnd={() => {
setDraggedIndex(null);
setDragOverIndex(null);
}}
onEdit={handleEdit}
/>
))}
</Tbody>
</Table>
{edges.length > 1 && canEdit && (
<p className="text-sm text-txt-tertiary">
{__("Drag and drop to change the displayed order")}
</p>
)}
<ExternalUrlDialog ref={dialogRef} />
</div>
);
}

View File

@@ -19,17 +19,14 @@
// SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Badge, Field, FrameworkLogo, Option, Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui";
import { useCallback, useState, useTransition } from "react";
import { useTransition } from "react";
import { useRefetchableFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime";
import { graphql } from "relay-runtime";
import type { CompliancePageFrameworkList_compliancePageFragment$data, CompliancePageFrameworkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
import type { CompliancePageFrameworkList_compliancePageFragment$key } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageFragment.graphql";
import type { CompliancePageFrameworkList_compliancePageRefetchQuery } from "#/__generated__/core/CompliancePageFrameworkList_compliancePageRefetchQuery.graphql";
import type { CompliancePageFrameworkList_createMutation } from "#/__generated__/core/CompliancePageFrameworkList_createMutation.graphql";
import type { CompliancePageFrameworkList_deleteMutation } from "#/__generated__/core/CompliancePageFrameworkList_deleteMutation.graphql";
import type { CompliancePageFrameworkList_updateRankMutation } from "#/__generated__/core/CompliancePageFrameworkList_updateRankMutation.graphql";
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
import { CompliancePageFrameworkListItem } from "./CompliancePageFrameworkListItem";
const compliancePageFragment = graphql`
fragment CompliancePageFrameworkList_compliancePageFragment on TrustCenter
@@ -39,206 +36,24 @@ const compliancePageFragment = graphql`
after: { type: CursorKey, defaultValue: null }
order: { type: ComplianceFrameworkOrder, defaultValue: { field: RANK, direction: ASC } }
) {
id
canUpdate: permission(action: "core:trust-center:update")
...CompliancePageFrameworkListItem_compliancePage
complianceFrameworks(first: $first, after: $after, orderBy: $order)
@connection(key: "CompliancePageFrameworkList_complianceFrameworks", filters: ["orderBy"]) {
edges {
node {
id
rank
visibility
framework {
id
name
lightLogo {
downloadUrl
}
darkLogo {
downloadUrl
}
}
...CompliancePageFrameworkListItem_complianceFramework
}
}
}
}
`;
const createMutation = graphql`
mutation CompliancePageFrameworkList_createMutation($input: CreateComplianceFrameworkInput!) {
createComplianceFramework(input: $input) {
complianceFrameworkEdge {
node {
id
}
}
}
}
`;
const updateRankMutation = graphql`
mutation CompliancePageFrameworkList_updateRankMutation($input: UpdateComplianceFrameworkInput!) {
updateComplianceFramework(input: $input) {
complianceFramework {
id
rank
}
}
}
`;
const deleteMutation = graphql`
mutation CompliancePageFrameworkList_deleteMutation($input: DeleteComplianceFrameworkInput!) {
deleteComplianceFramework(input: $input) {
deletedComplianceFrameworkId
}
}
`;
type Edge = CompliancePageFrameworkList_compliancePageFragment$data["complianceFrameworks"]["edges"][number];
function CompliancePageFrameworkListItem(props: {
edge: Edge;
compliancePage: CompliancePageFrameworkList_compliancePageFragment$data;
draggedCfId: string | null;
dragOverCfId: string | null;
onDragStart: (cfId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent, cfId: string) => void;
onDrop: (cfId: string) => void;
onRefetch: () => void;
}) {
const { edge, draggedCfId, dragOverCfId, onDragStart, onDragEnd, onDragOver, onDrop, onRefetch } = props;
const { __ } = useTranslate();
const [isMouseDown, setIsMouseDown] = useState(false);
const compliancePage = props.compliancePage;
const { id, visibility, framework } = edge.node;
const isPublic = visibility === "PUBLIC";
const canDrag = isPublic && compliancePage.canUpdate;
const isDragging = draggedCfId === id;
const isDropTarget = dragOverCfId === id && draggedCfId !== id;
const [createComplianceFramework, isCreating] = useMutationWithToasts<CompliancePageFrameworkList_createMutation>(
createMutation,
{
successMessage: __("Framework visibility updated successfully."),
errorMessage: __("Failed to update framework visibility"),
},
);
const [deleteComplianceFramework, isDeleting] = useMutationWithToasts<CompliancePageFrameworkList_deleteMutation>(
deleteMutation,
{
successMessage: __("Framework visibility updated successfully."),
errorMessage: __("Failed to update framework visibility"),
},
);
const isLoading = isCreating || isDeleting;
const handleVisibilityChange = useCallback(
async (value: string) => {
if (value === "PUBLIC" && !isPublic) {
await createComplianceFramework({
variables: {
input: {
trustCenterId: compliancePage.id,
frameworkId: framework.id,
},
},
onCompleted: (_, errors) => {
if (!errors?.length) {
onRefetch();
}
},
});
} else if (value === "NONE" && isPublic) {
await deleteComplianceFramework({
variables: {
input: { id },
},
onCompleted: (_, errors) => {
if (!errors?.length) {
onRefetch();
}
},
});
}
},
[compliancePage.id, framework.id, id, isPublic, onRefetch, createComplianceFramework, deleteComplianceFramework],
);
const visibilityOptions = [
{ value: "PUBLIC", label: __("Public"), variant: "success" as const },
{ value: "NONE", label: __("None"), variant: "neutral" as const },
];
const rowClassName = [
canDrag && isDragging && "opacity-50 cursor-grabbing",
canDrag && !isDragging && !isMouseDown && "cursor-grab",
canDrag && !isDragging && isMouseDown && "cursor-grabbing",
isDropTarget && "!bg-primary-50 border-y-2 border-primary-500",
]
.filter(Boolean)
.join(" ");
return (
<Tr
draggable={canDrag}
onDragStart={canDrag ? () => onDragStart(id) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
onDragOver={canDrag ? e => onDragOver(e, id) : undefined}
onDrop={
canDrag
? (e) => {
e.preventDefault();
onDrop(id);
}
: undefined
}
onMouseDown={canDrag ? () => setIsMouseDown(true) : undefined}
onMouseUp={canDrag ? () => setIsMouseDown(false) : undefined}
onMouseLeave={canDrag ? () => setIsMouseDown(false) : undefined}
className={rowClassName}
>
<Td>
<div className="flex items-center gap-3">
<FrameworkLogo
className="size-8"
lightLogoURL={framework.lightLogo?.downloadUrl}
darkLogoURL={framework.darkLogo?.downloadUrl}
name={framework.name}
/>
{framework.name}
</div>
</Td>
<Td noLink width={130} className="pr-0">
<Field
type="select"
value={visibility}
onValueChange={value => void handleVisibilityChange(value)}
disabled={isLoading || !compliancePage.canUpdate}
className="w-[105px]"
>
{visibilityOptions.map(option => (
<Option key={option.value} value={option.value}>
<div className="flex items-center justify-between w-full">
<Badge variant={option.variant}>{option.label}</Badge>
</div>
</Option>
))}
</Field>
</Td>
</Tr>
);
export interface CompliancePageFrameworkListProps {
compliancePageRef: CompliancePageFrameworkList_compliancePageFragment$key;
}
export function CompliancePageFrameworkList(props: {
compliancePageRef: CompliancePageFrameworkList_compliancePageFragment$key;
}) {
export function CompliancePageFrameworkList(props: CompliancePageFrameworkListProps) {
const { __ } = useTranslate();
const [, startTransition] = useTransition();
@@ -247,134 +62,32 @@ export function CompliancePageFrameworkList(props: {
CompliancePageFrameworkList_compliancePageFragment$key
>(compliancePageFragment, props.compliancePageRef);
const [updateRank] = useMutationWithToasts<CompliancePageFrameworkList_updateRankMutation>(
updateRankMutation,
{
successMessage: __("Order updated successfully"),
errorMessage: __("Failed to update order"),
},
);
const edges = compliancePage.complianceFrameworks.edges;
const [draggedCfId, setDraggedCfId] = useState<string | null>(null);
const [dragOverCfId, setDragOverCfId] = useState<string | null>(null);
const allEdges = compliancePage.complianceFrameworks.edges;
const publicEdges = allEdges.filter(e => e.node.visibility === "PUBLIC");
const handleDragStart = (cfId: string) => {
setDraggedCfId(cfId);
};
const handleDragEnd = () => {
setDraggedCfId(null);
setDragOverCfId(null);
};
const handleDragOver = (e: React.DragEvent, cfId: string) => {
e.preventDefault();
if (draggedCfId !== cfId) {
setDragOverCfId(cfId);
}
};
const handleDrop = async (targetCfId: string) => {
if (!draggedCfId || draggedCfId === targetCfId) {
setDraggedCfId(null);
setDragOverCfId(null);
return;
}
const targetEdge = publicEdges.find(e => e.node.id === targetCfId);
if (!targetEdge) {
setDraggedCfId(null);
setDragOverCfId(null);
return;
}
const draggedId = draggedCfId;
await updateRank({
variables: {
input: {
id: draggedId,
rank: targetEdge.node.rank,
},
},
updater: (store) => {
const trustCenterRecord = store.get(compliancePage.id);
if (!trustCenterRecord) return;
const connection = ConnectionHandler.getConnection(
trustCenterRecord,
"CompliancePageFrameworkList_complianceFrameworks",
{ orderBy: { field: "RANK", direction: "ASC" } },
);
if (!connection) return;
const edges = connection.getLinkedRecords("edges");
if (!edges) return;
const fromIdx = edges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === draggedId);
const toIdx = edges.findIndex(e => e.getLinkedRecord("node")?.getDataID() === targetCfId);
if (fromIdx === -1 || toIdx === -1) return;
const reordered = [...edges];
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
connection.setLinkedRecords(reordered, "edges");
},
onCompleted: (_, errors) => {
startTransition(() => {
refetch({}, { fetchPolicy: errors?.length ? "network-only" : "store-and-network" });
});
},
const handleRefetch = () => {
startTransition(() => {
refetch({}, { fetchPolicy: "store-and-network" });
});
setDraggedCfId(null);
setDragOverCfId(null);
};
const hasMultiplePublic = publicEdges.length > 1;
if (edges.length === 0) {
return (
<p className="text-sm text-txt-secondary">
{__("No frameworks available")}
</p>
);
}
return (
<>
<Table>
<Thead>
<Tr>
<Th>{__("Framework")}</Th>
<Th>{__("Visibility")}</Th>
</Tr>
</Thead>
<Tbody>
{allEdges.length === 0 && (
<Tr>
<Td colSpan={2} className="text-center text-txt-secondary">
{__("No frameworks available")}
</Td>
</Tr>
)}
{allEdges.map(edge => (
<CompliancePageFrameworkListItem
key={edge.node.id}
edge={edge}
compliancePage={compliancePage}
draggedCfId={draggedCfId}
dragOverCfId={dragOverCfId}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={cfId => void handleDrop(cfId)}
onRefetch={() => startTransition(() => { refetch({}, { fetchPolicy: "store-and-network" }); })}
/>
))}
</Tbody>
</Table>
{hasMultiplePublic && compliancePage.canUpdate && (
<p className="text-sm text-txt-tertiary">
{__("Drag and drop public frameworks to change their displayed order")}
</p>
)}
</>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{edges.map(edge => (
<CompliancePageFrameworkListItem
key={edge.node.id}
complianceFrameworkKey={edge.node}
compliancePageKey={compliancePage}
onRefetch={handleRefetch}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { FrameworkLogo, IconCheckmark1, Spinner } from "@probo/ui";
import { useCallback, useState } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageFrameworkListItem_complianceFramework$key } from "#/__generated__/core/CompliancePageFrameworkListItem_complianceFramework.graphql";
import type { CompliancePageFrameworkListItem_compliancePage$key } from "#/__generated__/core/CompliancePageFrameworkListItem_compliancePage.graphql";
import type { CompliancePageFrameworkListItem_createMutation } from "#/__generated__/core/CompliancePageFrameworkListItem_createMutation.graphql";
import type { CompliancePageFrameworkListItem_deleteMutation } from "#/__generated__/core/CompliancePageFrameworkListItem_deleteMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
export const compliancePageFrameworkListItemFragment = graphql`
fragment CompliancePageFrameworkListItem_complianceFramework on ComplianceFramework {
id
visibility
framework {
id
name
lightLogo {
downloadUrl
}
darkLogo {
downloadUrl
}
}
}
`;
const compliancePageFragment = graphql`
fragment CompliancePageFrameworkListItem_compliancePage on TrustCenter {
id
canUpdate: permission(action: "compliance-portal:portal:update")
}
`;
const createMutation = graphql`
mutation CompliancePageFrameworkListItem_createMutation($input: CreateComplianceFrameworkInput!) {
createComplianceFramework(input: $input) {
complianceFrameworkEdge {
node {
id
}
}
}
}
`;
const deleteMutation = graphql`
mutation CompliancePageFrameworkListItem_deleteMutation($input: DeleteComplianceFrameworkInput!) {
deleteComplianceFramework(input: $input) {
deletedComplianceFrameworkId
}
}
`;
export interface CompliancePageFrameworkListItemProps {
complianceFrameworkKey: CompliancePageFrameworkListItem_complianceFramework$key;
compliancePageKey: CompliancePageFrameworkListItem_compliancePage$key;
onRefetch: () => void;
}
export function CompliancePageFrameworkListItem(props: CompliancePageFrameworkListItemProps) {
const { complianceFrameworkKey, compliancePageKey, onRefetch } = props;
const { __ } = useTranslate();
const [optimisticPublic, setOptimisticPublic] = useState<boolean | null>(null);
const complianceFramework = useFragment(
compliancePageFrameworkListItemFragment,
complianceFrameworkKey,
);
const compliancePage = useFragment(compliancePageFragment, compliancePageKey);
const canUpdate = compliancePage.canUpdate;
const trustCenterId = compliancePage.id;
const { id, visibility, framework } = complianceFramework;
const serverPublic = visibility === "PUBLIC";
if (optimisticPublic !== null && optimisticPublic === serverPublic) {
setOptimisticPublic(null);
}
const isPublic = optimisticPublic ?? serverPublic;
const [createComplianceFramework, isCreating] = useMutation<CompliancePageFrameworkListItem_createMutation>(
createMutation,
{
successMessage: __("Framework visibility updated successfully."),
errorToast: __("Failed to update framework visibility"),
},
);
const [deleteComplianceFramework, isDeleting] = useMutation<CompliancePageFrameworkListItem_deleteMutation>(
deleteMutation,
{
successMessage: __("Framework visibility updated successfully."),
errorToast: __("Failed to update framework visibility"),
},
);
const isLoading = isCreating || isDeleting;
const handleToggle = useCallback(async () => {
if (!canUpdate || isLoading) return;
const nextPublic = !isPublic;
setOptimisticPublic(nextPublic);
try {
if (isPublic) {
await deleteComplianceFramework({
variables: { input: { id } },
onCompleted: (_, errors) => {
if (errors?.length) {
setOptimisticPublic(null);
return;
}
onRefetch();
},
});
} else {
await createComplianceFramework({
variables: {
input: {
trustCenterId,
frameworkId: framework.id,
},
},
onCompleted: (_, errors) => {
if (errors?.length) {
setOptimisticPublic(null);
return;
}
onRefetch();
},
});
}
} catch {
setOptimisticPublic(null);
}
}, [
canUpdate,
isLoading,
isPublic,
deleteComplianceFramework,
id,
onRefetch,
createComplianceFramework,
trustCenterId,
framework.id,
]);
const className = [
"relative flex flex-col items-center gap-3 rounded-lg border p-4 text-center",
"transition-[background-color,border-color,box-shadow,opacity] duration-200 ease-in-out",
isPublic
? "border-primary-500 bg-primary-50 ring-2 ring-primary-500"
: "border-border-solid bg-surface-secondary hover:border-border-medium hover:bg-surface-primary",
canUpdate && !isLoading && "cursor-pointer",
!canUpdate && "cursor-default",
isLoading && "pointer-events-none",
]
.filter(Boolean)
.join(" ");
return (
<button
type="button"
disabled={!canUpdate}
aria-pressed={isPublic}
aria-busy={isLoading}
aria-label={framework.name}
onClick={() => void handleToggle()}
className={className}
>
<span
className={[
"absolute top-2 right-2 flex size-5 items-center justify-center rounded-full bg-primary-500 text-white",
"transition-[opacity,transform] duration-200 ease-in-out",
isPublic ? "scale-100 opacity-100" : "scale-75 opacity-0",
].join(" ")}
aria-hidden={!isPublic}
>
<IconCheckmark1 size={12} />
</span>
<div
className={[
"flex flex-col items-center gap-3 transition-opacity duration-200 ease-in-out",
isLoading ? "opacity-50" : "opacity-100",
].join(" ")}
>
<FrameworkLogo
className="size-12"
lightLogoURL={framework.lightLogo?.downloadUrl}
darkLogoURL={framework.darkLogo?.downloadUrl}
name={framework.name}
/>
<span className="text-sm font-medium">{framework.name}</span>
</div>
{isLoading && (
<span className="absolute inset-0 flex items-center justify-center rounded-lg">
<Spinner size={24} />
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageFrameworksSectionFragment$key } from "#/__generated__/core/CompliancePageFrameworksSectionFragment.graphql";
import { CompliancePageFrameworkList } from "./CompliancePageFrameworkList";
const fragment = graphql`
fragment CompliancePageFrameworksSectionFragment on Organization {
compliancePage: trustCenter {
...CompliancePageFrameworkList_compliancePageFragment
}
}
`;
export function CompliancePageFrameworksSection(props: {
fragmentRef: CompliancePageFrameworksSectionFragment$key;
}) {
const { fragmentRef } = props;
const { __ } = useTranslate();
const organization = useFragment(fragment, fragmentRef);
if (!organization.compliancePage) {
return null;
}
return (
<section className="space-y-4">
<div>
<h2 className="text-base font-medium">{__("Compliance frameworks")}</h2>
<p className="text-sm text-txt-tertiary">
{__("Select which frameworks to show on your public compliance page.")}
</p>
</div>
<CompliancePageFrameworkList compliancePageRef={organization.compliancePage} />
</section>
);
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { safeOpenUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, Card } from "@probo/ui";
import { type ChangeEventHandler, useRef } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageNDACard_compliancePage$key } from "#/__generated__/core/CompliancePageNDACard_compliancePage.graphql";
const fragment = graphql`
fragment CompliancePageNDACard_compliancePage on TrustCenter {
nda {
fileName
downloadUrl
}
canUploadNDA: permission(action: "compliance-portal:portal:upload-nda")
canDeleteNDA: permission(action: "compliance-portal:portal:delete-nda")
}
`;
export interface CompliancePageNDACardProps {
compliancePageKey: CompliancePageNDACard_compliancePage$key;
isBusy: boolean;
isUploading: boolean;
onFileChange: ChangeEventHandler<HTMLInputElement>;
onDelete: () => void;
}
export function CompliancePageNDACard(props: CompliancePageNDACardProps) {
const { compliancePageKey, isBusy, isUploading, onFileChange, onDelete } = props;
const { __ } = useTranslate();
const fileInputRef = useRef<HTMLInputElement>(null);
const compliancePage = useFragment(fragment, compliancePageKey);
const fileName = compliancePage.nda?.fileName;
if (!fileName) {
return null;
}
return (
<Card padded>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<span className="font-medium">{fileName}</span>
<p className="text-sm text-txt-secondary">
{__(
"Visitors must accept this agreement before accessing your compliance page.",
)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
if (compliancePage.nda?.downloadUrl) {
safeOpenUrl(compliancePage.nda.downloadUrl);
}
}}
>
{__("Download")}
</Button>
{compliancePage.canUploadNDA && (
<>
<Button
type="button"
variant="secondary"
disabled={isBusy}
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? __("Uploading...") : __("Replace")}
</Button>
<input
ref={fileInputRef}
type="file"
hidden
accept="application/pdf,.pdf"
onChange={onFileChange}
/>
</>
)}
{compliancePage.canDeleteNDA && (
<Button
type="button"
variant="danger"
disabled={isBusy}
onClick={onDelete}
>
{__("Delete")}
</Button>
)}
</div>
</div>
</Card>
);
}

View File

@@ -19,12 +19,15 @@
// SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Button, Dropzone, IconTrashCan, Spinner, useToast } from "@probo/ui";
import { Button, IconChevronRight, useConfirm, useToast } from "@probo/ui";
import { type ChangeEventHandler, useRef } from "react";
import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageNDASectionFragment$key } from "#/__generated__/core/CompliancePageNDASectionFragment.graphql";
import { useDeleteTrustCenterNDAMutation, useUploadTrustCenterNDAMutation } from "#/hooks/graph/TrustCenterGraph";
import { useDeleteCompliancePageNDAMutation, useUploadCompliancePageNDAMutation } from "#/hooks/graph/CompliancePageGraph";
import { CompliancePageNDACard } from "./CompliancePageNDACard";
const fragment = graphql`
fragment CompliancePageNDASectionFragment on Organization {
@@ -32,26 +35,31 @@ const fragment = graphql`
id
nda {
fileName
downloadUrl
}
canUploadNDA: permission(action: "core:trust-center:upload-nda")
canDeleteNDA: permission(action: "core:trust-center:delete-nda")
canUploadNDA: permission(action: "compliance-portal:portal:upload-nda")
...CompliancePageNDACard_compliancePage
}
}
`;
export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDASectionFragment$key }) {
export interface CompliancePageNDASectionProps {
fragmentRef: CompliancePageNDASectionFragment$key;
}
export function CompliancePageNDASection(props: CompliancePageNDASectionProps) {
const { fragmentRef } = props;
const { __ } = useTranslate();
const { toast } = useToast();
const confirm = useConfirm();
const fileInputRef = useRef<HTMLInputElement>(null);
const organization = useFragment<CompliancePageNDASectionFragment$key>(fragment, fragmentRef);
const [uploadNDA, isUploadingNDA] = useUploadTrustCenterNDAMutation();
const [deleteNDA, isDeletingNDA] = useDeleteTrustCenterNDAMutation();
const [uploadNDA, isUploadingNDA] = useUploadCompliancePageNDAMutation();
const [deleteNDA, isDeletingNDA] = useDeleteCompliancePageNDAMutation();
const handleNDAUpload = async (files: File[]) => {
const handleNDAUpload = async (file: File) => {
if (!organization.compliancePage?.id) {
toast({
title: __("Error"),
@@ -61,10 +69,6 @@ export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDA
return;
}
if (files.length === 0) return;
const file = files[0];
await uploadNDA({
variables: {
input: {
@@ -79,8 +83,36 @@ export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDA
});
};
const handleNDADelete = async () => {
if (!organization.compliancePage?.id) {
const handleNDAFileChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (file.type !== "application/pdf") {
toast({
title: __("Unsupported file type"),
description: __("Please upload a PDF file."),
variant: "error",
});
return;
}
if (file.size > 10 * 1024 * 1024) {
toast({
title: __("File size too large"),
description: __("Please upload a file smaller than 10MB."),
variant: "error",
});
return;
}
void handleNDAUpload(file);
};
const handleNDADelete = () => {
const trustCenterId = organization.compliancePage?.id;
if (!trustCenterId) {
toast({
title: __("Error"),
description: __("Compliance page not found"),
@@ -89,107 +121,76 @@ export function CompliancePageNDASection(props: { fragmentRef: CompliancePageNDA
return;
}
if (!confirm(__("Are you sure you want to delete the NDA file?"))) {
return;
}
await deleteNDA({
variables: {
input: {
trustCenterId: organization.compliancePage.id,
},
confirm(
() => deleteNDA({ variables: { input: { trustCenterId } } }),
{
title: __("Delete NDA"),
message: __("Are you sure you want to delete the NDA file? This action cannot be undone."),
label: __("Delete"),
variant: "danger",
},
});
);
};
const compliancePage = organization.compliancePage;
const hasNDA = !!compliancePage?.nda?.fileName;
const canUploadNDA = compliancePage?.canUploadNDA;
const isBusy = isUploadingNDA || isDeletingNDA;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<section className="space-y-4">
<div>
<h2 className="text-base font-medium">
{__("Non-Disclosure Agreement")}
</h2>
{(isUploadingNDA || isDeletingNDA) && <Spinner />}
<p className="text-sm text-txt-tertiary">
{__(
"Require visitors to accept a Non-Disclosure Agreement before accessing your compliance page.",
)}
</p>
</div>
<div className="space-y-2">
{!organization.compliancePage?.nda?.fileName
&& organization.compliancePage?.canUploadNDA
<div className="space-y-3">
{hasNDA && compliancePage
? (
<p className="text-sm text-txt-tertiary">
{__(
"Upload a Non-Disclosure Agreement that visitors must accept before accessing your compliance page",
)}
</p>
<CompliancePageNDACard
compliancePageKey={compliancePage}
isBusy={isBusy}
isUploading={isUploadingNDA}
onFileChange={handleNDAFileChange}
onDelete={handleNDADelete}
/>
)
: (
<></>
)}
{organization.compliancePage?.nda?.fileName
? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{organization.compliancePage.nda?.fileName
|| __("Non-Disclosure Agreement")}
</p>
</div>
<p className="text-xs text-txt-tertiary">
{__(
"Visitors will need to accept this NDA before accessing your compliance page",
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
if (organization.compliancePage?.nda?.downloadUrl) {
window.open(
organization.compliancePage.nda.downloadUrl,
"_blank",
"noopener,noreferrer",
);
}
}}
>
{__("Download PDF")}
</Button>
{organization.compliancePage?.canDeleteNDA && (
<Button
variant="quaternary"
icon={IconTrashCan}
onClick={() => void handleNDADelete()}
disabled={isDeletingNDA}
/>
: canUploadNDA
? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border-solid px-4 py-8">
<p className="max-w-md text-center text-sm text-txt-tertiary">
{__(
"Upload a PDF that visitors must accept before they can access your compliance page.",
)}
</div>
</p>
<Button
iconAfter={IconChevronRight}
disabled={isBusy}
onClick={() => fileInputRef.current?.click()}
>
{isUploadingNDA ? __("Uploading...") : __("Upload NDA")}
</Button>
<input
ref={fileInputRef}
type="file"
hidden
accept="application/pdf,.pdf"
onChange={handleNDAFileChange}
/>
</div>
</div>
)
: (
<>
{organization.compliancePage?.canUploadNDA
? (
<Dropzone
description={__("Upload PDF files up to 10MB")}
isUploading={isUploadingNDA}
onDrop={files => void handleNDAUpload(files)}
accept={{
"application/pdf": [".pdf"],
}}
maxSize={10}
/>
)
: (
<p className="text-sm text-txt-tertiary">
{__("No NDA file uploaded")}
</p>
)}
</>
)}
)
: (
<p className="text-sm text-txt-tertiary">
{__("No NDA file uploaded")}
</p>
)}
</div>
</div>
</section>
);
}

View File

@@ -24,7 +24,7 @@ import { useFragment } from "react-relay";
import { graphql } from "relay-runtime";
import type { CompliancePageStatusSectionFragment$key } from "#/__generated__/core/CompliancePageStatusSectionFragment.graphql";
import { useUpdateTrustCenterMutation } from "#/hooks/graph/TrustCenterGraph";
import { useUpdateCompliancePageMutation } from "#/hooks/graph/CompliancePageGraph";
const fragment = graphql`
fragment CompliancePageStatusSectionFragment on Organization {
@@ -32,7 +32,7 @@ const fragment = graphql`
id
active
searchEngineIndexing
canUpdate: permission(action: "core:trust-center:update")
canUpdate: permission(action: "compliance-portal:portal:update")
}
}
`;
@@ -50,7 +50,7 @@ export function CompliancePageStatusSection(props: {
fragmentRef,
);
const [updateCompliancePage, isUpdating] = useUpdateTrustCenterMutation();
const [updateCompliancePage, isUpdating] = useUpdateCompliancePageMutation();
const handleToggleActive = async (active: boolean) => {
if (!organization.compliancePage?.id) {

View File

@@ -19,6 +19,7 @@
// SOFTWARE.
import { lazy } from "@probo/react-lazy";
import { redirect } from "react-router";
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
@@ -36,11 +37,9 @@ export const compliancePageRoutes = [
},
{
path: "domain",
Fallback: LinkCardSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/compliance-page/domain/CompliancePageDomainPageLoader"),
),
loader: () => {
throw redirect("brand");
},
},
{
path: "brand",