Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion api/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,45 @@ 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"))

licenseKey := configuration.LicenseKey
if billingEnabled && slug != "" {
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 {
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
}
if !result.SSOAvailable {
_ = render.Render(w, r, util.NewErrorResponse("SSO is not available for this workspace", http.StatusBadRequest))
return
}
licenseKey = result.LicenseKey
}

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,
}
Expand Down
27 changes: 26 additions & 1 deletion api/handlers/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handlers
import (
"errors"
"net/http"
"strings"

"github.com/go-chi/render"

Expand Down Expand Up @@ -116,15 +117,39 @@ 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
}
}

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(),
"client_id": cfg.Auth.GoogleOAuth.ClientID,
"redirect_url": cfg.Auth.GoogleOAuth.RedirectURL,
},
"sso": map[string]interface{}{
"enabled": h.A.Licenser.EnterpriseSSO(),
"enabled": ssoEnabled,
"redirect_url": cfg.Auth.SSO.RedirectURL,
},
}
Expand Down
3 changes: 3 additions & 0 deletions api/handlers/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/pkg/billing/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/pkg/billing/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
7 changes: 7 additions & 0 deletions internal/pkg/billing/models.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
package billing

// WorkspaceConfigData is the workspace_config API response.
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"`
Expand Down
89 changes: 89 additions & 0 deletions services/workspace_slug.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 ResolveWorkspaceBySlug.
type ResolveWorkspaceBySlugDeps struct {
BillingClient billing.Client
OrgRepo datastore.OrganisationRepository
Logger log.StdLogger
Cfg config.Configuration
RefreshDeps RefreshLicenseDataDeps
}

// ResolveWorkspaceBySlugResult is the result of ResolveWorkspaceBySlug.
type ResolveWorkspaceBySlugResult struct {
ExternalID string
LicenseKey string
SSOAvailable bool
Org *datastore.Organisation
}

// ResolveWorkspaceBySlug resolves workspace by slug via billing and syncs license data for the org.
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")
}

org, err := deps.OrgRepo.FetchOrganisationByID(ctx, resp.Data.ExternalID)
if err != nil {
return nil, fmt.Errorf("organisation not found for workspace: %w", err)
}

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)

org, err = deps.OrgRepo.FetchOrganisationByID(ctx, resp.Data.ExternalID)
if err != nil {
org = nil
}

return &ResolveWorkspaceBySlugResult{
ExternalID: resp.Data.ExternalID,
LicenseKey: resp.Data.LicenseKey,
SSOAvailable: resp.Data.SSOAvailable,
Org: org,
}, nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ <h1 class="text-18 font-bold text-neutral-12 mb-24px">Subscriptions</h1>
<div class="w-280px truncate pl-16px" *ngIf="privateService.getProjectDetails?.type === 'outgoing'">{{ subscription.name }}</div>
<div class="flex items-center pl-16px min-w-[370px]" *ngIf="privateService.getProjectDetails?.type === 'incoming'">
<div class="flex items-center">
<span class="max-w-[150px] truncate">{{ subscription.source_metadata.name }}</span>
<div convoy-tag *ngIf="subscription.source_metadata.verifier.type !== 'noop'" class="flex items-center ml-8px bg-neutral-4 !py-2px !px-8px text-10">
{{ subscription.source_metadata.provider || (subscription.source_metadata.verifier.type | sourceValue : 'verifier') }}
<span class="max-w-[150px] truncate">{{ subscription.source_metadata?.name }}</span>
<div convoy-tag *ngIf="subscription.source_metadata?.verifier?.type !== 'noop'" class="flex items-center ml-8px bg-neutral-4 !py-2px !px-8px text-10">
{{ subscription.source_metadata?.provider || ((subscription.source_metadata?.verifier?.type ?? '') | sourceValue : 'verifier') }}
</div>
</div>

Expand Down
16 changes: 15 additions & 1 deletion web/ui/dashboard/src/app/public/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,21 @@
<img *ngIf="disableLoginBtn" src="assets/img/button-loader.gif" alt="loader" class="h-18px" />
</button>

