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,