feat: Add the option to generate a trial license during setup (#5110)

This allows users to generate a 30 day free license during setup to
test out Enterprise features.
This commit is contained in:
Kyle Carberry 2022-11-16 17:09:49 -06:00 committed by GitHub
parent b6703b11c6
commit fb9ca7b830
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 332 additions and 79 deletions

View File

@ -9,6 +9,7 @@ MacOS = "macOS"
doas = "doas"
darcula = "darcula"
Hashi = "Hashi"
trialer = "trialer"
[files]
extend-exclude = [

View File

@ -123,6 +123,8 @@ linters-settings:
misspell:
locale: US
ignore-words:
- trialer
nestif:
min-complexity: 4 # Min complexity of if statements (def 5, goal 4)

View File

@ -128,6 +128,7 @@
"tfstate",
"tios",
"tparallel",
"trialer",
"trimprefix",
"tsdial",
"tslogger",

View File

@ -38,10 +38,13 @@ func init() {
}
func login() *cobra.Command {
const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL"
var (
email string
username string
password string
trial bool
)
cmd := &cobra.Command{
Use: "login <url>",
@ -162,11 +165,20 @@ func login() *cobra.Command {
}
}
if !cmd.Flags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
v, _ := cliui.Prompt(cmd, cliui.PromptOptions{
Text: "Start a 30-day trial of Enterprise?",
IsConfirm: true,
Default: "yes",
})
trial = v == "yes" || v == "y"
}
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
Email: email,
Username: username,
OrganizationName: username,
Password: password,
Email: email,
Username: username,
Password: password,
Trial: trial,
})
if err != nil {
return xerrors.Errorf("create initial user: %w", err)
@ -251,6 +263,7 @@ func login() *cobra.Command {
cliflag.StringVarP(cmd.Flags(), &email, "first-user-email", "", "CODER_FIRST_USER_EMAIL", "", "Specifies an email address to use if creating the first user for the deployment.")
cliflag.StringVarP(cmd.Flags(), &username, "first-user-username", "", "CODER_FIRST_USER_USERNAME", "", "Specifies a username to use if creating the first user for the deployment.")
cliflag.StringVarP(cmd.Flags(), &password, "first-user-password", "", "CODER_FIRST_USER_PASSWORD", "", "Specifies a password to use if creating the first user for the deployment.")
cliflag.BoolVarP(cmd.Flags(), &trial, "first-user-trial", "", firstUserTrialEnv, false, "Specifies whether a trial license should be provisioned for the Coder deployment or not.")
return cmd
}

View File

@ -56,6 +56,7 @@ func TestLogin(t *testing.T) {
"email", "user@coder.com",
"password", "password",
"password", "password", // Confirm.
"trial", "yes",
}
for i := 0; i < len(matches); i += 2 {
match := matches[i]
@ -74,7 +75,7 @@ func TestLogin(t *testing.T) {
// accurately detect Windows ptys when they are not attached to a process:
// https://github.com/mattn/go-isatty/issues/59
doneChan := make(chan struct{})
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password")
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial")
pty := ptytest.New(t)
root.SetIn(pty.Input())
root.SetOut(pty.Output())
@ -127,6 +128,8 @@ func TestLogin(t *testing.T) {
pty.WriteLine("pass")
pty.ExpectMatch("Confirm")
pty.WriteLine("pass")
pty.ExpectMatch("trial")
pty.WriteLine("yes")
pty.ExpectMatch("Welcome to Coder")
<-doneChan
})

View File

@ -60,10 +60,9 @@ func TestResetPassword(t *testing.T) {
client := codersdk.New(accessURL)
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: email,
Username: username,
Password: oldPassword,
OrganizationName: "example",
Email: email,
Username: username,
Password: oldPassword,
})
require.NoError(t, err)

View File

@ -71,10 +71,9 @@ func TestServer(t *testing.T) {
client := codersdk.New(accessURL)
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "some@one.com",
Username: "example",
Password: "password",
OrganizationName: "example",
Email: "some@one.com",
Username: "example",
Password: "password",
})
require.NoError(t, err)
cancelFunc()

View File

