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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user