Address PR review on data request pages

Require a verified viewer email before creating a rights request and
validate the free-text fields with the same SafeText bounds the console
uses, so this public portal mutation stays safe and bounded.

Move myRightsRequests onto the base Query, drop the now-dead count
loaders, and order the RECTIFICATION enum value before PORTABILITY so
the Postgres sort order matches RightsRequestTypes().

Harden the v2 kit primitives: SegmentedControl keeps equal-width cards
(auto-fill), preserves its selection when the active card is toggled,
and forwards an accessible name; Field associates its label and error
by id/aria instead of wrapping the control in a label. Give the type
group an accessible name, require the name field for non-complaint
types, use a timezone-stable reference year, drop the underreporting
header count, and neutralize the response-deadline copy.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-20 09:47:55 +02:00
parent 6623cbc6f2
commit 622f1ba67d
16 changed files with 156 additions and 146 deletions

View File

@@ -18,7 +18,9 @@
-- 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';
-- Insert RECTIFICATION before PORTABILITY so the enum's sort order matches the
-- canonical RightsRequestTypes() ordering used for type-sorted cursors.
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'RECTIFICATION' BEFORE 'PORTABILITY';
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'OBJECTION';
ALTER TYPE rights_request_type ADD VALUE IF NOT EXISTS 'COMPLAINT';

View File

@@ -240,44 +240,6 @@ 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,

View File

@@ -14,6 +14,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mailman"
"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/schema"
@@ -304,6 +305,38 @@ func (r *queryResolver) OidcProviders(ctx context.Context) ([]*types.OIDCProvide
return result, 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
}
// Mutation returns schema.MutationResolver implementation.
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }

View File

@@ -30,6 +30,16 @@ type Query {
oidcProviders: [OIDCProviderInfo!]!
@goField(forceResolver: true)
@authentication(required: OPTIONAL)
# 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! @goField(forceResolver: true)
}
type OIDCProviderInfo {

View File

@@ -52,18 +52,6 @@ type RightsRequestEdge {
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.

View File

@@ -10,7 +10,6 @@ import (
"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"
@@ -21,8 +20,10 @@ import (
// 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")
if identity == nil || !identity.EmailAddressVerified {
// The request is attributed to the viewer's email, so the email must be
// verified — an authenticated-but-unverified identity is not enough.
return nil, gqlutils.Unauthenticatedf(ctx, "a verified email is required to submit a request")
}
compliancePage := compliancepage.CompliancePageFromContext(ctx)
@@ -51,35 +52,3 @@ func (r *mutationResolver) CreateRightsRequest(ctx context.Context, input types.
),
}, 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

@@ -29,6 +29,8 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/validator"
)
// RightsRequestDeadlineDays is the number of days a portal-submitted data
@@ -54,11 +56,26 @@ type (
}
)
// Validate bounds the free-text fields with the same rules the console applies,
// so this public portal mutation can't persist oversized or unsafe input.
func (r *CreateRightsRequest) Validate() error {
v := validator.New()
v.Check(r.DataSubject, "data_subject", validator.SafeText(probo.ContentMaxLength))
v.Check(r.Details, "details", validator.SafeText(probo.ContentMaxLength))
return v.Error()
}
func (s *RightsRequestService) Create(
ctx context.Context,
scope coredata.Scoper,
req *CreateRightsRequest,
) (*coredata.RightsRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
now := time.Now()
deadline := now.AddDate(0, 0, RightsRequestDeadlineDays)
@@ -97,34 +114,6 @@ func (s *RightsRequestService) Create(
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,