From f44a3b59d7b39f84f44e30b787c916f08fb107b8 Mon Sep 17 00:00:00 2001 From: Samuel Volin Date: Fri, 21 Aug 2026 07:54:54 -0600 Subject: [PATCH] feat: Premium Page CTA updates and form handling (#28226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Premium page has form for signing up for a trial * Input validation and normalization for trial form fields * api endpoint definition to make this processes seamless * license page updates to access `?success=true` queryparam and confetti Screenshot 2026-08-17 at 10 08
41 PM Aug-17-2026 22-12-38 ```mermaid sequenceDiagram autonumber actor Owner participant UI as UI
TrialRequestForm + PremiumPage participant API as API
api.ts + coderd middleware participant Coderd as Coderd
postTrialLicense + trialer participant Licensor as Licensor
v2-licensor + Postgres + pubsub + cache Note over UI: mutation.status = "idle" Owner->>UI: fills 10 fields, checks acknowledgement UI->>UI: Yup validate, strip "acknowledged", submit UI->>API: POST /api/v2/licenses/trial
cookie + X-CSRF-TOKEN Note over UI: mutation.status = "pending" API->>API: verify CSRF, apiRateLimiter,
apiKeyMiddleware sets actor API->>Coderd: dispatch Coderd->>Coderd: audit.InitRequest, authorize license:create,
httpapi.Read validates, HasLicense is false Coderd->>Licensor: POST trial request
deployment_id unspoofable, 10s deadline Licensor-->>Coderd: 200, raw signed JWT Coderd->>Coderd: ParseClaimsIgnoreNbf, uuid.Parse Coderd->>Licensor: InsertLicense, updateEntitlements,
publish PubsubEventLicense, commit audit log Licensor-->>Coderd: database.License Coderd-->>API: 201 codersdk.License API-->>UI: 201 codersdk.License Note over UI: mutation.status = "success" UI->>Licensor: invalidate entitlements + licenses keys UI->>Owner: navigate /deployment/premium?success=true Note over Owner: confetti fires on the success param ``` --------- Co-authored-by: Matt Vollmer --- coderd/apidoc/docs.go | 97 ++++++ coderd/apidoc/swagger.json | 91 ++++++ codersdk/licenses.go | 26 ++ docs/reference/api/enterprise.md | 56 ++++ docs/reference/api/schemas.md | 28 ++ enterprise/cli/server.go | 2 +- enterprise/coderd/coderd.go | 5 + enterprise/coderd/licenses.go | 162 +++++++-- enterprise/coderd/licenses_test.go | 38 +++ enterprise/trialer/trialer.go | 185 +++++++---- enterprise/trialer/trialer_test.go | 169 +++++++++- site/src/api/api.ts | 7 + site/src/api/queries/entitlements.ts | 2 +- site/src/api/queries/licenses.ts | 20 ++ site/src/api/typesGenerated.ts | 15 + site/src/components/FormField/FormField.tsx | 41 ++- .../SelectField/SelectField.stories.tsx | 192 +++++++++++ .../components/SelectField/SelectField.tsx | 64 ++++ .../modules/licenses/PrivacyPolicyNotice.tsx | 18 + site/src/modules/licenses/trialLicense.ts | 73 +++++ .../modules/management/DeploymentSidebar.tsx | 6 +- .../DeploymentSidebarView.stories.tsx | 31 ++ .../management/DeploymentSidebarView.tsx | 11 +- .../ExternalAuthSettingsPageView.tsx | 3 +- .../LicensesSettingsPage.tsx | 10 +- .../LicensesSettingsPageView.tsx | 12 +- .../PremiumPage/LicenseActivePanel.tsx | 26 ++ .../PremiumPage/PremiumPage.tsx | 82 ++++- .../PremiumPage/PremiumPageView.stories.tsx | 160 ++++++++- .../PremiumPage/PremiumPageView.tsx | 307 ++++-------------- .../PremiumPage/TrialActivePanel.tsx | 35 ++ .../PremiumPage/TrialRequestForm.stories.tsx | 299 +++++++++++++++++ .../PremiumPage/TrialRequestForm.tsx | 219 +++++++++++++ .../SecuritySettingsPageView.tsx | 16 +- .../pages/SetupPage/SetupPageView.stories.tsx | 22 ++ site/src/pages/SetupPage/SetupPageView.tsx | 134 +------- 36 files changed, 2155 insertions(+), 509 deletions(-) create mode 100644 site/src/api/queries/licenses.ts create mode 100644 site/src/components/SelectField/SelectField.stories.tsx create mode 100644 site/src/components/SelectField/SelectField.tsx create mode 100644 site/src/modules/licenses/PrivacyPolicyNotice.tsx create mode 100644 site/src/modules/licenses/trialLicense.ts create mode 100644 site/src/pages/DeploymentSettingsPage/PremiumPage/LicenseActivePanel.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/PremiumPage/TrialActivePanel.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.tsx diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2a610074c24..917052d93a6 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4559,6 +4559,45 @@ const docTemplate = `{ ] } }, + "/api/v2/licenses/trial": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Request a trial license", + "operationId": "request-a-trial-license", + "parameters": [ + { + "description": "Trial license request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateTrialLicenseRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.License" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/licenses/{id}": { "delete": { "produces": [ @@ -19842,6 +19881,64 @@ const docTemplate = `{ } } }, + "codersdk.CreateTrialLicenseRequest": { + "type": "object", + "required": [ + "company_name", + "country", + "developers", + "email", + "first_name", + "job_title", + "last_name", + "phone_number" + ], + "properties": { + "company_name": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "Acme Corp" + }, + "country": { + "type": "string", + "example": "United States" + }, + "developers": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254, + "example": "jane.doe@example.com" + }, + "first_name": { + "type": "string", + "maxLength": 60, + "minLength": 1, + "example": "Jane" + }, + "job_title": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "Engineering Manager" + }, + "last_name": { + "type": "string", + "maxLength": 60, + "minLength": 1, + "example": "Doe" + }, + "phone_number": { + "type": "string", + "maxLength": 20, + "minLength": 7, + "example": "+14155552671" + } + } + }, "codersdk.CreateUserRequestWithOrgs": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c213ab2b9c4..d1733e59cc8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4047,6 +4047,39 @@ ] } }, + "/api/v2/licenses/trial": { + "post": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Request a trial license", + "operationId": "request-a-trial-license", + "parameters": [ + { + "description": "Trial license request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.CreateTrialLicenseRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/codersdk.License" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/licenses/{id}": { "delete": { "produces": ["application/json"], @@ -17965,6 +17998,64 @@ } } }, + "codersdk.CreateTrialLicenseRequest": { + "type": "object", + "required": [ + "company_name", + "country", + "developers", + "email", + "first_name", + "job_title", + "last_name", + "phone_number" + ], + "properties": { + "company_name": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "Acme Corp" + }, + "country": { + "type": "string", + "example": "United States" + }, + "developers": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email", + "maxLength": 254, + "example": "jane.doe@example.com" + }, + "first_name": { + "type": "string", + "maxLength": 60, + "minLength": 1, + "example": "Jane" + }, + "job_title": { + "type": "string", + "maxLength": 100, + "minLength": 2, + "example": "Engineering Manager" + }, + "last_name": { + "type": "string", + "maxLength": 60, + "minLength": 1, + "example": "Doe" + }, + "phone_number": { + "type": "string", + "maxLength": 20, + "minLength": 7, + "example": "+14155552671" + } + } + }, "codersdk.CreateUserRequestWithOrgs": { "type": "object", "required": ["username"], diff --git a/codersdk/licenses.go b/codersdk/licenses.go index 59dc470e217..b2099f388d7 100644 --- a/codersdk/licenses.go +++ b/codersdk/licenses.go @@ -31,6 +31,18 @@ type AddLicenseRequest struct { License string `json:"license" validate:"required"` } +// Defines the input payload for requesting a trial license. +type CreateTrialLicenseRequest struct { + Email string `json:"email" validate:"required,email,max=254" example:"jane.doe@example.com" format:"email" maxLength:"254"` + FirstName string `json:"first_name" validate:"required,min=1,max=60" example:"Jane" minLength:"1" maxLength:"60"` + LastName string `json:"last_name" validate:"required,min=1,max=60" example:"Doe" minLength:"1" maxLength:"60"` + PhoneNumber string `json:"phone_number" validate:"required,min=7,max=20" example:"+14155552671" minLength:"7" maxLength:"20"` + JobTitle string `json:"job_title" validate:"required,min=2,max=100" example:"Engineering Manager" minLength:"2" maxLength:"100"` + CompanyName string `json:"company_name" validate:"required,min=2,max=100" example:"Acme Corp" minLength:"2" maxLength:"100"` + Country string `json:"country" validate:"required" example:"United States"` + Developers string `json:"developers" validate:"required"` +} + type License struct { ID int32 `json:"id"` UUID uuid.UUID `json:"uuid" format:"uuid"` @@ -117,6 +129,20 @@ func (c *Client) AddLicense(ctx context.Context, r AddLicenseRequest) (License, return l, ReadBodyAsJSONUseNumber(res, &l) } +// CreateTrialLicense requests a trial license from the Coder licensor and install it. +func (c *Client) CreateTrialLicense(ctx context.Context, r CreateTrialLicenseRequest) (License, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/licenses/trial", r) + if err != nil { + return License{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return License{}, ReadBodyAsError(res) + } + var l License + return l, ReadBodyAsJSONUseNumber(res, &l) +} + func (c *Client) Licenses(ctx context.Context) ([]License, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/licenses", nil) if err != nil { diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index aada5a73777..f7930b1c246 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -1336,6 +1336,62 @@ curl -X POST http://coder-server:8080/api/v2/licenses/refresh-entitlements \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Request a trial license + +### Code samples + +```sh +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/licenses/trial \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /api/v2/licenses/trial` + +> Body parameter + +```json +{ + "company_name": "Acme Corp", + "country": "United States", + "developers": "string", + "email": "jane.doe@example.com", + "first_name": "Jane", + "job_title": "Engineering Manager", + "last_name": "Doe", + "phone_number": "+14155552671" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|------------------------------------------------------------------------------------|----------|-----------------------| +| `body` | body | [codersdk.CreateTrialLicenseRequest](schemas.md#codersdkcreatetriallicenserequest) | true | Trial license request | + +### Example responses + +> 201 Response + +```json +{ + "claims": {}, + "id": 0, + "uploaded_at": "2019-08-24T14:15:22Z", + "uuid": "095be615-a8ad-4c33-8e9c-c7612fbf6c9f" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|--------------------------------------------------------------|-------------|------------------------------------------------| +| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.License](schemas.md#codersdklicense) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Delete license ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 13f7b0a36f6..4e944c71bbe 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5418,6 +5418,34 @@ This is required on creation to enable a user-flow of validating a template work | `scopes` | array of [codersdk.APIKeyScope](#codersdkapikeyscope) | false | | | | `token_name` | string | false | | | +## codersdk.CreateTrialLicenseRequest + +```json +{ + "company_name": "Acme Corp", + "country": "United States", + "developers": "string", + "email": "jane.doe@example.com", + "first_name": "Jane", + "job_title": "Engineering Manager", + "last_name": "Doe", + "phone_number": "+14155552671" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------|----------|--------------|-------------| +| `company_name` | string | true | | | +| `country` | string | true | | | +| `developers` | string | true | | | +| `email` | string | true | | | +| `first_name` | string | true | | | +| `job_title` | string | true | | | +| `last_name` | string | true | | | +| `phone_number` | string | true | | | + ## codersdk.CreateUserRequestWithOrgs ```json diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 6a334d1b8e4..f451e11b864 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -79,7 +79,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { backends.NewSlog(options.Logger), ) - options.TrialGenerator = trialer.New(options.Database, "https://v2-licensor.coder.com/trial", coderd.Keys) + options.TrialGenerator = trialer.New(options.Database, trialer.LicenseRequestURL, coderd.Keys).Generate o := &coderd.Options{ Options: options, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 469909a19ea..b9367f97e89 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -63,6 +63,7 @@ import ( "github.com/coder/coder/v2/enterprise/derpmesh" "github.com/coder/coder/v2/enterprise/replicasync" "github.com/coder/coder/v2/enterprise/tailnet" + "github.com/coder/coder/v2/enterprise/trialer" "github.com/coder/coder/v2/provisionerd/proto" agpltailnet "github.com/coder/coder/v2/tailnet" "github.com/coder/quartz" @@ -226,6 +227,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { api.AGPL = coderd.New(options.Options) api.aiSeatTracker = aiseats.New(options.Database, api.Logger.Named("aiseats"), quartz.NewReal(), &api.AGPL.Auditor) + api.trialer = trialer.New(options.Database, trialer.LicenseRequestURL, options.LicenseKeys) api.AGPL.AISeatTracker = api.aiSeatTracker defer func() { if err != nil { @@ -379,6 +381,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Use(apiKeyMiddleware) r.Post("/refresh-entitlements", api.postRefreshEntitlements) r.Post("/", api.postLicense) + r.Post("/trial", api.postTrialLicense) r.Get("/", api.licenses) r.Delete("/{id}", api.deleteLicense) }) @@ -889,6 +892,8 @@ type API struct { AGPL *coderd.API *Options + trialer *trialer.Trialer + // ctx is canceled immediately on shutdown, it can be used to abort // interruptible tasks. ctx context.Context diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 60b35fd2e7a..e51f1286a87 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -8,6 +8,7 @@ import ( _ "embed" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "slices" @@ -30,6 +31,7 @@ import ( "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/enterprise/trialer" ) const ( @@ -86,7 +88,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { ) defer commitAudit() - if !api.AGPL.Authorize(r, policy.ActionCreate, rbac.ResourceLicense) { + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceLicense) { httpapi.Forbidden(rw) return } @@ -105,15 +107,6 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { return } - id, err := uuid.Parse(claims.ID) - if err != nil { - // If no uuid is in the license, we generate a random uuid. - // This is not ideal, and this should be fixed to require a uuid - // for all licenses. We require this patch to support older licenses. - // TODO: In the future (April 2023?) we should remove this and reissue - // old licenses with a uuid. - id = uuid.New() - } if len(claims.DeploymentIDs) > 0 && !slices.Contains(claims.DeploymentIDs, api.AGPL.DeploymentID) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "License cannot be used on this deployment!", @@ -123,28 +116,143 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { return } + lic, err := api.installLicense(ctx, aReq, addLicense.License, claims) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to install license", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusCreated, lic) +} + +// @Summary Request a trial license +// @ID request-a-trial-license +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param request body codersdk.CreateTrialLicenseRequest true "Trial license request" +// @Success 201 {object} codersdk.License +// @Router /api/v2/licenses/trial [post] +func (api *API) postTrialLicense(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + auditor = api.AGPL.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.License](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + }) + ) + defer commitAudit() + + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceLicense) { + httpapi.Forbidden(rw) + return + } + + var req codersdk.CreateTrialLicenseRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Prevent overwriting existing license + if api.Entitlements.HasLicense() { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "This deployment already has a license.", + Detail: "Remove the existing license before requesting a trial.", + }) + return + } + + rawLicense, err := api.trialer.Request(ctx, codersdk.LicensorTrialRequest{ + DeploymentID: api.AGPL.DeploymentID, + Email: req.Email, + FirstName: req.FirstName, + LastName: req.LastName, + PhoneNumber: req.PhoneNumber, + JobTitle: req.JobTitle, + CompanyName: req.CompanyName, + Country: req.Country, + Developers: req.Developers, + }) + if err != nil { + if licensorErr, ok := errors.AsType[*trialer.LicensorError](err); ok { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Unable to issue a trial license.", + Detail: licensorErr.Message, + }) + return + } + httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ + Message: "Failed to reach the Coder license server.", + Detail: err.Error(), + }) + return + } + + // The deployment ID was supplied by us, so unlike postLicense there is no + // need to check the license against claims.DeploymentIDs. + claims, err := license.ParseClaimsIgnoreNbf(rawLicense, api.LicenseKeys) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadGateway, codersdk.Response{ + Message: "The Coder license server returned an invalid license.", + Detail: err.Error(), + }) + return + } + + lic, err := api.installLicense(ctx, aReq, rawLicense, claims) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to install license", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusCreated, lic) +} + +// installLicense persists a parsed license and brings the deployment up to date +// with it: the row is recorded on the audit request, entitlements are recomputed +// so the caller's response reflects the new features, and other replicas are +// told to recompute their own. +// +// Entitlements are refreshed here rather than through postRefreshEntitlements +// because that path is rate limited and would reject a refresh immediately after +// a license lands. +// +// Every failure is a server-side fault; callers should map a non-nil error to a +// 500. +func (api *API) installLicense( + ctx context.Context, + aReq *audit.Request[database.License], + rawLicense string, + claims *license.Claims, +) (codersdk.License, error) { + id, err := uuid.Parse(claims.ID) + if err != nil { + // See postLicense: licenses are not guaranteed to carry a uuid. + id = uuid.New() + } + dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{ UploadedAt: dbtime.Now(), - JWT: addLicense.License, + JWT: rawLicense, Exp: claims.ExpiresAt.Time, UUID: id, }) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Unable to add license to database", - Detail: err.Error(), - }) - return + return codersdk.License{}, xerrors.Errorf("add license to database: %w", err) } aReq.New = dl err = api.updateEntitlements(ctx) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to update entitlements", - Detail: err.Error(), - }) - return + return codersdk.License{}, xerrors.Errorf("update entitlements: %w", err) } // Postgres pubsub; see PubsubEventLicenses. err = api.ReplicaSyncPubsub.Publish(PubsubEventLicenses, []byte("add")) @@ -155,13 +263,9 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) { c, err := decodeClaims(dl) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to decode database response", - Detail: err.Error(), - }) - return + return codersdk.License{}, xerrors.Errorf("decode database response: %w", err) } - httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, c)) + return convertLicense(dl, c), nil } // postRefreshEntitlements forces an `updateEntitlements` call and publishes @@ -183,7 +287,7 @@ func (api *API) postRefreshEntitlements(rw http.ResponseWriter, r *http.Request) // If the user cannot create a new license, then they cannot refresh entitlements. // Refreshing entitlements is a way to force a refresh of the license, so it is // equivalent to creating a new license. - if !api.AGPL.Authorize(r, policy.ActionCreate, rbac.ResourceLicense) { + if !api.Authorize(r, policy.ActionCreate, rbac.ResourceLicense) { httpapi.Forbidden(rw) return } @@ -316,7 +420,7 @@ func (api *API) deleteLicense(rw http.ResponseWriter, r *http.Request) { defer commitAudit() aReq.Old = dl - if !api.AGPL.Authorize(r, policy.ActionDelete, rbac.ResourceLicense) { + if !api.Authorize(r, policy.ActionDelete, rbac.ResourceLicense) { httpapi.Forbidden(rw) return } diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 953f9dd1935..e07c5f367c5 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" @@ -252,6 +253,43 @@ func TestPostLicense(t *testing.T) { }) } +func TestPostTrialLicense(t *testing.T) { + t.Parallel() + + t.Run("Forbidden", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, user := coderdenttest.New(t, nil) + member, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + _, err := member.CreateTrialLicense(ctx, codersdk.CreateTrialLicenseRequest{}) + errResp := &codersdk.Error{} + require.ErrorAs(t, err, &errResp) + require.Equal(t, http.StatusForbidden, errResp.StatusCode()) + }) + + t.Run("MissingFields", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client, _ := coderdenttest.New(t, nil) + + //nolint:gocritic // Requesting a trial is owner-only; the Forbidden subtest covers non-owners. + _, err := client.CreateTrialLicense(ctx, codersdk.CreateTrialLicenseRequest{}) + errResp := &codersdk.Error{} + require.ErrorAs(t, err, &errResp) + require.Equal(t, http.StatusBadRequest, errResp.StatusCode()) + fields := make([]string, 0, len(errResp.Validations)) + for _, v := range errResp.Validations { + fields = append(fields, v.Field) + } + require.Contains(t, fields, "email") + }) +} + func TestGetLicense(t *testing.T) { t.Parallel() t.Run("Success", func(t *testing.T) { diff --git a/enterprise/trialer/trialer.go b/enterprise/trialer/trialer.go index fa5d15a65b2..a9f340121e1 100644 --- a/enterprise/trialer/trialer.go +++ b/enterprise/trialer/trialer.go @@ -7,6 +7,7 @@ import ( "encoding/json" "io" "net/http" + "strings" "time" "github.com/google/uuid" @@ -18,74 +19,122 @@ import ( "github.com/coder/coder/v2/enterprise/coderd/license" ) -// New creates a handler that can issue trial licenses! -func New(db database.Store, url string, keys map[string]ed25519.PublicKey) func(ctx context.Context, body codersdk.LicensorTrialRequest) error { - return func(ctx context.Context, body codersdk.LicensorTrialRequest) error { - deploymentID, err := db.GetDeploymentID(ctx) - if err != nil { - return xerrors.Errorf("get deployment id: %w", err) - } - body.DeploymentID = deploymentID - data, err := json.Marshal(body) - if err != nil { - return xerrors.Errorf("marshal: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) - if err != nil { - return xerrors.Errorf("create license request: %w", err) - } - res, err := http.DefaultClient.Do(req) - if err != nil { - return xerrors.Errorf("perform license request: %w", err) - } - defer res.Body.Close() - if res.StatusCode > 300 { - body, err := io.ReadAll(res.Body) - if err != nil { - return xerrors.Errorf("read license response: %w", err) - } - // This is the format of the error response from - // the license server. - var msg struct { - Error string `json:"error"` - } - err = json.Unmarshal(body, &msg) - if err != nil { - return xerrors.Errorf("unmarshal error: %w", err) - } - return xerrors.New(msg.Error) - } - raw, err := io.ReadAll(res.Body) - if err != nil { - return xerrors.Errorf("read license: %w", err) - } - rawClaims, err := license.ParseRaw(string(raw), keys) - if err != nil { - return xerrors.Errorf("parse license: %w", err) - } - exp, ok := rawClaims["exp"].(float64) - if !ok { - return xerrors.New("invalid license missing exp claim") - } - expTime := time.Unix(int64(exp), 0) +// LicenseRequestURL is the Coder licensor endpoint that issues trial licenses. +const LicenseRequestURL = "https://v2-licensor.coder.com/trial" - claims, err := license.ParseClaims(string(raw), keys) - if err != nil { - return xerrors.Errorf("parse claims: %w", err) - } - id, err := uuid.Parse(claims.ID) - if err != nil { - return xerrors.Errorf("parse uuid: %w", err) - } - _, err = db.InsertLicense(ctx, database.InsertLicenseParams{ - UploadedAt: dbtime.Now(), - JWT: string(raw), - Exp: expTime, - UUID: id, - }) - if err != nil { - return xerrors.Errorf("insert license: %w", err) - } - return nil +const ( + // licenseRequestSource tells the licensor where a trial request originated. + licenseRequestSource = "Product" + // licenseRequestTimeout bounds a single request to the licensor. + licenseRequestTimeout = 30 * time.Second +) + +type LicensorError struct { + Message string +} + +func (e *LicensorError) Error() string { + return e.Message +} + +type Trialer struct { + db database.Store + url string + keys map[string]ed25519.PublicKey + client *http.Client +} + +// New creates a handler that can issue trial licenses +func New(db database.Store, url string, keys map[string]ed25519.PublicKey) *Trialer { + return &Trialer{ + db: db, + url: url, + keys: keys, + client: &http.Client{Timeout: licenseRequestTimeout}, } } + +// Request returns the raw signed license without storing it. +func (t *Trialer) Request(ctx context.Context, body codersdk.LicensorTrialRequest) (string, error) { + body.Source = licenseRequestSource + data, err := json.Marshal(body) + if err != nil { + return "", xerrors.Errorf("marshal: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(data)) + if err != nil { + return "", xerrors.Errorf("create license request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + res, err := t.client.Do(req) + if err != nil { + return "", xerrors.Errorf("perform license request: %w", err) + } + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + return "", xerrors.Errorf("read license response: %w", err) + } + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { + return "", &LicensorError{Message: licenseRequestErrorMessage(resBody, res.Status)} + } + raw := strings.TrimSpace(string(resBody)) + if raw == "" { + return "", xerrors.New("licensor returned an empty license") + } + return raw, nil +} + +// Generate requests a trial license and stores it, skipping the auditing and +// entitlement refresh that installing an uploaded license performs. +func (t *Trialer) Generate(ctx context.Context, body codersdk.LicensorTrialRequest) error { + deploymentID, err := t.db.GetDeploymentID(ctx) + if err != nil { + return xerrors.Errorf("get deployment id: %w", err) + } + body.DeploymentID = deploymentID + raw, err := t.Request(ctx, body) + if err != nil { + return err + } + rawClaims, err := license.ParseRaw(raw, t.keys) + if err != nil { + return xerrors.Errorf("parse license: %w", err) + } + exp, ok := rawClaims["exp"].(float64) + if !ok { + return xerrors.New("invalid license missing exp claim") + } + expTime := time.Unix(int64(exp), 0) + + claims, err := license.ParseClaims(raw, t.keys) + if err != nil { + return xerrors.Errorf("parse claims: %w", err) + } + id, err := uuid.Parse(claims.ID) + if err != nil { + return xerrors.Errorf("parse uuid: %w", err) + } + _, err = t.db.InsertLicense(ctx, database.InsertLicenseParams{ + UploadedAt: dbtime.Now(), + JWT: raw, + Exp: expTime, + UUID: id, + }) + if err != nil { + return xerrors.Errorf("insert license: %w", err) + } + return nil +} + +// licenseRequestErrorMessage reads the licensor's error message, falling back to +// the HTTP status when the body is not in the expected shape. +func licenseRequestErrorMessage(body []byte, status string) string { + var msg struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &msg); err == nil && msg.Error != "" { + return msg.Error + } + return status +} diff --git a/enterprise/trialer/trialer_test.go b/enterprise/trialer/trialer_test.go index 575c945fe3d..3990af35759 100644 --- a/enterprise/trialer/trialer_test.go +++ b/enterprise/trialer/trialer_test.go @@ -2,6 +2,8 @@ package trialer_test import ( "context" + "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -19,7 +21,18 @@ func TestTrialer(t *testing.T) { license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{ Trial: true, }) + type capture struct { + contentType string + body map[string]any + } + captured := make(chan capture, 1) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := capture{contentType: r.Header.Get("Content-Type")} + if err := json.NewDecoder(r.Body).Decode(&c.body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + captured <- c w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(license)) })) @@ -29,9 +42,163 @@ func TestTrialer(t *testing.T) { require.NoError(t, err) gen := trialer.New(db, srv.URL, coderdenttest.Keys) - err = gen(context.Background(), codersdk.LicensorTrialRequest{Email: "kyle+colin@coder.com"}) + err = gen.Generate(context.Background(), codersdk.LicensorTrialRequest{ + Email: "kyle+colin@coder.com", + FirstName: "Kyle", + LastName: "Carberry", + PhoneNumber: "+1 555 0100", + JobTitle: "Platform Engineer", + CompanyName: "Coder", + Country: "United States", + Developers: "51 - 100", + }) require.NoError(t, err) licenses, err := db.GetLicenses(context.Background()) require.NoError(t, err) require.Len(t, licenses, 1) + + require.Len(t, captured, 1) + got := <-captured + require.Equal(t, "application/json", got.contentType) + require.Equal(t, map[string]any{ + "deployment_id": "test-deployment", + "source": "Product", + "email": "kyle+colin@coder.com", + "first_name": "Kyle", + "last_name": "Carberry", + "phone_number": "+1 555 0100", + "job_title": "Platform Engineer", + "company_name": "Coder", + "country": "United States", + "developers": "51 - 100", + }, got.body) +} + +func TestTrialerRequest(t *testing.T) { + t.Parallel() + + req := codersdk.LicensorTrialRequest{ + DeploymentID: "test-deployment", + Email: "coder@coder.com", + FirstName: "Coder", + LastName: "McCoder", + PhoneNumber: "+1 555 0100", + JobTitle: "Platform Engineer", + CompanyName: "Coder", + Country: "United States", + Developers: "51 - 100", + } + + // Request reads neither the store nor the keys. + requester := func(url string) *trialer.Trialer { + return trialer.New(nil, url, nil) + } + + t.Run("Success", func(t *testing.T) { + t.Parallel() + want := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{Trial: true}) + + // The handler and the assertions run on different goroutines, so the + // captured request travels over a channel to stay race-free. + type capture struct { + method string + path string + contentType string + body map[string]any + } + captured := make(chan capture, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := capture{ + method: r.Method, + path: r.URL.Path, + contentType: r.Header.Get("Content-Type"), + } + if err := json.NewDecoder(r.Body).Decode(&c.body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + captured <- c + // The licensor responds with the bare JWT. A trailing newline must not + // end up in the stored license. + _, _ = w.Write([]byte(want + "\n")) + })) + defer srv.Close() + + raw, err := requester(srv.URL).Request(context.Background(), req) + require.NoError(t, err) + require.Equal(t, want, raw) + + require.Len(t, captured, 1) + got := <-captured + require.Equal(t, http.MethodPost, got.method) + require.Equal(t, "/", got.path) + require.Equal(t, "application/json", got.contentType) + require.Equal(t, map[string]any{ + "deployment_id": "test-deployment", + "source": "Product", + "email": req.Email, + "first_name": req.FirstName, + "last_name": req.LastName, + "phone_number": req.PhoneNumber, + "job_title": req.JobTitle, + "company_name": req.CompanyName, + "country": req.Country, + "developers": req.Developers, + }, got.body) + }) + + t.Run("LicensorRejects", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"this deployment already had a trial"}`)) + })) + defer srv.Close() + + _, err := requester(srv.URL).Request(context.Background(), req) + licensorErr, ok := errors.AsType[*trialer.LicensorError](err) + require.True(t, ok, "expected a LicensorError, got %v", err) + require.Equal(t, "this deployment already had a trial", licensorErr.Message) + }) + + t.Run("LicensorRejectsWithoutJSON", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("gateway timeout")) + })) + defer srv.Close() + + // A body that is not the licensor's error shape must still surface as a + // LicensorError carrying the status, not as an unmarshal failure. + _, err := requester(srv.URL).Request(context.Background(), req) + licensorErr, ok := errors.AsType[*trialer.LicensorError](err) + require.True(t, ok, "expected a LicensorError, got %v", err) + require.Equal(t, "500 Internal Server Error", licensorErr.Message) + }) + + t.Run("EmptyLicense", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + _, err := requester(srv.URL).Request(context.Background(), req) + require.Error(t, err) + _, ok := errors.AsType[*trialer.LicensorError](err) + require.False(t, ok, "an empty body is a protocol failure, not a licensor rejection") + }) + + t.Run("Unreachable", func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() + + _, err := requester(url).Request(context.Background(), req) + require.Error(t, err) + _, ok := errors.AsType[*trialer.LicensorError](err) + require.False(t, ok, "a transport failure is not a licensor rejection") + }) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 44a854c43d6..7c220b56ca4 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2621,6 +2621,13 @@ class ApiMethods { return response.data; }; + createTrialLicense = async ( + data: TypesGen.CreateTrialLicenseRequest, + ): Promise => { + const response = await this.axios.post("/api/v2/licenses/trial", data); + return response.data; + }; + removeLicense = async (licenseId: number): Promise => { await this.axios.delete(`/api/v2/licenses/${licenseId}`); }; diff --git a/site/src/api/queries/entitlements.ts b/site/src/api/queries/entitlements.ts index d1a2575dae5..84ca72869d3 100644 --- a/site/src/api/queries/entitlements.ts +++ b/site/src/api/queries/entitlements.ts @@ -4,7 +4,7 @@ import type { Entitlements } from "#/api/typesGenerated"; import type { MetadataState } from "#/hooks/useEmbeddedMetadata"; import { cachedQuery } from "./util"; -const entitlementsQueryKey = ["entitlements"] as const; +export const entitlementsQueryKey = ["entitlements"] as const; export const entitlements = (metadata: MetadataState) => { return cachedQuery({ diff --git a/site/src/api/queries/licenses.ts b/site/src/api/queries/licenses.ts new file mode 100644 index 00000000000..95cb51584e0 --- /dev/null +++ b/site/src/api/queries/licenses.ts @@ -0,0 +1,20 @@ +import type { QueryClient } from "react-query"; +import { API } from "#/api/api"; +import { entitlementsQueryKey } from "./entitlements"; + +export const licensesKey = ["licenses"] as const; + +export const licenses = () => ({ + queryKey: licensesKey, + queryFn: () => API.getLicenses(), +}); + +export const createTrialLicense = (queryClient: QueryClient) => ({ + mutationFn: API.createTrialLicense, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: entitlementsQueryKey }), + queryClient.invalidateQueries({ queryKey: licensesKey }), + ]); + }, +}); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 131a640455e..171ad711f37 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4229,6 +4229,21 @@ export interface CreateTokenRequest { readonly allow_list?: readonly APIAllowListTarget[]; } +// From codersdk/licenses.go +/** + * Defines the input payload for requesting a trial license. + */ +export interface CreateTrialLicenseRequest { + readonly email: string; + readonly first_name: string; + readonly last_name: string; + readonly phone_number: string; + readonly job_title: string; + readonly company_name: string; + readonly country: string; + readonly developers: string; +} + // From codersdk/chats.go /** * CreateUserAIProviderKeyRequest creates or replaces a user's API key diff --git a/site/src/components/FormField/FormField.tsx b/site/src/components/FormField/FormField.tsx index e87eb637d69..af31d24edc4 100644 --- a/site/src/components/FormField/FormField.tsx +++ b/site/src/components/FormField/FormField.tsx @@ -1,13 +1,22 @@ -import { type FC, type ReactNode, useId } from "react"; +import { type FC, type HTMLAttributes, type ReactNode, useId } from "react"; import { Input } from "#/components/Input/Input"; import { Label } from "#/components/Label/Label"; import { cn } from "#/utils/cn"; import type { FormHelpers } from "#/utils/formUtils"; +type ControlProps = Pick< + HTMLAttributes, + "id" | "aria-invalid" | "aria-describedby" +>; + type FormFieldProps = React.ComponentPropsWithRef<"input"> & { field: FormHelpers; label: ReactNode; description?: ReactNode; + /** + * Renders in place of the default `Input` element + */ + control?: (props: ControlProps) => ReactNode; }; export const FormField: FC = ({ @@ -15,6 +24,7 @@ export const FormField: FC = ({ label, description, className, + control, ...inputProps }) => { const generatedId = useId(); @@ -29,6 +39,11 @@ export const FormField: FC = ({ .filter(Boolean) .join(" "); const required = inputProps.required ?? false; + const controlProps: ControlProps = { + id, + "aria-invalid": field.error, + "aria-describedby": describedBy || undefined, + }; return (
@@ -48,17 +63,19 @@ export const FormField: FC = ({ {description}
)} - + {control ? ( + control(controlProps) + ) : ( + + )} {field.error ? ( {field.helperText} diff --git a/site/src/components/SelectField/SelectField.stories.tsx b/site/src/components/SelectField/SelectField.stories.tsx new file mode 100644 index 00000000000..9bb23f9126c --- /dev/null +++ b/site/src/components/SelectField/SelectField.stories.tsx @@ -0,0 +1,192 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useFormik } from "formik"; +import type { FC } from "react"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { SelectItem } from "#/components/Select/Select"; +import { SelectField } from "./SelectField"; + +const regions = ["us-east-1", "us-west-2", "eu-central-1"]; + +interface ExampleSelectFieldProps { + id?: string; + label: string; + description?: string; + helperText?: string; + required?: boolean; + error?: string; + value?: string; + disabled?: boolean; +} + +const ExampleSelectField: FC = ({ + id, + label, + description, + helperText, + required, + error, + value = "", + disabled, +}) => { + const form = useFormik({ + initialValues: { value }, + onSubmit: () => {}, + }); + + return ( + form.setFieldValue("value", next)} + > + {regions.map((region) => ( + + {region} + + ))} + + ); +}; + +const meta: Meta = { + title: "components/SelectField", + component: ExampleSelectField, + args: { + id: "story-select", + label: "Deployment region", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("combobox", { name: /Deployment region/ }); + await expect(trigger).not.toHaveAttribute("aria-describedby"); + await expect(trigger).not.toHaveAttribute("aria-invalid", "true"); + await expect(canvas.queryByText("*")).not.toBeInTheDocument(); + await expect(trigger).toHaveTextContent("Select a region..."); + }, +}; + +export const Required: Story = { + args: { + required: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("*")).toBeVisible(); + await expect( + canvas.getByRole("combobox", { name: /Deployment region/ }), + ).toHaveAttribute("aria-required", "true"); + }, +}; + +export const WithDescription: Story = { + args: { + description: "Where new workspaces are provisioned.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("combobox", { name: /Deployment region/ }); + await expect(trigger).toHaveAttribute( + "aria-describedby", + "story-select-description", + ); + await expect( + canvas.getByText("Where new workspaces are provisioned."), + ).toHaveAttribute("id", "story-select-description"); + }, +}; + +export const WithHelperText: Story = { + args: { + helperText: "This cannot be changed later.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("combobox", { name: /Deployment region/ }); + await expect(trigger).toHaveAttribute( + "aria-describedby", + "story-select-helper", + ); + }, +}; + +export const WithError: Story = { + args: { + error: "Please choose a region.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("combobox", { name: /Deployment region/ }); + await expect(trigger).toHaveAttribute( + "aria-describedby", + "story-select-error", + ); + await expect(trigger).toHaveAttribute("aria-invalid", "true"); + await expect(canvas.getByText("Please choose a region.")).toBeVisible(); + }, +}; + +export const WithDescriptionAndError: Story = { + args: { + description: "Where new workspaces are provisioned.", + error: "Please choose a region.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("combobox", { name: /Deployment region/ }); + await expect(trigger).toHaveAttribute( + "aria-describedby", + "story-select-description story-select-error", + ); + await expect(trigger).toHaveAttribute("aria-invalid", "true"); + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("combobox", { name: /Deployment region/ }), + ).toBeDisabled(); + }, +}; + +export const SelectsOption: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Radix portals the listbox outside the story canvas. + const body = within(canvasElement.ownerDocument.body); + const trigger = canvas.getByRole("combobox", { name: /Deployment region/ }); + + await userEvent.click(trigger); + await waitFor(() => + expect(body.getAllByRole("option").map((o) => o.textContent)).toEqual( + regions, + ), + ); + + await userEvent.click(body.getByRole("option", { name: "eu-central-1" })); + await waitFor(() => expect(trigger).toHaveTextContent("eu-central-1")); + }, +}; diff --git a/site/src/components/SelectField/SelectField.tsx b/site/src/components/SelectField/SelectField.tsx new file mode 100644 index 00000000000..e7fa8cc5df2 --- /dev/null +++ b/site/src/components/SelectField/SelectField.tsx @@ -0,0 +1,64 @@ +import type { FC, ReactNode } from "react"; +import { FormField } from "#/components/FormField/FormField"; +import { + Select, + SelectContent, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; +import { cn } from "#/utils/cn"; +import type { FormHelpers } from "#/utils/formUtils"; + +type SelectFieldProps = { + field: FormHelpers; + label: ReactNode; + onValueChange: (value: string) => void; + children: ReactNode; + id?: string; + description?: ReactNode; + placeholder?: string; + required?: boolean; + disabled?: boolean; + className?: string; +}; + +/** + * A labelled Select wired to Formik through getFormHelpers. The label's + * `htmlFor` targets the trigger's `id`, which makes the ComboBox name accessible. + */ +export const SelectField: FC = ({ + field, + label, + onValueChange, + children, + id, + description, + placeholder, + required, + disabled, + className, +}) => ( + ( + + )} + /> +); diff --git a/site/src/modules/licenses/PrivacyPolicyNotice.tsx b/site/src/modules/licenses/PrivacyPolicyNotice.tsx new file mode 100644 index 00000000000..c17406fb679 --- /dev/null +++ b/site/src/modules/licenses/PrivacyPolicyNotice.tsx @@ -0,0 +1,18 @@ +import type { FC } from "react"; + +export const PrivacyPolicyNotice: FC = () => { + return ( + <> + The information you provide will be treated in accordance with the{" "} + + Coder Privacy Policy + + . + + ); +}; diff --git a/site/src/modules/licenses/trialLicense.ts b/site/src/modules/licenses/trialLicense.ts new file mode 100644 index 00000000000..b4b4cafa073 --- /dev/null +++ b/site/src/modules/licenses/trialLicense.ts @@ -0,0 +1,73 @@ +import * as Yup from "yup"; + +// Keep in sync with cli/login.go. The values are forwarded to the Coder licensor, +// so changing them requires coordinating with the licensor service. +export const numberOfDevelopersOptions = [ + "1 - 50", + "51 - 100", + "101 - 200", + "201 - 500", + "501 - 1000", + "1001 - 2500", + "2500+", +]; + +export const DATABASE_DOCS_LINK = + "/admin/infrastructure/architecture#postgresql-recommended"; + +export const CONTACT_SALES_LINK = "https://coder.com/contact/sales"; + +// REMARK: Keep these consts in sync with codersdk.CreateTrialLicenseRequest. +export const MAX_EMAIL_LENGTH = 254; +export const MAX_NAME_LENGTH = 60; +const MIN_JOB_TITLE_LENGTH = 2; +export const MAX_JOB_TITLE_LENGTH = 100; +const MIN_COMPANY_NAME_LENGTH = 2; +export const MAX_COMPANY_NAME_LENGTH = 100; +const PHONE_NUMBER_RE = /^\+?[\d\s\-.()]{7,20}$/; + +export const trialInfoValidationSchema = Yup.object({ + first_name: Yup.string() + .max( + MAX_NAME_LENGTH, + `First name should be no longer than ${MAX_NAME_LENGTH} characters.`, + ) + .required("Please enter your first name."), + last_name: Yup.string() + .max( + MAX_NAME_LENGTH, + `Last name should be no longer than ${MAX_NAME_LENGTH} characters.`, + ) + .required("Please enter your last name."), + phone_number: Yup.string() + .matches(PHONE_NUMBER_RE, { + message: + "Phone number should be in international format (e.g. +14155552671).", + excludeEmptyString: true, + }) + .required("Please enter your phone number."), + job_title: Yup.string() + .min( + MIN_JOB_TITLE_LENGTH, + `Job title should be at least ${MIN_JOB_TITLE_LENGTH} characters.`, + ) + .max( + MAX_JOB_TITLE_LENGTH, + `Job title should be no longer than ${MAX_JOB_TITLE_LENGTH} characters.`, + ) + .required("Please enter your job title."), + company_name: Yup.string() + .min( + MIN_COMPANY_NAME_LENGTH, + `Company name should be at least ${MIN_COMPANY_NAME_LENGTH} characters.`, + ) + .max( + MAX_COMPANY_NAME_LENGTH, + `Company name should be no longer than ${MAX_COMPANY_NAME_LENGTH} characters.`, + ) + .required("Please enter your company name."), + country: Yup.string().required("Please select your country."), + developers: Yup.string().required( + "Please select the number of developers in your company.", + ), +}); diff --git a/site/src/modules/management/DeploymentSidebar.tsx b/site/src/modules/management/DeploymentSidebar.tsx index a91c312f38c..a40e1cf6da9 100644 --- a/site/src/modules/management/DeploymentSidebar.tsx +++ b/site/src/modules/management/DeploymentSidebar.tsx @@ -10,14 +10,14 @@ export const DeploymentSidebar: FC = () => { const { permissions } = useAuthenticated(); const { entitlements, showOrganizations, experiments, buildInfo } = useDashboard(); - const hasPremiumLicense = - entitlements.features.multiple_organizations.enabled; + // Trialing deployments keep the Premium tab so they can convert. + const hidePremiumTab = entitlements.has_license && !entitlements.trial; return ( diff --git a/site/src/modules/management/DeploymentSidebarView.stories.tsx b/site/src/modules/management/DeploymentSidebarView.stories.tsx index a9b01facf93..02320b84450 100644 --- a/site/src/modules/management/DeploymentSidebarView.stories.tsx +++ b/site/src/modules/management/DeploymentSidebarView.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; import { MockBuildInfo, MockNoPermissions, @@ -14,6 +15,7 @@ const meta: Meta = { parameters: { showOrganizations: true }, args: { permissions: MockPermissions, + hidePremiumTab: false, experiments: [], buildInfo: MockBuildInfo, }, @@ -64,3 +66,32 @@ export const NoPermissions: Story = { permissions: MockNoPermissions, }, }; + +export const PremiumTabVisible: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByRole("link", { name: "Premium" })).toHaveAttribute( + "href", + "/deployment/premium", + ); + }, +}; + +// A licensed, non-trialing deployment has nothing to upsell. +export const PremiumTabHidden: Story = { + args: { + hidePremiumTab: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.queryByRole("link", { name: "Premium" }), + ).not.toBeInTheDocument(); + // A neighbouring item must survive the change. + await expect( + canvas.getByRole("link", { name: "Licenses" }), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/modules/management/DeploymentSidebarView.tsx b/site/src/modules/management/DeploymentSidebarView.tsx index 18ae75f5769..7733284404d 100644 --- a/site/src/modules/management/DeploymentSidebarView.tsx +++ b/site/src/modules/management/DeploymentSidebarView.tsx @@ -1,6 +1,7 @@ import { ArrowUpRightIcon } from "lucide-react"; import type { FC } from "react"; import type { BuildInfoResponse, Experiment } from "#/api/typesGenerated"; +import { PREMIUM_PAGE_PATH } from "#/components/Paywall/Paywall"; import { Sidebar as BaseSidebar, SettingsSidebarNavItem as SidebarNavItem, @@ -12,7 +13,7 @@ interface DeploymentSidebarViewProps { /** Site-wide permissions. */ permissions: Permissions; showOrganizations: boolean; - hasPremiumLicense: boolean; + hidePremiumTab: boolean; experiments: Experiment[]; buildInfo: BuildInfoResponse; } @@ -24,7 +25,7 @@ interface DeploymentSidebarViewProps { export const DeploymentSidebarView: FC = ({ permissions, showOrganizations, - hasPremiumLicense, + hidePremiumTab, experiments, buildInfo, }) => { @@ -98,8 +99,10 @@ export const DeploymentSidebarView: FC = ({ )} - {!hasPremiumLicense && ( - Premium + {!hidePremiumTab && ( + + Trial Upgrade + )} diff --git a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx index e41ebbbb474..bc67b6feee9 100644 --- a/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.tsx @@ -4,7 +4,6 @@ import type { ExternalAuthConfig, } from "#/api/typesGenerated"; import { Alert } from "#/components/Alert/Alert"; -import { PremiumBadge } from "#/components/Badges/Badges"; import { SettingsHeader, SettingsHeaderDescription, @@ -54,7 +53,7 @@ export const ExternalAuthSettingsPageView: FC< />
- }> + Integrating with multiple external authentication providers is a Premium feature.{" "} { mutationFn: API.removeLicense, onSuccess: () => { toast.success("Successfully removed license."); - void queryClient.invalidateQueries({ queryKey: ["licenses"] }); + void queryClient.invalidateQueries({ queryKey: licensesKey }); }, onError: (error) => { toast.error("Failed to remove license.", { @@ -53,10 +54,7 @@ const LicensesSettingsPage: FC = () => { }, }); - const { data: licenses, isLoading } = useQuery({ - queryKey: ["licenses"], - queryFn: () => API.getLicenses(), - }); + const { data: licensesList, isLoading } = useQuery(licenses()); useEffect(() => { if (!success) { @@ -87,7 +85,7 @@ const LicensesSettingsPage: FC = () => { } userLimitActual={entitlementsQuery.data?.features.user_limit?.actual} userLimitLimit={entitlementsQuery.data?.features.user_limit?.limit} - licenses={licenses} + licenses={licensesList} isRemovingLicense={isRemovingLicense} removeLicense={(licenseId: number) => removeLicenseApi(licenseId)} activeUsers={userStatusCount?.active} diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index b2c99b41950..fb4b5c194eb 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -19,6 +19,7 @@ import { TooltipTrigger, } from "#/components/Tooltip/Tooltip"; import { useWindowSize } from "#/hooks/useWindowSize"; +import { CONTACT_SALES_LINK } from "#/modules/licenses/trialLicense"; import { useTheme } from "#/theme/context"; import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart"; import { LicenseCard } from "./LicenseCard"; @@ -146,20 +147,19 @@ const LicensesSettingsPageView: FC = ({ You're missing out on high availability, RBAC, quotas, and much more. Contact{" "} sales {" "} or{" "} - request a trial license - {" "} + {" "} to get started.
diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/LicenseActivePanel.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/LicenseActivePanel.tsx new file mode 100644 index 00000000000..30a92a7eb03 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/LicenseActivePanel.tsx @@ -0,0 +1,26 @@ +import type { FC } from "react"; +import { Link as RouterLink } from "react-router"; +import { Button } from "#/components/Button/Button"; + +/** + * Shown on premium page when any license is installed. The copy stays license-neutral + * to also cover existing Enterprise licenses, which are not Premium. + */ +export const LicenseActivePanel: FC = () => { + return ( +
+

+ A license is already installed +

+ +

+ This deployment already has a license. Review your entitlements on the + Licenses page. +

+ + +
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx index 235de881025..2db7eee9765 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPage.tsx @@ -1,16 +1,94 @@ +import dayjs from "dayjs"; import type { FC } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { getErrorDetail, getErrorMessage } from "#/api/errors"; +import { createTrialLicense, licenses } from "#/api/queries/licenses"; +import { Link } from "#/components/Link/Link"; +import { + SettingsHeader, + SettingsHeaderDescription, + SettingsHeaderDocsLink, + SettingsHeaderTitle, +} from "#/components/SettingsHeader/SettingsHeader"; +import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; +import { DATABASE_DOCS_LINK } from "#/modules/licenses/trialLicense"; +import { docs } from "#/utils/docs"; import { pageTitle } from "#/utils/page"; import { PremiumPageView } from "./PremiumPageView"; const PremiumPage: FC = () => { const { entitlements } = useDashboard(); + const { permissions } = useAuthenticated(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const hasLicense = entitlements.has_license; + const isTrial = entitlements.trial; + + // Only the trial panel needs license claims; other states render entitlements + const licensesQuery = useQuery({ + ...licenses(), + enabled: hasLicense && isTrial, + }); + const trialMutation = useMutation(createTrialLicense(queryClient)); + + const expiresAt = licensesQuery.data?.find((license) => license.claims.trial) + ?.claims.license_expires; + const trialDaysRemaining = Number.isFinite(expiresAt) + ? dayjs.unix(Number(expiresAt)).diff(dayjs(), "day") + : undefined; return ( <> - {pageTitle("Premium Features")} + {pageTitle("Start a Coder trial")} - + + Review Coder system requirements + + } + > + Start a Coder trial + + For enterprises ready to achieve world-class security, scalability, + and developer experience. + + + Learn more + + + + { + trialMutation.mutate(request, { + onSuccess: () => { + navigate("/deployment/licenses?success=true"); + }, + onError: (error) => { + toast.error( + getErrorMessage(error, "Failed to request a trial license."), + { description: getErrorDetail(error) }, + ); + }, + }); + }} + /> ); }; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx index b3cfdbdb19a..231df078326 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.stories.tsx @@ -1,40 +1,172 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, fn, within } from "storybook/test"; import { PremiumPageView } from "./PremiumPageView"; const meta: Meta = { title: "pages/DeploymentSettingsPage/PremiumPageView", component: PremiumPageView, + args: { + hasLicense: false, + isTrial: false, + canRequestTrial: true, + trialDaysRemaining: undefined, + isSubmitting: false, + onSubmit: fn(), + }, }; export default meta; type Story = StoryObj; -const expectHeaderBadge = async (canvasElement: HTMLElement) => { - const canvas = within(canvasElement); +export const NoLicense: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); - await expect( - canvas.getByRole("heading", { name: "Premium", level: 1 }), - ).toBeVisible(); - // The title and the badge beside it are the only exact "Premium" matches. - await expect(canvas.getAllByText("Premium")).toHaveLength(2); + // The hero tracks the panel, so the trial pitch only appears here. + await expect( + canvas.getByRole("heading", { + name: "Start a 30-day trial of Coder Premium", + level: 2, + }), + ).toBeInTheDocument(); + await expect(canvas.getByLabelText(/^Business email/)).toBeVisible(); + // The acknowledgement gates submission. + await expect( + canvas.getByRole("button", { name: "Start a trial" }), + ).toBeDisabled(); + // Requesting a trial replaces the old contact-sales upsell. + await expect( + canvas.queryByRole("link", { name: /contact sales/i }), + ).not.toBeInTheDocument(); + }, }; -export const Enterprise: Story = { +export const NoLicenseWithoutPermission: Story = { args: { - isEnterprise: true, + canRequestTrial: false, }, play: async ({ canvasElement }) => { - await expectHeaderBadge(canvasElement); + const canvas = within(canvasElement); + + await expect( + canvas.getByText("Contact your deployment administrator for Premium."), + ).toBeVisible(); + await expect( + canvas.queryByLabelText(/^Business email/), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByRole("button", { name: "Start a trial" }), + ).not.toBeInTheDocument(); }, }; -export const OSS: Story = { +export const TrialActive: Story = { args: { - isEnterprise: false, + hasLicense: true, + isTrial: true, + trialDaysRemaining: 23, }, play: async ({ canvasElement }) => { - await expectHeaderBadge(canvasElement); + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("heading", { + name: "23 days remaining", + level: 1, + }), + ).toBeVisible(); + await expect( + canvas.getByRole("link", { name: /contact sales/i }), + ).toHaveAttribute("href", "https://coder.com/contact/sales"); + await expect( + canvas.queryByLabelText(/^Business email/), + ).not.toBeInTheDocument(); + // The upsell drops the secondary CTA and the feature checklist so it no + // longer mirrors the trial request form. + await expect( + canvas.queryByRole("link", { name: "View licenses" }), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByText("24x7 global support with SLA"), + ).not.toBeInTheDocument(); + }, +}; + +export const TrialActiveSingleDay: Story = { + args: { + hasLicense: true, + isTrial: true, + trialDaysRemaining: 1, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText("1 day remaining")).toBeVisible(); + }, +}; + +export const TrialExpiryUnavailable: Story = { + args: { + hasLicense: true, + isTrial: true, + trialDaysRemaining: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The count is the heading, so an unreadable expiry leaves no heading. + await expect(canvas.queryByRole("heading")).not.toBeInTheDocument(); + await expect(canvas.queryByText(/remaining/)).not.toBeInTheDocument(); + await expect(canvas.queryByText(/NaN|undefined/)).not.toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: /contact sales/i }), + ).toBeVisible(); + }, +}; + +export const TrialExpired: Story = { + args: { + hasLicense: true, + isTrial: true, + trialDaysRemaining: -3, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // An elapsed trial must never render a negative count. + await expect(canvas.queryByText(/remaining/)).not.toBeInTheDocument(); + await expect(canvas.queryByText(/-3/)).not.toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: /contact sales/i }), + ).toBeVisible(); + }, +}; + +export const LicenseActive: Story = { + args: { + hasLicense: true, + isTrial: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Copy stays license-neutral: this state also covers Enterprise licenses. + await expect( + canvas.getByRole("heading", { + name: "A license is already installed", + level: 1, + }), + ).toBeVisible(); + await expect( + canvas.getByRole("link", { name: "View licenses" }), + ).toHaveAttribute("href", "/deployment/licenses"); + // An installed license collapses the two-column pitch into the banner. + await expect( + canvas.queryByRole("heading", { level: 2 }), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByLabelText(/^Business email/), + ).not.toBeInTheDocument(); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx index b48f101027b..5772d9a49ee 100644 --- a/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/PremiumPageView.tsx @@ -1,250 +1,75 @@ -import { - ActivityIcon, - CoinsIcon, - ExpandIcon, - SquareArrowOutUpRightIcon, -} from "lucide-react"; import type { FC } from "react"; -import { PremiumBadge } from "#/components/Badges/Badges"; -import { Button } from "#/components/Button/Button"; -import { Link } from "#/components/Link/Link"; -import { docs } from "#/utils/docs"; +import type * as TypesGen from "#/api/typesGenerated"; +import { PaywallGuidance } from "#/components/Paywall/Paywall"; +import { Supergraphic } from "#/components/Supergraphic/Supergraphic"; +import { LicenseActivePanel } from "./LicenseActivePanel"; +import { TrialActivePanel } from "./TrialActivePanel"; +import { TrialRequestForm } from "./TrialRequestForm"; -type PremiumPageViewProps = { isEnterprise: boolean }; - -export const PremiumPageView: FC = ({ isEnterprise }) => { - return isEnterprise ? : ; +type PremiumPageViewProps = { + hasLicense: boolean; + isTrial: boolean; + /** Whether the viewer may request a trial for this deployment. */ + canRequestTrial: boolean; + trialDaysRemaining: number | undefined; + onSubmit: (request: TypesGen.CreateTrialLicenseRequest) => void; + isSubmitting: boolean; + error?: unknown; }; -const EnterpriseVersion: FC = () => { - return ( -
-
-
-
-

Premium

- -
-

- As an Enterprise license holder, you already benefit from Coder’s - features for secure, large-scale deployments. Upgrade to Coder - Premium for enhanced multi-tenant control and flexibility. -

-
-
-
- -
-

- - Multi-Organization access controls - -

-

- Manage multiple teams and projects within a single deployment, each - with isolated access. -

-
- -
-

- - Custom role - -

-

- Configure specific permissions for teams or contractors with tailored - roles. -

-
- -
-

- - Org-Level quotas for chargeback - -

-

- Set and monitor resource quotas at the organization level to support - internal cost tracking. -

-
- -
-

- These Premium features enable you to manage team structure and budget - allocation across multiple platform teams. -

-
-
- ); -}; - -const OSSVersion: FC = () => { - return ( -
-
-
-
-

- Premium -

- -
-

- Coder Premium is designed for enterprises that need to scale their - Coder deployment efficiently, securely, and with full control. By - upgrading, your team gains access to advanced features enabling - governance across all environments. -

-
- +export const PremiumPageView: FC = ({ + hasLicense, + isTrial, + canRequestTrial, + trialDaysRemaining, + onSubmit, + isSubmitting, + error, +}) => { + // An installed license has nothing to sell, so both license states collapse + // the two-column pitch into a single banner. + if (hasLicense) { + return ( +
+ + {isTrial ? ( + + ) : ( + + )}
+ ); + } -
-

- - -   Deploy coder at scale - -

-

- Equip your enterprise to deploy and manage thousands of workspaces - with performance and reliability. -

-
    -
  • - - High availability - -
    - - Scale with automatic failover and load balancing across multiple - Coder instances. - -
  • -
  • - - Multi-Organization access control - -
    - - Isolate teams, projects, and environments within a single Coder - deployment. - -
  • -
  • - - Unlimited external authentication integrations - -
    - - Integrate with external service providers like GitHub, JFrog, and - Vault. - -
  • -
-
- -
-

- - -   Control infrastructure costs - -

-

- Optimize cloud usage and maintain cost-effective resource management - for your teams. -

-
    -
  • - - Auto-Stop idle workspaces - -
    - - Automatically shut down inactive workspaces to prevent unnecessary - costs. - -
  • -
  • - - Resource quotas - -
    - - Set user and team-specific limits to control spending and resource - allocation. - -
  • -
  • - - Usage insights - -
    - - Track workspace usage patterns to make data-driven decisions about - infrastructure needs. - -
  • -
-
- -
-

- - -   Govern workspace activity - -

-

- Maintain security and compliance across your organization with robust - governance features. -

-
    -
  • - - Audit logging - -
    - - Capture detailed records of user actions and workspace activity - for compliance and security. - -
  • -
  • - - Template permissions - -
    - - Control who can create, modify, and access workspace templates - across teams. - -
  • -
  • - - Workspace command logging - -
    - - Monitor and log critical commands to ensure security and - compliance standards are met. - -
  • -
-
+ return ( +
+
+
+ +

+ Start an unlimited 30-day Coder trial +

+

+ Unlock unlimited Coder Agents usage and enterprise AI governance. + Control what agents can access, manage who can use templates and + infrastructure, and run your self-hosted development platform with + role-based access controls and high availability. +

+
+
+ {canRequestTrial ? ( + + ) : ( + + Contact your deployment administrator for Premium. + + )} +
+
); }; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialActivePanel.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialActivePanel.tsx new file mode 100644 index 00000000000..a6d5302c5e9 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialActivePanel.tsx @@ -0,0 +1,35 @@ +import type { FC } from "react"; +import { Button } from "#/components/Button/Button"; +import { CONTACT_SALES_LINK } from "#/modules/licenses/trialLicense"; + +interface TrialActivePanelProps { + daysRemaining: number | undefined; +} + +export const TrialActivePanel: FC = ({ + daysRemaining, +}) => { + const showRemaining = daysRemaining !== undefined && daysRemaining > 0; + + return ( +
+ {showRemaining ? ( +
+

+ {daysRemaining} {daysRemaining === 1 ? "day" : "days"} remaining +

+
+ ) : null} + +

+ Contact our sales team to extend your trial. +

+ + +
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx new file mode 100644 index 00000000000..1eaa92328a5 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.stories.tsx @@ -0,0 +1,299 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { mockApiError } from "#/testHelpers/entities"; +import { TrialRequestForm } from "./TrialRequestForm"; + +const meta: Meta = { + title: "pages/DeploymentSettingsPage/TrialRequestForm", + component: TrialRequestForm, + args: { + isSubmitting: false, + onSubmit: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +const COMPLETE_REQUEST = { + email: "coder@coder.com", + first_name: "Coder", + last_name: "McCoder", + phone_number: "+14155552671", + job_title: "Platform Engineer", + company_name: "Coder", + country: "United States", + developers: "51 - 100", +}; + +/** Radix Select portals its listbox into document.body. */ +const selectOption = async ( + canvasElement: HTMLElement, + comboboxName: RegExp, + optionName: RegExp, +) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click( + await canvas.findByRole("combobox", { name: comboboxName }), + ); + await userEvent.click(await body.findByRole("option", { name: optionName })); +}; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const submit = canvas.getByRole("button", { name: "Start a trial" }); + + await expect(submit).toBeDisabled(); + + await userEvent.click(canvas.getByRole("checkbox")); + + await waitFor(() => expect(submit).toBeEnabled()); + }, +}; + +export const ValidationErrors: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByRole("checkbox")); + await userEvent.click( + canvas.getByRole("button", { name: "Start a trial" }), + ); + + await waitFor(() => + expect( + canvas.getByText("Please enter an email address."), + ).toBeInTheDocument(), + ); + await expect( + canvas.getByText("Please enter your first name."), + ).toBeInTheDocument(); + await expect( + canvas.getByText("Please select your country."), + ).toBeInTheDocument(); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const InvalidEmail: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.type( + canvas.getByLabelText(/^Business email/), + "not-an-email", + ); + await userEvent.click(canvas.getByRole("checkbox")); + await userEvent.click( + canvas.getByRole("button", { name: "Start a trial" }), + ); + + await waitFor(() => + expect( + canvas.getByText("Please enter a valid email address."), + ).toBeInTheDocument(), + ); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const InvalidPhoneNumber: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.type( + canvas.getByLabelText(/^Phone number/), + "415-555-CODE", + ); + await userEvent.click(canvas.getByRole("checkbox")); + await userEvent.click( + canvas.getByRole("button", { name: "Start a trial" }), + ); + + await waitFor(() => + expect( + canvas.getByText( + "Phone number should be in international format (e.g. +14155552671).", + ), + ).toBeInTheDocument(), + ); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +export const TooShortJobTitle: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.type(canvas.getByLabelText(/^Job title/), "X"); + await userEvent.click(canvas.getByRole("checkbox")); + await userEvent.click( + canvas.getByRole("button", { name: "Start a trial" }), + ); + + await waitFor(() => + expect( + canvas.getByText("Job title should be at least 2 characters."), + ).toBeInTheDocument(), + ); + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +// The character counter is driven by `maxLength` in getFormHelpers, which starts +// warning 30 characters before the limit and turns into an error past it. +export const CompanyNameOverLimit: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.type(canvas.getByLabelText(/^Company/), "a".repeat(101)); + + await waitFor(() => + expect( + canvas.getByText( + "This cannot be longer than 100 characters. (101/100)", + ), + ).toBeInTheDocument(), + ); + + await userEvent.click(canvas.getByRole("checkbox")); + await userEvent.click( + canvas.getByRole("button", { name: "Start a trial" }), + ); + + await expect(args.onSubmit).not.toHaveBeenCalled(); + }, +}; + +// Pins the "Number of developers" bucket list. +export const SelectDevelopers: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(canvasElement.ownerDocument.body); + + await userEvent.click( + await canvas.findByRole("combobox", { name: /^Number of developers/ }), + ); + + await waitFor(() => { + const options = body.getAllByRole("option"); + expect(options.map((o) => o.textContent)).toEqual([ + "1 - 50", + "51 - 100", + "101 - 200", + "201 - 500", + "501 - 1000", + "1001 - 2500", + "2500+", + ]); + }); + }, +}; + +export const SubmitsCompleteForm: Story = { + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + await userEvent.type( + canvas.getByLabelText(/^Business email/), + COMPLETE_REQUEST.email, + ); + await userEvent.type( + canvas.getByLabelText(/^First name/), + COMPLETE_REQUEST.first_name, + ); + await userEvent.type( + canvas.getByLabelText(/^Last name/), + COMPLETE_REQUEST.last_name, + ); + await userEvent.type( + canvas.getByLabelText(/^Company/), + COMPLETE_REQUEST.company_name, + ); + await userEvent.type( + canvas.getByLabelText(/^Job title/), + COMPLETE_REQUEST.job_title, + ); + await userEvent.type( + canvas.getByLabelText(/^Phone number/), + COMPLETE_REQUEST.phone_number, + ); + await selectOption(canvasElement, /^Number of developers/, /^51 - 100$/); + await selectOption(canvasElement, /^Country/, /United States$/); + + await userEvent.click(canvas.getByRole("checkbox")); + await userEvent.click( + canvas.getByRole("button", { name: "Start a trial" }), + ); + + // Deep equality, so an extra `acknowledged` key would fail this assertion. + await waitFor(() => + expect(args.onSubmit).toHaveBeenCalledWith(COMPLETE_REQUEST), + ); + }, +}; + +export const KeyboardNavigation: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + canvas.getByLabelText(/^Phone number/).focus(); + + const tabOrder = [ + canvas.getByRole("combobox", { name: /^Number of developers/ }), + canvas.getByRole("combobox", { name: /^Country/ }), + canvas.getByRole("checkbox"), + canvas.getByRole("link", { name: "Learn more" }), + ]; + + for (const element of tabOrder) { + await userEvent.tab(); + await expect(element).toHaveFocus(); + } + + // The docs link sits outside the checkbox label, so reaching it leaves + // the acknowledgement untouched. + await expect(canvas.getByRole("checkbox")).not.toBeChecked(); + }, +}; + +export const ServerError: Story = { + args: { + error: mockApiError({ + message: "Unable to issue a trial license.", + validations: [ + { field: "email", detail: "This email already started a trial." }, + ], + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByLabelText(/^Business email/)); + await userEvent.tab(); + + await waitFor(() => + expect( + canvas.getByText("This email already started a trial."), + ).toBeInTheDocument(), + ); + }, +}; + +export const Submitting: Story = { + args: { + isSubmitting: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.getByRole("button", { name: /Start a trial/ }), + ).toBeDisabled(); + await expect(canvas.getByLabelText(/^Business email/)).toBeDisabled(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.tsx b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.tsx new file mode 100644 index 00000000000..18a1a2e82e8 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/PremiumPage/TrialRequestForm.tsx @@ -0,0 +1,219 @@ +import { useFormik } from "formik"; +import { type FC, useId } from "react"; +import * as Yup from "yup"; +import { countries } from "#/api/countriesGenerated"; +import type * as TypesGen from "#/api/typesGenerated"; +import { Button } from "#/components/Button/Button"; +import { Checkbox } from "#/components/Checkbox/Checkbox"; +import { FormField } from "#/components/FormField/FormField"; +import { SelectItem } from "#/components/Select/Select"; +import { SelectField } from "#/components/SelectField/SelectField"; +import { Spinner } from "#/components/Spinner/Spinner"; +import { PrivacyPolicyNotice } from "#/modules/licenses/PrivacyPolicyNotice"; +import { + DATABASE_DOCS_LINK, + MAX_COMPANY_NAME_LENGTH, + MAX_EMAIL_LENGTH, + MAX_JOB_TITLE_LENGTH, + MAX_NAME_LENGTH, + numberOfDevelopersOptions, + trialInfoValidationSchema, +} from "#/modules/licenses/trialLicense"; +import { docs } from "#/utils/docs"; +import { getFormHelpers, onChangeTrimmed } from "#/utils/formUtils"; + +type TrialFormValues = TypesGen.CreateTrialLicenseRequest & { + // client-side gate only + acknowledged: boolean; +}; + +const validationSchema = trialInfoValidationSchema.shape({ + email: Yup.string() + .trim() + .email("Please enter a valid email address.") + .max( + MAX_EMAIL_LENGTH, + `Email address should be no longer than ${MAX_EMAIL_LENGTH} characters.`, + ) + .required("Please enter an email address."), + acknowledged: Yup.bool().oneOf( + [true], + "Please acknowledge the database requirements.", + ), +}); + +const initialValues: TrialFormValues = { + email: "", + first_name: "", + last_name: "", + phone_number: "", + job_title: "", + company_name: "", + country: "", + developers: "", + acknowledged: false, +}; + +interface TrialRequestFormProps { + onSubmit: (request: TypesGen.CreateTrialLicenseRequest) => void; + isSubmitting: boolean; + error?: unknown; +} + +export const TrialRequestForm: FC = ({ + onSubmit, + isSubmitting, + error, +}) => { + const acknowledgementId = useId(); + const form = useFormik({ + initialValues, + validationSchema, + onSubmit: (values) => { + // Built field by field so the acknowledgement cannot reach the licensor. + onSubmit({ + email: values.email, + first_name: values.first_name, + last_name: values.last_name, + phone_number: values.phone_number, + job_title: values.job_title, + company_name: values.company_name, + country: values.country, + developers: values.developers, + }); + }, + }); + const getFieldHelpers = getFormHelpers(form, error); + + return ( +
+
+
+ + +
+ + + +
+ + +
+ +
+ + form.setFieldValue("developers", value)} + placeholder="Select..." + disabled={isSubmitting} + > + {numberOfDevelopersOptions.map((opt) => ( + + {opt} + + ))} + +
+ + form.setFieldValue("country", value)} + placeholder="Select..." + disabled={isSubmitting} + > + {countries.map((c) => ( + + {c.flag} {c.name} + + ))} + +
+
+ + form.setFieldValue("acknowledged", checked === true) + } + disabled={isSubmitting} + /> +
+ {" "} + + Learn more + +
+
+ +
+ +

+ Opt-out at any time. +

+
+
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx index bc33b885e38..188f329c92b 100644 --- a/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/SecuritySettingsPage/SecuritySettingsPageView.tsx @@ -4,8 +4,8 @@ import { Badges, DisabledBadge, EnabledBadge, - PremiumBadge, } from "#/components/Badges/Badges"; +import { PaywallSmall } from "#/components/Paywall/PaywallSmall"; import { SettingsHeader, SettingsHeaderDescription, @@ -61,7 +61,10 @@ export const SecuritySettingsPageView: FC = ({ } > - Browser-Only Connections + Browser-Only Connections{" "} + + {featureBrowserOnlyEnabled ? : } + Block all workspace access via SSH, port forward, and other @@ -71,8 +74,15 @@ export const SecuritySettingsPageView: FC = ({ {featureBrowserOnlyEnabled ? : } - + {!featureBrowserOnlyEnabled ? ( + + ) : null}
{tlsOptions.length > 0 && ( diff --git a/site/src/pages/SetupPage/SetupPageView.stories.tsx b/site/src/pages/SetupPage/SetupPageView.stories.tsx index 89acabb566e..41b14b413d3 100644 --- a/site/src/pages/SetupPage/SetupPageView.stories.tsx +++ b/site/src/pages/SetupPage/SetupPageView.stories.tsx @@ -46,6 +46,28 @@ export const Loading: Story = { }, }; +export const TrialInfoValidation: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(canvas.getByTestId("trial")); + + const phone = await canvas.findByRole("textbox", { + name: "Phone number", + }); + await userEvent.type(phone, "abc"); + await userEvent.click(canvas.getByTestId("create")); + + await waitFor(() => { + expect( + canvas.getByText( + "Phone number should be in international format (e.g. +14155552671).", + ), + ).toBeVisible(); + }); + }, +}; + // TrialOpen pins the "Number of developers" bucket list. If this assertion // changes, coordinate the new values with the licensor service owner, since // the selected bucket is forwarded verbatim to v2-licensor.coder.com/trial. diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index 3ebb2b41d33..8513aa04f6c 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -1,6 +1,6 @@ import { isAxiosError } from "axios"; import { type FormikContextType, useFormik } from "formik"; -import type { FC, ReactNode } from "react"; +import type { FC } from "react"; import * as Yup from "yup"; import { countries } from "#/api/countriesGenerated"; import type * as TypesGen from "#/api/typesGenerated"; @@ -10,19 +10,17 @@ import { Checkbox } from "#/components/Checkbox/Checkbox"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; import { FormField } from "#/components/FormField/FormField"; import { ProductLogo } from "#/components/Icons/ProductLogo"; -import { Label } from "#/components/Label/Label"; import { PasswordField } from "#/components/PasswordField/PasswordField"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "#/components/Select/Select"; +import { SelectItem } from "#/components/Select/Select"; +import { SelectField } from "#/components/SelectField/SelectField"; import { Spinner } from "#/components/Spinner/Spinner"; -import { cn } from "#/utils/cn"; +import { PrivacyPolicyNotice } from "#/modules/licenses/PrivacyPolicyNotice"; +import { + CONTACT_SALES_LINK, + numberOfDevelopersOptions, + trialInfoValidationSchema, +} from "#/modules/licenses/trialLicense"; import { - type FormHelpers, getFormHelpers, nameValidator, onChangeTrimmed, @@ -54,18 +52,7 @@ const validationSchema = Yup.object({ trial: Yup.bool(), trial_info: Yup.object().when("trial", { is: true, - then: (schema) => - schema.shape({ - first_name: Yup.string().required("Please enter your first name."), - last_name: Yup.string().required("Please enter your last name."), - phone_number: Yup.string().required("Please enter your phone number."), - job_title: Yup.string().required("Please enter your job title."), - company_name: Yup.string().required("Please enter your company name."), - country: Yup.string().required("Please select your country."), - developers: Yup.string().required( - "Please select the number of developers in your company.", - ), - }), + then: () => trialInfoValidationSchema, }), onboarding_info: Yup.object().shape({ newsletter_marketing: Yup.bool(), @@ -73,82 +60,6 @@ const validationSchema = Yup.object({ }), }); -// Keep in sync with cli/login.go (developerBuckets). -const numberOfDevelopersOptions = [ - "1 - 50", - "51 - 100", - "101 - 200", - "201 - 500", - "501 - 1000", - "1001 - 2500", - "2500+", -]; - -const Field: FC<{ - label: string; - id: string; - error?: boolean; - helperText?: ReactNode; - className?: string; - children: ReactNode; -}> = ({ label, id, error, helperText, className, children }) => ( -
- - {children} - {helperText && ( - - {helperText} - - )} -
-); - -type SelectFieldProps = FormHelpers & { - label: string; - className?: string; - onValueChange: (value: string) => void; - placeholder?: string; - children: ReactNode; - disabled?: boolean; -}; - -const SelectField: FC = ({ - label, - id, - error, - helperText, - className, - value, - onValueChange, - placeholder, - children, - disabled, -}) => ( - - - -); - interface SetupPageViewProps { onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void; error?: unknown; @@ -266,7 +177,7 @@ export const SetupPageView: FC = ({ />
- Start a 30-day trial of Premium + Start an unlimited 30-day Coder trial Get access to high availability, template RBAC, audit logging, @@ -307,8 +218,8 @@ export const SetupPageView: FC = ({ /> + field={getFieldHelpers("trial_info.developers")} + onValueChange={(value) => form.setFieldValue("trial_info.developers", value) } placeholder="Select..." @@ -335,8 +246,8 @@ export const SetupPageView: FC = ({ /> + field={getFieldHelpers("trial_info.country")} + onValueChange={(value) => form.setFieldValue("trial_info.country", value) } placeholder="Select..." @@ -410,17 +321,8 @@ export const SetupPageView: FC = ({ {/* Privacy policy notice */}

- Subscribe for the latest product and news updates from Coder. The - information you provide will be treated in accordance with the{" "} - - Coder Privacy Policy - - . + Subscribe for the latest product and news updates from Coder.{" "} + Opt-out at any time.

@@ -435,7 +337,7 @@ export const SetupPageView: FC = ({ Contact Sales