feat: Premium Page CTA updates and form handling (#28226)

* 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

<img width="1512" height="833" alt="Screenshot 2026-08-17 at 10 08
41 PM"
src="https://github.com/user-attachments/assets/1766e724-c63a-4da9-b2bd-a98e6b66cc9b"
/>

<img width="1510" height="874" alt="Aug-17-2026 22-12-38"
src="https://github.com/user-attachments/assets/3a2a0f35-1176-4290-aa0d-b3f7d66c15d6"
/>


```mermaid
sequenceDiagram
    autonumber
    actor Owner
    participant UI as UI<br/>TrialRequestForm + PremiumPage
    participant API as API<br/>api.ts + coderd middleware
    participant Coderd as Coderd<br/>postTrialLicense + trialer
    participant Licensor as Licensor<br/>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<br/>cookie + X-CSRF-TOKEN
    Note over UI: mutation.status = "pending"

    API->>API: verify CSRF, apiRateLimiter,<br/>apiKeyMiddleware sets actor
    API->>Coderd: dispatch

    Coderd->>Coderd: audit.InitRequest, authorize license:create,<br/>httpapi.Read validates, HasLicense is false
    Coderd->>Licensor: POST trial request<br/>deployment_id unspoofable, 10s deadline
    Licensor-->>Coderd: 200, raw signed JWT

    Coderd->>Coderd: ParseClaimsIgnoreNbf, uuid.Parse
    Coderd->>Licensor: InsertLicense, updateEntitlements,<br/>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 <matthewjvollmer@outlook.com>
This commit is contained in:
Samuel Volin
2026-08-21 09:54:54 -04:00
committed by GitHub
co-authored by Matt Vollmer
parent 58c1248cf3
commit f44a3b59d7
36 changed files with 2155 additions and 509 deletions
+97
View File
@@ -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": [
+91
View File
@@ -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"],
+26
View File
@@ -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 {
+56
View File
@@ -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
+28
View File
@@ -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
+1 -1
View File
@@ -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,
+5
View File
@@ -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
+133 -29
View File
@@ -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
}
+38
View File
@@ -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) {
+117 -68
View File
@@ -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
}
+168 -1
View File
@@ -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("<html>gateway timeout</html>"))
}))
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")
})
}
+7
View File
@@ -2621,6 +2621,13 @@ class ApiMethods {
return response.data;
};
createTrialLicense = async (
data: TypesGen.CreateTrialLicenseRequest,
): Promise<TypesGen.License> => {
const response = await this.axios.post("/api/v2/licenses/trial", data);
return response.data;
};
removeLicense = async (licenseId: number): Promise<void> => {
await this.axios.delete(`/api/v2/licenses/${licenseId}`);
};
+1 -1
View File
@@ -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<Entitlements>) => {
return cachedQuery({
+20
View File
@@ -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 }),
]);
},
});
+15
View File
@@ -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
+29 -12
View File
@@ -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<HTMLElement>,
"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<FormFieldProps> = ({
@@ -15,6 +24,7 @@ export const FormField: FC<FormFieldProps> = ({
label,
description,
className,
control,
...inputProps
}) => {
const generatedId = useId();
@@ -29,6 +39,11 @@ export const FormField: FC<FormFieldProps> = ({
.filter(Boolean)
.join(" ");
const required = inputProps.required ?? false;
const controlProps: ControlProps = {
id,
"aria-invalid": field.error,
"aria-describedby": describedBy || undefined,
};
return (
<div className="flex flex-col gap-2">
@@ -48,17 +63,19 @@ export const FormField: FC<FormFieldProps> = ({
{description}
</div>
)}
<Input
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
{...inputProps}
id={id}
aria-invalid={field.error}
aria-describedby={describedBy || undefined}
className={cn(field.error && "border-border-destructive", className)}
/>
{control ? (
control(controlProps)
) : (
<Input
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
{...inputProps}
{...controlProps}
className={cn(field.error && "border-border-destructive", className)}
/>
)}
{field.error ? (
<span id={errorId} className="text-xs text-content-destructive">
{field.helperText}
@@ -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<ExampleSelectFieldProps> = ({
id,
label,
description,
helperText,
required,
error,
value = "",
disabled,
}) => {
const form = useFormik({
initialValues: { value },
onSubmit: () => {},
});
return (
<SelectField
id={id}
field={{
name: "value",
id: "value",
value: form.values.value,
onChange: form.handleChange,
onBlur: form.handleBlur,
error: Boolean(error),
helperText: error ?? helperText,
}}
label={label}
description={description}
required={required}
disabled={disabled}
placeholder="Select a region..."
onValueChange={(next) => form.setFieldValue("value", next)}
>
{regions.map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
</SelectField>
);
};
const meta: Meta<typeof ExampleSelectField> = {
title: "components/SelectField",
component: ExampleSelectField,
args: {
id: "story-select",
label: "Deployment region",
},
};
export default meta;
type Story = StoryObj<typeof ExampleSelectField>;
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"));
},
};
@@ -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<SelectFieldProps> = ({
field,
label,
onValueChange,
children,
id,
description,
placeholder,
required,
disabled,
className,
}) => (
<FormField
field={field}
label={label}
description={description}
id={id}
required={required}
control={(controlProps) => (
<Select
value={String(field.value ?? "")}
onValueChange={onValueChange}
disabled={disabled}
>
<SelectTrigger
{...controlProps}
aria-required={required}
className={cn(field.error && "border-border-destructive", className)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
)}
/>
);
@@ -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{" "}
<a
href="https://coder.com/legal/privacy-policy"
target="_blank"
rel="noreferrer"
className="text-content-link hover:underline"
>
Coder Privacy Policy
</a>
.
</>
);
};
+73
View File
@@ -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.",
),
});
@@ -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 (
<DeploymentSidebarView
permissions={permissions}
showOrganizations={showOrganizations}
hasPremiumLicense={hasPremiumLicense}
hidePremiumTab={hidePremiumTab}
experiments={experiments}
buildInfo={buildInfo}
/>
@@ -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<typeof DeploymentSidebarView> = {
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();
},
};
@@ -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<DeploymentSidebarViewProps> = ({
permissions,
showOrganizations,
hasPremiumLicense,
hidePremiumTab,
experiments,
buildInfo,
}) => {
@@ -98,8 +99,10 @@ export const DeploymentSidebarView: FC<DeploymentSidebarViewProps> = ({
</div>
</SidebarNavItem>
)}
{!hasPremiumLicense && (
<SidebarNavItem href="/deployment/premium">Premium</SidebarNavItem>
{!hidePremiumTab && (
<SidebarNavItem href={PREMIUM_PAGE_PATH}>
Trial Upgrade
</SidebarNavItem>
)}
</div>
</BaseSidebar>
@@ -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<
/>
<div className="mt-6 mb-6">
<Alert severity="info" actions={<PremiumBadge />}>
<Alert severity="info">
Integrating with multiple external authentication providers is a
Premium feature.{" "}
<a
@@ -6,6 +6,7 @@ import { API } from "#/api/api";
import { getErrorDetail, getErrorMessage } from "#/api/errors";
import { entitlements, refreshEntitlements } from "#/api/queries/entitlements";
import { insightsUserStatusCounts } from "#/api/queries/insights";
import { licenses, licensesKey } from "#/api/queries/licenses";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import { pageTitle } from "#/utils/page";
import LicensesSettingsPageView from "./LicensesSettingsPageView";
@@ -44,7 +45,7 @@ const LicensesSettingsPage: FC = () => {
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}
@@ -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<Props> = ({
You&apos;re missing out on high availability, RBAC, quotas,
and much more. Contact{" "}
<Link
href="mailto:sales@coder.com"
href={CONTACT_SALES_LINK}
className="m-0 p-0 text-base"
showExternalIcon={false}
>
sales
</Link>{" "}
or{" "}
<Link
href="https://coder.com/trial"
className="m-0 p-0 text-base"
showExternalIcon={false}
<RouterLink
to="/deployment/premium"
className="m-0 p-0 text-content-link"
>
request a trial license
</Link>{" "}
</RouterLink>{" "}
to get started.
</span>
</div>
@@ -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 (
<div className="flex flex-col items-center gap-6 text-center max-w-md">
<h1 className="m-0 font-semibold text-3xl text-content-primary">
A license is already installed
</h1>
<p className="m-0 px-8 text-sm text-content-primary">
This deployment already has a license. Review your entitlements on the
Licenses page.
</p>
<Button asChild className="w-full">
<RouterLink to="/deployment/licenses">View licenses</RouterLink>
</Button>
</div>
);
};
@@ -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 (
<>
<title>{pageTitle("Premium Features")}</title>
<title>{pageTitle("Start a Coder trial")}</title>
<PremiumPageView isEnterprise={entitlements.has_license} />
<SettingsHeader
actions={
<SettingsHeaderDocsLink href={docs(DATABASE_DOCS_LINK)}>
Review Coder system requirements
</SettingsHeaderDocsLink>
}
>
<SettingsHeaderTitle>Start a Coder trial</SettingsHeaderTitle>
<SettingsHeaderDescription>
For enterprises ready to achieve world-class security, scalability,
and developer experience.
</SettingsHeaderDescription>
<Link
href="https://coder.com/pricing#compare"
target="_blank"
rel="noreferrer"
size="sm"
className="w-fit"
>
Learn more
</Link>
</SettingsHeader>
<PremiumPageView
hasLicense={hasLicense}
isTrial={isTrial}
canRequestTrial={permissions.viewAllLicenses}
trialDaysRemaining={trialDaysRemaining}
isSubmitting={trialMutation.isPending}
error={trialMutation.error}
onSubmit={(request) => {
trialMutation.mutate(request, {
onSuccess: () => {
navigate("/deployment/licenses?success=true");
},
onError: (error) => {
toast.error(
getErrorMessage(error, "Failed to request a trial license."),
{ description: getErrorDetail(error) },
);
},
});
}}
/>
</>
);
};
@@ -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<typeof PremiumPageView> = {
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<typeof PremiumPageView>;
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();
},
};
@@ -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<PremiumPageViewProps> = ({ isEnterprise }) => {
return isEnterprise ? <EnterpriseVersion /> : <OSSVersion />;
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 (
<div className="max-w-4xl">
<header className="flex flex-row justify-between align-baseline pb-5">
<div>
<div className="flex flex-row items-center gap-3">
<h1 className="text-3xl m-0 font-semibold">Premium</h1>
<PremiumBadge />
</div>
<p className="text-sm max-w-xl mt-2 text-content-secondary font-medium">
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.
</p>
</div>
<Button asChild>
<a href="https://coder.com/contact/sales" className="no-underline">
<SquareArrowOutUpRightIcon />
Contact sales
</a>
</Button>
</header>
<section className="pb-1">
<h2 className="text-sm font-semibold m-0">
<Link className="px-0" href={docs("/admin/users/organizations")}>
Multi-Organization access controls
</Link>
</h2>
<p className="text-sm max-w-xl text-content-secondary mt-0 font-medium">
Manage multiple teams and projects within a single deployment, each
with isolated access.
</p>
</section>
<section className="pb-1">
<h2 className="text-sm font-semibold m-0">
<Link className="px-0" href={docs("/admin/users/groups-roles")}>
Custom role
</Link>
</h2>
<p className="text-sm max-w-xl text-content-secondary mt-0 font-medium">
Configure specific permissions for teams or contractors with tailored
roles.
</p>
</section>
<section>
<h2 className="text-sm font-semibold m-0">
<Link className="px-0" href={docs("/admin/users/quotas")}>
Org-Level quotas for chargeback
</Link>
</h2>
<p className="text-sm max-w-xl text-content-secondary mt-0 font-medium">
Set and monitor resource quotas at the organization level to support
internal cost tracking.
</p>
</section>
<section className="pt-10">
<p className="text-sm max-w-xl text-content-secondary mt-0 font-medium">
These Premium features enable you to manage team structure and budget
allocation across multiple platform teams.
</p>
</section>
</div>
);
};
const OSSVersion: FC = () => {
return (
<div className="max-w-4xl">
<div className="flex flex-row justify-between align-baseline pb-10">
<div>
<div className="flex flex-row items-center gap-3">
<h1 className="text-3xl m-0 text-content-primary font-semibold">
Premium
</h1>
<PremiumBadge />
</div>
<p className="text-sm max-w-xl mt-2 text-content-secondary">
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.
</p>
</div>
<Button asChild>
<a href="https://coder.com/contact/sales" className="no-underline">
<SquareArrowOutUpRightIcon />
Contact sales
</a>
</Button>
export const PremiumPageView: FC<PremiumPageViewProps> = ({
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 (
<div className="relative isolate overflow-hidden rounded-lg py-12 mb-8 border border-solid bg-surface-secondary flex items-center justify-center">
<Supergraphic className="bg-[position:20%_20%]" />
{isTrial ? (
<TrialActivePanel daysRemaining={trialDaysRemaining} />
) : (
<LicenseActivePanel />
)}
</div>
);
}
<section className="pb-10 max-w-xl text-sm text-content-secondary">
<h2 className="text-xl text-content-primary m-0">
<span className="flex flex-row items-center">
<ExpandIcon className="size-icon-sm text-content-secondary" />
&nbsp; Deploy coder at scale
</span>
</h2>
<p>
Equip your enterprise to deploy and manage thousands of workspaces
with performance and reliability.
</p>
<ul className="pl-5">
<li>
<span className="text-content-primary font-semibold">
High availability
</span>
<br />
<span className="font-medium">
Scale with automatic failover and load balancing across multiple
Coder instances.
</span>
</li>
<li>
<span className="text-content-primary font-semibold">
Multi-Organization access control
</span>
<br />
<span className="font-medium">
Isolate teams, projects, and environments within a single Coder
deployment.
</span>
</li>
<li>
<span className="text-content-primary font-semibold">
Unlimited external authentication integrations
</span>
<br />
<span className="font-medium">
Integrate with external service providers like GitHub, JFrog, and
Vault.
</span>
</li>
</ul>
</section>
<section className="pb-10 max-w-xl text-sm text-content-secondary">
<h2 className="text-xl text-content-primary m-0">
<span className="flex flex-row items-center">
<CoinsIcon className="size-icon-sm text-content-secondary" />
&nbsp; Control infrastructure costs
</span>
</h2>
<p>
Optimize cloud usage and maintain cost-effective resource management
for your teams.
</p>
<ul className="pl-5">
<li>
<span className="text-content-primary font-semibold">
Auto-Stop idle workspaces
</span>
<br />
<span className="font-medium">
Automatically shut down inactive workspaces to prevent unnecessary
costs.
</span>
</li>
<li>
<span className="text-content-primary font-semibold">
Resource quotas
</span>
<br />
<span className="font-medium">
Set user and team-specific limits to control spending and resource
allocation.
</span>
</li>
<li>
<span className="text-content-primary font-semibold">
Usage insights
</span>
<br />
<span className="font-medium">
Track workspace usage patterns to make data-driven decisions about
infrastructure needs.
</span>
</li>
</ul>
</section>
<section className="pb-5 max-w-xl text-sm text-content-secondary">
<h2 className="text-xl text-content-primary m-0">
<span className="flex flex-row items-center">
<ActivityIcon className="size-icon-sm text-content-secondary" />
&nbsp; Govern workspace activity
</span>
</h2>
<p>
Maintain security and compliance across your organization with robust
governance features.
</p>
<ul className="pl-5">
<li>
<span className="text-content-primary font-semibold">
Audit logging
</span>
<br />
<span className="font-medium">
Capture detailed records of user actions and workspace activity
for compliance and security.
</span>
</li>
<li>
<span className="text-content-primary font-semibold">
Template permissions
</span>
<br />
<span className="font-medium">
Control who can create, modify, and access workspace templates
across teams.
</span>
</li>
<li>
<span className="text-content-primary font-semibold">
Workspace command logging
</span>
<br />
<span className="font-medium">
Monitor and log critical commands to ensure security and
compliance standards are met.
</span>
</li>
</ul>
</section>
return (
<div className="rounded-lg border border-solid border-border-default bg-surface-primary overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-[640px]">
<div className="relative isolate overflow-hidden hidden lg:flex flex-col p-12 bg-surface-secondary">
<Supergraphic className="bg-[position:20%_20%] bg-[length:110%_125%] -scale-x-100" />
<h2 className="self-start m-0 pt-24 max-w-md text-3xl font-semibold text-content-primary text-balance">
Start an unlimited 30-day Coder trial
</h2>
<p className="self-start m-0 max-w-sm pt-6 text-sm text-content-primary">
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.
</p>
</div>
<div className="flex flex-col justify-center p-8 lg:p-12 bg-surface-secondary">
{canRequestTrial ? (
<TrialRequestForm
onSubmit={onSubmit}
isSubmitting={isSubmitting}
error={error}
/>
) : (
<PaywallGuidance className="mx-0">
Contact your deployment administrator for Premium.
</PaywallGuidance>
)}
</div>
</div>
</div>
);
};
@@ -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<TrialActivePanelProps> = ({
daysRemaining,
}) => {
const showRemaining = daysRemaining !== undefined && daysRemaining > 0;
return (
<div className="flex flex-col items-center gap-6 text-center max-w-md">
{showRemaining ? (
<div className="flex flex-col gap-2">
<h1 className="m-0 font-semibold text-3xl text-content-primary">
{daysRemaining} {daysRemaining === 1 ? "day" : "days"} remaining
</h1>
</div>
) : null}
<p className="m-0 px-8 text-sm text-content-primary">
Contact our sales team to extend your trial.
</p>
<Button asChild className="w-full">
<a href={CONTACT_SALES_LINK} target="_blank" rel="noreferrer">
Contact sales
</a>
</Button>
</div>
);
};
@@ -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<typeof TrialRequestForm> = {
title: "pages/DeploymentSettingsPage/TrialRequestForm",
component: TrialRequestForm,
args: {
isSubmitting: false,
onSubmit: fn(),
},
};
export default meta;
type Story = StoryObj<typeof TrialRequestForm>;
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();
},
};
@@ -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<TrialRequestFormProps> = ({
onSubmit,
isSubmitting,
error,
}) => {
const acknowledgementId = useId();
const form = useFormik<TrialFormValues>({
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<TrialFormValues>(form, error);
return (
<form
onSubmit={form.handleSubmit}
className="flex flex-col gap-6"
noValidate
>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField
label="First name"
placeholder="Jane"
field={getFieldHelpers("first_name", {
maxLength: MAX_NAME_LENGTH,
})}
disabled={isSubmitting}
/>
<FormField
label="Last name"
placeholder="Doe"
field={getFieldHelpers("last_name", { maxLength: MAX_NAME_LENGTH })}
disabled={isSubmitting}
/>
</div>
<FormField
label="Business email"
type="email"
placeholder="you@company.com"
field={getFieldHelpers("email", { maxLength: MAX_EMAIL_LENGTH })}
onChange={onChangeTrimmed(form)}
disabled={isSubmitting}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField
label="Company"
placeholder="Acme Inc."
field={getFieldHelpers("company_name", {
maxLength: MAX_COMPANY_NAME_LENGTH,
})}
disabled={isSubmitting}
/>
<FormField
label="Job title"
placeholder="Platform Engineer"
field={getFieldHelpers("job_title", {
maxLength: MAX_JOB_TITLE_LENGTH,
})}
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField
type="tel"
label="Phone number"
placeholder="+1 415 5552671"
field={getFieldHelpers("phone_number")}
disabled={isSubmitting}
/>
<SelectField
label="Number of developers"
field={getFieldHelpers("developers")}
onValueChange={(value) => form.setFieldValue("developers", value)}
placeholder="Select..."
disabled={isSubmitting}
>
{numberOfDevelopersOptions.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectField>
</div>
<SelectField
label="Country"
field={getFieldHelpers("country")}
onValueChange={(value) => form.setFieldValue("country", value)}
placeholder="Select..."
disabled={isSubmitting}
>
{countries.map((c) => (
<SelectItem key={c.name} value={c.name}>
{c.flag} {c.name}
</SelectItem>
))}
</SelectField>
</div>
<div className="flex gap-2 items-start text-sm text-content-primary">
<Checkbox
id={acknowledgementId}
checked={form.values.acknowledged}
onCheckedChange={(checked) =>
form.setFieldValue("acknowledged", checked === true)
}
disabled={isSubmitting}
/>
<div>
<label htmlFor={acknowledgementId} className="cursor-pointer">
I understand that Coder trial features increase database load, and
that Coder recommends an external PostgreSQL database for production
deployments.
</label>{" "}
<a
href={docs(DATABASE_DOCS_LINK)}
target="_blank"
rel="noreferrer"
className="text-content-link hover:underline"
>
Learn more
</a>
</div>
</div>
<div className="flex flex-col gap-2">
<Button
type="submit"
size="lg"
className="w-full"
disabled={!form.values.acknowledged || isSubmitting}
>
<Spinner loading={isSubmitting} />
Start a trial
</Button>
<p className="m-0 text-xs text-content-secondary leading-relaxed">
<PrivacyPolicyNotice /> Opt-out at any time.
</p>
</div>
</form>
);
};
@@ -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<SecuritySettingsPageViewProps> = ({
}
>
<SettingsHeaderTitle level="h2" hierarchy="secondary">
Browser-Only Connections
Browser-Only Connections{" "}
<Badges>
{featureBrowserOnlyEnabled ? <EnabledBadge /> : <DisabledBadge />}
</Badges>
</SettingsHeaderTitle>
<SettingsHeaderDescription>
Block all workspace access via SSH, port forward, and other
@@ -71,8 +74,15 @@ export const SecuritySettingsPageView: FC<SecuritySettingsPageViewProps> = ({
<Badges>
{featureBrowserOnlyEnabled ? <EnabledBadge /> : <DisabledBadge />}
<PremiumBadge />
</Badges>
{!featureBrowserOnlyEnabled ? (
<PaywallSmall
message="Browser-Only Connections"
description="Block all workspace access via SSH, port forward, and other
non-browser connections."
canViewPremium
/>
) : null}
</div>
{tlsOptions.length > 0 && (
@@ -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.
+18 -116
View File
@@ -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 }) => (
<div className={cn("flex flex-col gap-2", className)}>
<Label htmlFor={id}>{label}</Label>
{children}
{helperText && (
<span
className={cn(
"text-xs text-left",
error ? "text-content-destructive" : "text-content-secondary",
)}
>
{helperText}
</span>
)}
</div>
);
type SelectFieldProps = FormHelpers & {
label: string;
className?: string;
onValueChange: (value: string) => void;
placeholder?: string;
children: ReactNode;
disabled?: boolean;
};
const SelectField: FC<SelectFieldProps> = ({
label,
id,
error,
helperText,
className,
value,
onValueChange,
placeholder,
children,
disabled,
}) => (
<Field
label={label}
id={id}
error={error}
helperText={helperText}
className={className}
>
<Select
value={String(value ?? "")}
onValueChange={onValueChange}
disabled={disabled}
>
<SelectTrigger id={id}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
</Field>
);
interface SetupPageViewProps {
onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void;
error?: unknown;
@@ -266,7 +177,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
/>
<div className="flex flex-col items-start gap-0.5">
<span className="text-sm font-semibold">
Start a 30-day trial of Premium
Start an unlimited 30-day Coder trial
</span>
<span className="text-xs text-content-secondary leading-relaxed">
Get access to high availability, template RBAC, audit logging,
@@ -307,8 +218,8 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
/>
<SelectField
label="Number of developers"
{...getFieldHelpers("trial_info.developers")}
onValueChange={(value: string) =>
field={getFieldHelpers("trial_info.developers")}
onValueChange={(value) =>
form.setFieldValue("trial_info.developers", value)
}
placeholder="Select..."
@@ -335,8 +246,8 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
/>
<SelectField
label="Country"
{...getFieldHelpers("trial_info.country")}
onValueChange={(value: string) =>
field={getFieldHelpers("trial_info.country")}
onValueChange={(value) =>
form.setFieldValue("trial_info.country", value)
}
placeholder="Select..."
@@ -410,17 +321,8 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
{/* Privacy policy notice */}
<p className="text-xs text-content-secondary leading-relaxed">
Subscribe for the latest product and news updates from Coder. The
information you provide will be treated in accordance with the{" "}
<a
href="https://coder.com/legal/privacy-policy"
target="_blank"
rel="noreferrer"
className="text-content-link hover:underline"
>
Coder Privacy Policy
</a>
.
Subscribe for the latest product and news updates from Coder.{" "}
<PrivacyPolicyNotice /> Opt-out at any time.
</p>
</div>
@@ -435,7 +337,7 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
<a
target="_blank"
rel="noreferrer"
href="https://coder.com/contact/sales"
href={CONTACT_SALES_LINK}
className="text-content-link hover:underline"
>
Contact Sales