From 60e7adbd2bf511f39c64f66c31a2b5a06feb9d8c Mon Sep 17 00:00:00 2001 From: Smart Mekiliuwa Date: Sat, 28 Feb 2026 01:26:50 +0100 Subject: [PATCH 1/4] fix(ui): use instance-level license features on login/signup - Add setLicenses(instanceLevelOnly) and getLicenses(orgId?, instanceLevelOnly) - Login and signup call setLicenses(true) so /license/features is called without orgID - Stops wrong org-scoped request on public pages (e.g. stale CONVOY_ORG) --- .../src/app/public/login/login.component.ts | 2 +- .../src/app/public/signup/signup.component.ts | 2 +- .../app/services/licenses/licenses.service.ts | 16 +++++++++------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/web/ui/dashboard/src/app/public/login/login.component.ts b/web/ui/dashboard/src/app/public/login/login.component.ts index 6e0a8affaa..914f52dad4 100644 --- a/web/ui/dashboard/src/app/public/login/login.component.ts +++ b/web/ui/dashboard/src/app/public/login/login.component.ts @@ -56,7 +56,7 @@ export class LoginComponent implements OnInit, AfterViewInit { ) {} ngOnInit() { - this.licenseService.setLicenses(); + this.licenseService.setLicenses(true); } async ngAfterViewInit() { diff --git a/web/ui/dashboard/src/app/public/signup/signup.component.ts b/web/ui/dashboard/src/app/public/signup/signup.component.ts index 97abf1a6b6..3bedf2efe8 100644 --- a/web/ui/dashboard/src/app/public/signup/signup.component.ts +++ b/web/ui/dashboard/src/app/public/signup/signup.component.ts @@ -45,7 +45,7 @@ export class SignupComponent implements OnInit { ) {} ngOnInit(): void { - this.licenseService.setLicenses(); + this.licenseService.setLicenses(true); this.checkGoogleOAuthConfig(); if (!this.licenseService.hasLicense('user_limit')) this.router.navigateByUrl('/login'); diff --git a/web/ui/dashboard/src/app/services/licenses/licenses.service.ts b/web/ui/dashboard/src/app/services/licenses/licenses.service.ts index 91a2860b03..7bd2447e75 100644 --- a/web/ui/dashboard/src/app/services/licenses/licenses.service.ts +++ b/web/ui/dashboard/src/app/services/licenses/licenses.service.ts @@ -8,12 +8,14 @@ import {HTTP_RESPONSE} from 'src/app/models/global.model'; export class LicensesService { constructor(private http: HttpService) {} - getLicenses(orgId?: string): Promise { + getLicenses(orgId?: string, instanceLevelOnly = false): Promise { return new Promise(async (resolve, reject) => { - const fromStorage = this.http.getOrganisation(); - const org = orgId ?? fromStorage?.uid; const query: Record = {}; - if (org) query['orgID'] = org; + if (!instanceLevelOnly) { + const fromStorage = this.http.getOrganisation(); + const org = orgId ?? fromStorage?.uid; + if (org) query['orgID'] = org; + } const queryUndefined = Object.keys(query).length === 0 ? undefined : query; try { const response = await this.http.request({ @@ -28,10 +30,10 @@ export class LicensesService { }); } - - async setLicenses() { + /** Call from login/signup (public pages) so the request uses instance-level only (no orgID). */ + async setLicenses(instanceLevelOnly = false) { try { - const response = await this.getLicenses(); + const response = await this.getLicenses(undefined, instanceLevelOnly); localStorage.setItem('licenses', JSON.stringify(response.data)); } catch {} } From ab2be7af6950616d9d12e0f44c1f079a8723d2cc Mon Sep 17 00:00:00 2001 From: Smart Mekiliuwa Date: Sat, 28 Feb 2026 08:06:48 +0100 Subject: [PATCH 2/4] fix: guard subscription source_metadata.verifier in template --- .../project/subscriptions/subscriptions.component.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/ui/dashboard/src/app/private/pages/project/subscriptions/subscriptions.component.html b/web/ui/dashboard/src/app/private/pages/project/subscriptions/subscriptions.component.html index cf1a2d41d3..3164410c1d 100644 --- a/web/ui/dashboard/src/app/private/pages/project/subscriptions/subscriptions.component.html +++ b/web/ui/dashboard/src/app/private/pages/project/subscriptions/subscriptions.component.html @@ -86,9 +86,9 @@

Subscriptions

{{ subscription.name }}
- {{ subscription.source_metadata.name }} -
- {{ subscription.source_metadata.provider || (subscription.source_metadata.verifier.type | sourceValue : 'verifier') }} + {{ subscription.source_metadata?.name }} +
+ {{ subscription.source_metadata?.provider || ((subscription.source_metadata?.verifier?.type ?? '') | sourceValue : 'verifier') }}
From 511c9480c01b1aacb3bc9859d87e68e9f9e37ce2 Mon Sep 17 00:00:00 2001 From: Smart Mekiliuwa Date: Sat, 28 Feb 2026 10:39:36 +0100 Subject: [PATCH 3/4] feat: workspace slug SSO flow, auth config slug/billing_enabled, billing GetWorkspaceConfigBySlug - Add services/workspace_slug.go: resolve by slug via billing workspace_config - InitSSO: use slug to get license_key + external_id, local orgRepo/orgMemberRepo, clear errors - GetAuthConfiguration: expose billing_enabled and slug for dashboard - Billing client: GetWorkspaceConfigBySlug, WorkspaceConfigData; mock implements it for tests - Login UI: slug input, Continue flow, validation, API error message, button styling - config.service: optional slug param; source.go: guard subscription source_metadata --- api/handlers/auth.go | 44 ++++++++- api/handlers/configuration.go | 28 +++++- api/handlers/source.go | 3 + internal/pkg/billing/client.go | 10 ++ internal/pkg/billing/mock.go | 11 +++ internal/pkg/billing/models.go | 7 ++ services/workspace_slug.go | 95 +++++++++++++++++++ .../src/app/public/login/login.component.html | 16 +++- .../src/app/public/login/login.component.ts | 35 +++++-- .../src/app/public/login/login.service.ts | 6 +- .../src/app/services/config/config.service.ts | 32 ++++--- 11 files changed, 260 insertions(+), 27 deletions(-) create mode 100644 services/workspace_slug.go diff --git a/api/handlers/auth.go b/api/handlers/auth.go index af2ddd2db5..cd427b19dd 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strings" "time" @@ -25,24 +26,65 @@ import ( func (h *Handler) InitSSO(w http.ResponseWriter, r *http.Request) { configuration := h.A.Cfg + billingEnabled := configuration.Billing.Enabled && h.A.BillingClient != nil + slug := strings.TrimSpace(r.URL.Query().Get("slug")) + + fmt.Printf("[InitSSO] billingEnabled=%v slug=%q\n", billingEnabled, slug) + + licenseKey := configuration.LicenseKey + if billingEnabled && slug != "" { + fmt.Printf("[InitSSO] resolving workspace by slug\n") + orgRepo := organisations.New(h.A.Logger, h.A.DB) + orgMemberRepo := organisation_members.New(h.A.Logger, h.A.DB) + result, err := services.ResolveWorkspaceBySlug(r.Context(), slug, services.ResolveWorkspaceBySlugDeps{ + BillingClient: h.A.BillingClient, + OrgRepo: orgRepo, + Logger: h.A.Logger, + Cfg: configuration, + RefreshDeps: services.RefreshLicenseDataDeps{ + OrgMemberRepo: orgMemberRepo, + OrgRepo: orgRepo, + BillingClient: h.A.BillingClient, + Logger: h.A.Logger, + Cfg: configuration, + }, + }) + if err != nil { + fmt.Printf("[InitSSO] workspace resolve failed: %v\n", err) + h.A.Logger.WithError(err).WithField("slug", slug).Debug("InitSSO: workspace resolve failed") + _ = render.Render(w, r, util.NewErrorResponse("Workspace not found", http.StatusBadRequest)) + return + } + fmt.Printf("[InitSSO] resolved externalID=%s SSOAvailable=%v\n", result.ExternalID, result.SSOAvailable) + if !result.SSOAvailable { + fmt.Printf("[InitSSO] SSO not available for workspace\n") + _ = render.Render(w, r, util.NewErrorResponse("SSO is not available for this workspace", http.StatusBadRequest)) + return + } + licenseKey = result.LicenseKey + } + + fmt.Printf("[InitSSO] running LoginUserSSOService\n") lu := services.LoginUserSSOService{ UserRepo: users.New(h.A.Logger, h.A.DB), OrgRepo: organisations.New(h.A.Logger, h.A.DB), OrgMemberRepo: organisation_members.New(h.A.Logger, h.A.DB), JWT: jwt.NewJwt(&configuration.Auth.Jwt, h.A.Cache), ConfigRepo: h.A.ConfigRepo, - LicenseKey: configuration.LicenseKey, + LicenseKey: licenseKey, Host: configuration.Host, Licenser: h.A.Licenser, } resp, err := lu.Run() if err != nil { + fmt.Printf("[InitSSO] LoginUserSSOService.Run failed: %v\n", err) h.A.Logger.WithError(err).Errorf("SSO initialization failed: %v", err) _ = render.Render(w, r, util.NewErrorResponse("Authentication failed", http.StatusForbidden)) return } + fmt.Printf("[InitSSO] success redirect\n") _ = render.Render(w, r, util.NewServerResponse("Get Redirect successful", resp, http.StatusOK)) } diff --git a/api/handlers/configuration.go b/api/handlers/configuration.go index 5fbfe2cf4d..64b1c3a797 100644 --- a/api/handlers/configuration.go +++ b/api/handlers/configuration.go @@ -3,6 +3,7 @@ package handlers import ( "errors" "net/http" + "strings" "github.com/go-chi/render" @@ -116,7 +117,32 @@ func (h *Handler) GetAuthConfiguration(w http.ResponseWriter, r *http.Request) { _ = render.Render(w, r, util.NewErrorResponse("failed to load configuration", http.StatusBadRequest)) return } + billingEnabled := cfg.Billing.Enabled && h.A.BillingClient != nil + slug := strings.TrimSpace(r.URL.Query().Get("slug")) + + ssoEnabled := h.A.Licenser.EnterpriseSSO() + if billingEnabled && slug != "" { + result, err := services.ResolveWorkspaceBySlug(r.Context(), slug, services.ResolveWorkspaceBySlugDeps{ + BillingClient: h.A.BillingClient, + OrgRepo: h.A.OrgRepo, + Logger: h.A.Logger, + Cfg: cfg, + RefreshDeps: services.RefreshLicenseDataDeps{ + OrgMemberRepo: h.A.OrgMemberRepo, + OrgRepo: h.A.OrgRepo, + BillingClient: h.A.BillingClient, + Logger: h.A.Logger, + Cfg: cfg, + }, + }) + if err == nil { + ssoEnabled = result.SSOAvailable + } + // On error (workspace not found, etc.), keep ssoEnabled as instance default or false + } + authConfig := map[string]interface{}{ + "billing_enabled": billingEnabled, "is_signup_enabled": cfg.Auth.IsSignupEnabled, "google_oauth": map[string]interface{}{ "enabled": cfg.Auth.GoogleOAuth.Enabled && h.A.Licenser.GoogleOAuth(), @@ -124,7 +150,7 @@ func (h *Handler) GetAuthConfiguration(w http.ResponseWriter, r *http.Request) { "redirect_url": cfg.Auth.GoogleOAuth.RedirectURL, }, "sso": map[string]interface{}{ - "enabled": h.A.Licenser.EnterpriseSSO(), + "enabled": ssoEnabled, "redirect_url": cfg.Auth.SSO.RedirectURL, }, } diff --git a/api/handlers/source.go b/api/handlers/source.go index 6dc495989b..430165178a 100644 --- a/api/handlers/source.go +++ b/api/handlers/source.go @@ -317,6 +317,9 @@ func (h *Handler) LoadSourcesPaged(w http.ResponseWriter, r *http.Request) { } func fillSourceURL(s *datastore.Source, baseUrl, customDomain string) { + if s == nil { + return + } url := baseUrl if len(customDomain) > 0 { url = customDomain diff --git a/internal/pkg/billing/client.go b/internal/pkg/billing/client.go index 0368a69ca3..5438bff327 100644 --- a/internal/pkg/billing/client.go +++ b/internal/pkg/billing/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" @@ -24,6 +25,7 @@ type Client interface { CreateOrganisation(ctx context.Context, orgData BillingOrganisation) (*Response[BillingOrganisation], error) GetOrganisationLicense(ctx context.Context, orgID string) (*Response[OrganisationLicense], error) GetOrganisation(ctx context.Context, orgID string) (*Response[BillingOrganisation], error) + GetWorkspaceConfigBySlug(ctx context.Context, slug string) (*Response[WorkspaceConfigData], error) UpdateOrganisation(ctx context.Context, orgID string, orgData BillingOrganisation) (*Response[BillingOrganisation], error) UpdateOrganisationTaxID(ctx context.Context, orgID string, taxData UpdateOrganisationTaxIDRequest) (*Response[BillingOrganisation], error) UpdateOrganisationAddress(ctx context.Context, orgID string, addressData UpdateOrganisationAddressRequest) (*Response[BillingOrganisation], error) @@ -157,6 +159,14 @@ func (c *HTTPClient) GetOrganisation(ctx context.Context, orgID string) (*Respon return makeRequest[BillingOrganisation](ctx, c.httpClient, c.config, "GET", fmt.Sprintf("/organisations/%s", orgID), nil) } +func (c *HTTPClient) GetWorkspaceConfigBySlug(ctx context.Context, slug string) (*Response[WorkspaceConfigData], error) { + if slug == "" { + return nil, fmt.Errorf("slug is required") + } + path := fmt.Sprintf("/api/v1/workspace_config?slug=%s", strings.ReplaceAll(url.QueryEscape(slug), "+", "%20")) + return makeRequest[WorkspaceConfigData](ctx, c.httpClient, c.config, "GET", path, nil) +} + func (c *HTTPClient) UpdateOrganisation(ctx context.Context, orgID string, orgData BillingOrganisation) (*Response[BillingOrganisation], error) { return makeRequest[BillingOrganisation](ctx, c.httpClient, c.config, "PUT", fmt.Sprintf("/organisations/%s", orgID), orgData) } diff --git a/internal/pkg/billing/mock.go b/internal/pkg/billing/mock.go index f5928de284..2ded1677aa 100644 --- a/internal/pkg/billing/mock.go +++ b/internal/pkg/billing/mock.go @@ -155,6 +155,17 @@ func (m *MockBillingClient) GetOrganisationLicense(ctx context.Context, orgID st }, nil } +func (m *MockBillingClient) GetWorkspaceConfigBySlug(ctx context.Context, slug string) (*Response[WorkspaceConfigData], error) { + if slug == "" { + return nil, &Error{Message: "slug is required"} + } + return &Response[WorkspaceConfigData]{ + Status: true, + Message: "OK", + Data: WorkspaceConfigData{ExternalID: slug, SSOAvailable: false}, + }, nil +} + func (m *MockBillingClient) UpdateOrganisation(ctx context.Context, orgID string, orgData BillingOrganisation) (*Response[BillingOrganisation], error) { if orgID == "" || orgData.Name == "" { return nil, &Error{Message: "invalid organisation update"} diff --git a/internal/pkg/billing/models.go b/internal/pkg/billing/models.go index c47dce2123..764dd1c7f5 100644 --- a/internal/pkg/billing/models.go +++ b/internal/pkg/billing/models.go @@ -1,5 +1,12 @@ package billing +// WorkspaceConfigData is the response from Overwatch GET /api/v1/workspace_config?slug=... +type WorkspaceConfigData struct { + ExternalID string `json:"external_id"` + LicenseKey string `json:"license_key"` + SSOAvailable bool `json:"sso_available"` +} + type BillingOrganisation struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` diff --git a/services/workspace_slug.go b/services/workspace_slug.go new file mode 100644 index 0000000000..c9faaa6367 --- /dev/null +++ b/services/workspace_slug.go @@ -0,0 +1,95 @@ +package services + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/frain-dev/convoy/config" + "github.com/frain-dev/convoy/datastore" + "github.com/frain-dev/convoy/internal/pkg/billing" + licensesvc "github.com/frain-dev/convoy/internal/pkg/license/service" + "github.com/frain-dev/convoy/pkg/log" +) + +// ResolveWorkspaceBySlugDeps holds dependencies for resolving workspace by slug and syncing Convoy org. +type ResolveWorkspaceBySlugDeps struct { + BillingClient billing.Client + OrgRepo datastore.OrganisationRepository + Logger log.StdLogger + Cfg config.Configuration + // RefreshDeps is used to call RefreshLicenseDataForOrg; can reuse same deps as auth handlers. + RefreshDeps RefreshLicenseDataDeps +} + +// ResolveWorkspaceBySlugResult is the result of resolving a workspace by slug from Overwatch and syncing Convoy. +type ResolveWorkspaceBySlugResult struct { + ExternalID string + LicenseKey string + SSOAvailable bool + Org *datastore.Organisation +} + +// ResolveWorkspaceBySlug calls Overwatch workspace_config by slug, then loads Convoy org by external_id (UID), +// refreshes license_data for that org, and returns the OW payload plus the reloaded org. +// Returns error if slug is empty, OW returns error/404, or Convoy org not found. +func ResolveWorkspaceBySlug(ctx context.Context, slug string, deps ResolveWorkspaceBySlugDeps) (*ResolveWorkspaceBySlugResult, error) { + if slug == "" { + return nil, errors.New("slug is required") + } + if deps.BillingClient == nil { + return nil, errors.New("billing client is required") + } + if deps.OrgRepo == nil { + return nil, errors.New("org repo is required") + } + + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + resp, err := deps.BillingClient.GetWorkspaceConfigBySlug(reqCtx, slug) + if err != nil { + if deps.Logger != nil { + deps.Logger.WithError(err).WithField("slug", slug).Debug("workspace_config by slug failed") + } + return nil, fmt.Errorf("workspace not found: %w", err) + } + if !resp.Status { + return nil, errors.New("workspace not found") + } + if resp.Data.ExternalID == "" { + return nil, errors.New("workspace config missing external_id: ensure the organisation in Overwatch has external_id set to the Convoy organisation UID") + } + + org, err := deps.OrgRepo.FetchOrganisationByID(ctx, resp.Data.ExternalID) + if err != nil { + return nil, fmt.Errorf("organisation not found for workspace: %w", err) + } + + // Refresh Convoy org license_data so local state stays in sync. + defaultKey := deps.Cfg.LicenseKey + billingEnabled := deps.Cfg.Billing.Enabled && deps.RefreshDeps.BillingClient != nil + licClient := licensesvc.NewClient(licensesvc.Config{ + Host: deps.Cfg.LicenseService.Host, + ValidatePath: deps.Cfg.LicenseService.ValidatePath, + Timeout: deps.Cfg.LicenseService.Timeout, + RetryCount: deps.Cfg.LicenseService.RetryCount, + Logger: deps.Logger, + }) + RefreshLicenseDataForOrg(ctx, *org, defaultKey, billingEnabled, deps.RefreshDeps, licClient) + + // Re-load org after refresh. + org, err = deps.OrgRepo.FetchOrganisationByID(ctx, resp.Data.ExternalID) + if err != nil { + // Return the result we have; refresh may have updated anyway. + org = nil + } + + return &ResolveWorkspaceBySlugResult{ + ExternalID: resp.Data.ExternalID, + LicenseKey: resp.Data.LicenseKey, + SSOAvailable: resp.Data.SSOAvailable, + Org: org, + }, nil +} diff --git a/web/ui/dashboard/src/app/public/login/login.component.html b/web/ui/dashboard/src/app/public/login/login.component.html index 344a286ef9..cbd7bb6d69 100644 --- a/web/ui/dashboard/src/app/public/login/login.component.html +++ b/web/ui/dashboard/src/app/public/login/login.component.html @@ -29,7 +29,21 @@ loader - + + + + +
+ + + Please enter your workspace slug +
+ +
+