From 57934c9bd359c30c4063cb36c65e425fa933a99f Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Tue, 7 Jul 2026 17:13:43 -0500 Subject: [PATCH 1/4] feat(google): unify Google Workspace setup into one sign-in Replace the eleven separate Google integration setups (Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Tasks, Chat, Contacts, Meet) with a single Google Workspace page: enter one OAuth client, pick services, and complete one Google consent. The resulting token is fanned out to every selected service so each adapter refreshes independently. Uses a bring-your-own OAuth client model so self-hosted users avoid Google verification, and requests incremental scopes so enabling more services later preserves existing grants. Partial consent enables only the services whose scopes were actually granted. Co-Authored-By: Crush --- AGENTS.md | 1 + config/config.go | 19 ++ config/config_test.go | 31 ++- docs/google-setup.md | 141 ++++++++++ docs/web-ui.md | 1 + googleoauth/family.go | 186 +++++++++++++ googleoauth/family_test.go | 125 +++++++++ googleoauth/oauth.go | 13 +- integrations/gcal/oauth.go | 6 +- integrations/gchat/oauth.go | 11 +- integrations/gdocs/oauth.go | 7 +- integrations/gdrive/oauth.go | 8 +- integrations/gforms/oauth.go | 10 +- integrations/gmail/oauth.go | 5 +- integrations/gmeet/oauth.go | 17 +- integrations/gpeople/oauth.go | 16 +- integrations/gsheets/oauth.go | 7 +- integrations/gslides/oauth.go | 7 +- integrations/gtasks/oauth.go | 10 +- web/templates/pages/gcal_setup.templ | 6 + web/templates/pages/gcal_setup_templ.go | 6 +- web/templates/pages/gchat_setup.templ | 6 + web/templates/pages/gchat_setup_templ.go | 6 +- web/templates/pages/gdocs_setup.templ | 6 + web/templates/pages/gdocs_setup_templ.go | 6 +- web/templates/pages/gdrive_setup.templ | 6 + web/templates/pages/gdrive_setup_templ.go | 6 +- web/templates/pages/gforms_setup.templ | 6 + web/templates/pages/gforms_setup_templ.go | 6 +- web/templates/pages/gmail_setup.templ | 6 + web/templates/pages/gmail_setup_templ.go | 6 +- web/templates/pages/gmeet_setup.templ | 6 + web/templates/pages/gmeet_setup_templ.go | 6 +- web/templates/pages/google_setup.templ | 158 +++++++++++ web/templates/pages/google_setup_templ.go | 249 ++++++++++++++++++ web/templates/pages/gpeople_setup.templ | 6 + web/templates/pages/gpeople_setup_templ.go | 6 +- web/templates/pages/gsheets_setup.templ | 6 + web/templates/pages/gsheets_setup_templ.go | 6 +- web/templates/pages/gslides_setup.templ | 6 + web/templates/pages/gslides_setup_templ.go | 6 +- web/templates/pages/gtasks_setup.templ | 6 + web/templates/pages/gtasks_setup_templ.go | 6 +- web/templates/pages/integrations_list.templ | 45 ++++ .../pages/integrations_list_templ.go | 118 +++++++-- web/web.go | 216 +++++++++++++++ 46 files changed, 1382 insertions(+), 157 deletions(-) create mode 100644 docs/google-setup.md create mode 100644 googleoauth/family.go create mode 100644 googleoauth/family_test.go create mode 100644 web/templates/pages/google_setup.templ create mode 100644 web/templates/pages/google_setup_templ.go diff --git a/AGENTS.md b/AGENTS.md index 5d8fce4e..17517e4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ Co-Authored-By: | [docs/notion-v3-transactions.md](docs/notion-v3-transactions.md) | Debugging Notion v3 400 errors, transaction patterns, ID disambiguation | | [docs/markdown-rendering.md](docs/markdown-rendering.md) | Adding markdown rendering to an integration (decision framework, shared utilities, implementation checklist) | | [docs/web-ui.md](docs/web-ui.md) | Modifying the web config UI | +| [docs/google-setup.md](docs/google-setup.md) | Unified Google Workspace OAuth setup (BYO client, token fan-out) | ## Skills diff --git a/config/config.go b/config/config.go index fc2e8e5a..f65b00f9 100644 --- a/config/config.go +++ b/config/config.go @@ -163,6 +163,25 @@ var envMapping = map[string]map[string]string{ }, } +// googleWorkspaceIntegrations lists the integrations that share one Google +// Cloud OAuth client. The shared GOOGLE_OAUTH_CLIENT_ID / _SECRET env vars are +// fanned out to each of them so a headless/Docker deployment can configure the +// whole Google Workspace suite with two variables. Env names match the hosted +// Switchboard product for parity. +var googleWorkspaceIntegrations = []string{ + "gmail", "gcal", "gdrive", "gdocs", "gsheets", "gslides", + "gforms", "gtasks", "gchat", "gpeople", "gmeet", +} + +func init() { + for _, name := range googleWorkspaceIntegrations { + envMapping[name] = map[string]string{ + mcp.CredKeyClientID: "GOOGLE_OAUTH_CLIENT_ID", + mcp.CredKeyClientSecret: "GOOGLE_OAUTH_CLIENT_SECRET", + } + } +} + // EnvMapping returns the env var mapping table. Useful for documentation and debugging. func EnvMapping() map[string]map[string]string { return envMapping diff --git a/config/config_test.go b/config/config_test.go index 7b8267c6..70434e7e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -349,6 +349,29 @@ func TestEnvOverrides_OverridesEmptyCredentials(t *testing.T) { assert.Equal(t, "dd_env_app", m.cfg.Integrations["datadog"].Credentials["app_key"]) } +func TestEnvOverrides_GoogleSharedClientFansOut(t *testing.T) { + m, _ := newTestManager(t) + m.envLookup = func(key string) string { + switch key { + case "GOOGLE_OAUTH_CLIENT_ID": + return "shared-client-id" + case "GOOGLE_OAUTH_CLIENT_SECRET": + return "shared-secret" + default: + return "" + } + } + + require.NoError(t, m.Load()) + + for _, name := range googleWorkspaceIntegrations { + ic := m.cfg.Integrations[name] + require.NotNil(t, ic, "integration %q missing", name) + assert.Equal(t, "shared-client-id", ic.Credentials[mcp.CredKeyClientID], "client_id for %q", name) + assert.Equal(t, "shared-secret", ic.Credentials[mcp.CredKeyClientSecret], "client_secret for %q", name) + } +} + func TestEnvOverrides_OverridesExistingValues(t *testing.T) { m, path := newTestManager(t) @@ -529,7 +552,13 @@ func TestEnvMapping_ReturnsMapping(t *testing.T) { assert.Equal(t, "VERCEL_TEAM_ID", m["vercel"]["team_id"]) assert.Equal(t, "VERCEL_TEAM_SLUG", m["vercel"]["team_slug"]) assert.Equal(t, "VERCEL_BASE_URL", m["vercel"]["base_url"]) - assert.Len(t, m, 28) + // 28 base integrations + 11 Google Workspace services sharing the + // GOOGLE_OAUTH_CLIENT_ID/SECRET env vars. + assert.Len(t, m, 39) + for _, name := range googleWorkspaceIntegrations { + assert.Equal(t, "GOOGLE_OAUTH_CLIENT_ID", m[name][mcp.CredKeyClientID]) + assert.Equal(t, "GOOGLE_OAUTH_CLIENT_SECRET", m[name][mcp.CredKeyClientSecret]) + } } func TestToolGlobs_PersistThroughSaveLoad(t *testing.T) { diff --git a/docs/google-setup.md b/docs/google-setup.md new file mode 100644 index 00000000..76356622 --- /dev/null +++ b/docs/google-setup.md @@ -0,0 +1,141 @@ +# Google Workspace Setup + +Switchboard connects to Google Workspace services (Gmail, Calendar, Drive, +Docs, Sheets, Slides, Forms, Tasks, Chat, Contacts, and Meet) using **your +own** Google Cloud OAuth client. You create one OAuth client, enable the APIs +you need, sign in once, and every selected service is configured from that +single consent. + +Because you bring your own client, there is no verification or CASA security +assessment required — the app runs in your own Google Cloud project against +your own account. + +The unified setup page lives at **`/integrations/google/setup`** in the web UI. + +--- + +## 1. Create a Google Cloud project + +1. Open the [Google Cloud Console](https://console.cloud.google.com/). +2. Create a new project (or reuse an existing one) and select it. + +## 2. Enable the APIs you want + +Enable an API for each service you plan to connect. In the console go to +**APIs & Services → Library** and enable the ones you need: + +| Service | API to enable | +|-----------|-----------------------------------| +| Gmail | Gmail API | +| Calendar | Google Calendar API | +| Drive | Google Drive API | +| Docs | Google Docs API | +| Sheets | Google Sheets API | +| Slides | Google Slides API | +| Forms | Google Forms API | +| Tasks | Google Tasks API | +| Chat | Google Chat API | +| Contacts | People API | +| Meet | Google Meet API | + +You can enable more later; just re-run the sign-in and Switchboard will request +the additional scopes incrementally (it sends `include_granted_scopes=true`, so +previously granted access is preserved). + +## 3. Configure the OAuth consent screen + +1. Go to **APIs & Services → OAuth consent screen**. +2. Choose **External** user type. +3. Fill in the required app name and support email. +4. Add yourself as a **Test user** (under the Audience/Test users section). + +> **⚠️ 7-day refresh-token expiry in Testing.** While the consent screen is in +> **Testing** status, Google expires refresh tokens after **7 days**. When that +> happens Switchboard's stored tokens stop refreshing and you'll see "Invalid +> token" badges — just sign in again from the setup page. +> +> To avoid the weekly re-auth, **publish your own app** (OAuth consent screen → +> **Publish app**). For a self-hosted, single-user setup you generally do not +> need Google's verification for personal use, and publishing removes the 7-day +> refresh-token cap. + +## 4. Create the OAuth client + +1. Go to **APIs & Services → Credentials**. +2. Click **Create Credentials → OAuth client ID**. +3. Set the application type to **Web application**. +4. Under **Authorized redirect URIs**, add: + + ``` + http://localhost:3847/api/google/oauth/callback + ``` + + Replace `3847` with your configured port if you run Switchboard on a + different one (the default is `3847`; the setup page shows the exact URI to + register). +5. Create the client and copy the **Client ID** and **Client secret**. + +## 5. Enter the client in Switchboard + +You have two options: + +### Option A — Web UI + +1. Open `/integrations/google/setup`. +2. Paste the Client ID and Client Secret and save. +3. Check the services you want, click **Sign in with Google**, and complete the + consent screen. You'll be redirected back and every granted service is + configured automatically. + +### Option B — Environment variables + +Set the shared client once and it fans out to all Google services: + +```sh +export GOOGLE_OAUTH_CLIENT_ID="123456789-abc.apps.googleusercontent.com" +export GOOGLE_OAUTH_CLIENT_SECRET="GOCSPX-..." +``` + +Then complete the sign-in from the web UI to obtain tokens. + +--- + +## Scopes requested per service + +| Service | Scopes | +|-----------|--------| +| Gmail | `https://mail.google.com/` | +| Calendar | `.../auth/calendar` | +| Drive | `.../auth/drive` | +| Docs | `.../auth/documents` | +| Sheets | `.../auth/spreadsheets` | +| Slides | `.../auth/presentations` | +| Forms | `.../auth/forms.body`, `.../auth/forms.responses.readonly` | +| Tasks | `.../auth/tasks` | +| Chat | `.../auth/chat.spaces.readonly`, `.../auth/chat.messages` | +| Contacts | `.../auth/contacts`, `.../auth/contacts.other.readonly`, `.../auth/directory.readonly` | +| Meet | `.../auth/meetings.space.created`, `.../auth/meetings.space.readonly`, `.../auth/meetings.space.settings` | + +(`...` = `https://www.googleapis.com`.) Only the scopes for the services you +select are requested. If Google grants a subset (partial consent), Switchboard +enables only the services whose scopes were actually granted. + +--- + +## How token storage works + +Signing in once obtains a single access/refresh token pair covering all granted +scopes. Switchboard writes that pair into the config entry for **each** granted +service, so every Google adapter can refresh independently. On a 401 each +adapter refreshes its own copy of the token and persists the rotated value. + +## Troubleshooting + +- **"Invalid token" badge after ~7 days** — your consent screen is in Testing + status. Sign in again, or publish your app (see step 3). +- **`redirect_uri_mismatch`** — the redirect URI registered in the console must + exactly match `http://localhost:/api/google/oauth/callback`, including + the port shown on the setup page. +- **A service you enabled isn't connected** — make sure you both enabled its API + (step 2) and left it checked during sign-in; partial grants only enable + services whose scopes were approved. diff --git a/docs/web-ui.md b/docs/web-ui.md index 0ae02ea8..f5e3d77a 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -11,6 +11,7 @@ - `GET /integrations/github/setup` — GitHub Device Flow OAuth - `GET /integrations/linear/setup` — Linear OAuth (PKCE) - `GET /integrations/sentry/setup` — Sentry Device Flow OAuth + - `GET /integrations/google/setup` — Unified Google Workspace setup (one OAuth client, one sign-in, fans tokens out to all selected Google services). See [google-setup.md](google-setup.md). The 11 per-service pages (`/integrations/gmail/setup`, `/integrations/gcal/setup`, etc.) remain but link back to this unified page. - `GET /integrations/slack/setup` — Slack token extraction (Chrome auto-extract, manual browser snippet, direct entry) - `GET /integrations/notion/setup` — Notion token_v2 entry (browser snippet extraction, manual entry) - `GET /integrations/postgres/setup` — Postgres default plus additional aliased connections diff --git a/googleoauth/family.go b/googleoauth/family.go new file mode 100644 index 00000000..f7427bc9 --- /dev/null +++ b/googleoauth/family.go @@ -0,0 +1,186 @@ +package googleoauth + +import ( + "errors" + "sort" + "strings" +) + +// errNoServices is returned when a unified flow is started with no selected +// services (or only unknown names). +var errNoServices = errors.New("googleoauth: no valid Google Workspace services selected") + +// Service describes one Google Workspace integration that can be authorized +// through the unified "Google Workspace" setup flow. The Name matches the +// integration's registry / config key. +type Service struct { + // Name is the integration registry key (e.g. "gmail", "gcal"). + Name string + // DisplayName is the human-facing label shown in the setup UI. + DisplayName string + // Scopes are the OAuth scopes this service requires. They are unioned + // with the scopes of the other selected services into a single consent + // request. + Scopes []string +} + +// gmailScope grants read/write/send access to the user's mail. +const gmailScope = "https://mail.google.com/" + +// GroupName is the flow key used for the unified "Google Workspace" setup +// flow. It is not a real integration — no adapter registers it — so it can +// never collide with a service's own single-integration flow. +const GroupName = "google-workspace" + +// StartGroup begins a unified OAuth + PKCE flow requesting the union of the +// selected services' scopes in a single consent screen. The resulting token +// covers every granted service and is fanned out to each service's config by +// the caller. +func StartGroup(clientID, clientSecret, redirectURI string, serviceNames []string) (*StartResult, error) { + scopes := UnionScopes(serviceNames) + if len(scopes) == 0 { + return nil, errNoServices + } + return Start(Config{ + IntegrationName: GroupName, + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURI: redirectURI, + Scopes: scopes, + }) +} + +// HandleGroupCallback completes the token exchange for the in-progress +// unified flow. +func HandleGroupCallback(code, state string) error { + return HandleCallback(GroupName, code, state) +} + +// PollGroup reports the status of the in-progress unified flow. +func PollGroup() PollResult { + return Poll(GroupName) +} + +// services is the single source of truth for the Google Workspace suite: the +// set of integrations, their display names, and the OAuth scopes each one +// needs. Per-integration oauth.go wrappers reference these scopes so there is +// exactly one place to update when a service's scope set changes. +// +// Order is intentional — it controls the order services appear in the unified +// setup UI (mail/calendar/drive first, productivity apps next, comms last). +var services = []Service{ + {Name: "gmail", DisplayName: "Gmail", Scopes: []string{gmailScope}}, + {Name: "gcal", DisplayName: "Google Calendar", Scopes: []string{ + "https://www.googleapis.com/auth/calendar", + }}, + {Name: "gdrive", DisplayName: "Google Drive", Scopes: []string{ + "https://www.googleapis.com/auth/drive", + }}, + {Name: "gdocs", DisplayName: "Google Docs", Scopes: []string{ + "https://www.googleapis.com/auth/documents", + }}, + {Name: "gsheets", DisplayName: "Google Sheets", Scopes: []string{ + "https://www.googleapis.com/auth/spreadsheets", + }}, + {Name: "gslides", DisplayName: "Google Slides", Scopes: []string{ + "https://www.googleapis.com/auth/presentations", + }}, + {Name: "gforms", DisplayName: "Google Forms", Scopes: []string{ + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.responses.readonly", + }}, + {Name: "gtasks", DisplayName: "Google Tasks", Scopes: []string{ + "https://www.googleapis.com/auth/tasks", + }}, + {Name: "gchat", DisplayName: "Google Chat", Scopes: []string{ + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages", + }}, + {Name: "gpeople", DisplayName: "Google Contacts", Scopes: []string{ + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/directory.readonly", + }}, + {Name: "gmeet", DisplayName: "Google Meet", Scopes: []string{ + "https://www.googleapis.com/auth/meetings.space.created", + "https://www.googleapis.com/auth/meetings.space.readonly", + "https://www.googleapis.com/auth/meetings.space.settings", + }}, +} + +var serviceByName = func() map[string]Service { + m := make(map[string]Service, len(services)) + for _, s := range services { + m[s.Name] = s + } + return m +}() + +// Services returns the ordered list of Google Workspace services available in +// the unified setup flow. The returned slice is a copy; callers may not mutate +// the shared definitions. +func Services() []Service { + out := make([]Service, len(services)) + copy(out, services) + return out +} + +// ScopesFor returns the OAuth scopes for a single integration name, or nil if +// the name is not a known Google Workspace service. +func ScopesFor(name string) []string { + s, ok := serviceByName[name] + if !ok { + return nil + } + out := make([]string, len(s.Scopes)) + copy(out, s.Scopes) + return out +} + +// UnionScopes returns the deduplicated, sorted union of scopes across the +// named services. Unknown names are ignored so a stale UI selection can't +// break the flow. The result is deterministic (sorted) so authorization URLs +// are stable and testable. +func UnionScopes(names []string) []string { + seen := make(map[string]struct{}) + for _, name := range names { + for _, scope := range ScopesFor(name) { + seen[scope] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for scope := range seen { + out = append(out, scope) + } + sort.Strings(out) + return out +} + +// GrantedServices returns the subset of requested service names whose scopes +// are all present in the granted scope string returned by Google. Google lets +// a user deny individual scopes on the consent screen, so a service is only +// considered connected when every scope it needs was actually granted. +func GrantedServices(requested []string, grantedScope string) []string { + granted := make(map[string]struct{}) + for _, s := range strings.Fields(grantedScope) { + granted[s] = struct{}{} + } + var out []string + for _, name := range requested { + scopes := ScopesFor(name) + if len(scopes) == 0 { + continue + } + all := true + for _, scope := range scopes { + if _, ok := granted[scope]; !ok { + all = false + break + } + } + if all { + out = append(out, name) + } + } + return out +} diff --git a/googleoauth/family_test.go b/googleoauth/family_test.go new file mode 100644 index 00000000..469d532c --- /dev/null +++ b/googleoauth/family_test.go @@ -0,0 +1,125 @@ +package googleoauth + +import ( + "net/url" + "reflect" + "sort" + "strings" + "testing" +) + +func TestServices_CoverAllElevenIntegrations(t *testing.T) { + want := []string{ + "gmail", "gcal", "gdrive", "gdocs", "gsheets", "gslides", + "gforms", "gtasks", "gchat", "gpeople", "gmeet", + } + got := make([]string, 0, len(Services())) + for _, s := range Services() { + got = append(got, s.Name) + if s.DisplayName == "" { + t.Errorf("service %q has empty DisplayName", s.Name) + } + if len(s.Scopes) == 0 { + t.Errorf("service %q has no scopes", s.Name) + } + } + sortedWant := append([]string(nil), want...) + sortedGot := append([]string(nil), got...) + sort.Strings(sortedWant) + sort.Strings(sortedGot) + if !reflect.DeepEqual(sortedWant, sortedGot) { + t.Errorf("service names = %v, want %v", sortedGot, sortedWant) + } +} + +func TestScopesFor(t *testing.T) { + if got := ScopesFor("gmail"); len(got) != 1 || got[0] != gmailScope { + t.Errorf("ScopesFor(gmail) = %v", got) + } + if got := ScopesFor("gpeople"); len(got) != 3 { + t.Errorf("ScopesFor(gpeople) = %v, want 3 scopes", got) + } + if got := ScopesFor("unknown"); got != nil { + t.Errorf("ScopesFor(unknown) = %v, want nil", got) + } +} + +func TestUnionScopes_DedupesAndSorts(t *testing.T) { + got := UnionScopes([]string{"gmail", "gcal", "gmail", "unknown"}) + want := []string{ + "https://mail.google.com/", + "https://www.googleapis.com/auth/calendar", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("UnionScopes = %v, want %v", got, want) + } + // Determinism: same input -> same output order. + if !reflect.DeepEqual(UnionScopes([]string{"gcal", "gmail"}), got) { + t.Error("UnionScopes not order-independent") + } +} + +func TestUnionScopes_Empty(t *testing.T) { + if got := UnionScopes(nil); len(got) != 0 { + t.Errorf("UnionScopes(nil) = %v, want empty", got) + } + if got := UnionScopes([]string{"nope"}); len(got) != 0 { + t.Errorf("UnionScopes(unknown) = %v, want empty", got) + } +} + +func TestGrantedServices(t *testing.T) { + requested := []string{"gmail", "gcal", "gpeople"} + // User granted gmail + calendar scopes but only one of gpeople's three. + granted := strings.Join([]string{ + "https://mail.google.com/", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/contacts", + }, " ") + got := GrantedServices(requested, granted) + want := []string{"gmail", "gcal"} + if !reflect.DeepEqual(got, want) { + t.Errorf("GrantedServices = %v, want %v (partial grant must drop gpeople)", got, want) + } +} + +func TestGrantedServices_AllGranted(t *testing.T) { + requested := []string{"gmail", "gcal"} + granted := strings.Join(UnionScopes(requested), " ") + got := GrantedServices(requested, granted) + if !reflect.DeepEqual(got, requested) { + t.Errorf("GrantedServices = %v, want %v", got, requested) + } +} + +func TestStartGroup_UnionScopeAuthURL(t *testing.T) { + Reset() + res, err := StartGroup("cid", "secret", "http://localhost/cb", []string{"gmail", "gcal"}) + if err != nil { + t.Fatalf("StartGroup: %v", err) + } + u, err := url.Parse(res.AuthorizeURL) + if err != nil { + t.Fatalf("parse authorize url: %v", err) + } + q := u.Query() + if q.Get("include_granted_scopes") != "true" { + t.Error("authorize url missing include_granted_scopes=true") + } + scope := q.Get("scope") + if !strings.Contains(scope, "https://mail.google.com/") || + !strings.Contains(scope, "https://www.googleapis.com/auth/calendar") { + t.Errorf("scope = %q, want gmail + calendar union", scope) + } + // Flow is keyed under the group name, not a real integration. + if Poll(GroupName).Status == "no_flow" { + t.Error("group flow not registered under GroupName") + } +} + +func TestStartGroup_NoServices(t *testing.T) { + Reset() + if _, err := StartGroup("cid", "secret", "http://localhost/cb", nil); err == nil { + t.Error("StartGroup with no services should error") + } +} diff --git a/googleoauth/oauth.go b/googleoauth/oauth.go index 661c0d27..6973b074 100644 --- a/googleoauth/oauth.go +++ b/googleoauth/oauth.go @@ -63,7 +63,11 @@ type PollResult struct { Status string `json:"status"` AccessToken string `json:"access_token,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` - Error string `json:"error,omitempty"` + // Scope is the space-separated set of scopes Google actually granted. + // It may be narrower than requested if the user denied individual + // scopes on the consent screen. + Scope string `json:"scope,omitempty"` + Error string `json:"error,omitempty"` } // flow holds the state for a single in-progress OAuth flow. @@ -76,6 +80,7 @@ type flow struct { codeVerifier string accessToken string refreshToken string + scope string err string done bool startedAt time.Time @@ -127,6 +132,10 @@ func Start(cfg Config) (*StartResult, error) { "code_challenge": {codeChallenge}, "code_challenge_method": {"S256"}, "prompt": {"consent"}, + // include_granted_scopes lets a later flow (e.g. adding a service to + // an already-connected Google account) build incrementally on scopes + // the user has previously granted instead of discarding them. + "include_granted_scopes": {"true"}, } f := &flow{ @@ -246,6 +255,7 @@ func HandleCallback(integrationName, code, state string) error { f.accessToken = tokenResp.AccessToken f.refreshToken = tokenResp.RefreshToken + f.scope = tokenResp.Scope f.done = true return nil } @@ -270,6 +280,7 @@ func Poll(integrationName string) PollResult { Status: "complete", AccessToken: f.accessToken, RefreshToken: f.refreshToken, + Scope: f.scope, } } diff --git a/integrations/gcal/oauth.go b/integrations/gcal/oauth.go index a4ebdc02..2123dbd6 100644 --- a/integrations/gcal/oauth.go +++ b/integrations/gcal/oauth.go @@ -15,10 +15,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gcal" - - // gcalScope grants full read/write access to the user's calendars - // and events. - gcalScope = "https://www.googleapis.com/auth/calendar" ) // OAuthStartResult is the wire shape returned by /api/gcal/oauth/start. @@ -34,7 +30,7 @@ func StartGcalOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartResu ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gcalScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gchat/oauth.go b/integrations/gchat/oauth.go index c6adc808..8aca60a2 100644 --- a/integrations/gchat/oauth.go +++ b/integrations/gchat/oauth.go @@ -16,15 +16,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gchat" - - // gchatSpacesScope grants read-only access to the Chat spaces the - // user belongs to (rooms, group DMs, direct messages). Needed to - // enumerate spaces and list members. - gchatSpacesScope = "https://www.googleapis.com/auth/chat.spaces.readonly" - - // gchatMessagesScope grants read/write access to messages within - // spaces the user belongs to. Covers list/get/create/update/delete. - gchatMessagesScope = "https://www.googleapis.com/auth/chat.messages" ) // OAuthStartResult is the wire shape returned by /api/gchat/oauth/start. @@ -40,7 +31,7 @@ func StartGchatOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartRes ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gchatSpacesScope, gchatMessagesScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gdocs/oauth.go b/integrations/gdocs/oauth.go index 6ea77226..a24716ec 100644 --- a/integrations/gdocs/oauth.go +++ b/integrations/gdocs/oauth.go @@ -15,11 +15,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gdocs" - - // gdocsScope grants full read/write access to the user's Google Docs - // documents. Drive scope is intentionally NOT requested here — the - // gdrive integration owns that surface. - gdocsScope = "https://www.googleapis.com/auth/documents" ) // OAuthStartResult is the wire shape returned by /api/gdocs/oauth/start. @@ -35,7 +30,7 @@ func StartGdocsOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartRes ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gdocsScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gdrive/oauth.go b/integrations/gdrive/oauth.go index 7e5804ca..ffc837ff 100644 --- a/integrations/gdrive/oauth.go +++ b/integrations/gdrive/oauth.go @@ -15,12 +15,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gdrive" - - // gdriveScope grants full read/write access to the user's Drive - // files, including ones the user has not authored but has access to. - // Use the narrower drive.file scope if/when we expose a per-tool - // access model. - gdriveScope = "https://www.googleapis.com/auth/drive" ) // OAuthStartResult is the wire shape returned by /api/gdrive/oauth/start. @@ -36,7 +30,7 @@ func StartGdriveOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartRe ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gdriveScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gforms/oauth.go b/integrations/gforms/oauth.go index 6194cb69..977c9dd8 100644 --- a/integrations/gforms/oauth.go +++ b/integrations/gforms/oauth.go @@ -15,14 +15,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gforms" - - // gformsBodyScope grants read/write access to the user's Google - // Forms form structure (items, info, settings). - gformsBodyScope = "https://www.googleapis.com/auth/forms.body" - - // gformsResponsesScope grants read-only access to form responses. - // Required for the responses.list / responses.get endpoints. - gformsResponsesScope = "https://www.googleapis.com/auth/forms.responses.readonly" ) // OAuthStartResult is the wire shape returned by /api/gforms/oauth/start. @@ -39,7 +31,7 @@ func StartGformsOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartRe ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gformsBodyScope, gformsResponsesScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gmail/oauth.go b/integrations/gmail/oauth.go index fd9bba97..e7fe73ad 100644 --- a/integrations/gmail/oauth.go +++ b/integrations/gmail/oauth.go @@ -15,9 +15,6 @@ const ( // adapter. It's used to key the in-progress OAuth flow so flows for // different Google services don't collide. integrationName = "gmail" - - // gmailScope grants full read/write/send access to the user's mail. - gmailScope = "https://mail.google.com/" ) // OAuthStartResult is the wire shape returned by /api/gmail/oauth/start. @@ -34,7 +31,7 @@ func StartGmailOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartRes ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gmailScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gmeet/oauth.go b/integrations/gmeet/oauth.go index 59b72b1f..390926e9 100644 --- a/integrations/gmeet/oauth.go +++ b/integrations/gmeet/oauth.go @@ -16,21 +16,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gmeet" - - // meetingsCreatedScope lets the app create + manage spaces it owns - // and read conference records for those spaces. This is the default - // scope a user-facing meet integration needs. - meetingsCreatedScope = "https://www.googleapis.com/auth/meetings.space.created" - - // meetingsReadonlyScope grants read access to all spaces and - // conference records the user has access to (not just ones created - // by this app). Needed for listing past meetings the user attended. - meetingsReadonlyScope = "https://www.googleapis.com/auth/meetings.space.readonly" - - // meetingsSettingsScope grants permission to update a space's - // configuration (access type, moderation, etc.). Without it, - // gmeet_update_space returns 403. - meetingsSettingsScope = "https://www.googleapis.com/auth/meetings.space.settings" ) // OAuthStartResult is the wire shape returned by /api/gmeet/oauth/start. @@ -46,7 +31,7 @@ func StartGmeetOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartRes ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{meetingsCreatedScope, meetingsReadonlyScope, meetingsSettingsScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gpeople/oauth.go b/integrations/gpeople/oauth.go index a3a3c42d..249a7165 100644 --- a/integrations/gpeople/oauth.go +++ b/integrations/gpeople/oauth.go @@ -16,20 +16,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gpeople" - - // contactsScope grants full read/write access to the authenticated - // user's Google Contacts (the /people/me/connections and the create/ - // update/delete contact endpoints). - contactsScope = "https://www.googleapis.com/auth/contacts" - - // otherContactsScope grants read access to "Other contacts" — auto- - // collected contacts the user has emailed but never explicitly saved. - otherContactsScope = "https://www.googleapis.com/auth/contacts.other.readonly" - - // directoryScope grants read access to the user's Google Workspace - // directory (coworkers in the same organization). Without it, - // listDirectoryPeople / searchDirectoryPeople return 403. - directoryScope = "https://www.googleapis.com/auth/directory.readonly" ) // OAuthStartResult is the wire shape returned by /api/gpeople/oauth/start. @@ -46,7 +32,7 @@ func StartGpeopleOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartR ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{contactsScope, otherContactsScope, directoryScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gsheets/oauth.go b/integrations/gsheets/oauth.go index 0c7b3cad..a85df0fb 100644 --- a/integrations/gsheets/oauth.go +++ b/integrations/gsheets/oauth.go @@ -15,11 +15,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gsheets" - - // gsheetsScope grants full read/write access to the user's Google - // Sheets spreadsheets. Drive scope is intentionally NOT requested - // here — the gdrive integration owns that surface. - gsheetsScope = "https://www.googleapis.com/auth/spreadsheets" ) // OAuthStartResult is the wire shape returned by /api/gsheets/oauth/start. @@ -36,7 +31,7 @@ func StartGsheetsOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartR ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gsheetsScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gslides/oauth.go b/integrations/gslides/oauth.go index a7ee18ff..a5592ce0 100644 --- a/integrations/gslides/oauth.go +++ b/integrations/gslides/oauth.go @@ -15,11 +15,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gslides" - - // gslidesScope grants full read/write access to the user's Google - // Slides presentations. Drive scope is intentionally NOT requested - // here — the gdrive integration owns that surface. - gslidesScope = "https://www.googleapis.com/auth/presentations" ) // OAuthStartResult is the wire shape returned by /api/gslides/oauth/start. @@ -36,7 +31,7 @@ func StartGslidesOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartR ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gslidesScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/integrations/gtasks/oauth.go b/integrations/gtasks/oauth.go index b8902221..5d1bc152 100644 --- a/integrations/gtasks/oauth.go +++ b/integrations/gtasks/oauth.go @@ -15,14 +15,6 @@ const ( // adapter. Each Google service keys its in-progress OAuth flow by // this name so concurrent flows don't collide. integrationName = "gtasks" - - // gtasksScope grants full read/write access to the user's Google - // Tasks resources (tasklists and tasks). The Tasks API does not - // expose a separate read-only scope at the per-service level — - // /auth/tasks.readonly exists but is broader than needed; we use - // the read/write scope so the same token can power both list and - // mutation tools. - gtasksScope = "https://www.googleapis.com/auth/tasks" ) // OAuthStartResult is the wire shape returned by /api/gtasks/oauth/start. @@ -39,7 +31,7 @@ func StartGtasksOAuth(clientID, clientSecret, redirectURI string) (*OAuthStartRe ClientID: clientID, ClientSecret: clientSecret, RedirectURI: redirectURI, - Scopes: []string{gtasksScope}, + Scopes: googleoauth.ScopesFor(integrationName), }) } diff --git a/web/templates/pages/gcal_setup.templ b/web/templates/pages/gcal_setup.templ index 468eebdd..e9b67972 100644 --- a/web/templates/pages/gcal_setup.templ +++ b/web/templates/pages/gcal_setup.templ @@ -49,6 +49,12 @@ templ GcalSetup(page layouts.PageData, data GcalSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gcal_setup_templ.go b/web/templates/pages/gcal_setup_templ.go index adf3a902..3665c1a5 100644 --- a/web/templates/pages/gcal_setup_templ.go +++ b/web/templates/pages/gcal_setup_templ.go @@ -131,7 +131,7 @@ func GcalSetup(page layouts.PageData, data GcalSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GcalSetup(page layouts.PageData, data GcalSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gcal_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gcal_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GcalSetup(page layouts.PageData, data GcalSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gcal_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gcal_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gchat_setup.templ b/web/templates/pages/gchat_setup.templ index b104d341..3f15e4ad 100644 --- a/web/templates/pages/gchat_setup.templ +++ b/web/templates/pages/gchat_setup.templ @@ -49,6 +49,12 @@ templ GchatSetup(page layouts.PageData, data GchatSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gchat_setup_templ.go b/web/templates/pages/gchat_setup_templ.go index 22baf859..682a5fde 100644 --- a/web/templates/pages/gchat_setup_templ.go +++ b/web/templates/pages/gchat_setup_templ.go @@ -131,7 +131,7 @@ func GchatSetup(page layouts.PageData, data GchatSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GchatSetup(page layouts.PageData, data GchatSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gchat_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gchat_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GchatSetup(page layouts.PageData, data GchatSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gchat_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gchat_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gdocs_setup.templ b/web/templates/pages/gdocs_setup.templ index 0536f91b..c5901faf 100644 --- a/web/templates/pages/gdocs_setup.templ +++ b/web/templates/pages/gdocs_setup.templ @@ -49,6 +49,12 @@ templ GdocsSetup(page layouts.PageData, data GdocsSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gdocs_setup_templ.go b/web/templates/pages/gdocs_setup_templ.go index 5c5c496d..b1fffa59 100644 --- a/web/templates/pages/gdocs_setup_templ.go +++ b/web/templates/pages/gdocs_setup_templ.go @@ -131,7 +131,7 @@ func GdocsSetup(page layouts.PageData, data GdocsSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GdocsSetup(page layouts.PageData, data GdocsSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdocs_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdocs_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GdocsSetup(page layouts.PageData, data GdocsSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdocs_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdocs_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gdrive_setup.templ b/web/templates/pages/gdrive_setup.templ index 44287632..7d352210 100644 --- a/web/templates/pages/gdrive_setup.templ +++ b/web/templates/pages/gdrive_setup.templ @@ -49,6 +49,12 @@ templ GdriveSetup(page layouts.PageData, data GdriveSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gdrive_setup_templ.go b/web/templates/pages/gdrive_setup_templ.go index 1afe491e..062c7ae5 100644 --- a/web/templates/pages/gdrive_setup_templ.go +++ b/web/templates/pages/gdrive_setup_templ.go @@ -131,7 +131,7 @@ func GdriveSetup(page layouts.PageData, data GdriveSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GdriveSetup(page layouts.PageData, data GdriveSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdrive_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdrive_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GdriveSetup(page layouts.PageData, data GdriveSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdrive_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gdrive_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gforms_setup.templ b/web/templates/pages/gforms_setup.templ index 69471267..744318f1 100644 --- a/web/templates/pages/gforms_setup.templ +++ b/web/templates/pages/gforms_setup.templ @@ -49,6 +49,12 @@ templ GformsSetup(page layouts.PageData, data GformsSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gforms_setup_templ.go b/web/templates/pages/gforms_setup_templ.go index dacf46ab..26bb5291 100644 --- a/web/templates/pages/gforms_setup_templ.go +++ b/web/templates/pages/gforms_setup_templ.go @@ -131,7 +131,7 @@ func GformsSetup(page layouts.PageData, data GformsSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GformsSetup(page layouts.PageData, data GformsSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gforms_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gforms_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GformsSetup(page layouts.PageData, data GformsSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gforms_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gforms_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gmail_setup.templ b/web/templates/pages/gmail_setup.templ index 3ff6f37d..1ec7fdaf 100644 --- a/web/templates/pages/gmail_setup.templ +++ b/web/templates/pages/gmail_setup.templ @@ -49,6 +49,12 @@ templ GmailSetup(page layouts.PageData, data GmailSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gmail_setup_templ.go b/web/templates/pages/gmail_setup_templ.go index 3e43283a..d24348cf 100644 --- a/web/templates/pages/gmail_setup_templ.go +++ b/web/templates/pages/gmail_setup_templ.go @@ -131,7 +131,7 @@ func GmailSetup(page layouts.PageData, data GmailSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GmailSetup(page layouts.PageData, data GmailSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmail_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmail_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GmailSetup(page layouts.PageData, data GmailSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmail_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmail_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gmeet_setup.templ b/web/templates/pages/gmeet_setup.templ index 61d0defc..929ab9ae 100644 --- a/web/templates/pages/gmeet_setup.templ +++ b/web/templates/pages/gmeet_setup.templ @@ -49,6 +49,12 @@ templ GmeetSetup(page layouts.PageData, data GmeetSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gmeet_setup_templ.go b/web/templates/pages/gmeet_setup_templ.go index 906f7ed2..4c3a3e9a 100644 --- a/web/templates/pages/gmeet_setup_templ.go +++ b/web/templates/pages/gmeet_setup_templ.go @@ -131,7 +131,7 @@ func GmeetSetup(page layouts.PageData, data GmeetSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GmeetSetup(page layouts.PageData, data GmeetSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmeet_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmeet_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GmeetSetup(page layouts.PageData, data GmeetSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmeet_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gmeet_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/google_setup.templ b/web/templates/pages/google_setup.templ new file mode 100644 index 00000000..41cb2e1d --- /dev/null +++ b/web/templates/pages/google_setup.templ @@ -0,0 +1,158 @@ +package pages + +import ( + "github.com/daltoniam/switchboard/web/templates/layouts" + "github.com/daltoniam/switchboard/web/templates/components" + "strconv" +) + +func googleProgressLabel(connected, total int) string { + return strconv.Itoa(connected) + " of " + strconv.Itoa(total) + " connected" +} + +// GoogleServiceStatus describes one Google Workspace service on the unified +// setup page: whether it currently holds a token and whether that token works. +type GoogleServiceStatus struct { + Name string + DisplayName string + Connected bool + Healthy bool +} + +// GoogleSetupData drives the unified Google Workspace setup page. +type GoogleSetupData struct { + // HasOAuth is true once a client_id/secret has been saved. + HasOAuth bool + ClientID string + // RedirectURI is the loopback callback the user must register in the + // Google Cloud Console. + RedirectURI string + // Services is the ordered list of all Google Workspace services with + // their current connection status. + Services []GoogleServiceStatus + // ConnectedCount / TotalCount summarize progress in the header. + ConnectedCount int + TotalCount int + FlashResult string + FlashError string +} + +templ GoogleSetup(page layouts.PageData, data GoogleSetupData) { + @layouts.Base(page) { +
+ ← Back +