<button *ngIf="isSSOEnabled" convoy-button size="sm" type="button" fill="text" class="w-full" (click)="loginWithSSO()">Login with SSO</button>
<ng-container *ngIf="billingEnabled && !showSlugInput">
<button convoy-button size="sm" type="button" fill="text" class="w-full mt-12px" (click)="showSlugInput = true">Login with SSO</button>
</ng-container>
<ng-container *ngIf="billingEnabled && showSlugInput">
<div convoy-input-field class="mt-12px">
<label for="workspace-slug" convoy-label>Workspace slug</label>
<input type="text" id="workspace-slug" convoy-input autocomplete="off" [(ngModel)]="workspaceSlug" (ngModelChange)="showSlugError = false" placeholder="e.g. my-team" [ngModelOptions]="{standalone: true}" />
<convoy-input-error *ngIf="showSlugError && !workspaceSlug?.trim()">Please enter your workspace slug</convoy-input-error>
</div>
<button convoy-button size="sm" type="button" fill="text" class="w-full mt-12px disabled:!opacity-100" (click)="loginWithSSO()" [disabled]="isSubmittingSSO">
<ng-container *ngIf="!isSubmittingSSO">Continue with SAML SSO</ng-container>
<ng-container *ngIf="isSubmittingSSO">Redirecting…</ng-container>
</button>
</ng-container>
<button *ngIf="!billingEnabled && isSSOEnabled" convoy-button size="sm" type="button" fill="text" class="w-full mt-12px" (click)="loginWithSSO()">Login with SSO</button>

<button *ngIf="isGoogleOAuthEnabled" convoy-button size="sm" type="button" fill="text" class="w-full mt-12px" (click)="loginWithGoogle()" [disabled]="isGoogleSigningIn">
<img *ngIf="!isGoogleSigningIn" src="/assets/img/svg/google.svg" alt="Google icon" class="w-16px h-16px inline mr-8px" />
Expand Down
37 changes: 26 additions & 11 deletions web/ui/dashboard/src/app/public/login/login.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {CommonModule} from '@angular/common';
import {AfterViewInit, Component, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {Router} from '@angular/router';
import {ButtonComponent} from 'src/app/components/button/button.component';
import {
Expand All @@ -23,7 +23,7 @@ import {GeneralService} from 'src/app/services/general/general.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, ButtonComponent, InputFieldDirective, InputDirective, LabelComponent, InputErrorComponent, PasswordInputFieldComponent, LoaderModule],
imports: [CommonModule, FormsModule, ReactiveFormsModule, ButtonComponent, InputFieldDirective, InputDirective, LabelComponent, InputErrorComponent, PasswordInputFieldComponent, LoaderModule],
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss']
})
Expand All @@ -42,6 +42,11 @@ export class LoginComponent implements OnInit, AfterViewInit {
isSSOEnabled = false;
googleClientId = '';
organisations?: ORGANIZATION_DATA[];
billingEnabled = false;
showSlugInput = false;
workspaceSlug = '';
isSubmittingSSO = false;
showSlugError = false;

constructor(
private formBuilder: FormBuilder,
Expand All @@ -56,20 +61,21 @@ export class LoginComponent implements OnInit, AfterViewInit {
) {}

ngOnInit() {
this.licenseService.setLicenses();
this.licenseService.setLicenses(true);
}

async ngAfterViewInit() {

try {
const config = await this.configService.getConfig();
this.billingEnabled = config.billing_enabled ?? false;
this.isGoogleOAuthEnabled = config.auth?.google_oauth?.enabled || false;
this.isSSOEnabled = config.auth?.sso?.enabled || false;
this.googleClientId = config.auth?.google_oauth?.client_id || '';
this.isSignupEnabled = config.auth?.is_signup_enabled || false;

} catch (error) {
console.error('Failed to get config:', error);
this.billingEnabled = false;
this.isGoogleOAuthEnabled = false;
this.isSSOEnabled = false;
this.googleClientId = '';
Expand Down Expand Up @@ -180,26 +186,35 @@ export class LoginComponent implements OnInit, AfterViewInit {

async loginWithSSO() {
localStorage.setItem('AUTH_TYPE', 'login');
const slug = this.billingEnabled ? this.workspaceSlug?.trim() : undefined;

if (this.billingEnabled && !slug) {
this.showSlugError = true;
setTimeout(() => document.getElementById('workspace-slug')?.focus(), 0);
return;
}
this.showSlugError = false;

this.isSubmittingSSO = true;
try {
const res = await this.loginService.loginWithSaml();
const res = await this.loginService.loginWithSaml(slug);

const { redirectUrl } = res.data;
window.open(redirectUrl);
} catch (error: any) {
console.error('SAML login failed:', error);

let errorMessage = 'SAML login failed';
if (error?.message) {
errorMessage = error.message;
} else if (error?.error?.message) {
errorMessage = error.error.message;
}
const errorMessage =
typeof error === 'string'
? error
: error?.message ?? error?.error?.message ?? 'SAML login failed';

this.generalService.showNotification({
message: errorMessage,
style: 'error'
});
} finally {
this.isSubmittingSSO = false;
}
}

Expand Down
Loading
Loading