From f9eca5a5f201a162b09fc3978c26f90925e88242 Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 9 Jan 2024 22:07:47 +0000 Subject: [PATCH 1/2] feat: add additional fields to first time setup trial flow --- .gitattributes | 1 + .github/workflows/typos.toml | 3 +- coderd/apidoc/docs.go | 29 + coderd/apidoc/swagger.json | 29 + coderd/coderd.go | 2 +- coderd/coderdtest/coderdtest.go | 2 +- coderd/externalauth/externalauth.go | 2 +- coderd/httpapi/websocket.go | 3 +- coderd/promoauth/github.go | 5 +- coderd/users.go | 11 +- coderd/users_test.go | 2 +- codersdk/users.go | 35 +- docs/api/schemas.md | 48 +- docs/api/users.md | 9 + enterprise/trialer/trialer.go | 16 +- enterprise/trialer/trialer_test.go | 3 +- go.mod | 2 +- site/src/api/typesGenerated.ts | 12 + site/src/pages/SetupPage/SetupPageView.tsx | 155 ++++ site/src/pages/SetupPage/countries.tsx | 998 +++++++++++++++++++++ site/src/theme/mui.ts | 2 +- 21 files changed, 1336 insertions(+), 33 deletions(-) create mode 100644 site/src/pages/SetupPage/countries.tsx diff --git a/.gitattributes b/.gitattributes index bad79cf54d329..d19626bd6d743 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,4 @@ provisionersdk/proto/*.go linguist-generated=true *.tfstate.dot linguist-generated=true *.tfplan.dot linguist-generated=true site/src/api/typesGenerated.ts linguist-generated=true +site/src/pages/SetupPage/countries.tsx linguist-generated=true diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 23043a35e1ad2..57d1b596ede18 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -14,7 +14,7 @@ darcula = "darcula" Hashi = "Hashi" trialer = "trialer" encrypter = "encrypter" -hel = "hel" # as in helsinki +hel = "hel" # as in helsinki [files] extend-exclude = [ @@ -31,4 +31,5 @@ extend-exclude = [ "**/*.test.tsx", "**/pnpm-lock.yaml", "tailnet/testdata/**", + "site/src/pages/SetupPage/countries.tsx", ] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b3f90cfa06b09..c377e13fbde23 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8261,6 +8261,9 @@ const docTemplate = `{ "trial": { "type": "boolean" }, + "trial_info": { + "$ref": "#/definitions/codersdk.CreateFirstUserTrialInfo" + }, "username": { "type": "string" } @@ -8279,6 +8282,32 @@ const docTemplate = `{ } } }, + "codersdk.CreateFirstUserTrialInfo": { + "type": "object", + "properties": { + "company_name": { + "type": "string" + }, + "country": { + "type": "string" + }, + "developers": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "job_title": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + } + } + }, "codersdk.CreateGroupRequest": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d517185ec2218..ffabf4fd6fd8d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7353,6 +7353,9 @@ "trial": { "type": "boolean" }, + "trial_info": { + "$ref": "#/definitions/codersdk.CreateFirstUserTrialInfo" + }, "username": { "type": "string" } @@ -7371,6 +7374,32 @@ } } }, + "codersdk.CreateFirstUserTrialInfo": { + "type": "object", + "properties": { + "company_name": { + "type": "string" + }, + "country": { + "type": "string" + }, + "developers": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "job_title": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + } + } + }, "codersdk.CreateGroupRequest": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 3e04e6a7dbd88..e5b7171b1d332 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -123,7 +123,7 @@ type Options struct { TracerProvider trace.TracerProvider ExternalAuthConfigs []*externalauth.Config RealIPConfig *httpmw.RealIPConfig - TrialGenerator func(ctx context.Context, email string) error + TrialGenerator func(ctx context.Context, body codersdk.LicensorTrialRequest) error // TLSCertificates is used to mesh DERP servers securely. TLSCertificates []tls.Certificate TailnetCoordinator tailnet.Coordinator diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 33184aede9aba..203380b5f3293 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -107,7 +107,7 @@ type Options struct { Auditor audit.Auditor TLSCertificates []tls.Certificate ExternalAuthConfigs []*externalauth.Config - TrialGenerator func(context.Context, string) error + TrialGenerator func(ctx context.Context, body codersdk.LicensorTrialRequest) error TemplateScheduleStore schedule.TemplateScheduleStore Coordinator tailnet.Coordinator diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index 5472025d93291..e73e35259a9ad 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -325,7 +325,7 @@ func (c *DeviceAuth) AuthorizeDevice(ctx context.Context) (*codersdk.ExternalAut // return a better error. switch resp.StatusCode { case http.StatusTooManyRequests: - return nil, fmt.Errorf("rate limit hit, unable to authorize device. please try again later") + return nil, xerrors.New("rate limit hit, unable to authorize device. please try again later") default: return nil, err } diff --git a/coderd/httpapi/websocket.go b/coderd/httpapi/websocket.go index ad3b4b277dff4..629dcac8131f3 100644 --- a/coderd/httpapi/websocket.go +++ b/coderd/httpapi/websocket.go @@ -4,8 +4,9 @@ import ( "context" "time" - "cdr.dev/slog" "nhooyr.io/websocket" + + "cdr.dev/slog" ) // Heartbeat loops to ping a WebSocket to keep it alive. diff --git a/coderd/promoauth/github.go b/coderd/promoauth/github.go index 7acbdb725592c..3f2a97d241b7f 100644 --- a/coderd/promoauth/github.go +++ b/coderd/promoauth/github.go @@ -1,10 +1,11 @@ package promoauth import ( - "fmt" "net/http" "strconv" "time" + + "golang.org/x/xerrors" ) type rateLimits struct { @@ -81,7 +82,7 @@ func (p *headerParser) string(key string) string { v := p.header.Get(key) if v == "" { - p.errors[key] = fmt.Errorf("missing header %q", key) + p.errors[key] = xerrors.Errorf("missing header %q", key) } return v } diff --git a/coderd/users.go b/coderd/users.go index 4cfa7e7ead877..ad7993db68967 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -152,7 +152,16 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { } if createUser.Trial && api.TrialGenerator != nil { - err = api.TrialGenerator(ctx, createUser.Email) + err = api.TrialGenerator(ctx, codersdk.LicensorTrialRequest{ + Email: createUser.Email, + FirstName: createUser.TrialInfo.FirstName, + LastName: createUser.TrialInfo.LastName, + PhoneNumber: createUser.TrialInfo.PhoneNumber, + JobTitle: createUser.TrialInfo.JobTitle, + CompanyName: createUser.TrialInfo.CompanyName, + Country: createUser.TrialInfo.CompanyName, + Developers: createUser.TrialInfo.Developers, + }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to generate trial", diff --git a/coderd/users_test.go b/coderd/users_test.go index 8cbd69308e61f..d0f8c36484843 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -76,7 +76,7 @@ func TestFirstUser(t *testing.T) { t.Parallel() called := make(chan struct{}) client := coderdtest.New(t, &coderdtest.Options{ - TrialGenerator: func(ctx context.Context, s string) error { + TrialGenerator: func(context.Context, codersdk.LicensorTrialRequest) error { close(called) return nil }, diff --git a/codersdk/users.go b/codersdk/users.go index 7b6492c811b25..1828ef706468f 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -63,11 +63,38 @@ type GetUsersResponse struct { Count int `json:"count"` } +// @typescript-ignore LicensorTrialRequest +type LicensorTrialRequest struct { + DeploymentID string `json:"deployment_id"` + Email string `json:"email"` + Source string `json:"source"` + + // Personal details. + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber string `json:"phone_number"` + JobTitle string `json:"job_title"` + CompanyName string `json:"company_name"` + Country string `json:"country"` + Developers string `json:"developers"` +} + type CreateFirstUserRequest struct { - Email string `json:"email" validate:"required,email"` - Username string `json:"username" validate:"required,username"` - Password string `json:"password" validate:"required"` - Trial bool `json:"trial"` + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required,username"` + Password string `json:"password" validate:"required"` + Trial bool `json:"trial"` + TrialInfo CreateFirstUserTrialInfo `json:"trial_info"` +} + +type CreateFirstUserTrialInfo struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber string `json:"phone_number"` + JobTitle string `json:"job_title"` + CompanyName string `json:"company_name"` + Country string `json:"country"` + Developers string `json:"developers"` } // CreateFirstUserResponse contains IDs for newly created user info. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index a93f5cfc1d9ba..5e3372b49ea09 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1511,18 +1511,28 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "email": "string", "password": "string", "trial": true, + "trial_info": { + "company_name": "string", + "country": "string", + "developers": "string", + "first_name": "string", + "job_title": "string", + "last_name": "string", + "phone_number": "string" + }, "username": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | ------- | -------- | ------------ | ----------- | -| `email` | string | true | | | -| `password` | string | true | | | -| `trial` | boolean | false | | | -| `username` | string | true | | | +| Name | Type | Required | Restrictions | Description | +| ------------ | ---------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `email` | string | true | | | +| `password` | string | true | | | +| `trial` | boolean | false | | | +| `trial_info` | [codersdk.CreateFirstUserTrialInfo](#codersdkcreatefirstusertrialinfo) | false | | | +| `username` | string | true | | | ## codersdk.CreateFirstUserResponse @@ -1540,6 +1550,32 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `organization_id` | string | false | | | | `user_id` | string | false | | | +## codersdk.CreateFirstUserTrialInfo + +```json +{ + "company_name": "string", + "country": "string", + "developers": "string", + "first_name": "string", + "job_title": "string", + "last_name": "string", + "phone_number": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `company_name` | string | false | | | +| `country` | string | false | | | +| `developers` | string | false | | | +| `first_name` | string | false | | | +| `job_title` | string | false | | | +| `last_name` | string | false | | | +| `phone_number` | string | false | | | + ## codersdk.CreateGroupRequest ```json diff --git a/docs/api/users.md b/docs/api/users.md index 13ffd813c5545..68d31497f40e0 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -226,6 +226,15 @@ curl -X POST http://coder-server:8080/api/v2/users/first \ "email": "string", "password": "string", "trial": true, + "trial_info": { + "company_name": "string", + "country": "string", + "developers": "string", + "first_name": "string", + "job_title": "string", + "last_name": "string", + "phone_number": "string" + }, "username": "string" } ``` diff --git a/enterprise/trialer/trialer.go b/enterprise/trialer/trialer.go index e143225b886cb..fd846df58db61 100644 --- a/enterprise/trialer/trialer.go +++ b/enterprise/trialer/trialer.go @@ -14,25 +14,19 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/license" ) -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 { +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) } - data, err := json.Marshal(request{ - DeploymentID: deploymentID, - Email: email, - }) + body.DeploymentID = deploymentID + data, err := json.Marshal(body) if err != nil { return xerrors.Errorf("marshal: %w", err) } diff --git a/enterprise/trialer/trialer_test.go b/enterprise/trialer/trialer_test.go index 22a9eeaca31a0..7149044a3e89f 100644 --- a/enterprise/trialer/trialer_test.go +++ b/enterprise/trialer/trialer_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/trialer" ) @@ -26,7 +27,7 @@ func TestTrialer(t *testing.T) { db := dbmem.New() gen := trialer.New(db, srv.URL, coderdenttest.Keys) - err := gen(context.Background(), "kyle@coder.com") + err := gen(context.Background(), codersdk.LicensorTrialRequest{Email: "kyle+colin@coder.com"}) require.NoError(t, err) licenses, err := db.GetLicenses(context.Background()) require.NoError(t, err) diff --git a/go.mod b/go.mod index 1fb18fc4b0195..965ef534b3822 100644 --- a/go.mod +++ b/go.mod @@ -206,7 +206,7 @@ require ( require go.uber.org/mock v0.4.0 -require github.com/benbjohnson/clock v1.3.5 // indirect +require github.com/benbjohnson/clock v1.3.5 require ( cloud.google.com/go/compute v1.23.3 // indirect diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index be677e07d58d7..2b6fcdcd6060e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -183,6 +183,7 @@ export interface CreateFirstUserRequest { readonly username: string; readonly password: string; readonly trial: boolean; + readonly trial_info: CreateFirstUserTrialInfo; } // From codersdk/users.go @@ -191,6 +192,17 @@ export interface CreateFirstUserResponse { readonly organization_id: string; } +// From codersdk/users.go +export interface CreateFirstUserTrialInfo { + 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/groups.go export interface CreateGroupRequest { readonly name: string; diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index 1059fb34e155f..bfd4a3937d418 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -15,6 +15,10 @@ import { docs } from "utils/docs"; import { SignInLayout } from "components/SignInLayout/SignInLayout"; import { FormFields, VerticalForm } from "components/Form/Form"; import { CoderIcon } from "components/Icons/CoderIcon"; +import MenuItem from "@mui/material/MenuItem"; +import { countries } from "./countries"; +import Autocomplete from "@mui/material/Autocomplete"; +import { Stack } from "components/Stack/Stack"; export const Language = { emailLabel: "Email", @@ -25,6 +29,20 @@ export const Language = { passwordRequired: "Please enter a password.", create: "Create account", welcomeMessage: <>Welcome to Coder, + + firstNameLabel: "First name", + lastNameLabel: "Last name", + companyLabel: "Company", + jobTitleLabel: "Job title", + phoneNumberLabel: "Phone number", + countryLabel: "Country", + developersLabel: "Number of developers", + firstNameRequired: "Please enter your first name.", + phoneNumberRequired: "Please enter your phone number.", + jobTitleRequired: "Please enter your job title.", + companyNameRequired: "Please enter your company name.", + countryRequired: "Please select your country.", + developersRequired: "Please select the number of developers in your company.", }; const validationSchema = Yup.object({ @@ -34,8 +52,30 @@ const validationSchema = Yup.object({ .required(Language.emailRequired), password: Yup.string().required(Language.passwordRequired), username: nameValidator(Language.usernameLabel), + trial: Yup.bool(), + trial_info: Yup.object().when("trial", { + is: true, + then: (schema) => + schema.shape({ + first_name: Yup.string().required(Language.firstNameRequired), + last_name: Yup.string().required(Language.firstNameRequired), + phone_number: Yup.string().required(Language.phoneNumberRequired), + job_title: Yup.string().required(Language.jobTitleRequired), + company_name: Yup.string().required(Language.companyNameRequired), + country: Yup.string().required(Language.countryRequired), + developers: Yup.string().required(Language.developersRequired), + }), + }), }); +const numberOfDevelopersOptions = [ + "1-100", + "101-500", + "501-1000", + "1001-2500", + "2500+", +]; + export interface SetupPageViewProps { onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void; error?: unknown; @@ -54,6 +94,15 @@ export const SetupPageView: FC = ({ password: "", username: "", trial: false, + trial_info: { + first_name: "", + last_name: "", + phone_number: "", + job_title: "", + company_name: "", + country: "", + developers: "", + }, }, validationSchema, onSubmit, @@ -161,6 +210,112 @@ export const SetupPageView: FC = ({ + {form.values.trial && ( + <> + + + + + + + + ( +
  • {`${country.flag} ${country.name}`}
  • + )} + getOptionLabel={(option) => option.name} + onChange={(_, newValue) => + form.setFieldValue("trial_info.country", newValue?.name) + } + css={{ + "&:not(:has(label))": { + margin: 0, + }, + }} + renderInput={(params) => ( + + )} + /> + + {numberOfDevelopersOptions.map((opt) => ( + + {opt} + + ))} + +
    ({ + color: theme.palette.text.secondary, + fontSize: 11, + textAlign: "center", + marginTop: -5, + lineHeight: 1.5, + })} + > + Complete the form to receive your trial license and be contacted + about Coder products and solutions. The information you provide + will be treated in accordance with the{" "} + + Coder Privacy Policy + + . Opt-out at any time. +
    + + )} + Date: Fri, 12 Jan 2024 19:18:40 +0000 Subject: [PATCH 2/2] trial generator typo --- coderd/users.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/users.go b/coderd/users.go index ad7993db68967..50ebf11fa5d99 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -159,7 +159,7 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) { PhoneNumber: createUser.TrialInfo.PhoneNumber, JobTitle: createUser.TrialInfo.JobTitle, CompanyName: createUser.TrialInfo.CompanyName, - Country: createUser.TrialInfo.CompanyName, + Country: createUser.TrialInfo.Country, Developers: createUser.TrialInfo.Developers, }) if err != nil {