Google Workspace Setup

+ if data.ConnectedCount == data.TotalCount && data.TotalCount > 0 { + @components.Badge("All connected", "green") + } else if data.ConnectedCount > 0 { + @components.Badge(googleProgressLabel(data.ConnectedCount, data.TotalCount), "yellow") + } else { + @components.Badge("Not connected", "muted") + } +
+

+ Connect Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Tasks, Chat, + Contacts, and Meet with a single Google sign-in. Enter your OAuth client + once below, pick the services you want, and authorize them all in one + consent screen. +

+ if data.FlashResult != "" { +
{ data.FlashResult }
+ } + if data.FlashError != "" { +
{ data.FlashError }
+ } +
+
1. Google OAuth App
+

+ Create one OAuth 2.0 Client ID in the + Google Cloud Console. + Set the application type to Web application, enable the + APIs for the services you want, and add + { data.RedirectURI } + as an authorized redirect URI. See the + setup guide + to walk through each step. +

+
+ @components.FormGroup("Client ID", "client_id", "text", data.ClientID, "123456789-abc.apps.googleusercontent.com") + @components.FormGroup("Client Secret", "client_secret", "password", "", "GOCSPX-...") + +
+
+ if data.HasOAuth { +
+
2. Choose services & sign in
+

+ Select which Google services to authorize, then sign in once. You'll be + redirected to Google to grant access, then sent back automatically. +

+
+
+ for _, svc := range data.Services { + + } +
+ +
+ +
+ } + + } +} diff --git a/web/templates/pages/google_setup_templ.go b/web/templates/pages/google_setup_templ.go new file mode 100644 index 00000000..e8b36e6d --- /dev/null +++ b/web/templates/pages/google_setup_templ.go @@ -0,0 +1,249 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "github.com/daltoniam/switchboard/web/templates/components" + "github.com/daltoniam/switchboard/web/templates/layouts" + "strconv" +) + +func googleProgressLabel(connected, total int) string { + return strconv.Itoa(connected) + " of " + strconv.Itoa(total) + " connected" +} + +// GoogleServiceStatus describes one Google Workspace service on the unified +// setup page: whether it currently holds a token and whether that token works. +type GoogleServiceStatus struct { + Name string + DisplayName string + Connected bool + Healthy bool +} + +// GoogleSetupData drives the unified Google Workspace setup page. +type GoogleSetupData struct { + // HasOAuth is true once a client_id/secret has been saved. + HasOAuth bool + ClientID string + // RedirectURI is the loopback callback the user must register in the + // Google Cloud Console. + RedirectURI string + // Services is the ordered list of all Google Workspace services with + // their current connection status. + Services []GoogleServiceStatus + // ConnectedCount / TotalCount summarize progress in the header. + ConnectedCount int + TotalCount int + FlashResult string + FlashError string +} + +func GoogleSetup(page layouts.PageData, data GoogleSetupData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
← Back

