From e5f4b9c58d6b5cb95f80332a300bc86f21d16b8e Mon Sep 17 00:00:00 2001
From: Steven4Hooisma <112615049+Steven4Hooisma@users.noreply.github.com>
Date: Wed, 12 Aug 2026 10:59:40 +0200
Subject: [PATCH 1/2] feat: add Front connector support - OAuth and API key
authorization - will retrieve all teammates /teammates
Signed-off-by: Steven4Hooisma <112615049+Steven4Hooisma@users.noreply.github.com>
---
.env.example | 2 +
packages/ui/src/Atoms/ThirdParties/Front.tsx | 13 +
.../src/Atoms/ThirdParties/ThirdPartyLogo.tsx | 2 +
packages/ui/src/Atoms/ThirdParties/index.ts | 1 +
pkg/accessreview/drivers/front.go | 316 ++++++++++++++++++
pkg/accessreview/drivers/front_test.go | 303 +++++++++++++++++
.../drivers/pagination_guard_test.go | 10 +
pkg/accessreview/drivers/testdata/front.yaml | 67 ++++
pkg/bootstrap/builder.go | 2 +
pkg/bootstrap/builder_test.go | 2 +-
pkg/connector/provider/builtin.go | 1 +
pkg/connector/provider/front.go | 72 ++++
pkg/connector/provider/front_test.go | 84 +++++
pkg/coredata/connector_provider.go | 5 +-
pkg/coredata/migrations/20260812T090000Z.sql | 21 ++
.../api/console/v1/graphql/connector.graphql | 1 +
16 files changed, 900 insertions(+), 2 deletions(-)
create mode 100644 packages/ui/src/Atoms/ThirdParties/Front.tsx
create mode 100644 pkg/accessreview/drivers/front.go
create mode 100644 pkg/accessreview/drivers/front_test.go
create mode 100644 pkg/accessreview/drivers/testdata/front.yaml
create mode 100644 pkg/connector/provider/front.go
create mode 100644 pkg/connector/provider/front_test.go
create mode 100644 pkg/coredata/migrations/20260812T090000Z.sql
diff --git a/.env.example b/.env.example
index 0fb72feae0..56a7115eeb 100644
--- a/.env.example
+++ b/.env.example
@@ -175,6 +175,8 @@
# Square OAuth (EMPLOYEES_READ); customers may also use a personal access token.
# PROBOD_CONNECTOR_SQUARE_CLIENT_ID=
# PROBOD_CONNECTOR_SQUARE_CLIENT_SECRET=
+# PROBOD_CONNECTOR_FRONT_CLIENT_ID=
+# PROBOD_CONNECTOR_FRONT_CLIENT_SECRET=
# PostHog Cloud (US + EU) OAuth needs NO config: it uses the CIMD public-client
# flow (no app registration, no client_secret), auto-enabled when this
# deployment is publicly reachable at PROBOD_BASE_URL. Self-hosted PostHog uses
diff --git a/packages/ui/src/Atoms/ThirdParties/Front.tsx b/packages/ui/src/Atoms/ThirdParties/Front.tsx
new file mode 100644
index 0000000000..184895563e
--- /dev/null
+++ b/packages/ui/src/Atoms/ThirdParties/Front.tsx
@@ -0,0 +1,13 @@
+import type { ComponentProps } from "react";
+
+export function Front(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
index 9bbc8d99c7..e228c8133c 100644
--- a/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
+++ b/packages/ui/src/Atoms/ThirdParties/ThirdPartyLogo.tsx
@@ -38,6 +38,7 @@ import { Deepgram } from "./Deepgram";
import { DocuSign } from "./DocuSign";
import { Dotfile } from "./Dotfile";
import { Figma } from "./Figma";
+import { Front } from "./Front";
import { GitHub } from "./GitHub";
import { GitLab } from "./GitLab";
import { Google } from "./Google";
@@ -101,6 +102,7 @@ const thirdParties: Record>> = {
DOCUSIGN: DocuSign,
DOTFILE: Dotfile,
FIGMA: Figma,
+ FRONT: Front,
GITHUB: GitHub,
GITLAB: GitLab,
GOOGLE: Google,
diff --git a/packages/ui/src/Atoms/ThirdParties/index.ts b/packages/ui/src/Atoms/ThirdParties/index.ts
index b63a00fb6b..8eb683bc46 100644
--- a/packages/ui/src/Atoms/ThirdParties/index.ts
+++ b/packages/ui/src/Atoms/ThirdParties/index.ts
@@ -16,6 +16,7 @@ export { Deepgram } from "./Deepgram";
export { DocuSign } from "./DocuSign";
export { Dotfile } from "./Dotfile";
export { Figma } from "./Figma";
+export { Front } from "./Front";
export { GitHub } from "./GitHub";
export { GitLab } from "./GitLab";
export { Google } from "./Google";
diff --git a/pkg/accessreview/drivers/front.go b/pkg/accessreview/drivers/front.go
new file mode 100644
index 0000000000..ae3ee22f75
--- /dev/null
+++ b/pkg/accessreview/drivers/front.go
@@ -0,0 +1,316 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "go.probo.inc/probo/pkg/coredata"
+)
+
+// FrontDriver reports who holds access to a Front company, from
+// GET /teammates — the Core API's company-wide teammate list, which the
+// connection's credential (OAuth token or API token) is already scoped to.
+//
+// What is reported: every teammate the company exposes, blocked ones included,
+// since classification belongs to the reviewer. `is_blocked` is the API's only
+// account-status signal and maps to Active; `is_admin` maps to IsAdmin and to
+// an "Admin" role label. Front's bot teammates (rules, macros, integrations,
+// OAuth clients — the non-"user"/"visitor" values of `type`) are access holders
+// too and are reported as service accounts, with their type carried as a role
+// qualifier so a reviewer can see which automation the grant belongs to.
+//
+// What is deliberately NOT reported:
+//
+// - Availability. `is_available` is a presence toggle ("away"), not an
+// account state; reading it as Active would mark everyone who logged off
+// as deactivated.
+// - Inbox and team membership. GET /teammates/{id}/inboxes and the team
+// endpoints would describe WHICH resources a teammate reaches, one request
+// per teammate; the campaign question is who has access to Front, and the
+// resources are not access holders.
+// - Custom fields. They are customer-defined and may carry personal data, so
+// they are left undecoded rather than folded into a record.
+//
+// What the API cannot answer: Front exposes no MFA state, no last-login, and
+// no account-creation timestamp on a teammate, so MFAStatus stays UNKNOWN and
+// LastLogin/CreatedAt stay nil rather than being invented.
+type FrontDriver struct {
+ httpClient *http.Client
+ baseURL string
+}
+
+var _ Driver = (*FrontDriver)(nil)
+
+const (
+ frontTeammatesPath = "/teammates"
+ frontMePath = "/me"
+)
+
+// NewFrontDriver builds a driver against baseURL, the Core API origin (e.g.
+// https://api2.frontapp.com).
+func NewFrontDriver(httpClient *http.Client, baseURL string) *FrontDriver {
+ // Copy the caller's client and swap only its transport: the connection's
+ // client carries SSRF protection in both its transport dial check and its
+ // CheckRedirect, and a fresh &http.Client{} would silently drop the second.
+ retryClient := *httpClient
+ retryClient.Transport = &retryRoundTripper{
+ next: httpClient.Transport,
+ maxRetries: 3,
+ }
+
+ return &FrontDriver{
+ httpClient: &retryClient,
+ baseURL: baseURL,
+ }
+}
+
+type (
+ frontTeammatesPage struct {
+ Pagination *struct {
+ Next string `json:"next"`
+ } `json:"_pagination"`
+ Results []frontTeammate `json:"_results"`
+ }
+
+ frontTeammate struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ IsAdmin bool `json:"is_admin"`
+ IsBlocked bool `json:"is_blocked"`
+ Type string `json:"type"`
+ }
+)
+
+func (d *FrontDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) {
+ next, err := url.JoinPath(d.baseURL, frontTeammatesPath)
+ if err != nil {
+ return nil, fmt.Errorf("cannot build front teammates URL: %w", err)
+ }
+
+ var records []AccountRecord
+
+ for range maxPaginationPages {
+ page, err := d.fetchTeammates(ctx, next)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, teammate := range page.Results {
+ if teammate.ID == "" {
+ continue
+ }
+
+ records = append(records, frontAccountRecord(teammate))
+ }
+
+ // An empty page ends the walk regardless of the cursor: the Core API
+ // keeps `_pagination` on the response even where the collection is
+ // unpaginated, and trusting a stale cursor over an empty page would
+ // spin until the guard trips.
+ if page.Pagination == nil || page.Pagination.Next == "" || len(page.Results) == 0 {
+ return records, nil
+ }
+
+ next, err = sameHostNextPageURL("front", d.baseURL, page.Pagination.Next)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return nil, fmt.Errorf("cannot list all front teammates: %w", ErrPaginationLimitReached)
+}
+
+func (d *FrontDriver) fetchTeammates(ctx context.Context, endpoint string) (*frontTeammatesPage, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, fmt.Errorf("cannot create front teammates request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ httpResp, err := d.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cannot execute front teammates request: %w", err)
+ }
+
+ defer func() { _ = httpResp.Body.Close() }()
+
+ if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
+ return nil, fmt.Errorf("cannot fetch front teammates: unexpected status %d", httpResp.StatusCode)
+ }
+
+ var page frontTeammatesPage
+ if err := json.NewDecoder(httpResp.Body).Decode(&page); err != nil {
+ return nil, fmt.Errorf("cannot decode front teammates response: %w", err)
+ }
+
+ return &page, nil
+}
+
+func frontAccountRecord(teammate frontTeammate) AccountRecord {
+ active := !teammate.IsBlocked
+
+ return AccountRecord{
+ Email: teammate.Email,
+ FullName: frontFullName(teammate),
+ Roles: frontRoles(teammate),
+ Active: &active,
+ IsAdmin: teammate.IsAdmin,
+ MFAStatus: coredata.MFAStatusUnknown,
+ AuthMethod: frontAuthMethod(teammate.Type),
+ AccountType: frontAccountType(teammate.Type),
+ ExternalID: teammate.ID,
+ }
+}
+
+// frontFullName joins the name fields, falling back to the "@" mention
+// username: Front's bot teammates carry no first/last name, and an entry with
+// no display name at all is harder for a reviewer to place than one named after
+// the automation.
+func frontFullName(teammate frontTeammate) string {
+ name := strings.TrimSpace(strings.Join([]string{teammate.FirstName, teammate.LastName}, " "))
+ if name != "" {
+ return name
+ }
+
+ return teammate.Username
+}
+
+// frontRoles labels the grant with the only permission Front exposes on a
+// teammate (admin or not), plus the account's type for the non-human ones so a
+// reviewer can tell an integration's access from a rule's.
+func frontRoles(teammate frontTeammate) []string {
+ roles := []string{"Teammate"}
+ if teammate.IsAdmin {
+ roles = []string{"Admin"}
+ }
+
+ if label := frontTypeLabel(teammate.Type); label != "" {
+ roles = append(roles, label)
+ }
+
+ return roles
+}
+
+// frontTypeLabel renders a bot teammate's type as a human-readable qualifier.
+// A human teammate ("user") and a chat visitor ("visitor") need none: the base
+// role already describes them.
+func frontTypeLabel(accountType string) string {
+ switch strings.ToLower(strings.TrimSpace(accountType)) {
+ case "", "user", "visitor":
+ return ""
+ case "ai":
+ return "Type: AI"
+ case "api":
+ return "Type: API"
+ case "bulk_reply":
+ return "Type: Bulk reply"
+ case "csat":
+ return "Type: CSAT"
+ case "smart_csat":
+ return "Type: Smart CSAT"
+ default:
+ // The remaining documented values (application, integration, macro,
+ // rule) and any type Front adds later read fine capitalised.
+ return "Type: " + strings.ToUpper(accountType[:1]) + accountType[1:]
+ }
+}
+
+// frontAccountType maps Front's teammate type to the account taxonomy. The
+// documented enum is fully enumerated here so a value Front adds later lands on
+// USER — the conservative side, since a wrongly-labelled service account drops
+// a real person out of the human-review path.
+func frontAccountType(accountType string) coredata.AccessReviewEntryAccountType {
+ switch strings.ToLower(strings.TrimSpace(accountType)) {
+ case "ai", "api", "application", "bulk_reply", "csat", "integration", "macro", "rule", "smart_csat":
+ return coredata.AccessReviewEntryAccountTypeServiceAccount
+ default:
+ return coredata.AccessReviewEntryAccountTypeUser
+ }
+}
+
+// frontAuthMethod reports how the account authenticates. Front states nothing
+// about how a human teammate signs in (SSO vs password), so that stays UNKNOWN;
+// a bot teammate acts on behalf of a rule, macro or OAuth client rather than a
+// person, which is exactly SERVICE_ACCOUNT.
+func frontAuthMethod(accountType string) coredata.AccessReviewEntryAuthMethod {
+ if frontAccountType(accountType) == coredata.AccessReviewEntryAccountTypeServiceAccount {
+ return coredata.AccessReviewEntryAuthMethodServiceAccount
+ }
+
+ return coredata.AccessReviewEntryAuthMethodUnknown
+}
+
+// frontNameResolver names the source after the Front company the credential
+// belongs to, from GET /me.
+type frontNameResolver struct {
+ httpClient *http.Client
+ baseURL string
+}
+
+var _ NameResolver = (*frontNameResolver)(nil)
+
+func NewFrontNameResolver(httpClient *http.Client, baseURL string) NameResolver {
+ return &frontNameResolver{httpClient: httpClient, baseURL: baseURL}
+}
+
+func (r *frontNameResolver) ResolveInstanceName(ctx context.Context) (string, error) {
+ endpoint, err := url.JoinPath(r.baseURL, frontMePath)
+ if err != nil {
+ return "", fmt.Errorf("cannot build front identity URL: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return "", fmt.Errorf("cannot create front identity request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ httpResp, err := r.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("cannot execute front identity request: %w", err)
+ }
+
+ defer func() { _ = httpResp.Body.Close() }()
+
+ if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
+ return "", nameStatusError("front identity", httpResp.StatusCode)
+ }
+
+ var resp struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
+ return "", fmt.Errorf("cannot decode front identity response: %w", err)
+ }
+
+ return resp.Name, nil
+}
diff --git a/pkg/accessreview/drivers/front_test.go b/pkg/accessreview/drivers/front_test.go
new file mode 100644
index 0000000000..a0c273346e
--- /dev/null
+++ b/pkg/accessreview/drivers/front_test.go
@@ -0,0 +1,303 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package drivers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.probo.inc/probo/pkg/coredata"
+)
+
+const frontTestBaseURL = "https://api2.frontapp.com"
+
+func TestFrontDriver(t *testing.T) {
+ t.Parallel()
+
+ rec := newRecorder(t, "testdata/front", "FRONT_API_KEY")
+ // Front accepts both an OAuth access token and a company API token as a
+ // Bearer credential. The matcher ignores Authorization, so replay needs none.
+ client := newVCRClient(rec, bearerAuth(os.Getenv("FRONT_API_KEY")))
+
+ records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background())
+ require.NoError(t, err)
+ require.Len(t, records, 6)
+
+ seen := make(map[string]bool, len(records))
+
+ for _, record := range records {
+ require.NotEmpty(t, record.ExternalID, "every record must carry a stable external ID")
+ require.False(t, seen[record.ExternalID], "external IDs must be unique: %q", record.ExternalID)
+ seen[record.ExternalID] = true
+
+ assert.Equal(t, coredata.MFAStatusUnknown, record.MFAStatus, "front exposes no MFA state")
+ assert.Nil(t, record.LastLogin, "front exposes no last-login")
+ assert.Nil(t, record.CreatedAt, "front exposes no creation timestamp")
+ }
+
+ admin := records[0]
+ assert.Equal(t, "tea_1", admin.ExternalID)
+ assert.Equal(t, "alice@example.com", admin.Email)
+ assert.Equal(t, "Alice Admin", admin.FullName)
+ assert.Equal(t, []string{"Admin"}, admin.Roles)
+ assert.True(t, admin.IsAdmin)
+ require.NotNil(t, admin.Active)
+ assert.True(t, *admin.Active)
+ assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, admin.AccountType)
+ assert.Equal(t, coredata.AccessReviewEntryAuthMethodUnknown, admin.AuthMethod)
+
+ member := records[1]
+ assert.Equal(t, "tea_2", member.ExternalID)
+ assert.Equal(t, []string{"Teammate"}, member.Roles)
+ assert.False(t, member.IsAdmin)
+ require.NotNil(t, member.Active)
+ // is_available false is a presence toggle, not an account state.
+ assert.True(t, *member.Active)
+
+ // is_blocked true is Front's only account-status signal, and with no
+ // first/last name the display name falls back to the mention username.
+ blocked := records[2]
+ assert.Equal(t, "tea_3", blocked.ExternalID)
+ assert.Equal(t, "carol_offboarded", blocked.FullName)
+ require.NotNil(t, blocked.Active)
+ assert.False(t, *blocked.Active)
+ assert.Equal(t, coredata.AccessReviewEntryAccountTypeUser, blocked.AccountType)
+
+ // A rule bot holds access too; its type qualifies the grant.
+ rule := records[3]
+ assert.Equal(t, "tea_4", rule.ExternalID)
+ assert.Equal(t, "triage_rule", rule.FullName)
+ assert.Empty(t, rule.Email)
+ assert.Equal(t, []string{"Teammate", "Type: Rule"}, rule.Roles)
+ assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, rule.AccountType)
+ assert.Equal(t, coredata.AccessReviewEntryAuthMethodServiceAccount, rule.AuthMethod)
+
+ // Page 2, reached through _pagination.next.
+ oauthClient := records[4]
+ assert.Equal(t, "tea_5", oauthClient.ExternalID)
+ assert.Equal(t, []string{"Teammate", "Type: API"}, oauthClient.Roles)
+ assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, oauthClient.AccountType)
+
+ application := records[5]
+ assert.Equal(t, "tea_6", application.ExternalID)
+ assert.Equal(t, []string{"Admin", "Type: Application"}, application.Roles)
+ assert.True(t, application.IsAdmin)
+ assert.Equal(t, coredata.AccessReviewEntryAccountTypeServiceAccount, application.AccountType)
+}
+
+// TestFrontDriverTransientFailureAborts pins the completeness guarantee: a
+// short answer returned as a success would mark every missing teammate removed
+// on the next campaign.
+func TestFrontDriverTransientFailureAborts(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ status int
+ body string
+ }{
+ {name: "server error", status: http.StatusInternalServerError, body: `{"_error":{"status":500}}`},
+ {name: "malformed body", status: http.StatusOK, body: `{"_results":`},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: tc.status,
+ Body: io.NopCloser(strings.NewReader(tc.body)),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }, nil
+ })}
+
+ records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background())
+ require.Error(t, err)
+ assert.Nil(t, records)
+ })
+ }
+}
+
+// TestFrontDriverContextCancellation verifies a cancelled context surfaces as
+// the cancellation error rather than a truncated success.
+func TestFrontDriverContextCancellation(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ cancel()
+
+ return nil, ctx.Err()
+ })}
+
+ records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(ctx)
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, context.Canceled))
+ assert.Nil(t, records)
+}
+
+// TestFrontDriverEmptyPageEndsWalk covers a cursor that never clears: Front
+// keeps _pagination on every response, so an empty page must end the walk
+// instead of spinning until the guard trips.
+func TestFrontDriverEmptyPageEndsWalk(t *testing.T) {
+ t.Parallel()
+
+ var requests int
+
+ client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ requests++
+
+ body := `{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=always"},"_results":[]}`
+ if requests == 1 {
+ body = `{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=always"},"_results":[{"id":"tea_1","email":"alice@example.com","first_name":"Alice","last_name":"Admin","type":"user"}]}`
+ }
+
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }, nil
+ })}
+
+ records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background())
+ require.NoError(t, err)
+ require.Len(t, records, 1)
+ assert.Equal(t, 2, requests, "the empty second page must end the walk")
+}
+
+// TestFrontDriverPaginationGuard covers a cursor that keeps returning results:
+// the walk stops at maxPaginationPages with an explicit error rather than
+// looping forever.
+func TestFrontDriverPaginationGuard(t *testing.T) {
+ t.Parallel()
+
+ var requests int
+
+ client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ requests++
+
+ body := fmt.Sprintf(
+ `{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=p%d"},"_results":[{"id":"tea_%d","email":"user%d@example.com","type":"user"}]}`,
+ requests, requests, requests,
+ )
+
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(body)),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }, nil
+ })}
+
+ records, err := NewFrontDriver(client, frontTestBaseURL).ListAccounts(context.Background())
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrPaginationLimitReached)
+ assert.Nil(t, records)
+ assert.Equal(t, maxPaginationPages, requests)
+}
+
+func TestFrontAccountTypeAndAuthMethod(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ accountType string
+ want coredata.AccessReviewEntryAccountType
+ }{
+ {accountType: "user", want: coredata.AccessReviewEntryAccountTypeUser},
+ {accountType: "visitor", want: coredata.AccessReviewEntryAccountTypeUser},
+ // Unset and unrecognised types fall to USER, keeping a real person in
+ // the human-review path.
+ {accountType: "", want: coredata.AccessReviewEntryAccountTypeUser},
+ {accountType: "something_new", want: coredata.AccessReviewEntryAccountTypeUser},
+ {accountType: "ai", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "api", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "APPLICATION", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "bulk_reply", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "csat", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "integration", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "macro", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "rule", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ {accountType: "smart_csat", want: coredata.AccessReviewEntryAccountTypeServiceAccount},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.accountType, func(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, tc.want, frontAccountType(tc.accountType))
+
+ wantAuth := coredata.AccessReviewEntryAuthMethodUnknown
+ if tc.want == coredata.AccessReviewEntryAccountTypeServiceAccount {
+ wantAuth = coredata.AccessReviewEntryAuthMethodServiceAccount
+ }
+
+ assert.Equal(t, wantAuth, frontAuthMethod(tc.accountType))
+ })
+ }
+}
+
+func TestFrontNameResolver(t *testing.T) {
+ t.Parallel()
+
+ t.Run("resolves the company name", func(t *testing.T) {
+ t.Parallel()
+
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ assert.Equal(t, "/me", req.URL.Path)
+
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{"id":"cmp_k30","name":"Dunder Mifflin, Inc."}`)),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }, nil
+ })}
+
+ name, err := NewFrontNameResolver(client, frontTestBaseURL).ResolveInstanceName(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, "Dunder Mifflin, Inc.", name)
+ })
+
+ t.Run("unauthorized is terminal", func(t *testing.T) {
+ t.Parallel()
+
+ client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusUnauthorized,
+ Body: io.NopCloser(strings.NewReader(`{}`)),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }, nil
+ })}
+
+ name, err := NewFrontNameResolver(client, frontTestBaseURL).ResolveInstanceName(context.Background())
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrTerminalNameResolution)
+ assert.Empty(t, name)
+ })
+}
diff --git a/pkg/accessreview/drivers/pagination_guard_test.go b/pkg/accessreview/drivers/pagination_guard_test.go
index 6f3a1a4bd7..346d3c56f6 100644
--- a/pkg/accessreview/drivers/pagination_guard_test.go
+++ b/pkg/accessreview/drivers/pagination_guard_test.go
@@ -93,6 +93,16 @@ func TestDriversRefuseCrossHostPagination(t *testing.T) {
return err
},
},
+ {
+ name: "front teammates",
+ baseURL: "https://api2.frontapp.com",
+ body: `{"_pagination":{"next":"https://evil.example.com/teammates?page_token=abc"},"_results":[{"id":"tea_1","email":"alice@example.com","type":"user"}]}`,
+ listFunc: func(ctx context.Context, client *http.Client, baseURL string) error {
+ _, err := NewFrontDriver(client, baseURL).ListAccounts(ctx)
+
+ return err
+ },
+ },
{
name: "github organization members",
baseURL: "https://api.github.com",
diff --git a/pkg/accessreview/drivers/testdata/front.yaml b/pkg/accessreview/drivers/testdata/front.yaml
new file mode 100644
index 0000000000..29a8d712ed
--- /dev/null
+++ b/pkg/accessreview/drivers/testdata/front.yaml
@@ -0,0 +1,67 @@
+---
+# Hand-authored fixture for the Front access-review driver. Two interactions:
+#
+# 0. GET /teammates — page 1: a human admin, a plain teammate, a blocked
+# teammate with no first/last name (display name falls back to the "@"
+# mention username), and a rule bot. `_pagination.next` points at page 2
+# on the same host, so the driver follows it.
+# 1. GET /teammates?page_token=... — page 2: one OAuth-client (api) bot and a
+# null `next`, ending the walk.
+#
+# Expected: 6 records. Object shapes mirror the documented TeammateResponse
+# schema. Synthetic IDs and .example.com emails only.
+version: 2
+interactions:
+ - id: 0
+ request:
+ proto: HTTP/1.1
+ proto_major: 1
+ proto_minor: 1
+ content_length: 0
+ host: api2.frontapp.com
+ headers:
+ Accept:
+ - application/json
+ url: https://api2.frontapp.com/teammates
+ method: GET
+ response:
+ proto: HTTP/2.0
+ proto_major: 2
+ proto_minor: 0
+ content_length: -1
+ uncompressed: true
+ body: '{"_pagination":{"next":"https://api2.frontapp.com/teammates?page_token=9fa92a7f385fd7be43f7153055b30e6d"},"_links":{"self":"https://api2.frontapp.com/teammates"},"_results":[{"_links":{"self":"https://api2.frontapp.com/teammates/tea_1"},"id":"tea_1","email":"alice@example.com","username":"alice","first_name":"Alice","last_name":"Admin","is_admin":true,"is_available":true,"is_blocked":false,"type":"user","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_2"},"id":"tea_2","email":"bob@example.com","username":"bob","first_name":"Bob","last_name":"Member","is_admin":false,"is_available":false,"is_blocked":false,"type":"user","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_3"},"id":"tea_3","email":"carol@example.com","username":"carol_offboarded","first_name":"","last_name":"","is_admin":false,"is_available":false,"is_blocked":true,"type":"user","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_4"},"id":"tea_4","email":"","username":"triage_rule","first_name":"","last_name":"","is_admin":false,"is_available":true,"is_blocked":false,"type":"rule","custom_fields":{}}]}'
+ headers:
+ Content-Type:
+ - application/json
+ status: 200 OK
+ code: 200
+ duration: 110ms
+ - id: 1
+ request:
+ proto: HTTP/1.1
+ proto_major: 1
+ proto_minor: 1
+ content_length: 0
+ host: api2.frontapp.com
+ form:
+ page_token:
+ - 9fa92a7f385fd7be43f7153055b30e6d
+ headers:
+ Accept:
+ - application/json
+ url: https://api2.frontapp.com/teammates?page_token=9fa92a7f385fd7be43f7153055b30e6d
+ method: GET
+ response:
+ proto: HTTP/2.0
+ proto_major: 2
+ proto_minor: 0
+ content_length: -1
+ uncompressed: true
+ body: '{"_pagination":{"next":null},"_links":{"self":"https://api2.frontapp.com/teammates?page_token=9fa92a7f385fd7be43f7153055b30e6d"},"_results":[{"_links":{"self":"https://api2.frontapp.com/teammates/tea_5"},"id":"tea_5","email":"","username":"probo_integration","first_name":"","last_name":"","is_admin":false,"is_available":true,"is_blocked":false,"type":"api","custom_fields":{}},{"_links":{"self":"https://api2.frontapp.com/teammates/tea_6"},"id":"tea_6","email":"dave@example.com","username":"dave","first_name":"Dave","last_name":"Integration","is_admin":true,"is_available":true,"is_blocked":false,"type":"application","custom_fields":{}}]}'
+ headers:
+ Content-Type:
+ - application/json
+ status: 200 OK
+ code: 200
+ duration: 95ms
diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go
index 30d9c6444a..c7d82ad584 100644
--- a/pkg/bootstrap/builder.go
+++ b/pkg/bootstrap/builder.go
@@ -508,6 +508,7 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) {
"LINEAR",
"GOOGLE_ANALYTICS",
"SQUARE",
+ "FRONT",
} {
clientID := b.resolver.getEnv("PROBOD_CONNECTOR_" + provider + "_CLIENT_ID")
if clientID == "" {
@@ -645,6 +646,7 @@ func (b *Builder) validateRequired() error {
{"CONNECTOR_LINEAR", []string{"CLIENT_SECRET"}},
{"CONNECTOR_GOOGLE_ANALYTICS", []string{"CLIENT_SECRET"}},
{"CONNECTOR_SQUARE", []string{"CLIENT_SECRET"}},
+ {"CONNECTOR_FRONT", []string{"CLIENT_SECRET"}},
{"CONNECTOR_VERCEL", []string{"CLIENT_SECRET", "INTEGRATION_SLUG"}},
}
diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go
index 0403b0a97c..f6edf9816b 100644
--- a/pkg/bootstrap/builder_test.go
+++ b/pkg/bootstrap/builder_test.go
@@ -636,7 +636,7 @@ func TestBuilder_Build_AccessReviewConnectors(t *testing.T) {
providers := []string{
"GITLAB", "BITBUCKET", "HEROKU", "PAGERDUTY",
"ASANA", "NETLIFY", "CLICKUP", "MONDAY", "DATADOG",
- "ZENDESK", "LINEAR", "GOOGLE_ANALYTICS", "SQUARE",
+ "ZENDESK", "LINEAR", "GOOGLE_ANALYTICS", "SQUARE", "FRONT",
}
env := requiredEnv()
diff --git a/pkg/connector/provider/builtin.go b/pkg/connector/provider/builtin.go
index 8398ca5ad7..8b8f00720f 100644
--- a/pkg/connector/provider/builtin.go
+++ b/pkg/connector/provider/builtin.go
@@ -73,6 +73,7 @@ func NewBuiltinRegistryWith(opts ...Option) (*Registry, error) {
deepgramRegistration(),
docusignRegistration(),
dotfileRegistration(),
+ frontRegistration(),
grafanaRegistration(),
githubRegistration(),
gitlabRegistration(),
diff --git a/pkg/connector/provider/front.go b/pkg/connector/provider/front.go
new file mode 100644
index 0000000000..af73d7dfc1
--- /dev/null
+++ b/pkg/connector/provider/front.go
@@ -0,0 +1,72 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package provider
+
+import (
+ "context"
+ "net/http"
+
+ "go.gearno.de/kit/log"
+ "go.probo.inc/probo/pkg/accessreview/drivers"
+ "go.probo.inc/probo/pkg/coredata"
+)
+
+// frontRegistration wires Front (the shared-inbox platform) as an access
+// source. Both credential paths are offered because either is enough on its
+// own: an OAuth app grants the `teammates:read` scope the driver needs, and a
+// company API token (Settings > API) carries the same read access for customers
+// who would rather not install an app.
+//
+// Front authorizes and exchanges on app.frontapp.com while the Core API lives
+// on api2.frontapp.com — a different host, so neither is derived from the other.
+// The token exchange authenticates with HTTP Basic client credentials (Front
+// documents it as required), which "basic-form" covers with the RFC 6749
+// form-encoded body; Front's docs list the body parameters without naming a
+// content type.
+//
+// Scopes are NOT requested per-authorization: Front resolves an OAuth token's
+// scopes from the app's own configuration, so an authorize URL carrying a scope
+// parameter would be neither honoured nor needed. The operator grants
+// `teammates:read` when registering the app.
+func frontRegistration() *Registration {
+ return &Registration{
+ Provider: coredata.ConnectorProviderFront,
+ DisplayName: "Front",
+ // No probo.com docs page for Front yet; a 404-ing link is worse than
+ // none, so this stays empty until the page ships.
+ Endpoints: Endpoints{
+ Auth: "https://app.frontapp.com/oauth/authorize",
+ Token: "https://app.frontapp.com/oauth/token",
+ // GET /me is the cheapest call that proves the credential reaches
+ // the company, and it is the same call the name resolver opens with.
+ Probe: "https://api2.frontapp.com/me",
+ APIBase: "https://api2.frontapp.com",
+ },
+ TokenEndpointAuth: "basic-form",
+ SupportsAPIKey: true,
+ NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger, ep Endpoints) (drivers.Driver, error) {
+ return drivers.NewFrontDriver(c, ep.APIBase), nil
+ },
+ NewNameResolver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger, ep Endpoints) drivers.NameResolver {
+ return drivers.NewFrontNameResolver(c, ep.APIBase)
+ },
+ }
+}
diff --git a/pkg/connector/provider/front_test.go b/pkg/connector/provider/front_test.go
new file mode 100644
index 0000000000..46b9f950d1
--- /dev/null
+++ b/pkg/connector/provider/front_test.go
@@ -0,0 +1,84 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+package provider_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.gearno.de/kit/httpclient"
+ "go.probo.inc/probo/pkg/accessreview/drivers"
+ "go.probo.inc/probo/pkg/connector/provider"
+ "go.probo.inc/probo/pkg/coredata"
+)
+
+func TestFrontRegistrationMetadata(t *testing.T) {
+ t.Parallel()
+
+ r := provider.NewBuiltinRegistry()
+ reg, ok := r.Get(coredata.ConnectorProviderFront)
+ require.True(t, ok, "front provider must be registered")
+
+ assert.Equal(t, "Front", reg.DisplayName)
+
+ // Both credential paths are offered: OAuth and a company API token.
+ assert.Equal(t, "https://app.frontapp.com/oauth/authorize", reg.Endpoints.Auth)
+ assert.Equal(t, "https://app.frontapp.com/oauth/token", reg.Endpoints.Token)
+ assert.Equal(t, "basic-form", reg.TokenEndpointAuth, "front requires HTTP Basic client credentials on the token exchange")
+ assert.True(t, reg.SupportsAPIKey)
+
+ // The Core API lives on a different host from the OAuth endpoints.
+ assert.Equal(t, "https://api2.frontapp.com", reg.Endpoints.APIBase)
+ assert.Equal(t, "https://api2.frontapp.com/me", reg.Endpoints.Probe)
+
+ // Front resolves an OAuth token's scopes from the app configuration, so
+ // none are requested per-authorization.
+ assert.Empty(t, reg.OAuth2Scopes)
+ // The company API token is a plain Bearer credential and the token is
+ // already company-scoped, so there is nothing extra to collect.
+ assert.Empty(t, reg.APIKeyExtraSettings)
+ assert.Empty(t, reg.APIKeyHeader)
+ assert.Empty(t, reg.APIKeyAuthScheme)
+ assert.False(t, reg.APIKeyBasicAuth)
+ assert.False(t, reg.APIKeyBasicAuthUserPass)
+}
+
+func TestFrontFactories(t *testing.T) {
+ t.Parallel()
+
+ r := provider.NewBuiltinRegistry()
+ reg, ok := r.Get(coredata.ConnectorProviderFront)
+ require.True(t, ok, "front provider must be registered")
+ require.NotNil(t, reg.NewDriver, "front NewDriver closure must be wired")
+ require.NotNil(t, reg.NewNameResolver, "front NewNameResolver closure must be wired")
+
+ conn := &coredata.Connector{Provider: coredata.ConnectorProviderFront}
+ client := httpclient.DefaultClient(httpclient.WithSSRFProtection())
+
+ drv, err := reg.NewDriver(context.Background(), client, conn, nil, reg.Endpoints)
+ require.NoError(t, err)
+ assert.IsType(t, &drivers.FrontDriver{}, drv)
+
+ resolver := reg.NewNameResolver(context.Background(), client, conn, nil, reg.Endpoints)
+ require.NotNil(t, resolver, "front name resolver must be constructed")
+}
diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go
index 51f0ec099b..246569389a 100644
--- a/pkg/coredata/connector_provider.go
+++ b/pkg/coredata/connector_provider.go
@@ -91,6 +91,7 @@ const (
ConnectorProviderSquare ConnectorProvider = "SQUARE"
ConnectorProviderGoogleAnalytics ConnectorProvider = "GOOGLE_ANALYTICS"
ConnectorProviderUpCloud ConnectorProvider = "UPCLOUD"
+ ConnectorProviderFront ConnectorProvider = "FRONT"
)
var (
@@ -160,6 +161,7 @@ func ConnectorProviders() []ConnectorProvider {
ConnectorProviderSquare,
ConnectorProviderGoogleAnalytics,
ConnectorProviderUpCloud,
+ ConnectorProviderFront,
}
}
@@ -225,7 +227,8 @@ func (v ConnectorProvider) IsValid() bool {
ConnectorProviderSegment,
ConnectorProviderSquare,
ConnectorProviderGoogleAnalytics,
- ConnectorProviderUpCloud:
+ ConnectorProviderUpCloud,
+ ConnectorProviderFront:
return true
}
diff --git a/pkg/coredata/migrations/20260812T090000Z.sql b/pkg/coredata/migrations/20260812T090000Z.sql
new file mode 100644
index 0000000000..c5c7234cd6
--- /dev/null
+++ b/pkg/coredata/migrations/20260812T090000Z.sql
@@ -0,0 +1,21 @@
+-- Copyright (c) 2026 Probo Inc .
+--
+-- Permission is hereby granted, free of charge, to any person obtaining a copy
+-- of this software and associated documentation files (the "Software"), to deal
+-- in the Software without restriction, including without limitation the rights
+-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+-- copies of the Software, and to permit persons to whom the Software is
+-- furnished to do so, subject to the following conditions:
+--
+-- The above copyright notice and this permission notice shall be included in
+-- all copies or substantial portions of the Software.
+--
+-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+-- SOFTWARE.
+
+ALTER TYPE connector_provider ADD VALUE IF NOT EXISTS 'FRONT';
diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql
index 9fbfcad1af..1bd2accba6 100644
--- a/pkg/server/api/console/v1/graphql/connector.graphql
+++ b/pkg/server/api/console/v1/graphql/connector.graphql
@@ -110,6 +110,7 @@ enum ConnectorProvider
)
UPCLOUD
@goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderUpCloud")
+ FRONT @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderFront")
}
type ConnectorProviderInfo {
From ed1d00f27d5dccd9e349d68282beefbb26296ca1 Mon Sep 17 00:00:00 2001
From: grootSH <112615049+Steven4Hooisma@users.noreply.github.com>
Date: Mon, 17 Aug 2026 18:41:24 +0200
Subject: [PATCH 2/2] Apply suggestion from @cubic-dev-ai[bot]
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Signed-off-by: grootSH <112615049+Steven4Hooisma@users.noreply.github.com>
---
pkg/coredata/connector_provider.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go
index f47f4590ae..176fdbf828 100644
--- a/pkg/coredata/connector_provider.go
+++ b/pkg/coredata/connector_provider.go
@@ -230,7 +230,7 @@ func (v ConnectorProvider) IsValid() bool {
ConnectorProviderSquare,
ConnectorProviderGoogleAnalytics,
ConnectorProviderUpCloud,
- ConnectorProviderFront:
+ ConnectorProviderFront,
ConnectorProviderNuki:
return true
}