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>{__("Banner Version")}</Th>
|
||||||
<Th>{__("IP Address")}</Th>
|
<Th>{__("IP Address")}</Th>
|
||||||
<Th>{__("SDK Version")}</Th>
|
<Th>{__("SDK Version")}</Th>
|
||||||
<Th>{__("Consent Data")}</Th>
|
|
||||||
<SortableTh field="CREATED_AT">{__("Date")}</SortableTh>
|
<SortableTh field="CREATED_AT">{__("Date")}</SortableTh>
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
|
|||||||
@@ -19,8 +19,15 @@ import { graphql, useFragment } from "react-relay";
|
|||||||
|
|
||||||
import type { ConsentRecordRowFragment$key } from "#/__generated__/core/ConsentRecordRowFragment.graphql";
|
import type { ConsentRecordRowFragment$key } from "#/__generated__/core/ConsentRecordRowFragment.graphql";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatAnonymizedIp,
|
||||||
|
getActionLabel,
|
||||||
|
getActionVariant,
|
||||||
|
} from "./consentRecordHelpers";
|
||||||
|
|
||||||
const consentRecordFragment = graphql`
|
const consentRecordFragment = graphql`
|
||||||
fragment ConsentRecordRowFragment on CookieConsentRecord {
|
fragment ConsentRecordRowFragment on CookieConsentRecord {
|
||||||
|
id
|
||||||
visitorId
|
visitorId
|
||||||
action
|
action
|
||||||
cookieBannerVersion {
|
cookieBannerVersion {
|
||||||
@@ -29,41 +36,10 @@ const consentRecordFragment = graphql`
|
|||||||
}
|
}
|
||||||
ipAddress
|
ipAddress
|
||||||
sdkVersion
|
sdkVersion
|
||||||
consentData
|
|
||||||
createdAt
|
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 {
|
interface ConsentRecordRowProps {
|
||||||
recordKey: ConsentRecordRowFragment$key;
|
recordKey: ConsentRecordRowFragment$key;
|
||||||
}
|
}
|
||||||
@@ -73,7 +49,7 @@ export function ConsentRecordRow({ recordKey }: ConsentRecordRowProps) {
|
|||||||
const record = useFragment(consentRecordFragment, recordKey);
|
const record = useFragment(consentRecordFragment, recordKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr>
|
<Tr to={record.id}>
|
||||||
<Td>
|
<Td>
|
||||||
<span className="font-mono text-sm">{record.visitorId}</span>
|
<span className="font-mono text-sm">{record.visitorId}</span>
|
||||||
</Td>
|
</Td>
|
||||||
@@ -92,16 +68,13 @@ export function ConsentRecordRow({ recordKey }: ConsentRecordRowProps) {
|
|||||||
: <span className="text-txt-tertiary">-</span>}
|
: <span className="text-txt-tertiary">-</span>}
|
||||||
</Td>
|
</Td>
|
||||||
<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>
|
||||||
<Td>
|
<Td>
|
||||||
<span className="font-mono text-sm">{record.sdkVersion}</span>
|
<span className="font-mono text-sm">{record.sdkVersion}</span>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
|
||||||
<span className="font-mono text-xs max-w-48 truncate block" title={record.consentData}>
|
|
||||||
{record.consentData}
|
|
||||||
</span>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
<Td>
|
||||||
<time dateTime={record.createdAt}>
|
<time dateTime={record.createdAt}>
|
||||||
{formatDate(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[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
@@ -1738,6 +1738,34 @@ func (s *Service) ListCookieConsentRecordsForBanner(
|
|||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetCookieConsentRecord(
|
||||||
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
|
id gid.GID,
|
||||||
|
) (*coredata.CookieConsentRecord, error) {
|
||||||
|
var record coredata.CookieConsentRecord
|
||||||
|
|
||||||
|
err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
if err := record.LoadByID(ctx, conn, scope, id); err != nil {
|
||||||
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
return ErrConsentNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot load consent record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CountCookieConsentRecordsForBanner(
|
func (s *Service) CountCookieConsentRecordsForBanner(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
|
|||||||
@@ -56,7 +56,18 @@ func (r *CookieConsentRecord) CursorKey(field CookieConsentRecordOrderField) pag
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *CookieConsentRecord) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
func (r *CookieConsentRecord) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||||
return map[string]string{"organization_id": r.OrganizationID.String()}, nil
|
q := `SELECT organization_id FROM cookie_consent_records WHERE id = $1 LIMIT 1;`
|
||||||
|
|
||||||
|
var organizationID gid.GID
|
||||||
|
if err := conn.QueryRow(ctx, q, r.ID).Scan(&organizationID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrResourceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("cannot query consent record authorization attributes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *CookieConsentRecords) LoadByCookieBannerID(
|
func (r *CookieConsentRecords) LoadByCookieBannerID(
|
||||||
@@ -203,6 +214,56 @@ INSERT INTO cookie_consent_records (
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *CookieConsentRecord) LoadByID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Querier,
|
||||||
|
scope Scoper,
|
||||||
|
id gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
cookie_banner_id,
|
||||||
|
cookie_banner_version_id,
|
||||||
|
visitor_id,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
consent_data,
|
||||||
|
action,
|
||||||
|
sdk_version,
|
||||||
|
created_at
|
||||||
|
FROM
|
||||||
|
cookie_consent_records
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND id = @id
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"id": id}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query consent record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[CookieConsentRecord])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrResourceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot collect consent record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*r = record
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *CookieConsentRecord) LoadLatestByVisitorAndBannerID(
|
func (r *CookieConsentRecord) LoadLatestByVisitorAndBannerID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Querier,
|
conn pg.Querier,
|
||||||
|
|||||||
@@ -352,6 +352,16 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
|||||||
}
|
}
|
||||||
return types.NewCookieCategory(category), nil
|
return types.NewCookieCategory(category), nil
|
||||||
}
|
}
|
||||||
|
case coredata.CookieConsentRecordEntityType:
|
||||||
|
action = probo.ActionCookieConsentRecordList
|
||||||
|
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
||||||
|
scope := coredata.NewScopeFromObjectID(id)
|
||||||
|
record, err := r.cookieBanner.GetCookieConsentRecord(ctx, scope, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return types.NewCookieConsentRecord(record), nil
|
||||||
|
}
|
||||||
case coredata.CookieBannerVersionEntityType:
|
case coredata.CookieBannerVersionEntityType:
|
||||||
action = probo.ActionCookieBannerVersionGet
|
action = probo.ActionCookieBannerVersionGet
|
||||||
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
||||||
|
|||||||
@@ -188,6 +188,45 @@ func (r *cookieBannerConnectionResolver) TotalCount(ctx context.Context, obj *ty
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Categories is the resolver for the categories field.
|
||||||
|
func (r *cookieBannerVersionResolver) Categories(ctx context.Context, obj *types.CookieBannerVersion) ([]*types.CookieBannerVersionCategory, error) {
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ID)
|
||||||
|
|
||||||
|
version, err := r.cookieBanner.GetCookieBannerVersion(ctx, scope, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot get cookie banner version", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := version.GetSnapshot()
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot get version snapshot", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
categories := make([]*types.CookieBannerVersionCategory, len(snapshot.Categories))
|
||||||
|
for i, cat := range snapshot.Categories {
|
||||||
|
cookies := make([]*types.CookieBannerVersionCookie, len(cat.Cookies))
|
||||||
|
for j, c := range cat.Cookies {
|
||||||
|
cookies[j] = &types.CookieBannerVersionCookie{
|
||||||
|
Name: c.Name,
|
||||||
|
Duration: c.Duration,
|
||||||
|
Description: c.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
categories[i] = &types.CookieBannerVersionCategory{
|
||||||
|
Name: cat.Name,
|
||||||
|
Slug: cat.Slug,
|
||||||
|
Description: cat.Description,
|
||||||
|
Kind: cat.Kind,
|
||||||
|
Cookies: cookies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CookieBanner is the resolver for the cookieBanner field.
|
// CookieBanner is the resolver for the cookieBanner field.
|
||||||
func (r *cookieCategoryResolver) CookieBanner(ctx context.Context, obj *types.CookieCategory) (*types.CookieBanner, error) {
|
func (r *cookieCategoryResolver) CookieBanner(ctx context.Context, obj *types.CookieCategory) (*types.CookieBanner, error) {
|
||||||
return obj.CookieBanner, nil
|
return obj.CookieBanner, nil
|
||||||
@@ -848,6 +887,11 @@ func (r *Resolver) CookieBannerConnection() schema.CookieBannerConnectionResolve
|
|||||||
return &cookieBannerConnectionResolver{r}
|
return &cookieBannerConnectionResolver{r}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CookieBannerVersion returns schema.CookieBannerVersionResolver implementation.
|
||||||
|
func (r *Resolver) CookieBannerVersion() schema.CookieBannerVersionResolver {
|
||||||
|
return &cookieBannerVersionResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
// CookieCategory returns schema.CookieCategoryResolver implementation.
|
// CookieCategory returns schema.CookieCategoryResolver implementation.
|
||||||
func (r *Resolver) CookieCategory() schema.CookieCategoryResolver { return &cookieCategoryResolver{r} }
|
func (r *Resolver) CookieCategory() schema.CookieCategoryResolver { return &cookieCategoryResolver{r} }
|
||||||
|
|
||||||
@@ -864,6 +908,7 @@ func (r *Resolver) CookieConnection() schema.CookieConnectionResolver {
|
|||||||
type cookieResolver struct{ *Resolver }
|
type cookieResolver struct{ *Resolver }
|
||||||
type cookieBannerResolver struct{ *Resolver }
|
type cookieBannerResolver struct{ *Resolver }
|
||||||
type cookieBannerConnectionResolver struct{ *Resolver }
|
type cookieBannerConnectionResolver struct{ *Resolver }
|
||||||
|
type cookieBannerVersionResolver struct{ *Resolver }
|
||||||
type cookieCategoryResolver struct{ *Resolver }
|
type cookieCategoryResolver struct{ *Resolver }
|
||||||
type cookieCategoryConnectionResolver struct{ *Resolver }
|
type cookieCategoryConnectionResolver struct{ *Resolver }
|
||||||
type cookieConnectionResolver struct{ *Resolver }
|
type cookieConnectionResolver struct{ *Resolver }
|
||||||
|
|||||||
@@ -20,7 +20,26 @@ import (
|
|||||||
|
|
||||||
// CookieBanner is the resolver for the cookieBanner field.
|
// CookieBanner is the resolver for the cookieBanner field.
|
||||||
func (r *cookieConsentRecordResolver) CookieBanner(ctx context.Context, obj *types.CookieConsentRecord) (*types.CookieBanner, error) {
|
func (r *cookieConsentRecordResolver) CookieBanner(ctx context.Context, obj *types.CookieConsentRecord) (*types.CookieBanner, error) {
|
||||||
return obj.CookieBanner, nil
|
if obj.CookieBanner == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.authorize(ctx, obj.CookieBanner.ID, probo.ActionCookieBannerGet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.CookieBanner.ID)
|
||||||
|
|
||||||
|
banner, err := r.cookieBanner.GetCookieBanner(ctx, scope, obj.CookieBanner.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot get cookie banner", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewCookieBanner(banner), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CookieBannerVersion is the resolver for the cookieBannerVersion field.
|
// CookieBannerVersion is the resolver for the cookieBannerVersion field.
|
||||||
|
|||||||
@@ -198,10 +198,25 @@ type CookieBannerVersion implements Node {
|
|||||||
id: ID!
|
id: ID!
|
||||||
version: Int!
|
version: Int!
|
||||||
state: String!
|
state: String!
|
||||||
|
categories: [CookieBannerVersionCategory!]! @goField(forceResolver: true)
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CookieBannerVersionCategory {
|
||||||
|
name: String!
|
||||||
|
slug: String!
|
||||||
|
description: String!
|
||||||
|
kind: CookieCategoryKind!
|
||||||
|
cookies: [CookieBannerVersionCookie!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieBannerVersionCookie {
|
||||||
|
name: String!
|
||||||
|
duration: String!
|
||||||
|
description: String!
|
||||||
|
}
|
||||||
|
|
||||||
type CookieBannerConnection
|
type CookieBannerConnection
|
||||||
@goModel(
|
@goModel(
|
||||||
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieBannerConnection"
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieBannerConnection"
|
||||||
|
|||||||
Reference in New Issue
Block a user