Google Workspace Setup

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.ConnectedCount == data.TotalCount && data.TotalCount > 0 { + templ_7745c5c3_Err = components.Badge("All connected", "green").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.ConnectedCount > 0 { + templ_7745c5c3_Err = components.Badge(googleProgressLabel(data.ConnectedCount, data.TotalCount), "yellow").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = components.Badge("Not connected", "muted").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Connect Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Tasks, Chat, Contacts, and Meet with a single Google sign-in. Enter your OAuth client once below, pick the services you want, and authorize them all in one consent screen.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.FlashResult != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.FlashResult) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 60, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.FlashError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.FlashError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 63, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
1. Google OAuth App

Create one OAuth 2.0 Client ID in the Google Cloud Console. Set the application type to Web application, enable the APIs for the services you want, and add ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 72, Col: 121} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " as an authorized redirect URI. See the setup guide to walk through each step.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormGroup("Client ID", "client_id", "text", data.ClientID, "123456789-abc.apps.googleusercontent.com").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.FormGroup("Client Secret", "client_secret", "password", "", "GOCSPX-...").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasOAuth { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
2. Choose services & sign in

Select which Google services to authorize, then sign in once. You'll be redirected to Google to grant access, then sent back automatically.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, svc := range data.Services { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base(page).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/web/templates/pages/gpeople_setup.templ b/web/templates/pages/gpeople_setup.templ index 0d5846c6..63f412dc 100644 --- a/web/templates/pages/gpeople_setup.templ +++ b/web/templates/pages/gpeople_setup.templ @@ -49,6 +49,12 @@ templ GpeopleSetup(page layouts.PageData, data GpeopleSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gpeople_setup_templ.go b/web/templates/pages/gpeople_setup_templ.go index 8306de69..5170cdb5 100644 --- a/web/templates/pages/gpeople_setup_templ.go +++ b/web/templates/pages/gpeople_setup_templ.go @@ -131,7 +131,7 @@ func GpeopleSetup(page layouts.PageData, data GpeopleSetupData) templ.Component return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GpeopleSetup(page layouts.PageData, data GpeopleSetupData) templ.Component var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gpeople_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gpeople_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GpeopleSetup(page layouts.PageData, data GpeopleSetupData) templ.Component var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gpeople_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gpeople_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gsheets_setup.templ b/web/templates/pages/gsheets_setup.templ index 24f3d872..ddfc30ee 100644 --- a/web/templates/pages/gsheets_setup.templ +++ b/web/templates/pages/gsheets_setup.templ @@ -49,6 +49,12 @@ templ GsheetsSetup(page layouts.PageData, data GsheetsSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gsheets_setup_templ.go b/web/templates/pages/gsheets_setup_templ.go index 156f50e1..3b44104d 100644 --- a/web/templates/pages/gsheets_setup_templ.go +++ b/web/templates/pages/gsheets_setup_templ.go @@ -131,7 +131,7 @@ func GsheetsSetup(page layouts.PageData, data GsheetsSetupData) templ.Component return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GsheetsSetup(page layouts.PageData, data GsheetsSetupData) templ.Component var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gsheets_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gsheets_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GsheetsSetup(page layouts.PageData, data GsheetsSetupData) templ.Component var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gsheets_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gsheets_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gslides_setup.templ b/web/templates/pages/gslides_setup.templ index 8abb6b40..ac879223 100644 --- a/web/templates/pages/gslides_setup.templ +++ b/web/templates/pages/gslides_setup.templ @@ -49,6 +49,12 @@ templ GslidesSetup(page layouts.PageData, data GslidesSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gslides_setup_templ.go b/web/templates/pages/gslides_setup_templ.go index 308852d4..035d668b 100644 --- a/web/templates/pages/gslides_setup_templ.go +++ b/web/templates/pages/gslides_setup_templ.go @@ -131,7 +131,7 @@ func GslidesSetup(page layouts.PageData, data GslidesSetupData) templ.Component return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GslidesSetup(page layouts.PageData, data GslidesSetupData) templ.Component var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gslides_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gslides_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GslidesSetup(page layouts.PageData, data GslidesSetupData) templ.Component var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gslides_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gslides_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/gtasks_setup.templ b/web/templates/pages/gtasks_setup.templ index 064c13bb..f4b00304 100644 --- a/web/templates/pages/gtasks_setup.templ +++ b/web/templates/pages/gtasks_setup.templ @@ -49,6 +49,12 @@ templ GtasksSetup(page layouts.PageData, data GtasksSetupData) { if data.FlashError != "" {
{ data.FlashError }
} +
+ This per-service page still works, but you can now connect all Google + services at once from the new + Google Workspace setup + page — one OAuth client, one sign-in. +
if data.HasToken && data.Healthy {
diff --git a/web/templates/pages/gtasks_setup_templ.go b/web/templates/pages/gtasks_setup_templ.go index 3440d2d3..c7311c7f 100644 --- a/web/templates/pages/gtasks_setup_templ.go +++ b/web/templates/pages/gtasks_setup_templ.go @@ -131,7 +131,7 @@ func GtasksSetup(page layouts.PageData, data GtasksSetupData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
This per-service page still works, but you can now connect all Google services at once from the new Google Workspace setup page — one OAuth client, one sign-in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -156,7 +156,7 @@ func GtasksSetup(page layouts.PageData, data GtasksSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.TokenSource) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gtasks_setup.templ`, Line: 61, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gtasks_setup.templ`, Line: 67, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -179,7 +179,7 @@ func GtasksSetup(page layouts.PageData, data GtasksSetupData) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gtasks_setup.templ`, Line: 73, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/gtasks_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { diff --git a/web/templates/pages/integrations_list.templ b/web/templates/pages/integrations_list.templ index a0095889..8ba95364 100644 --- a/web/templates/pages/integrations_list.templ +++ b/web/templates/pages/integrations_list.templ @@ -12,6 +12,35 @@ type IntegrationsListData struct { Errored []IntegrationSummary Connected []IntegrationSummary Disabled []IntegrationSummary + // Google summarizes the unified Google Workspace group shown as a single + // banner card linking to the consolidated setup page. + Google GoogleGroupSummary +} + +// GoogleGroupSummary describes the collapsed Google Workspace group card. +type GoogleGroupSummary struct { + ConnectedCount int + TotalCount int +} + +func googleGroupLabel(g GoogleGroupSummary) string { + if g.TotalCount > 0 && g.ConnectedCount == g.TotalCount { + return "All connected" + } + if g.ConnectedCount > 0 { + return fmt.Sprintf("%d of %d connected", g.ConnectedCount, g.TotalCount) + } + return "Not connected" +} + +func googleGroupBadge(g GoogleGroupSummary) string { + if g.TotalCount > 0 && g.ConnectedCount == g.TotalCount { + return "green" + } + if g.ConnectedCount > 0 { + return "yellow" + } + return "muted" } func sortByName(items []IntegrationSummary) []IntegrationSummary { @@ -35,6 +64,22 @@ templ IntegrationsList(page layouts.PageData, data IntegrationsListData) {
+ if data.Google.TotalCount > 0 { +
+

Google Workspace

+ +
+ } if len(data.Errored) > 0 {

Needs Attention

diff --git a/web/templates/pages/integrations_list_templ.go b/web/templates/pages/integrations_list_templ.go index ab8ef50e..180e64ed 100644 --- a/web/templates/pages/integrations_list_templ.go +++ b/web/templates/pages/integrations_list_templ.go @@ -20,6 +20,35 @@ type IntegrationsListData struct { Errored []IntegrationSummary Connected []IntegrationSummary Disabled []IntegrationSummary + // Google summarizes the unified Google Workspace group shown as a single + // banner card linking to the consolidated setup page. + Google GoogleGroupSummary +} + +// GoogleGroupSummary describes the collapsed Google Workspace group card. +type GoogleGroupSummary struct { + ConnectedCount int + TotalCount int +} + +func googleGroupLabel(g GoogleGroupSummary) string { + if g.TotalCount > 0 && g.ConnectedCount == g.TotalCount { + return "All connected" + } + if g.ConnectedCount > 0 { + return fmt.Sprintf("%d of %d connected", g.ConnectedCount, g.TotalCount) + } + return "Not connected" +} + +func googleGroupBadge(g GoogleGroupSummary) string { + if g.TotalCount > 0 && g.ConnectedCount == g.TotalCount { + return "green" + } + if g.ConnectedCount > 0 { + return "yellow" + } + return "muted" } func sortByName(items []IntegrationSummary) []IntegrationSummary { @@ -72,8 +101,39 @@ func IntegrationsList(page layouts.PageData, data IntegrationsListData) templ.Co if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if data.Google.TotalCount > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Google Workspace

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } if len(data.Errored) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Needs Attention

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Needs Attention

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -83,17 +143,17 @@ func IntegrationsList(page layouts.PageData, data IntegrationsListData) templ.Co return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(data.Connected) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Connected

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Connected

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -103,17 +163,17 @@ func IntegrationsList(page layouts.PageData, data IntegrationsListData) templ.Co return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(data.Disabled) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Disabled

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

Disabled

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -123,7 +183,7 @@ func IntegrationsList(page layouts.PageData, data IntegrationsListData) templ.Co return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -154,38 +214,38 @@ func integrationCard(i IntegrationSummary) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var3 := templ.GetChildren(ctx) - if templ_7745c5c3_Var3 == nil { - templ_7745c5c3_Var3 = templ.NopComponent + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"integration-card\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(i.Name) + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(i.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/integrations_list.templ`, Line: 74, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/integrations_list.templ`, Line: 119, Col: 30} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -195,7 +255,7 @@ func integrationCard(i IntegrationSummary) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -203,20 +263,20 @@ func integrationCard(i IntegrationSummary) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d tools", i.ToolCount)) + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d tools", i.ToolCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/integrations_list.templ`, Line: 81, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/integrations_list.templ`, Line: 126, Col: 78} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/web.go b/web/web.go index 680b9e2c..7a08bc31 100644 --- a/web/web.go +++ b/web/web.go @@ -10,6 +10,7 @@ import ( "time" mcp "github.com/daltoniam/switchboard" + "github.com/daltoniam/switchboard/googleoauth" "github.com/daltoniam/switchboard/integrations/gcal" "github.com/daltoniam/switchboard/integrations/gchat" "github.com/daltoniam/switchboard/integrations/gdocs" @@ -103,6 +104,11 @@ func (w *WebServer) Handler() http.Handler { mux.HandleFunc("POST /api/sentry/oauth/save", w.handleSentryOAuthSave) mux.HandleFunc("POST /api/sentry/save-token", w.handleSentrySaveToken) + mux.HandleFunc("GET /integrations/google/setup", w.handleGoogleSetup) + mux.HandleFunc("POST /api/google/save-oauth-credentials", w.handleGoogleSaveOAuthCredentials) + mux.HandleFunc("POST /api/google/oauth/start", w.handleGoogleOAuthStart) + mux.HandleFunc("GET /api/google/oauth/callback", w.handleGoogleOAuthCallback) + mux.HandleFunc("GET /integrations/gmail/setup", w.handleGmailSetup) mux.HandleFunc("POST /api/gmail/oauth/start", w.handleGmailOAuthStart) mux.HandleFunc("GET /api/gmail/oauth/callback", w.handleGmailOAuthCallback) @@ -297,7 +303,15 @@ func (w *WebServer) handleIntegrationsList(rw http.ResponseWriter, r *http.Reque summaries := w.integrationSummaries(r.Context()) var errored, connected, disabled []pages.IntegrationSummary + var googleSummary pages.GoogleGroupSummary for _, s := range summaries { + if googleWorkspaceServices[s.Name] { + googleSummary.TotalCount++ + if s.Enabled && s.Healthy { + googleSummary.ConnectedCount++ + } + continue + } if !s.Enabled { disabled = append(disabled, s) } else if s.Healthy { @@ -312,12 +326,24 @@ func (w *WebServer) handleIntegrationsList(rw http.ResponseWriter, r *http.Reque Errored: errored, Connected: connected, Disabled: disabled, + Google: googleSummary, } pages.IntegrationsList(page, data).Render(r.Context(), rw) } const notionExtractionSnippet = `(function(){var c=document.cookie.split(';').find(function(c){return c.trim().startsWith('token_v2=')});if(!c){alert('token_v2 cookie not found. Make sure you are on notion.so and signed in.');return;}var t=c.split('=').slice(1).join('=').trim();prompt('Copy this token_v2 value:',t);})()` +// googleWorkspaceServices is the set of integration names managed by the +// unified Google Workspace setup page. Derived from googleoauth.Services() so +// it stays in sync with the single source of truth for Google scopes. +var googleWorkspaceServices = func() map[string]bool { + m := make(map[string]bool) + for _, s := range googleoauth.Services() { + m[s.Name] = true + } + return m +}() + var setupIntegrations = map[string]bool{ "slack": true, "github": true, @@ -3017,6 +3043,196 @@ func (w *WebServer) handleGmeetSaveOAuthCredentials(rw http.ResponseWriter, r *h http.Redirect(rw, r, "/integrations/gmeet/setup?result=OAuth+credentials+saved.+You+can+now+sign+in+with+Google.", http.StatusSeeOther) } +// --- Unified Google Workspace setup --------------------------------------- +// +// One OAuth client, one consent screen, N services. The client_id/secret is +// written to every Google service's config; a single token is fanned out to +// each service the user authorizes so the existing per-adapter refresh path +// works unchanged. + +const googleSetupPath = "/integrations/google/setup" + +func googleFlash(result, errMsg string) string { + if errMsg != "" { + return googleSetupPath + "?error=" + strings.ReplaceAll(errMsg, " ", "+") + } + return googleSetupPath + "?result=" + strings.ReplaceAll(result, " ", "+") +} + +func (w *WebServer) handleGoogleSetup(rw http.ResponseWriter, r *http.Request) { + svcDefs := googleoauth.Services() + var hasOAuth bool + var clientID string + statuses := make([]pages.GoogleServiceStatus, 0, len(svcDefs)) + connected := 0 + + for _, def := range svcDefs { + ic, exists := w.services.Config.GetIntegration(def.Name) + hasToken := exists && ic.Credentials["access_token"] != "" + if exists && ic.Credentials[mcp.CredKeyClientID] != "" && ic.Credentials[mcp.CredKeyClientSecret] != "" { + hasOAuth = true + if clientID == "" { + clientID = ic.Credentials[mcp.CredKeyClientID] + } + } + + var healthy bool + if hasToken { + if integration, ok := w.services.Registry.Get(def.Name); ok { + if err := integration.Configure(r.Context(), ic.Credentials); err == nil { + healthy = integration.Healthy(r.Context()) + } + } + } + if hasToken { + connected++ + } + statuses = append(statuses, pages.GoogleServiceStatus{ + Name: def.Name, + DisplayName: def.DisplayName, + Connected: hasToken, + Healthy: healthy, + }) + } + + data := pages.GoogleSetupData{ + HasOAuth: hasOAuth, + ClientID: clientID, + RedirectURI: fmt.Sprintf("http://localhost:%d/api/google/oauth/callback", w.port), + Services: statuses, + ConnectedCount: connected, + TotalCount: len(svcDefs), + } + if flash := r.URL.Query().Get("result"); flash != "" { + data.FlashResult = flash + } + if flash := r.URL.Query().Get("error"); flash != "" { + data.FlashError = flash + } + + page := w.pageData(r, "Google Workspace Setup", "/integrations") + pages.GoogleSetup(page, data).Render(r.Context(), rw) +} + +func (w *WebServer) handleGoogleSaveOAuthCredentials(rw http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Redirect(rw, r, googleFlash("", "Invalid form data"), http.StatusSeeOther) + return + } + clientID := strings.TrimSpace(r.FormValue("client_id")) + clientSecret := strings.TrimSpace(r.FormValue("client_secret")) + if clientID == "" || clientSecret == "" { + http.Redirect(rw, r, googleFlash("", "Client ID and Client Secret are required"), http.StatusSeeOther) + return + } + for _, def := range googleoauth.Services() { + ic, _ := w.services.Config.GetIntegration(def.Name) + if ic == nil { + ic = &mcp.IntegrationConfig{Credentials: mcp.Credentials{}} + } + ic.Credentials[mcp.CredKeyClientID] = clientID + ic.Credentials[mcp.CredKeyClientSecret] = clientSecret + _ = w.services.Config.SetIntegration(def.Name, ic) + } + http.Redirect(rw, r, googleFlash("OAuth credentials saved. Choose services and sign in with Google.", ""), http.StatusSeeOther) +} + +func (w *WebServer) handleGoogleOAuthStart(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + + var body struct { + Services []string `json:"services"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Services) == 0 { + json.NewEncoder(rw).Encode(map[string]string{"error": "Select at least one service to connect"}) + return + } + + // Client credentials are shared across all Google services; read them + // from the first selected service that has them configured. + var clientID, clientSecret string + for _, name := range body.Services { + if ic, ok := w.services.Config.GetIntegration(name); ok { + if ic.Credentials[mcp.CredKeyClientID] != "" && ic.Credentials[mcp.CredKeyClientSecret] != "" { + clientID = ic.Credentials[mcp.CredKeyClientID] + clientSecret = ic.Credentials[mcp.CredKeyClientSecret] + break + } + } + } + if clientID == "" || clientSecret == "" { + json.NewEncoder(rw).Encode(map[string]string{"error": "Google OAuth client_id/client_secret not configured"}) + return + } + + redirectURI := fmt.Sprintf("http://localhost:%d/api/google/oauth/callback", w.port) + result, err := googleoauth.StartGroup(clientID, clientSecret, redirectURI, body.Services) + if err != nil { + json.NewEncoder(rw).Encode(map[string]string{"error": err.Error()}) + return + } + json.NewEncoder(rw).Encode(result) +} + +func (w *WebServer) handleGoogleOAuthCallback(rw http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + if code == "" { + errMsg := r.URL.Query().Get("error") + if errMsg == "" { + errMsg = "No authorization code received" + } + http.Redirect(rw, r, googleFlash("", errMsg), http.StatusSeeOther) + return + } + + if err := googleoauth.HandleGroupCallback(code, state); err != nil { + http.Redirect(rw, r, googleFlash("", err.Error()), http.StatusSeeOther) + return + } + + result := googleoauth.PollGroup() + if result.Status != "complete" || result.AccessToken == "" { + http.Redirect(rw, r, googleFlash("", "Failed to get access token"), http.StatusSeeOther) + return + } + + // Only enable services whose scopes Google actually granted. If Google + // did not echo a scope string, fall back to enabling all services the + // flow requested (older token responses omit scope on refresh). + requested := make([]string, 0, len(googleoauth.Services())) + for _, def := range googleoauth.Services() { + requested = append(requested, def.Name) + } + granted := googleoauth.GrantedServices(requested, result.Scope) + if len(granted) == 0 && result.Scope == "" { + granted = requested + } + if len(granted) == 0 { + http.Redirect(rw, r, googleFlash("", "Google did not grant access to any selected service"), http.StatusSeeOther) + return + } + + for _, name := range granted { + ic, _ := w.services.Config.GetIntegration(name) + if ic == nil { + ic = &mcp.IntegrationConfig{Credentials: mcp.Credentials{}} + } + ic.Enabled = true + ic.Credentials["access_token"] = result.AccessToken + if result.RefreshToken != "" { + ic.Credentials["refresh_token"] = result.RefreshToken + } + if result.Scope != "" { + ic.Credentials["scope"] = result.Scope + } + ic.Credentials[mcp.CredKeyTokenSource] = "oauth" + _ = w.services.Config.SetIntegration(name, ic) + } + + http.Redirect(rw, r, googleFlash(fmt.Sprintf("Connected %d Google service(s) via OAuth", len(granted)), ""), http.StatusSeeOther) +} + func (w *WebServer) handleMetricsAPI(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "application/json") if w.services.Metrics == nil { From d2ea157f9f6b7f681d552f4646a02574d91c3885 Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 9 Jul 2026 15:14:16 -0500 Subject: [PATCH 2/4] fix(google): distinguish disabled APIs from invalid tokens in setup A connected service could show "Invalid token" even when the token was perfectly valid, because its Google API was simply not enabled in the user's Cloud project. The setup page now validates the shared token once and shows an actionable "Enable API" hint with guidance instead of a misleading error, and the header progress now counts only services that actually pass their health check. Co-Authored-By: Crush --- googleoauth/family.go | 35 +++++++++++++++ googleoauth/family_test.go | 31 +++++++++++++ web/templates/pages/google_setup.templ | 18 ++++++++ web/templates/pages/google_setup_templ.go | 46 ++++++++++++++----- web/web.go | 54 ++++++++++++++++++++--- 5 files changed, 165 insertions(+), 19 deletions(-) diff --git a/googleoauth/family.go b/googleoauth/family.go index f7427bc9..2e7a38eb 100644 --- a/googleoauth/family.go +++ b/googleoauth/family.go @@ -1,11 +1,46 @@ package googleoauth import ( + "context" "errors" + "net/http" + "net/url" "sort" "strings" + "time" ) +// tokenInfoURL is Google's tokeninfo endpoint. It reports whether an access +// token is currently valid and which scopes it carries. It is a var so tests +// can point it at an httptest server. +var tokenInfoURL = "https://www.googleapis.com/oauth2/v3/tokeninfo" + +// TokenValid reports whether the given Google access token is currently valid +// (not expired or revoked). It does not check any particular API — a token can +// be valid yet still fail an API call because that API is not enabled in the +// Cloud project. Callers use this to distinguish a genuinely bad token from a +// disabled/unpermitted API when a health check fails. +func TokenValid(ctx context.Context, accessToken string) bool { + if accessToken == "" { + return false + } + reqURL := tokenInfoURL + "?access_token=" + url.QueryEscape(accessToken) + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer func() { _ = resp.Body.Close() }() + // tokeninfo returns 200 for a live token and 400 for an + // invalid/expired/revoked one. + return resp.StatusCode == http.StatusOK +} + // errNoServices is returned when a unified flow is started with no selected // services (or only unknown names). var errNoServices = errors.New("googleoauth: no valid Google Workspace services selected") diff --git a/googleoauth/family_test.go b/googleoauth/family_test.go index 469d532c..8eab17bd 100644 --- a/googleoauth/family_test.go +++ b/googleoauth/family_test.go @@ -1,6 +1,9 @@ package googleoauth import ( + "context" + "net/http" + "net/http/httptest" "net/url" "reflect" "sort" @@ -123,3 +126,31 @@ func TestStartGroup_NoServices(t *testing.T) { t.Error("StartGroup with no services should error") } } + +func TestTokenValid(t *testing.T) { + tests := []struct { + name string + token string + statusCode int + want bool + }{ + {name: "empty token", token: "", statusCode: 200, want: false}, + {name: "valid token", token: "good", statusCode: 200, want: true}, + {name: "invalid token", token: "bad", statusCode: 400, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + })) + defer srv.Close() + orig := tokenInfoURL + tokenInfoURL = srv.URL + defer func() { tokenInfoURL = orig }() + + if got := TokenValid(context.Background(), tt.token); got != tt.want { + t.Errorf("TokenValid() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/web/templates/pages/google_setup.templ b/web/templates/pages/google_setup.templ index 41cb2e1d..fe6348dd 100644 --- a/web/templates/pages/google_setup.templ +++ b/web/templates/pages/google_setup.templ @@ -17,6 +17,10 @@ type GoogleServiceStatus struct { DisplayName string Connected bool Healthy bool + // APIDisabled is true when the token is valid but the service's Google + // API call still fails — almost always because the API has not been + // enabled in the Google Cloud project. + APIDisabled bool } // GoogleSetupData drives the unified Google Workspace setup page. @@ -33,6 +37,9 @@ type GoogleSetupData struct { // ConnectedCount / TotalCount summarize progress in the header. ConnectedCount int TotalCount int + // AnyAPIDisabled is true when at least one connected service has a valid + // token but its Google API is not enabled in the Cloud project. + AnyAPIDisabled bool FlashResult string FlashError string } @@ -87,6 +94,15 @@ templ GoogleSetup(page layouts.PageData, data GoogleSetupData) { Select which Google services to authorize, then sign in once. You'll be redirected to Google to grant access, then sent back automatically.

+ if data.AnyAPIDisabled { +
+ Your token is valid, but some services show Enable API. That + means the matching API is not turned on in your Google Cloud project. Open + APIs & Services → Library, enable each service's API + (Gmail API, Google Calendar API, Google Drive API, etc.), wait a minute, then + reload this page. +
+ }
for _, svc := range data.Services { @@ -96,6 +112,8 @@ templ GoogleSetup(page layouts.PageData, data GoogleSetupData) { if svc.Connected { if svc.Healthy { @components.Badge("Connected", "green") + } else if svc.APIDisabled { + @components.Badge("Enable API", "yellow") } else { @components.Badge("Invalid token", "red") } diff --git a/web/templates/pages/google_setup_templ.go b/web/templates/pages/google_setup_templ.go index e8b36e6d..a3f78822 100644 --- a/web/templates/pages/google_setup_templ.go +++ b/web/templates/pages/google_setup_templ.go @@ -25,6 +25,10 @@ type GoogleServiceStatus struct { DisplayName string Connected bool Healthy bool + // APIDisabled is true when the token is valid but the service's Google + // API call still fails — almost always because the API has not been + // enabled in the Google Cloud project. + APIDisabled bool } // GoogleSetupData drives the unified Google Workspace setup page. @@ -41,6 +45,9 @@ type GoogleSetupData struct { // ConnectedCount / TotalCount summarize progress in the header. ConnectedCount int TotalCount int + // AnyAPIDisabled is true when at least one connected service has a valid + // token but its Google API is not enabled in the Cloud project. + AnyAPIDisabled bool FlashResult string FlashError string } @@ -110,7 +117,7 @@ func GoogleSetup(page layouts.PageData, data GoogleSetupData) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.FlashResult) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 60, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 67, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -133,7 +140,7 @@ func GoogleSetup(page layouts.PageData, data GoogleSetupData) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.FlashError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 63, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 70, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -151,7 +158,7 @@ func GoogleSetup(page layouts.PageData, data GoogleSetupData) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.RedirectURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 72, Col: 121} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `web/templates/pages/google_setup.templ`, Line: 79, Col: 121} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -174,38 +181,48 @@ func GoogleSetup(page layouts.PageData, data GoogleSetupData) templ.Component { return templ_7745c5c3_Err } if data.HasOAuth { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
2. Choose services & sign in

Select which Google services to authorize, then sign in once. You'll be redirected to Google to grant access, then sent back automatically.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
2. Choose services & sign in

Select which Google services to authorize, then sign in once. You'll be redirected to Google to grant access, then sent back automatically.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.AnyAPIDisabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Your token is valid, but some services show Enable API. That means the matching API is not turned on in your Google Cloud project. Open APIs & Services → Library, enable each service's API (Gmail API, Google Calendar API, Google Drive API, etc.), wait a minute, then reload this page.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, svc := range data.Services { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/web.go b/web/web.go index 7a08bc31..04acaef0 100644 --- a/web/web.go +++ b/web/web.go @@ -3063,10 +3063,17 @@ func (w *WebServer) handleGoogleSetup(rw http.ResponseWriter, r *http.Request) { svcDefs := googleoauth.Services() var hasOAuth bool var clientID string + var sharedToken string statuses := make([]pages.GoogleServiceStatus, 0, len(svcDefs)) - connected := 0 - for _, def := range svcDefs { + type probe struct { + hasToken bool + healthy bool + } + probes := make([]probe, len(svcDefs)) + anyUnhealthyWithToken := false + + for i, def := range svcDefs { ic, exists := w.services.Config.GetIntegration(def.Name) hasToken := exists && ic.Credentials["access_token"] != "" if exists && ic.Credentials[mcp.CredKeyClientID] != "" && ic.Credentials[mcp.CredKeyClientSecret] != "" { @@ -3078,21 +3085,53 @@ func (w *WebServer) handleGoogleSetup(rw http.ResponseWriter, r *http.Request) { var healthy bool if hasToken { + if sharedToken == "" { + sharedToken = ic.Credentials["access_token"] + } if integration, ok := w.services.Registry.Get(def.Name); ok { if err := integration.Configure(r.Context(), ic.Credentials); err == nil { healthy = integration.Healthy(r.Context()) } } + if !healthy { + anyUnhealthyWithToken = true + } } - if hasToken { - connected++ + probes[i] = probe{hasToken: hasToken, healthy: healthy} + } + + // A service can hold a perfectly valid token yet still report unhealthy + // when its Google API has not been enabled in the Cloud project (a 403 + // "API has not been used ... or it is disabled"). Probe the shared token + // once so the UI can show an actionable "Enable the API" hint instead of + // a misleading "Invalid token" badge. The token is shared across every + // service, so a single probe is authoritative for all of them. + tokenValid := false + if anyUnhealthyWithToken && sharedToken != "" { + tokenValid = googleoauth.TokenValid(r.Context(), sharedToken) + } + + healthyCount := 0 + anyAPIDisabled := false + for i, def := range svcDefs { + p := probes[i] + apiDisabled := p.hasToken && !p.healthy && tokenValid + if apiDisabled { + anyAPIDisabled = true } statuses = append(statuses, pages.GoogleServiceStatus{ Name: def.Name, DisplayName: def.DisplayName, - Connected: hasToken, - Healthy: healthy, + Connected: p.hasToken, + Healthy: p.healthy, + // APIDisabled is true when the token works but this service's + // API call still fails — almost always because the API is not + // enabled in the Google Cloud project. + APIDisabled: apiDisabled, }) + if p.healthy { + healthyCount++ + } } data := pages.GoogleSetupData{ @@ -3100,8 +3139,9 @@ func (w *WebServer) handleGoogleSetup(rw http.ResponseWriter, r *http.Request) { ClientID: clientID, RedirectURI: fmt.Sprintf("http://localhost:%d/api/google/oauth/callback", w.port), Services: statuses, - ConnectedCount: connected, + ConnectedCount: healthyCount, TotalCount: len(svcDefs), + AnyAPIDisabled: anyAPIDisabled, } if flash := r.URL.Query().Get("result"); flash != "" { data.FlashResult = flash From 5d73272b59c5bdaeb0f0729c4ce17e9691cc18d9 Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 9 Jul 2026 15:15:38 -0500 Subject: [PATCH 3/4] docs(google): clarify enabling APIs is separate from granting scopes Call out the most common first-run confusion: approving the consent screen grants scopes but does not enable the underlying APIs, which surfaces as a yellow "Enable API" badge. Add matching troubleshooting guidance. Co-Authored-By: Crush --- docs/google-setup.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/google-setup.md b/docs/google-setup.md index 76356622..87f5efe9 100644 --- a/docs/google-setup.md +++ b/docs/google-setup.md @@ -38,6 +38,15 @@ Enable an API for each service you plan to connect. In the console go to | Contacts | People API | | Meet | Google Meet API | +> **⚠️ Enabling an API is separate from granting a scope.** Signing in and +> approving the consent screen only grants your token the *scopes*; it does +> **not** enable the APIs. If an API is not enabled in your project, calls to it +> return `403 " API has not been used in project ... before or it is +> disabled"` — even though your token is perfectly valid. On the setup page +> these services show a yellow **"Enable API"** badge (not "Invalid token"). +> Enable the API from the Library, wait a minute for it to propagate, then +> reload the page. + You can enable more later; just re-run the sign-in and Switchboard will request the additional scopes incrementally (it sends `include_granted_scopes=true`, so previously granted access is preserved). @@ -131,6 +140,11 @@ adapter refreshes its own copy of the token and persists the rotated value. ## Troubleshooting +- **Yellow "Enable API" badge** — your token is valid but the service's API is + not enabled in your Cloud project. Enable it from **APIs & Services → + Library** (see step 2), wait ~1 minute, then reload the page. This is the most + common first-run issue: granting scopes on the consent screen does not enable + the APIs. - **"Invalid token" badge after ~7 days** — your consent screen is in Testing status. Sign in again, or publish your app (see step 3). - **`redirect_uri_mismatch`** — the redirect URI registered in the console must From 15532907bf97e279e1359f5c93554c53b1c29291 Mon Sep 17 00:00:00 2001 From: Dalton Cherry Date: Thu, 9 Jul 2026 15:35:05 -0500 Subject: [PATCH 4/4] test(google): cover unified Workspace setup web handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add handler tests for the Google Workspace setup page, credential save fan-out, OAuth start guards, and callback error path — closing the test-coverage gap for the new unified setup flow. Co-Authored-By: Claude --- web/web_test.go | 98 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/web/web_test.go b/web/web_test.go index 7b75eb89..e939d0f9 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -13,6 +13,7 @@ import ( "testing" mcp "github.com/daltoniam/switchboard" + "github.com/daltoniam/switchboard/googleoauth" "github.com/daltoniam/switchboard/marketplace" wasmmod "github.com/daltoniam/switchboard/wasm" "github.com/daltoniam/switchboard/web/templates/pages" @@ -802,3 +803,100 @@ func TestClickHouseConnection_DecodesSnakeCase(t *testing.T) { assert.Equal(t, "warehouse.example.com", conns[0].Host) assert.Equal(t, "false", conns[0].SkipVerify) } + +func TestGoogleSetup_Renders(t *testing.T) { + ws, _, _ := setupTestWeb() + handler := ws.Handler() + + req := httptest.NewRequest("GET", "/integrations/google/setup", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Google Workspace") +} + +func TestGoogleSaveOAuthCredentials_FansOutToAllServices(t *testing.T) { + ws, _, cfgService := setupTestWeb() + handler := ws.Handler() + + form := strings.NewReader("client_id=cid.apps.googleusercontent.com&client_secret=secret123") + req := httptest.NewRequest("POST", "/api/google/save-oauth-credentials", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusSeeOther, rr.Code) + assert.Contains(t, rr.Header().Get("Location"), "/integrations/google/setup") + assert.Contains(t, rr.Header().Get("Location"), "result=") + + for _, def := range googleoauth.Services() { + ic, ok := cfgService.GetIntegration(def.Name) + require.Truef(t, ok, "expected %s to be configured", def.Name) + assert.Equal(t, "cid.apps.googleusercontent.com", ic.Credentials[mcp.CredKeyClientID]) + assert.Equal(t, "secret123", ic.Credentials[mcp.CredKeyClientSecret]) + } +} + +func TestGoogleSaveOAuthCredentials_RequiresBothFields(t *testing.T) { + ws, _, cfgService := setupTestWeb() + handler := ws.Handler() + + form := strings.NewReader("client_id=cid&client_secret=") + req := httptest.NewRequest("POST", "/api/google/save-oauth-credentials", form) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusSeeOther, rr.Code) + assert.Contains(t, rr.Header().Get("Location"), "error=") + + _, ok := cfgService.GetIntegration("gmail") + assert.False(t, ok, "no service should be configured when a field is missing") +} + +func TestGoogleOAuthStart_RequiresServices(t *testing.T) { + ws, _, _ := setupTestWeb() + handler := ws.Handler() + + req := httptest.NewRequest("POST", "/api/google/oauth/start", strings.NewReader(`{"services":[]}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "error") + + var resp map[string]string + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp["error"]) +} + +func TestGoogleOAuthStart_RequiresConfiguredClient(t *testing.T) { + ws, _, _ := setupTestWeb() + handler := ws.Handler() + + req := httptest.NewRequest("POST", "/api/google/oauth/start", strings.NewReader(`{"services":["gmail"]}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp map[string]string + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.Contains(t, resp["error"], "client_id") +} + +func TestGoogleOAuthCallback_NoCodeRedirectsWithError(t *testing.T) { + ws, _, _ := setupTestWeb() + handler := ws.Handler() + + req := httptest.NewRequest("GET", "/api/google/oauth/callback?error=access_denied", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusSeeOther, rr.Code) + assert.Contains(t, rr.Header().Get("Location"), "/integrations/google/setup") + assert.Contains(t, rr.Header().Get("Location"), "error=") +}