Add UX for cookie banner management
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@probo/coredata": "^1.0.0",
|
"@probo/coredata": "^1.0.0",
|
||||||
"@probo/helpers": "^1.0.0",
|
"@probo/helpers": "^1.0.0",
|
||||||
"@probo/hooks": "1.0.0",
|
"@probo/hooks": "1.0.0",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { CookieIcon } from "@phosphor-icons/react";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
IconBank,
|
IconBank,
|
||||||
@@ -63,6 +64,7 @@ const fragment = graphql`
|
|||||||
canListRightsRequests: permission(action: "core:rights-request:list")
|
canListRightsRequests: permission(action: "core:rights-request:list")
|
||||||
canListSnapshots: permission(action: "core:snapshot:list")
|
canListSnapshots: permission(action: "core:snapshot:list")
|
||||||
canGetTrustCenter: permission(action: "core:trust-center:get")
|
canGetTrustCenter: permission(action: "core:trust-center:get")
|
||||||
|
canListCookieBanners: permission(action: "core:cookie-banner:list")
|
||||||
canUpdateOrganization: permission(action: "iam:organization:update")
|
canUpdateOrganization: permission(action: "iam:organization:update")
|
||||||
canListStatementsOfApplicability: permission(
|
canListStatementsOfApplicability: permission(
|
||||||
action: "core:statement-of-applicability:list"
|
action: "core:statement-of-applicability:list"
|
||||||
@@ -218,6 +220,13 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
|
|||||||
to={`${prefix}/compliance-page`}
|
to={`${prefix}/compliance-page`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{organization.canListCookieBanners && (
|
||||||
|
<SidebarItem
|
||||||
|
label={__("Cookie Banners")}
|
||||||
|
icon={CookieIcon}
|
||||||
|
to={`${prefix}/cookie-banners`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{organization.canUpdateOrganization && (
|
{organization.canUpdateOrganization && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
label={__("Settings")}
|
label={__("Settings")}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// 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 { usePageTitle } from "@probo/hooks";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { PageHeader } from "@probo/ui";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
export default function CookieBannerLayout() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
|
usePageTitle(__("Cookie Banners"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={__("Cookie Banners")}
|
||||||
|
description={__(
|
||||||
|
"Manage cookie consent banners for your websites. Configure categories, cookies, and install the SDK.",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { usePageTitle } from "@probo/hooks";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Field,
|
||||||
|
IconChevronLeft,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Option,
|
||||||
|
PageHeader,
|
||||||
|
Select,
|
||||||
|
useToast,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { NewCookieBannerPageMutation } from "#/__generated__/core/NewCookieBannerPageMutation.graphql";
|
||||||
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
const createCookieBannerMutation = graphql`
|
||||||
|
mutation NewCookieBannerPageMutation($input: CreateCookieBannerInput!) {
|
||||||
|
createCookieBanner(input: $input) {
|
||||||
|
cookieBannerEdge {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function NewCookieBannerPage() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const organizationId = useOrganizationId();
|
||||||
|
|
||||||
|
usePageTitle(__("New Cookie Banner"));
|
||||||
|
|
||||||
|
const [commitMutation, isInFlight]
|
||||||
|
= useMutation<NewCookieBannerPageMutation>(createCookieBannerMutation);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [origin, setOrigin] = useState("");
|
||||||
|
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState("");
|
||||||
|
const [consentExpiryDays, setConsentExpiryDays] = useState("365");
|
||||||
|
const [consentMode, setConsentMode] = useState("OPT_IN");
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
commitMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
privacyPolicyUrl,
|
||||||
|
consentExpiryDays: parseInt(consentExpiryDays, 10),
|
||||||
|
consentMode: consentMode as "OPT_IN" | "OPT_OUT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted(data) {
|
||||||
|
toast({
|
||||||
|
title: __("Success"),
|
||||||
|
description: __("Cookie banner created successfully"),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
const bannerId = data.createCookieBanner.cookieBannerEdge.node.id;
|
||||||
|
void navigate(`/organizations/${organizationId}/cookie-banners/${bannerId}`);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(__("Failed to create cookie banner"), error as GraphQLError),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
to={`/organizations/${organizationId}/cookie-banners`}
|
||||||
|
className="mb-4 inline-flex gap-2 items-center"
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={16} />
|
||||||
|
{__("Back")}
|
||||||
|
</Link>
|
||||||
|
<PageHeader
|
||||||
|
title={__("Create Cookie Banner")}
|
||||||
|
description={__(
|
||||||
|
"Set up a new cookie consent banner with its origin URL and consent configuration.",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Card padded asChild>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Field label={__("Name")}>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder={__("My Website")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={__("Origin URL")}>
|
||||||
|
<Input
|
||||||
|
value={origin}
|
||||||
|
onChange={e => setOrigin(e.target.value)}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={__("Privacy Policy URL")}>
|
||||||
|
<Input
|
||||||
|
value={privacyPolicyUrl}
|
||||||
|
onChange={e => setPrivacyPolicyUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/privacy"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__("Consent Expiry (days)")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={consentExpiryDays}
|
||||||
|
onChange={e => setConsentExpiryDays(e.target.value)}
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__("Consent Mode")}</Label>
|
||||||
|
<Select value={consentMode} onValueChange={setConsentMode}>
|
||||||
|
<Option value="OPT_IN">{__("Opt-in")}</Option>
|
||||||
|
<Option value="OPT_OUT">{__("Opt-out")}</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isInFlight}>
|
||||||
|
{isInFlight ? __("Creating...") : __("Create Banner")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Breadcrumb,
|
||||||
|
Button,
|
||||||
|
IconImage,
|
||||||
|
IconPageTextLine,
|
||||||
|
IconSettingsGear2,
|
||||||
|
PageHeader,
|
||||||
|
TabLink,
|
||||||
|
Tabs,
|
||||||
|
useToast,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
|
||||||
|
import { Outlet, useParams } from "react-router";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { CookieBannerConfigLayoutActivateMutation } from "#/__generated__/core/CookieBannerConfigLayoutActivateMutation.graphql";
|
||||||
|
import type { CookieBannerConfigLayoutDeactivateMutation } from "#/__generated__/core/CookieBannerConfigLayoutDeactivateMutation.graphql";
|
||||||
|
import type { CookieBannerConfigLayoutPublishMutation } from "#/__generated__/core/CookieBannerConfigLayoutPublishMutation.graphql";
|
||||||
|
import type { CookieBannerConfigLayoutQuery } from "#/__generated__/core/CookieBannerConfigLayoutQuery.graphql";
|
||||||
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
export const cookieBannerConfigLayoutQuery = graphql`
|
||||||
|
query CookieBannerConfigLayoutQuery($cookieBannerId: ID!) {
|
||||||
|
node(id: $cookieBannerId) {
|
||||||
|
__typename
|
||||||
|
... on CookieBanner {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
origin
|
||||||
|
state
|
||||||
|
latestVersion {
|
||||||
|
id
|
||||||
|
version
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const activateMutation = graphql`
|
||||||
|
mutation CookieBannerConfigLayoutActivateMutation($input: ActivateCookieBannerInput!) {
|
||||||
|
activateCookieBanner(input: $input) {
|
||||||
|
cookieBanner {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const deactivateMutation = graphql`
|
||||||
|
mutation CookieBannerConfigLayoutDeactivateMutation($input: DeactivateCookieBannerInput!) {
|
||||||
|
deactivateCookieBanner(input: $input) {
|
||||||
|
cookieBanner {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const publishMutation = graphql`
|
||||||
|
mutation CookieBannerConfigLayoutPublishMutation($input: PublishCookieBannerVersionInput!) {
|
||||||
|
publishCookieBannerVersion(input: $input) {
|
||||||
|
cookieBannerVersion {
|
||||||
|
id
|
||||||
|
version
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface CookieBannerConfigLayoutProps {
|
||||||
|
queryRef: PreloadedQuery<CookieBannerConfigLayoutQuery>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfigLayoutProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const organizationId = useOrganizationId();
|
||||||
|
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
|
||||||
|
|
||||||
|
const data = usePreloadedQuery(cookieBannerConfigLayoutQuery, queryRef);
|
||||||
|
if (data.node.__typename !== "CookieBanner") {
|
||||||
|
throw new Error("invalid type for node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = data.node;
|
||||||
|
|
||||||
|
const [commitActivate, isActivating] = useMutation<CookieBannerConfigLayoutActivateMutation>(activateMutation);
|
||||||
|
const [commitDeactivate, isDeactivating] = useMutation<CookieBannerConfigLayoutDeactivateMutation>(deactivateMutation);
|
||||||
|
const [commitPublish, isPublishing] = useMutation<CookieBannerConfigLayoutPublishMutation>(publishMutation);
|
||||||
|
|
||||||
|
const handleToggleState = () => {
|
||||||
|
if (banner.state === "ACTIVE") {
|
||||||
|
commitDeactivate({
|
||||||
|
variables: { input: { cookieBannerId: banner.id } },
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Banner deactivated"), variant: "success" });
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to deactivate"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commitActivate({
|
||||||
|
variables: { input: { cookieBannerId: banner.id } },
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Banner activated"), variant: "success" });
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to activate"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = () => {
|
||||||
|
commitPublish({
|
||||||
|
variables: { input: { cookieBannerId: banner.id } },
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Version published"), variant: "success" });
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to publish"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDraft = banner.latestVersion?.state === "DRAFT";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: __("Cookie Banners"),
|
||||||
|
to: `/organizations/${organizationId}/cookie-banners`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: banner.name,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={banner.name}
|
||||||
|
description={banner.origin}
|
||||||
|
>
|
||||||
|
<Badge variant={banner.state === "ACTIVE" ? "success" : "danger"}>
|
||||||
|
{banner.state === "ACTIVE" ? __("Active") : __("Inactive")}
|
||||||
|
</Badge>
|
||||||
|
{hasDraft && (
|
||||||
|
<Button onClick={handlePublish} disabled={isPublishing}>
|
||||||
|
{isPublishing ? __("Publishing...") : __("Publish Changes")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleToggleState}
|
||||||
|
disabled={isActivating || isDeactivating}
|
||||||
|
>
|
||||||
|
{banner.state === "ACTIVE" ? __("Deactivate") : __("Activate")}
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/settings`}>
|
||||||
|
<IconSettingsGear2 size={20} />
|
||||||
|
{__("Settings")}
|
||||||
|
</TabLink>
|
||||||
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/snippet`}>
|
||||||
|
<IconPageTextLine size={20} />
|
||||||
|
{__("Snippet")}
|
||||||
|
</TabLink>
|
||||||
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/theme`}>
|
||||||
|
<IconImage size={20} />
|
||||||
|
{__("Theme")}
|
||||||
|
</TabLink>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
import { Suspense, useEffect } from "react";
|
||||||
|
import { useQueryLoader } from "react-relay";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
|
import type { CookieBannerConfigLayoutQuery } from "#/__generated__/core/CookieBannerConfigLayoutQuery.graphql";
|
||||||
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
|
|
||||||
|
import CookieBannerConfigLayout, { cookieBannerConfigLayoutQuery } from "./CookieBannerConfigLayout";
|
||||||
|
|
||||||
|
export default function CookieBannerConfigLayoutLoader() {
|
||||||
|
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
|
||||||
|
const [queryRef, loadQuery] = useQueryLoader<CookieBannerConfigLayoutQuery>(cookieBannerConfigLayoutQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cookieBannerId) {
|
||||||
|
loadQuery({ cookieBannerId });
|
||||||
|
}
|
||||||
|
}, [loadQuery, cookieBannerId]);
|
||||||
|
|
||||||
|
if (!queryRef) {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<CookieBannerConfigLayout queryRef={queryRef} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { Button, Card, Field, Input, Label, Option, Select, useToast } from "@probo/ui";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { BannerSettingsFormMutation } from "#/__generated__/core/BannerSettingsFormMutation.graphql";
|
||||||
|
|
||||||
|
const updateBannerMutation = graphql`
|
||||||
|
mutation BannerSettingsFormMutation($input: UpdateCookieBannerInput!) {
|
||||||
|
updateCookieBanner(input: $input) {
|
||||||
|
cookieBanner {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
origin
|
||||||
|
privacyPolicyUrl
|
||||||
|
consentExpiryDays
|
||||||
|
consentMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface BannerSettingsFormProps {
|
||||||
|
banner: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
origin: string;
|
||||||
|
privacyPolicyUrl: string;
|
||||||
|
consentExpiryDays: number;
|
||||||
|
consentMode: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BannerSettingsForm({ banner }: BannerSettingsFormProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [commitMutation, isInFlight] = useMutation<BannerSettingsFormMutation>(updateBannerMutation);
|
||||||
|
|
||||||
|
const [name, setName] = useState(banner.name);
|
||||||
|
const [origin, setOrigin] = useState(banner.origin);
|
||||||
|
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState(banner.privacyPolicyUrl);
|
||||||
|
const [consentExpiryDays, setConsentExpiryDays] = useState(String(banner.consentExpiryDays));
|
||||||
|
const [consentMode, setConsentMode] = useState(banner.consentMode);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
commitMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
cookieBannerId: banner.id,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
privacyPolicyUrl,
|
||||||
|
consentExpiryDays: parseInt(consentExpiryDays, 10),
|
||||||
|
consentMode: consentMode as "OPT_IN" | "OPT_OUT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Banner settings updated"), variant: "success" });
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to update"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-medium">{__("Settings")}</h3>
|
||||||
|
<Card className="border p-4">
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<Field label={__("Name")}>
|
||||||
|
<Input value={name} onChange={e => setName(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={__("Origin URL")}>
|
||||||
|
<Input value={origin} onChange={e => setOrigin(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={__("Privacy Policy URL")}>
|
||||||
|
<Input value={privacyPolicyUrl} onChange={e => setPrivacyPolicyUrl(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__("Consent Expiry (days)")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={consentExpiryDays}
|
||||||
|
onChange={e => setConsentExpiryDays(e.target.value)}
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__("Consent Mode")}</Label>
|
||||||
|
<Select value={consentMode} onValueChange={setConsentMode}>
|
||||||
|
<Option value="OPT_IN">{__("Opt-in")}</Option>
|
||||||
|
<Option value="OPT_OUT">{__("Opt-out")}</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isInFlight}>
|
||||||
|
{isInFlight ? __("Saving...") : __("Save")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Textarea,
|
||||||
|
useToast,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { CategoryDialogCreateMutation } from "#/__generated__/core/CategoryDialogCreateMutation.graphql";
|
||||||
|
import type { CategoryDialogUpdateMutation } from "#/__generated__/core/CategoryDialogUpdateMutation.graphql";
|
||||||
|
|
||||||
|
const createMutation = graphql`
|
||||||
|
mutation CategoryDialogCreateMutation(
|
||||||
|
$input: CreateCookieCategoryInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
createCookieCategory(input: $input) {
|
||||||
|
cookieCategoryEdge @appendEdge(connections: $connections) {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
required
|
||||||
|
rank
|
||||||
|
cookies {
|
||||||
|
name
|
||||||
|
duration
|
||||||
|
description
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateMutation = graphql`
|
||||||
|
mutation CategoryDialogUpdateMutation($input: UpdateCookieCategoryInput!) {
|
||||||
|
updateCookieCategory(input: $input) {
|
||||||
|
cookieCategory {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
rank
|
||||||
|
cookies {
|
||||||
|
name
|
||||||
|
duration
|
||||||
|
description
|
||||||
|
}
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface CookieEntry {
|
||||||
|
name: string;
|
||||||
|
duration: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryDialogProps {
|
||||||
|
cookieBannerId: string;
|
||||||
|
connectionId: string;
|
||||||
|
category?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
rank: number;
|
||||||
|
cookies: ReadonlyArray<CookieEntry>;
|
||||||
|
};
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryDialog({
|
||||||
|
cookieBannerId,
|
||||||
|
connectionId,
|
||||||
|
category,
|
||||||
|
onOpenChange,
|
||||||
|
}: CategoryDialogProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const isEditing = !!category;
|
||||||
|
|
||||||
|
const [commitCreate, isCreating] = useMutation<CategoryDialogCreateMutation>(createMutation);
|
||||||
|
const [commitUpdate, isUpdating] = useMutation<CategoryDialogUpdateMutation>(updateMutation);
|
||||||
|
|
||||||
|
const [name, setName] = useState(category?.name ?? "");
|
||||||
|
const [description, setDescription] = useState(category?.description ?? "");
|
||||||
|
const [required, setRequired] = useState(category?.required ?? false);
|
||||||
|
const [cookies, setCookies] = useState<CookieEntry[]>(
|
||||||
|
category?.cookies ? [...category.cookies.map(c => ({ ...c }))] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addCookie = () => {
|
||||||
|
setCookies([...cookies, { name: "", duration: "", description: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCookie = (index: number) => {
|
||||||
|
setCookies(cookies.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCookie = (index: number, field: keyof CookieEntry, value: string) => {
|
||||||
|
setCookies(cookies.map((c, i) => (i === index ? { ...c, [field]: value } : c)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const cookieItems = cookies.filter(c => c.name.trim() !== "");
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
commitUpdate({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
cookieCategoryId: category.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
cookies: cookieItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Category updated"), variant: "success" });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to update category"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commitCreate({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
cookieBannerId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
rank: 0,
|
||||||
|
cookies: cookieItems.length > 0 ? cookieItems : null,
|
||||||
|
},
|
||||||
|
connections: [connectionId],
|
||||||
|
},
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Category created"), variant: "success" });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to create category"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
defaultOpen
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
title={isEditing ? __("Edit Category") : __("Add Category")}
|
||||||
|
className="max-w-lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogContent padded className="space-y-4">
|
||||||
|
<Field label={__("Name")}>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={__("Description")}>
|
||||||
|
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} required rows={2} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={required}
|
||||||
|
onCheckedChange={(v) => setRequired(v === true)}
|
||||||
|
id="required"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="required">{__("Required")}</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{__("Cookies")}</Label>
|
||||||
|
<Button type="button" variant="secondary" onClick={addCookie}>
|
||||||
|
{__("Add Cookie")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{cookies.map((cookie, i) => (
|
||||||
|
<div key={i} className="rounded border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">{__("Cookie")} {i + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCookie(i)}
|
||||||
|
className="text-xs text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
{__("Remove")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder={__("Cookie name")}
|
||||||
|
value={cookie.name}
|
||||||
|
onChange={(e) => updateCookie(i, "name", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder={__("Duration (e.g. 1 year)")}
|
||||||
|
value={cookie.duration}
|
||||||
|
onChange={(e) => updateCookie(i, "duration", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder={__("Description")}
|
||||||
|
value={cookie.description}
|
||||||
|
onChange={(e) => updateCookie(i, "description", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isCreating || isUpdating}>
|
||||||
|
{isCreating || isUpdating ? __("Saving...") : isEditing ? __("Update") : __("Create")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { Badge, Button, Card, IconArrowDown, IconArrowUp, useToast } from "@probo/ui";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { CategoryListDeleteMutation } from "#/__generated__/core/CategoryListDeleteMutation.graphql";
|
||||||
|
import type { CategoryListUpdateMutation } from "#/__generated__/core/CategoryListUpdateMutation.graphql";
|
||||||
|
|
||||||
|
import { CategoryDialog } from "./CategoryDialog";
|
||||||
|
import { CookieDialog } from "./CookieDialog";
|
||||||
|
|
||||||
|
const deleteCategoryMutation = graphql`
|
||||||
|
mutation CategoryListDeleteMutation(
|
||||||
|
$input: DeleteCookieCategoryInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
deleteCookieCategory(input: $input) {
|
||||||
|
deletedCookieCategoryId @deleteEdge(connections: $connections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateCategoryMutation = graphql`
|
||||||
|
mutation CategoryListUpdateMutation($input: UpdateCookieCategoryInput!) {
|
||||||
|
updateCookieCategory(input: $input) {
|
||||||
|
cookieCategory {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
rank
|
||||||
|
cookies {
|
||||||
|
name
|
||||||
|
duration
|
||||||
|
description
|
||||||
|
}
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
rank: number;
|
||||||
|
cookies: ReadonlyArray<{
|
||||||
|
name: string;
|
||||||
|
duration: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryListProps {
|
||||||
|
cookieBannerId: string;
|
||||||
|
categories: ReadonlyArray<Category>;
|
||||||
|
connectionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryList({ cookieBannerId, categories, connectionId }: CategoryListProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
|
const [showCookieDialog, setShowCookieDialog] = useState(false);
|
||||||
|
|
||||||
|
const [commitDelete] = useMutation<CategoryListDeleteMutation>(deleteCategoryMutation);
|
||||||
|
const [commitUpdate] = useMutation<CategoryListUpdateMutation>(updateCategoryMutation);
|
||||||
|
|
||||||
|
const sorted = [...categories].sort((a, b) => a.rank - b.rank);
|
||||||
|
|
||||||
|
const handleDelete = (categoryId: string) => {
|
||||||
|
commitDelete({
|
||||||
|
variables: {
|
||||||
|
input: { cookieCategoryId: categoryId },
|
||||||
|
connections: [connectionId],
|
||||||
|
},
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Category deleted"), variant: "success" });
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to delete category"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveUp = (index: number) => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const current = sorted[index];
|
||||||
|
const above = sorted[index - 1];
|
||||||
|
commitUpdate({
|
||||||
|
variables: { input: { cookieCategoryId: current.id, rank: above.rank } },
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
commitUpdate({
|
||||||
|
variables: { input: { cookieCategoryId: above.id, rank: current.rank } },
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveDown = (index: number) => {
|
||||||
|
if (index >= sorted.length - 1) return;
|
||||||
|
const current = sorted[index];
|
||||||
|
const below = sorted[index + 1];
|
||||||
|
commitUpdate({
|
||||||
|
variables: { input: { cookieCategoryId: current.id, rank: below.rank } },
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
commitUpdate({
|
||||||
|
variables: { input: { cookieCategoryId: below.id, rank: current.rank } },
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium">{__("Categories")}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowCookieDialog(true)}>
|
||||||
|
{__("Add Cookie")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setShowCreateDialog(true)}>
|
||||||
|
{__("Add Category")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="divide-y divide-border-low rounded-lg border">
|
||||||
|
{sorted.map((category, index) => (
|
||||||
|
<div key={category.id} className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{category.name}</span>
|
||||||
|
{category.required && (
|
||||||
|
<Badge variant="neutral">{__("Required")}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveUp(index)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<IconArrowUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveDown(index)}
|
||||||
|
disabled={index === sorted.length - 1}
|
||||||
|
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<IconArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => setEditingCategory(category)}
|
||||||
|
>
|
||||||
|
{__("Edit")}
|
||||||
|
</Button>
|
||||||
|
{!category.required && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => handleDelete(category.id)}
|
||||||
|
>
|
||||||
|
{__("Delete")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">{category.description}</p>
|
||||||
|
|
||||||
|
{category.cookies.length > 0 && (
|
||||||
|
<div className="mt-3 rounded bg-muted/50 p-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
{__("Cookies")}
|
||||||
|
{" "}
|
||||||
|
(
|
||||||
|
{category.cookies.length}
|
||||||
|
)
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{category.cookies.map((cookie, i) => (
|
||||||
|
<div key={i} className="flex items-baseline justify-between text-xs">
|
||||||
|
<code className="font-mono">{cookie.name}</code>
|
||||||
|
<span className="text-muted-foreground">{cookie.duration}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showCreateDialog && (
|
||||||
|
<CategoryDialog
|
||||||
|
cookieBannerId={cookieBannerId}
|
||||||
|
connectionId={connectionId}
|
||||||
|
onOpenChange={setShowCreateDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingCategory && (
|
||||||
|
<CategoryDialog
|
||||||
|
cookieBannerId={cookieBannerId}
|
||||||
|
connectionId={connectionId}
|
||||||
|
category={editingCategory}
|
||||||
|
onOpenChange={(open) => { if (!open) setEditingCategory(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCookieDialog && (
|
||||||
|
<CookieDialog
|
||||||
|
categories={sorted}
|
||||||
|
onOpenChange={setShowCookieDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// 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 { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
Field,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Option,
|
||||||
|
Select,
|
||||||
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { CookieDialogUpdateMutation } from "#/__generated__/core/CookieDialogUpdateMutation.graphql";
|
||||||
|
|
||||||
|
const updateCategoryMutation = graphql`
|
||||||
|
mutation CookieDialogUpdateMutation($input: UpdateCookieCategoryInput!) {
|
||||||
|
updateCookieCategory(input: $input) {
|
||||||
|
cookieCategory {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
rank
|
||||||
|
cookies {
|
||||||
|
name
|
||||||
|
duration
|
||||||
|
description
|
||||||
|
}
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface CookieEntry {
|
||||||
|
name: string;
|
||||||
|
duration: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cookies: ReadonlyArray<CookieEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CookieDialogProps {
|
||||||
|
categories: ReadonlyArray<Category>;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CookieDialog({ categories, onOpenChange }: CookieDialogProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const dialogRef = useDialogRef();
|
||||||
|
|
||||||
|
const [commitUpdate, isUpdating] = useMutation<CookieDialogUpdateMutation>(updateCategoryMutation);
|
||||||
|
|
||||||
|
const [categoryId, setCategoryId] = useState(categories[0]?.id ?? "");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [duration, setDuration] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const category = categories.find(c => c.id === categoryId);
|
||||||
|
if (!category) return;
|
||||||
|
|
||||||
|
const existingCookies = category.cookies.map(c => ({
|
||||||
|
name: c.name,
|
||||||
|
duration: c.duration,
|
||||||
|
description: c.description,
|
||||||
|
}));
|
||||||
|
|
||||||
|
commitUpdate({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
cookieCategoryId: categoryId,
|
||||||
|
cookies: [
|
||||||
|
...existingCookies,
|
||||||
|
{
|
||||||
|
name: name.trim(),
|
||||||
|
duration: duration.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted() {
|
||||||
|
toast({ title: __("Success"), description: __("Cookie added"), variant: "success" });
|
||||||
|
dialogRef.current?.close();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({ title: __("Error"), description: formatError(__("Failed to add cookie"), error as GraphQLError), variant: "error" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
defaultOpen
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
title={__("Add Cookie")}
|
||||||
|
className="max-w-lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<DialogContent padded className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{__("Category")}</Label>
|
||||||
|
<Select value={categoryId} onValueChange={id => setCategoryId(id)}>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<Option key={cat.id} value={cat.id}>{cat.name}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label={__("Cookie name")}>
|
||||||
|
<Input value={name} onChange={e => setName(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={__("Duration")}>
|
||||||
|
<Input
|
||||||
|
value={duration}
|
||||||
|
onChange={e => setDuration(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder={__("e.g. 1 year")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={__("Description")}>
|
||||||
|
<Input value={description} onChange={e => setDescription(e.target.value)} required />
|
||||||
|
</Field>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isUpdating}>
|
||||||
|
{isUpdating ? __("Adding...") : __("Add Cookie")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// 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 { useLazyLoadQuery } from "react-relay";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { CookieBannerSettingsPageQuery } from "#/__generated__/core/CookieBannerSettingsPageQuery.graphql";
|
||||||
|
|
||||||
|
import { BannerSettingsForm } from "../_components/BannerSettingsForm";
|
||||||
|
import { CategoryList } from "../_components/CategoryList";
|
||||||
|
|
||||||
|
const settingsPageQuery = graphql`
|
||||||
|
query CookieBannerSettingsPageQuery($cookieBannerId: ID!) {
|
||||||
|
node(id: $cookieBannerId) {
|
||||||
|
__typename
|
||||||
|
... on CookieBanner {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
origin
|
||||||
|
privacyPolicyUrl
|
||||||
|
consentExpiryDays
|
||||||
|
consentMode
|
||||||
|
categories(first: 50, orderBy: { field: RANK, direction: ASC }) {
|
||||||
|
__id
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
required
|
||||||
|
rank
|
||||||
|
cookies {
|
||||||
|
name
|
||||||
|
duration
|
||||||
|
description
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function CookieBannerSettingsPage() {
|
||||||
|
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
|
||||||
|
if (!cookieBannerId) {
|
||||||
|
throw new Error("Missing :cookieBannerId param in route");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = useLazyLoadQuery<CookieBannerSettingsPageQuery>(
|
||||||
|
settingsPageQuery,
|
||||||
|
{ cookieBannerId },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.node.__typename !== "CookieBanner") {
|
||||||
|
throw new Error("invalid type for node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = data.node;
|
||||||
|
const connectionId = banner.categories.__id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<BannerSettingsForm banner={banner} />
|
||||||
|
|
||||||
|
<CategoryList
|
||||||
|
cookieBannerId={banner.id}
|
||||||
|
categories={banner.categories.edges.map(e => e.node)}
|
||||||
|
connectionId={connectionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// 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 } from "@probo/ui";
|
||||||
|
import { useLazyLoadQuery } from "react-relay";
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { CookieBannerSnippetPageQuery } from "#/__generated__/core/CookieBannerSnippetPageQuery.graphql";
|
||||||
|
|
||||||
|
import { CodeSnippets } from "./_components/CodeSnippets";
|
||||||
|
|
||||||
|
const snippetPageQuery = graphql`
|
||||||
|
query CookieBannerSnippetPageQuery($cookieBannerId: ID!) {
|
||||||
|
node(id: $cookieBannerId) {
|
||||||
|
__typename
|
||||||
|
... on CookieBanner {
|
||||||
|
id
|
||||||
|
origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function CookieBannerSnippetPage() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
|
||||||
|
if (!cookieBannerId) {
|
||||||
|
throw new Error("Missing :cookieBannerId param in route");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = useLazyLoadQuery<CookieBannerSnippetPageQuery>(
|
||||||
|
snippetPageQuery,
|
||||||
|
{ cookieBannerId },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.node.__typename !== "CookieBanner") {
|
||||||
|
throw new Error("invalid type for node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = data.node;
|
||||||
|
const baseUrl = `${window.location.origin}/api/cookie-banner/v1`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<Step
|
||||||
|
number={1}
|
||||||
|
title={__("Add the cookie banner snippet")}
|
||||||
|
description={__("Include the Probo cookie banner on your website by adding one of the following snippets. The banner will automatically appear and collect visitor consent.")}
|
||||||
|
>
|
||||||
|
<CodeSnippets bannerId={banner.id} baseUrl={baseUrl} />
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step
|
||||||
|
number={2}
|
||||||
|
title={__("Tag third-party elements with consent categories")}
|
||||||
|
description={__("Mark scripts, iframes, and other third-party resources with a data-cookie-consent attribute so they only load after the visitor grants consent for the corresponding category. Replace src with data-src (or href with data-href) to prevent the browser from loading the resource before consent is given.")}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium">{__("Scripts")}</h4>
|
||||||
|
<Card className="border">
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
|
||||||
|
<code>{`<!-- Before: loads immediately -->
|
||||||
|
<script src="https://analytics.example.com/tracker.js"></script>
|
||||||
|
|
||||||
|
<!-- After: loads only when "analytics" consent is granted -->
|
||||||
|
<script
|
||||||
|
type="text/plain"
|
||||||
|
data-cookie-consent="analytics"
|
||||||
|
data-src="https://analytics.example.com/tracker.js"
|
||||||
|
></script>`}</code>
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h4 className="text-sm font-medium">{__("Inline scripts")}</h4>
|
||||||
|
<Card className="border">
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
|
||||||
|
<code>{`<script
|
||||||
|
type="text/plain"
|
||||||
|
data-cookie-consent="analytics"
|
||||||
|
data-type="text/javascript"
|
||||||
|
>
|
||||||
|
// This code runs only after consent
|
||||||
|
gtag('config', 'G-XXXXXXX');
|
||||||
|
</script>`}</code>
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h4 className="text-sm font-medium">{__("Iframes & embeds")}</h4>
|
||||||
|
<Card className="border">
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
|
||||||
|
<code>{`<iframe
|
||||||
|
data-cookie-consent="marketing"
|
||||||
|
data-src="https://www.youtube.com/embed/VIDEO_ID"
|
||||||
|
width="560"
|
||||||
|
height="315"
|
||||||
|
></iframe>`}</code>
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h4 className="text-sm font-medium">{__("Images, video & audio")}</h4>
|
||||||
|
<Card className="border">
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
|
||||||
|
<code>{`<img
|
||||||
|
data-cookie-consent="analytics"
|
||||||
|
data-src="https://tracker.example.com/pixel.gif"
|
||||||
|
width="1"
|
||||||
|
height="1"
|
||||||
|
/>`}</code>
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h4 className="text-sm font-medium">{__("Stylesheets")}</h4>
|
||||||
|
<Card className="border">
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
|
||||||
|
<code>{`<link
|
||||||
|
rel="stylesheet"
|
||||||
|
data-cookie-consent="marketing"
|
||||||
|
data-href="https://widgets.example.com/styles.css"
|
||||||
|
/>`}</code>
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-sm text-txt-secondary">
|
||||||
|
{__("Supported elements: script, iframe, img, video, audio, embed, object, and link. The data-cookie-consent value must match a category name configured in your banner settings.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-sm font-semibold text-invert">
|
||||||
|
{number}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">{title}</h3>
|
||||||
|
<p className="mt-1 text-sm text-txt-secondary">{description}</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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 { Button, Card, useToast } from "@probo/ui";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface CodeSnippetsProps {
|
||||||
|
bannerId: string;
|
||||||
|
baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeSnippets({ bannerId, baseUrl }: CodeSnippetsProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
label: __("Script Tag"),
|
||||||
|
code: `<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/@probo/cookie-banner/dist/cookie-banner.iife.js"
|
||||||
|
data-banner-id="${bannerId}"
|
||||||
|
data-base-url="${baseUrl}"
|
||||||
|
data-position="bottom-left"
|
||||||
|
></script>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __("ES Module"),
|
||||||
|
code: `import { registerThemedBanner } from "@probo/cookie-banner/themed-banner";
|
||||||
|
|
||||||
|
registerThemedBanner();
|
||||||
|
|
||||||
|
// In your HTML or template:
|
||||||
|
// <probo-cookie-banner
|
||||||
|
// banner-id="${bannerId}"
|
||||||
|
// base-url="${baseUrl}"
|
||||||
|
// position="bottom-left"
|
||||||
|
// ></probo-cookie-banner>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __("Headless"),
|
||||||
|
code: `import { registerComponents } from "@probo/cookie-banner";
|
||||||
|
|
||||||
|
registerComponents();
|
||||||
|
|
||||||
|
// Build your own UI with headless components:
|
||||||
|
// <probo-cookie-banner-root banner-id="${bannerId}" base-url="${baseUrl}">
|
||||||
|
// <probo-banner>
|
||||||
|
// <probo-accept-button><button>Accept all</button></probo-accept-button>
|
||||||
|
// <probo-reject-button><button>Reject all</button></probo-reject-button>
|
||||||
|
// <probo-customize-button><button>Customize</button></probo-customize-button>
|
||||||
|
// </probo-banner>
|
||||||
|
// <probo-settings-button position="bottom-left"></probo-settings-button>
|
||||||
|
// </probo-cookie-banner-root>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const activeCode = tabs[activeTab].code;
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
void navigator.clipboard.writeText(activeCode);
|
||||||
|
toast({
|
||||||
|
title: __("Copied"),
|
||||||
|
description: __("Code copied to clipboard"),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="rounded-lg border">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-low px-1">
|
||||||
|
<div className="flex">
|
||||||
|
{tabs.map((tab, i) => (
|
||||||
|
<button
|
||||||
|
key={tab.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(i)}
|
||||||
|
className={`cursor-pointer px-3 py-2.5 text-sm font-light border-b-2 border-border-low -mb-px transition-colors ${
|
||||||
|
i === activeTab
|
||||||
|
? "border-border-mid text-foreground font-semibold"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={handleCopy}>
|
||||||
|
{__("Copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm font-mono bg-muted/30 rounded-b-lg text-invert bg-accent">
|
||||||
|
<code>{activeCode}</code>
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// 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 { ThemePreview } from "./_components/ThemePreview";
|
||||||
|
|
||||||
|
export default function CookieBannerThemePage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<ThemePreview />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// 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 { Button, Card, Field, Input, useToast } from "@probo/ui";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type CSSVariable = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
defaultValue: string;
|
||||||
|
type: "color" | "text";
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSS_VARIABLES: CSSVariable[] = [
|
||||||
|
{ key: "--probo-bg", label: "Background", defaultValue: "#ffffff", type: "color" },
|
||||||
|
{ key: "--probo-text", label: "Text", defaultValue: "#1a1a1a", type: "color" },
|
||||||
|
{ key: "--probo-text-secondary", label: "Text Secondary", defaultValue: "#555555", type: "color" },
|
||||||
|
{ key: "--probo-border", label: "Border", defaultValue: "#e0e0e0", type: "color" },
|
||||||
|
{ key: "--probo-accent", label: "Accent", defaultValue: "#1a1a1a", type: "color" },
|
||||||
|
{ key: "--probo-accent-text", label: "Accent Text", defaultValue: "#ffffff", type: "color" },
|
||||||
|
{ key: "--probo-radius", label: "Border Radius", defaultValue: "12px", type: "text" },
|
||||||
|
{ key: "--probo-btn-radius", label: "Button Radius", defaultValue: "8px", type: "text" },
|
||||||
|
{ key: "--probo-font-size", label: "Font Size", defaultValue: "14px", type: "text" },
|
||||||
|
{ key: "--probo-font-family", label: "Font Family", defaultValue: "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif", type: "text" },
|
||||||
|
{ key: "--probo-shadow", label: "Shadow", defaultValue: "0 4px 24px rgba(0, 0, 0, 0.12)", type: "text" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildCSSSnippet(values: Record<string, string>): string {
|
||||||
|
const overrides = CSS_VARIABLES
|
||||||
|
.filter(v => values[v.key] !== v.defaultValue)
|
||||||
|
.map(v => ` ${v.key}: ${values[v.key]};`);
|
||||||
|
|
||||||
|
if (overrides.length === 0) {
|
||||||
|
return "/* Using default theme — no overrides needed */";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `probo-cookie-banner {\n${overrides.join("\n")}\n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemePreview() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const v of CSS_VARIABLES) {
|
||||||
|
initial[v.key] = v.defaultValue;
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setValue = useCallback((key: string, value: string) => {
|
||||||
|
setValues(prev => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
for (const v of CSS_VARIABLES) {
|
||||||
|
initial[v.key] = v.defaultValue;
|
||||||
|
}
|
||||||
|
setValues(initial);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cssSnippet = useMemo(() => buildCSSSnippet(values), [values]);
|
||||||
|
|
||||||
|
const handleCopyCSS = () => {
|
||||||
|
void navigator.clipboard.writeText(cssSnippet);
|
||||||
|
toast({
|
||||||
|
title: __("Copied"),
|
||||||
|
description: __("CSS snippet copied to clipboard"),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewStyle = useMemo(() => {
|
||||||
|
const style: Record<string, string> = {};
|
||||||
|
for (const v of CSS_VARIABLES) {
|
||||||
|
style[v.key] = values[v.key];
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
}, [values]);
|
||||||
|
|
||||||
|
const colorVariables = CSS_VARIABLES.filter(v => v.type === "color");
|
||||||
|
const textVariables = CSS_VARIABLES.filter(v => v.type === "text");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-lg">{__("Theme")}</h3>
|
||||||
|
<Button variant="tertiary" onClick={handleReset}>
|
||||||
|
{__("Reset to defaults")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{colorVariables.map(v => (
|
||||||
|
<div key={v.key} className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-txt-primary">
|
||||||
|
{v.label}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={values[v.key]}
|
||||||
|
onChange={e => setValue(v.key, e.target.value)}
|
||||||
|
className="h-8 w-10 shrink-0 cursor-pointer rounded border border-border-mid bg-transparent p-0.5"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={values[v.key]}
|
||||||
|
onChange={e => setValue(v.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{textVariables.map(v => (
|
||||||
|
<Field key={v.key} label={v.label}>
|
||||||
|
<Input
|
||||||
|
value={values[v.key]}
|
||||||
|
onChange={e => setValue(v.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h3 className="font-medium text-lg">{__("Preview")}</h3>
|
||||||
|
|
||||||
|
<Card className="border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="relative flex items-end justify-center bg-[repeating-conic-gradient(#e5e7eb_0%_25%,transparent_0%_50%)] bg-size-[20px_20px] p-8"
|
||||||
|
style={{ minHeight: 280, ...previewStyle }}
|
||||||
|
>
|
||||||
|
<BannerPreview />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-lg">{__("CSS Snippet")}</h3>
|
||||||
|
<Button variant="secondary" onClick={handleCopyCSS}>
|
||||||
|
{__("Copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border">
|
||||||
|
<pre className="overflow-x-auto p-4 text-sm font-mono text-invert bg-accent rounded-lg">
|
||||||
|
<code>{cssSnippet}</code>
|
||||||
|
</pre>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BannerPreview() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--probo-bg, #ffffff)",
|
||||||
|
color: "var(--probo-text, #1a1a1a)",
|
||||||
|
borderRadius: "var(--probo-radius, 12px)",
|
||||||
|
boxShadow: "var(--probo-shadow, 0 4px 24px rgba(0, 0, 0, 0.12))",
|
||||||
|
fontFamily: "var(--probo-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif)",
|
||||||
|
fontSize: "var(--probo-font-size, 14px)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
maxWidth: 520,
|
||||||
|
width: "100%",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "calc(var(--probo-font-size, 14px) + 2px)",
|
||||||
|
fontWeight: 600,
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cookie Preferences
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--probo-text-secondary, #555555)",
|
||||||
|
margin: "0 0 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
We use cookies to improve your experience and analyze site traffic.
|
||||||
|
{" "}
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={e => e.preventDefault()}
|
||||||
|
style={{
|
||||||
|
color: "var(--probo-accent, #1a1a1a)",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderRadius: "var(--probo-btn-radius, 8px)",
|
||||||
|
border: "1px solid var(--probo-accent, #1a1a1a)",
|
||||||
|
background: "var(--probo-accent, #1a1a1a)",
|
||||||
|
color: "var(--probo-accent-text, #ffffff)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: "var(--probo-font-size, 14px)",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Accept all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderRadius: "var(--probo-btn-radius, 8px)",
|
||||||
|
border: "1px solid var(--probo-border, #e0e0e0)",
|
||||||
|
background: "var(--probo-bg, #ffffff)",
|
||||||
|
color: "var(--probo-text, #1a1a1a)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: "var(--probo-font-size, 14px)",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reject all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
padding: "10px 16px",
|
||||||
|
borderRadius: "var(--probo-btn-radius, 8px)",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--probo-accent, #1a1a1a)",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: "var(--probo-font-size, 14px)",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Customize
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// 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 { Badge, Button, Card } from "@probo/ui";
|
||||||
|
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
|
import type { CookieBannerOverviewPageQuery } from "#/__generated__/core/CookieBannerOverviewPageQuery.graphql";
|
||||||
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
import { CookieBannerEmptyState } from "./_components/CookieBannerEmptyState";
|
||||||
|
|
||||||
|
export const cookieBannerOverviewPageQuery = graphql`
|
||||||
|
query CookieBannerOverviewPageQuery($organizationId: ID!) {
|
||||||
|
organization: node(id: $organizationId) {
|
||||||
|
__typename
|
||||||
|
... on Organization {
|
||||||
|
cookieBanners(first: 50, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
origin
|
||||||
|
state
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface CookieBannerOverviewPageProps {
|
||||||
|
queryRef: PreloadedQuery<CookieBannerOverviewPageQuery>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CookieBannerOverviewPage({ queryRef }: CookieBannerOverviewPageProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const organizationId = useOrganizationId();
|
||||||
|
|
||||||
|
const { organization } = usePreloadedQuery(cookieBannerOverviewPageQuery, queryRef);
|
||||||
|
if (organization.__typename !== "Organization") {
|
||||||
|
throw new Error("invalid type for node");
|
||||||
|
}
|
||||||
|
|
||||||
|
const banners = organization.cookieBanners.edges.map(e => e.node);
|
||||||
|
const newBannerHref = `/organizations/${organizationId}/cookie-banners/new`;
|
||||||
|
|
||||||
|
if (banners.length === 0) {
|
||||||
|
return (
|
||||||
|
<CookieBannerEmptyState>
|
||||||
|
<Button to={newBannerHref}>{__("Create your first banner")}</Button>
|
||||||
|
</CookieBannerEmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button to={newBannerHref}>{__("Create Banner")}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="divide-y divide-border rounded-lg border">
|
||||||
|
{banners.map(banner => (
|
||||||
|
<Link
|
||||||
|
key={banner.id}
|
||||||
|
to={`/organizations/${organizationId}/cookie-banners/${banner.id}`}
|
||||||
|
className="flex items-center justify-between gap-4 p-4 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium">{banner.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground truncate">{banner.origin}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant={banner.state === "ACTIVE" ? "success" : "danger"}>
|
||||||
|
{banner.state === "ACTIVE" ? __("Active") : __("Inactive")}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(banner.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// 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 type { CookieBannerOverviewPageQuery } from "#/__generated__/core/CookieBannerOverviewPageQuery.graphql";
|
||||||
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
import CookieBannerOverviewPage, { cookieBannerOverviewPageQuery } from "./CookieBannerOverviewPage";
|
||||||
|
|
||||||
|
export default function CookieBannerOverviewPageLoader() {
|
||||||
|
const organizationId = useOrganizationId();
|
||||||
|
const [queryRef, loadQuery] = useQueryLoader<CookieBannerOverviewPageQuery>(cookieBannerOverviewPageQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuery({ organizationId });
|
||||||
|
}, [loadQuery, organizationId]);
|
||||||
|
|
||||||
|
if (!queryRef) {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<CookieBannerOverviewPage queryRef={queryRef} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// 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 { CookieIcon } from "@phosphor-icons/react";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface CookieBannerEmptyStateProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CookieBannerEmptyState({ children }: CookieBannerEmptyStateProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
step: "1",
|
||||||
|
title: __("Create a banner"),
|
||||||
|
description: __("Set up your cookie consent banner with a name, origin URL, and privacy policy link."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "2",
|
||||||
|
title: __("Configure categories"),
|
||||||
|
description: __("Organize your cookies into categories like Analytics, Advertising, and Functional."),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "3",
|
||||||
|
title: __("Install the SDK"),
|
||||||
|
description: __("Add a single script tag or import the ES module to start collecting consent."),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<CookieIcon size={48} weight="duotone" className="mb-2 text-muted-foreground" />
|
||||||
|
<h2 className="text-xl font-semibold mb-2">{__("No cookie banners yet")}</h2>
|
||||||
|
<p className="text-muted-foreground mb-8 max-w-md">
|
||||||
|
{__("Create your first cookie consent banner to start collecting GDPR-compliant consent from your website visitors.")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-3 mb-8 w-full max-w-2xl">
|
||||||
|
{steps.map(s => (
|
||||||
|
<div key={s.step} className="rounded-lg border border-border-mid p-4 text-left">
|
||||||
|
<div className="mb-2 flex size-8 items-center justify-center rounded-full bg-border-solid text-primary-foreground text-sm font-semibold">
|
||||||
|
{s.step}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium mb-1">{s.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// 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 { lazy } from "@probo/react-lazy";
|
||||||
|
import type { AppRoute } from "@probo/routes";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
|
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||||
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
|
|
||||||
|
export const cookieBannerRoutes = [
|
||||||
|
{
|
||||||
|
path: "cookie-banners",
|
||||||
|
Fallback: PageSkeleton,
|
||||||
|
Component: lazy(() => import("#/pages/organizations/cookie-banners/CookieBannerLayout")),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
Fallback: LinkCardSkeleton,
|
||||||
|
Component: lazy(() => import("#/pages/organizations/cookie-banners/overview/CookieBannerOverviewPageLoader")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "new",
|
||||||
|
Component: lazy(() => import("#/pages/organizations/cookie-banners/NewCookieBannerPage")),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "cookie-banners/:cookieBannerId",
|
||||||
|
Fallback: PageSkeleton,
|
||||||
|
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayoutLoader")),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
loader: () => {
|
||||||
|
throw redirect("settings");
|
||||||
|
},
|
||||||
|
Component: Fragment,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
Fallback: LinkCardSkeleton,
|
||||||
|
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/settings/CookieBannerSettingsPage")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "snippet",
|
||||||
|
Fallback: LinkCardSkeleton,
|
||||||
|
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/snippet/CookieBannerSnippetPage")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "theme",
|
||||||
|
Fallback: LinkCardSkeleton,
|
||||||
|
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/theme/CookieBannerThemePage")),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] satisfies AppRoute[];
|
||||||
@@ -30,6 +30,7 @@ import { PageSkeleton } from "./components/skeletons/PageSkeleton";
|
|||||||
import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading";
|
import { ViewerLayoutLoading } from "./pages/iam/memberships/ViewerLayoutLoading";
|
||||||
import { peopleRoutes } from "./pages/iam/organizations/people/routes";
|
import { peopleRoutes } from "./pages/iam/organizations/people/routes";
|
||||||
import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";
|
import { compliancePageRoutes } from "./pages/organizations/compliance-page/routes";
|
||||||
|
import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes";
|
||||||
import { CurrentUser } from "./providers/CurrentUser";
|
import { CurrentUser } from "./providers/CurrentUser";
|
||||||
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
|
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
|
||||||
import { assetRoutes } from "./routes/assetRoutes";
|
import { assetRoutes } from "./routes/assetRoutes";
|
||||||
@@ -305,6 +306,7 @@ const routes = [
|
|||||||
...statementsOfApplicabilityRoutes,
|
...statementsOfApplicabilityRoutes,
|
||||||
...accessReviewRoutes,
|
...accessReviewRoutes,
|
||||||
...compliancePageRoutes,
|
...compliancePageRoutes,
|
||||||
|
...cookieBannerRoutes,
|
||||||
...snapshotsRoutes,
|
...snapshotsRoutes,
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
|
|||||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@probo/coredata": "^1.0.0",
|
"@probo/coredata": "^1.0.0",
|
||||||
"@probo/helpers": "^1.0.0",
|
"@probo/helpers": "^1.0.0",
|
||||||
"@probo/hooks": "1.0.0",
|
"@probo/hooks": "1.0.0",
|
||||||
|
|||||||
@@ -67,7 +67,18 @@ func (v *CookieBannerVersion) CursorKey(field CookieBannerVersionOrderField) pag
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *CookieBannerVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
func (v *CookieBannerVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||||
return map[string]string{"organization_id": v.OrganizationID.String()}, nil
|
q := `SELECT organization_id FROM cookie_banner_versions WHERE id = $1 LIMIT 1;`
|
||||||
|
|
||||||
|
var organizationID gid.GID
|
||||||
|
if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrResourceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("cannot query cookie banner version authorization attributes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *CookieBannerVersion) GetSnapshot() (CookieBannerVersionSnapshot, error) {
|
func (v *CookieBannerVersion) GetSnapshot() (CookieBannerVersionSnapshot, error) {
|
||||||
|
|||||||
@@ -78,7 +78,18 @@ func (c *CookieCategory) CursorKey(field CookieCategoryOrderField) page.CursorKe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *CookieCategory) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
func (c *CookieCategory) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) {
|
||||||
return map[string]string{"organization_id": c.OrganizationID.String()}, nil
|
q := `SELECT organization_id FROM cookie_categories WHERE id = $1 LIMIT 1;`
|
||||||
|
|
||||||
|
var organizationID gid.GID
|
||||||
|
if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrResourceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("cannot query cookie category authorization attributes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{"organization_id": organizationID.String()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CookieCategory) LoadByID(
|
func (c *CookieCategory) LoadByID(
|
||||||
|
|||||||
@@ -377,4 +377,25 @@ const (
|
|||||||
ActionAccessSourceUpdate = "core:access-source:update"
|
ActionAccessSourceUpdate = "core:access-source:update"
|
||||||
ActionAccessSourceDelete = "core:access-source:delete"
|
ActionAccessSourceDelete = "core:access-source:delete"
|
||||||
ActionAccessSourceSync = "core:access-source:sync"
|
ActionAccessSourceSync = "core:access-source:sync"
|
||||||
|
|
||||||
|
// CookieBanner actions
|
||||||
|
ActionCookieBannerGet = "core:cookie-banner:get"
|
||||||
|
ActionCookieBannerList = "core:cookie-banner:list"
|
||||||
|
ActionCookieBannerCreate = "core:cookie-banner:create"
|
||||||
|
ActionCookieBannerUpdate = "core:cookie-banner:update"
|
||||||
|
ActionCookieBannerDelete = "core:cookie-banner:delete"
|
||||||
|
ActionCookieBannerActivate = "core:cookie-banner:activate"
|
||||||
|
ActionCookieBannerDeactivate = "core:cookie-banner:deactivate"
|
||||||
|
|
||||||
|
// CookieBannerVersion actions
|
||||||
|
ActionCookieBannerVersionGet = "core:cookie-banner-version:get"
|
||||||
|
ActionCookieBannerVersionList = "core:cookie-banner-version:list"
|
||||||
|
ActionCookieBannerVersionPublish = "core:cookie-banner-version:publish"
|
||||||
|
|
||||||
|
// CookieCategory actions
|
||||||
|
ActionCookieCategoryGet = "core:cookie-category:get"
|
||||||
|
ActionCookieCategoryList = "core:cookie-category:list"
|
||||||
|
ActionCookieCategoryCreate = "core:cookie-category:create"
|
||||||
|
ActionCookieCategoryUpdate = "core:cookie-category:update"
|
||||||
|
ActionCookieCategoryDelete = "core:cookie-category:delete"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ var ViewerPolicy = policy.NewPolicy(
|
|||||||
ActionAccessReviewCampaignGet, ActionAccessReviewCampaignList,
|
ActionAccessReviewCampaignGet, ActionAccessReviewCampaignList,
|
||||||
ActionAccessEntryGet, ActionAccessEntryList,
|
ActionAccessEntryGet, ActionAccessEntryList,
|
||||||
ActionAccessSourceGet, ActionAccessSourceList,
|
ActionAccessSourceGet, ActionAccessSourceList,
|
||||||
|
ActionCookieBannerGet, ActionCookieBannerList,
|
||||||
|
ActionCookieBannerVersionGet, ActionCookieBannerVersionList,
|
||||||
|
ActionCookieCategoryGet, ActionCookieCategoryList,
|
||||||
).WithSID("entity-read-access").When(organizationCondition),
|
).WithSID("entity-read-access").When(organizationCondition),
|
||||||
|
|
||||||
policy.Allow(
|
policy.Allow(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/certmanager"
|
"go.probo.inc/probo/pkg/certmanager"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
|
"go.probo.inc/probo/pkg/cookiebanner"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||||
"go.probo.inc/probo/pkg/crypto/keys"
|
"go.probo.inc/probo/pkg/crypto/keys"
|
||||||
@@ -522,6 +523,8 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
mailmanService := mailman.NewService(pgClient, fileManagerService, impl.cfg.Auth.Cookie.Secret, baseURL, impl.cfg.AWS.Bucket, encryptionKey, l)
|
mailmanService := mailman.NewService(pgClient, fileManagerService, impl.cfg.Auth.Cookie.Secret, baseURL, impl.cfg.AWS.Bucket, encryptionKey, l)
|
||||||
|
|
||||||
|
cookieBannerService := cookiebanner.NewService(pgClient)
|
||||||
|
|
||||||
proboService, err := probo.NewService(
|
proboService, err := probo.NewService(
|
||||||
ctx,
|
ctx,
|
||||||
encryptionKey,
|
encryptionKey,
|
||||||
@@ -582,6 +585,7 @@ func (impl *Implm) Run(
|
|||||||
ESign: esignService,
|
ESign: esignService,
|
||||||
AccessReview: accessReviewService,
|
AccessReview: accessReviewService,
|
||||||
Mailman: mailmanService,
|
Mailman: mailmanService,
|
||||||
|
CookieBanner: cookieBannerService,
|
||||||
Slack: slackService,
|
Slack: slackService,
|
||||||
ConnectorRegistry: defaultConnectorRegistry,
|
ConnectorRegistry: defaultConnectorRegistry,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
cfg.ESign,
|
cfg.ESign,
|
||||||
cfg.AccessReview,
|
cfg.AccessReview,
|
||||||
cfg.Mailman,
|
cfg.Mailman,
|
||||||
|
cfg.CookieBanner,
|
||||||
cfg.Cookie,
|
cfg.Cookie,
|
||||||
cfg.TokenSecret,
|
cfg.TokenSecret,
|
||||||
cfg.ConnectorRegistry,
|
cfg.ConnectorRegistry,
|
||||||
|
|||||||
@@ -332,6 +332,42 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
|
|||||||
}
|
}
|
||||||
return types.NewAccessEntry(entry), nil
|
return types.NewAccessEntry(entry), nil
|
||||||
}
|
}
|
||||||
|
case coredata.CookieBannerEntityType:
|
||||||
|
action = probo.ActionCookieBannerGet
|
||||||
|
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
||||||
|
scope := coredata.NewScopeFromObjectID(id)
|
||||||
|
banner, err := r.cookieBanner.GetCookieBanner(ctx, scope, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return types.NewCookieBanner(banner), nil
|
||||||
|
}
|
||||||
|
case coredata.CookieCategoryEntityType:
|
||||||
|
action = probo.ActionCookieCategoryGet
|
||||||
|
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
||||||
|
scope := coredata.NewScopeFromObjectID(id)
|
||||||
|
category, err := r.cookieBanner.GetCookieCategory(ctx, scope, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return types.NewCookieCategory(category), nil
|
||||||
|
}
|
||||||
|
case coredata.CookieBannerVersionEntityType:
|
||||||
|
action = probo.ActionCookieBannerVersionGet
|
||||||
|
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
|
||||||
|
scope := coredata.NewScopeFromObjectID(id)
|
||||||
|
version, err := r.cookieBanner.GetCookieBannerVersion(ctx, scope, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &types.CookieBannerVersion{
|
||||||
|
ID: version.ID,
|
||||||
|
Version: version.Version,
|
||||||
|
State: string(version.State),
|
||||||
|
CreatedAt: version.CreatedAt,
|
||||||
|
UpdatedAt: version.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
463
pkg/server/api/console/v1/cookie_banner_resolvers.go
Normal file
463
pkg/server/api/console/v1/cookie_banner_resolvers.go
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
package console_v1
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.87
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.probo.inc/probo/pkg/cookiebanner"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/page"
|
||||||
|
"go.probo.inc/probo/pkg/probo"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||||
|
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
||||||
|
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||||
|
"go.probo.inc/probo/pkg/validator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Organization is the resolver for the organization field.
|
||||||
|
func (r *cookieBannerResolver) Organization(ctx context.Context, obj *types.CookieBanner) (*types.Organization, error) {
|
||||||
|
return obj.Organization, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories is the resolver for the categories field.
|
||||||
|
func (r *cookieBannerResolver) Categories(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookieCategoryOrderBy) (*types.CookieCategoryConnection, error) {
|
||||||
|
if err := r.authorize(ctx, obj.ID, probo.ActionCookieCategoryList); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageOrderBy := page.OrderBy[coredata.CookieCategoryOrderField]{
|
||||||
|
Field: coredata.CookieCategoryOrderFieldRank,
|
||||||
|
Direction: page.OrderDirectionAsc,
|
||||||
|
}
|
||||||
|
if orderBy != nil {
|
||||||
|
pageOrderBy = page.OrderBy[coredata.CookieCategoryOrderField]{
|
||||||
|
Field: orderBy.Field,
|
||||||
|
Direction: orderBy.Direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ID)
|
||||||
|
|
||||||
|
categories, err := r.cookieBanner.ListCookieCategoriesForBanner(ctx, scope, obj.ID, cursor)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot list cookie categories", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := page.NewPage(categories, cursor)
|
||||||
|
|
||||||
|
return types.NewCookieCategoryConnection(p, r, obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestVersion is the resolver for the latestVersion field.
|
||||||
|
func (r *cookieBannerResolver) LatestVersion(ctx context.Context, obj *types.CookieBanner) (*types.CookieBannerVersion, error) {
|
||||||
|
if err := r.authorize(ctx, obj.ID, probo.ActionCookieBannerVersionList); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ID)
|
||||||
|
|
||||||
|
cursor := &page.Cursor[coredata.CookieBannerVersionOrderField]{
|
||||||
|
Size: 1,
|
||||||
|
Position: page.Head,
|
||||||
|
OrderBy: page.OrderBy[coredata.CookieBannerVersionOrderField]{
|
||||||
|
Field: coredata.CookieBannerVersionOrderFieldCreatedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := r.cookieBanner.ListCookieBannerVersionsForBanner(ctx, scope, obj.ID, cursor)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot load latest cookie banner version", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v := versions[0]
|
||||||
|
return &types.CookieBannerVersion{
|
||||||
|
ID: v.ID,
|
||||||
|
Version: v.Version,
|
||||||
|
State: string(v.State),
|
||||||
|
CreatedAt: v.CreatedAt,
|
||||||
|
UpdatedAt: v.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission is the resolver for the permission field.
|
||||||
|
func (r *cookieBannerResolver) Permission(ctx context.Context, obj *types.CookieBanner, action string) (bool, error) {
|
||||||
|
return r.Resolver.Permission(ctx, obj, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalCount is the resolver for the totalCount field.
|
||||||
|
func (r *cookieBannerConnectionResolver) TotalCount(ctx context.Context, obj *types.CookieBannerConnection) (int, error) {
|
||||||
|
if err := r.authorize(ctx, obj.ParentID, probo.ActionCookieBannerList); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ParentID)
|
||||||
|
|
||||||
|
count, err := r.cookieBanner.CountCookieBannersForOrganization(ctx, scope, obj.ParentID, coredata.NewCookieBannerFilter(nil))
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot count cookie banners", log.Error(err))
|
||||||
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookieBanner is the resolver for the cookieBanner field.
|
||||||
|
func (r *cookieCategoryResolver) CookieBanner(ctx context.Context, obj *types.CookieCategory) (*types.CookieBanner, error) {
|
||||||
|
return obj.CookieBanner, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission is the resolver for the permission field.
|
||||||
|
func (r *cookieCategoryResolver) Permission(ctx context.Context, obj *types.CookieCategory, action string) (bool, error) {
|
||||||
|
return r.Resolver.Permission(ctx, obj, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalCount is the resolver for the totalCount field.
|
||||||
|
func (r *cookieCategoryConnectionResolver) TotalCount(ctx context.Context, obj *types.CookieCategoryConnection) (int, error) {
|
||||||
|
if err := r.authorize(ctx, obj.ParentID, probo.ActionCookieCategoryList); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ParentID)
|
||||||
|
|
||||||
|
count, err := r.cookieBanner.CountCookieCategoriesForBanner(ctx, scope, obj.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot count cookie categories", log.Error(err))
|
||||||
|
return 0, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCookieBanner is the resolver for the createCookieBanner field.
|
||||||
|
func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.OrganizationID, probo.ActionCookieBannerCreate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.OrganizationID)
|
||||||
|
|
||||||
|
banner, err := r.cookieBanner.CreateCookieBanner(
|
||||||
|
ctx,
|
||||||
|
scope,
|
||||||
|
cookiebanner.CreateCookieBannerRequest{
|
||||||
|
OrganizationID: input.OrganizationID,
|
||||||
|
Name: input.Name,
|
||||||
|
Origin: input.Origin,
|
||||||
|
PrivacyPolicyURL: input.PrivacyPolicyURL,
|
||||||
|
ConsentExpiryDays: input.ConsentExpiryDays,
|
||||||
|
ConsentMode: input.ConsentMode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrOriginAlreadyInUse) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot create cookie banner", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CreateCookieBannerPayload{
|
||||||
|
CookieBannerEdge: types.NewCookieBannerEdge(banner, coredata.CookieBannerOrderFieldCreatedAt),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCookieBanner is the resolver for the updateCookieBanner field.
|
||||||
|
func (r *mutationResolver) UpdateCookieBanner(ctx context.Context, input types.UpdateCookieBannerInput) (*types.UpdateCookieBannerPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerUpdate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieBannerID)
|
||||||
|
|
||||||
|
banner, err := r.cookieBanner.UpdateCookieBanner(
|
||||||
|
ctx,
|
||||||
|
scope,
|
||||||
|
cookiebanner.UpdateCookieBannerRequest{
|
||||||
|
CookieBannerID: input.CookieBannerID,
|
||||||
|
Name: input.Name,
|
||||||
|
Origin: input.Origin,
|
||||||
|
PrivacyPolicyURL: input.PrivacyPolicyURL,
|
||||||
|
ConsentExpiryDays: input.ConsentExpiryDays,
|
||||||
|
ConsentMode: input.ConsentMode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, cookiebanner.ErrOriginAlreadyInUse) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot update cookie banner", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.UpdateCookieBannerPayload{
|
||||||
|
CookieBanner: types.NewCookieBanner(banner),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCookieBanner is the resolver for the deleteCookieBanner field.
|
||||||
|
func (r *mutationResolver) DeleteCookieBanner(ctx context.Context, input types.DeleteCookieBannerInput) (*types.DeleteCookieBannerPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerDelete); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieBannerID)
|
||||||
|
|
||||||
|
err := r.cookieBanner.DeleteCookieBanner(ctx, scope, input.CookieBannerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot delete cookie banner", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.DeleteCookieBannerPayload{
|
||||||
|
DeletedCookieBannerID: input.CookieBannerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateCookieBanner is the resolver for the activateCookieBanner field.
|
||||||
|
func (r *mutationResolver) ActivateCookieBanner(ctx context.Context, input types.ActivateCookieBannerInput) (*types.ActivateCookieBannerPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerActivate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieBannerID)
|
||||||
|
|
||||||
|
banner, err := r.cookieBanner.ActivateCookieBanner(ctx, scope, input.CookieBannerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerAlreadyActive) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, cookiebanner.ErrOriginAlreadyInUse) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot activate cookie banner", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.ActivateCookieBannerPayload{
|
||||||
|
CookieBanner: types.NewCookieBanner(banner),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateCookieBanner is the resolver for the deactivateCookieBanner field.
|
||||||
|
func (r *mutationResolver) DeactivateCookieBanner(ctx context.Context, input types.DeactivateCookieBannerInput) (*types.DeactivateCookieBannerPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerDeactivate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieBannerID)
|
||||||
|
|
||||||
|
banner, err := r.cookieBanner.DeactivateCookieBanner(ctx, scope, input.CookieBannerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerAlreadyInactive) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot deactivate cookie banner", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.DeactivateCookieBannerPayload{
|
||||||
|
CookieBanner: types.NewCookieBanner(banner),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishCookieBannerVersion is the resolver for the publishCookieBannerVersion field.
|
||||||
|
func (r *mutationResolver) PublishCookieBannerVersion(ctx context.Context, input types.PublishCookieBannerVersionInput) (*types.PublishCookieBannerVersionPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieBannerVersionPublish); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieBannerID)
|
||||||
|
|
||||||
|
version, err := r.cookieBanner.PublishCookieBannerVersion(ctx, scope, input.CookieBannerID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrNoDraftVersion) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot publish cookie banner version", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.PublishCookieBannerVersionPayload{
|
||||||
|
CookieBannerVersion: &types.CookieBannerVersion{
|
||||||
|
ID: version.ID,
|
||||||
|
Version: version.Version,
|
||||||
|
State: string(version.State),
|
||||||
|
CreatedAt: version.CreatedAt,
|
||||||
|
UpdatedAt: version.UpdatedAt,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCookieCategory is the resolver for the createCookieCategory field.
|
||||||
|
func (r *mutationResolver) CreateCookieCategory(ctx context.Context, input types.CreateCookieCategoryInput) (*types.CreateCookieCategoryPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieBannerID, probo.ActionCookieCategoryCreate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieBannerID)
|
||||||
|
|
||||||
|
var cookies coredata.CookieItems
|
||||||
|
if input.Cookies != nil {
|
||||||
|
cookies = make(coredata.CookieItems, len(input.Cookies))
|
||||||
|
for i, c := range input.Cookies {
|
||||||
|
cookies[i] = coredata.CookieItem{
|
||||||
|
Name: c.Name,
|
||||||
|
Duration: c.Duration,
|
||||||
|
Description: c.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := r.cookieBanner.CreateCookieCategory(
|
||||||
|
ctx,
|
||||||
|
scope,
|
||||||
|
cookiebanner.CreateCookieCategoryRequest{
|
||||||
|
CookieBannerID: input.CookieBannerID,
|
||||||
|
Name: input.Name,
|
||||||
|
Description: input.Description,
|
||||||
|
Required: input.Required,
|
||||||
|
Rank: input.Rank,
|
||||||
|
Cookies: cookies,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot create cookie category", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CreateCookieCategoryPayload{
|
||||||
|
CookieCategoryEdge: types.NewCookieCategoryEdge(category, coredata.CookieCategoryOrderFieldRank),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCookieCategory is the resolver for the updateCookieCategory field.
|
||||||
|
func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types.UpdateCookieCategoryInput) (*types.UpdateCookieCategoryPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieCategoryID)
|
||||||
|
|
||||||
|
var cookies *coredata.CookieItems
|
||||||
|
if input.Cookies != nil {
|
||||||
|
items := make(coredata.CookieItems, len(input.Cookies))
|
||||||
|
for i, c := range input.Cookies {
|
||||||
|
items[i] = coredata.CookieItem{
|
||||||
|
Name: c.Name,
|
||||||
|
Duration: c.Duration,
|
||||||
|
Description: c.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cookies = &items
|
||||||
|
}
|
||||||
|
|
||||||
|
category, err := r.cookieBanner.UpdateCookieCategory(
|
||||||
|
ctx,
|
||||||
|
scope,
|
||||||
|
cookiebanner.UpdateCookieCategoryRequest{
|
||||||
|
CookieCategoryID: input.CookieCategoryID,
|
||||||
|
Name: input.Name,
|
||||||
|
Description: input.Description,
|
||||||
|
Rank: input.Rank,
|
||||||
|
Cookies: cookies,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrCategoryNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
|
||||||
|
return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot update cookie category", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.UpdateCookieCategoryPayload{
|
||||||
|
CookieCategory: types.NewCookieCategory(category),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCookieCategory is the resolver for the deleteCookieCategory field.
|
||||||
|
func (r *mutationResolver) DeleteCookieCategory(ctx context.Context, input types.DeleteCookieCategoryInput) (*types.DeleteCookieCategoryPayload, error) {
|
||||||
|
if err := r.authorize(ctx, input.CookieCategoryID, probo.ActionCookieCategoryDelete); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScopeFromObjectID(input.CookieCategoryID)
|
||||||
|
|
||||||
|
err := r.cookieBanner.DeleteCookieCategory(ctx, scope, input.CookieCategoryID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, cookiebanner.ErrCategoryNotFound) {
|
||||||
|
return nil, gqlutils.NotFound(ctx, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, cookiebanner.ErrCannotDeleteRequiredCategory) {
|
||||||
|
return nil, gqlutils.Conflict(ctx, err)
|
||||||
|
}
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot delete cookie category", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.DeleteCookieCategoryPayload{
|
||||||
|
DeletedCookieCategoryID: input.CookieCategoryID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookieBanner returns schema.CookieBannerResolver implementation.
|
||||||
|
func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} }
|
||||||
|
|
||||||
|
// CookieBannerConnection returns schema.CookieBannerConnectionResolver implementation.
|
||||||
|
func (r *Resolver) CookieBannerConnection() schema.CookieBannerConnectionResolver {
|
||||||
|
return &cookieBannerConnectionResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookieCategory returns schema.CookieCategoryResolver implementation.
|
||||||
|
func (r *Resolver) CookieCategory() schema.CookieCategoryResolver { return &cookieCategoryResolver{r} }
|
||||||
|
|
||||||
|
// CookieCategoryConnection returns schema.CookieCategoryConnectionResolver implementation.
|
||||||
|
func (r *Resolver) CookieCategoryConnection() schema.CookieCategoryConnectionResolver {
|
||||||
|
return &cookieCategoryConnectionResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
|
type cookieBannerResolver struct{ *Resolver }
|
||||||
|
type cookieBannerConnectionResolver struct{ *Resolver }
|
||||||
|
type cookieCategoryResolver struct{ *Resolver }
|
||||||
|
type cookieCategoryConnectionResolver struct{ *Resolver }
|
||||||
269
pkg/server/api/console/v1/graphql/cookie_banner.graphql
Normal file
269
pkg/server/api/console/v1/graphql/cookie_banner.graphql
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
enum CookieBannerState
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.CookieBannerState") {
|
||||||
|
ACTIVE
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.CookieBannerStateActive"
|
||||||
|
)
|
||||||
|
INACTIVE
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.CookieBannerStateInactive"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CookieConsentMode
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.CookieConsentMode") {
|
||||||
|
OPT_IN
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.CookieConsentModeOptIn"
|
||||||
|
)
|
||||||
|
OPT_OUT
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.CookieConsentModeOptOut"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CookieBannerOrderField
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/coredata.CookieBannerOrderField"
|
||||||
|
) {
|
||||||
|
CREATED_AT
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.CookieBannerOrderFieldCreatedAt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CookieCategoryOrderField
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/coredata.CookieCategoryOrderField"
|
||||||
|
) {
|
||||||
|
RANK
|
||||||
|
@goEnum(
|
||||||
|
value: "go.probo.inc/probo/pkg/coredata.CookieCategoryOrderFieldRank"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
input CookieBannerOrder
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieBannerOrderBy"
|
||||||
|
) {
|
||||||
|
direction: OrderDirection!
|
||||||
|
field: CookieBannerOrderField!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CookieCategoryOrder
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieCategoryOrderBy"
|
||||||
|
) {
|
||||||
|
direction: OrderDirection!
|
||||||
|
field: CookieCategoryOrderField!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieBanner implements Node {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
origin: String!
|
||||||
|
state: CookieBannerState!
|
||||||
|
privacyPolicyUrl: String!
|
||||||
|
consentExpiryDays: Int!
|
||||||
|
consentMode: CookieConsentMode!
|
||||||
|
|
||||||
|
organization: Organization! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
categories(
|
||||||
|
first: Int
|
||||||
|
after: CursorKey
|
||||||
|
last: Int
|
||||||
|
before: CursorKey
|
||||||
|
orderBy: CookieCategoryOrder
|
||||||
|
): CookieCategoryConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
latestVersion: CookieBannerVersion @goField(forceResolver: true)
|
||||||
|
|
||||||
|
createdAt: Datetime!
|
||||||
|
updatedAt: Datetime!
|
||||||
|
|
||||||
|
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieCategory implements Node {
|
||||||
|
id: ID!
|
||||||
|
cookieBanner: CookieBanner! @goField(forceResolver: true)
|
||||||
|
name: String!
|
||||||
|
description: String!
|
||||||
|
required: Boolean!
|
||||||
|
rank: Int!
|
||||||
|
cookies: [CookieItem!]!
|
||||||
|
createdAt: Datetime!
|
||||||
|
updatedAt: Datetime!
|
||||||
|
|
||||||
|
permission(action: String!): Boolean! @goField(forceResolver: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieItem {
|
||||||
|
name: String!
|
||||||
|
duration: String!
|
||||||
|
description: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieBannerVersion implements Node {
|
||||||
|
id: ID!
|
||||||
|
version: Int!
|
||||||
|
state: String!
|
||||||
|
createdAt: Datetime!
|
||||||
|
updatedAt: Datetime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieBannerConnection
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieBannerConnection"
|
||||||
|
) {
|
||||||
|
totalCount: Int! @goField(forceResolver: true)
|
||||||
|
edges: [CookieBannerEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieBannerEdge {
|
||||||
|
cursor: CursorKey!
|
||||||
|
node: CookieBanner!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieCategoryConnection
|
||||||
|
@goModel(
|
||||||
|
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieCategoryConnection"
|
||||||
|
) {
|
||||||
|
totalCount: Int! @goField(forceResolver: true)
|
||||||
|
edges: [CookieCategoryEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CookieCategoryEdge {
|
||||||
|
cursor: CursorKey!
|
||||||
|
node: CookieCategory!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createCookieBanner(
|
||||||
|
input: CreateCookieBannerInput!
|
||||||
|
): CreateCookieBannerPayload!
|
||||||
|
updateCookieBanner(
|
||||||
|
input: UpdateCookieBannerInput!
|
||||||
|
): UpdateCookieBannerPayload!
|
||||||
|
deleteCookieBanner(
|
||||||
|
input: DeleteCookieBannerInput!
|
||||||
|
): DeleteCookieBannerPayload!
|
||||||
|
activateCookieBanner(
|
||||||
|
input: ActivateCookieBannerInput!
|
||||||
|
): ActivateCookieBannerPayload!
|
||||||
|
deactivateCookieBanner(
|
||||||
|
input: DeactivateCookieBannerInput!
|
||||||
|
): DeactivateCookieBannerPayload!
|
||||||
|
publishCookieBannerVersion(
|
||||||
|
input: PublishCookieBannerVersionInput!
|
||||||
|
): PublishCookieBannerVersionPayload!
|
||||||
|
createCookieCategory(
|
||||||
|
input: CreateCookieCategoryInput!
|
||||||
|
): CreateCookieCategoryPayload!
|
||||||
|
updateCookieCategory(
|
||||||
|
input: UpdateCookieCategoryInput!
|
||||||
|
): UpdateCookieCategoryPayload!
|
||||||
|
deleteCookieCategory(
|
||||||
|
input: DeleteCookieCategoryInput!
|
||||||
|
): DeleteCookieCategoryPayload!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateCookieBannerInput {
|
||||||
|
organizationId: ID!
|
||||||
|
name: String!
|
||||||
|
origin: String!
|
||||||
|
privacyPolicyUrl: String!
|
||||||
|
consentExpiryDays: Int!
|
||||||
|
consentMode: CookieConsentMode!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateCookieBannerInput {
|
||||||
|
cookieBannerId: ID!
|
||||||
|
name: String
|
||||||
|
origin: String
|
||||||
|
privacyPolicyUrl: String
|
||||||
|
consentExpiryDays: Int
|
||||||
|
consentMode: CookieConsentMode
|
||||||
|
}
|
||||||
|
|
||||||
|
input DeleteCookieBannerInput {
|
||||||
|
cookieBannerId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ActivateCookieBannerInput {
|
||||||
|
cookieBannerId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input DeactivateCookieBannerInput {
|
||||||
|
cookieBannerId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input PublishCookieBannerVersionInput {
|
||||||
|
cookieBannerId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateCookieCategoryInput {
|
||||||
|
cookieBannerId: ID!
|
||||||
|
name: String!
|
||||||
|
description: String!
|
||||||
|
required: Boolean!
|
||||||
|
rank: Int!
|
||||||
|
cookies: [CookieItemInput!]
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateCookieCategoryInput {
|
||||||
|
cookieCategoryId: ID!
|
||||||
|
name: String
|
||||||
|
description: String
|
||||||
|
rank: Int
|
||||||
|
cookies: [CookieItemInput!]
|
||||||
|
}
|
||||||
|
|
||||||
|
input DeleteCookieCategoryInput {
|
||||||
|
cookieCategoryId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CookieItemInput {
|
||||||
|
name: String!
|
||||||
|
duration: String!
|
||||||
|
description: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCookieBannerPayload {
|
||||||
|
cookieBannerEdge: CookieBannerEdge!
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCookieBannerPayload {
|
||||||
|
cookieBanner: CookieBanner!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteCookieBannerPayload {
|
||||||
|
deletedCookieBannerId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivateCookieBannerPayload {
|
||||||
|
cookieBanner: CookieBanner!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeactivateCookieBannerPayload {
|
||||||
|
cookieBanner: CookieBanner!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublishCookieBannerVersionPayload {
|
||||||
|
cookieBannerVersion: CookieBannerVersion!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCookieCategoryPayload {
|
||||||
|
cookieCategoryEdge: CookieCategoryEdge!
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCookieCategoryPayload {
|
||||||
|
cookieCategory: CookieCategory!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteCookieCategoryPayload {
|
||||||
|
deletedCookieCategoryId: ID!
|
||||||
|
}
|
||||||
@@ -306,6 +306,14 @@ type Organization implements Node {
|
|||||||
orderBy: TrustCenterFileOrder
|
orderBy: TrustCenterFileOrder
|
||||||
): TrustCenterFileConnection! @goField(forceResolver: true)
|
): TrustCenterFileConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
cookieBanners(
|
||||||
|
first: Int
|
||||||
|
after: CursorKey
|
||||||
|
last: Int
|
||||||
|
before: CursorKey
|
||||||
|
orderBy: CookieBannerOrder
|
||||||
|
): CookieBannerConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
vendors(
|
vendors(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/accessreview"
|
"go.probo.inc/probo/pkg/accessreview"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
|
"go.probo.inc/probo/pkg/cookiebanner"
|
||||||
"go.probo.inc/probo/pkg/esign"
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/iam"
|
"go.probo.inc/probo/pkg/iam"
|
||||||
"go.probo.inc/probo/pkg/mailman"
|
"go.probo.inc/probo/pkg/mailman"
|
||||||
@@ -29,7 +30,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *esign.Service, accessReviewSvc *accessreview.Service, mailmanSvc *mailman.Service, connectorRegistry *connector.ConnectorRegistry, customDomainCname string, logger *log.Logger) http.Handler {
|
func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *esign.Service, accessReviewSvc *accessreview.Service, mailmanSvc *mailman.Service, cookieBannerSvc *cookiebanner.Service, connectorRegistry *connector.ConnectorRegistry, customDomainCname string, logger *log.Logger) http.Handler {
|
||||||
config := schema.Config{
|
config := schema.Config{
|
||||||
Resolvers: &Resolver{
|
Resolvers: &Resolver{
|
||||||
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
|
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
|
||||||
@@ -38,6 +39,7 @@ func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *e
|
|||||||
esign: esignSvc,
|
esign: esignSvc,
|
||||||
accessReview: accessReviewSvc,
|
accessReview: accessReviewSvc,
|
||||||
mailman: mailmanSvc,
|
mailman: mailmanSvc,
|
||||||
|
cookieBanner: cookieBannerSvc,
|
||||||
connectorRegistry: connectorRegistry,
|
connectorRegistry: connectorRegistry,
|
||||||
customDomainCname: customDomainCname,
|
customDomainCname: customDomainCname,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
|||||||
@@ -1044,6 +1044,37 @@ func (r *organizationResolver) TrustCenterFiles(ctx context.Context, obj *types.
|
|||||||
return types.NewTrustCenterFileConnection(pageResult, obj.ID), nil
|
return types.NewTrustCenterFileConnection(pageResult, obj.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CookieBanners is the resolver for the cookieBanners field.
|
||||||
|
func (r *organizationResolver) CookieBanners(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookieBannerOrderBy) (*types.CookieBannerConnection, error) {
|
||||||
|
if err := r.authorize(ctx, obj.ID, probo.ActionCookieBannerList); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pageOrderBy := page.OrderBy[coredata.CookieBannerOrderField]{
|
||||||
|
Field: coredata.CookieBannerOrderFieldCreatedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
}
|
||||||
|
if orderBy != nil {
|
||||||
|
pageOrderBy = page.OrderBy[coredata.CookieBannerOrderField]{
|
||||||
|
Field: orderBy.Field,
|
||||||
|
Direction: orderBy.Direction,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
scope := coredata.NewScopeFromObjectID(obj.ID)
|
||||||
|
|
||||||
|
banners, err := r.cookieBanner.ListCookieBannersForOrganization(ctx, scope, obj.ID, cursor, coredata.NewCookieBannerFilter(nil))
|
||||||
|
if err != nil {
|
||||||
|
r.logger.ErrorCtx(ctx, "cannot list cookie banners", log.Error(err))
|
||||||
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := page.NewPage(banners, cursor)
|
||||||
|
|
||||||
|
return types.NewCookieBannerConnection(p, r, obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Vendors is the resolver for the vendors field.
|
// Vendors is the resolver for the vendors field.
|
||||||
func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy, filter *types.VendorFilter) (*types.VendorConnection, error) {
|
func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.VendorOrderBy, filter *types.VendorFilter) (*types.VendorConnection, error) {
|
||||||
if err := r.authorize(ctx, obj.ID, probo.ActionVendorList); err != nil {
|
if err := r.authorize(ctx, obj.ID, probo.ActionVendorList); err != nil {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/accessreview"
|
"go.probo.inc/probo/pkg/accessreview"
|
||||||
"go.probo.inc/probo/pkg/baseurl"
|
"go.probo.inc/probo/pkg/baseurl"
|
||||||
"go.probo.inc/probo/pkg/connector"
|
"go.probo.inc/probo/pkg/connector"
|
||||||
|
"go.probo.inc/probo/pkg/cookiebanner"
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/esign"
|
"go.probo.inc/probo/pkg/esign"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
@@ -64,6 +65,7 @@ type (
|
|||||||
esign *esign.Service
|
esign *esign.Service
|
||||||
accessReview *accessreview.Service
|
accessReview *accessreview.Service
|
||||||
mailman *mailman.Service
|
mailman *mailman.Service
|
||||||
|
cookieBanner *cookiebanner.Service
|
||||||
connectorRegistry *connector.ConnectorRegistry
|
connectorRegistry *connector.ConnectorRegistry
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
customDomainCname string
|
customDomainCname string
|
||||||
@@ -77,6 +79,7 @@ func NewMux(
|
|||||||
esignSvc *esign.Service,
|
esignSvc *esign.Service,
|
||||||
accessReviewSvc *accessreview.Service,
|
accessReviewSvc *accessreview.Service,
|
||||||
mailmanSvc *mailman.Service,
|
mailmanSvc *mailman.Service,
|
||||||
|
cookieBannerSvc *cookiebanner.Service,
|
||||||
cookieConfig securecookie.Config,
|
cookieConfig securecookie.Config,
|
||||||
tokenSecret string,
|
tokenSecret string,
|
||||||
connectorRegistry *connector.ConnectorRegistry,
|
connectorRegistry *connector.ConnectorRegistry,
|
||||||
@@ -87,7 +90,17 @@ func NewMux(
|
|||||||
|
|
||||||
safeRedirect := saferedirect.New(saferedirect.StaticHosts(baseURL.Host()))
|
safeRedirect := saferedirect.New(saferedirect.StaticHosts(baseURL.Host()))
|
||||||
|
|
||||||
graphqlHandler := NewGraphQLHandler(iamSvc, proboSvc, esignSvc, accessReviewSvc, mailmanSvc, connectorRegistry, customDomainCname, logger)
|
graphqlHandler := NewGraphQLHandler(
|
||||||
|
iamSvc,
|
||||||
|
proboSvc,
|
||||||
|
esignSvc,
|
||||||
|
accessReviewSvc,
|
||||||
|
mailmanSvc,
|
||||||
|
cookieBannerSvc,
|
||||||
|
connectorRegistry,
|
||||||
|
customDomainCname,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))
|
||||||
|
|||||||
78
pkg/server/api/console/v1/types/cookie_banner.go
Normal file
78
pkg/server/api/console/v1/types/cookie_banner.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
CookieBannerOrderBy OrderBy[coredata.CookieBannerOrderField]
|
||||||
|
|
||||||
|
CookieBannerConnection struct {
|
||||||
|
TotalCount int
|
||||||
|
Edges []*CookieBannerEdge
|
||||||
|
PageInfo PageInfo
|
||||||
|
|
||||||
|
Resolver any
|
||||||
|
ParentID gid.GID
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCookieBannerConnection(
|
||||||
|
p *page.Page[*coredata.CookieBanner, coredata.CookieBannerOrderField],
|
||||||
|
parentType any,
|
||||||
|
parentID gid.GID,
|
||||||
|
) *CookieBannerConnection {
|
||||||
|
var edges = make([]*CookieBannerEdge, len(p.Data))
|
||||||
|
|
||||||
|
for i := range edges {
|
||||||
|
edges[i] = NewCookieBannerEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CookieBannerConnection{
|
||||||
|
Edges: edges,
|
||||||
|
PageInfo: *NewPageInfo(p),
|
||||||
|
|
||||||
|
Resolver: parentType,
|
||||||
|
ParentID: parentID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCookieBannerEdge(b *coredata.CookieBanner, orderBy coredata.CookieBannerOrderField) *CookieBannerEdge {
|
||||||
|
return &CookieBannerEdge{
|
||||||
|
Cursor: b.CursorKey(orderBy),
|
||||||
|
Node: NewCookieBanner(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCookieBanner(b *coredata.CookieBanner) *CookieBanner {
|
||||||
|
return &CookieBanner{
|
||||||
|
ID: b.ID,
|
||||||
|
Organization: &Organization{
|
||||||
|
ID: b.OrganizationID,
|
||||||
|
},
|
||||||
|
Name: b.Name,
|
||||||
|
Origin: b.Origin,
|
||||||
|
State: b.State,
|
||||||
|
PrivacyPolicyURL: b.PrivacyPolicyURL,
|
||||||
|
ConsentExpiryDays: b.ConsentExpiryDays,
|
||||||
|
ConsentMode: b.ConsentMode,
|
||||||
|
CreatedAt: b.CreatedAt,
|
||||||
|
UpdatedAt: b.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
86
pkg/server/api/console/v1/types/cookie_category.go
Normal file
86
pkg/server/api/console/v1/types/cookie_category.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
CookieCategoryOrderBy OrderBy[coredata.CookieCategoryOrderField]
|
||||||
|
|
||||||
|
CookieCategoryConnection struct {
|
||||||
|
TotalCount int
|
||||||
|
Edges []*CookieCategoryEdge
|
||||||
|
PageInfo PageInfo
|
||||||
|
|
||||||
|
Resolver any
|
||||||
|
ParentID gid.GID
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCookieCategoryConnection(
|
||||||
|
p *page.Page[*coredata.CookieCategory, coredata.CookieCategoryOrderField],
|
||||||
|
parentType any,
|
||||||
|
parentID gid.GID,
|
||||||
|
) *CookieCategoryConnection {
|
||||||
|
var edges = make([]*CookieCategoryEdge, len(p.Data))
|
||||||
|
|
||||||
|
for i := range edges {
|
||||||
|
edges[i] = NewCookieCategoryEdge(p.Data[i], p.Cursor.OrderBy.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CookieCategoryConnection{
|
||||||
|
Edges: edges,
|
||||||
|
PageInfo: *NewPageInfo(p),
|
||||||
|
|
||||||
|
Resolver: parentType,
|
||||||
|
ParentID: parentID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCookieCategoryEdge(c *coredata.CookieCategory, orderBy coredata.CookieCategoryOrderField) *CookieCategoryEdge {
|
||||||
|
return &CookieCategoryEdge{
|
||||||
|
Cursor: c.CursorKey(orderBy),
|
||||||
|
Node: NewCookieCategory(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCookieCategory(c *coredata.CookieCategory) *CookieCategory {
|
||||||
|
cookies := make([]*CookieItem, len(c.Cookies))
|
||||||
|
for i, cookie := range c.Cookies {
|
||||||
|
cookies[i] = &CookieItem{
|
||||||
|
Name: cookie.Name,
|
||||||
|
Duration: cookie.Duration,
|
||||||
|
Description: cookie.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CookieCategory{
|
||||||
|
ID: c.ID,
|
||||||
|
CookieBanner: &CookieBanner{
|
||||||
|
ID: c.CookieBannerID,
|
||||||
|
},
|
||||||
|
Name: c.Name,
|
||||||
|
Description: c.Description,
|
||||||
|
Required: c.Required,
|
||||||
|
Rank: c.Rank,
|
||||||
|
Cookies: cookies,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
UpdatedAt: c.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,10 +46,12 @@ func NewMux(
|
|||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewMux()
|
r := chi.NewMux()
|
||||||
r.Use(newCORSMiddleware(logger, cookieBannerSvc))
|
r.Route("/{bannerID}", func(r chi.Router) {
|
||||||
r.Get("/{bannerID}/config", h.handleGetConfig)
|
r.Use(newCORSMiddleware(logger, cookieBannerSvc))
|
||||||
r.Get("/{bannerID}/consents/{visitorID}", h.handleGetConsent)
|
r.Get("/config", h.handleGetConfig)
|
||||||
r.Post("/{bannerID}/consents", h.handlePostConsent)
|
r.Get("/consents/{visitorID}", h.handleGetConsent)
|
||||||
|
r.Post("/consents", h.handlePostConsent)
|
||||||
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user