diff --git a/.gitattributes b/.gitattributes index bad79cf54d..d19626bd6d 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 23043a35e1..57d1b596ed 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 bb29511d86..1cc435c909 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 8ccff51017..f39e0bf9e4 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 91ba398075..1f8a73a077 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 a8472c745d..0949e39816 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/users.go b/coderd/users.go index 4cfa7e7ead..50ebf11fa5 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.Country, + 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 8cbd69308e..d0f8c36484 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 7b6492c811..1828ef7064 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 5d24093728..c04c8dbf87 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 13ffd813c5..68d31497f4 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 e143225b88..fd846df58d 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 22a9eeaca3..7149044a3e 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/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a5a7663d0d..5e41a57ed9 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 1059fb34e1..bfd4a3937d 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. +
    + + )} +