mirror of
https://github.com/coder/coder.git
synced 2026-10-30 18:12:29 +08:00
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:
co-authored by
Matt Vollmer
parent
58c1248cf3
commit
f44a3b59d7
Generated
+97
@@ -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": [
|
||||
|
||||
Generated
+91
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+56
@@ -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
|
||||
|
||||
Generated
+28
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
Generated
+15
@@ -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
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
+1
-2
@@ -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}
|
||||
|
||||
+6
-6
@@ -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'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" />
|
||||
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" />
|
||||
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" />
|
||||
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>
|
||||
);
|
||||
};
|
||||
+13
-3
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user