diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b4b60423d0..0a3199d6f9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4142,6 +4142,50 @@ const docTemplate = `{ } } }, + "/users/{user}/autofill-parameters": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get autofill build parameters for user", + "operationId": "get-autofill-build-parameters-for-user", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Template ID", + "name": "template_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserParameter" + } + } + } + } + } + }, "/users/{user}/convert-login": { "post": { "security": [ @@ -10493,6 +10537,7 @@ const docTemplate = `{ "api_key", "user", "user_data", + "user_workspace_build_parameters", "organization_member", "license", "deployment_config", @@ -10518,6 +10563,7 @@ const docTemplate = `{ "ResourceAPIKey", "ResourceUser", "ResourceUserData", + "ResourceUserWorkspaceBuildParameters", "ResourceOrganizationMember", "ResourceLicense", "ResourceDeploymentValues", @@ -11923,6 +11969,17 @@ const docTemplate = `{ } } }, + "codersdk.UserParameter": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 75c0f6b64a..08ed1c781b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3638,6 +3638,46 @@ } } }, + "/users/{user}/autofill-parameters": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Users"], + "summary": "Get autofill build parameters for user", + "operationId": "get-autofill-build-parameters-for-user", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Template ID", + "name": "template_id", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserParameter" + } + } + } + } + } + }, "/users/{user}/convert-login": { "post": { "security": [ @@ -9439,6 +9479,7 @@ "api_key", "user", "user_data", + "user_workspace_build_parameters", "organization_member", "license", "deployment_config", @@ -9464,6 +9505,7 @@ "ResourceAPIKey", "ResourceUser", "ResourceUserData", + "ResourceUserWorkspaceBuildParameters", "ResourceOrganizationMember", "ResourceLicense", "ResourceDeploymentValues", @@ -10810,6 +10852,17 @@ } } }, + "codersdk.UserParameter": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "codersdk.UserQuietHoursScheduleConfig": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 11d931e924..4ab7f040ab 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -821,6 +821,7 @@ func New(options *Options) *API { r.Post("/convert-login", api.postConvertLoginType) r.Delete("/", api.deleteUser) r.Get("/", api.userByName) + r.Get("/autofill-parameters", api.userAutofillParameters) r.Get("/login-type", api.userLoginType) r.Put("/profile", api.putUserProfile) r.Route("/status", func(r chi.Router) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 257e12ebf4..3b460e7766 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1768,6 +1768,17 @@ func (q *querier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([ return q.db.GetUserLinksByUserID(ctx, userID) } +func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { + u, err := q.db.GetUserByID(ctx, params.OwnerID) + if err != nil { + return nil, err + } + if err := q.authorizeContext(ctx, rbac.ActionRead, u.UserWorkspaceBuildParametersObject()); err != nil { + return nil, err + } + return q.db.GetUserWorkspaceBuildParameters(ctx, params) +} + func (q *querier) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) { // This does the filtering in SQL. prep, err := prepareSQLFilter(ctx, q.auth, rbac.ActionRead, rbac.ResourceUser.Type) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 34b3c7ddc0..2441a45348 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1052,6 +1052,17 @@ func (s *MethodTestSuite) TestUser() { UpdatedAt: u.UpdatedAt, }).Asserts(u.UserDataRBACObject(), rbac.ActionUpdate).Returns(u) })) + s.Run("GetUserWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) { + u := dbgen.User(s.T(), db, database.User{}) + check.Args( + database.GetUserWorkspaceBuildParametersParams{ + OwnerID: u.ID, + TemplateID: uuid.UUID{}, + }, + ).Asserts(u.UserWorkspaceBuildParametersObject(), rbac.ActionRead).Returns( + []database.GetUserWorkspaceBuildParametersRow{}, + ) + })) s.Run("UpdateUserAppearanceSettings", s.Subtest(func(db database.Store, check *expects) { u := dbgen.User(s.T(), db, database.User{}) check.Args(database.UpdateUserAppearanceSettingsParams{ diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 80c80547f4..cd2f196a68 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -3797,6 +3797,65 @@ func (q *FakeQuerier) GetUserLinksByUserID(_ context.Context, userID uuid.UUID) return uls, nil } +func (q *FakeQuerier) GetUserWorkspaceBuildParameters(_ context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + userWorkspaceIDs := make(map[uuid.UUID]struct{}) + for _, ws := range q.workspaces { + if ws.OwnerID != params.OwnerID { + continue + } + if ws.TemplateID != params.TemplateID { + continue + } + userWorkspaceIDs[ws.ID] = struct{}{} + } + + userWorkspaceBuilds := make(map[uuid.UUID]struct{}) + for _, wb := range q.workspaceBuilds { + if _, ok := userWorkspaceIDs[wb.WorkspaceID]; !ok { + continue + } + userWorkspaceBuilds[wb.ID] = struct{}{} + } + + templateVersions := make(map[uuid.UUID]struct{}) + for _, tv := range q.templateVersions { + if tv.TemplateID.UUID != params.TemplateID { + continue + } + templateVersions[tv.ID] = struct{}{} + } + + tvps := make(map[string]struct{}) + for _, tvp := range q.templateVersionParameters { + if _, ok := templateVersions[tvp.TemplateVersionID]; !ok { + continue + } + + if _, ok := tvps[tvp.Name]; !ok && !tvp.Ephemeral { + tvps[tvp.Name] = struct{}{} + } + } + + userWorkspaceBuildParameters := make(map[string]database.GetUserWorkspaceBuildParametersRow) + for _, wbp := range q.workspaceBuildParameters { + if _, ok := userWorkspaceBuilds[wbp.WorkspaceBuildID]; !ok { + continue + } + if _, ok := tvps[wbp.Name]; !ok { + continue + } + userWorkspaceBuildParameters[wbp.Name] = database.GetUserWorkspaceBuildParametersRow{ + Name: wbp.Name, + Value: wbp.Value, + } + } + + return maps.Values(userWorkspaceBuildParameters), nil +} + func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams) ([]database.GetUsersRow, error) { if err := validateDatabaseType(params); err != nil { return nil, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index d53bc484ef..433a1202cc 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1012,6 +1012,13 @@ func (m metricsStore) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID return r0, r1 } +func (m metricsStore) GetUserWorkspaceBuildParameters(ctx context.Context, ownerID database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { + start := time.Now() + r0, r1 := m.s.GetUserWorkspaceBuildParameters(ctx, ownerID) + m.queryLatencies.WithLabelValues("GetUserWorkspaceBuildParameters").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) { start := time.Now() users, err := m.s.GetUsers(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index ebc171a09e..6c2ae8b942 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2104,6 +2104,21 @@ func (mr *MockStoreMockRecorder) GetUserLinksByUserID(arg0, arg1 any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLinksByUserID", reflect.TypeOf((*MockStore)(nil).GetUserLinksByUserID), arg0, arg1) } +// GetUserWorkspaceBuildParameters mocks base method. +func (m *MockStore) GetUserWorkspaceBuildParameters(arg0 context.Context, arg1 database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserWorkspaceBuildParameters", arg0, arg1) + ret0, _ := ret[0].([]database.GetUserWorkspaceBuildParametersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserWorkspaceBuildParameters indicates an expected call of GetUserWorkspaceBuildParameters. +func (mr *MockStoreMockRecorder) GetUserWorkspaceBuildParameters(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserWorkspaceBuildParameters", reflect.TypeOf((*MockStore)(nil).GetUserWorkspaceBuildParameters), arg0, arg1) +} + // GetUsers mocks base method. func (m *MockStore) GetUsers(arg0 context.Context, arg1 database.GetUsersParams) ([]database.GetUsersRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index beaac600a6..24a7e0f0e6 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -256,6 +256,10 @@ func (u User) UserDataRBACObject() rbac.Object { return rbac.ResourceUserData.WithID(u.ID).WithOwner(u.ID.String()) } +func (u User) UserWorkspaceBuildParametersObject() rbac.Object { + return rbac.ResourceUserWorkspaceBuildParameters.WithID(u.ID).WithOwner(u.ID.String()) +} + func (u GetUsersRow) RBACObject() rbac.Object { return rbac.ResourceUserObject(u.ID) } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index dc53934227..7d9ec07649 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -211,6 +211,7 @@ type sqlcQuerier interface { GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error) + GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) // This will never return deleted users. GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error) // This shouldn't check for deleted, because it's frequently used diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e3725ebdf5..9a4fa07ec5 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9939,6 +9939,62 @@ func (q *sqlQuerier) InsertWorkspaceAppStats(ctx context.Context, arg InsertWork return err } +const getUserWorkspaceBuildParameters = `-- name: GetUserWorkspaceBuildParameters :many +SELECT DISTINCT ON (tvp.name) + tvp.name, + wbp.value +FROM + workspace_build_parameters wbp +JOIN + workspace_builds wb ON wb.id = wbp.workspace_build_id +JOIN + workspaces w ON w.id = wb.workspace_id +JOIN + template_version_parameters tvp ON tvp.template_version_id = wb.template_version_id +WHERE + w.owner_id = $1 + AND wb.transition = 'start' + AND w.template_id = $2 + AND tvp.ephemeral = false + AND tvp.name = wbp.name +ORDER BY + tvp.name, wb.created_at DESC +LIMIT 100 +` + +type GetUserWorkspaceBuildParametersParams struct { + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` +} + +type GetUserWorkspaceBuildParametersRow struct { + Name string `db:"name" json:"name"` + Value string `db:"value" json:"value"` +} + +func (q *sqlQuerier) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) { + rows, err := q.db.QueryContext(ctx, getUserWorkspaceBuildParameters, arg.OwnerID, arg.TemplateID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserWorkspaceBuildParametersRow + for rows.Next() { + var i GetUserWorkspaceBuildParametersRow + if err := rows.Scan(&i.Name, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceBuildParameters = `-- name: GetWorkspaceBuildParameters :many SELECT workspace_build_id, name, value diff --git a/coderd/database/queries/workspacebuildparameters.sql b/coderd/database/queries/workspacebuildparameters.sql index 3b90673da7..76bbbae1f3 100644 --- a/coderd/database/queries/workspacebuildparameters.sql +++ b/coderd/database/queries/workspacebuildparameters.sql @@ -14,3 +14,27 @@ FROM workspace_build_parameters WHERE workspace_build_id = $1; + +-- name: GetUserWorkspaceBuildParameters :many +-- name: GetUserWorkspaceBuildParameters :many +SELECT DISTINCT ON (tvp.name) + tvp.name, + wbp.value +FROM + workspace_build_parameters wbp +JOIN + workspace_builds wb ON wb.id = wbp.workspace_build_id +JOIN + workspaces w ON w.id = wb.workspace_id +JOIN + template_version_parameters tvp ON tvp.template_version_id = wb.template_version_id +WHERE + w.owner_id = $1 + AND wb.transition = 'start' + AND w.template_id = $2 + AND tvp.ephemeral = false + AND tvp.name = wbp.name +ORDER BY + tvp.name, wb.created_at DESC +LIMIT 100; + diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 373c5b6b2d..ace060b314 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -148,6 +148,12 @@ var ( Type: "user_data", } + // ResourceUserWorkspaceBuildParameters is the user's workspace build + // parameter history. + ResourceUserWorkspaceBuildParameters = Object{ + Type: "user_workspace_build_parameters", + } + // ResourceOrganizationMember is a user's membership in an organization. // Has ONLY an organization owner. // create/delete = Create/delete member from org. diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index aadf3fa1ed..4668f56b06 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -25,6 +25,7 @@ func AllResources() []Object { ResourceTemplateInsights, ResourceUser, ResourceUserData, + ResourceUserWorkspaceBuildParameters, ResourceWildcard, ResourceWorkspace, ResourceWorkspaceApplicationConnect, diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 7f8e0b2759..d6a53d5b9b 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -154,7 +154,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Permissions(map[string][]Action{ // Users cannot do create/update/delete on themselves, but they // can read their own details. - ResourceUser.Type: {ActionRead}, + ResourceUser.Type: {ActionRead}, + ResourceUserWorkspaceBuildParameters.Type: {ActionRead}, // Users can create provisioner daemons scoped to themselves. ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate}, })..., @@ -209,9 +210,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) { Name: userAdmin, DisplayName: "User Admin", Site: Permissions(map[string][]Action{ - ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceUserData.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceUserData.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceUserWorkspaceBuildParameters.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, // Full perms to manage org members ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, ResourceGroup.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, diff --git a/coderd/users.go b/coderd/users.go index 43e00f8112..ca757ed804 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -569,6 +569,57 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(user, organizationIDs)) } +// Returns recent build parameters for the signed-in user. +// +// @Summary Get autofill build parameters for user +// @ID get-autofill-build-parameters-for-user +// @Security CoderSessionToken +// @Produce json +// @Tags Users +// @Param user path string true "User ID, username, or me" +// @Param template_id query string true "Template ID" +// @Success 200 {array} codersdk.UserParameter +// @Router /users/{user}/autofill-parameters [get] +func (api *API) userAutofillParameters(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + + p := httpapi.NewQueryParamParser().Required("template_id") + templateID := p.UUID(r.URL.Query(), uuid.UUID{}, "template_id") + p.ErrorExcessParams(r.URL.Query()) + if len(p.Errors) > 0 { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid query parameters.", + Validations: p.Errors, + }) + return + } + + params, err := api.Database.GetUserWorkspaceBuildParameters( + r.Context(), + database.GetUserWorkspaceBuildParametersParams{ + OwnerID: user.ID, + TemplateID: templateID, + }, + ) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user's parameters.", + Detail: err.Error(), + }) + return + } + + sdkParams := []codersdk.UserParameter{} + for _, param := range params { + sdkParams = append(sdkParams, codersdk.UserParameter{ + Name: param.Name, + Value: param.Value, + }) + } + + httpapi.Write(r.Context(), rw, http.StatusOK, sdkParams) +} + // Returns the user's login type. This only works if the api key for authorization // and the requested user match. Eg: 'me' // diff --git a/coderd/users_test.go b/coderd/users_test.go index c73bd3014d..a9b4fa8de7 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" @@ -1721,6 +1722,129 @@ func TestSuspendedPagination(t *testing.T) { require.Equal(t, expected, page.Users, "expected page") } +func TestUserAutofillParameters(t *testing.T) { + t.Parallel() + t.Run("NotSelf", func(t *testing.T) { + t.Parallel() + client1, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + + u1 := coderdtest.CreateFirstUser(t, client1) + + client2, u2 := coderdtest.CreateAnotherUser(t, client1, u1.OrganizationID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + db := api.Database + + version := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + CreatedBy: u1.UserID, + OrganizationID: u1.OrganizationID, + }).Params(database.TemplateVersionParameter{ + Name: "param", + Required: true, + }).Do() + + _, err := client2.UserAutofillParameters( + ctx, + u1.UserID.String(), + version.Template.ID, + ) + + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + + // u1 should be able to read u2's parameters as u1 is site admin. + _, err = client1.UserAutofillParameters( + ctx, + u2.ID.String(), + version.Template.ID, + ) + require.NoError(t, err) + }) + + t.Run("FindsParameters", func(t *testing.T) { + t.Parallel() + client1, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{}) + + u1 := coderdtest.CreateFirstUser(t, client1) + + client2, u2 := coderdtest.CreateAnotherUser(t, client1, u1.OrganizationID) + + db := api.Database + + version := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{ + CreatedBy: u1.UserID, + OrganizationID: u1.OrganizationID, + }).Params(database.TemplateVersionParameter{ + Name: "param", + Required: true, + }, + database.TemplateVersionParameter{ + Name: "param2", + Ephemeral: true, + }, + ).Do() + + dbfake.WorkspaceBuild(t, db, database.Workspace{ + OwnerID: u2.ID, + TemplateID: version.Template.ID, + OrganizationID: u1.OrganizationID, + }).Params( + database.WorkspaceBuildParameter{ + Name: "param", + Value: "foo", + }, + database.WorkspaceBuildParameter{ + Name: "param2", + Value: "bar", + }, + ).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Use client2 since client1 is site admin, so + // we don't get good coverage on RBAC working. + params, err := client2.UserAutofillParameters( + ctx, + u2.ID.String(), + version.Template.ID, + ) + require.NoError(t, err) + + require.Equal(t, 1, len(params)) + + require.Equal(t, "param", params[0].Name) + require.Equal(t, "foo", params[0].Value) + + // Verify that latest parameter value is returned. + dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: u1.OrganizationID, + OwnerID: u2.ID, + TemplateID: version.Template.ID, + }).Params( + database.WorkspaceBuildParameter{ + Name: "param", + Value: "foo_new", + }, + ).Do() + + params, err = client2.UserAutofillParameters( + ctx, + u2.ID.String(), + version.Template.ID, + ) + require.NoError(t, err) + + require.Equal(t, 1, len(params)) + + require.Equal(t, "param", params[0].Name) + require.Equal(t, "foo_new", params[0].Value) + }) +} + // TestPaginatedUsers creates a list of users, then tries to paginate through // them using different page sizes. func TestPaginatedUsers(t *testing.T) { diff --git a/codersdk/rbacresources.go b/codersdk/rbacresources.go index 8854568525..4b517e544e 100644 --- a/codersdk/rbacresources.go +++ b/codersdk/rbacresources.go @@ -3,29 +3,30 @@ package codersdk type RBACResource string const ( - ResourceWorkspace RBACResource = "workspace" - ResourceWorkspaceProxy RBACResource = "workspace_proxy" - ResourceWorkspaceExecution RBACResource = "workspace_execution" - ResourceWorkspaceApplicationConnect RBACResource = "application_connect" - ResourceAuditLog RBACResource = "audit_log" - ResourceTemplate RBACResource = "template" - ResourceGroup RBACResource = "group" - ResourceFile RBACResource = "file" - ResourceProvisionerDaemon RBACResource = "provisioner_daemon" - ResourceOrganization RBACResource = "organization" - ResourceRoleAssignment RBACResource = "assign_role" - ResourceOrgRoleAssignment RBACResource = "assign_org_role" - ResourceAPIKey RBACResource = "api_key" - ResourceUser RBACResource = "user" - ResourceUserData RBACResource = "user_data" - ResourceOrganizationMember RBACResource = "organization_member" - ResourceLicense RBACResource = "license" - ResourceDeploymentValues RBACResource = "deployment_config" - ResourceDeploymentStats RBACResource = "deployment_stats" - ResourceReplicas RBACResource = "replicas" - ResourceDebugInfo RBACResource = "debug_info" - ResourceSystem RBACResource = "system" - ResourceTemplateInsights RBACResource = "template_insights" + ResourceWorkspace RBACResource = "workspace" + ResourceWorkspaceProxy RBACResource = "workspace_proxy" + ResourceWorkspaceExecution RBACResource = "workspace_execution" + ResourceWorkspaceApplicationConnect RBACResource = "application_connect" + ResourceAuditLog RBACResource = "audit_log" + ResourceTemplate RBACResource = "template" + ResourceGroup RBACResource = "group" + ResourceFile RBACResource = "file" + ResourceProvisionerDaemon RBACResource = "provisioner_daemon" + ResourceOrganization RBACResource = "organization" + ResourceRoleAssignment RBACResource = "assign_role" + ResourceOrgRoleAssignment RBACResource = "assign_org_role" + ResourceAPIKey RBACResource = "api_key" + ResourceUser RBACResource = "user" + ResourceUserData RBACResource = "user_data" + ResourceUserWorkspaceBuildParameters RBACResource = "user_workspace_build_parameters" + ResourceOrganizationMember RBACResource = "organization_member" + ResourceLicense RBACResource = "license" + ResourceDeploymentValues RBACResource = "deployment_config" + ResourceDeploymentStats RBACResource = "deployment_stats" + ResourceReplicas RBACResource = "replicas" + ResourceDebugInfo RBACResource = "debug_info" + ResourceSystem RBACResource = "system" + ResourceTemplateInsights RBACResource = "template_insights" ) const ( @@ -52,6 +53,7 @@ var ( ResourceAPIKey, ResourceUser, ResourceUserData, + ResourceUserWorkspaceBuildParameters, ResourceOrganizationMember, ResourceLicense, ResourceDeploymentValues, diff --git a/codersdk/users.go b/codersdk/users.go index a43b197c74..1bd8852cf4 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -221,6 +221,27 @@ type OIDCAuthMethod struct { IconURL string `json:"iconUrl"` } +type UserParameter struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// UserAutofillParameters returns all recently used parameters for the given user. +func (c *Client) UserAutofillParameters(ctx context.Context, user string, templateID uuid.UUID) ([]UserParameter, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/autofill-parameters?template_id=%s", user, templateID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, ReadBodyAsError(res) + } + + var params []UserParameter + return params, json.NewDecoder(res.Body).Decode(¶ms) +} + // HasFirstUser returns whether the first user has been created. func (c *Client) HasFirstUser(ctx context.Context) (bool, error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/first", nil) diff --git a/docs/api/schemas.md b/docs/api/schemas.md index dd5b1d4876..3f2ebc9788 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4213,31 +4213,32 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| --------------------- | -| `workspace` | -| `workspace_proxy` | -| `workspace_execution` | -| `application_connect` | -| `audit_log` | -| `template` | -| `group` | -| `file` | -| `provisioner_daemon` | -| `organization` | -| `assign_role` | -| `assign_org_role` | -| `api_key` | -| `user` | -| `user_data` | -| `organization_member` | -| `license` | -| `deployment_config` | -| `deployment_stats` | -| `replicas` | -| `debug_info` | -| `system` | -| `template_insights` | +| Value | +| --------------------------------- | +| `workspace` | +| `workspace_proxy` | +| `workspace_execution` | +| `application_connect` | +| `audit_log` | +| `template` | +| `group` | +| `file` | +| `provisioner_daemon` | +| `organization` | +| `assign_role` | +| `assign_org_role` | +| `api_key` | +| `user` | +| `user_data` | +| `user_workspace_build_parameters` | +| `organization_member` | +| `license` | +| `deployment_config` | +| `deployment_stats` | +| `replicas` | +| `debug_info` | +| `system` | +| `template_insights` | ## codersdk.RateLimitConfig @@ -5832,6 +5833,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| | ------------ | ---------------------------------------- | -------- | ------------ | ----------- | | `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +## codersdk.UserParameter + +```json +{ + "name": "string", + "value": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------- | ------ | -------- | ------------ | ----------- | +| `name` | string | false | | | +| `value` | string | false | | | + ## codersdk.UserQuietHoursScheduleConfig ```json diff --git a/docs/api/users.md b/docs/api/users.md index 86869d1e8e..e656ed6ac2 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -513,6 +513,57 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/appearance \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get autofill build parameters for user + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/autofill-parameters?template_id=string \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/autofill-parameters` + +### Parameters + +| Name | In | Type | Required | Description | +| ------------- | ----- | ------ | -------- | ------------------------ | +| `user` | path | string | true | User ID, username, or me | +| `template_id` | query | string | true | Template ID | + +### Example responses + +> 200 Response + +```json +[ + { + "name": "string", + "value": "string" + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserParameter](schemas.md#codersdkuserparameter) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | +| `» name` | string | false | | | +| `» value` | string | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get user Git SSH key ### Code samples diff --git a/docs/manifest.json b/docs/manifest.json index abc19e86af..58a3e82cdb 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -177,14 +177,14 @@ "title": "Resource metadata", "description": "Show information in the workspace about template resources", "path": "./templates/resource-metadata.md" - }, - { - "title": "Parameters", - "description": "Prompt the user for additional information about a workspace", - "path": "./templates/parameters.md" } ] }, + { + "title": "Parameters", + "description": "Prompt the user for additional information about a workspace", + "path": "./templates/parameters.md" + }, { "title": "Administering templates", "description": "Configuration settings for template admins", diff --git a/docs/templates/parameters.md b/docs/templates/parameters.md index a417157f25..3dfc181cb4 100644 --- a/docs/templates/parameters.md +++ b/docs/templates/parameters.md @@ -281,3 +281,15 @@ variable "CLOUD_API_KEY" { } ``` + +## Create Autofill + +When the template doesn't specify default values, Coder may still autofill +parameters. + +1. Coder will look for URL query parameters with form `param.=`. + This feature enables platform teams to create pre-filled template creation + links. +2. Coder will populate recently used parameter key-value pairs for the user. + This feature helps reduce repetition when filling common parameters such as + `dotfiles_url` or `region`. diff --git a/site/e2e/constants.ts b/site/e2e/constants.ts index f75af482c7..c7c6a474dc 100644 --- a/site/e2e/constants.ts +++ b/site/e2e/constants.ts @@ -1,7 +1,10 @@ // Default port from the server export const defaultPort = 3000; export const prometheusPort = 2114; -export const pprofPort = 6061; + +// Use alternate ports in case we're running in a Coder Workspace. +export const agentPProfPort = 6061; +export const coderdPProfPort = 6062; // Credentials for the first user export const username = "admin"; diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index 77960b3223..ff3e7206df 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -15,7 +15,7 @@ import { Resource, RichParameter, } from "./provisionerGenerated"; -import { prometheusPort, pprofPort } from "./constants"; +import { prometheusPort, agentPProfPort } from "./constants"; import { port } from "./playwright.config"; import * as ssh from "ssh2"; import { Duplex } from "stream"; @@ -306,7 +306,7 @@ export const startAgentWithCommand = async ( ...process.env, CODER_AGENT_URL: "http://localhost:" + port, CODER_AGENT_TOKEN: token, - CODER_AGENT_PPROF_ADDRESS: "127.0.0.1:" + pprofPort, + CODER_AGENT_PPROF_ADDRESS: "127.0.0.1:" + agentPProfPort, CODER_AGENT_PROMETHEUS_ADDRESS: "127.0.0.1:" + prometheusPort, }, }); diff --git a/site/e2e/parameters.ts b/site/e2e/parameters.ts index c1477fad4c..47048d3468 100644 --- a/site/e2e/parameters.ts +++ b/site/e2e/parameters.ts @@ -128,6 +128,14 @@ export const seventhParameter: RichParameter = { order: 1, }; +// randParamName returns a new parameter with a random name. +// It helps to avoid cross-test interference when user-auto-fill triggers on +// the same parameter name. +export const randParamName = (p: RichParameter): RichParameter => { + const name = p.name + "_" + Math.random().toString(36).substring(7); + return { ...p, name: name }; +}; + // Build options export const firstBuildOption: RichParameter = { diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 78ebf4cc03..dd70350e7f 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from "@playwright/test"; import path from "path"; -import { defaultPort, gitAuth } from "./constants"; +import { defaultPort, coderdPProfPort, gitAuth } from "./constants"; export const port = process.env.CODER_E2E_PORT ? Number(process.env.CODER_E2E_PORT) @@ -103,6 +103,7 @@ export default defineConfig({ gitAuth.webPort, gitAuth.validatePath, ), + CODER_PPROF_ADDRESS: "127.0.0.1:" + coderdPProfPort, }, reuseExistingServer: false, }, diff --git a/site/e2e/reporter.ts b/site/e2e/reporter.ts index 6be742b02b..48a1c27ac1 100644 --- a/site/e2e/reporter.ts +++ b/site/e2e/reporter.ts @@ -119,7 +119,7 @@ const filteredServerLogLines = (chunk: string): string[] => const exportDebugPprof = async (outputFile: string) => { const response = await axios.get( - "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1", + "http://127.0.0.1:6062/debug/pprof/goroutine?debug=1", ); if (response.status !== 200) { throw new Error(`Error: Received status code ${response.status}`); diff --git a/site/e2e/tests/createWorkspace.spec.ts b/site/e2e/tests/createWorkspace.spec.ts index d7cec29a90..5e435340f3 100644 --- a/site/e2e/tests/createWorkspace.spec.ts +++ b/site/e2e/tests/createWorkspace.spec.ts @@ -14,6 +14,7 @@ import { thirdParameter, seventhParameter, sixthParameter, + randParamName, } from "../parameters"; import { RichParameter } from "../provisionerGenerated"; import { beforeCoderTest } from "../hooks"; @@ -101,10 +102,16 @@ test("create workspace with default and required parameters", async ({ }); test("create workspace and overwrite default parameters", async ({ page }) => { - const richParameters: RichParameter[] = [secondParameter, fourthParameter]; + // We use randParamName to prevent the new values from corrupting user_history + // and thus affecting other tests. + const richParameters: RichParameter[] = [ + randParamName(secondParameter), + randParamName(fourthParameter), + ]; + const buildParameters = [ - { name: secondParameter.name, value: "AAAAA" }, - { name: fourthParameter.name, value: "false" }, + { name: richParameters[0].name, value: "AAAAA" }, + { name: richParameters[1].name, value: "false" }, ]; const template = await createTemplate( page, diff --git a/site/e2e/tests/restartWorkspace.spec.ts b/site/e2e/tests/restartWorkspace.spec.ts index 3d8fb704b6..a0710e96fd 100644 --- a/site/e2e/tests/restartWorkspace.spec.ts +++ b/site/e2e/tests/restartWorkspace.spec.ts @@ -23,14 +23,14 @@ test("restart workspace with ephemeral parameters", async ({ page }) => { // Verify that build options are default (not selected). await verifyParameters(page, workspaceName, richParameters, [ - { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, - { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + { name: richParameters[0].name, value: firstBuildOption.defaultValue }, + { name: richParameters[1].name, value: secondBuildOption.defaultValue }, ]); // Now, restart the workspace with ephemeral parameters selected. const buildParameters = [ - { name: firstBuildOption.name, value: "AAAAA" }, - { name: secondBuildOption.name, value: "true" }, + { name: richParameters[0].name, value: "AAAAA" }, + { name: richParameters[1].name, value: "true" }, ]; await buildWorkspaceWithParameters( page, @@ -42,7 +42,7 @@ test("restart workspace with ephemeral parameters", async ({ page }) => { // Verify that build options are default (not selected). await verifyParameters(page, workspaceName, richParameters, [ - { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, - { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + { name: richParameters[0].name, value: firstBuildOption.defaultValue }, + { name: richParameters[1].name, value: secondBuildOption.defaultValue }, ]); }); diff --git a/site/e2e/tests/startWorkspace.spec.ts b/site/e2e/tests/startWorkspace.spec.ts index ec22cda01d..be1cc5a5d7 100644 --- a/site/e2e/tests/startWorkspace.spec.ts +++ b/site/e2e/tests/startWorkspace.spec.ts @@ -21,8 +21,8 @@ test("start workspace with ephemeral parameters", async ({ page }) => { // Verify that build options are default (not selected). await verifyParameters(page, workspaceName, richParameters, [ - { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, - { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + { name: richParameters[0].name, value: firstBuildOption.defaultValue }, + { name: richParameters[1].name, value: secondBuildOption.defaultValue }, ]); // Stop the workspace @@ -30,8 +30,8 @@ test("start workspace with ephemeral parameters", async ({ page }) => { // Now, start the workspace with ephemeral parameters selected. const buildParameters = [ - { name: firstBuildOption.name, value: "AAAAA" }, - { name: secondBuildOption.name, value: "true" }, + { name: richParameters[0].name, value: "AAAAA" }, + { name: richParameters[1].name, value: "true" }, ]; await buildWorkspaceWithParameters( @@ -43,7 +43,7 @@ test("start workspace with ephemeral parameters", async ({ page }) => { // Verify that build options are default (not selected). await verifyParameters(page, workspaceName, richParameters, [ - { name: firstBuildOption.name, value: firstBuildOption.defaultValue }, - { name: secondBuildOption.name, value: secondBuildOption.defaultValue }, + { name: richParameters[0].name, value: firstBuildOption.defaultValue }, + { name: richParameters[1].name, value: secondBuildOption.defaultValue }, ]); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 80c2802b38..f21afbabf9 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -129,6 +129,13 @@ export const getAuthenticatedUser = async () => { return response.data; }; +export const getUserParameters = async (templateID: string) => { + const response = await axios.get( + "/api/v2/users/me/autofill-parameters?template_id=" + templateID, + ); + return response.data; +}; + export const getAuthMethods = async (): Promise => { const response = await axios.get( "/api/v2/users/authmethods", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d8ad438558..501bb2eabc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1435,6 +1435,12 @@ export interface UserLoginType { readonly login_type: LoginType; } +// From codersdk/users.go +export interface UserParameter { + readonly name: string; + readonly value: string; +} + // From codersdk/deployment.go export interface UserQuietHoursScheduleConfig { readonly default_schedule: string; @@ -2011,6 +2017,7 @@ export type RBACResource = | "template_insights" | "user" | "user_data" + | "user_workspace_build_parameters" | "workspace" | "workspace_execution" | "workspace_proxy"; @@ -2035,6 +2042,7 @@ export const RBACResources: RBACResource[] = [ "template_insights", "user", "user_data", + "user_workspace_build_parameters", "workspace", "workspace_execution", "workspace_proxy", diff --git a/site/src/components/RichParameterInput/RichParameterInput.tsx b/site/src/components/RichParameterInput/RichParameterInput.tsx index fe9d961d0d..31f27e4a43 100644 --- a/site/src/components/RichParameterInput/RichParameterInput.tsx +++ b/site/src/components/RichParameterInput/RichParameterInput.tsx @@ -10,6 +10,7 @@ import { MemoizedMarkdown } from "components/Markdown/Markdown"; import { Stack } from "components/Stack/Stack"; import { MultiTextField } from "./MultiTextField"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { AutofillSource } from "utils/richParameters"; import { Pill } from "components/Pill/Pill"; import ErrorOutline from "@mui/icons-material/ErrorOutline"; @@ -167,6 +168,7 @@ export type RichParameterInputProps = Omit< "size" | "onChange" > & { parameter: TemplateVersionParameter; + autofillSource?: AutofillSource; onChange: (value: string) => void; size?: Size; }; @@ -174,6 +176,7 @@ export type RichParameterInputProps = Omit< export const RichParameterInput: FC = ({ parameter, size = "medium", + autofillSource, ...fieldProps }) => { return ( @@ -186,6 +189,17 @@ export const RichParameterInput: FC = ({
+ {autofillSource && autofillSource !== "active_build" && ( +
+ 🪄 Autofilled:{" "} + { + { + ["url"]: "value supplied by URL.", + ["user_history"]: "recently used value.", + }[autofillSource] + } +
+ )}
); diff --git a/site/src/components/TemplateParameters/TemplateParameters.tsx b/site/src/components/TemplateParameters/TemplateParameters.tsx index d01f5b29bf..8f20720856 100644 --- a/site/src/components/TemplateParameters/TemplateParameters.tsx +++ b/site/src/components/TemplateParameters/TemplateParameters.tsx @@ -1,13 +1,15 @@ import { TemplateVersionParameter } from "api/typesGenerated"; -import { FormSection, FormFields } from "components/Form/Form"; +import { FormFields, FormSection } from "components/Form/Form"; import { RichParameterInput, RichParameterInputProps, } from "components/RichParameterInput/RichParameterInput"; import { ComponentProps, FC } from "react"; +import { AutofillSource } from "utils/richParameters"; export type TemplateParametersSectionProps = { templateParameters: TemplateVersionParameter[]; + autofillSources?: Record; getInputProps: ( parameter: TemplateVersionParameter, index: number, @@ -17,6 +19,7 @@ export type TemplateParametersSectionProps = { export const TemplateParametersSection: FC = ({ templateParameters, getInputProps, + autofillSources, ...formSectionProps }) => { const hasMutableParameters = @@ -38,6 +41,9 @@ export const TemplateParametersSection: FC = ({ {...getInputProps(parameter, index)} key={parameter.name} parameter={parameter} + autofillSource={ + autofillSources && autofillSources[parameter.name] + } /> ), )} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index a616c106bf..4dc65ed64c 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -176,7 +176,9 @@ describe("CreateWorkspacePage", () => { "me", expect.objectContaining({ template_id: MockTemplate.id, - rich_parameter_values: [{ name: param, value: paramValue }], + rich_parameter_values: [ + expect.objectContaining({ name: param, value: paramValue }), + ], }), ); }); @@ -201,7 +203,9 @@ describe("CreateWorkspacePage", () => { "me", expect.objectContaining({ template_version_id: MockTemplate.active_version_id, - rich_parameter_values: [{ name: param, value: paramValue }], + rich_parameter_values: [ + expect.objectContaining({ name: param, value: paramValue }), + ], }), ); }); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 297339dd53..036e05548e 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,34 +1,36 @@ -import { type FC, useCallback, useState, useEffect, useMemo } from "react"; -import { Helmet } from "react-helmet-async"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { useMutation, useQuery, useQueryClient } from "react-query"; -import { - uniqueNamesGenerator, - animals, - colors, - NumberDictionary, -} from "unique-names-generator"; -import type { - TemplateVersionParameter, - Workspace, - WorkspaceBuildParameter, -} from "api/typesGenerated"; -import { useMe } from "contexts/auth/useMe"; -import { useOrganizationId } from "contexts/auth/useOrganizationId"; -import { pageTitle } from "utils/page"; +import { getUserParameters } from "api/api"; import { checkAuthorization } from "api/queries/authCheck"; import { + richParameters, templateByName, templateVersionExternalAuth, - richParameters, } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; -import { useEffectEvent } from "hooks/hookPolyfills"; -import { paramsUsedToCreateWorkspace } from "utils/workspace"; -import { Loader } from "components/Loader/Loader"; +import { + TemplateVersionParameter, + UserParameter, + Workspace, +} from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { CreateWSPermissions, createWorkspaceChecks } from "./permissions"; +import { Loader } from "components/Loader/Loader"; +import { useMe } from "contexts/auth/useMe"; +import { useOrganizationId } from "contexts/auth/useOrganizationId"; +import { useEffectEvent } from "hooks/hookPolyfills"; +import { useCallback, useEffect, useMemo, useState, type FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { + NumberDictionary, + animals, + colors, + uniqueNamesGenerator, +} from "unique-names-generator"; +import { pageTitle } from "utils/page"; +import { AutofillBuildParameter } from "utils/richParameters"; +import { paramsUsedToCreateWorkspace } from "utils/workspace"; import { CreateWorkspacePageView } from "./CreateWorkspacePageView"; +import { CreateWSPermissions, createWorkspaceChecks } from "./permissions"; export const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; @@ -41,7 +43,6 @@ const CreateWorkspacePage: FC = () => { const me = useMe(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const defaultBuildParameters = getDefaultBuildParameters(searchParams); const mode = getWorkspaceMode(searchParams); const customVersionId = searchParams.get("version") ?? undefined; @@ -61,6 +62,15 @@ const CreateWorkspacePage: FC = () => { const createWorkspaceMutation = useMutation(createWorkspace(queryClient)); const templateQuery = useQuery(templateByName(organizationId, templateName)); + + const userParametersQuery = useQuery( + ["userParameters"], + () => getUserParameters(templateQuery.data!.id), + { + enabled: templateQuery.isSuccess, + }, + ); + const permissionsQuery = useQuery( checkAuthorization({ checks: createWorkspaceChecks(organizationId), @@ -101,12 +111,17 @@ const CreateWorkspacePage: FC = () => { [navigate], ); + const autofillParameters = getAutofillParameters( + searchParams, + userParametersQuery.data ? userParametersQuery.data : [], + ); + const automateWorkspaceCreation = useEffectEvent(async () => { try { const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({ templateName, organizationId, - defaultBuildParameters, + defaultBuildParameters: autofillParameters, defaultName, versionId: realizedVersionId, }); @@ -139,7 +154,7 @@ const CreateWorkspacePage: FC = () => { mode={mode} defaultName={defaultName} defaultOwner={me} - defaultBuildParameters={defaultBuildParameters} + autofillParameters={autofillParameters} error={createWorkspaceMutation.error} resetMutation={createWorkspaceMutation.reset} template={templateQuery.data!} @@ -223,17 +238,34 @@ const useExternalAuth = (versionId: string | undefined) => { }; }; -const getDefaultBuildParameters = ( +const getAutofillParameters = ( urlSearchParams: URLSearchParams, -): WorkspaceBuildParameter[] => { - const buildValues: WorkspaceBuildParameter[] = []; - Array.from(urlSearchParams.keys()) + userParameters: UserParameter[], +): AutofillBuildParameter[] => { + const userParamMap = userParameters.reduce((acc, param) => { + acc.set(param.name, param); + return acc; + }, new Map()); + + const buildValues: AutofillBuildParameter[] = Array.from( + urlSearchParams.keys(), + ) .filter((key) => key.startsWith("param.")) - .forEach((key) => { + .map((key) => { const name = key.replace("param.", ""); const value = urlSearchParams.get(key) ?? ""; - buildValues.push({ name, value }); + // URL should take precedence over user parameters + userParamMap.delete(name); + return { name, value, source: "url" }; }); + + userParamMap.forEach((param) => { + buildValues.push({ + name: param.name, + value: param.value, + source: "user_history", + }); + }); return buildValues; }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 38e118431a..fc0a23810b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -17,7 +17,7 @@ const meta: Meta = { args: { defaultName: "", defaultOwner: MockUser, - defaultBuildParameters: [], + autofillParameters: [], template: MockTemplate, parameters: [], externalAuth: [], @@ -86,6 +86,13 @@ export const Parameters: Story = { ephemeral: false, }, ], + autofillParameters: [ + { + name: "first_parameter", + value: "It works!", + source: "user_history", + }, + ], }, }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index a7aabb56a5..7737a79161 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -3,7 +3,7 @@ import TextField from "@mui/material/TextField"; import type * as TypesGen from "api/typesGenerated"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { FormikContextType, useFormik } from "formik"; -import { type FC, useEffect, useState } from "react"; +import { type FC, useEffect, useState, useMemo } from "react"; import { getFormHelpers, nameValidator, @@ -17,6 +17,8 @@ import { HorizontalForm, } from "components/Form/Form"; import { + AutofillBuildParameter, + AutofillSource, getInitialRichParameterValues, useValidationSchemaForRichParameters, } from "utils/richParameters"; @@ -58,7 +60,7 @@ export interface CreateWorkspacePageViewProps { externalAuthPollingState: ExternalAuthPollingState; startPollingExternalAuth: () => void; parameters: TypesGen.TemplateVersionParameter[]; - defaultBuildParameters: TypesGen.WorkspaceBuildParameter[]; + autofillParameters: AutofillBuildParameter[]; permissions: CreateWSPermissions; creatingWorkspace: boolean; onCancel: () => void; @@ -80,7 +82,7 @@ export const CreateWorkspacePageView: FC = ({ externalAuthPollingState, startPollingExternalAuth, parameters, - defaultBuildParameters, + autofillParameters, permissions, creatingWorkspace, onSubmit, @@ -98,7 +100,7 @@ export const CreateWorkspacePageView: FC = ({ template_id: template.id, rich_parameter_values: getInitialRichParameterValues( parameters, - defaultBuildParameters, + autofillParameters, ), }, validationSchema: Yup.object({ @@ -126,6 +128,16 @@ export const CreateWorkspacePageView: FC = ({ error, ); + const autofillSources = useMemo(() => { + return autofillParameters.reduce( + (acc, param) => { + acc[param.name] = param.source; + return acc; + }, + {} as Record, + ); + }, [autofillParameters]); + return ( Cancel}> @@ -244,6 +256,7 @@ export const CreateWorkspacePageView: FC = ({ value, }); }} + autofillSource={autofillSources[parameter.name]} key={parameter.name} parameter={parameter} disabled={isDisabled} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx index 46e84c49eb..905898023c 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx @@ -21,7 +21,10 @@ import { import { useFormik } from "formik"; import { docs } from "utils/docs"; import { getFormHelpers } from "utils/formUtils"; -import { getInitialRichParameterValues } from "utils/richParameters"; +import { + AutofillBuildParameter, + getInitialRichParameterValues, +} from "utils/richParameters"; import { Popover, PopoverContent, @@ -113,7 +116,12 @@ const BuildParametersPopoverContent: FC = ({ popover.setIsOpen(false); }} ephemeralParameters={ephemeralParameters} - buildParameters={buildParameters} + buildParameters={buildParameters.map( + (p): AutofillBuildParameter => ({ + ...p, + source: "active_build", + }), + )} /> @@ -147,7 +155,7 @@ const BuildParametersPopoverContent: FC = ({ interface FormProps { ephemeralParameters: TemplateVersionParameter[]; - buildParameters: WorkspaceBuildParameter[]; + buildParameters: AutofillBuildParameter[]; onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void; } diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx index f5a03304b9..481053c1b7 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx @@ -10,6 +10,7 @@ import { } from "components/Form/Form"; import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"; import { + AutofillBuildParameter, getInitialRichParameterValues, useValidationSchemaForRichParameters, } from "utils/richParameters"; @@ -27,7 +28,7 @@ export type WorkspaceParametersFormValues = { interface WorkspaceParameterFormProps { workspace: Workspace; templateVersionRichParameters: TemplateVersionParameter[]; - buildParameters: WorkspaceBuildParameter[]; + autofillParams: AutofillBuildParameter[]; isSubmitting: boolean; canChangeVersions: boolean; error: unknown; @@ -40,7 +41,7 @@ export const WorkspaceParametersForm: FC = ({ onCancel, onSubmit, templateVersionRichParameters, - buildParameters, + autofillParams, error, canChangeVersions, isSubmitting, @@ -50,7 +51,7 @@ export const WorkspaceParametersForm: FC = ({ initialValues: { rich_parameter_values: getInitialRichParameterValues( templateVersionRichParameters, - buildParameters, + autofillParams, ), }, validationSchema: Yup.object({ diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index 6d9f64dae5..296bd6500b 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -24,6 +24,7 @@ import { EmptyState } from "components/EmptyState/EmptyState"; import Button from "@mui/material/Button"; import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; import { docs } from "utils/docs"; +import { AutofillBuildParameter } from "utils/richParameters"; const WorkspaceParametersPage: FC = () => { const workspace = useWorkspaceSettings(); @@ -126,7 +127,12 @@ export const WorkspaceParametersPageView: FC< ({ + ...p, + source: "active_build", + }), + )} templateVersionRichParameters={data.templateVersionRichParameters} error={submitError} isSubmitting={isSubmitting} diff --git a/site/src/utils/richParameters.test.ts b/site/src/utils/richParameters.test.ts index d2a0bdc7a2..97d12747bf 100644 --- a/site/src/utils/richParameters.test.ts +++ b/site/src/utils/richParameters.test.ts @@ -46,7 +46,7 @@ test("getInitialRichParameterValues return default value when default build para const cpuParameter = templateParameters[0]; const [cpuParameterInitialValue] = getInitialRichParameterValues( templateParameters, - [{ name: cpuParameter.name, value: "100" }], + [{ name: cpuParameter.name, value: "100", source: "user_history" }], ); expect(cpuParameterInitialValue.value).toBe(cpuParameter.default_value); diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts index f2e3eed821..b09a70883c 100644 --- a/site/src/utils/richParameters.ts +++ b/site/src/utils/richParameters.ts @@ -4,25 +4,39 @@ import { } from "api/typesGenerated"; import * as Yup from "yup"; +export type AutofillSource = "user_history" | "url" | "active_build"; + +// AutofillBuildParameter is a build parameter destined to a form, alongside +// its source so that the form can explain where the value comes from. +export type AutofillBuildParameter = { + source: AutofillSource; +} & WorkspaceBuildParameter; + export const getInitialRichParameterValues = ( - templateParameters: TemplateVersionParameter[], - buildParameters?: WorkspaceBuildParameter[], + templateParams: TemplateVersionParameter[], + autofillParams?: AutofillBuildParameter[], ): WorkspaceBuildParameter[] => { - return templateParameters.map((parameter) => { - const existentBuildParameter = buildParameters?.find( - (p) => p.name === parameter.name, - ); - const shouldReturnTheDefaultValue = - !existentBuildParameter || - !isValidValue(parameter, existentBuildParameter) || - parameter.ephemeral; - if (shouldReturnTheDefaultValue) { + return templateParams.map((parameter) => { + // Short-circuit for ephemeral parameters, which are always reset to + // the template-defined default. + if (parameter.ephemeral) { return { name: parameter.name, value: parameter.default_value, }; } - return existentBuildParameter; + + const autofillParam = autofillParams?.find( + ({ name }) => name === parameter.name, + ); + + return { + name: parameter.name, + value: + autofillParam && isValidValue(parameter, autofillParam) + ? autofillParam.value + : parameter.default_value, + }; }); };