diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_components/APIKeyConnectorDialog.tsx b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/APIKeyConnectorDialog.tsx index 8e852ab94..d94a0b884 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/_components/APIKeyConnectorDialog.tsx +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_components/APIKeyConnectorDialog.tsx @@ -26,6 +26,8 @@ import { DialogContent, DialogFooter, Field, + Option, + Select, useDialogRef, useToast, } from "@probo/ui"; @@ -248,7 +250,8 @@ export function APIKeyConnectorDialog({ }; // PostHog renders a dedicated deployment selector (Cloud region or - // self-hosted URL); every other provider falls back to generic fields. + // self-hosted URL) and Segment a region selector; every other provider falls + // back to generic fields. const renderAPIKeyExtraSettings = () => { if (!provider) { return null; @@ -263,6 +266,25 @@ export function APIKeyConnectorDialog({ ); } + // The server maps this to a regional API host and rejects anything outside + // the allow-list, so it must not be typed by hand. + if (provider.provider === "SEGMENT") { + return ( +
+ + +
+ ); + } + return provider.extraSettings.map((setting) => { const value = extraSettingValues[setting.key] ?? ""; return ( diff --git a/pkg/accessreview/drivers/segment.go b/pkg/accessreview/drivers/segment.go index 99e71acfb..6fb7584b5 100644 --- a/pkg/accessreview/drivers/segment.go +++ b/pkg/accessreview/drivers/segment.go @@ -116,6 +116,7 @@ func (d *SegmentDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro } records := make([]AccountRecord, 0, len(users)) + seen := make(map[string]struct{}, len(users)) for _, u := range users { email := strings.TrimSpace(u.Email) @@ -123,9 +124,11 @@ func (d *SegmentDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro continue } + seen[strings.ToLower(email)] = struct{}{} + perms, err := d.userPermissions(ctx, base, u.ID) if err != nil { - return nil, err + return nil, fmt.Errorf("cannot list segment permissions for user %q: %w", u.ID, err) } roles, isAdmin := segmentRolesAndAdmin(perms) @@ -157,15 +160,22 @@ func (d *SegmentDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro continue } + // An invite that has already been accepted can still be listed; the + // member record above is the authoritative one, so skip the duplicate + // rather than emit two rows for the same person. + if _, ok := seen[strings.ToLower(email)]; ok { + continue + } + // A pending invite carries no role and no stable id at the workspace // level, so it is surfaced as an inactive member keyed by email. - inactive := false + active := false records = append(records, AccountRecord{ Email: email, FullName: email, Roles: []string{}, - Active: &inactive, + Active: &active, MFAStatus: coredata.MFAStatusUnknown, AuthMethod: coredata.AccessReviewEntryAuthMethodUnknown, AccountType: coredata.AccessReviewEntryAccountTypeUser, diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go index 28783362b..db49ef0ef 100644 --- a/pkg/connector/provider/probe.go +++ b/pkg/connector/provider/probe.go @@ -636,8 +636,11 @@ func buildSegmentProbeURL(conn *coredata.Connector) (string, error) { return "", fmt.Errorf("cannot parse segment base URL: %w", err) } + q := url.Values{} + q.Set("pagination.count", "1") + u.Path = "/users" - u.RawQuery = "pagination.count=1" + u.RawQuery = q.Encode() return u.String(), nil } diff --git a/pkg/connector/provider/segment.go b/pkg/connector/provider/segment.go index 7cda2ba95..5ad243d54 100644 --- a/pkg/connector/provider/segment.go +++ b/pkg/connector/provider/segment.go @@ -43,7 +43,7 @@ func segmentRegistration() *Registration { // as an extra setting and resolved to a base URL (Pattern 3 + region); // there is nothing to pick. ExtraSettings: []ExtraSetting{ - {Key: "region", Label: "Region (US or EU)", Required: true}, + {Key: "region", Label: "Region", Required: true}, }, BuildProbeURL: buildSegmentProbeURL, // No NewNameResolver: the Public API exposes no read-only workspace-name diff --git a/pkg/server/api/console/v1/segment_test.go b/pkg/server/api/console/v1/segment_test.go new file mode 100644 index 000000000..0b2017905 --- /dev/null +++ b/pkg/server/api/console/v1/segment_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 console_v1 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/server/api/console/v1/types" +) + +// The Segment region is the only API-key setting resolved to a derived value +// rather than stored verbatim, so the region -> host mapping is pinned here: +// a typo in either host would otherwise only surface as a live 404. +func TestApiKeyConnectorSettings_SegmentRegion(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + region string + baseURL string + }{ + {name: "US", region: "US", baseURL: "https://api.segmentapis.com"}, + {name: "EU", region: "EU", baseURL: "https://eu1.api.segmentapis.com"}, + {name: "lowercase", region: "eu", baseURL: "https://eu1.api.segmentapis.com"}, + {name: "padded", region: " us ", baseURL: "https://api.segmentapis.com"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + raw, err := apiKeyConnectorSettings(types.CreateAPIKeyConnectorInput{ + Provider: coredata.ConnectorProviderSegment, + SegmentRegion: &tc.region, + }) + require.NoError(t, err) + + var settings coredata.SegmentConnectorSettings + require.NoError(t, json.Unmarshal(raw, &settings)) + assert.Equal(t, tc.baseURL, settings.BaseURL) + }) + } +} + +func TestApiKeyConnectorSettings_SegmentRejectsUnknownRegion(t *testing.T) { + t.Parallel() + + for _, name := range []string{"empty", "whitespace", "apac", "eu1", "host"} { + region := map[string]string{ + "empty": "", + "whitespace": " ", + "apac": "APAC", + // The region Segment's own UI shows for the EU workspace; it must + // not silently fall through to a wrong host. + "eu1": "EU1", + "host": "https://eu1.api.segmentapis.com", + }[name] + + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := apiKeyConnectorSettings(types.CreateAPIKeyConnectorInput{ + Provider: coredata.ConnectorProviderSegment, + SegmentRegion: ®ion, + }) + require.Error(t, err) + }) + } +} + +func TestApiKeyConnectorSettings_SegmentRequiresRegion(t *testing.T) { + t.Parallel() + + _, err := apiKeyConnectorSettings(types.CreateAPIKeyConnectorInput{ + Provider: coredata.ConnectorProviderSegment, + }) + require.Error(t, err) +}