Add UX for cookie banner management

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-20 10:48:11 +04:00
parent 6c5c1fa818
commit 1ec8e475de
37 changed files with 3263 additions and 8 deletions

View File

@@ -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) {
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) {

View File

@@ -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) {
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(

View File

@@ -377,4 +377,25 @@ const (
ActionAccessSourceUpdate = "core:access-source:update"
ActionAccessSourceDelete = "core:access-source:delete"
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"
)

View File

@@ -84,6 +84,9 @@ var ViewerPolicy = policy.NewPolicy(
ActionAccessReviewCampaignGet, ActionAccessReviewCampaignList,
ActionAccessEntryGet, ActionAccessEntryList,
ActionAccessSourceGet, ActionAccessSourceList,
ActionCookieBannerGet, ActionCookieBannerList,
ActionCookieBannerVersionGet, ActionCookieBannerVersionList,
ActionCookieCategoryGet, ActionCookieCategoryList,
).WithSID("entity-read-access").When(organizationCondition),
policy.Allow(

View File

@@ -49,6 +49,7 @@ import (
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/certmanager"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"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)
cookieBannerService := cookiebanner.NewService(pgClient)
proboService, err := probo.NewService(
ctx,
encryptionKey,
@@ -582,6 +585,7 @@ func (impl *Implm) Run(
ESign: esignService,
AccessReview: accessReviewService,
Mailman: mailmanService,
CookieBanner: cookieBannerService,
Slack: slackService,
ConnectorRegistry: defaultConnectorRegistry,
BaseURL: baseURL,

View File

@@ -182,6 +182,7 @@ func NewServer(cfg Config) (*Server, error) {
cfg.ESign,
cfg.AccessReview,
cfg.Mailman,
cfg.CookieBanner,
cfg.Cookie,
cfg.TokenSecret,
cfg.ConnectorRegistry,

View File

@@ -332,6 +332,42 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
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:
}

View 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 }

View 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!
}

View File

@@ -306,6 +306,14 @@ type Organization implements Node {
orderBy: TrustCenterFileOrder
): TrustCenterFileConnection! @goField(forceResolver: true)
cookieBanners(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: CookieBannerOrder
): CookieBannerConnection! @goField(forceResolver: true)
vendors(
first: Int
after: CursorKey

View File

@@ -20,6 +20,7 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailman"
@@ -29,7 +30,7 @@ import (
"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{
Resolvers: &Resolver{
authorize: authz.NewAuthorizeFunc(iamSvc, logger),
@@ -38,6 +39,7 @@ func NewGraphQLHandler(iamSvc *iam.Service, proboSvc *probo.Service, esignSvc *e
esign: esignSvc,
accessReview: accessReviewSvc,
mailman: mailmanSvc,
cookieBanner: cookieBannerSvc,
connectorRegistry: connectorRegistry,
customDomainCname: customDomainCname,
logger: logger,

View File

@@ -1044,6 +1044,37 @@ func (r *organizationResolver) TrustCenterFiles(ctx context.Context, obj *types.
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.
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 {

View File

@@ -42,6 +42,7 @@ import (
"go.probo.inc/probo/pkg/accessreview"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/cookiebanner"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid"
@@ -64,6 +65,7 @@ type (
esign *esign.Service
accessReview *accessreview.Service
mailman *mailman.Service
cookieBanner *cookiebanner.Service
connectorRegistry *connector.ConnectorRegistry
logger *log.Logger
customDomainCname string
@@ -77,6 +79,7 @@ func NewMux(
esignSvc *esign.Service,
accessReviewSvc *accessreview.Service,
mailmanSvc *mailman.Service,
cookieBannerSvc *cookiebanner.Service,
cookieConfig securecookie.Config,
tokenSecret string,
connectorRegistry *connector.ConnectorRegistry,
@@ -87,7 +90,17 @@ func NewMux(
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.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig))

View 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,
}
}

View 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,
}
}

View File

@@ -46,10 +46,12 @@ func NewMux(
}
r := chi.NewMux()
r.Use(newCORSMiddleware(logger, cookieBannerSvc))
r.Get("/{bannerID}/config", h.handleGetConfig)
r.Get("/{bannerID}/consents/{visitorID}", h.handleGetConsent)
r.Post("/{bannerID}/consents", h.handlePostConsent)
r.Route("/{bannerID}", func(r chi.Router) {
r.Use(newCORSMiddleware(logger, cookieBannerSvc))
r.Get("/config", h.handleGetConfig)
r.Get("/consents/{visitorID}", h.handleGetConsent)
r.Post("/consents", h.handlePostConsent)
})
return r
}