Wire post-OAuth pickers and 2-auto callback handling → Track e2e gap for new access-review connectors
- 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>
This commit is contained in:
27
.env.example
27
.env.example
@@ -98,6 +98,33 @@
|
|||||||
# CONNECTOR_DOCUSIGN_CLIENT_SECRET=
|
# CONNECTOR_DOCUSIGN_CLIENT_SECRET=
|
||||||
# CONNECTOR_BREX_CLIENT_ID=
|
# CONNECTOR_BREX_CLIENT_ID=
|
||||||
# CONNECTOR_BREX_CLIENT_SECRET=
|
# 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 (Pebble ACME via compose) ──────────────────────────
|
||||||
# CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com
|
# CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com
|
||||||
|
|||||||
@@ -181,6 +181,15 @@ probo:
|
|||||||
insecureTls: false
|
insecureTls: false
|
||||||
|
|
||||||
# External OAuth2 connectors (optional)
|
# 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:
|
connectors:
|
||||||
- name: "github"
|
- name: "github"
|
||||||
type: "oauth2"
|
type: "oauth2"
|
||||||
@@ -190,6 +199,12 @@ probo:
|
|||||||
redirect-uri: "https://probo.example.com/api/console/v1/connectors/complete"
|
redirect-uri: "https://probo.example.com/api/console/v1/connectors/complete"
|
||||||
auth-url: "https://github.com/login/oauth/authorize"
|
auth-url: "https://github.com/login/oauth/authorize"
|
||||||
token-url: "https://github.com/login/oauth/access_token"
|
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)
|
# PostgreSQL configuration (REQUIRED)
|
||||||
# For production: Use external managed PostgreSQL (AWS RDS, GCP Cloud SQL, etc.)
|
# For production: Use external managed PostgreSQL (AWS RDS, GCP Cloud SQL, etc.)
|
||||||
|
|||||||
@@ -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"
|
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) {
|
func TestAccessSource_Create(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||||
|
|||||||
12
packages/ui/src/Atoms/ThirdParties/Asana.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Asana.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Asana(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M18.78 12.653a5.22 5.22 0 1 0 0 10.44 5.22 5.22 0 0 0 0-10.44zm-13.56 0a5.22 5.22 0 1 0 .001 10.44 5.22 5.22 0 0 0-.001-10.44zM17.22 5.22a5.22 5.22 0 1 1-10.44 0 5.22 5.22 0 0 1 10.44 0z"
|
||||||
|
fill="#F06A6A"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Bitbucket.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Bitbucket(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M.778 1.213a.768.768 0 0 0-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 0 0 .77-.646l3.27-20.03a.768.768 0 0 0-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"
|
||||||
|
fill="#0052CC"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
packages/ui/src/Atoms/ThirdParties/ClickUp.tsx
Normal file
16
packages/ui/src/Atoms/ThirdParties/ClickUp.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function ClickUp(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="m2 18.439 3.69-2.828c1.961 2.56 4.044 3.739 6.363 3.739 2.307 0 4.33-1.166 6.203-3.708L22 18.405C19.298 22.067 15.941 24 12.053 24 8.178 24 4.788 22.08 2 18.439Z"
|
||||||
|
fill="#7B68EE"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m12.04 7.476-6.566 5.66-3.035-3.519L12.054 1.36l9.543 8.265-3.05 3.508z"
|
||||||
|
fill="#7B68EE"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Deel.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Deel.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Deel(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 3h7.5c4.97 0 9 4.03 9 9s-4.03 9-9 9H3V3zm3.5 3.2v11.6h4c3.2 0 5.8-2.6 5.8-5.8S13.7 6.2 10.5 6.2h-4z"
|
||||||
|
fill="#15847B"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/GitLab.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/GitLab.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function GitLab(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"
|
||||||
|
fill="#FC6D26"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Heroku.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Heroku.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Heroku(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M20.61 0H3.39C2.189 0 1.23.96 1.23 2.16V21.84c0 1.2.99 2.16 2.16 2.16H20.61c1.2 0 2.16-.96 2.16-2.16V2.16C22.74.96 21.78 0 20.61 0zm.54 21.84a.555.555 0 0 1-.54.54H3.39a.555.555 0 0 1-.54-.54V2.16c0-.301.24-.541.54-.541H20.61c.3 0 .54.24.54.541V21.84zM6.9 19.86V14.4s6.42-3.061 6.42 0v5.46h3.18v-5.7c0-1.71-.72-3.66-3.78-3.66-2.85 0-5.82 1.32-5.82 1.32V4.14H3.72V19.86H6.9zm9.69-11.7h-3.21c1.351-1.62 2.041-3.21 2.34-4.02h3.18c-.45 1.59-1.5 2.94-2.31 4.02zM6.9 19.86l3.359-3.18-3.36-3.18v6.36z"
|
||||||
|
fill="#430098"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Lever.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Lever.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Lever(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 3h3.6v14.8H21V21H3V3zm6.4 0h3.6v11.2h7V17.5H9.4V3z"
|
||||||
|
fill="#16323F"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Monday.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Monday.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Monday(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M2.25 16.5L6.75 6a2.25 2.25 0 014.13 1.78L6.38 18.28A2.25 2.25 0 012.25 16.5zm7.5 0L14.25 6a2.25 2.25 0 014.13 1.78L13.88 18.28A2.25 2.25 0 019.75 16.5zM18 18.75a2.25 2.25 0 110-4.5 2.25 2.25 0 010 4.5z"
|
||||||
|
fill="#FF3D57"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Netlify.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Netlify.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Netlify(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M6.49 19.04h-.23L5.13 17.91v-.23l3.46-3.46h1.92l.25.25v1.92zM5.13 6.31V6.08l1.13-1.13h.23l3.46 3.46v1.92l-.25.25H7.78zm9.96 9.09h-2.65l-.22-.22V8.83c0-.39-.15-.74-.5-.85-.36-.11-.74.03-.95.34l-.27.27h-1.93l-.25-.25V6.41l.22-.22h6.55l.22.22v8.77zm0-13.78v2.21l-.22.22h-1.49l-.22-.22V1.62l.22-.22h1.49zm0 17.55v2.21l-.22.22h-1.49l-.22-.22v-2.21l.22-.22h1.49zM20.5 12.93h-2.21l-.22-.22v-1.49l.22-.22h2.21l.22.22v1.49zm-13.34 0H1.5l-.22-.22v-1.49l.22-.22h5.66l.22.22v1.49z"
|
||||||
|
fill="#00C7B7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/PagerDuty.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function PagerDuty(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M5 2h7.5c4.14 0 7.5 3.36 7.5 7.5S16.64 17 12.5 17H8.5v5H5V2zm3.5 3.2v8.6h4c2.37 0 4.3-1.93 4.3-4.3S14.87 5.2 12.5 5.2h-4z"
|
||||||
|
fill="#06AC38"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Ramp.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Ramp.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Ramp(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 3h11.5c2.76 0 5 2.24 5 5 0 2.05-1.23 3.81-2.99 4.59L20 21h-3.7l-3.16-7.5H6.5V21H3V3zm3.5 3.2v4.1h7.6c1.13 0 2.05-.92 2.05-2.05S15.23 6.2 14.1 6.2H6.5z"
|
||||||
|
fill="#FFCC33"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
packages/ui/src/Atoms/ThirdParties/Snyk.tsx
Normal file
12
packages/ui/src/Atoms/ThirdParties/Snyk.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Snyk(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path
|
||||||
|
d="M22.226 7.563c-.876-1.518-3.351-1.519-4.227 0l-1.252 2.169-2.633-4.561c-.876-1.518-3.351-1.518-4.227 0L8.012 9.928 6.137 6.68c-.624-1.083-2.394-1.085-3.02 0L.328 11.32c-.626 1.085.26 2.612 1.51 2.612h3.748l-1.872 3.243c-.626 1.085.26 2.612 1.51 2.612h13.553c1.25 0 2.135-1.527 1.51-2.612l-1.873-3.243h2.103c1.25 0 2.137-1.527 1.51-2.612z"
|
||||||
|
fill="#4C4A73"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,47 +14,73 @@
|
|||||||
|
|
||||||
import type { ComponentProps, FC } from "react";
|
import type { ComponentProps, FC } from "react";
|
||||||
|
|
||||||
|
import { Asana } from "./Asana";
|
||||||
|
import { Bitbucket } from "./Bitbucket";
|
||||||
import { Brex } from "./Brex";
|
import { Brex } from "./Brex";
|
||||||
|
import { ClickUp } from "./ClickUp";
|
||||||
import { Cloudflare } from "./Cloudflare";
|
import { Cloudflare } from "./Cloudflare";
|
||||||
|
import { Deel } from "./Deel";
|
||||||
import { DocuSign } from "./DocuSign";
|
import { DocuSign } from "./DocuSign";
|
||||||
import { Figma } from "./Figma";
|
import { Figma } from "./Figma";
|
||||||
import { GitHub } from "./GitHub";
|
import { GitHub } from "./GitHub";
|
||||||
|
import { GitLab } from "./GitLab";
|
||||||
import { Google } from "./Google";
|
import { Google } from "./Google";
|
||||||
|
import { Heroku } from "./Heroku";
|
||||||
import { HubSpot } from "./HubSpot";
|
import { HubSpot } from "./HubSpot";
|
||||||
import { Intercom } from "./Intercom";
|
import { Intercom } from "./Intercom";
|
||||||
|
import { Lever } from "./Lever";
|
||||||
import { Linear } from "./Linear";
|
import { Linear } from "./Linear";
|
||||||
import { Microsoft } from "./Microsoft";
|
import { Microsoft } from "./Microsoft";
|
||||||
|
import { Monday } from "./Monday";
|
||||||
|
import { Netlify } from "./Netlify";
|
||||||
import { Notion } from "./Notion";
|
import { Notion } from "./Notion";
|
||||||
import { OnePassword } from "./OnePassword";
|
import { OnePassword } from "./OnePassword";
|
||||||
import { OpenAI } from "./OpenAI";
|
import { OpenAI } from "./OpenAI";
|
||||||
|
import { PagerDuty } from "./PagerDuty";
|
||||||
|
import { Ramp } from "./Ramp";
|
||||||
import { Resend } from "./Resend";
|
import { Resend } from "./Resend";
|
||||||
import { Sentry } from "./Sentry";
|
import { Sentry } from "./Sentry";
|
||||||
import { Slack } from "./Slack";
|
import { Slack } from "./Slack";
|
||||||
|
import { Snyk } from "./Snyk";
|
||||||
import { Supabase } from "./Supabase";
|
import { Supabase } from "./Supabase";
|
||||||
import { Tally } from "./Tally";
|
import { Tally } from "./Tally";
|
||||||
|
import { Vercel } from "./Vercel";
|
||||||
|
|
||||||
const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
const thirdParties: Record<string, FC<ComponentProps<"svg">>> = {
|
||||||
|
ASANA: Asana,
|
||||||
|
BITBUCKET: Bitbucket,
|
||||||
BREX: Brex,
|
BREX: Brex,
|
||||||
|
CLICKUP: ClickUp,
|
||||||
CLOUDFLARE: Cloudflare,
|
CLOUDFLARE: Cloudflare,
|
||||||
|
DEEL: Deel,
|
||||||
DOCUSIGN: DocuSign,
|
DOCUSIGN: DocuSign,
|
||||||
FIGMA: Figma,
|
FIGMA: Figma,
|
||||||
GITHUB: GitHub,
|
GITHUB: GitHub,
|
||||||
|
GITLAB: GitLab,
|
||||||
GOOGLE: Google,
|
GOOGLE: Google,
|
||||||
GOOGLE_WORKSPACE: Google,
|
GOOGLE_WORKSPACE: Google,
|
||||||
|
HEROKU: Heroku,
|
||||||
HUBSPOT: HubSpot,
|
HUBSPOT: HubSpot,
|
||||||
INTERCOM: Intercom,
|
INTERCOM: Intercom,
|
||||||
|
LEVER: Lever,
|
||||||
LINEAR: Linear,
|
LINEAR: Linear,
|
||||||
MICROSOFT: Microsoft,
|
MICROSOFT: Microsoft,
|
||||||
MICROSOFT_365: Microsoft,
|
MICROSOFT_365: Microsoft,
|
||||||
|
MONDAY: Monday,
|
||||||
|
NETLIFY: Netlify,
|
||||||
NOTION: Notion,
|
NOTION: Notion,
|
||||||
ONE_PASSWORD: OnePassword,
|
ONE_PASSWORD: OnePassword,
|
||||||
ONEPASSWORD: OnePassword,
|
ONEPASSWORD: OnePassword,
|
||||||
OPENAI: OpenAI,
|
OPENAI: OpenAI,
|
||||||
|
PAGERDUTY: PagerDuty,
|
||||||
|
RAMP: Ramp,
|
||||||
RESEND: Resend,
|
RESEND: Resend,
|
||||||
SENTRY: Sentry,
|
SENTRY: Sentry,
|
||||||
SLACK: Slack,
|
SLACK: Slack,
|
||||||
|
SNYK: Snyk,
|
||||||
SUPABASE: Supabase,
|
SUPABASE: Supabase,
|
||||||
TALLY: Tally,
|
TALLY: Tally,
|
||||||
|
VERCEL: Vercel,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ThirdPartyLogoProps = ComponentProps<"svg"> & {
|
type ThirdPartyLogoProps = ComponentProps<"svg"> & {
|
||||||
@@ -72,7 +98,9 @@ export function ThirdPartyLogo({ thirdParty, tint, ...props }: ThirdPartyLogoPro
|
|||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
{...props}
|
{...props}
|
||||||
className={["grayscale brightness-0 dark:invert", props.className].filter(Boolean).join(" ")}
|
className={["grayscale brightness-0 dark:invert", props.className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
packages/ui/src/Atoms/ThirdParties/Vercel.tsx
Normal file
9
packages/ui/src/Atoms/ThirdParties/Vercel.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
export function Vercel(props: ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||||
|
<path d="M24 22.525H0l12-21.05 12 21.05z" fill="#000000" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
|
export { Asana } from "./Asana";
|
||||||
|
export { Bitbucket } from "./Bitbucket";
|
||||||
export { Brex } from "./Brex";
|
export { Brex } from "./Brex";
|
||||||
|
export { ClickUp } from "./ClickUp";
|
||||||
export { Cloudflare } from "./Cloudflare";
|
export { Cloudflare } from "./Cloudflare";
|
||||||
|
export { Deel } from "./Deel";
|
||||||
export { DocuSign } from "./DocuSign";
|
export { DocuSign } from "./DocuSign";
|
||||||
export { Figma } from "./Figma";
|
export { Figma } from "./Figma";
|
||||||
export { GitHub } from "./GitHub";
|
export { GitHub } from "./GitHub";
|
||||||
|
export { GitLab } from "./GitLab";
|
||||||
export { Google } from "./Google";
|
export { Google } from "./Google";
|
||||||
|
export { Heroku } from "./Heroku";
|
||||||
export { HubSpot } from "./HubSpot";
|
export { HubSpot } from "./HubSpot";
|
||||||
export { Intercom } from "./Intercom";
|
export { Intercom } from "./Intercom";
|
||||||
|
export { Lever } from "./Lever";
|
||||||
export { Linear } from "./Linear";
|
export { Linear } from "./Linear";
|
||||||
export { Microsoft } from "./Microsoft";
|
export { Microsoft } from "./Microsoft";
|
||||||
|
export { Monday } from "./Monday";
|
||||||
|
export { Netlify } from "./Netlify";
|
||||||
export { Notion } from "./Notion";
|
export { Notion } from "./Notion";
|
||||||
export { OnePassword } from "./OnePassword";
|
export { OnePassword } from "./OnePassword";
|
||||||
export { OpenAI } from "./OpenAI";
|
export { OpenAI } from "./OpenAI";
|
||||||
|
export { PagerDuty } from "./PagerDuty";
|
||||||
|
export { Ramp } from "./Ramp";
|
||||||
export { Resend } from "./Resend";
|
export { Resend } from "./Resend";
|
||||||
export { Sentry } from "./Sentry";
|
export { Sentry } from "./Sentry";
|
||||||
export { Slack } from "./Slack";
|
export { Slack } from "./Slack";
|
||||||
|
export { Snyk } from "./Snyk";
|
||||||
export { Supabase } from "./Supabase";
|
export { Supabase } from "./Supabase";
|
||||||
export { Tally } from "./Tally";
|
export { Tally } from "./Tally";
|
||||||
export { ThirdPartyLogo } from "./ThirdPartyLogo";
|
export { ThirdPartyLogo } from "./ThirdPartyLogo";
|
||||||
|
export { Vercel } from "./Vercel";
|
||||||
|
|||||||
@@ -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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -435,6 +435,64 @@ func TestBuilder_Build_Microsoft365Connector(t *testing.T) {
|
|||||||
assert.Equal(t, "ms365-client-secret", rawConfig.ClientSecret)
|
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) {
|
func TestBuilder_Build_SlackConnector(t *testing.T) {
|
||||||
env := requiredEnv()
|
env := requiredEnv()
|
||||||
env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id"
|
env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id"
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ type (
|
|||||||
SupabaseSettings *coredata.SupabaseConnectorSettings
|
SupabaseSettings *coredata.SupabaseConnectorSettings
|
||||||
GitHubSettings *coredata.GitHubConnectorSettings
|
GitHubSettings *coredata.GitHubConnectorSettings
|
||||||
OnePasswordUsersAPISettings *coredata.OnePasswordUsersAPISettings
|
OnePasswordUsersAPISettings *coredata.OnePasswordUsersAPISettings
|
||||||
|
PagerDutySettings *coredata.PagerDutyConnectorSettings
|
||||||
|
VercelSettings *coredata.VercelConnectorSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
ReconnectConnectorRequest struct {
|
ReconnectConnectorRequest struct {
|
||||||
@@ -270,6 +272,14 @@ func (s *ConnectorService) Create(
|
|||||||
if err := newConnector.SetSettings(req.OnePasswordUsersAPISettings); err != nil {
|
if err := newConnector.SetSettings(req.OnePasswordUsersAPISettings); err != nil {
|
||||||
return nil, fmt.Errorf("cannot set one password users api settings: %w", err)
|
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(
|
err := s.svc.pg.WithTx(
|
||||||
|
|||||||
@@ -411,6 +411,57 @@ func (r *accessSourceResolver) ProviderOrganizations(ctx context.Context, obj *t
|
|||||||
return nil, fmt.Errorf("cannot fetch sentry organizations: %w", err)
|
return nil, fmt.Errorf("cannot fetch sentry organizations: %w", err)
|
||||||
}
|
}
|
||||||
return orgs, nil
|
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:
|
default:
|
||||||
return []*types.ProviderOrganization{}, nil
|
return []*types.ProviderOrganization{}, nil
|
||||||
}
|
}
|
||||||
@@ -439,6 +490,38 @@ func (r *accessSourceResolver) NeedsConfiguration(ctx context.Context, obj *type
|
|||||||
case coredata.ConnectorProviderSentry:
|
case coredata.ConnectorProviderSentry:
|
||||||
settings, _ := dbConnector.SentrySettings()
|
settings, _ := dbConnector.SentrySettings()
|
||||||
return settings.OrganizationSlug == "", nil
|
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:
|
default:
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -502,6 +585,51 @@ func (r *accessSourceResolver) SelectedOrganization(ctx context.Context, obj *ty
|
|||||||
if settings.OrganizationSlug != "" {
|
if settings.OrganizationSlug != "" {
|
||||||
return &settings.OrganizationSlug, nil
|
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
|
return nil, nil
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
"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
|
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
|
// 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
|
// the OAuth token is still valid. The probe URL is configured per connector
|
||||||
// in the connector registry.
|
// in the connector registry.
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ package console_v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.gearno.de/kit/httpclient"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/accessreview"
|
"go.probo.inc/probo/pkg/accessreview"
|
||||||
@@ -195,15 +198,49 @@ func handleConnectorComplete(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cnnctr, err = svc.Connectors.Create(
|
createReq := probo.CreateConnectorRequest{
|
||||||
r.Context(),
|
|
||||||
probo.CreateConnectorRequest{
|
|
||||||
OrganizationID: organizationID,
|
OrganizationID: organizationID,
|
||||||
Provider: connectorProvider,
|
Provider: connectorProvider,
|
||||||
Protocol: coredata.ConnectorProtocol(connection.Type()),
|
Protocol: coredata.ConnectorProtocol(connection.Type()),
|
||||||
Connection: connection,
|
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 {
|
if err != nil {
|
||||||
logger.ErrorCtx(r.Context(), "cannot create connector", log.Error(err))
|
logger.ErrorCtx(r.Context(), "cannot create connector", log.Error(err))
|
||||||
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error"))
|
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("internal error"))
|
||||||
@@ -271,6 +308,43 @@ func handleConnectorOAuth2Error(
|
|||||||
safeRedirect.Redirect(w, r, parsedURL.String(), "/", http.StatusSeeOther)
|
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 {
|
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
|
||||||
return r.probo.WithTenant(tenantID)
|
return r.probo.WithTenant(tenantID)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user