diff --git a/internal/cloudcp/auth/handlers.go b/internal/cloudcp/auth/handlers.go index 7f307cbef..46e36c4cc 100644 --- a/internal/cloudcp/auth/handlers.go +++ b/internal/cloudcp/auth/handlers.go @@ -40,7 +40,7 @@ type errorResponse struct { // HandleMagicLinkVerify returns an http.HandlerFunc that validates a control-plane // magic link token, generates a short-lived handoff token, and redirects the user // to the tenant container. -func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDir, baseDomain string) http.HandlerFunc { +func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDir, baseDomain, portalPath string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") @@ -64,13 +64,67 @@ func HandleMagicLinkVerify(svc *Service, reg *registry.TenantRegistry, tenantsDi Msg("Magic link verification failed") // Browser redirect on failure. if !strings.Contains(r.Header.Get("Accept"), "application/json") { - http.Redirect(w, r, "/login?error=magic_link_invalid", http.StatusTemporaryRedirect) + http.Redirect(w, r, strings.TrimSpace(portalPath), http.StatusTemporaryRedirect) return } writeError(w, http.StatusBadRequest, "invalid_token", "Invalid or expired magic link") return } + if token.Target == MagicLinkTargetPortal { + redirectPath := strings.TrimSpace(portalPath) + if redirectPath == "" { + redirectPath = "/portal" + } + userID, err := ensurePortalUserAndMembership(reg, token.TenantID, token.Email) + if err != nil { + auditEvent(r, "cp_magic_link_verify", "failure"). + Err(err). + Str("tenant_id", token.TenantID). + Str("reason", "portal_session_identity_failed"). + Msg("Magic link verification failed") + writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session") + return + } + sessionVersion, err := reg.GetUserSessionVersion(userID) + if err != nil { + auditEvent(r, "cp_magic_link_verify", "failure"). + Err(err). + Str("reason", "session_version_lookup_failed"). + Msg("Magic link verification failed") + writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session") + return + } + sessionToken, err := svc.GenerateSessionTokenWithVersion(userID, token.Email, sessionVersion, SessionTTL) + if err != nil { + auditEvent(r, "cp_magic_link_verify", "failure"). + Err(err). + Str("reason", "session_issue_failed"). + Msg("Magic link verification failed") + writeError(w, http.StatusInternalServerError, "session_error", "Unable to establish portal session") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: sessionToken, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(SessionTTL.Seconds()), + }) + + auditEvent(r, "cp_magic_link_verify", "success"). + Str("tenant_id", token.TenantID). + Str("email", token.Email). + Str("target", string(token.Target)). + Msg("Magic link verified, redirecting to portal") + + http.Redirect(w, r, redirectPath, http.StatusTemporaryRedirect) + return + } + // Look up tenant to confirm it exists and is active. tenant, err := reg.Get(token.TenantID) if err != nil || tenant == nil { @@ -328,16 +382,27 @@ func clearSessionCookie(w http.ResponseWriter) { }) } -func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, error) { +func ensurePortalUserAndMembership(reg *registry.TenantRegistry, tenantID, email string) (string, error) { if reg == nil { return "", fmt.Errorf("registry unavailable") } - if tenant == nil { - return "", fmt.Errorf("tenant is required") + email = strings.ToLower(strings.TrimSpace(email)) + tenantID = strings.TrimSpace(tenantID) + if tenantID != "" { + tenant, err := reg.Get(tenantID) + if err != nil { + return "", fmt.Errorf("lookup tenant: %w", err) + } + if tenant != nil { + return ensureAccountUserAndMembership(reg, tenant, email) + } } - accountID := strings.TrimSpace(tenant.AccountID) - if accountID == "" { - return "", fmt.Errorf("tenant has no account id") + return ensurePortalUser(reg, email) +} + +func ensurePortalUser(reg *registry.TenantRegistry, email string) (string, error) { + if reg == nil { + return "", fmt.Errorf("registry unavailable") } email = strings.ToLower(strings.TrimSpace(email)) if email == "" { @@ -370,6 +435,31 @@ func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *regist if user == nil || strings.TrimSpace(user.ID) == "" { return "", fmt.Errorf("user resolution failed") } + _ = reg.UpdateUserLastLogin(user.ID) + return user.ID, nil +} + +func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *registry.Tenant, email string) (string, error) { + if reg == nil { + return "", fmt.Errorf("registry unavailable") + } + if tenant == nil { + return "", fmt.Errorf("tenant is required") + } + accountID := strings.TrimSpace(tenant.AccountID) + if accountID == "" { + return "", fmt.Errorf("tenant has no account id") + } + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return "", fmt.Errorf("email is required") + } + + userID, err := ensurePortalUser(reg, email) + if err != nil { + return "", err + } + user := ®istry.User{ID: userID} m, err := reg.GetMembership(accountID, user.ID) if err != nil { @@ -389,6 +479,5 @@ func ensureAccountUserAndMembership(reg *registry.TenantRegistry, tenant *regist } } - _ = reg.UpdateUserLastLogin(user.ID) return user.ID, nil } diff --git a/internal/cloudcp/auth/handlers_test.go b/internal/cloudcp/auth/handlers_test.go new file mode 100644 index 000000000..fd11c4d80 --- /dev/null +++ b/internal/cloudcp/auth/handlers_test.go @@ -0,0 +1,86 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" +) + +func TestHandleMagicLinkVerifyPortalTargetCreatesSessionAndRedirectsToPortal(t *testing.T) { + dir := t.TempDir() + reg, err := registry.NewTenantRegistry(dir) + if err != nil { + t.Fatalf("NewTenantRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close() }) + + svc, err := NewService(dir) + if err != nil { + t.Fatalf("NewService: %v", err) + } + t.Cleanup(svc.Close) + + token, err := svc.GeneratePortalToken("buyer@example.com", "") + if err != nil { + t.Fatalf("GeneratePortalToken: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/auth/magic-link/verify?token="+token, nil) + rec := httptest.NewRecorder() + + HandleMagicLinkVerify(svc, reg, dir, "cloud.example.com", "/portal")(rec, req) + + if rec.Code != http.StatusTemporaryRedirect { + t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusTemporaryRedirect, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "/portal" { + t.Fatalf("location=%q, want /portal", got) + } + foundSessionCookie := false + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == SessionCookieName && cookie.Value != "" { + foundSessionCookie = true + break + } + } + if !foundSessionCookie { + t.Fatalf("expected %s cookie to be set", SessionCookieName) + } + + user, err := reg.GetUserByEmail("buyer@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if user == nil { + t.Fatal("expected control-plane user to be created") + } +} + +func TestHandleMagicLinkVerifyInvalidBrowserRedirectsToPortal(t *testing.T) { + dir := t.TempDir() + reg, err := registry.NewTenantRegistry(dir) + if err != nil { + t.Fatalf("NewTenantRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close() }) + + svc, err := NewService(dir) + if err != nil { + t.Fatalf("NewService: %v", err) + } + t.Cleanup(svc.Close) + + req := httptest.NewRequest(http.MethodGet, "/auth/magic-link/verify?token=ml1_invalid", nil) + rec := httptest.NewRecorder() + + HandleMagicLinkVerify(svc, reg, dir, "cloud.example.com", "/portal")(rec, req) + + if rec.Code != http.StatusTemporaryRedirect { + t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusTemporaryRedirect, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "/portal" { + t.Fatalf("location=%q, want /portal", got) + } +} diff --git a/internal/cloudcp/auth/magiclink.go b/internal/cloudcp/auth/magiclink.go index ef2c24385..1d46f9662 100644 --- a/internal/cloudcp/auth/magiclink.go +++ b/internal/cloudcp/auth/magiclink.go @@ -23,10 +23,18 @@ const ( hmacKeySize = 32 ) +type MagicLinkTarget string + +const ( + MagicLinkTargetTenant MagicLinkTarget = "tenant" + MagicLinkTargetPortal MagicLinkTarget = "portal" +) + // Token holds the validated data from a consumed magic link token. type Token struct { Email string TenantID string + Target MagicLinkTarget ExpiresAt time.Time } @@ -67,13 +75,34 @@ func NewService(cpDataDir string) (*Service, error) { // GenerateToken creates a new magic link token for the given email and tenant. // Returns a string in the format "ml1_" that can be included in a URL. func (s *Service) GenerateToken(email, tenantID string) (string, error) { + return s.generateTokenForTarget(email, tenantID, MagicLinkTargetTenant) +} + +// GeneratePortalToken creates a new portal-targeted magic link token. tenantID +// is optional and, when present, is used during verification to ensure the +// control-plane membership exists before the session is established. +func (s *Service) GeneratePortalToken(email, tenantID string) (string, error) { + return s.generateTokenForTarget(email, tenantID, MagicLinkTargetPortal) +} + +func (s *Service) generateTokenForTarget(email, tenantID string, target MagicLinkTarget) (string, error) { if s == nil { return "", fmt.Errorf("magic link service not configured") } email = strings.ToLower(strings.TrimSpace(email)) tenantID = strings.TrimSpace(tenantID) - if email == "" || tenantID == "" { - return "", fmt.Errorf("email and tenantID are required") + if email == "" { + return "", fmt.Errorf("email is required") + } + switch target { + case MagicLinkTargetTenant: + if tenantID == "" { + return "", fmt.Errorf("tenantID is required") + } + case MagicLinkTargetPortal: + // tenantID is optional for portal-targeted sign-in. + default: + return "", fmt.Errorf("unsupported magic link target %q", target) } expiresAt := s.now().UTC().Add(s.ttl) @@ -88,6 +117,7 @@ func (s *Service) GenerateToken(email, tenantID string) (string, error) { if err := s.store.Put(tokenHash, &TokenRecord{ Email: email, TenantID: tenantID, + Target: string(target), ExpiresAt: expiresAt, }); err != nil { return "", err @@ -115,6 +145,7 @@ func (s *Service) ValidateToken(token string) (*Token, error) { return &Token{ Email: rec.Email, TenantID: rec.TenantID, + Target: MagicLinkTarget(rec.Target), ExpiresAt: rec.ExpiresAt, }, nil } diff --git a/internal/cloudcp/auth/magiclink_store.go b/internal/cloudcp/auth/magiclink_store.go index 1a20cb060..8062bb573 100644 --- a/internal/cloudcp/auth/magiclink_store.go +++ b/internal/cloudcp/auth/magiclink_store.go @@ -36,6 +36,7 @@ func ensureOwnerOnlyDir(dir string) error { type TokenRecord struct { Email string TenantID string + Target string ExpiresAt time.Time Used bool } @@ -95,7 +96,8 @@ func (s *Store) initSchema() error { CREATE TABLE IF NOT EXISTS magic_link_tokens ( token_hash TEXT PRIMARY KEY, email TEXT NOT NULL, - tenant_id TEXT NOT NULL, + tenant_id TEXT NOT NULL DEFAULT '', + target TEXT NOT NULL DEFAULT 'tenant', expires_at INTEGER NOT NULL, used INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, @@ -107,6 +109,41 @@ func (s *Store) initSchema() error { if _, err := s.db.Exec(schema); err != nil { return fmt.Errorf("init magic link schema: %w", err) } + if err := ensureMagicLinkColumn(s.db, "target", "ALTER TABLE magic_link_tokens ADD COLUMN target TEXT NOT NULL DEFAULT 'tenant'"); err != nil { + return err + } + if err := ensureMagicLinkColumn(s.db, "tenant_id", "ALTER TABLE magic_link_tokens ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ''"); err != nil { + return err + } + return nil +} + +func ensureMagicLinkColumn(db *sql.DB, columnName, alterStmt string) error { + rows, err := db.Query(`PRAGMA table_info(magic_link_tokens)`) + if err != nil { + return fmt.Errorf("inspect magic link schema: %w", err) + } + defer rows.Close() + + for rows.Next() { + var cid int + var name, columnType string + var notNull int + var defaultValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &pk); err != nil { + return fmt.Errorf("scan magic link schema: %w", err) + } + if strings.EqualFold(name, columnName) { + return nil + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate magic link schema: %w", err) + } + if _, err := db.Exec(alterStmt); err != nil { + return fmt.Errorf("add magic link column %s: %w", columnName, err) + } return nil } @@ -133,9 +170,12 @@ func (s *Store) Put(tokenHash []byte, rec *TokenRecord) error { if len(tokenHash) == 0 { return fmt.Errorf("tokenHash is required") } - if rec == nil || rec.Email == "" || rec.TenantID == "" || rec.ExpiresAt.IsZero() { + if rec == nil || rec.Email == "" || rec.ExpiresAt.IsZero() { return fmt.Errorf("token record is required") } + if strings.TrimSpace(rec.Target) == "" { + rec.Target = string(MagicLinkTargetTenant) + } key := hex.EncodeToString(tokenHash) now := time.Now().UTC().Unix() @@ -148,9 +188,9 @@ func (s *Store) Put(tokenHash []byte, rec *TokenRecord) error { } defer s.mu.Unlock() _, err := db.Exec( - `INSERT OR REPLACE INTO magic_link_tokens (token_hash, email, tenant_id, expires_at, used, created_at, used_at) - VALUES (?, ?, ?, ?, 0, ?, NULL)`, - key, rec.Email, rec.TenantID, rec.ExpiresAt.UTC().Unix(), now, + `INSERT OR REPLACE INTO magic_link_tokens (token_hash, email, tenant_id, target, expires_at, used, created_at, used_at) + VALUES (?, ?, ?, ?, ?, 0, ?, NULL)`, + key, rec.Email, rec.TenantID, rec.Target, rec.ExpiresAt.UTC().Unix(), now, ) if err != nil { return fmt.Errorf("put magic link token: %w", err) @@ -188,12 +228,12 @@ func (s *Store) Consume(tokenHash []byte, now time.Time) (*TokenRecord, error) { } }() - var email, tenantID string + var email, tenantID, target string var expiresAtUnix int64 var usedInt int - row := tx.QueryRow(`SELECT email, tenant_id, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`, key) - if scanErr := row.Scan(&email, &tenantID, &expiresAtUnix, &usedInt); scanErr != nil { + row := tx.QueryRow(`SELECT email, tenant_id, target, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`, key) + if scanErr := row.Scan(&email, &tenantID, &target, &expiresAtUnix, &usedInt); scanErr != nil { if errors.Is(scanErr, sql.ErrNoRows) { return nil, ErrTokenInvalid } @@ -227,6 +267,7 @@ func (s *Store) Consume(tokenHash []byte, now time.Time) (*TokenRecord, error) { return &TokenRecord{ Email: email, TenantID: tenantID, + Target: target, ExpiresAt: expiresAt, Used: true, }, nil diff --git a/internal/cloudcp/auth/magiclink_store_migration_test.go b/internal/cloudcp/auth/magiclink_store_migration_test.go new file mode 100644 index 000000000..d101a8603 --- /dev/null +++ b/internal/cloudcp/auth/magiclink_store_migration_test.go @@ -0,0 +1,62 @@ +package auth + +import ( + "database/sql" + "path/filepath" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +func TestNewStoreMigratesLegacySchemaWithNoTargetColumn(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "cp_magic_links.db") + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + defer db.Close() + + if _, err := db.Exec(` + CREATE TABLE magic_link_tokens ( + token_hash TEXT PRIMARY KEY, + email TEXT NOT NULL, + tenant_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + used_at INTEGER + ); + `); err != nil { + t.Fatalf("create legacy schema: %v", err) + } + + store, err := NewStore(dir) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + t.Cleanup(store.Close) + + tokenHash := signHMAC([]byte("legacy-key"), "portal") + if err := store.Put(tokenHash, &TokenRecord{ + Email: "buyer@example.com", + TenantID: "", + Target: string(MagicLinkTargetPortal), + ExpiresAt: time.Now().UTC().Add(10 * time.Minute), + }); err != nil { + t.Fatalf("Put: %v", err) + } + + rec, err := store.Consume(tokenHash, time.Now().UTC()) + if err != nil { + t.Fatalf("Consume: %v", err) + } + if rec.Target != string(MagicLinkTargetPortal) { + t.Fatalf("target=%q, want %q", rec.Target, MagicLinkTargetPortal) + } + if rec.TenantID != "" { + t.Fatalf("tenantID=%q, want empty", rec.TenantID) + } +} diff --git a/internal/cloudcp/auth/magiclink_store_queryplan_test.go b/internal/cloudcp/auth/magiclink_store_queryplan_test.go index 627a61b85..5ec8c3f6c 100644 --- a/internal/cloudcp/auth/magiclink_store_queryplan_test.go +++ b/internal/cloudcp/auth/magiclink_store_queryplan_test.go @@ -22,7 +22,7 @@ func TestMagicLinkStoreQueryPlansUseIndexes(t *testing.T) { }{ { name: "consume lookup uses token primary key", - query: `SELECT email, tenant_id, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`, + query: `SELECT email, tenant_id, target, expires_at, used FROM magic_link_tokens WHERE token_hash = ?`, args: []any{hex.EncodeToString(tokenHash)}, wantIndex: "sqlite_autoindex_magic_link_tokens_1", }, diff --git a/internal/cloudcp/auth/magiclink_test.go b/internal/cloudcp/auth/magiclink_test.go index 7661f5a11..eb2fdaf4d 100644 --- a/internal/cloudcp/auth/magiclink_test.go +++ b/internal/cloudcp/auth/magiclink_test.go @@ -67,6 +67,37 @@ func TestValidateToken_Valid(t *testing.T) { if result.TenantID != "t-xyz" { t.Errorf("tenantID = %q, want t-xyz", result.TenantID) } + if result.Target != MagicLinkTargetTenant { + t.Errorf("target = %q, want %q", result.Target, MagicLinkTargetTenant) + } +} + +func TestValidateToken_PortalTargetAllowsEmptyTenant(t *testing.T) { + dir := t.TempDir() + svc, err := NewService(dir) + if err != nil { + t.Fatalf("NewService: %v", err) + } + defer svc.Close() + + token, err := svc.GeneratePortalToken("buyer@example.com", "") + if err != nil { + t.Fatalf("GeneratePortalToken: %v", err) + } + + result, err := svc.ValidateToken(token) + if err != nil { + t.Fatalf("ValidateToken: %v", err) + } + if result.Email != "buyer@example.com" { + t.Errorf("email = %q, want buyer@example.com", result.Email) + } + if result.TenantID != "" { + t.Errorf("tenantID = %q, want empty", result.TenantID) + } + if result.Target != MagicLinkTargetPortal { + t.Errorf("target = %q, want %q", result.Target, MagicLinkTargetPortal) + } } func TestValidateToken_AlreadyUsed(t *testing.T) { diff --git a/internal/cloudcp/commercial_identity.go b/internal/cloudcp/commercial_identity.go new file mode 100644 index 000000000..3b7df23c1 --- /dev/null +++ b/internal/cloudcp/commercial_identity.go @@ -0,0 +1,75 @@ +package cloudcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +const commercialIdentityLookupTimeout = 10 * time.Second + +type commercialIdentity struct { + Email string `json:"email"` + HasCommercialIdentity bool `json:"has_commercial_identity"` + Sources []string `json:"sources,omitempty"` + V5LicenseCount int `json:"v5_license_count"` + V6LicenseCount int `json:"v6_license_count"` + StripeCustomerID string `json:"stripe_customer_id,omitempty"` +} + +type commercialIdentityLookupFunc func(ctx context.Context, email string) (*commercialIdentity, error) + +func newCommercialIdentityLookup(cfg *CPConfig) commercialIdentityLookupFunc { + if cfg == nil { + return nil + } + baseURL := strings.TrimSpace(cfg.LicenseServerURL) + adminToken := strings.TrimSpace(cfg.LicenseAdminToken) + if baseURL == "" || adminToken == "" { + return nil + } + + client := &http.Client{Timeout: commercialIdentityLookupTimeout} + return func(ctx context.Context, email string) (*commercialIdentity, error) { + email = strings.TrimSpace(email) + if email == "" { + return nil, fmt.Errorf("email is required") + } + + endpoint, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parse license server url: %w", err) + } + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/v1/admin/commercial/lookup" + query := endpoint.Query() + query.Set("email", email) + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("build commercial lookup request: %w", err) + } + req.Header.Set("X-Admin-Key", adminToken) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("perform commercial lookup request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("commercial lookup returned status %d", resp.StatusCode) + } + + var result commercialIdentity + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode commercial lookup response: %w", err) + } + return &result, nil + } +} diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 2d63e02db..84b186678 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -41,6 +41,8 @@ type CPConfig struct { TrialSignupPriceID string // Cloud Starter (default tier) price ID CloudPowerPriceID string // Cloud Power tier price ID (optional) CloudMaxPriceID string // Cloud Max tier price ID (optional) + LicenseServerURL string + LicenseAdminToken string TrialActivationPrivateKey string TrialActivationPublicKey string RequireEmailProvider bool @@ -127,6 +129,8 @@ func LoadConfig() (*CPConfig, error) { TrialSignupPriceID: strings.TrimSpace(os.Getenv("CP_TRIAL_SIGNUP_PRICE_ID")), CloudPowerPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_POWER_PRICE_ID")), CloudMaxPriceID: strings.TrimSpace(os.Getenv("CP_CLOUD_MAX_PRICE_ID")), + LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"), + LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")), TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")), RequireEmailProvider: envOrDefaultBool("CP_REQUIRE_EMAIL_PROVIDER", true), ResendAPIKey: strings.TrimSpace(os.Getenv("RESEND_API_KEY")), @@ -220,6 +224,9 @@ func (c *CPConfig) validate() error { if strings.TrimSpace(c.StripeAPIKey) != "" && strings.TrimSpace(c.TrialActivationPrivateKey) == "" { return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when STRIPE_API_KEY is configured") } + if strings.TrimSpace(c.LicenseServerURL) == "" && strings.TrimSpace(c.LicenseAdminToken) != "" { + return fmt.Errorf("PULSE_LICENSE_SERVER_URL is required when PULSE_LICENSE_ADMIN_TOKEN is configured") + } if strings.TrimSpace(c.StripeAPIKey) != "" { stripeMode := stripeSecretKeyMode(c.StripeAPIKey) switch c.Environment { diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 0aa26f17c..2538bb16b 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "32f6b26c75224ffa7ad3ca4e9e444cb647655d9e0a9c9fd8b0a03ff38168cd63", + "source_hash": "d3efcc19901a11a535626510e9104c2428ad66d786604e8e59372e47af1a0e92", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 9d3354cbb..0a8c3597c 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -388,7 +388,7 @@ return request(bootstrap().magic_link_request_path, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }) + body: JSON.stringify({ email, target: "portal" }) }, "Failed to send magic link."); }, logout: function() { @@ -477,7 +477,8 @@ return { emailValue: "", request: createMutationState(), - success: false + success: false, + successMessage: "" }; } function createPortalAccountState() { @@ -869,12 +870,14 @@ deps.store.updateLoginState(function(nextState) { beginMutationState(nextState.request); nextState.success = false; + nextState.successMessage = ""; }); try { - await deps.api.requestMagicLink(email); + var response = await deps.api.requestMagicLink(email); deps.store.updateLoginState(function(nextState) { succeedMutationState(nextState.request); nextState.success = true; + nextState.successMessage = String(response?.message || "").trim(); }); return; } catch (error) { @@ -882,6 +885,7 @@ deps.store.updateLoginState(function(nextState) { succeedMutationState(nextState.request); nextState.success = true; + nextState.successMessage = ""; }); return; } @@ -906,6 +910,7 @@ event.preventDefault(); deps.store.updateLoginState(function(nextState) { nextState.success = false; + nextState.successMessage = ""; resetMutationState(nextState.request); }); void sendMagicLink(); @@ -1652,7 +1657,8 @@ if (context.loginState.request.error) { statusHTML = '
' + escapeHTML(context.loginState.request.error) + "
"; } else if (context.loginState.success) { - statusHTML = `
Magic link sent. Check your inbox and click the link to sign in.

Don't see it? Send a new link.
`; + var successMessage = context.loginState.successMessage || "If that email is registered, a magic link is on the way."; + statusHTML = '
' + escapeHTML(successMessage) + `

Don't see it? Send a new link.
`; } return '

Pulse Account

Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; } diff --git a/internal/cloudcp/portal/frontend/src/api.test.ts b/internal/cloudcp/portal/frontend/src/api.test.ts index 7314a7712..3960eabb6 100644 --- a/internal/cloudcp/portal/frontend/src/api.test.ts +++ b/internal/cloudcp/portal/frontend/src/api.test.ts @@ -69,4 +69,32 @@ describe('portal api', function() { message: 'Member already exists.', }); }); + + it('sends portal-targeted magic-link requests and returns the server message', async function() { + var fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async function() { + return { message: 'If that email is registered, you will receive a magic link shortly.' }; + }, + }); + vi.stubGlobal('fetch', fetchMock); + + var api = createPortalAPI({ + getBootstrap: function() { + return bootstrap; + }, + }); + + var result = await api.requestMagicLink('buyer@example.com'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/public/magic-link/request', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'buyer@example.com', target: 'portal' }), + }) + ); + expect(result.message).toContain('If that email is registered'); + }); }); diff --git a/internal/cloudcp/portal/frontend/src/api.ts b/internal/cloudcp/portal/frontend/src/api.ts index ae2b7311b..ab1c1d8be 100644 --- a/internal/cloudcp/portal/frontend/src/api.ts +++ b/internal/cloudcp/portal/frontend/src/api.ts @@ -20,6 +20,10 @@ export interface PortalBillingResponse { url?: string; } +export interface PortalMagicLinkResponse { + message?: string; +} + export interface PortalWorkspaceCreateRequest { display_name: string; } @@ -35,7 +39,7 @@ export interface PortalMemberRoleRequest { export interface PortalAPI { fetchBootstrap(): Promise; - requestMagicLink(email: string): Promise; + requestMagicLink(email: string): Promise; logout(): Promise; postCommercialJSON(path: string, body: Record): Promise; createWorkspace(accountID: string, body: PortalWorkspaceCreateRequest): Promise; @@ -125,10 +129,10 @@ export function createPortalAPI(context: PortalAPIContext): PortalAPI { }, 'Failed to refresh account state.'); }, requestMagicLink: function(email: string) { - return request(bootstrap().magic_link_request_path, { + return request(bootstrap().magic_link_request_path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email }), + body: JSON.stringify({ email: email, target: 'portal' }), }, 'Failed to send magic link.'); }, logout: function() { diff --git a/internal/cloudcp/portal/frontend/src/app.test.ts b/internal/cloudcp/portal/frontend/src/app.test.ts index 179d4f483..1615160b9 100644 --- a/internal/cloudcp/portal/frontend/src/app.test.ts +++ b/internal/cloudcp/portal/frontend/src/app.test.ts @@ -206,10 +206,10 @@ describe('portal app', function() { '/api/public/magic-link/request', expect.objectContaining({ method: 'POST', - body: JSON.stringify({ email: 'buyer@example.com' }), + body: JSON.stringify({ email: 'buyer@example.com', target: 'portal' }), }) ); - expect(document.getElementById('portal-app-root')?.textContent).toContain('Magic link sent. Check your inbox and click the link to sign in.'); + expect(document.getElementById('portal-app-root')?.textContent).toContain('If that email is registered, a magic link is on the way.'); }); it('completes the retrieve-license flow through the real authenticated app shell', async function() { diff --git a/internal/cloudcp/portal/frontend/src/auth_controller.test.ts b/internal/cloudcp/portal/frontend/src/auth_controller.test.ts index 7d5ffec06..6b2334c74 100644 --- a/internal/cloudcp/portal/frontend/src/auth_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/auth_controller.test.ts @@ -58,6 +58,13 @@ describe('auth controller', function() { vi.fn().mockResolvedValue({ ok: true, status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async function() { + return { message: 'If that email is registered, you will receive a magic link shortly.' }; + }, + text: async function() { + return ''; + }, }) ); @@ -81,10 +88,12 @@ describe('auth controller', function() { expect(controller.getLoginState().request.pending).toBe(false); expect(controller.getLoginState().request.error).toBe(''); expect(controller.getLoginState().success).toBe(true); + expect(controller.getLoginState().successMessage).toContain('If that email is registered'); expect(fetch).toHaveBeenCalledWith( '/magic-link', expect.objectContaining({ method: 'POST', + body: JSON.stringify({ email: 'buyer@example.com', target: 'portal' }), }) ); }); diff --git a/internal/cloudcp/portal/frontend/src/auth_controller.ts b/internal/cloudcp/portal/frontend/src/auth_controller.ts index c13d6386f..3bdfd1791 100644 --- a/internal/cloudcp/portal/frontend/src/auth_controller.ts +++ b/internal/cloudcp/portal/frontend/src/auth_controller.ts @@ -41,12 +41,14 @@ export function installAuthController(deps: AuthControllerDeps): AuthController deps.store.updateLoginState(function(nextState) { beginMutationState(nextState.request); nextState.success = false; + nextState.successMessage = ''; }); try { - await deps.api.requestMagicLink(email); + var response = await deps.api.requestMagicLink(email); deps.store.updateLoginState(function(nextState) { succeedMutationState(nextState.request); nextState.success = true; + nextState.successMessage = String(response?.message || '').trim(); }); return; } catch (error) { @@ -54,6 +56,7 @@ export function installAuthController(deps: AuthControllerDeps): AuthController deps.store.updateLoginState(function(nextState) { succeedMutationState(nextState.request); nextState.success = true; + nextState.successMessage = ''; }); return; } @@ -81,6 +84,7 @@ export function installAuthController(deps: AuthControllerDeps): AuthController event.preventDefault(); deps.store.updateLoginState(function(nextState) { nextState.success = false; + nextState.successMessage = ''; resetMutationState(nextState.request); }); void sendMagicLink(); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index d23c1e032..92ece702f 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -36,6 +36,7 @@ function createLoginState(overrides: Partial = {}): PortalLogi error: '', }, success: false, + successMessage: '', ...overrides, }; } @@ -211,7 +212,7 @@ describe('shell view', function() { expect(errorHTML).toContain('value="buyer@example.com"'); expect(errorHTML).toContain('Invalid email'); - expect(successHTML).toContain('Magic link sent.'); + expect(successHTML).toContain('If that email is registered, a magic link is on the way.'); expect(successHTML).toContain('data-portal-action="resend-magic-link"'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index be423c367..2c39b20a9 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -524,9 +524,10 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string { if (context.loginState.request.error) { statusHTML = '
' + escapeHTML(context.loginState.request.error) + '
'; } else if (context.loginState.success) { + var successMessage = context.loginState.successMessage || 'If that email is registered, a magic link is on the way.'; statusHTML = '
' + - 'Magic link sent. Check your inbox and click the link to sign in.' + + escapeHTML(successMessage) + '

Don\'t see it? Send a new link.' + '
'; } diff --git a/internal/cloudcp/portal/frontend/src/state.ts b/internal/cloudcp/portal/frontend/src/state.ts index 68c954764..4036adc5a 100644 --- a/internal/cloudcp/portal/frontend/src/state.ts +++ b/internal/cloudcp/portal/frontend/src/state.ts @@ -39,6 +39,7 @@ export function createPortalLoginState(): PortalLoginState { emailValue: '', request: createMutationState(), success: false, + successMessage: '', }; } diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index 83b4dc91d..8bac4f6ad 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -39,6 +39,7 @@ export interface PortalLoginState { emailValue: string; request: PortalMutationState; success: boolean; + successMessage: string; } export interface PortalTeamMember { diff --git a/internal/cloudcp/public_cloud_signup_handlers.go b/internal/cloudcp/public_cloud_signup_handlers.go index 875aa9b32..e8e92b5d5 100644 --- a/internal/cloudcp/public_cloud_signup_handlers.go +++ b/internal/cloudcp/public_cloud_signup_handlers.go @@ -11,6 +11,7 @@ import ( "strings" "unicode" + cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth" "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/cpsec" cpemail "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/email" "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" @@ -154,7 +155,9 @@ type PublicCloudSignupHandlers struct { registry *registry.TenantRegistry magicLinks interface { GenerateToken(email, tenantID string) (string, error) + GeneratePortalToken(email, tenantID string) (string, error) } + commercialLookup commercialIdentityLookupFunc emailSender cpemail.Sender createCheckoutSession func(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) } @@ -182,16 +185,19 @@ type publicCloudSignupRequest struct { } type publicMagicLinkRequest struct { - Email string `json:"email"` + Email string `json:"email"` + Target string `json:"target,omitempty"` } func NewPublicCloudSignupHandlers(cfg *CPConfig, reg *registry.TenantRegistry, magicLinks interface { GenerateToken(email, tenantID string) (string, error) + GeneratePortalToken(email, tenantID string) (string, error) }, emailSender cpemail.Sender) *PublicCloudSignupHandlers { return &PublicCloudSignupHandlers{ cfg: cfg, registry: reg, magicLinks: magicLinks, + commercialLookup: newCommercialIdentityLookup(cfg), emailSender: emailSender, createCheckoutSession: stripesession.New, } @@ -389,6 +395,7 @@ func (h *PublicCloudSignupHandlers) HandlePublicMagicLinkRequest(w http.Response writePublicSignupError(w, http.StatusBadRequest, "invalid_email", "Invalid email format") return } + target := parsePublicMagicLinkTarget(req.Target) const msg = "If that email is registered, you'll receive a magic link shortly." if h.registry == nil || h.magicLinks == nil { @@ -399,9 +406,9 @@ func (h *PublicCloudSignupHandlers) HandlePublicMagicLinkRequest(w http.Response return } - tenantID, ok, err := h.findTenantForEmail(email) + tenantID, ok, err := h.resolveMagicLinkTenantID(r.Context(), email, target) if err != nil { - log.Warn().Err(err).Str("email", email).Msg("public magic link request: tenant lookup failed") + log.Warn().Err(err).Str("email", email).Str("target", string(target)).Msg("public magic link request: identity lookup failed") writePublicSignupJSON(w, http.StatusOK, map[string]any{ "success": true, "message": msg, @@ -416,9 +423,9 @@ func (h *PublicCloudSignupHandlers) HandlePublicMagicLinkRequest(w http.Response return } - token, err := h.magicLinks.GenerateToken(email, tenantID) + token, err := h.generateMagicLink(email, tenantID, target) if err != nil { - log.Warn().Err(err).Str("email", email).Str("tenant_id", tenantID).Msg("public magic link request: token generation failed") + log.Warn().Err(err).Str("email", email).Str("tenant_id", tenantID).Str("target", string(target)).Msg("public magic link request: token generation failed") writePublicSignupJSON(w, http.StatusOK, map[string]any{ "success": true, "message": msg, @@ -538,6 +545,52 @@ func (h *PublicCloudSignupHandlers) findTenantForEmail(email string) (string, bo return "", false, nil } +func parsePublicMagicLinkTarget(raw string) cpauth.MagicLinkTarget { + switch strings.ToLower(strings.TrimSpace(raw)) { + case string(cpauth.MagicLinkTargetPortal): + return cpauth.MagicLinkTargetPortal + default: + return cpauth.MagicLinkTargetTenant + } +} + +func (h *PublicCloudSignupHandlers) generateMagicLink(email, tenantID string, target cpauth.MagicLinkTarget) (string, error) { + switch target { + case cpauth.MagicLinkTargetPortal: + return h.magicLinks.GeneratePortalToken(email, tenantID) + default: + return h.magicLinks.GenerateToken(email, tenantID) + } +} + +func (h *PublicCloudSignupHandlers) resolveMagicLinkTenantID(ctx context.Context, email string, target cpauth.MagicLinkTarget) (string, bool, error) { + if target == cpauth.MagicLinkTargetPortal { + if user, err := h.registry.GetUserByEmail(strings.ToLower(strings.TrimSpace(email))); err != nil { + return "", false, err + } else if user != nil { + return "", true, nil + } + } + + tenantID, ok, err := h.findTenantForEmail(email) + if err != nil || ok { + return tenantID, ok, err + } + + if target != cpauth.MagicLinkTargetPortal || h.commercialLookup == nil { + return "", false, nil + } + + identity, err := h.commercialLookup(ctx, email) + if err != nil { + return "", false, err + } + if identity != nil && identity.HasCommercialIdentity { + return "", true, nil + } + return "", false, nil +} + func (h *PublicCloudSignupHandlers) sendMagicLinkEmail(email, verifyURL string) error { if h.emailSender == nil || h.cfg == nil || strings.TrimSpace(h.cfg.EmailFrom) == "" { log.Info(). diff --git a/internal/cloudcp/public_cloud_signup_handlers_test.go b/internal/cloudcp/public_cloud_signup_handlers_test.go index d56c30489..d00ff2e72 100644 --- a/internal/cloudcp/public_cloud_signup_handlers_test.go +++ b/internal/cloudcp/public_cloud_signup_handlers_test.go @@ -15,11 +15,16 @@ import ( ) type captureMagicLinkGenerator struct { - calls int - email string - tenantID string - token string - err error + calls int + email string + tenantID string + token string + err error + portalCalls int + portalEmail string + portalTenant string + portalToken string + portalErr error } func (c *captureMagicLinkGenerator) GenerateToken(email, tenantID string) (string, error) { @@ -32,6 +37,16 @@ func (c *captureMagicLinkGenerator) GenerateToken(email, tenantID string) (strin return c.token, nil } +func (c *captureMagicLinkGenerator) GeneratePortalToken(email, tenantID string) (string, error) { + c.portalCalls++ + c.portalEmail = email + c.portalTenant = tenantID + if c.portalErr != nil { + return "", c.portalErr + } + return c.portalToken, nil +} + type captureEmailSender struct { calls int msg cpemail.Message @@ -201,7 +216,7 @@ func TestPublicCloudSignupHandlePublicMagicLinkRequestKnownTenantSendsEmail(t *t t.Fatalf("Create tenant: %v", err) } - magic := &captureMagicLinkGenerator{token: "ml_test_123"} + magic := &captureMagicLinkGenerator{token: "ml_test_123", portalToken: "ml_portal_123"} emailSender := &captureEmailSender{} h := NewPublicCloudSignupHandlers(&CPConfig{ BaseURL: "https://cloud.example.com", @@ -234,6 +249,94 @@ func TestPublicCloudSignupHandlePublicMagicLinkRequestKnownTenantSendsEmail(t *t } } +func TestPublicCloudSignupHandlePublicMagicLinkRequestPortalKnownUserSendsEmail(t *testing.T) { + dir := t.TempDir() + reg, err := registry.NewTenantRegistry(dir) + if err != nil { + t.Fatalf("NewTenantRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close() }) + + if err := reg.CreateUser(®istry.User{ + ID: "u_test_1", + Email: "owner@example.com", + }); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + magic := &captureMagicLinkGenerator{token: "ml_test_123", portalToken: "ml_portal_123"} + emailSender := &captureEmailSender{} + h := NewPublicCloudSignupHandlers(&CPConfig{ + BaseURL: "https://cloud.example.com", + EmailFrom: "noreply@pulserelay.pro", + }, reg, magic, emailSender) + + req := httptest.NewRequest(http.MethodPost, "/api/public/magic-link/request", strings.NewReader(`{"email":"OWNER@EXAMPLE.COM","target":"portal"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.HandlePublicMagicLinkRequest(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusOK, rec.Body.String()) + } + if magic.calls != 0 { + t.Fatalf("tenant GenerateToken calls=%d, want 0", magic.calls) + } + if magic.portalCalls != 1 { + t.Fatalf("portal GeneratePortalToken calls=%d, want 1", magic.portalCalls) + } + if magic.portalTenant != "" { + t.Fatalf("portal tenantID=%q, want empty", magic.portalTenant) + } + if emailSender.calls != 1 { + t.Fatalf("email sender calls=%d, want 1", emailSender.calls) + } +} + +func TestPublicCloudSignupHandlePublicMagicLinkRequestPortalCommercialIdentitySendsEmail(t *testing.T) { + dir := t.TempDir() + reg, err := registry.NewTenantRegistry(dir) + if err != nil { + t.Fatalf("NewTenantRegistry: %v", err) + } + t.Cleanup(func() { _ = reg.Close() }) + + magic := &captureMagicLinkGenerator{portalToken: "ml_portal_123"} + emailSender := &captureEmailSender{} + h := NewPublicCloudSignupHandlers(&CPConfig{ + BaseURL: "https://cloud.example.com", + EmailFrom: "noreply@pulserelay.pro", + }, reg, magic, emailSender) + h.commercialLookup = func(_ context.Context, email string) (*commercialIdentity, error) { + return &commercialIdentity{ + Email: email, + HasCommercialIdentity: true, + Sources: []string{"v6_license"}, + V6LicenseCount: 1, + }, nil + } + + req := httptest.NewRequest(http.MethodPost, "/api/public/magic-link/request", strings.NewReader(`{"email":"buyer@example.com","target":"portal"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + h.HandlePublicMagicLinkRequest(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want %d body=%q", rec.Code, http.StatusOK, rec.Body.String()) + } + if magic.portalCalls != 1 { + t.Fatalf("portal GeneratePortalToken calls=%d, want 1", magic.portalCalls) + } + if magic.portalTenant != "" { + t.Fatalf("portal tenantID=%q, want empty", magic.portalTenant) + } + if emailSender.calls != 1 { + t.Fatalf("email sender calls=%d, want 1", emailSender.calls) + } +} + func TestPublicCloudSignupCheckoutMetadataIncludesPlanVersion(t *testing.T) { // Use a real Stripe price ID from PriceIDToPlanVersion so plan_version is set. h := NewPublicCloudSignupHandlers(&CPConfig{ diff --git a/internal/cloudcp/routes.go b/internal/cloudcp/routes.go index ea5134a3b..558df5c8b 100644 --- a/internal/cloudcp/routes.go +++ b/internal/cloudcp/routes.go @@ -112,7 +112,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) { // Magic link verification (public, token-authenticated) baseDomain := baseDomainFromURL(deps.Config.BaseURL) - mux.Handle("/auth/magic-link/verify", magicLinkVerifyLimiter.Middleware(http.HandlerFunc(cpauth.HandleMagicLinkVerify(deps.MagicLinks, deps.Registry, deps.Config.TenantsDir(), baseDomain)))) + mux.Handle("/auth/magic-link/verify", magicLinkVerifyLimiter.Middleware(http.HandlerFunc(cpauth.HandleMagicLinkVerify(deps.MagicLinks, deps.Registry, deps.Config.TenantsDir(), baseDomain, portal.PortalPagePath)))) if deps.MagicLinks != nil { mux.Handle(portal.PortalLogoutPath, sessionAuthLimiter.Middleware(sessionAuth(cpauth.HandleLogout(deps.Registry)))) } diff --git a/internal/cloudcp/routes_auth_test.go b/internal/cloudcp/routes_auth_test.go index e10353cc7..1877e57ab 100644 --- a/internal/cloudcp/routes_auth_test.go +++ b/internal/cloudcp/routes_auth_test.go @@ -433,8 +433,9 @@ func TestRegisterRoutes_PortalPageSessionModes(t *testing.T) { `id="pulse-account-bootstrap"`, `id="portal-app-root"`, `"authenticated":true`, - "Other account services", - "self-hosted billing, license recovery, refund, and privacy tools below now share the same Pulse Account shell", + "Hosted operations", + "Account services", + "Self-hosted licenses and billing", } { if !strings.Contains(authRec.Body.String(), needle) { t.Fatalf("expected authenticated portal page to contain %q, body=%q", needle, authRec.Body.String())