Add data request pages to compliance portal

Let trust-portal data subjects submit and track GDPR/CCPA rights
requests. The new Data Requests page lists the viewer's own requests
and a dialog submits new ones, scoped server-side to the verified
viewer email so former or inactive users can still exercise their
rights. Submission requires magic-link sign-in (reusing the existing
gate) but not the NDA gate.

Extend the shared rights_request enums with RECTIFICATION, OBJECTION
and COMPLAINT types plus a REJECTED state, and keep the console
GraphQL, @probo/helpers and the MCP specification in sync. Expose a
trust GraphQL surface (myRightsRequests query, createRightsRequest
mutation) backed by a trust service and contact-scoped coredata
loaders.

Add the missing v2 UI kit primitives the dialog needs on top of Base
UI: a SegmentedControl radio-cards group, a form Textarea, and a
Field wrapper.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-17 17:57:35 +02:00
parent e7ebab0d58
commit 6623cbc6f2
36 changed files with 1820 additions and 70 deletions

View File

@@ -0,0 +1,25 @@
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy
-- of this software and associated documentation files (the "Software"), to deal
-- in the Software without restriction, including without limitation the rights
-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-- copies of the Software, and to permit persons to whom the Software is
-- furnished to do so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in
-- all copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'RECTIFICATION';
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'OBJECTION';
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'COMPLAINT';
ALTER TYPE rights_request_state ADD VALUE IF NOT EXISTS 'REJECTED';

View File

@@ -31,6 +31,7 @@ const (
RightsRequestStateTodo RightsRequestState = "TODO"
RightsRequestStateInProgress RightsRequestState = "IN_PROGRESS"
RightsRequestStateDone RightsRequestState = "DONE"
RightsRequestStateRejected RightsRequestState = "REJECTED"
)
var (
@@ -44,6 +45,7 @@ func RightsRequestStates() []RightsRequestState {
RightsRequestStateTodo,
RightsRequestStateInProgress,
RightsRequestStateDone,
RightsRequestStateRejected,
}
}
@@ -52,7 +54,8 @@ func (v RightsRequestState) IsValid() bool {
case
RightsRequestStateTodo,
RightsRequestStateInProgress,
RightsRequestStateDone:
RightsRequestStateDone,
RightsRequestStateRejected:
return true
}

View File

@@ -28,9 +28,12 @@ import (
type RightsRequestType string
const (
RightsRequestTypeAccess RightsRequestType = "ACCESS"
RightsRequestTypeDeletion RightsRequestType = "DELETION"
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
RightsRequestTypeAccess RightsRequestType = "ACCESS"
RightsRequestTypeDeletion RightsRequestType = "DELETION"
RightsRequestTypeRectification RightsRequestType = "RECTIFICATION"
RightsRequestTypePortability RightsRequestType = "PORTABILITY"
RightsRequestTypeObjection RightsRequestType = "OBJECTION"
RightsRequestTypeComplaint RightsRequestType = "COMPLAINT"
)
var (
@@ -43,7 +46,10 @@ func RightsRequestTypes() []RightsRequestType {
return []RightsRequestType{
RightsRequestTypeAccess,
RightsRequestTypeDeletion,
RightsRequestTypeRectification,
RightsRequestTypePortability,
RightsRequestTypeObjection,
RightsRequestTypeComplaint,
}
}
@@ -52,7 +58,10 @@ func (v RightsRequestType) IsValid() bool {
case
RightsRequestTypeAccess,
RightsRequestTypeDeletion,
RightsRequestTypePortability:
RightsRequestTypeRectification,
RightsRequestTypePortability,
RightsRequestTypeObjection,
RightsRequestTypeComplaint:
return true
}

View File

@@ -240,6 +240,98 @@ WHERE
return nil
}
func (rrs *RightsRequests) CountByOrganizationIDAndContact(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
contact string,
) (int, error) {
q := `
SELECT
COUNT(id)
FROM
rights_requests
WHERE
%s
AND organization_id = @organization_id
AND contact = @contact
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"contact": contact,
}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count rights requests: %w", err)
}
return count, nil
}
func (rrs *RightsRequests) LoadByOrganizationIDAndContact(
ctx context.Context,
conn pg.Querier,
scope Scoper,
organizationID gid.GID,
contact string,
cursor *page.Cursor[RightsRequestOrderField],
) error {
q := `
SELECT
id,
organization_id,
request_type,
request_state,
data_subject,
contact,
details,
deadline,
action_taken,
created_at,
updated_at
FROM
rights_requests
WHERE
%s
AND organization_id = @organization_id
AND contact = @contact
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"contact": contact,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query rights requests: %w", err)
}
requests, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RightsRequest])
if err != nil {
return fmt.Errorf("cannot collect rights requests: %w", err)
}
*rrs = requests
return nil
}
func (rr *RightsRequest) Insert(
ctx context.Context,
conn pg.Tx,

View File

@@ -8,10 +8,22 @@ enum RightsRequestType
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion"
)
RECTIFICATION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
)
PORTABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
)
OBJECTION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeObjection"
)
COMPLAINT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeComplaint"
)
}
enum RightsRequestState
@@ -24,6 +36,10 @@ enum RightsRequestState
)
DONE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
REJECTED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected"
)
}
enum RightsRequestOrderField

