Add consent records tab to cookie banner config
Exposes the cookie consent record audit trail through a new "Consent Records" tab on the cookie banner configuration page. The full stack includes: extended coredata filter (visitor ID, banner version), GraphQL schema/types/resolvers, and a React page with SortableTable (size 50) and three compliance filters (action, visitor ID, banner version). Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { ClipboardTextIcon } from "@phosphor-icons/react";
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
@@ -251,6 +252,10 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
|
||||
<IconListStack size={20} />
|
||||
{__("Cookies")}
|
||||
</TabLink>
|
||||
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/consent-records`}>
|
||||
<ClipboardTextIcon size={20} />
|
||||
{__("Consent Records")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 {
|
||||
Card,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Tbody,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { type ComponentProps, useState, useTransition } from "react";
|
||||
import {
|
||||
graphql,
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
|
||||
import type { CookieBannerConsentRecordsPageFragment$key } from "#/__generated__/core/CookieBannerConsentRecordsPageFragment.graphql";
|
||||
import type { CookieBannerConsentRecordsPageQuery } from "#/__generated__/core/CookieBannerConsentRecordsPageQuery.graphql";
|
||||
import type {
|
||||
CookieBannerConsentRecordsPageRefetchQuery,
|
||||
CookieConsentAction,
|
||||
CookieConsentRecordOrderField,
|
||||
} from "#/__generated__/core/CookieBannerConsentRecordsPageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
|
||||
import { ConsentRecordRow } from "./_components/ConsentRecordRow";
|
||||
|
||||
export const cookieBannerConsentRecordsPageQuery = graphql`
|
||||
query CookieBannerConsentRecordsPageQuery($cookieBannerId: ID!) {
|
||||
node(id: $cookieBannerId) @required(action: THROW) {
|
||||
__typename
|
||||
... on CookieBanner {
|
||||
...CookieBannerConsentRecordsPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const consentRecordsFragment = graphql`
|
||||
fragment CookieBannerConsentRecordsPageFragment on CookieBanner
|
||||
@refetchable(queryName: "CookieBannerConsentRecordsPageRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "CookieConsentRecordOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
action: { type: "CookieConsentAction", defaultValue: null }
|
||||
visitorId: { type: "String", defaultValue: null }
|
||||
cookieBannerVersionId: { type: "ID", defaultValue: null }
|
||||
) {
|
||||
consentRecords(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: {
|
||||
action: $action
|
||||
visitorId: $visitorId
|
||||
cookieBannerVersionId: $cookieBannerVersionId
|
||||
}
|
||||
)
|
||||
@connection(
|
||||
key: "CookieBannerConsentRecordsPage_consentRecords"
|
||||
filters: ["filter"]
|
||||
) @required(action: THROW) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ConsentRecordRowFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface CookieBannerConsentRecordsPageProps {
|
||||
queryRef: PreloadedQuery<CookieBannerConsentRecordsPageQuery>;
|
||||
}
|
||||
|
||||
export default function CookieBannerConsentRecordsPage({
|
||||
queryRef,
|
||||
}: CookieBannerConsentRecordsPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const data = usePreloadedQuery(cookieBannerConsentRecordsPageQuery, queryRef);
|
||||
|
||||
if (data.node.__typename !== "CookieBanner") {
|
||||
throw new Error("invalid type for node");
|
||||
}
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [actionFilter, setActionFilter] = useState<CookieConsentAction | null>(null);
|
||||
const [visitorIdFilter, setVisitorIdFilter] = useState<string>("");
|
||||
const [versionIdFilter, setVersionIdFilter] = useState<string>("");
|
||||
|
||||
const { data: fragmentData, ...pagination } = usePaginationFragment<
|
||||
CookieBannerConsentRecordsPageRefetchQuery,
|
||||
CookieBannerConsentRecordsPageFragment$key
|
||||
>(consentRecordsFragment, data.node);
|
||||
|
||||
const records = fragmentData.consentRecords.edges.map(edge => edge.node) ?? [];
|
||||
|
||||
const refetchFilters = (overrides: Record<string, unknown> = {}) => {
|
||||
startTransition(() => {
|
||||
pagination.refetch(
|
||||
{
|
||||
action: actionFilter,
|
||||
visitorId: visitorIdFilter || null,
|
||||
cookieBannerVersionId: versionIdFilter || null,
|
||||
...overrides,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleActionFilterChange = (value: string) => {
|
||||
const newAction = value === "ALL" ? null : (value as CookieConsentAction);
|
||||
setActionFilter(newAction);
|
||||
refetchFilters({ action: newAction });
|
||||
};
|
||||
|
||||
const handleVisitorIdSubmit = () => {
|
||||
refetchFilters({ visitorId: visitorIdFilter || null });
|
||||
};
|
||||
|
||||
const handleVersionIdSubmit = () => {
|
||||
refetchFilters({ cookieBannerVersionId: versionIdFilter || null });
|
||||
};
|
||||
|
||||
const refetchWithFilters: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
|
||||
pagination.refetch({
|
||||
order: { direction: order.direction, field: order.field as CookieConsentRecordOrderField },
|
||||
action: actionFilter,
|
||||
visitorId: visitorIdFilter || null,
|
||||
cookieBannerVersionId: versionIdFilter || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={actionFilter ?? "ALL"}
|
||||
onValueChange={handleActionFilterChange}
|
||||
>
|
||||
<Option value="ALL">{__("All actions")}</Option>
|
||||
<Option value="ACCEPT_ALL">{__("Accept All")}</Option>
|
||||
<Option value="REJECT_ALL">{__("Reject All")}</Option>
|
||||
<Option value="CUSTOMIZE">{__("Customize")}</Option>
|
||||
<Option value="GPC">{__("GPC")}</Option>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder={__("Visitor ID")}
|
||||
value={visitorIdFilter}
|
||||
onChange={e => setVisitorIdFilter(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleVisitorIdSubmit()}
|
||||
onBlur={handleVisitorIdSubmit}
|
||||
className="w-48"
|
||||
/>
|
||||
<Input
|
||||
placeholder={__("Version ID")}
|
||||
value={versionIdFilter}
|
||||
onChange={e => setVersionIdFilter(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleVersionIdSubmit()}
|
||||
onBlur={handleVersionIdSubmit}
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
|
||||
{records.length > 0
|
||||
? (
|
||||
<SortableTable
|
||||
{...pagination}
|
||||
refetch={refetchWithFilters}
|
||||
pageSize={50}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Visitor ID")}</Th>
|
||||
<Th>{__("Action")}</Th>
|
||||
<Th>{__("Version")}</Th>
|
||||
<Th>{__("IP Address")}</Th>
|
||||
<Th>{__("SDK Version")}</Th>
|
||||
<Th>{__("Consent Data")}</Th>
|
||||
<SortableTh field="CREATED_AT">{__("Date")}</SortableTh>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{records.map(record => (
|
||||
<ConsentRecordRow key={record.id} recordKey={record} />
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__("No consent records")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary">
|
||||
{__("Consent records will appear here once visitors interact with your cookie banner.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { CookieBannerConsentRecordsPageQuery } from "#/__generated__/core/CookieBannerConsentRecordsPageQuery.graphql";
|
||||
|
||||
import CookieBannerConsentRecordsPage, { cookieBannerConsentRecordsPageQuery } from "./CookieBannerConsentRecordsPage";
|
||||
import { CookieBannerConsentRecordsPageSkeleton } from "./CookieBannerConsentRecordsPageSkeleton";
|
||||
|
||||
export default function CookieBannerConsentRecordsPageLoader() {
|
||||
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
|
||||
if (typeof cookieBannerId !== "string") {
|
||||
throw new Error("Missing cookieBannerId parameter");
|
||||
}
|
||||
|
||||
const [queryRef, loadQuery] = useQueryLoader<CookieBannerConsentRecordsPageQuery>(
|
||||
cookieBannerConsentRecordsPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ cookieBannerId });
|
||||
}, [loadQuery, cookieBannerId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <CookieBannerConsentRecordsPageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<CookieBannerConsentRecordsPageSkeleton />}>
|
||||
<CookieBannerConsentRecordsPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.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.
|
||||
|
||||
export function CookieBannerConsentRecordsPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-9 w-36 rounded bg-bg-subtle" />
|
||||
<div className="h-9 w-48 rounded bg-bg-subtle" />
|
||||
<div className="h-9 w-40 rounded bg-bg-subtle" />
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-low">
|
||||
<div className="h-10 border-b border-border-low bg-bg-subtle" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 border-b border-border-low last:border-b-0" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 { formatDate } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Td, Tr } from "@probo/ui";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import type { ConsentRecordRowFragment$key } from "#/__generated__/core/ConsentRecordRowFragment.graphql";
|
||||
|
||||
const consentRecordFragment = graphql`
|
||||
fragment ConsentRecordRowFragment on CookieConsentRecord {
|
||||
visitorId
|
||||
action
|
||||
cookieBannerVersion {
|
||||
id
|
||||
version
|
||||
}
|
||||
ipAddress
|
||||
sdkVersion
|
||||
consentData
|
||||
createdAt
|
||||
}
|
||||
`;
|
||||
|
||||
function getActionLabel(action: string, __: (s: string) => string): string {
|
||||
switch (action) {
|
||||
case "ACCEPT_ALL":
|
||||
return __("Accept All");
|
||||
case "REJECT_ALL":
|
||||
return __("Reject All");
|
||||
case "CUSTOMIZE":
|
||||
return __("Customize");
|
||||
case "GPC":
|
||||
return __("GPC");
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
function getActionVariant(action: string): "success" | "danger" | "warning" | "neutral" {
|
||||
switch (action) {
|
||||
case "ACCEPT_ALL":
|
||||
return "success";
|
||||
case "REJECT_ALL":
|
||||
return "danger";
|
||||
case "CUSTOMIZE":
|
||||
return "warning";
|
||||
case "GPC":
|
||||
return "neutral";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsentRecordRowProps {
|
||||
recordKey: ConsentRecordRowFragment$key;
|
||||
}
|
||||
|
||||
export function ConsentRecordRow({ recordKey }: ConsentRecordRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const record = useFragment(consentRecordFragment, recordKey);
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>
|
||||
<span className="font-mono text-sm">{record.visitorId}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={getActionVariant(record.action)}>
|
||||
{getActionLabel(record.action, __)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{record.cookieBannerVersion
|
||||
? (
|
||||
<span className="font-mono text-sm">
|
||||
v
|
||||
{record.cookieBannerVersion.version}
|
||||
</span>
|
||||
)
|
||||
: <span className="text-txt-tertiary">-</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-sm">{record.ipAddress ?? "-"}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-sm">{record.sdkVersion}</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-xs max-w-48 truncate block" title={record.consentData}>
|
||||
{record.consentData}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<time dateTime={record.createdAt}>
|
||||
{formatDate(record.createdAt)}
|
||||
</time>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,11 @@ export const cookieBannerRoutes = [
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/display/CookieBannerDisplayPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "consent-records",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageLoader")),
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
Reference in New Issue
Block a user