@ -94,7 +94,7 @@ type Options struct {
AutoImportTemplates []AutoImportTemplate
GitAuthConfigs []*gitauth.Config
RealIPConfig *httpmw.RealIPConfig
TrialGenerator func(ctx context.Context, email string) error
// TLSCertificates is used to mesh DERP servers securely.
TLSCertificates []tls.Certificate
TailnetCoordinator tailnet.Coordinator

View File

@ -94,6 +94,7 @@ type Options struct {
Auditor audit.Auditor
TLSCertificates []tls.Certificate
GitAuthConfigs []*gitauth.Config
TrialGenerator func(context.Context, string) error
// IncludeProvisionerDaemon when true means to start an in-memory provisionerD
IncludeProvisionerDaemon bool
@ -258,6 +259,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
Authorizer: options.Authorizer,
Telemetry: telemetry.NewNoop(),
TLSCertificates: options.TLSCertificates,
TrialGenerator: options.TrialGenerator,
DERPMap: &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
@ -383,10 +385,9 @@ func NewExternalProvisionerDaemon(t *testing.T, client *codersdk.Client, org uui
}
var FirstUserParams = codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
OrganizationName: "testorg",
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
}
// CreateFirstUser creates a user with preset credentials and authenticates

View File

@ -200,7 +200,8 @@ CREATE TABLE licenses (
id integer NOT NULL,
uploaded_at timestamp with time zone NOT NULL,
jwt text NOT NULL,
exp timestamp with time zone NOT NULL
exp timestamp with time zone NOT NULL,
uuid uuid
);
COMMENT ON COLUMN licenses.exp IS 'exp tracks the claim of the same name in the JWT, and we include it here so that we can easily query for licenses that have not yet expired.';

View File

@ -0,0 +1 @@
ALTER TABLE licenses DROP COLUMN uuid;

View File

@ -0,0 +1 @@
ALTER TABLE licenses ADD COLUMN uuid uuid;

View File

@ -468,7 +468,8 @@ type License struct {
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
JWT string `db:"jwt" json:"jwt"`
// exp tracks the claim of the same name in the JWT, and we include it here so that we can easily query for licenses that have not yet expired.
Exp time.Time `db:"exp" json:"exp"`
Exp time.Time `db:"exp" json:"exp"`
Uuid uuid.NullUUID `db:"uuid" json:"uuid"`
}
type Organization struct {

View File

@ -1402,7 +1402,7 @@ func (q *sqlQuerier) DeleteLicense(ctx context.Context, id int32) (int32, error)
}
const getLicenses = `-- name: GetLicenses :many
SELECT id, uploaded_at, jwt, exp
SELECT id, uploaded_at, jwt, exp, uuid
FROM licenses
ORDER BY (id)
`
@ -1421,6 +1421,7 @@ func (q *sqlQuerier) GetLicenses(ctx context.Context) ([]License, error) {
&i.UploadedAt,
&i.JWT,
&i.Exp,
&i.Uuid,
); err != nil {
return nil, err
}
@ -1436,7 +1437,7 @@ func (q *sqlQuerier) GetLicenses(ctx context.Context) ([]License, error) {
}
const getUnexpiredLicenses = `-- name: GetUnexpiredLicenses :many
SELECT id, uploaded_at, jwt, exp
SELECT id, uploaded_at, jwt, exp, uuid
FROM licenses
WHERE exp > NOW()
ORDER BY (id)
@ -1456,6 +1457,7 @@ func (q *sqlQuerier) GetUnexpiredLicenses(ctx context.Context) ([]License, error
&i.UploadedAt,
&i.JWT,
&i.Exp,
&i.Uuid,
); err != nil {
return nil, err
}
@ -1475,26 +1477,34 @@ INSERT INTO
licenses (
uploaded_at,
jwt,
exp
exp,
uuid
)
VALUES
($1, $2, $3) RETURNING id, uploaded_at, jwt, exp
($1, $2, $3, $4) RETURNING id, uploaded_at, jwt, exp, uuid
`
type InsertLicenseParams struct {
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
JWT string `db:"jwt" json:"jwt"`
Exp time.Time `db:"exp" json:"exp"`
UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"`
JWT string `db:"jwt" json:"jwt"`
Exp time.Time `db:"exp" json:"exp"`
Uuid uuid.NullUUID `db:"uuid" json:"uuid"`
}
func (q *sqlQuerier) InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) {
row := q.db.QueryRowContext(ctx, insertLicense, arg.UploadedAt, arg.JWT, arg.Exp)
row := q.db.QueryRowContext(ctx, insertLicense,
arg.UploadedAt,
arg.JWT,
arg.Exp,
arg.Uuid,
)
var i License
err := row.Scan(
&i.ID,
&i.UploadedAt,
&i.JWT,
&i.Exp,
&i.Uuid,
)
return i, err
}

View File

@ -3,10 +3,11 @@ INSERT INTO
licenses (
uploaded_at,
jwt,
exp
exp,
uuid
)
VALUES
($1, $2, $3) RETURNING *;
($1, $2, $3, $4) RETURNING *;
-- name: GetLicenses :many
SELECT *