View File

@@ -8489,7 +8489,10 @@ components:
enum:
- ACCESS
- DELETION
- RECTIFICATION
- PORTABILITY
- OBJECTION
- COMPLAINT
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestType
RightsRequestState:
@@ -8498,6 +8501,7 @@ components:
- TODO
- IN_PROGRESS
- DONE
- REJECTED
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.RightsRequestState
RightsRequestOrderField:

View File

@@ -0,0 +1,83 @@
enum RightsRequestType
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestType") {
ACCESS @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeAccess")
DELETION
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeDeletion")
RECTIFICATION
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeRectification"
)
PORTABILITY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypePortability"
)
OBJECTION
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeObjection")
COMPLAINT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestTypeComplaint")
}
enum RightsRequestState
@goModel(model: "go.probo.inc/probo/pkg/coredata.RightsRequestState") {
TODO @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateTodo")
IN_PROGRESS
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateInProgress"
)
DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateDone")
REJECTED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.RightsRequestStateRejected")
}
type RightsRequest implements Node {
id: ID!
requestType: RightsRequestType!
requestState: RightsRequestState!
dataSubject: String
contact: String
details: String
deadline: Datetime
actionTaken: String
createdAt: Datetime!
updatedAt: Datetime!
}
type RightsRequestConnection {
edges: [RightsRequestEdge!]!
pageInfo: PageInfo!
}
type RightsRequestEdge {
cursor: CursorKey!
node: RightsRequest!
}
extend type Query {
# The current viewer's own data subject requests for this trust center,
# scoped by their verified email. Returns an empty connection for guests so
# the portal can still render its empty state.
myRightsRequests(
first: Int
after: CursorKey
last: Int
before: CursorKey
): RightsRequestConnection!
}
extend type Mutation {
# Submit a data subject request. Requires a verified viewer; the request is
# attributed to the viewer's email, so no NDA gate applies.
createRightsRequest(
input: CreateRightsRequestInput!
): CreateRightsRequestPayload! @authentication(required: PRESENT)
}
input CreateRightsRequestInput {
requestType: RightsRequestType!
dataSubject: String
details: String
}
type CreateRightsRequestPayload {
rightsRequestEdge: RightsRequestEdge!
}

View File

