From 8ed5d1ac0359899ba650d96bbf3deb26af50fa63 Mon Sep 17 00:00:00 2001 From: Chris Wendt Date: Mon, 1 Oct 2018 16:30:44 -0700 Subject: [PATCH 1/4] docs: link to sourcegraph-extension-api (#183) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index adaad0457..192dfbbb1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - Fast global code search with a hybrid backend that combines a trigram index with in-memory streaming - Code intelligence for many languages via the [Language Server Protocol](https://langserver.org/) - Enhances GitHub, GitLab, Phabricator, and other code hosts and code review tools via the [Sourcegraph browser extension](https://about.sourcegraph.com/docs/features/browser-extension/) -- Integration with third-party developer tools via the Sourcegraph Extension API +- Integration with third-party developer tools via the [Sourcegraph Extension API](https://github.com/sourcegraph/sourcegraph-extension-api) ## Try it From 4ddaf7ccfab79cb438fbbda1e236e382e9b4c81c Mon Sep 17 00:00:00 2001 From: Quinn Slack Date: Mon, 1 Oct 2018 01:30:05 -0700 Subject: [PATCH 2/4] feat: support hooks for billing/subscriptions This makes it possible to support self-service subscription management and billing on Sourcegraph.com for self-hosted Sourcegraph instances. --- cmd/frontend/db/migrations/bindata.go | 48 +++ cmd/frontend/db/schema.md | 71 +++-- cmd/frontend/db/users.go | 6 + cmd/frontend/external/app/app.go | 9 +- cmd/frontend/graphqlbackend/dotcom.go | 165 +++++++++- cmd/frontend/graphqlbackend/graphqlbackend.go | 20 ++ .../graphqlbackend/product_license_info.go | 32 +- .../product_subscription_status.go | 23 +- cmd/frontend/graphqlbackend/schema.go | 289 ++++++++++++++++-- cmd/frontend/graphqlbackend/schema.graphql | 289 ++++++++++++++++-- cmd/frontend/graphqlbackend/user.go | 11 + .../internal/app/jscontext/jscontext.go | 7 + migrations/1528395555_.down.sql | 5 + migrations/1528395555_.up.sql | 18 ++ schema/site.schema.json | 2 +- schema/site_stringdata.go | 2 +- 16 files changed, 907 insertions(+), 90 deletions(-) create mode 100644 migrations/1528395555_.down.sql create mode 100644 migrations/1528395555_.up.sql diff --git a/cmd/frontend/db/migrations/bindata.go b/cmd/frontend/db/migrations/bindata.go index 9d55c5aa6..095b6ca39 100644 --- a/cmd/frontend/db/migrations/bindata.go +++ b/cmd/frontend/db/migrations/bindata.go @@ -172,6 +172,8 @@ // ../../../../migrations/1528395553_.up.sql (0) // ../../../../migrations/1528395554_oss_fake_migration.down.sql (80B) // ../../../../migrations/1528395554_oss_fake_migration.up.sql (80B) +// ../../../../migrations/1528395555_.down.sql (153B) +// ../../../../migrations/1528395555_.up.sql (710B) package migrations @@ -3680,6 +3682,46 @@ func _1528395554_oss_fake_migrationUpSql() (*asset, error) { return a, nil } +var __1528395555_DownSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x09\xf2\x0f\x50\x08\x71\x74\xf2\x71\x55\x28\x28\xca\x4f\x29\x4d\x2e\x89\xcf\xc9\x4c\x4e\xcd\x2b\x4e\x2d\xb6\xe6\xc2\x22\x59\x5c\x9a\x54\x9c\x5c\x94\x59\x50\x92\x99\x9f\x57\x6c\xcd\x05\x51\xe2\xe9\xe7\xe2\x1a\xa1\x50\x5a\x9c\x5a\x54\x1c\x9f\x94\x99\x93\x93\x99\x97\x1e\x9f\x5c\x5a\x5c\x92\x9f\x9b\x5a\x14\x9f\x99\x62\xcd\xe5\xe8\x13\xe2\x1a\x04\x35\x09\xac\x4c\x01\xac\xcf\xd9\xdf\x27\xd4\xd7\x4f\x01\xab\x16\x40\x00\x00\x00\xff\xff\x15\xba\xff\xd6\x99\x00\x00\x00") + +func _1528395555_DownSqlBytes() ([]byte, error) { + return bindataRead( + __1528395555_DownSql, + "1528395555_.down.sql", + ) +} + +func _1528395555_DownSql() (*asset, error) { + bytes, err := _1528395555_DownSqlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "1528395555_.down.sql", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)} + a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x3a, 0x17, 0xf3, 0xfa, 0x87, 0x1a, 0x4f, 0xb2, 0x17, 0x31, 0x66, 0x37, 0x22, 0x2f, 0xf6, 0xb2, 0xcb, 0xd4, 0x8c, 0x97, 0xc5, 0xda, 0x8c, 0x82, 0xa1, 0x3c, 0x24, 0x9e, 0x3c, 0x44, 0x85, 0xea}} + return a, nil +} + +var __1528395555_UpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xa4\x91\xdd\x6e\xb2\x40\x10\x86\xcf\xb9\x8a\x39\xd4\xe4\xbb\x03\x8f\xf8\x64\x4c\x49\x71\x6d\x57\x48\xeb\xd1\x06\xd9\x89\x4e\x8a\x0b\xd9\x9f\xda\xf6\xea\x1b\x50\xaa\x49\x69\xd3\xc4\x43\x32\x3c\xf3\xbe\xf3\x6c\x9c\xe5\x28\x21\x8f\xff\x67\x08\xc1\x91\x75\x10\x27\x09\xcc\x57\x59\xb1\x14\xb0\xe5\xba\x66\xb3\x53\x55\x70\xbe\x39\x90\x55\xac\xc1\xd3\x9b\x9f\x45\x73\x89\x71\x8e\x50\x88\xf4\xb1\x40\x48\x45\x82\xcf\x27\x5c\x8d\x31\x2b\x71\x1a\x4e\x46\x86\x53\x78\xba\x43\x89\xa0\xa9\x26\x4f\x5a\x95\x1e\xd2\x35\x88\x22\xcb\x66\xd1\x10\x73\xaa\xd7\xda\x46\x87\xca\x2b\x17\xb6\xae\xb2\xdc\x7a\x6e\x8c\x83\x49\x04\xc0\x1a\x42\x60\x0d\x62\x95\xf7\x24\x3c\xc8\x74\x19\xcb\x0d\xdc\xe3\xe6\x5f\x04\x7d\x78\xd7\x83\x8d\xa7\x1d\xd9\xcb\x7f\x12\x17\x28\x51\xcc\x71\x7d\x2e\xc8\x7a\xda\x01\x43\xcf\xeb\xa8\xe1\xf8\x6e\x5e\x59\x2a\xcf\x65\x3d\x1f\xc8\xf9\xf2\xd0\xc2\x91\xfd\xbe\xff\x84\x8f\xc6\xd0\x25\x24\xc1\x45\x5c\x64\x39\x98\xe6\x38\xe9\xb7\x87\x56\xdf\x40\x97\xb6\xda\xf3\xeb\xef\x78\x34\xfd\x49\x5e\xcd\x15\x19\x47\x7f\xf3\x36\x66\x5c\x7d\x83\xae\x24\x8e\x3e\xd1\x20\xf5\x1c\xad\x5e\xe8\xbd\x17\xf9\xb5\xe1\x36\xa3\xdd\xad\x9f\x01\x00\x00\xff\xff\x8a\xd3\xb0\x31\xc6\x02\x00\x00") + +func _1528395555_UpSqlBytes() ([]byte, error) { + return bindataRead( + __1528395555_UpSql, + "1528395555_.up.sql", + ) +} + +func _1528395555_UpSql() (*asset, error) { + bytes, err := _1528395555_UpSqlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "1528395555_.up.sql", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)} + a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x7b, 0x2b, 0x64, 0xb2, 0x64, 0xc6, 0xe, 0xce, 0x3f, 0xd, 0x33, 0x68, 0xe8, 0x43, 0x1f, 0x98, 0xf5, 0x47, 0x71, 0x13, 0xb4, 0x78, 0x12, 0x92, 0x6, 0xe8, 0x25, 0xc0, 0xd5, 0x1e, 0x6, 0x70}} + return a, nil +} + // Asset loads and returns the asset for the given name. // It returns an error if the asset could not be found or // could not be loaded. @@ -4114,6 +4156,10 @@ var _bindata = map[string]func() (*asset, error){ "1528395554_oss_fake_migration.down.sql": _1528395554_oss_fake_migrationDownSql, "1528395554_oss_fake_migration.up.sql": _1528395554_oss_fake_migrationUpSql, + + "1528395555_.down.sql": _1528395555_DownSql, + + "1528395555_.up.sql": _1528395555_UpSql, } // AssetDir returns the file names below a certain @@ -4329,6 +4375,8 @@ var _bintree = &bintree{nil, map[string]*bintree{ "1528395553_.up.sql": &bintree{_1528395553_UpSql, map[string]*bintree{}}, "1528395554_oss_fake_migration.down.sql": &bintree{_1528395554_oss_fake_migrationDownSql, map[string]*bintree{}}, "1528395554_oss_fake_migration.up.sql": &bintree{_1528395554_oss_fake_migrationUpSql, map[string]*bintree{}}, + "1528395555_.down.sql": &bintree{_1528395555_DownSql, map[string]*bintree{}}, + "1528395555_.up.sql": &bintree{_1528395555_UpSql, map[string]*bintree{}}, }} // RestoreAsset restores an asset under the given directory. diff --git a/cmd/frontend/db/schema.md b/cmd/frontend/db/schema.md index f77731fb2..c6fe0bad4 100644 --- a/cmd/frontend/db/schema.md +++ b/cmd/frontend/db/schema.md @@ -287,6 +287,40 @@ Foreign-key constraints: ``` +# Table "public.product_licenses" +``` + Column | Type | Modifiers +-------------------------+--------------------------+------------------------ + id | uuid | not null + product_subscription_id | uuid | not null + license_key | text | not null + created_at | timestamp with time zone | not null default now() +Indexes: + "product_licenses_pkey" PRIMARY KEY, btree (id) +Foreign-key constraints: + "product_licenses_product_subscription_id_fkey" FOREIGN KEY (product_subscription_id) REFERENCES product_subscriptions(id) + +``` + +# Table "public.product_subscriptions" +``` + Column | Type | Modifiers +-------------------------+--------------------------+------------------------ + id | uuid | not null + user_id | integer | not null + billing_subscription_id | text | + created_at | timestamp with time zone | not null default now() + updated_at | timestamp with time zone | not null default now() + archived_at | timestamp with time zone | +Indexes: + "product_subscriptions_pkey" PRIMARY KEY, btree (id) +Foreign-key constraints: + "product_subscriptions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) +Referenced by: + TABLE "product_licenses" CONSTRAINT "product_licenses_product_subscription_id_fkey" FOREIGN KEY (product_subscription_id) REFERENCES product_subscriptions(id) + +``` + # Table "public.registry_extension_releases" ``` Column | Type | Modifiers @@ -501,25 +535,27 @@ Foreign-key constraints: # Table "public.users" ``` - Column | Type | Modifiers --------------------+--------------------------+---------------------------------------------------- - id | integer | not null default nextval('users_id_seq'::regclass) - username | citext | not null - display_name | text | - avatar_url | text | - created_at | timestamp with time zone | not null default now() - updated_at | timestamp with time zone | not null default now() - deleted_at | timestamp with time zone | - invite_quota | integer | not null default 15 - passwd | text | - passwd_reset_code | text | - passwd_reset_time | timestamp with time zone | - site_admin | boolean | not null default false - page_views | integer | not null default 0 - search_queries | integer | not null default 0 - tags | text[] | default '{}'::text[] + Column | Type | Modifiers +---------------------+--------------------------+---------------------------------------------------- + id | integer | not null default nextval('users_id_seq'::regclass) + username | citext | not null + display_name | text | + avatar_url | text | + created_at | timestamp with time zone | not null default now() + updated_at | timestamp with time zone | not null default now() + deleted_at | timestamp with time zone | + invite_quota | integer | not null default 15 + passwd | text | + passwd_reset_code | text | + passwd_reset_time | timestamp with time zone | + site_admin | boolean | not null default false + page_views | integer | not null default 0 + search_queries | integer | not null default 0 + tags | text[] | default '{}'::text[] + billing_customer_id | text | Indexes: "users_pkey" PRIMARY KEY, btree (id) + "users_billing_customer_id" UNIQUE, btree (billing_customer_id) WHERE deleted_at IS NULL "users_username" UNIQUE, btree (username) WHERE deleted_at IS NULL Check constraints: "users_display_name_max_length" CHECK (char_length(display_name) <= 255) @@ -535,6 +571,7 @@ Referenced by: TABLE "org_invitations" CONSTRAINT "org_invitations_recipient_user_id_fkey" FOREIGN KEY (recipient_user_id) REFERENCES users(id) TABLE "org_invitations" CONSTRAINT "org_invitations_sender_user_id_fkey" FOREIGN KEY (sender_user_id) REFERENCES users(id) TABLE "org_members" CONSTRAINT "org_members_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT + TABLE "product_subscriptions" CONSTRAINT "product_subscriptions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) TABLE "registry_extension_releases" CONSTRAINT "registry_extension_releases_creator_user_id_fkey" FOREIGN KEY (creator_user_id) REFERENCES users(id) TABLE "registry_extensions" CONSTRAINT "registry_extensions_publisher_user_id_fkey" FOREIGN KEY (publisher_user_id) REFERENCES users(id) TABLE "settings" CONSTRAINT "settings_author_user_id_fkey" FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE RESTRICT diff --git a/cmd/frontend/db/users.go b/cmd/frontend/db/users.go index 6e0c9e43f..704d061c8 100644 --- a/cmd/frontend/db/users.go +++ b/cmd/frontend/db/users.go @@ -42,6 +42,12 @@ func (err userNotFoundErr) NotFound() bool { return true } +// NewUserNotFoundError returns a new error indicating that the user with the given user ID was not +// found. +func NewUserNotFoundError(userID int32) error { + return userNotFoundErr{args: []interface{}{"userID", userID}} +} + // errCannotCreateUser is the error that is returned when // a user cannot be added to the DB due to a constraint. type errCannotCreateUser struct { diff --git a/cmd/frontend/external/app/app.go b/cmd/frontend/external/app/app.go index 2cbcf61f5..637a9d0c3 100644 --- a/cmd/frontend/external/app/app.go +++ b/cmd/frontend/external/app/app.go @@ -1,6 +1,9 @@ package app -import "github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app" +import ( + "github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app" + "github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app/jscontext" +) type ( SignOutURL = app.SignOutURL @@ -9,3 +12,7 @@ type ( var ( RegisterSSOSignOutHandler = app.RegisterSSOSignOutHandler ) + +func SetBillingPublishableKey(value string) { + jscontext.BillingPublishableKey = value +} diff --git a/cmd/frontend/graphqlbackend/dotcom.go b/cmd/frontend/graphqlbackend/dotcom.go index 89a2bbf4f..b226b8ec9 100644 --- a/cmd/frontend/graphqlbackend/dotcom.go +++ b/cmd/frontend/graphqlbackend/dotcom.go @@ -3,28 +3,167 @@ package graphqlbackend import ( "context" "errors" + + graphql "github.com/graph-gophers/graphql-go" + "github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend/graphqlutil" ) -// DotcomMutation is the implementation of the GraphQL type DotcomMutation. If it is not set at -// runtime, a "not implemented" error is returned to API clients who invoke it. +// Dotcom is the implementation of the GraphQL type DotcomMutation. If it is not set at runtime, a +// "not implemented" error is returned to API clients who invoke it. // // This is contributed by enterprise. -var DotcomMutation DotcomMutationResolver +var Dotcom DotcomResolver -func (schemaResolver) Dotcom() (DotcomMutationResolver, error) { - if DotcomMutation == nil { +func (schemaResolver) Dotcom() (DotcomResolver, error) { + if Dotcom == nil { return nil, errors.New("dotcom is not implemented") } - return DotcomMutation, nil + return Dotcom, nil +} + +// DotcomResolver is the interface for the GraphQL types DotcomMutation and DotcomQuery. +type DotcomResolver interface { + // DotcomMutation + SetUserBilling(context.Context, *SetUserBillingArgs) (*EmptyResponse, error) + CreateProductSubscription(context.Context, *CreateProductSubscriptionArgs) (ProductSubscription, error) + SetProductSubscriptionBilling(context.Context, *SetProductSubscriptionBillingArgs) (*EmptyResponse, error) + GenerateProductLicenseForSubscription(context.Context, *GenerateProductLicenseForSubscriptionArgs) (ProductLicense, error) + CreatePaidProductSubscription(context.Context, *CreatePaidProductSubscriptionArgs) (*CreatePaidProductSubscriptionResult, error) + ArchiveProductSubscription(context.Context, *ArchiveProductSubscriptionArgs) (*EmptyResponse, error) + + // DotcomQuery + ProductSubscriptions(context.Context, *ProductSubscriptionsArgs) (ProductSubscriptionConnection, error) + ProductLicenses(context.Context, *ProductLicensesArgs) (ProductLicenseConnection, error) + ProductPlans(context.Context) ([]ProductPlan, error) +} + +// ProductSubscriptionByID is called to look up a ProductSubscription given its GraphQL ID. +// +// This is contributed by enterprise. +var ProductSubscriptionByID func(context.Context, graphql.ID) (ProductSubscription, error) + +// ProductSubscription is the interface for the GraphQL type ProductSubscription. +type ProductSubscription interface { + ID() graphql.ID + Name() string + Account(context.Context) (*UserResolver, error) + Plan(context.Context) (ProductPlan, error) + UserCount(context.Context) (*int32, error) + ExpiresAt(context.Context) (*string, error) + Events(context.Context) ([]ProductSubscriptionEvent, error) + ActiveLicense(context.Context) (ProductLicense, error) + ProductLicenses(context.Context, *graphqlutil.ConnectionArgs) (ProductLicenseConnection, error) + CreatedAt() string + IsArchived() bool + URL(context.Context) (string, error) + URLForSiteAdmin(context.Context) *string + URLForSiteAdminBilling(context.Context) (*string, error) +} + +type SetUserBillingArgs struct { + User graphql.ID + BillingCustomerID *string +} + +type CreateProductSubscriptionArgs struct { + AccountID graphql.ID +} + +type SetProductSubscriptionBillingArgs struct { + ID graphql.ID + BillingSubscriptionID *string +} + +type GenerateProductLicenseForSubscriptionArgs struct { + ProductSubscriptionID graphql.ID + License *ProductLicenseInput +} + +type CreatePaidProductSubscriptionArgs struct { + AccountID graphql.ID + ProductSubscription ProductSubscriptionInput + PaymentToken string +} + +// ProductSubscriptionInput implements the GraphQL type ProductSubscriptionInput. +type ProductSubscriptionInput struct { + Plan string + UserCount int32 + TotalPriceNonAuthoritative int32 +} + +// CreatePaidProductSubscriptionResult implements the GraphQL type CreatePaidProductSubscriptionResult. +type CreatePaidProductSubscriptionResult struct { + ProductSubscriptionValue ProductSubscription +} + +func (r *CreatePaidProductSubscriptionResult) ProductSubscription() ProductSubscription { + return r.ProductSubscriptionValue +} + +type ArchiveProductSubscriptionArgs struct{ ID graphql.ID } + +type ProductSubscriptionsArgs struct { + graphqlutil.ConnectionArgs + Account *graphql.ID +} + +// ProductSubscriptionConnection is the interface for the GraphQL type +// ProductSubscriptionConnection. +type ProductSubscriptionConnection interface { + Nodes(context.Context) ([]ProductSubscription, error) + TotalCount(context.Context) (int32, error) + PageInfo(context.Context) (*graphqlutil.PageInfo, error) +} + +// ProductLicenseByID is called to look up a ProductLicense given its GraphQL ID. +// +// This is contributed by enterprise. +var ProductLicenseByID func(context.Context, graphql.ID) (ProductLicense, error) + +// ProductLicense is the interface for the GraphQL type ProductLicense. +type ProductLicense interface { + ID() graphql.ID + Subscription(context.Context) (ProductSubscription, error) + Info() (*ProductLicenseInfo, error) + LicenseKey() string + CreatedAt() string +} + +// ProductLicenseInput implements the GraphQL type ProductLicenseInput. +type ProductLicenseInput struct { + Tags []string + UserCount int32 + ExpiresAt int32 +} + +type ProductLicensesArgs struct { + graphqlutil.ConnectionArgs + LicenseKeySubstring *string + ProductSubscriptionID *graphql.ID +} + +// ProductLicenseConnection is the interface for the GraphQL type ProductLicenseConnection. +type ProductLicenseConnection interface { + Nodes(context.Context) ([]ProductLicense, error) + TotalCount(context.Context) (int32, error) + PageInfo(context.Context) (*graphqlutil.PageInfo, error) } -// DotcomMutationResolver is the API of the GraphQL type DotcomMutation. -type DotcomMutationResolver interface { - GenerateSourcegraphLicenseKey(ctx context.Context, args *DotcomMutationGenerateSourcegraphLicenseKeyArgs) (string, error) +// ProductPlan is the interface for the GraphQL type ProductPlan. +type ProductPlan interface { + BillingID() string + Name() string + Title() string + FullProductName() string + PricePerUserPerYear() int32 } -type DotcomMutationGenerateSourcegraphLicenseKeyArgs struct { - Plan string - MaxUserCount *int32 - ExpiresAt *int32 +// ProductSubscriptionEvent is the interface for the GraphQL type ProductSubscriptionEvent. +type ProductSubscriptionEvent interface { + ID() string + Date() string + Title() string + Description() *string + URL() *string } diff --git a/cmd/frontend/graphqlbackend/graphqlbackend.go b/cmd/frontend/graphqlbackend/graphqlbackend.go index 0dca527f4..0f4781d17 100644 --- a/cmd/frontend/graphqlbackend/graphqlbackend.go +++ b/cmd/frontend/graphqlbackend/graphqlbackend.go @@ -84,6 +84,16 @@ func (r *nodeResolver) ToDependency() (*dependencyResolver, bool) { return n, ok } +func (r *nodeResolver) ToProductLicense() (ProductLicense, bool) { + n, ok := r.node.(ProductLicense) + return n, ok +} + +func (r *nodeResolver) ToProductSubscription() (ProductSubscription, bool) { + n, ok := r.node.(ProductSubscription) + return n, ok +} + func (r *nodeResolver) ToExternalAccount() (*externalAccountResolver, bool) { n, ok := r.node.(*externalAccountResolver) return n, ok @@ -152,6 +162,16 @@ func nodeByID(ctx context.Context, id graphql.ID) (node, error) { switch relay.UnmarshalKind(id) { case "AccessToken": return accessTokenByID(ctx, id) + case "ProductLicense": + if f := ProductLicenseByID; f != nil { + return f(ctx, id) + } + return nil, errors.New("not implemented") + case "ProductSubscription": + if f := ProductSubscriptionByID; f != nil { + return f(ctx, id) + } + return nil, errors.New("not implemented") case "ExternalAccount": return externalAccountByID(ctx, id) case "GitRef": diff --git a/cmd/frontend/graphqlbackend/product_license_info.go b/cmd/frontend/graphqlbackend/product_license_info.go index 85559b32a..60a375ef8 100644 --- a/cmd/frontend/graphqlbackend/product_license_info.go +++ b/cmd/frontend/graphqlbackend/product_license_info.go @@ -1,7 +1,6 @@ package graphqlbackend import ( - "context" "time" ) @@ -12,34 +11,25 @@ import ( // // It is overridden in non-OSS builds to return information about the actual product subscription in // use. -var GetConfiguredProductLicenseInfo = func(ctx context.Context) (*ProductLicenseInfo, error) { +var GetConfiguredProductLicenseInfo = func() (*ProductLicenseInfo, error) { return nil, nil // OSS builds have no license } // ProductLicenseInfo implements the GraphQL type ProductLicenseInfo. type ProductLicenseInfo struct { - PlanValue string - UserCountValue *uint - ExpiresAtValue *time.Time + TagsValue []string + UserCountValue uint + ExpiresAtValue time.Time } -// Plan implements the GraphQL type ProductLicenseInfo. -func (r ProductLicenseInfo) Plan() string { return r.PlanValue } +func (r ProductLicenseInfo) FullProductName() string { return GetFullProductName(true, r.TagsValue) } -// UserCount implements the GraphQL type ProductLicenseInfo. -func (r ProductLicenseInfo) UserCount(ctx context.Context) (*int32, error) { - if r.UserCountValue == nil { - return nil, nil - } - n2 := int32(*r.UserCountValue) - return &n2, nil +func (r ProductLicenseInfo) Tags() []string { return r.TagsValue } + +func (r ProductLicenseInfo) UserCount() int32 { + return int32(r.UserCountValue) } -// ExpiresAt implements the GraphQL type ProductLicenseInfo. -func (r ProductLicenseInfo) ExpiresAt() *string { - if r.ExpiresAtValue == nil { - return nil - } - s := r.ExpiresAtValue.Format(time.RFC3339) - return &s +func (r ProductLicenseInfo) ExpiresAt() string { + return r.ExpiresAtValue.Format(time.RFC3339) } diff --git a/cmd/frontend/graphqlbackend/product_subscription_status.go b/cmd/frontend/graphqlbackend/product_subscription_status.go index 7c74633d0..94af24777 100644 --- a/cmd/frontend/graphqlbackend/product_subscription_status.go +++ b/cmd/frontend/graphqlbackend/product_subscription_status.go @@ -6,14 +6,33 @@ import ( "github.com/sourcegraph/sourcegraph/cmd/frontend/db" ) +// GetFullProductName is called to obtain the full product name (e.g., "Sourcegraph OSS") from a +// product license. +var GetFullProductName = func(hasLicense bool, licenseTags []string) string { + return "Sourcegraph OSS" +} + // productSubscriptionStatus implements the GraphQL type ProductSubscriptionStatus. type productSubscriptionStatus struct{} +func (productSubscriptionStatus) FullProductName() (string, error) { + info, err := GetConfiguredProductLicenseInfo() + if err != nil { + return "", err + } + hasLicense := info != nil + var licenseTags []string + if hasLicense { + licenseTags = info.Tags() + } + return GetFullProductName(hasLicense, licenseTags), nil +} + func (productSubscriptionStatus) ActualUserCount(ctx context.Context) (int32, error) { count, err := db.Users.Count(ctx, nil) return int32(count), err } -func (r productSubscriptionStatus) License(ctx context.Context) (*ProductLicenseInfo, error) { - return GetConfiguredProductLicenseInfo(ctx) +func (r productSubscriptionStatus) License() (*ProductLicenseInfo, error) { + return GetConfiguredProductLicenseInfo() } diff --git a/cmd/frontend/graphqlbackend/schema.go b/cmd/frontend/graphqlbackend/schema.go index e74d72319..66046c088 100644 --- a/cmd/frontend/graphqlbackend/schema.go +++ b/cmd/frontend/graphqlbackend/schema.go @@ -721,6 +721,10 @@ type Query { ): SurveyResponseConnection! # The extension registry. extensionRegistry: ExtensionRegistry! + # Queries that are only used on Sourcegraph.com. + # + # FOR INTERNAL USE ONLY. + dotcom: DotcomQuery! } # Configuration details for the browser extension, editor extensions, etc. @@ -2084,6 +2088,12 @@ type User implements Node & ConfigurationSubject { # # Only the user and site admins can access this field. surveyResponses: [SurveyResponse!]! + # The URL to view this user's customer information (for Sourcegraph.com site admins). + # + # Only Sourcegraph.com site admins may query this field. + # + # FOR INTERNAL USE ONLY. + urlForSiteAdminBilling: String } # An access token that grants to the holder the privileges of the user who created it. @@ -3067,6 +3077,8 @@ type SurveyResponse { # Information about this site's product subscription (which enables access to and renewals of a product license). type ProductSubscriptionStatus { + # The full name of the product in use, such as "Sourcegraph Enterprise". + fullProductName: String! # The actual total number of users on this Sourcegraph site. actualUserCount: Int! # The product license associated with this subscription, if any. @@ -3075,12 +3087,16 @@ type ProductSubscriptionStatus { # Information about this site's product license (which activates certain Sourcegraph features). type ProductLicenseInfo { - # The name of the product plan associated with the license. - plan: String! - # The number of users allowed by this license, or null if there is no limit. - userCount: Int - # The date when this license expires, or null if there is no expiration. - expiresAt: String + # The full name of the product that this license is for. To get the product name for the current + # Sourcegraph site, use ProductSubscriptionStatus.fullProductName instead (to handle cases where there is + # no license). + fullProductName: String! + # Tags indicating the product plan and features activated by this license. + tags: [String!]! + # The number of users allowed by this license. + userCount: Int! + # The date when this license expires. + expiresAt: String! } # An extension registry. @@ -3276,20 +3292,259 @@ type RegistryExtensionConnection { # # FOR INTERNAL USE ONLY. type DotcomMutation { - # Generates and returns a new Sourcegraph license key (signed with Sourcegraph.com's private key and verifiable - # with the corresponding public key). + # Set or unset a user's associated billing information. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + setUserBilling( + # The user to update. + user: ID! + # The billing customer ID (on the billing system) to associate this user with. If null, the association is + # removed (i.e., the user is unlinked from the billing customer record). + billingCustomerID: String + ): EmptyResponse! + # Creates new product subscription for an account. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + createProductSubscription( + # The ID of the user (i.e., customer) to whom this product subscription is assigned. + accountID: ID! + ): ProductSubscription! + # Set or unset a product subscription's associated billing system subscription. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + setProductSubscriptionBilling( + # The product subscription to update. + id: ID! + # The billing subscription ID (on the billing system) to associate this product subscription with. If null, + # the association is removed (i.e., the subscription is unlinked from billing). + billingSubscriptionID: String + ): EmptyResponse! + # Generates and signs a new product license and associates it with an existing product subscription. The + # product license key is signed with Sourcegraph.com's private key and is verifiable with the corresponding + # public key. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + generateProductLicenseForSubscription( + # The product subscription to associate with the license. + productSubscriptionID: ID! + # The license to generate. + license: ProductLicenseInput! + ): ProductLicense! + # Creates a new product subscription and bills the associated payment method. + # + # Only Sourcegraph.com authenticated users may perform this mutation. + # + # FOR INTERNAL USE ONLY. + createPaidProductSubscription( + # The ID of the user (i.e., customer) to whom the product subscription is assigned. + # + # Only Sourcegraph.com site admins may perform this mutation for an accountID != the user ID of the + # authenticated user. + accountID: ID! + # The details of the product subscription. + productSubscription: ProductSubscriptionInput! + # The token that represents the payment method used to purchase this product subscription. + paymentToken: String! + ): CreatePaidProductSubscriptionResult! + # Archives an existing product subscription. # # Only Sourcegraph.com site admins may perform this mutation. # # FOR INTERNAL USE ONLY. - generateSourcegraphLicenseKey( - # The name of the plan associated with the license. - plan: String! - # The maximum number of users for which this license is valid (or null if the license has no user limit). - maxUserCount: Int - # The expiration date of this license (when it is no longer valid), expressed as the number of seconds - # since the epoch. - expiresAt: Int - ): String! + archiveProductSubscription(id: ID!): EmptyResponse! +} + +# Mutations that are only used on Sourcegraph.com. +# +# FOR INTERNAL USE ONLY. +type DotcomQuery { + # A list of product subscriptions. + # + # FOR INTERNAL USE ONLY. + productSubscriptions( + # Returns the first n product subscriptions from the list. + first: Int + # Returns only product subscriptions for the given account. + # + # Only Sourcegraph.com site admins may perform this query with account == null. + account: ID + ): ProductSubscriptionConnection! + # A list of product licenses. + # + # Only Sourcegraph.com site admins may perform this query. + # + # FOR INTERNAL USE ONLY. + productLicenses( + # Returns the first n product subscriptions from the list. + first: Int + # Returns only product subscriptions whose license key contains this substring. + licenseKeySubstring: String + # Returns only product licenses associated with the given subscription + productSubscriptionID: ID + ): ProductLicenseConnection! + # A list of product pricing plans for Sourcegraph. + productPlans: [ProductPlan!]! +} + +# A product subscription that was created on Sourcegraph.com. +# +# FOR INTERNAL USE ONLY. +type ProductSubscription implements Node { + # The unique ID of this product subscription. + id: ID! + # A name for the product subscription derived from its ID. The name is not guaranteed to be unique. + name: String! + # The user (i.e., customer) to whom this subscription is granted, or null if the account has been deleted. + account: User + # The product and pricing plan that this subscription entitles the account to, or null if there is none. + plan: ProductPlan + # The user count that this subscription entitles the account to, or null if there is none. + userCount: Int + # The date when the subscription expires. + expiresAt: String + # A list of billing-related events related to this product subscription. + events: [ProductSubscriptionEvent!]! + # The currently active product license associated with this product subscription, if any. + activeLicense: ProductLicense + # A list of product licenses associated with this product subscription. + # + # Only Sourcegraph.com site admins may list inactive product licenses (other viewers should use + # ProductSubscription.activeLicense). + productLicenses( + # Returns the first n product licenses from the list. + first: Int + ): ProductLicenseConnection! + # The date when this product subscription was created. + createdAt: String! + # Whether this product subscription was archived. + isArchived: Boolean! + # The URL to view this product subscription. + url: String! + # The URL to view this product subscription in the site admin area. + # + # Only Sourcegraph.com site admins may query this field. + urlForSiteAdmin: String + # The URL to view this product subscription's billing information (for site admins). + # + # Only Sourcegraph.com site admins may query this field. + urlForSiteAdminBilling: String +} + +# A list of product subscriptions. +# +# FOR INTERNAL USE ONLY. +type ProductSubscriptionConnection { + # A list of product subscriptions. + nodes: [ProductSubscription!]! + # The total count of product subscriptions in the connection. This total count may be larger than the number of + # nodes in this object when the result is paginated. + totalCount: Int! + # Pagination information. + pageInfo: PageInfo! +} + +# An input type that describes a product license to be generated and signed. +# +# FOR INTERNAL USE ONLY. +input ProductLicenseInput { + # The tags that indicate which features are activated by this license. + tags: [String!]! + # The number of users for which this product subscription is valid. + userCount: Int! + # The expiration date of this product license, expressed as the number of seconds since the epoch. + expiresAt: Int! +} + +# A product license that was created on Sourcegraph.com. +# +# FOR INTERNAL USE ONLY. +type ProductLicense implements Node { + # The unique ID of this product license. + id: ID! + # The product subscription associated with this product license. + subscription: ProductSubscription! + # Information about this product license. + info: ProductLicenseInfo + # The license key. + licenseKey: String! + # The date when this product license was created. + createdAt: String! +} + +# A list of product licenses. +# +# FOR INTERNAL USE ONLY. +type ProductLicenseConnection { + # A list of product licenses. + nodes: [ProductLicense!]! + # The total count of product licenses in the connection. This total count may be larger than the number of + # nodes in this object when the result is paginated. + totalCount: Int! + # Pagination information. + pageInfo: PageInfo! +} + +# A product pricing plan for Sourcegraph. +# +# FOR INTERNAL USE ONLY. +type ProductPlan { + # The billing system's unique ID of this pricing plan. + billingID: String! + # The internal name of the pricing plan (e.g., "enterprise-starter"). This is not displayed on the page but may + # be shown in the URL. + name: String! + # The title of the pricing plan (e.g., "Enterprise Starter"). This is displayed to the user and should be + # human-readable. + title: String! + # The full product name of this plan's offering (e.g., "Sourcegraph Enterprise Starter"). + fullProductName: String! + # The price (in USD cents) for one user for a year. + pricePerUserPerYear: Int! +} + +# An input type that describes a product subscription to be purchased. +# +# FOR INTERNAL USE ONLY. +input ProductSubscriptionInput { + # The name of the subscription's plan (ProductPlan.name). + plan: String! + # This subscription's user count. + userCount: Int! + # The non-authoritative price (in USD cents) that the client computed. The server MUST independently compute + # the price given this input object's other properties. If the prices differ (which indicates a bug or a + # malicious client), then the server MUST abort and return an error. + totalPriceNonAuthoritative: Int! +} + +# The result of Mutation.dotcom.createPaidProductSubscription. +# +# FOR INTERNAL USE ONLY. +type CreatePaidProductSubscriptionResult { + # The newly created product subscription. + productSubscription: ProductSubscription! +} + +# An event related to a product subscription. +# +# FOR INTERNAL USE ONLY. +type ProductSubscriptionEvent { + # The unique ID of the event. + id: String! + # The date when the event occurred. + date: String! + # The title of the event. + title: String! + # A description of the event. + description: String + # A URL where the user can see more information about the event. + url: String } ` diff --git a/cmd/frontend/graphqlbackend/schema.graphql b/cmd/frontend/graphqlbackend/schema.graphql index 3beb84a92..07a1e93ea 100755 --- a/cmd/frontend/graphqlbackend/schema.graphql +++ b/cmd/frontend/graphqlbackend/schema.graphql @@ -728,6 +728,10 @@ type Query { ): SurveyResponseConnection! # The extension registry. extensionRegistry: ExtensionRegistry! + # Queries that are only used on Sourcegraph.com. + # + # FOR INTERNAL USE ONLY. + dotcom: DotcomQuery! } # Configuration details for the browser extension, editor extensions, etc. @@ -2091,6 +2095,12 @@ type User implements Node & ConfigurationSubject { # # Only the user and site admins can access this field. surveyResponses: [SurveyResponse!]! + # The URL to view this user's customer information (for Sourcegraph.com site admins). + # + # Only Sourcegraph.com site admins may query this field. + # + # FOR INTERNAL USE ONLY. + urlForSiteAdminBilling: String } # An access token that grants to the holder the privileges of the user who created it. @@ -3074,6 +3084,8 @@ type SurveyResponse { # Information about this site's product subscription (which enables access to and renewals of a product license). type ProductSubscriptionStatus { + # The full name of the product in use, such as "Sourcegraph Enterprise". + fullProductName: String! # The actual total number of users on this Sourcegraph site. actualUserCount: Int! # The product license associated with this subscription, if any. @@ -3082,12 +3094,16 @@ type ProductSubscriptionStatus { # Information about this site's product license (which activates certain Sourcegraph features). type ProductLicenseInfo { - # The name of the product plan associated with the license. - plan: String! - # The number of users allowed by this license, or null if there is no limit. - userCount: Int - # The date when this license expires, or null if there is no expiration. - expiresAt: String + # The full name of the product that this license is for. To get the product name for the current + # Sourcegraph site, use ProductSubscriptionStatus.fullProductName instead (to handle cases where there is + # no license). + fullProductName: String! + # Tags indicating the product plan and features activated by this license. + tags: [String!]! + # The number of users allowed by this license. + userCount: Int! + # The date when this license expires. + expiresAt: String! } # An extension registry. @@ -3283,19 +3299,258 @@ type RegistryExtensionConnection { # # FOR INTERNAL USE ONLY. type DotcomMutation { - # Generates and returns a new Sourcegraph license key (signed with Sourcegraph.com's private key and verifiable - # with the corresponding public key). + # Set or unset a user's associated billing information. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + setUserBilling( + # The user to update. + user: ID! + # The billing customer ID (on the billing system) to associate this user with. If null, the association is + # removed (i.e., the user is unlinked from the billing customer record). + billingCustomerID: String + ): EmptyResponse! + # Creates new product subscription for an account. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + createProductSubscription( + # The ID of the user (i.e., customer) to whom this product subscription is assigned. + accountID: ID! + ): ProductSubscription! + # Set or unset a product subscription's associated billing system subscription. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + setProductSubscriptionBilling( + # The product subscription to update. + id: ID! + # The billing subscription ID (on the billing system) to associate this product subscription with. If null, + # the association is removed (i.e., the subscription is unlinked from billing). + billingSubscriptionID: String + ): EmptyResponse! + # Generates and signs a new product license and associates it with an existing product subscription. The + # product license key is signed with Sourcegraph.com's private key and is verifiable with the corresponding + # public key. + # + # Only Sourcegraph.com site admins may perform this mutation. + # + # FOR INTERNAL USE ONLY. + generateProductLicenseForSubscription( + # The product subscription to associate with the license. + productSubscriptionID: ID! + # The license to generate. + license: ProductLicenseInput! + ): ProductLicense! + # Creates a new product subscription and bills the associated payment method. + # + # Only Sourcegraph.com authenticated users may perform this mutation. + # + # FOR INTERNAL USE ONLY. + createPaidProductSubscription( + # The ID of the user (i.e., customer) to whom the product subscription is assigned. + # + # Only Sourcegraph.com site admins may perform this mutation for an accountID != the user ID of the + # authenticated user. + accountID: ID! + # The details of the product subscription. + productSubscription: ProductSubscriptionInput! + # The token that represents the payment method used to purchase this product subscription. + paymentToken: String! + ): CreatePaidProductSubscriptionResult! + # Archives an existing product subscription. # # Only Sourcegraph.com site admins may perform this mutation. # # FOR INTERNAL USE ONLY. - generateSourcegraphLicenseKey( - # The name of the plan associated with the license. - plan: String! - # The maximum number of users for which this license is valid (or null if the license has no user limit). - maxUserCount: Int - # The expiration date of this license (when it is no longer valid), expressed as the number of seconds - # since the epoch. - expiresAt: Int - ): String! + archiveProductSubscription(id: ID!): EmptyResponse! +} + +# Mutations that are only used on Sourcegraph.com. +# +# FOR INTERNAL USE ONLY. +type DotcomQuery { + # A list of product subscriptions. + # + # FOR INTERNAL USE ONLY. + productSubscriptions( + # Returns the first n product subscriptions from the list. + first: Int + # Returns only product subscriptions for the given account. + # + # Only Sourcegraph.com site admins may perform this query with account == null. + account: ID + ): ProductSubscriptionConnection! + # A list of product licenses. + # + # Only Sourcegraph.com site admins may perform this query. + # + # FOR INTERNAL USE ONLY. + productLicenses( + # Returns the first n product subscriptions from the list. + first: Int + # Returns only product subscriptions whose license key contains this substring. + licenseKeySubstring: String + # Returns only product licenses associated with the given subscription + productSubscriptionID: ID + ): ProductLicenseConnection! + # A list of product pricing plans for Sourcegraph. + productPlans: [ProductPlan!]! +} + +# A product subscription that was created on Sourcegraph.com. +# +# FOR INTERNAL USE ONLY. +type ProductSubscription implements Node { + # The unique ID of this product subscription. + id: ID! + # A name for the product subscription derived from its ID. The name is not guaranteed to be unique. + name: String! + # The user (i.e., customer) to whom this subscription is granted, or null if the account has been deleted. + account: User + # The product and pricing plan that this subscription entitles the account to, or null if there is none. + plan: ProductPlan + # The user count that this subscription entitles the account to, or null if there is none. + userCount: Int + # The date when the subscription expires. + expiresAt: String + # A list of billing-related events related to this product subscription. + events: [ProductSubscriptionEvent!]! + # The currently active product license associated with this product subscription, if any. + activeLicense: ProductLicense + # A list of product licenses associated with this product subscription. + # + # Only Sourcegraph.com site admins may list inactive product licenses (other viewers should use + # ProductSubscription.activeLicense). + productLicenses( + # Returns the first n product licenses from the list. + first: Int + ): ProductLicenseConnection! + # The date when this product subscription was created. + createdAt: String! + # Whether this product subscription was archived. + isArchived: Boolean! + # The URL to view this product subscription. + url: String! + # The URL to view this product subscription in the site admin area. + # + # Only Sourcegraph.com site admins may query this field. + urlForSiteAdmin: String + # The URL to view this product subscription's billing information (for site admins). + # + # Only Sourcegraph.com site admins may query this field. + urlForSiteAdminBilling: String +} + +# A list of product subscriptions. +# +# FOR INTERNAL USE ONLY. +type ProductSubscriptionConnection { + # A list of product subscriptions. + nodes: [ProductSubscription!]! + # The total count of product subscriptions in the connection. This total count may be larger than the number of + # nodes in this object when the result is paginated. + totalCount: Int! + # Pagination information. + pageInfo: PageInfo! +} + +# An input type that describes a product license to be generated and signed. +# +# FOR INTERNAL USE ONLY. +input ProductLicenseInput { + # The tags that indicate which features are activated by this license. + tags: [String!]! + # The number of users for which this product subscription is valid. + userCount: Int! + # The expiration date of this product license, expressed as the number of seconds since the epoch. + expiresAt: Int! +} + +# A product license that was created on Sourcegraph.com. +# +# FOR INTERNAL USE ONLY. +type ProductLicense implements Node { + # The unique ID of this product license. + id: ID! + # The product subscription associated with this product license. + subscription: ProductSubscription! + # Information about this product license. + info: ProductLicenseInfo + # The license key. + licenseKey: String! + # The date when this product license was created. + createdAt: String! +} + +# A list of product licenses. +# +# FOR INTERNAL USE ONLY. +type ProductLicenseConnection { + # A list of product licenses. + nodes: [ProductLicense!]! + # The total count of product licenses in the connection. This total count may be larger than the number of + # nodes in this object when the result is paginated. + totalCount: Int! + # Pagination information. + pageInfo: PageInfo! +} + +# A product pricing plan for Sourcegraph. +# +# FOR INTERNAL USE ONLY. +type ProductPlan { + # The billing system's unique ID of this pricing plan. + billingID: String! + # The internal name of the pricing plan (e.g., "enterprise-starter"). This is not displayed on the page but may + # be shown in the URL. + name: String! + # The title of the pricing plan (e.g., "Enterprise Starter"). This is displayed to the user and should be + # human-readable. + title: String! + # The full product name of this plan's offering (e.g., "Sourcegraph Enterprise Starter"). + fullProductName: String! + # The price (in USD cents) for one user for a year. + pricePerUserPerYear: Int! +} + +# An input type that describes a product subscription to be purchased. +# +# FOR INTERNAL USE ONLY. +input ProductSubscriptionInput { + # The name of the subscription's plan (ProductPlan.name). + plan: String! + # This subscription's user count. + userCount: Int! + # The non-authoritative price (in USD cents) that the client computed. The server MUST independently compute + # the price given this input object's other properties. If the prices differ (which indicates a bug or a + # malicious client), then the server MUST abort and return an error. + totalPriceNonAuthoritative: Int! +} + +# The result of Mutation.dotcom.createPaidProductSubscription. +# +# FOR INTERNAL USE ONLY. +type CreatePaidProductSubscriptionResult { + # The newly created product subscription. + productSubscription: ProductSubscription! +} + +# An event related to a product subscription. +# +# FOR INTERNAL USE ONLY. +type ProductSubscriptionEvent { + # The unique ID of the event. + id: String! + # The date when the event occurred. + date: String! + # The title of the event. + title: String! + # A description of the event. + description: String + # A URL where the user can see more information about the event. + url: String } diff --git a/cmd/frontend/graphqlbackend/user.go b/cmd/frontend/graphqlbackend/user.go index 4d65f137d..459691633 100644 --- a/cmd/frontend/graphqlbackend/user.go +++ b/cmd/frontend/graphqlbackend/user.go @@ -232,6 +232,17 @@ func (r *UserResolver) ViewerCanAdminister(ctx context.Context) (bool, error) { return true, nil } +// UserURLForSiteAdminBilling is called to obtain the GraphQL User.urlForSiteAdminBilling value. It +// is only set if billing is implemented. +var UserURLForSiteAdminBilling func(ctx context.Context, userID int32) (*string, error) + +func (r *UserResolver) URLForSiteAdminBilling(ctx context.Context) (*string, error) { + if UserURLForSiteAdminBilling == nil { + return nil, nil + } + return UserURLForSiteAdminBilling(ctx, r.user.ID) +} + func (r *schemaResolver) UpdatePassword(ctx context.Context, args *struct { OldPassword string NewPassword string diff --git a/cmd/frontend/internal/app/jscontext/jscontext.go b/cmd/frontend/internal/app/jscontext/jscontext.go index fa71dc809..f3414c1de 100644 --- a/cmd/frontend/internal/app/jscontext/jscontext.go +++ b/cmd/frontend/internal/app/jscontext/jscontext.go @@ -25,6 +25,9 @@ import ( var sentryDSNFrontend = env.Get("SENTRY_DSN_FRONTEND", "", "Sentry/Raven DSN used for tracking of JavaScript errors") +// BillingPublishableKey is the publishable (non-secret) API key for the billing system, if any. +var BillingPublishableKey string + type authProviderInfo struct { IsBuiltin bool `json:"isBuiltin"` DisplayName string `json:"displayName"` @@ -64,6 +67,8 @@ type JSContext struct { SourcegraphDotComMode bool `json:"sourcegraphDotComMode"` + BillingPublishableKey string `json:"billingPublishableKey,omitempty"` + AccessTokensAllow conf.AccessTokAllow `json:"accessTokensAllow"` AllowSignup bool `json:"allowSignup"` @@ -146,6 +151,8 @@ func NewJSContextFromRequest(req *http.Request) JSContext { SourcegraphDotComMode: envvar.SourcegraphDotComMode(), + BillingPublishableKey: BillingPublishableKey, + // Experiments. We pass these through explicitly so we can // do the default behavior only in Go land. AccessTokensAllow: conf.AccessTokensAllow(), diff --git a/migrations/1528395555_.down.sql b/migrations/1528395555_.down.sql new file mode 100644 index 000000000..a58e03272 --- /dev/null +++ b/migrations/1528395555_.down.sql @@ -0,0 +1,5 @@ +DROP TABLE product_licenses; +DROP TABLE product_subscriptions; + +DROP INDEX users_billing_customer_id; +ALTER TABLE users DROP COLUMN billing_customer_id; diff --git a/migrations/1528395555_.up.sql b/migrations/1528395555_.up.sql new file mode 100644 index 000000000..fdda65193 --- /dev/null +++ b/migrations/1528395555_.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE users ADD COLUMN billing_customer_id text; +CREATE UNIQUE INDEX users_billing_customer_id ON users(billing_customer_id) WHERE deleted_at IS NULL; + +CREATE TABLE product_subscriptions ( + id uuid NOT NULL PRIMARY KEY, + user_id integer NOT NULL REFERENCES users(id), + billing_subscription_id text, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + archived_at timestamp with time zone +); + +CREATE TABLE product_licenses ( + id uuid NOT NULL PRIMARY KEY, + product_subscription_id uuid NOT NULL REFERENCES product_subscriptions(id), + license_key text NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now() +); diff --git a/schema/site.schema.json b/schema/site.schema.json index 14ee99c8d..c487ddfd7 100644 --- a/schema/site.schema.json +++ b/schema/site.schema.json @@ -320,7 +320,7 @@ }, "licenseKey": { "description": - "The Sourcegraph license key, which enables premium Sourcegraph features. To obtain this value, contact Sourcegraph to purchase a license.", + "The license key associated with a Sourcegraph product subscription, which is necessary to activate Sourcegraph Enterprise functionality. To obtain this value, contact Sourcegraph to purchase a subscription.", "type": "string" }, "maxReposToSearch": { diff --git a/schema/site_stringdata.go b/schema/site_stringdata.go index 949553ed5..79568d34f 100644 --- a/schema/site_stringdata.go +++ b/schema/site_stringdata.go @@ -325,7 +325,7 @@ const SiteSchemaJSON = `{ }, "licenseKey": { "description": - "The Sourcegraph license key, which enables premium Sourcegraph features. To obtain this value, contact Sourcegraph to purchase a license.", + "The product subscription key, which enables premium Sourcegraph features. To obtain this value, contact Sourcegraph to purchase a license.", "type": "string" }, "maxReposToSearch": { From 7305d295f767698d3f57b6bd022e907c19ba93db Mon Sep 17 00:00:00 2001 From: Quinn Slack Date: Mon, 1 Oct 2018 01:29:32 -0700 Subject: [PATCH 3/4] feat: support injecting user area routes and nav items Previously this was supported for the user account area, but not the user area itself. --- src/Layout.tsx | 4 ++ src/SourcegraphWebApp.tsx | 7 ++- src/main.tsx | 4 ++ src/routes.tsx | 4 +- src/user/area/UserArea.tsx | 99 +++++++++++++------------------- src/user/area/UserAreaHeader.tsx | 47 ++++++--------- src/user/area/navitems.ts | 24 ++++++++ src/user/area/routes.tsx | 50 ++++++++++++++++ 8 files changed, 148 insertions(+), 91 deletions(-) create mode 100644 src/user/area/navitems.ts create mode 100644 src/user/area/routes.tsx diff --git a/src/Layout.tsx b/src/Layout.tsx index 05b6d731c..472979a98 100644 --- a/src/Layout.tsx +++ b/src/Layout.tsx @@ -25,6 +25,8 @@ import { SiteAdminAreaRoute } from './site-admin/SiteAdminArea' import { SiteAdminSideBarGroups } from './site-admin/SiteAdminSidebar' import { UserAccountAreaRoute } from './user/account/UserAccountArea' import { UserAccountSidebarItems } from './user/account/UserAccountSidebar' +import { UserAreaRoute } from './user/area/UserArea' +import { UserAreaHeaderNavItem } from './user/area/UserAreaHeader' export interface LayoutProps extends RouteComponentProps, @@ -40,6 +42,8 @@ export interface LayoutProps siteAdminAreaRoutes: ReadonlyArray siteAdminSideBarGroups: SiteAdminSideBarGroups siteAdminOverviewComponents: ReadonlyArray + userAreaHeaderNavItems: ReadonlyArray + userAreaRoutes: ReadonlyArray userAccountSideBarItems: UserAccountSidebarItems userAccountAreaRoutes: ReadonlyArray repoRevContainerRoutes: ReadonlyArray diff --git a/src/SourcegraphWebApp.tsx b/src/SourcegraphWebApp.tsx index 6cf211ba6..9e9dc8a3d 100644 --- a/src/SourcegraphWebApp.tsx +++ b/src/SourcegraphWebApp.tsx @@ -13,8 +13,7 @@ import ServerIcon from 'mdi-react/ServerIcon' import * as React from 'react' import { Route } from 'react-router' import { BrowserRouter } from 'react-router-dom' -import { combineLatest, Subscription } from 'rxjs' -import { from } from 'rxjs' +import { combineLatest, from, Subscription } from 'rxjs' import { startWith } from 'rxjs/operators' import { EMPTY_ENVIRONMENT as EXTENSIONS_EMPTY_ENVIRONMENT } from 'sourcegraph/module/client/environment' import { TextDocumentItem } from 'sourcegraph/module/client/types/textDocument' @@ -45,6 +44,8 @@ import { SiteAdminSideBarGroups } from './site-admin/SiteAdminSidebar' import { eventLogger } from './tracking/eventLogger' import { UserAccountAreaRoute } from './user/account/UserAccountArea' import { UserAccountSidebarItems } from './user/account/UserAccountSidebar' +import { UserAreaRoute } from './user/area/UserArea' +import { UserAreaHeaderNavItem } from './user/area/UserAreaHeader' import { isErrorLike } from './util/errors' export interface SourcegraphWebAppProps { @@ -55,6 +56,8 @@ export interface SourcegraphWebAppProps { siteAdminAreaRoutes: ReadonlyArray siteAdminSideBarGroups: SiteAdminSideBarGroups siteAdminOverviewComponents: ReadonlyArray + userAreaHeaderNavItems: ReadonlyArray + userAreaRoutes: ReadonlyArray userAccountSideBarItems: UserAccountSidebarItems userAccountAreaRoutes: ReadonlyArray repoRevContainerRoutes: ReadonlyArray diff --git a/src/main.tsx b/src/main.tsx index e05421d79..b78d46824 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,8 @@ import { siteAdminSidebarGroups } from './site-admin/sidebaritems' import { SourcegraphWebApp } from './SourcegraphWebApp' import { userAccountAreaRoutes } from './user/account/routes' import { userAccountSideBarItems } from './user/account/sidebaritems' +import { userAreaHeaderNavItems } from './user/area/navitems' +import { userAreaRoutes } from './user/area/routes' window.addEventListener('DOMContentLoaded', () => { render( @@ -32,6 +34,8 @@ window.addEventListener('DOMContentLoaded', () => { siteAdminAreaRoutes={siteAdminAreaRoutes} siteAdminSideBarGroups={siteAdminSidebarGroups} siteAdminOverviewComponents={siteAdminOverviewComponents} + userAreaRoutes={userAreaRoutes} + userAreaHeaderNavItems={userAreaHeaderNavItems} userAccountSideBarItems={userAccountSideBarItems} userAccountAreaRoutes={userAccountAreaRoutes} repoRevContainerRoutes={repoRevContainerRoutes} diff --git a/src/routes.tsx b/src/routes.tsx index 517f8dbd1..f1177662f 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -145,9 +145,7 @@ export const routes: ReadonlyArray = [ }, { path: '/users/:username', - render: props => ( - - ), + render: props => , }, { path: '/survey/:score?', diff --git a/src/user/area/UserArea.tsx b/src/user/area/UserArea.tsx index cd32bdf44..868f0bdc7 100644 --- a/src/user/area/UserArea.tsx +++ b/src/user/area/UserArea.tsx @@ -9,13 +9,11 @@ import { gql, queryGraphQL } from '../../backend/graphql' import * as GQL from '../../backend/graphqlschema' import { HeroPage } from '../../components/HeroPage' import { ExtensionsProps } from '../../extensions/ExtensionsClientCommonContext' -import { SettingsArea } from '../../settings/SettingsArea' -import { SiteAdminAlert } from '../../site-admin/SiteAdminAlert' +import { RouteDescriptor } from '../../util/contributions' import { createAggregateError, ErrorLike, isErrorLike } from '../../util/errors' -import { UserAccountArea, UserAccountAreaRoute } from '../account/UserAccountArea' +import { UserAccountAreaRoute } from '../account/UserAccountArea' import { UserAccountSidebarItems } from '../account/UserAccountSidebar' -import { UserAreaHeader } from './UserAreaHeader' -import { UserOverviewPage } from './UserOverviewPage' +import { UserAreaHeader, UserAreaHeaderNavItem } from './UserAreaHeader' const fetchUser = (args: { username: string }): Observable => queryGraphQL( @@ -60,9 +58,13 @@ const NotFoundPage = () => ( ) +export interface UserAreaRoute extends RouteDescriptor {} + interface UserAreaProps extends RouteComponentProps<{ username: string }>, ExtensionsProps { - sideBarItems: UserAccountSidebarItems - routes: ReadonlyArray + userAreaRoutes: ReadonlyArray + userAreaHeaderNavItems: ReadonlyArray + userAccountSideBarItems: UserAccountSidebarItems + userAccountAreaRoutes: ReadonlyArray /** * The currently authenticated user, NOT the user whose username is specified in the URL's "username" route @@ -84,6 +86,9 @@ interface UserAreaState { * Properties passed to all page components in the user area. */ export interface UserAreaRouteContext extends ExtensionsProps { + /** The extension registry area main URL. */ + url: string + /** * The user who is the subject of the page. */ @@ -99,6 +104,10 @@ export interface UserAreaRouteContext extends ExtensionsProps { * user is Bob. */ authenticatedUser: GQL.IUser | null + + isLightTheme: boolean + userAccountSideBarItems: UserAccountSidebarItems + userAccountAreaRoutes: ReadonlyArray } /** @@ -160,67 +169,41 @@ export class UserArea extends React.Component { ) } - const transferProps: UserAreaRouteContext = { + const context: UserAreaRouteContext = { + url: this.props.match.url, user: this.state.userOrError, onDidUpdateUser: this.onDidUpdateUser, authenticatedUser: this.props.user, extensions: this.props.extensions, + isLightTheme: this.props.isLightTheme, + userAccountAreaRoutes: this.props.userAccountAreaRoutes, + userAccountSideBarItems: this.props.userAccountSideBarItems, } return (
- +
- ( - - )} - /> - ( - - {transferProps.authenticatedUser && - transferProps.user.id !== transferProps.authenticatedUser.id && ( - - Viewing settings for{' '} - {transferProps.user.username} - - )} -

User settings override global and organization settings.

- - } - /> - )} - /> - ( - - )} - /> + {this.props.userAreaRoutes.map( + ({ path, exact, render, condition = () => true }) => + condition(context) && ( + + render({ ...context, ...routeComponentProps }) + } + /> + ) + )}
diff --git a/src/user/area/UserAreaHeader.tsx b/src/user/area/UserAreaHeader.tsx index 3b5ee64ba..31ae3db40 100644 --- a/src/user/area/UserAreaHeader.tsx +++ b/src/user/area/UserAreaHeader.tsx @@ -1,16 +1,20 @@ -import SettingsIcon from 'mdi-react/SettingsIcon' -import TuneVerticalIcon from 'mdi-react/TuneVerticalIcon' import * as React from 'react' import { Link, NavLink, RouteComponentProps } from 'react-router-dom' import { orgURL } from '../../org' import { OrgAvatar } from '../../org/OrgAvatar' +import { NavItemWithIconDescriptor } from '../../util/contributions' import { UserAvatar } from '../UserAvatar' import { UserAreaRouteContext } from './UserArea' interface UserAreaHeaderProps extends UserAreaRouteContext, RouteComponentProps<{}> { + navItems: ReadonlyArray className: string } +export type UserAreaHeaderContext = Pick + +export interface UserAreaHeaderNavItem extends NavItemWithIconDescriptor {} + /** * Header for the user area. */ @@ -32,32 +36,19 @@ export const UserAreaHeader: React.SFC = (props: UserAreaHe
- - Overview - - {props.user.viewerCanAdminister && ( - - Settings - - )} - {props.user.viewerCanAdminister && ( - - Account - + {props.navItems.map( + ({ to, label, exact, icon: Icon, condition = () => true }) => + condition(props) && ( + + {Icon && } {label} + + ) )}
{props.user.organizations.nodes.length > 0 && ( diff --git a/src/user/area/navitems.ts b/src/user/area/navitems.ts new file mode 100644 index 000000000..4dc25921f --- /dev/null +++ b/src/user/area/navitems.ts @@ -0,0 +1,24 @@ +import SettingsIcon from 'mdi-react/SettingsIcon' +import TuneVerticalIcon from 'mdi-react/TuneVerticalIcon' +import { UserAreaHeaderNavItem } from './UserAreaHeader' + +export const userAreaHeaderNavItems: ReadonlyArray = [ + { + to: '', + exact: true, + label: 'Profile', + }, + { + to: '/settings', + exact: true, + label: 'Settings', + icon: SettingsIcon, + condition: ({ user: { viewerCanAdminister } }) => viewerCanAdminister, + }, + { + to: '/account', + label: 'Account', + icon: TuneVerticalIcon, + condition: ({ user: { viewerCanAdminister } }) => viewerCanAdminister, + }, +] diff --git a/src/user/area/routes.tsx b/src/user/area/routes.tsx new file mode 100644 index 000000000..f21c429dd --- /dev/null +++ b/src/user/area/routes.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { SettingsArea } from '../../settings/SettingsArea' +import { SiteAdminAlert } from '../../site-admin/SiteAdminAlert' +import { UserAccountArea } from '../account/UserAccountArea' +import { UserAreaRoute } from './UserArea' +import { UserOverviewPage } from './UserOverviewPage' + +export const userAreaRoutes: ReadonlyArray = [ + { + path: '', + exact: true, + // tslint:disable-next-line:jsx-no-lambda + render: props => , + }, + { + path: '/settings', + exact: true, + // tslint:disable-next-line:jsx-no-lambda + render: props => ( + + {props.authenticatedUser && + props.user.id !== props.authenticatedUser.id && ( + + Viewing settings for {props.user.username} + + )} +

User settings override global and organization settings.

+ + } + /> + ), + }, + { + path: '/account', + // tslint:disable-next-line:jsx-no-lambda + render: props => ( + + ), + }, +] From 7e5fb3ca576b29f20727de1dd8c81cd5b46975dc Mon Sep 17 00:00:00 2001 From: Beyang Liu Date: Mon, 1 Oct 2018 18:58:46 -0700 Subject: [PATCH 4/4] generate --- schema/site_stringdata.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/site_stringdata.go b/schema/site_stringdata.go index 79568d34f..01f655c04 100644 --- a/schema/site_stringdata.go +++ b/schema/site_stringdata.go @@ -325,7 +325,7 @@ const SiteSchemaJSON = `{ }, "licenseKey": { "description": - "The product subscription key, which enables premium Sourcegraph features. To obtain this value, contact Sourcegraph to purchase a license.", + "The license key associated with a Sourcegraph product subscription, which is necessary to activate Sourcegraph Enterprise functionality. To obtain this value, contact Sourcegraph to purchase a subscription.", "type": "string" }, "maxReposToSearch": {