View File

@ -446,6 +446,17 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
}
return nil
})
eg.Go(func() error {
licenses, err := r.options.Database.GetUnexpiredLicenses(ctx)
if err != nil {
return xerrors.Errorf("get licenses: %w", err)
}
snapshot.Licenses = make([]License, 0, len(licenses))
for _, license := range licenses {
snapshot.Licenses = append(snapshot.Licenses, ConvertLicense(license))
}
return nil
})
err := eg.Wait()
if err != nil {
@ -622,6 +633,14 @@ func ConvertTemplateVersion(version database.TemplateVersion) TemplateVersion {
return snapVersion
}
// ConvertLicense anonymizes a license.
func ConvertLicense(license database.License) License {
return License{
UploadedAt: license.UploadedAt,
UUID: license.Uuid.UUID,
}
}
// Snapshot represents a point-in-time anonymized database dump.
// Data is aggregated by latest on the server-side, so partial data
// can be sent without issue.
@ -631,6 +650,7 @@ type Snapshot struct {
APIKeys []APIKey `json:"api_keys"`
ParameterSchemas []ParameterSchema `json:"parameter_schemas"`
ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"`
Licenses []License `json:"licenses"`
Templates []Template `json:"templates"`
TemplateVersions []TemplateVersion `json:"template_versions"`
Users []User `json:"users"`
@ -791,6 +811,11 @@ type ParameterSchema struct {
ValidationCondition string `json:"validation_condition"`
}
type License struct {
UploadedAt time.Time `json:"uploaded_at"`
UUID uuid.UUID `json:"uuid"`
}
type noopReporter struct{}
func (*noopReporter) Report(_ *Snapshot) {}

View File

@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
@ -87,9 +88,20 @@ func TestTelemetry(t *testing.T) {
CreatedAt: database.Now(),
})
require.NoError(t, err)
_, err = db.InsertLicense(ctx, database.InsertLicenseParams{
UploadedAt: database.Now(),
JWT: "",
Exp: database.Now().Add(time.Hour),
Uuid: uuid.NullUUID{
UUID: uuid.New(),
Valid: true,
},
})
require.NoError(t, err)
snapshot := collectSnapshot(t, db)
require.Len(t, snapshot.ParameterSchemas, 1)
require.Len(t, snapshot.ProvisionerJobs, 1)
require.Len(t, snapshot.Licenses, 1)
require.Len(t, snapshot.Templates, 1)
require.Len(t, snapshot.TemplateVersions, 1)
require.Len(t, snapshot.Users, 1)

View File

@ -80,6 +80,17 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) {
return
}
if createUser.Trial && api.TrialGenerator != nil {
err = api.TrialGenerator(ctx, createUser.Email)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate trial",
Detail: err.Error(),
})
return
}
}
user, organizationID, err := api.CreateUser(ctx, api.Database, CreateUserRequest{
CreateUserRequest: codersdk.CreateUserRequest{
Email: createUser.Email,

View File

@ -49,10 +49,9 @@ func TestFirstUser(t *testing.T) {
defer cancel()
_, err := client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "some@email.com",
Username: "exampleuser",
Password: "password",
OrganizationName: "someorg",
Email: "some@email.com",
Username: "exampleuser",
Password: "password",
})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
@ -65,6 +64,30 @@ func TestFirstUser(t *testing.T) {
_ = coderdtest.CreateFirstUser(t, client)
})
t.Run("Trial", func(t *testing.T) {
t.Parallel()
called := make(chan struct{})
client := coderdtest.New(t, &coderdtest.Options{
TrialGenerator: func(ctx context.Context, s string) error {
close(called)
return nil
},
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
Trial: true,
}
_, err := client.CreateFirstUser(ctx, req)
require.NoError(t, err)
<-called
})
t.Run("LastSeenAt", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@ -192,10 +215,9 @@ func TestPostLogin(t *testing.T) {
defer cancel()
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
OrganizationName: "testorg",
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
}
_, err := client.CreateFirstUser(ctx, req)
require.NoError(t, err)
@ -249,10 +271,9 @@ func TestPostLogin(t *testing.T) {
defer cancel()
req := codersdk.CreateFirstUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
OrganizationName: "testorg",
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpass",
}
_, err := client.CreateFirstUser(ctx, req)
require.NoError(t, err)

View File

@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"time"
"github.com/google/uuid"
)
type AddLicenseRequest struct {
@ -14,6 +16,7 @@ type AddLicenseRequest struct {
type License struct {
ID int32 `json:"id"`
UUID uuid.UUID `json:"uuid"`
UploadedAt time.Time `json:"uploaded_at"`
// Claims are the JWT claims asserted by the license. Here we use
// a generic string map to ensure that all data from the server is

View File

@ -53,10 +53,10 @@ type GetUsersResponse struct {
}
type CreateFirstUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
OrganizationName string `json:"organization" validate:"required,username"`
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
Trial bool `json:"trial"`
}
// CreateFirstUserResponse contains IDs for newly created user info.

View File

@ -17,6 +17,7 @@ import (
"github.com/coder/coder/enterprise/audit"
"github.com/coder/coder/enterprise/audit/backends"
"github.com/coder/coder/enterprise/coderd"
"github.com/coder/coder/enterprise/trialer"
"github.com/coder/coder/tailnet"
agpl "github.com/coder/coder/cli"
@ -57,6 +58,8 @@ func server() *cobra.Command {
)
}
options.TrialGenerator = trialer.New(options.Database, "https://v2-licensor.coder.com/trial", coderd.Keys)
o := &coderd.Options{
AuditLogging: options.DeploymentConfig.AuditLogging.Value,
BrowserOnly: options.DeploymentConfig.BrowserOnly.Value,

View File

@ -54,7 +54,7 @@ func Entitlements(
// Here we loop through licenses to detect enabled features.
for _, l := range licenses {
claims, err := validateDBLicense(l, keys)
claims, err := ParseClaims(l.JWT, keys)
if err != nil {
logger.Debug(ctx, "skipping invalid license",
slog.F("id", l.ID), slog.Error(err))
@ -270,8 +270,8 @@ type Claims struct {
Features Features `json:"features"`
}
// Parse consumes a license and returns the claims.
func Parse(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) {
// ParseRaw consumes a license and returns the claims.
func ParseRaw(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) {
tok, err := jwt.Parse(
l,
keyFunc(keys),
@ -293,11 +293,11 @@ func Parse(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error) {
return nil, xerrors.New("unable to parse Claims")
}
// validateDBLicense validates a database.License record, and if valid, returns the claims. If
// ParseClaims validates a database.License record, and if valid, returns the claims. If
// unparsable or invalid, it returns an error
func validateDBLicense(l database.License, keys map[string]ed25519.PublicKey) (*Claims, error) {
func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
tok, err := jwt.ParseWithClaims(
l.JWT,
rawJWT,
&Claims{},
keyFunc(keys),
jwt.WithValidMethods(ValidMethods),

View File

@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
@ -59,7 +60,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
return
}
claims, err := license.Parse(addLicense.License, api.Keys)
rawClaims, err := license.ParseRaw(addLicense.License, api.Keys)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",
@ -67,7 +68,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
})
return
}
exp, ok := claims["exp"].(float64)
exp, ok := rawClaims["exp"].(float64)
if !ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",
@ -77,10 +78,24 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
}
expTime := time.Unix(int64(exp), 0)
claims, err := license.ParseClaims(addLicense.License, api.Keys)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid license",
Detail: err.Error(),
})
return
}
id, err := uuid.Parse(claims.ID)
dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{
UploadedAt: database.Now(),
JWT: addLicense.License,
Exp: expTime,
Uuid: uuid.NullUUID{
UUID: id,
Valid: err == nil,
},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@ -103,7 +118,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
// don't fail the HTTP request, since we did write it successfully to the database
}
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, claims))
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims))
}
func (api *API) licenses(rw http.ResponseWriter, r *http.Request) {
@ -189,6 +204,7 @@ func (api *API) deleteLicense(rw http.ResponseWriter, r *http.Request) {
func convertLicense(dl database.License, c jwt.MapClaims) codersdk.License {
return codersdk.License{
ID: dl.ID,
UUID: dl.Uuid.UUID,
UploadedAt: dl.UploadedAt,
Claims: c,
}

View File

@ -0,0 +1,80 @@
package trialer
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"io"
"net/http"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/google/uuid"
)
type request struct {
DeploymentID string `json:"deployment_id"`
Email string `json:"email"`
}
// 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, email string) error {
return func(ctx context.Context, email string) error {
deploymentID, err := db.GetDeploymentID(ctx)
if err != nil {
return xerrors.Errorf("get deployment id: %w", err)
}
data, err := json.Marshal(request{
DeploymentID: deploymentID,
Email: email,
})
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()
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)
claims, err := license.ParseClaims(string(raw), keys)
if err != nil {
return xerrors.Errorf("parse claims: %w", err)
}
id, err := uuid.Parse(claims.ID)
_, err = db.InsertLicense(ctx, database.InsertLicenseParams{
UploadedAt: database.Now(),
JWT: string(raw),
Exp: expTime,
Uuid: uuid.NullUUID{
UUID: id,
Valid: err == nil,
},
})
if err != nil {
return xerrors.Errorf("insert license: %w", err)
}
return nil
}
}

