From 8f6ecd9f816d7db35f3bbc2a404ee814f92ec780 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?=
<81782+aureliensibiril@users.noreply.github.com>
Date: Sun, 17 May 2026 17:22:48 +0200
Subject: [PATCH] =?UTF-8?q?Wire=20post-OAuth=20pickers=20and=202-auto=20ca?=
=?UTF-8?q?llback=20handling=20=E2=86=92=20Track=20e2e=20gap=20for=20new?=
=?UTF-8?q?=20access-review=20connectors?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Wire post-OAuth pickers and 2-auto callback handling
- Add 13 vendor logo components for new connectors
- Wire access-review connectors into bootstrap config
- Track e2e gap for new access-review connectors
Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
---
.env.example | 27 ++
.../probo/values-production.yaml.example | 15 +
e2e/console/access_review_test.go | 8 +
packages/ui/src/Atoms/ThirdParties/Asana.tsx | 12 +
.../ui/src/Atoms/ThirdParties/Bitbucket.tsx | 12 +
.../ui/src/Atoms/ThirdParties/ClickUp.tsx | 16 +
packages/ui/src/Atoms/ThirdParties/Deel.tsx | 12 +
packages/ui/src/Atoms/ThirdParties/GitLab.tsx | 12 +
packages/ui/src/Atoms/ThirdParties/Heroku.tsx | 12 +
packages/ui/src/Atoms/ThirdParties/Lever.tsx | 12 +
packages/ui/src/Atoms/ThirdParties/Monday.tsx | 12 +
.../ui/src/Atoms/ThirdParties/Netlify.tsx | 12 +
.../ui/src/Atoms/ThirdParties/PagerDuty.tsx | 12 +
packages/ui/src/Atoms/ThirdParties/Ramp.tsx | 12 +
packages/ui/src/Atoms/ThirdParties/Snyk.tsx | 12 +
.../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 30 +-
packages/ui/src/Atoms/ThirdParties/Vercel.tsx | 9 +
packages/ui/src/Atoms/ThirdParties/index.ts | 13 +
pkg/bootstrap/builder.go | 42 +++
pkg/bootstrap/builder_test.go | 58 +++
pkg/probo/connector_service.go | 10 +
.../v1/access_review_campaign_resolvers.go | 128 +++++++
.../api/console/v1/provider_organizations.go | 343 ++++++++++++++++++
pkg/server/api/console/v1/resolver.go | 92 ++++-
24 files changed, 913 insertions(+), 10 deletions(-)
create mode 100644 packages/ui/src/Atoms/ThirdParties/Asana.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/ClickUp.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Deel.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/GitLab.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Heroku.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Lever.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Monday.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Netlify.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Ramp.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Snyk.tsx
create mode 100644 packages/ui/src/Atoms/ThirdParties/Vercel.tsx
diff --git a/.env.example b/.env.example
index c096c8a7b..9aab20142 100644
--- a/.env.example
+++ b/.env.example
@@ -98,6 +98,33 @@
# CONNECTOR_DOCUSIGN_CLIENT_SECRET=
# CONNECTOR_BREX_CLIENT_ID=
# CONNECTOR_BREX_CLIENT_SECRET=
+# CONNECTOR_GITLAB_CLIENT_ID=
+# CONNECTOR_GITLAB_CLIENT_SECRET=
+# CONNECTOR_BITBUCKET_CLIENT_ID=
+# CONNECTOR_BITBUCKET_CLIENT_SECRET=
+# CONNECTOR_HEROKU_CLIENT_ID=
+# CONNECTOR_HEROKU_CLIENT_SECRET=
+# CONNECTOR_PAGERDUTY_CLIENT_ID=
+# CONNECTOR_PAGERDUTY_CLIENT_SECRET=
+# CONNECTOR_ASANA_CLIENT_ID=
+# CONNECTOR_ASANA_CLIENT_SECRET=
+# CONNECTOR_SNYK_CLIENT_ID=
+# CONNECTOR_SNYK_CLIENT_SECRET=
+# CONNECTOR_NETLIFY_CLIENT_ID=
+# CONNECTOR_NETLIFY_CLIENT_SECRET=
+# CONNECTOR_RAMP_CLIENT_ID=
+# CONNECTOR_RAMP_CLIENT_SECRET=
+# CONNECTOR_CLICKUP_CLIENT_ID=
+# CONNECTOR_CLICKUP_CLIENT_SECRET=
+# CONNECTOR_VERCEL_CLIENT_ID=
+# CONNECTOR_VERCEL_CLIENT_SECRET=
+# CONNECTOR_VERCEL_INTEGRATION_SLUG=
+# CONNECTOR_MONDAY_CLIENT_ID=
+# CONNECTOR_MONDAY_CLIENT_SECRET=
+# CONNECTOR_LEVER_CLIENT_ID=
+# CONNECTOR_LEVER_CLIENT_SECRET=
+# CONNECTOR_DEEL_CLIENT_ID=
+# CONNECTOR_DEEL_CLIENT_SECRET=
# ── Custom domains (Pebble ACME via compose) ──────────────────────────
# CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com
diff --git a/contrib/helm/charts/probo/values-production.yaml.example b/contrib/helm/charts/probo/values-production.yaml.example
index 6e1b60660..45cbabd2d 100644
--- a/contrib/helm/charts/probo/values-production.yaml.example
+++ b/contrib/helm/charts/probo/values-production.yaml.example
@@ -181,6 +181,15 @@ probo:
insecureTls: false
# External OAuth2 connectors (optional)
+ #
+ # Supported providers (set name to one of):
+ # slack, hubspot, docusign, notion, github, sentry, intercom, brex,
+ # google_workspace, gitlab, bitbucket, heroku, pagerduty, asana,
+ # snyk, netlify, ramp, clickup, vercel, monday, lever, deel
+ #
+ # Vercel additionally requires "integration-slug" so the templated
+ # AuthURL ("https://vercel.com/integrations/{integration_slug}/new")
+ # resolves to the operator's registered integration.
connectors:
- name: "github"
type: "oauth2"
@@ -190,6 +199,12 @@ probo:
redirect-uri: "https://probo.example.com/api/console/v1/connectors/complete"
auth-url: "https://github.com/login/oauth/authorize"
token-url: "https://github.com/login/oauth/access_token"
+ # - name: "vercel"
+ # type: "oauth2"
+ # config:
+ # client-id: "CHANGE_ME_VERCEL_CLIENT_ID"
+ # client-secret: "CHANGE_ME_VERCEL_CLIENT_SECRET"
+ # integration-slug: "CHANGE_ME_VERCEL_INTEGRATION_SLUG"
# PostgreSQL configuration (REQUIRED)
# For production: Use external managed PostgreSQL (AWS RDS, GCP Cloud SQL, etc.)
diff --git a/e2e/console/access_review_test.go b/e2e/console/access_review_test.go
index 23d95239c..54365e51f 100644
--- a/e2e/console/access_review_test.go
+++ b/e2e/console/access_review_test.go
@@ -26,6 +26,14 @@ import (
const testCsvData = "email,full_name,role,job_title,is_admin,mfa_status,auth_method,last_login,account_created_at,external_id\njane@example.com,Jane Smith,admin,CTO,true,ENABLED,SSO,2026-01-15T00:00:00Z,2024-06-01T00:00:00Z,ext-jane"
+// TODO(access-review-connectors): add e2e tests for the new GitLab,
+// Bitbucket, Heroku, PagerDuty, Asana, Snyk, Netlify, Ramp, ClickUp,
+// Vercel, Monday.com, Lever, and Deel access-source connectors once the
+// e2e factory grows OAuth2-connector seed support for arbitrary
+// providers. For now the per-driver VCR tests under
+// pkg/accessreview/drivers cover the fetch path; this e2e suite still
+// exercises CSV + Slack which were the providers wired before these
+// sprints.
func TestAccessSource_Create(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
diff --git a/packages/ui/src/Atoms/ThirdParties/Asana.tsx b/packages/ui/src/Atoms/ThirdParties/Asana.tsx
new file mode 100644
index 000000000..773488b02
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Asana.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Asana(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx b/packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx
new file mode 100644
index 000000000..605bdb147
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Bitbucket(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/ClickUp.tsx b/packages/ui/src/Atoms/ThirdParties/ClickUp.tsx
new file mode 100644
index 000000000..9fc2cf763
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/ClickUp.tsx
@@ -0,0 +1,16 @@
+import type { ComponentProps } from "react";
+
+export function ClickUp(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Deel.tsx b/packages/ui/src/Atoms/ThirdParties/Deel.tsx
new file mode 100644
index 000000000..2dc9b2e42
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Deel.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Deel(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/GitLab.tsx b/packages/ui/src/Atoms/ThirdParties/GitLab.tsx
new file mode 100644
index 000000000..7f888a699
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/GitLab.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function GitLab(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Heroku.tsx b/packages/ui/src/Atoms/ThirdParties/Heroku.tsx
new file mode 100644
index 000000000..b9ec123bf
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Heroku.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Heroku(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Lever.tsx b/packages/ui/src/Atoms/ThirdParties/Lever.tsx
new file mode 100644
index 000000000..9cfb6f929
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Lever.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Lever(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Monday.tsx b/packages/ui/src/Atoms/ThirdParties/Monday.tsx
new file mode 100644
index 000000000..9f65ea2b5
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Monday.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Monday(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Netlify.tsx b/packages/ui/src/Atoms/ThirdParties/Netlify.tsx
new file mode 100644
index 000000000..7fc36ca89
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Netlify.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Netlify(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx b/packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx
new file mode 100644
index 000000000..b579a56df
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function PagerDuty(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Ramp.tsx b/packages/ui/src/Atoms/ThirdParties/Ramp.tsx
new file mode 100644
index 000000000..deede6ff9
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Ramp.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Ramp(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/Snyk.tsx b/packages/ui/src/Atoms/ThirdParties/Snyk.tsx
new file mode 100644
index 000000000..83b422a49
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Snyk.tsx
@@ -0,0 +1,12 @@
+import type { ComponentProps } from "react";
+
+export function Snyk(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
index ce420de7d..8f2550d93 100644
--- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
+++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
@@ -14,47 +14,73 @@
import type { ComponentProps, FC } from "react";
+import { Asana } from "./Asana";
+import { Bitbucket } from "./Bitbucket";
import { Brex } from "./Brex";
+import { ClickUp } from "./ClickUp";
import { Cloudflare } from "./Cloudflare";
+import { Deel } from "./Deel";
import { DocuSign } from "./DocuSign";
import { Figma } from "./Figma";
import { GitHub } from "./GitHub";
+import { GitLab } from "./GitLab";
import { Google } from "./Google";
+import { Heroku } from "./Heroku";
import { HubSpot } from "./HubSpot";
import { Intercom } from "./Intercom";
+import { Lever } from "./Lever";
import { Linear } from "./Linear";
import { Microsoft } from "./Microsoft";
+import { Monday } from "./Monday";
+import { Netlify } from "./Netlify";
import { Notion } from "./Notion";
import { OnePassword } from "./OnePassword";
import { OpenAI } from "./OpenAI";
+import { PagerDuty } from "./PagerDuty";
+import { Ramp } from "./Ramp";
import { Resend } from "./Resend";
import { Sentry } from "./Sentry";
import { Slack } from "./Slack";
+import { Snyk } from "./Snyk";
import { Supabase } from "./Supabase";
import { Tally } from "./Tally";
+import { Vercel } from "./Vercel";
const thirdParties: Record>> = {
+ ASANA: Asana,
+ BITBUCKET: Bitbucket,
BREX: Brex,
+ CLICKUP: ClickUp,
CLOUDFLARE: Cloudflare,
+ DEEL: Deel,
DOCUSIGN: DocuSign,
FIGMA: Figma,
GITHUB: GitHub,
+ GITLAB: GitLab,
GOOGLE: Google,
GOOGLE_WORKSPACE: Google,
+ HEROKU: Heroku,
HUBSPOT: HubSpot,
INTERCOM: Intercom,
+ LEVER: Lever,
LINEAR: Linear,
MICROSOFT: Microsoft,
MICROSOFT_365: Microsoft,
+ MONDAY: Monday,
+ NETLIFY: Netlify,
NOTION: Notion,
ONE_PASSWORD: OnePassword,
ONEPASSWORD: OnePassword,
OPENAI: OpenAI,
+ PAGERDUTY: PagerDuty,
+ RAMP: Ramp,
RESEND: Resend,
SENTRY: Sentry,
SLACK: Slack,
+ SNYK: Snyk,
SUPABASE: Supabase,
TALLY: Tally,
+ VERCEL: Vercel,
};
type ThirdPartyLogoProps = ComponentProps<"svg"> & {
@@ -72,7 +98,9 @@ export function ThirdPartyLogo({ thirdParty, tint, ...props }: ThirdPartyLogoPro
return (
);
}
diff --git a/packages/ui/src/Atoms/ThirdParties/Vercel.tsx b/packages/ui/src/Atoms/ThirdParties/Vercel.tsx
new file mode 100644
index 000000000..619e23d40
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Vercel.tsx
@@ -0,0 +1,9 @@
+import type { ComponentProps } from "react";
+
+export function Vercel(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts
index 26e572fbe..61c9ede04 100644
--- a/packages/ui/src/Atoms/ThirdParties/index.ts
+++ b/packages/ui/src/Atoms/ThirdParties/index.ts
@@ -1,19 +1,32 @@
+export { Asana } from "./Asana";
+export { Bitbucket } from "./Bitbucket";
export { Brex } from "./Brex";
+export { ClickUp } from "./ClickUp";
export { Cloudflare } from "./Cloudflare";
+export { Deel } from "./Deel";
export { DocuSign } from "./DocuSign";
export { Figma } from "./Figma";
export { GitHub } from "./GitHub";
+export { GitLab } from "./GitLab";
export { Google } from "./Google";
+export { Heroku } from "./Heroku";
export { HubSpot } from "./HubSpot";
export { Intercom } from "./Intercom";
+export { Lever } from "./Lever";
export { Linear } from "./Linear";
export { Microsoft } from "./Microsoft";
+export { Monday } from "./Monday";
+export { Netlify } from "./Netlify";
export { Notion } from "./Notion";
export { OnePassword } from "./OnePassword";
export { OpenAI } from "./OpenAI";
+export { PagerDuty } from "./PagerDuty";
+export { Ramp } from "./Ramp";
export { Resend } from "./Resend";
export { Sentry } from "./Sentry";
export { Slack } from "./Slack";
+export { Snyk } from "./Snyk";
export { Supabase } from "./Supabase";
export { Tally } from "./Tally";
export { ThirdPartyLogo } from "./ThirdPartyLogo";
+export { Vercel } from "./Vercel";
diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go
index b2cc64c99..8b3584746 100644
--- a/pkg/bootstrap/builder.go
+++ b/pkg/bootstrap/builder.go
@@ -349,6 +349,48 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
})
}
+ for _, provider := range []string{
+ "GITLAB",
+ "BITBUCKET",
+ "HEROKU",
+ "PAGERDUTY",
+ "ASANA",
+ "SNYK",
+ "NETLIFY",
+ "RAMP",
+ "CLICKUP",
+ "MONDAY",
+ "LEVER",
+ "DEEL",
+ } {
+ clientID := b.getEnv("CONNECTOR_" + provider + "_CLIENT_ID")
+ if clientID == "" {
+ continue
+ }
+ cfg.Probod.Connectors = append(cfg.Probod.Connectors, probodconfig.ConnectorConfig{
+ Provider: provider,
+ Protocol: "oauth2",
+ RawConfig: probodconfig.ConnectorConfigOAuth2{
+ ClientID: clientID,
+ ClientSecret: b.getEnv("CONNECTOR_" + provider + "_CLIENT_SECRET"),
+ },
+ })
+ }
+
+ // Vercel needs the operator-supplied integration slug to resolve the
+ // templated AuthURL ("https://vercel.com/integrations/{integration_slug}/new").
+ if vercelClientID := b.getEnv("CONNECTOR_VERCEL_CLIENT_ID"); vercelClientID != "" {
+ cfg.Probod.Connectors = append(cfg.Probod.Connectors, probodconfig.ConnectorConfig{
+ Provider: "VERCEL",
+ Protocol: "oauth2",
+ RawConfig: probodconfig.ConnectorConfigOAuth2{
+ ClientID: vercelClientID,
+ ClientSecret: b.getEnv("CONNECTOR_VERCEL_CLIENT_SECRET"),
+ IntegrationSlug: b.getEnv("CONNECTOR_VERCEL_INTEGRATION_SLUG"),
+ },
+ })
+ }
+
return cfg, nil
}
diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go
index 2d15356c1..996a5032e 100644
--- a/pkg/bootstrap/builder_test.go
+++ b/pkg/bootstrap/builder_test.go
@@ -435,6 +435,64 @@ func TestBuilder_Build_Microsoft365Connector(t *testing.T) {
assert.Equal(t, "ms365-client-secret", rawConfig.ClientSecret)
}
+func TestBuilder_Build_AccessReviewConnectors(t *testing.T) {
+ env := requiredEnv()
+ env["CONNECTOR_GITLAB_CLIENT_ID"] = "gitlab-id"
+ env["CONNECTOR_GITLAB_CLIENT_SECRET"] = "gitlab-secret"
+ env["CONNECTOR_BITBUCKET_CLIENT_ID"] = "bitbucket-id"
+ env["CONNECTOR_BITBUCKET_CLIENT_SECRET"] = "bitbucket-secret"
+ env["CONNECTOR_PAGERDUTY_CLIENT_ID"] = "pagerduty-id"
+ env["CONNECTOR_PAGERDUTY_CLIENT_SECRET"] = "pagerduty-secret"
+ env["CONNECTOR_DEEL_CLIENT_ID"] = "deel-id"
+ env["CONNECTOR_DEEL_CLIENT_SECRET"] = "deel-secret"
+
+ b := NewBuilder(mockEnv(env))
+ b.samlCertificate = "test-cert"
+ b.samlPrivateKey = "test-key"
+
+ cfg, err := b.Build()
+ require.NoError(t, err)
+
+ require.Len(t, cfg.Probod.Connectors, 4)
+ byProvider := make(map[string]probodconfig.ConnectorConfig, len(cfg.Probod.Connectors))
+ for _, c := range cfg.Probod.Connectors {
+ byProvider[c.Provider] = c
+ }
+
+ for _, provider := range []string{"GITLAB", "BITBUCKET", "PAGERDUTY", "DEEL"} {
+ c, ok := byProvider[provider]
+ require.True(t, ok, "missing %s connector", provider)
+ assert.Equal(t, "oauth2", string(c.Protocol))
+ raw := c.RawConfig.(probodconfig.ConnectorConfigOAuth2)
+ assert.NotEmpty(t, raw.ClientID, "%s client-id", provider)
+ assert.NotEmpty(t, raw.ClientSecret, "%s client-secret", provider)
+ assert.Empty(t, raw.IntegrationSlug, "%s should not carry integration-slug", provider)
+ }
+}
+
+func TestBuilder_Build_VercelConnector(t *testing.T) {
+ env := requiredEnv()
+ env["CONNECTOR_VERCEL_CLIENT_ID"] = "vercel-id"
+ env["CONNECTOR_VERCEL_CLIENT_SECRET"] = "vercel-secret"
+ env["CONNECTOR_VERCEL_INTEGRATION_SLUG"] = "probo-app"
+
+ b := NewBuilder(mockEnv(env))
+ b.samlCertificate = "test-cert"
+ b.samlPrivateKey = "test-key"
+
+ cfg, err := b.Build()
+ require.NoError(t, err)
+
+ require.Len(t, cfg.Probod.Connectors, 1)
+ c := cfg.Probod.Connectors[0]
+ assert.Equal(t, "VERCEL", c.Provider)
+ assert.Equal(t, "oauth2", string(c.Protocol))
+ raw := c.RawConfig.(probodconfig.ConnectorConfigOAuth2)
+ assert.Equal(t, "vercel-id", raw.ClientID)
+ assert.Equal(t, "vercel-secret", raw.ClientSecret)
+ assert.Equal(t, "probo-app", raw.IntegrationSlug)
+}
+
func TestBuilder_Build_SlackConnector(t *testing.T) {
env := requiredEnv()
env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id"
diff --git a/pkg/probo/connector_service.go b/pkg/probo/connector_service.go
index 4fedf5618..795b818eb 100644
--- a/pkg/probo/connector_service.go
+++ b/pkg/probo/connector_service.go
@@ -59,6 +59,8 @@ type (
SupabaseSettings *coredata.SupabaseConnectorSettings
GitHubSettings *coredata.GitHubConnectorSettings
OnePasswordUsersAPISettings *coredata.OnePasswordUsersAPISettings
+ PagerDutySettings *coredata.PagerDutyConnectorSettings
+ VercelSettings *coredata.VercelConnectorSettings
}
ReconnectConnectorRequest struct {
@@ -270,6 +272,14 @@ func (s *ConnectorService) Create(
if err := newConnector.SetSettings(req.OnePasswordUsersAPISettings); err != nil {
return nil, fmt.Errorf("cannot set one password users api settings: %w", err)
}
+ case req.PagerDutySettings != nil:
+ if err := newConnector.SetSettings(req.PagerDutySettings); err != nil {
+ return nil, fmt.Errorf("cannot set pagerduty settings: %w", err)
+ }
+ case req.VercelSettings != nil:
+ if err := newConnector.SetSettings(req.VercelSettings); err != nil {
+ return nil, fmt.Errorf("cannot set vercel settings: %w", err)
+ }
}
err := s.svc.pg.WithTx(
diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go
index 2b154e285..90314d6a1 100644
--- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go
+++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go
@@ -411,6 +411,57 @@ func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *t
return nil, fmt.Errorf("cannot fetch sentry organizations: %w", err)
}
return orgs, nil
+ case coredata.ConnectorProviderGitLab:
+ orgs, err := fetchGitLabOrganizations(ctx, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err)
+ }
+ return orgs, nil
+ case coredata.ConnectorProviderBitbucket:
+ orgs, err := fetchBitbucketOrganizations(ctx, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err)
+ }
+ return orgs, nil
+ case coredata.ConnectorProviderHeroku:
+ orgs, err := fetchHerokuOrganizations(ctx, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err)
+ }
+ return orgs, nil
+ case coredata.ConnectorProviderAsana:
+ orgs, err := fetchAsanaOrganizations(ctx, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch asana organizations: %w", err)
+ }
+ return orgs, nil
+ case coredata.ConnectorProviderPagerDuty:
+ // PagerDuty uses Pattern 2-auto: the subdomain is captured during
+ // the OAuth callback, no picker UI is required.
+ return []*types.ProviderOrganization{}, nil
+ case coredata.ConnectorProviderSnyk:
+ orgs, err := fetchSnykOrganizations(ctx, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err)
+ }
+ return orgs, nil
+ case coredata.ConnectorProviderNetlify:
+ orgs, err := fetchNetlifyOrganizations(ctx, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err)
+ }
+ return orgs, nil
+ case coredata.ConnectorProviderClickUp:
+ orgs, err := fetchClickUpOrganizations(ctx, httpClient)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err)
+ }
+ return orgs, nil
+ case coredata.ConnectorProviderVercel:
+ // Vercel uses Pattern 2-auto: the team_id is captured during the
+ // OAuth callback (or synthesized from /v2/user for personal
+ // accounts), no picker UI is required.
+ return []*types.ProviderOrganization{}, nil
default:
return []*types.ProviderOrganization{}, nil
}
@@ -439,6 +490,38 @@ func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *type
case coredata.ConnectorProviderSentry:
settings, _ := dbConnector.SentrySettings()
return settings.OrganizationSlug == "", nil
+ case coredata.ConnectorProviderGitLab:
+ settings, _ := dbConnector.GitLabSettings()
+ return settings.GroupID == "", nil
+ case coredata.ConnectorProviderBitbucket:
+ settings, _ := dbConnector.BitbucketSettings()
+ return settings.Workspace == "", nil
+ case coredata.ConnectorProviderHeroku:
+ settings, _ := dbConnector.HerokuSettings()
+ return settings.TeamID == "", nil
+ case coredata.ConnectorProviderAsana:
+ settings, _ := dbConnector.AsanaSettings()
+ return settings.WorkspaceGID == "", nil
+ case coredata.ConnectorProviderPagerDuty:
+ // 2-auto: subdomain is set during the OAuth callback. If for any
+ // reason it is missing, we still return false so the configure
+ // mutation does not surface — there is no manual picker.
+ return false, nil
+ case coredata.ConnectorProviderSnyk:
+ settings, _ := dbConnector.SnykSettings()
+ return settings.OrgID == "", nil
+ case coredata.ConnectorProviderNetlify:
+ settings, _ := dbConnector.NetlifySettings()
+ return settings.AccountSlug == "", nil
+ case coredata.ConnectorProviderClickUp:
+ settings, _ := dbConnector.ClickUpSettings()
+ return settings.TeamID == "", nil
+ case coredata.ConnectorProviderVercel:
+ // 2-auto: team_id is set during the OAuth callback (or synthesized
+ // from /v2/user for personal accounts). If for any reason it is
+ // missing, return false so the configure mutation does not surface
+ // — there is no manual picker.
+ return false, nil
default:
return false, nil
}
@@ -502,6 +585,51 @@ func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *ty
if settings.OrganizationSlug != "" {
return &settings.OrganizationSlug, nil
}
+ case coredata.ConnectorProviderGitLab:
+ settings, _ := dbConnector.GitLabSettings()
+ if settings.GroupID != "" {
+ return &settings.GroupID, nil
+ }
+ case coredata.ConnectorProviderBitbucket:
+ settings, _ := dbConnector.BitbucketSettings()
+ if settings.Workspace != "" {
+ return &settings.Workspace, nil
+ }
+ case coredata.ConnectorProviderHeroku:
+ settings, _ := dbConnector.HerokuSettings()
+ if settings.TeamID != "" {
+ return &settings.TeamID, nil
+ }
+ case coredata.ConnectorProviderPagerDuty:
+ settings, _ := dbConnector.PagerDutySettings()
+ if settings.Subdomain != "" {
+ return &settings.Subdomain, nil
+ }
+ case coredata.ConnectorProviderAsana:
+ settings, _ := dbConnector.AsanaSettings()
+ if settings.WorkspaceGID != "" {
+ return &settings.WorkspaceGID, nil
+ }
+ case coredata.ConnectorProviderSnyk:
+ settings, _ := dbConnector.SnykSettings()
+ if settings.OrgID != "" {
+ return &settings.OrgID, nil
+ }
+ case coredata.ConnectorProviderNetlify:
+ settings, _ := dbConnector.NetlifySettings()
+ if settings.AccountSlug != "" {
+ return &settings.AccountSlug, nil
+ }
+ case coredata.ConnectorProviderClickUp:
+ settings, _ := dbConnector.ClickUpSettings()
+ if settings.TeamID != "" {
+ return &settings.TeamID, nil
+ }
+ case coredata.ConnectorProviderVercel:
+ settings, _ := dbConnector.VercelSettings()
+ if settings.TeamID != "" {
+ return &settings.TeamID, nil
+ }
}
return nil, nil
diff --git a/pkg/server/api/console/v1/provider_organizations.go b/pkg/server/api/console/v1/provider_organizations.go
index 096e07a36..572bc0da4 100644
--- a/pkg/server/api/console/v1/provider_organizations.go
+++ b/pkg/server/api/console/v1/provider_organizations.go
@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"net/http"
+ "strconv"
"go.probo.inc/probo/pkg/server/api/console/v1/types"
)
@@ -108,6 +109,348 @@ func fetchSentryOrganizations(ctx context.Context, httpClient *http.Client) ([]*
return result, nil
}
+// fetchGitLabOrganizations fetches the list of groups the authenticated
+// GitLab user owns. Group IDs are numeric int64 values; we surface them
+// as strings so they fit the ProviderOrganization.Slug shape.
+func fetchGitLabOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "https://gitlab.com/api/v4/groups?min_access_level=50&per_page=100",
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot create gitlab organizations request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch gitlab organizations: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch gitlab organizations: status %d", resp.StatusCode)
+ }
+
+ var groups []struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ FullPath string `json:"full_path"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&groups); err != nil {
+ return nil, fmt.Errorf("cannot decode gitlab organizations response: %w", err)
+ }
+
+ result := make([]*types.ProviderOrganization, len(groups))
+ for i, g := range groups {
+ displayName := g.Name
+ if displayName == "" {
+ displayName = g.FullPath
+ }
+ result[i] = &types.ProviderOrganization{
+ Slug: strconv.FormatInt(g.ID, 10),
+ DisplayName: displayName,
+ }
+ }
+
+ return result, nil
+}
+
+// fetchBitbucketOrganizations fetches the list of workspaces the
+// authenticated Bitbucket user belongs to.
+func fetchBitbucketOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "https://api.bitbucket.org/2.0/workspaces?role=member&pagelen=100",
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot create bitbucket organizations request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch bitbucket organizations: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch bitbucket organizations: status %d", resp.StatusCode)
+ }
+
+ var body struct {
+ Values []struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ } `json:"values"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("cannot decode bitbucket organizations response: %w", err)
+ }
+
+ result := make([]*types.ProviderOrganization, len(body.Values))
+ for i, w := range body.Values {
+ displayName := w.Name
+ if displayName == "" {
+ displayName = w.Slug
+ }
+ result[i] = &types.ProviderOrganization{
+ Slug: w.Slug,
+ DisplayName: displayName,
+ }
+ }
+
+ return result, nil
+}
+
+// fetchHerokuOrganizations fetches the list of teams the authenticated
+// Heroku user belongs to.
+func fetchHerokuOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.heroku.com/teams", nil)
+ if err != nil {
+ return nil, fmt.Errorf("cannot create heroku organizations request: %w", err)
+ }
+ req.Header.Set("Accept", "application/vnd.heroku+json; version=3")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch heroku organizations: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch heroku organizations: status %d", resp.StatusCode)
+ }
+
+ var teams []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&teams); err != nil {
+ return nil, fmt.Errorf("cannot decode heroku organizations response: %w", err)
+ }
+
+ result := make([]*types.ProviderOrganization, len(teams))
+ for i, t := range teams {
+ displayName := t.Name
+ if displayName == "" {
+ displayName = t.ID
+ }
+ result[i] = &types.ProviderOrganization{
+ Slug: t.ID,
+ DisplayName: displayName,
+ }
+ }
+
+ return result, nil
+}
+
+// fetchAsanaOrganizations fetches the list of workspaces the
+// authenticated Asana user belongs to.
+func fetchAsanaOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "https://app.asana.com/api/1.0/workspaces?limit=100",
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot create asana organizations request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch asana organizations: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch asana organizations: status %d", resp.StatusCode)
+ }
+
+ var body struct {
+ Data []struct {
+ GID string `json:"gid"`
+ Name string `json:"name"`
+ } `json:"data"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("cannot decode asana organizations response: %w", err)
+ }
+
+ result := make([]*types.ProviderOrganization, len(body.Data))
+ for i, w := range body.Data {
+ displayName := w.Name
+ if displayName == "" {
+ displayName = w.GID
+ }
+ result[i] = &types.ProviderOrganization{
+ Slug: w.GID,
+ DisplayName: displayName,
+ }
+ }
+
+ return result, nil
+}
+
+// fetchSnykOrganizations fetches the list of Snyk organizations the
+// authenticated user belongs to. The Snyk REST API is JSON:API style;
+// the org id is the unique identifier surfaced as the slug.
+func fetchSnykOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "https://api.snyk.io/rest/orgs?version=2024-10-15&limit=100",
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot create snyk organizations request: %w", err)
+ }
+ req.Header.Set("Accept", "application/vnd.api+json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch snyk organizations: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch snyk organizations: status %d", resp.StatusCode)
+ }
+
+ var body struct {
+ Data []struct {
+ ID string `json:"id"`
+ Attributes struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ } `json:"attributes"`
+ } `json:"data"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("cannot decode snyk organizations response: %w", err)
+ }
+
+ result := make([]*types.ProviderOrganization, len(body.Data))
+ for i, org := range body.Data {
+ displayName := org.Attributes.Name
+ if displayName == "" {
+ displayName = org.Attributes.Slug
+ }
+ if displayName == "" {
+ displayName = org.ID
+ }
+ result[i] = &types.ProviderOrganization{
+ Slug: org.ID,
+ DisplayName: displayName,
+ }
+ }
+
+ return result, nil
+}
+
+// fetchNetlifyOrganizations fetches the list of Netlify accounts the
+// authenticated user belongs to.
+func fetchNetlifyOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "https://api.netlify.com/api/v1/accounts",
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot create netlify organizations request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch netlify organizations: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch netlify organizations: status %d", resp.StatusCode)
+ }
+
+ var accounts []struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil {
+ return nil, fmt.Errorf("cannot decode netlify organizations response: %w", err)
+ }
+
+ result := make([]*types.ProviderOrganization, len(accounts))
+ for i, a := range accounts {
+ displayName := a.Name
+ if displayName == "" {
+ displayName = a.Slug
+ }
+ result[i] = &types.ProviderOrganization{
+ Slug: a.Slug,
+ DisplayName: displayName,
+ }
+ }
+
+ return result, nil
+}
+
+// fetchClickUpOrganizations fetches the list of ClickUp teams (workspaces)
+// the authenticated user belongs to.
+func fetchClickUpOrganizations(ctx context.Context, httpClient *http.Client) ([]*types.ProviderOrganization, error) {
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodGet,
+ "https://api.clickup.com/api/v2/team",
+ nil,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("cannot create clickup organizations request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot fetch clickup organizations: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("cannot fetch clickup organizations: status %d", resp.StatusCode)
+ }
+
+ var body struct {
+ Teams []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ } `json:"teams"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return nil, fmt.Errorf("cannot decode clickup organizations response: %w", err)
+ }
+
+ result := make([]*types.ProviderOrganization, len(body.Teams))
+ for i, t := range body.Teams {
+ displayName := t.Name
+ if displayName == "" {
+ displayName = t.ID
+ }
+ result[i] = &types.ProviderOrganization{
+ Slug: t.ID,
+ DisplayName: displayName,
+ }
+ }
+
+ return result, nil
+}
+
// probeConnection makes a lightweight API call to the given URL to verify
// the OAuth token is still valid. The probe URL is configured per connector
// in the connector registry.
diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go
index 3fd91eff4..d8f8751ba 100644
--- a/pkg/server/api/console/v1/resolver.go
+++ b/pkg/server/api/console/v1/resolver.go
@@ -18,11 +18,14 @@ package console_v1
import (
"context"
+ "encoding/json"
"fmt"
"net/http"
"net/url"
+ "time"
"github.com/go-chi/chi/v5"
+ "go.gearno.de/kit/httpclient"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/accessreview"
@@ -195,15 +198,49 @@ func handleConnectorComplete(
return
}
} else {
- cnnctr, err = svc.Connectors.Create(
- r.Context(),
- probo.CreateConnectorRequest{
- OrganizationID: organizationID,
- Provider: connectorProvider,
- Protocol: coredata.ConnectorProtocol(connection.Type()),
- Connection: connection,
- },
- )
+ createReq := probo.CreateConnectorRequest{
+ OrganizationID: organizationID,
+ Provider: connectorProvider,
+ Protocol: coredata.ConnectorProtocol(connection.Type()),
+ Connection: connection,
+ }
+
+ // PagerDuty Scoped OAuth surfaces the customer's subdomain
+ // in the token response body; CompleteWithState parsed it
+ // into state.ProviderMetadata. Persist it on the connector
+ // settings so the driver and name resolver can read it.
+ if connectorProvider == coredata.ConnectorProviderPagerDuty {
+ if subdomain := state.ProviderMetadata["subdomain"]; subdomain != "" {
+ createReq.PagerDutySettings = &coredata.PagerDutyConnectorSettings{
+ Subdomain: subdomain,
+ }
+ }
+ }
+
+ // Vercel surfaces the customer's team_id as an OAuth callback
+ // query parameter (not in the token response body). When the
+ // install targets a personal account no team_id is sent — fall
+ // back to /v2/user.id as a synthetic TeamID; the v3 members
+ // endpoint accepts personal-account UIDs.
+ if connectorProvider == coredata.ConnectorProviderVercel {
+ teamID := query.Get("team_id")
+ if teamID == "" {
+ if oauth2Conn, ok := connection.(*connector.OAuth2Connection); ok && oauth2Conn.AccessToken != "" {
+ if uid, err := fetchVercelUserID(r.Context(), oauth2Conn.AccessToken); err == nil {
+ teamID = uid
+ } else {
+ logger.WarnCtx(r.Context(), "cannot fetch vercel user id for personal-account fallback", log.Error(err))
+ }
+ }
+ }
+ if teamID != "" {
+ createReq.VercelSettings = &coredata.VercelConnectorSettings{
+ TeamID: teamID,
+ }
+ }
+ }
+
+ cnnctr, err = svc.Connectors.Create(r.Context(), createReq)
if err != nil {
logger.ErrorCtx(r.Context(), "cannot create connector", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error"))
@@ -271,6 +308,43 @@ func handleConnectorOAuth2Error(
safeRedirect.Redirect(w, r, parsedURL.String(), "/", http.StatusSeeOther)
}
+// fetchVercelUserID calls Vercel's /v2/user with the freshly-minted access
+// token to retrieve the user's UID. This is used as a synthetic TeamID
+// when the OAuth callback omits team_id (personal-account installs).
+func fetchVercelUserID(ctx context.Context, accessToken string) (string, error) {
+ reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, "https://api.vercel.com/v2/user", nil)
+ if err != nil {
+ return "", fmt.Errorf("cannot create vercel user request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ client := httpclient.DefaultClient(httpclient.WithSSRFProtection())
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("cannot execute vercel user request: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("cannot fetch vercel user: unexpected status %d", resp.StatusCode)
+ }
+
+ var body struct {
+ User struct {
+ ID string `json:"id"`
+ } `json:"user"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ return "", fmt.Errorf("cannot decode vercel user response: %w", err)
+ }
+
+ return body.User.ID, nil
+}
+
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
return r.probo.WithTenant(tenantID)
}