@@ -0,0 +1,85 @@
package trust_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.93
import (
"context"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/compliancepage"
"go.probo.inc/probo/pkg/server/api/trust/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
"go.probo.inc/probo/pkg/trust"
)
// CreateRightsRequest is the resolver for the createRightsRequest field.
func (r *mutationResolver) CreateRightsRequest(ctx context.Context, input types.CreateRightsRequestInput) (*types.CreateRightsRequestPayload, error) {
identity := authn.IdentityFromContext(ctx)
if identity == nil {
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to submit a request")
}
compliancePage := compliancepage.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
rightsRequest, err := r.trust.RightsRequests.Create(
ctx,
scope,
&trust.CreateRightsRequest{
OrganizationID: compliancePage.OrganizationID,
RequestType: input.RequestType,
DataSubject: input.DataSubject,
Contact: identity.EmailAddress.String(),
Details: input.Details,
},
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot create rights request", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return &types.CreateRightsRequestPayload{
RightsRequestEdge: types.NewRightsRequestEdge(
rightsRequest,
coredata.RightsRequestOrderFieldCreatedAt,
),
}, nil
}
// MyRightsRequests is the resolver for the myRightsRequests field.
func (r *queryResolver) MyRightsRequests(ctx context.Context, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.RightsRequestConnection, error) {
pageOrderBy := page.OrderBy[coredata.RightsRequestOrderField]{
Field: coredata.RightsRequestOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
identity := authn.IdentityFromContext(ctx)
if identity == nil {
emptyPage := page.NewPage([]*coredata.RightsRequest{}, cursor)
return types.NewRightsRequestConnection(emptyPage), nil
}
compliancePage := compliancepage.CompliancePageFromContext(ctx)
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
result, err := r.trust.RightsRequests.ListForOrganizationIDAndContact(
ctx,
scope,
compliancePage.OrganizationID,
identity.EmailAddress.String(),
cursor,
)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list rights requests", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewRightsRequestConnection(result), nil
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/page"
)
func NewRightsRequest(rr *coredata.RightsRequest) *RightsRequest {
return &RightsRequest{
ID: rr.ID,
RequestType: rr.RequestType,
RequestState: rr.RequestState,
DataSubject: rr.DataSubject,
Contact: rr.Contact,
Details: rr.Details,
Deadline: rr.Deadline,
ActionTaken: rr.ActionTaken,
CreatedAt: rr.CreatedAt,
UpdatedAt: rr.UpdatedAt,
}
}
func NewRightsRequestEdge(
rr *coredata.RightsRequest,
orderBy coredata.RightsRequestOrderField,
) *RightsRequestEdge {
return &RightsRequestEdge{
Cursor: rr.CursorKey(orderBy),
Node: NewRightsRequest(rr),
}
}
func NewRightsRequestConnection(
p *page.Page[*coredata.RightsRequest, coredata.RightsRequestOrderField],
) *RightsRequestConnection {
edges := make([]*RightsRequestEdge, len(p.Data))
for i, item := range p.Data {
edges[i] = NewRightsRequestEdge(item, p.Cursor.OrderBy.Field)
}
return &RightsRequestConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package trust
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
// RightsRequestDeadlineDays is the number of days a portal-submitted data
// subject request is given before its response deadline. Thirty days matches
// the GDPR Article 12(3) one-month standard (the shorter of GDPR / CCPA), and
// the console can adjust it afterwards.
const RightsRequestDeadlineDays = 30
type (
RightsRequestService struct {
svc *Service
}
// CreateRightsRequest is a data subject request submitted from the trust
// portal. The organization comes from the current compliance page and the
// contact from the verified viewer's identity, so neither is client-supplied.
CreateRightsRequest struct {
OrganizationID gid.GID
RequestType coredata.RightsRequestType
DataSubject *string
Contact string
Details *string
}
)
func (s *RightsRequestService) Create(
ctx context.Context,
scope coredata.Scoper,
req *CreateRightsRequest,
) (*coredata.RightsRequest, error) {
now := time.Now()
deadline := now.AddDate(0, 0, RightsRequestDeadlineDays)
request := &coredata.RightsRequest{
ID: gid.New(scope.GetTenantID(), coredata.RightsRequestEntityType),
OrganizationID: req.OrganizationID,
RequestType: req.RequestType,
RequestState: coredata.RightsRequestStateTodo,
DataSubject: req.DataSubject,
Contact: &req.Contact,
Details: req.Details,
Deadline: &deadline,
CreatedAt: now,
UpdatedAt: now,
}
err := s.svc.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
if err := request.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert rights request: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return request, nil
}
func (s RightsRequestService) CountForOrganizationIDAndContact(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
contact string,
) (int, error) {
var count int
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) (err error) {
requests := coredata.RightsRequests{}
count, err = requests.CountByOrganizationIDAndContact(ctx, conn, scope, organizationID, contact)
if err != nil {
return fmt.Errorf("cannot count rights requests: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s RightsRequestService) ListForOrganizationIDAndContact(
ctx context.Context,
scope coredata.Scoper,
organizationID gid.GID,
contact string,
cursor *page.Cursor[coredata.RightsRequestOrderField],
) (*page.Page[*coredata.RightsRequest, coredata.RightsRequestOrderField], error) {
var requests coredata.RightsRequests
err := s.svc.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
err := requests.LoadByOrganizationIDAndContact(ctx, conn, scope, organizationID, contact, cursor)
if err != nil {
return fmt.Errorf("cannot load rights requests: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(requests, cursor), nil
}

View File

@@ -73,6 +73,7 @@ type (
Reports *ReportService
Organizations *OrganizationService
ComplianceExternalURLs *ComplianceExternalURLService
RightsRequests *RightsRequestService
resourceAlias *resourcealias.Service
}
)
@@ -119,6 +120,7 @@ func NewService(
svc.Reports = &ReportService{svc: svc}
svc.Organizations = &OrganizationService{svc: svc}
svc.ComplianceExternalURLs = &ComplianceExternalURLService{svc: svc}
svc.RightsRequests = &RightsRequestService{svc: svc}
return svc
}