View File

@ -0,0 +1,34 @@
package trialer_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database/databasefake"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/enterprise/trialer"
)
func TestTrialer(t *testing.T) {
t.Parallel()
license := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Trial: true,
})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(license))
}))
defer srv.Close()
db := databasefake.New()
gen := trialer.New(db, srv.URL, coderdenttest.Keys)
err := gen(context.Background(), "kyle@coder.com")
require.NoError(t, err)
licenses, err := db.GetLicenses(context.Background())
require.NoError(t, err)
require.Len(t, licenses, 1)
}

View File

@ -143,7 +143,7 @@ export interface CreateFirstUserRequest {
readonly email: string
readonly username: string
readonly password: string
readonly organization: string
readonly trial: boolean
}
// From codersdk/users.go
@ -396,6 +396,7 @@ export interface Healthcheck {
// From codersdk/licenses.go
export interface License {
readonly id: number
readonly uuid: string
readonly uploaded_at: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO explain why this is needed
readonly claims: Record<string, any>

View File

@ -12,20 +12,14 @@ const fillForm = async ({
username = "someuser",
email = "someone@coder.com",
password = "password",
organization = "Coder",
}: {
username?: string
email?: string
password?: string
organization?: string
} = {}) => {
const usernameField = screen.getByLabelText(PageViewLanguage.usernameLabel)
const emailField = screen.getByLabelText(PageViewLanguage.emailLabel)
const passwordField = screen.getByLabelText(PageViewLanguage.passwordLabel)
const organizationField = screen.getByLabelText(
PageViewLanguage.organizationLabel,
)
await userEvent.type(organizationField, organization)
await userEvent.type(usernameField, username)
await userEvent.type(emailField, email)
await userEvent.type(passwordField, password)

View File

@ -1,5 +1,9 @@
import Box from "@material-ui/core/Box"
import Checkbox from "@material-ui/core/Checkbox"
import FormHelperText from "@material-ui/core/FormHelperText"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Typography from "@material-ui/core/Typography"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { SignInLayout } from "components/SignInLayout/SignInLayout"
import { Stack } from "components/Stack/Stack"
@ -13,17 +17,11 @@ export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
usernameLabel: "Username",
organizationLabel: "Organization name",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
passwordRequired: "Please enter a password.",
organizationRequired: "Please enter an organization name.",
create: "Setup account",
welcomeMessage: (
<>
Set up <strong>your account</strong>
</>
),
welcomeMessage: <>Welcome to Coder</>,
}
const validationSchema = Yup.object({
@ -32,7 +30,6 @@ const validationSchema = Yup.object({
.email(Language.emailInvalid)
.required(Language.emailRequired),
password: Yup.string().required(Language.passwordRequired),
organization: Yup.string().required(Language.organizationRequired),
username: nameValidator(Language.usernameLabel),
})
@ -55,7 +52,7 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
email: "",
password: "",
username: "",
organization: "",
trial: true,
},
validationSchema,
onSubmit,
@ -64,20 +61,13 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
form,
formErrors,
)
const styles = useStyles()
return (
<SignInLayout>
<Welcome message={Language.welcomeMessage} />
<form onSubmit={form.handleSubmit}>
<Stack>
<TextField
{...getFieldHelpers("organization")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={Language.organizationLabel}
variant="outlined"
/>
<TextField
{...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)}
@ -106,6 +96,29 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
{genericError && (
<FormHelperText error>{genericError}</FormHelperText>
)}
<div className={styles.callout}>
<Box display="flex">
<div>
<Checkbox
id="trial"
name="trial"
defaultChecked
value={form.values.trial}
onChange={form.handleChange}
/>
</div>
<Box>
<Typography variant="h6" style={{ fontSize: 14 }}>
Start a 30-day free trial of Enterprise
</Typography>
<Typography variant="caption" color="textSecondary">
Get access to high availability, template RBAC, audit logging,
quotas, and more.
</Typography>
</Box>
</Box>
</div>
<LoadingButton
fullWidth
variant="contained"
@ -119,3 +132,9 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
</SignInLayout>
)
}
const useStyles = makeStyles(() => ({
callout: {
borderRadius: 16,
},
}))