Add consent record detail page
Display record attributes and parsed consent data with per-category consent state and cookies from the banner version snapshot. The page lives outside the config layout with its own breadcrumb navigation. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// 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, Breadcrumb, Card, PageHeader, PropertyRow } from "@probo/ui";
|
||||
import { useMemo } from "react";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { CookieBannerConsentRecordPageQuery } from "#/__generated__/core/CookieBannerConsentRecordPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import {
|
||||
formatAnonymizedIp,
|
||||
getActionLabel,
|
||||
getActionVariant,
|
||||
} from "./_components/consentRecordHelpers";
|
||||
|
||||
export const cookieBannerConsentRecordPageQuery = graphql`
|
||||
query CookieBannerConsentRecordPageQuery($consentRecordId: ID!) {
|
||||
node(id: $consentRecordId) @required(action: THROW) {
|
||||
__typename
|
||||
... on CookieConsentRecord {
|
||||
id
|
||||
visitorId
|
||||
action
|
||||
cookieBanner @required(action: THROW) {
|
||||
id
|
||||
name
|
||||
}
|
||||
cookieBannerVersion @required(action: THROW) {
|
||||
id
|
||||
version
|
||||
categories {
|
||||
name
|
||||
slug
|
||||
description
|
||||
kind
|
||||
cookies {
|
||||
name
|
||||
duration
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
ipAddress
|
||||
userAgent
|
||||
sdkVersion
|
||||
consentData
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface CookieBannerConsentRecordPageProps {
|
||||
queryRef: PreloadedQuery<CookieBannerConsentRecordPageQuery>;
|
||||
}
|
||||
|
||||
export default function CookieBannerConsentRecordPage({
|
||||
queryRef,
|
||||
}: CookieBannerConsentRecordPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const data = usePreloadedQuery(cookieBannerConsentRecordPageQuery, queryRef);
|
||||
|
||||
if (data.node.__typename !== "CookieConsentRecord") {
|
||||
throw new Error("invalid type for node");
|
||||
}
|
||||
|
||||
const record = data.node;
|
||||
const bannerId = record.cookieBanner.id;
|
||||
const bannerName = record.cookieBanner.name;
|
||||
|
||||
const consentMap = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(record.consentData) as Record<string, boolean>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [record.consentData]);
|
||||
|
||||
const categories = record.cookieBannerVersion.categories;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
label: __("Cookie Banners"),
|
||||
to: `/organizations/${organizationId}/cookie-banners`,
|
||||
},
|
||||
{
|
||||
label: bannerName,
|
||||
to: `/organizations/${organizationId}/cookie-banners/${bannerId}`,
|
||||
},
|
||||
{
|
||||
label: __("Consent Records"),
|
||||
to: `/organizations/${organizationId}/cookie-banners/${bannerId}/consent-records`,
|
||||
},
|
||||
{
|
||||
label: record.id,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<PageHeader title={__("Consent Record")} />
|
||||
|
||||
<Card padded>
|
||||
<PropertyRow label={__("Visitor ID")}>
|
||||
<span className="font-mono text-sm">{record.visitorId}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Action")}>
|
||||
<Badge variant={getActionVariant(record.action)}>
|
||||
{getActionLabel(record.action, __)}
|
||||
</Badge>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Banner Version")}>
|
||||
{record.cookieBannerVersion
|
||||
? (
|
||||
<span className="font-mono text-sm">
|
||||
{record.cookieBannerVersion.version}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="text-txt-tertiary">-</span>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("IP Address")}>
|
||||
<span className="font-mono text-sm">
|
||||
{record.ipAddress
|
||||
? formatAnonymizedIp(record.ipAddress)
|
||||
: "-"}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("User Agent")}>
|
||||
<span className="font-mono text-sm truncate max-w-md" title={record.userAgent ?? undefined}>
|
||||
{record.userAgent ?? "-"}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("SDK Version")}>
|
||||
<span className="font-mono text-sm">{record.sdkVersion}</span>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Date")}>
|
||||
<time dateTime={record.createdAt}>
|
||||
{formatDate(record.createdAt)}
|
||||
</time>
|
||||
</PropertyRow>
|
||||
</Card>
|
||||
|
||||
<Card padded>
|
||||
<h3 className="text-lg font-semibold mb-4">{__("Consent Data")}</h3>
|
||||
{categories.length > 0
|
||||
? (
|
||||
<div className="space-y-4">
|
||||
{categories.map((category) => {
|
||||
const consented = consentMap[category.slug];
|
||||
return (
|
||||
<div
|
||||
key={category.slug}
|
||||
className="border-b border-border-low pb-4 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<span className="font-medium">{category.name}</span>
|
||||
{category.kind === "NECESSARY" && (
|
||||
<span className="ml-2 text-xs text-txt-tertiary">
|
||||
(
|
||||
{__("Required")}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={consented ? "success" : "danger"}
|
||||
size="sm"
|
||||
>
|
||||
{consented ? __("Accepted") : __("Rejected")}
|
||||
</Badge>
|
||||
</div>
|
||||
{category.description && (
|
||||
<p className="text-sm text-txt-secondary mb-2">
|
||||
{category.description}
|
||||
</p>
|
||||
)}
|
||||
{category.cookies.length > 0 && (
|
||||
<div className="ml-4 mt-2">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-txt-tertiary">
|
||||
<th className="font-medium pb-1 pr-4">
|
||||
{__("Cookie")}
|
||||
</th>
|
||||
<th className="font-medium pb-1 pr-4">
|
||||
{__("Duration")}
|
||||
</th>
|
||||
<th className="font-medium pb-1">
|
||||
{__("Description")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{category.cookies.map(cookie => (
|
||||
<tr key={cookie.name}>
|
||||
<td className="py-1 pr-4 font-mono text-xs">
|
||||
{cookie.name}
|
||||
</td>
|
||||
<td className="py-1 pr-4 text-txt-secondary">
|
||||
{cookie.duration}
|
||||
</td>
|
||||
<td className="py-1 text-txt-secondary">
|
||||
{cookie.description}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-txt-tertiary font-mono">
|
||||
{record.consentData}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</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 { CookieBannerConsentRecordPageQuery } from "#/__generated__/core/CookieBannerConsentRecordPageQuery.graphql";
|
||||
|
||||
import CookieBannerConsentRecordPage, { cookieBannerConsentRecordPageQuery } from "./CookieBannerConsentRecordPage";
|
||||
import { CookieBannerConsentRecordPageSkeleton } from "./CookieBannerConsentRecordPageSkeleton";
|
||||
|
||||
export default function CookieBannerConsentRecordPageLoader() {
|
||||
const { consentRecordId } = useParams<{ consentRecordId: string }>();
|
||||
if (typeof consentRecordId !== "string") {
|
||||
throw new Error("Missing consentRecordId parameter");
|
||||
}
|
||||
|
||||
const [queryRef, loadQuery] = useQueryLoader<CookieBannerConsentRecordPageQuery>(
|
||||
cookieBannerConsentRecordPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ consentRecordId });
|
||||
}, [loadQuery, consentRecordId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <CookieBannerConsentRecordPageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<CookieBannerConsentRecordPageSkeleton />}>
|
||||
<CookieBannerConsentRecordPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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 CookieBannerConsentRecordPageSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="rounded-2xl border border-border-low p-6 space-y-4">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-3 border-b border-border-low last:border-b-0">
|
||||
<div className="h-4 w-28 rounded bg-bg-subtle" />
|
||||
<div className="h-4 w-48 rounded bg-bg-subtle" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border-low p-6 space-y-4">
|
||||
<div className="h-5 w-32 rounded bg-bg-subtle" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2 py-3 border-b border-border-low last:border-b-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-32 rounded bg-bg-subtle" />
|
||||
<div className="h-5 w-16 rounded bg-bg-subtle" />
|
||||
</div>
|
||||
<div className="ml-4 space-y-1">
|
||||
<div className="h-3 w-40 rounded bg-bg-subtle" />
|
||||
<div className="h-3 w-36 rounded bg-bg-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -211,7 +211,6 @@ export default function CookieBannerConsentRecordsPage({
|
||||
<Th>{__("Banner Version")}</Th>
|
||||
<Th>{__("IP Address")}</Th>
|
||||
<Th>{__("SDK Version")}</Th>
|
||||
<Th>{__("Consent Data")}</Th>
|
||||
<SortableTh field="CREATED_AT">{__("Date")}</SortableTh>
|
||||
</Tr>
|
||||
</Thead>
|
||||
|
||||
@@ -19,8 +19,15 @@ import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import type { ConsentRecordRowFragment$key } from "#/__generated__/core/ConsentRecordRowFragment.graphql";
|
||||
|
||||
import {
|
||||
formatAnonymizedIp,
|
||||
getActionLabel,
|
||||
getActionVariant,
|
||||
} from "./consentRecordHelpers";
|
||||
|
||||
const consentRecordFragment = graphql`
|
||||
fragment ConsentRecordRowFragment on CookieConsentRecord {
|
||||
id
|
||||
visitorId
|
||||
action
|
||||
cookieBannerVersion {
|
||||
@@ -29,41 +36,10 @@ const consentRecordFragment = graphql`
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -73,7 +49,7 @@ export function ConsentRecordRow({ recordKey }: ConsentRecordRowProps) {
|
||||
const record = useFragment(consentRecordFragment, recordKey);
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Tr to={record.id}>
|
||||
<Td>
|
||||
<span className="font-mono text-sm">{record.visitorId}</span>
|
||||
</Td>
|
||||
@@ -92,16 +68,13 @@ export function ConsentRecordRow({ recordKey }: ConsentRecordRowProps) {
|
||||
: <span className="text-txt-tertiary">-</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono text-sm">{record.ipAddress ?? "-"}</span>
|
||||
<span className="font-mono text-sm">
|
||||
{record.ipAddress ? formatAnonymizedIp(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)}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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 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;
|
||||
}
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAnonymizedIp(ip: string): string {
|
||||
if (ip.includes(".")) {
|
||||
return ip.replace(/\.0$/, ".*");
|
||||
}
|
||||
if (ip.endsWith("::")) {
|
||||
return ip + "*";
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
@@ -71,4 +71,9 @@ export const cookieBannerRoutes = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "cookie-banners/:cookieBannerId/consent-records/:consentRecordId",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordPageLoader")),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
Reference in New Issue
Block a user