From ca34c6a03f2f2f9ae392498e998a6aaee7fe4d13 Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Mon, 13 Jan 2025 18:12:38 +0100 Subject: [PATCH] Custom profile attributes field endpoints (#29662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adds the main Property System Architecture components This change adds the necessary migrations for the Property Groups, Fields and Values tables to be created, the store layer and a Property Service that can be used from the app layer. * Adds Custom Profile Attributes endpoints and app layer * implement get and patch cpa values * run i18n-extract * Update property field type to use user instead of person * Update PropertyFields to allow for unique nondeleted fields and remove redundant indexes * Update PropertyValues to allow for unique nondeleted fields and remove redundant indexes * Use StringMap instead of the map[string]any on property fields * Add i18n strings * Revert "Use StringMap instead of the map[string]any on property fields" This reverts commit e2735ab0f8589d2524d636419ca0cb144575c4d6. * Cast JSON binary data to string and add todo note for StringMap use * Add mocks to the retrylayer tests * Cast JSON binary data to string in property value store * Check for binary parameter instead of casting to string for JSON data * Fix bad merge * Check property field type is one of the allowed ones * Avoid reusing err variable to be explicit about the returned value * Merge Property System Migrations into one file * Adds NOT NULL to timestamps at the DB level * Update stores to use tableSelectQuery instead of a slice var * Update PropertyField model translations to be more explicit and avoid repetition * Update PropertyValue model translations to be more explicit and avoid repetition * Use ExecBuilder instead of ToSql&Exec * Update property field errors to add context * Ensure PerPage is greater than zero * Update store errors to give more context * Use ExecBuilder in the property stores where possible * Add an on conflict suffix to the group register to avoid race conditions * Remove user profile API documentation changes * Update patchCPAValues endpoint and docs to return the updated information * Merge two similar error conditions * Use a route function for ListCPAValues * Remove badly used translation string * Remove unused get in register group method * Adds input sanitization and validation to the CPA API endpoints * Takes login outside of one test case to make it clear it affects multiple t.Runs * Fix wrap error and return code when property field has been deleted * Fix receiver name * Adds comment to move the CPA group ID to the db cache * Set the PerPage of CPA fields to the fields limit * Update server/channels/app/custom_profile_attributes_test.go Co-authored-by: Alejandro García Montoro * Standardize group ID access * Avoid polluting the state between tests * Use specific errors for the retrieval of CPA group --------- Co-authored-by: Scott Bishel Co-authored-by: Mattermost Build Co-authored-by: Alejandro García Montoro --- api/Makefile | 1 + api/v4/source/custom_profile_attributes.yaml | 257 ++++++++++ api/v4/source/definitions.yaml | 63 +++ api/v4/source/introduction.yaml | 1 + api/v4/source/users.yaml | 6 + server/channels/api4/api.go | 17 + .../api4/custom_profile_attributes.go | 218 +++++++++ .../api4/custom_profile_attributes_local.go | 17 + .../api4/custom_profile_attributes_test.go | 269 ++++++++++ server/channels/app/app_iface.go | 8 + .../channels/app/custom_profile_attributes.go | 240 +++++++++ .../app/custom_profile_attributes_test.go | 462 ++++++++++++++++++ .../app/opentracing/opentracing_layer.go | 176 +++++++ .../store/sqlstore/property_field_store.go | 8 + server/channels/web/context.go | 11 + server/channels/web/params.go | 4 + server/i18n/en.json | 48 ++ server/public/model/client4.go | 113 +++++ .../public/model/custom_profile_attributes.go | 6 + server/public/model/feature_flags.go | 3 + server/public/model/property_field.go | 70 ++- 21 files changed, 1997 insertions(+), 1 deletion(-) create mode 100644 api/v4/source/custom_profile_attributes.yaml create mode 100644 server/channels/api4/custom_profile_attributes.go create mode 100644 server/channels/api4/custom_profile_attributes_local.go create mode 100644 server/channels/api4/custom_profile_attributes_test.go create mode 100644 server/channels/app/custom_profile_attributes.go create mode 100644 server/channels/app/custom_profile_attributes_test.go create mode 100644 server/public/model/custom_profile_attributes.go diff --git a/api/Makefile b/api/Makefile index f4331d9b420..a5444bdd78c 100644 --- a/api/Makefile +++ b/api/Makefile @@ -58,6 +58,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/outgoing_oauth_connections.yaml >> $(V4_YAML) @cat $(V4_SRC)/metrics.yaml >> $(V4_YAML) @cat $(V4_SRC)/scheduled_post.yaml >> $(V4_YAML) + @cat $(V4_SRC)/custom_profile_attributes.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/custom_profile_attributes.yaml b/api/v4/source/custom_profile_attributes.yaml new file mode 100644 index 00000000000..b1dcd451482 --- /dev/null +++ b/api/v4/source/custom_profile_attributes.yaml @@ -0,0 +1,257 @@ + "/api/v4/custom_profile_attributes/fields": + get: + tags: + - custom profile attributes + summary: List all the Custom Profile Attributes fields + description: | + List all the Custom Profile Attributes fields. + + _This endpoint is experimental._ + + __Minimum server version__: 10.5 + + ##### Permissions + Must be authenticated. + operationId: ListAllCPAFields + responses: + "200": + description: Custom Profile Attributes fetch successful. Result may be empty. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PropertyField" + "401": + $ref: "#/components/responses/Unauthorized" + + post: + tags: + - custom profile attributes + summary: Create a Custom Profile Attribute field + description: | + Create a new Custom Profile Attribute field on the system. + + _This endpoint is experimental._ + + __Minimum server version__: 10.5 + + ##### Permissions + Must have `manage_system` permission. + operationId: CreateCPAField + requestBody: + content: + application/json: + schema: + type: object + required: + - name + - type + properties: + name: + type: string + type: + type: string + attrs: + type: string + responses: + "201": + description: Custom Profile Attribute field creation successful + content: + application/json: + schema: + $ref: "#/components/schemas/PropertyField" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + "/api/v4/custom_profile_attributes/fields/{field_id}": + patch: + tags: + - custom profile attributes + summary: Patch a Custom Profile Attribute field + description: | + Partially update a Custom Profile Attribute field by providing + only the fields you want to update. Omitted fields will not be + updated. The fields that can be updated are defined in the + request body, all other provided fields will be ignored. + + _This endpoint is experimental._ + + __Minimum server version__: 10.5 + + ##### Permissions + Must have `manage_system` permission. + operationId: PatchCPAField + parameters: + - name: field_id + in: path + description: Custom Profile Attribute field GUID + required: true + schema: + type: string + requestBody: + description: Custom Profile Attribute field that is to be updated + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + type: + type: string + attrs: + type: string + responses: + "200": + description: Custom Profile Attribute field patch successful + content: + application/json: + schema: + $ref: "#/components/schemas/PropertyField" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + delete: + tags: + - custom profile attributes + summary: Delete a Custom Profile Attribute field + description: | + Marks a Custom Profile Attribute field and all its values as + deleted. + + _This endpoint is experimental._ + + __Minimum server version__: 10.5 + + ##### Permissions + Must have `manage_system` permission. + operationId: DeleteCPAField + parameters: + - name: field_id + in: path + description: Custom Profile Attribute field GUID + required: true + schema: + type: string + responses: + "200": + description: Custom Profile Attribute field deletion successful + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + "/api/v4/custom_profile_attributes/values": + patch: + tags: + - custom profile attributes + summary: Patch Custom Profile Attribute values + description: | + Partially update a set of values on the requester's Custom + Profile Attribute fields by providing only the information you + want to update. Omitted fields will not be updated. The fields + that can be updated are defined in the request body, all other + provided fields will be ignored. + + _This endpoint is experimental._ + + __Minimum server version__: 10.5 + + ##### Permissions + Must be authenticated. + operationId: PatchCPAValues + requestBody: + description: Custom Profile Attribute values that are to be updated + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + value: + type: string + responses: + "200": + description: Custom Profile Attribute values patch successful + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + value: + type: string + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "/api/v4/users/{user_id}/custom_profile_attributes": + get: + tags: + - custom profile attributes + summary: List Custom Profile Attribute values + description: | + List all the Custom Profile Attributes values for specified user. + + _This endpoint is experimental._ + + __Minimum server version__: 10.5 + + ##### Permissions + Must have `view members` permission. + operationId: ListCPAValues + parameters: + - name: user_id + in: path + description: User GUID + required: true + schema: + type: string + responses: + "200": + description: Custom Profile Attribute values fetch successful. Result may be empty. + content: + application/json: + schema: + type: array + items: + type: object + properties: + field_id: + type: string + value: + type: string + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + + diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 12d35f066d3..514077941b1 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -404,6 +404,69 @@ components: type: string metadata: $ref: "#/components/schemas/PostMetadata" + PropertyField: + type: object + properties: + id: + type: string + group_id: + type: string + name: + type: string + type: + type: string + attrs: + type: object + target_id: + type: string + target_type: + type: string + create_at: + type: integer + format: int64 + update_at: + type: integer + format: int64 + delete_at: + type: integer + format: int64 + PropertyFieldPatch: + type: object + properties: + name: + type: string + type: + type: string + attrs: + type: object + target_id: + type: string + target_type: + type: string + PropertyValue: + type: object + properties: + id: + type: string + target_id: + type: string + target_type: + type: string + group_id: + type: string + field_id: + type: string + value: + type: string + create_at: + type: integer + format: int64 + update_at: + type: integer + format: int64 + delete_at: + type: integer + format: int64 FileInfoList: type: object properties: diff --git a/api/v4/source/introduction.yaml b/api/v4/source/introduction.yaml index 367d5904867..37e82616b28 100644 --- a/api/v4/source/introduction.yaml +++ b/api/v4/source/introduction.yaml @@ -614,6 +614,7 @@ x-tagGroups: - exports - usage - reports + - custom profile attributes servers: - url: http://your-mattermost-url.com - url: https://your-mattermost-url.com diff --git a/api/v4/source/users.yaml b/api/v4/source/users.yaml index 139a62e410a..3120306bb42 100644 --- a/api/v4/source/users.yaml +++ b/api/v4/source/users.yaml @@ -756,6 +756,12 @@ required: true schema: type: string + - name: cpa + in: query + description: Includes the Custom Profile Attributes information if set to true. + required: false + schema: + type: boolean responses: "200": description: User retrieval successful diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 012bb01dd72..4791bb976fd 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -151,6 +151,11 @@ type Routes struct { OutgoingOAuthConnections *mux.Router // 'api/v4/oauth/outgoing_connections' OutgoingOAuthConnection *mux.Router // 'api/v4/oauth/outgoing_connections/{outgoing_oauth_connection_id:[A-Za-z0-9]+}' + + CustomProfileAttributes *mux.Router // 'api/v4/custom_profile_attributes' + CustomProfileAttributesFields *mux.Router // 'api/v4/custom_profile_attributes/fields' + CustomProfileAttributesField *mux.Router // 'api/v4/custom_profile_attributes/fields/{field_id:[A-Za-z0-9]+}' + CustomProfileAttributesValues *mux.Router // 'api/v4/custom_profile_attributes/values' } type API struct { @@ -288,6 +293,11 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.OutgoingOAuthConnections = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections").Subrouter() api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.OutgoingOAuthConnections.PathPrefix("/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter() + api.BaseRoutes.CustomProfileAttributes = api.BaseRoutes.APIRoot.PathPrefix("/custom_profile_attributes").Subrouter() + api.BaseRoutes.CustomProfileAttributesFields = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/fields").Subrouter() + api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter() + api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -338,6 +348,7 @@ func Init(srv *app.Server) (*API, error) { api.InitOutgoingOAuthConnection() api.InitClientPerformanceMetrics() api.InitScheduledPost() + api.InitCustomProfileAttributes() // If we allow testing then listen for manual testing URL hits if *srv.Config().ServiceSettings.EnableTesting { @@ -420,6 +431,11 @@ func InitLocal(srv *app.Server) *API { api.BaseRoutes.SAML = api.BaseRoutes.APIRoot.PathPrefix("/saml").Subrouter() + api.BaseRoutes.CustomProfileAttributes = api.BaseRoutes.APIRoot.PathPrefix("/custom_profile_attributes").Subrouter() + api.BaseRoutes.CustomProfileAttributesFields = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/fields").Subrouter() + api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter() + api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter() + api.InitUserLocal() api.InitTeamLocal() api.InitChannelLocal() @@ -440,6 +456,7 @@ func InitLocal(srv *app.Server) *API { api.InitExportLocal() api.InitJobLocal() api.InitSamlLocal() + api.InitCustomProfileAttributesLocal() srv.LocalRouter.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/server/channels/api4/custom_profile_attributes.go b/server/channels/api4/custom_profile_attributes.go new file mode 100644 index 00000000000..c2e23a371e8 --- /dev/null +++ b/server/channels/api4/custom_profile_attributes.go @@ -0,0 +1,218 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/audit" +) + +func (api *API) InitCustomProfileAttributes() { + if api.srv.Config().FeatureFlags.CustomProfileAttributes { + api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(listCPAFields)).Methods(http.MethodGet) + api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(createCPAField)).Methods(http.MethodPost) + api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APISessionRequired(patchCPAField)).Methods(http.MethodPatch) + api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APISessionRequired(deleteCPAField)).Methods(http.MethodDelete) + api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APISessionRequired(listCPAValues)).Methods(http.MethodGet) + api.BaseRoutes.CustomProfileAttributesValues.Handle("", api.APISessionRequired(patchCPAValues)).Methods(http.MethodPatch) + } +} + +func listCPAFields(c *Context, w http.ResponseWriter, r *http.Request) { + fields, appErr := c.App.ListCPAFields() + if appErr != nil { + c.Err = appErr + return + } + + if err := json.NewEncoder(w).Encode(fields); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + var pf *model.PropertyField + err := json.NewDecoder(r.Body).Decode(&pf) + if err != nil || pf == nil { + c.SetInvalidParamWithErr("property_field", err) + return + } + + pf.SanitizeInput() + + auditRec := c.MakeAuditRecord("createCPAField", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameterAuditable(auditRec, "property_field", pf) + + createdField, appErr := c.App.CreateCPAField(pf) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(createdField) + auditRec.AddEventObjectType("property_field") + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(createdField); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequireFieldId() + if c.Err != nil { + return + } + + var patch *model.PropertyFieldPatch + err := json.NewDecoder(r.Body).Decode(&patch) + if err != nil || patch == nil { + c.SetInvalidParamWithErr("property_field_patch", err) + return + } + + patch.SanitizeInput() + + auditRec := c.MakeAuditRecord("patchCPAField", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameterAuditable(auditRec, "property_field_patch", patch) + + originalField, appErr := c.App.GetCPAField(c.Params.FieldId) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.AddEventPriorState(originalField) + + patchedField, appErr := c.App.PatchCPAField(c.Params.FieldId, patch) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(patchedField) + auditRec.AddEventObjectType("property_field") + + if err := json.NewEncoder(w).Encode(patchedField); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + c.RequireFieldId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord("deleteCPAField", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "field_id", c.Params.FieldId) + + field, appErr := c.App.GetCPAField(c.Params.FieldId) + if appErr != nil { + c.Err = appErr + return + } + auditRec.AddEventPriorState(field) + + if appErr := c.App.DeleteCPAField(c.Params.FieldId); appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(field) + auditRec.AddEventObjectType("property_field") + + ReturnStatusOK(w) +} + +func patchCPAValues(c *Context, w http.ResponseWriter, r *http.Request) { + var attributeValues map[string]string + if jsonErr := json.NewDecoder(r.Body).Decode(&attributeValues); jsonErr != nil { + c.SetInvalidParamWithErr("attrs", jsonErr) + return + } + + // This check is unnecessary for now + // Will be required when/if admins can patch other's values + userID := c.AppContext.Session().UserId + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userID) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + auditRec := c.MakeAuditRecord("patchCPAValues", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "user_id", userID) + + results := make(map[string]string) + for fieldID, value := range attributeValues { + patchedValue, appErr := c.App.PatchCPAValue(userID, fieldID, strings.TrimSpace(value)) + if appErr != nil { + c.Err = appErr + return + } + results[fieldID] = patchedValue.Value + } + + auditRec.Success() + auditRec.AddEventObjectType("patchCPAValues") + + if err := json.NewEncoder(w).Encode(results); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func listCPAValues(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId() + if c.Err != nil { + return + } + + userID := c.Params.UserId + canSee, err := c.App.UserCanSeeOtherUser(c.AppContext, c.AppContext.Session().UserId, userID) + if err != nil || !canSee { + c.SetPermissionError(model.PermissionViewMembers) + return + } + + values, appErr := c.App.ListCPAValues(userID) + if appErr != nil { + c.Err = appErr + return + } + + returnValue := make(map[string]string) + for _, value := range values { + returnValue[value.FieldID] = value.Value + } + if err := json.NewEncoder(w).Encode(returnValue); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} diff --git a/server/channels/api4/custom_profile_attributes_local.go b/server/channels/api4/custom_profile_attributes_local.go new file mode 100644 index 00000000000..6b818b11aa3 --- /dev/null +++ b/server/channels/api4/custom_profile_attributes_local.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import "net/http" + +func (api *API) InitCustomProfileAttributesLocal() { + if api.srv.Config().FeatureFlags.CustomProfileAttributes { + api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APILocal(listCPAFields)).Methods(http.MethodGet) + api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APILocal(createCPAField)).Methods(http.MethodPost) + api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APILocal(patchCPAField)).Methods(http.MethodPatch) + api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APILocal(deleteCPAField)).Methods(http.MethodDelete) + api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APISessionRequired(listCPAValues)).Methods(http.MethodGet) + api.BaseRoutes.CustomProfileAttributesValues.Handle("", api.APISessionRequired(patchCPAValues)).Methods(http.MethodPatch) + } +} diff --git a/server/channels/api4/custom_profile_attributes_test.go b/server/channels/api4/custom_profile_attributes_test.go new file mode 100644 index 00000000000..ca77204baea --- /dev/null +++ b/server/channels/api4/custom_profile_attributes_test.go @@ -0,0 +1,269 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestCreateCPAField(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t) + defer th.TearDown() + + t.Run("a user without admin permissions should not be able to create a field", func(t *testing.T) { + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + + _, resp, err := th.Client.CreateCPAField(context.Background(), field) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + field := &model.PropertyField{Name: model.NewId()} + + createdField, resp, err := client.CreateCPAField(context.Background(), field) + CheckBadRequestStatus(t, resp) + require.Error(t, err) + require.Empty(t, createdField) + }, "an invalid field should be rejected") + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + name := model.NewId() + field := &model.PropertyField{ + Name: fmt.Sprintf(" %s\t", name), // name should be sanitized + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{"visibility": "default"}, + } + + createdField, resp, err := client.CreateCPAField(context.Background(), field) + CheckCreatedStatus(t, resp) + require.NoError(t, err) + require.NotZero(t, createdField.ID) + require.Equal(t, name, createdField.Name) + require.Equal(t, "default", createdField.Attrs["visibility"]) + }, "a user with admin permissions should be able to create the field") +} + +func TestListCPAFields(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t) + defer th.TearDown() + + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{"visibility": "default"}, + } + createdField, _, err := th.SystemAdminClient.CreateCPAField(context.Background(), field) + require.NoError(t, err) + require.NotNil(t, createdField) + + t.Run("any user should be able to list fields", func(t *testing.T) { + fields, resp, err := th.Client.ListCPAFields(context.Background()) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.NotEmpty(t, fields) + require.Len(t, fields, 1) + require.Equal(t, createdField.ID, fields[0].ID) + }) + + t.Run("the endpoint should only list non deleted fields", func(t *testing.T) { + require.Nil(t, th.App.DeleteCPAField(createdField.ID)) + fields, resp, err := th.Client.ListCPAFields(context.Background()) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Empty(t, fields) + }) +} + +func TestPatchCPAField(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t) + defer th.TearDown() + + t.Run("a user without admin permissions should not be able to patch a field", func(t *testing.T) { + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, appErr := th.App.CreateCPAField(field) + require.Nil(t, appErr) + require.NotNil(t, createdField) + + patch := &model.PropertyFieldPatch{Name: model.NewPointer(model.NewId())} + _, resp, err := th.Client.PatchCPAField(context.Background(), createdField.ID, patch) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, appErr := th.App.CreateCPAField(field) + require.Nil(t, appErr) + require.NotNil(t, createdField) + + newName := model.NewId() + patch := &model.PropertyFieldPatch{Name: model.NewPointer(fmt.Sprintf(" %s \t ", newName))} // name should be sanitized + patchedField, resp, err := client.PatchCPAField(context.Background(), createdField.ID, patch) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Equal(t, newName, patchedField.Name) + }, "a user with admin permissions should be able to patch the field") +} + +func TestDeleteCPAField(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t) + defer th.TearDown() + + t.Run("a user without admin permissions should not be able to delete a field", func(t *testing.T) { + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, _, err := th.SystemAdminClient.CreateCPAField(context.Background(), field) + require.NoError(t, err) + require.NotNil(t, createdField) + + resp, err := th.Client.DeleteCPAField(context.Background(), createdField.ID) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, _, err := th.SystemAdminClient.CreateCPAField(context.Background(), field) + require.NoError(t, err) + require.NotNil(t, createdField) + require.Zero(t, createdField.DeleteAt) + + resp, err := client.DeleteCPAField(context.Background(), createdField.ID) + CheckOKStatus(t, resp) + require.NoError(t, err) + + deletedField, appErr := th.App.GetCPAField(createdField.ID) + require.Nil(t, appErr) + require.NotZero(t, deletedField.DeleteAt) + }, "a user with admin permissions should be able to delete the field") +} + +func TestListCPAValues(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + th.RemovePermissionFromRole(model.PermissionViewMembers.Id, model.SystemUserRoleId) + defer func() { + th.AddPermissionToRole(model.PermissionViewMembers.Id, model.SystemUserRoleId) + }() + + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, appErr := th.App.CreateCPAField(field) + require.Nil(t, appErr) + require.NotNil(t, createdField) + + values := map[string]string{} + values[createdField.ID] = "Field Value" + _, _, err := th.Client.PatchCPAValues(context.Background(), values) + require.NoError(t, err) + + // login with Client2 from this point on + th.LoginBasic2() + + t.Run("any team member should be able to list values", func(t *testing.T) { + values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.NotEmpty(t, values) + require.Len(t, values, 1) + }) + + t.Run("non team member should NOT be able to list values", func(t *testing.T) { + resp, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id) + CheckOKStatus(t, resp) + require.NoError(t, err) + + _, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) +} + +func TestPatchCPAValues(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, appErr := th.App.CreateCPAField(field) + require.Nil(t, appErr) + require.NotNil(t, createdField) + + t.Run("any team member should be able to create their own values", func(t *testing.T) { + values := map[string]string{} + value := "Field Value" + values[createdField.ID] = fmt.Sprintf(" %s ", value) // value should be sanitized + patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.NotEmpty(t, patchedValues) + require.Len(t, patchedValues, 1) + require.Equal(t, value, patchedValues[createdField.ID]) + + values, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.NotEmpty(t, values) + require.Len(t, values, 1) + require.Equal(t, "Field Value", values[createdField.ID]) + }) + + t.Run("any team member should be able to patch their own values", func(t *testing.T) { + values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.NotEmpty(t, values) + require.Len(t, values, 1) + + value := "Updated Field Value" + values[createdField.ID] = fmt.Sprintf(" %s \t", value) // value should be sanitized + patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Equal(t, value, patchedValues[createdField.ID]) + + values, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Equal(t, value, values[createdField.ID]) + }) +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 97e777442d2..19414cd3c04 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -513,6 +513,7 @@ type AppIface interface { CountNotification(notificationType model.NotificationType, platform string) CountNotificationAck(notificationType model.NotificationType, platform string) CountNotificationReason(notificationStatus model.NotificationStatus, notificationType model.NotificationType, notificationReason model.NotificationReason, platform string) + CreateCPAField(field *model.PropertyField) (*model.PropertyField, *model.AppError) CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) CreateChannelBookmark(c request.CTX, newBookmark *model.ChannelBookmark, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) CreateChannelWithUser(c request.CTX, channel *model.Channel, userID string) (*model.Channel, *model.AppError) @@ -560,6 +561,7 @@ type AppIface interface { DeleteAllExpiredPluginKeys() *model.AppError DeleteAllKeysForPlugin(pluginID string) *model.AppError DeleteBrandImage(rctx request.CTX) *model.AppError + DeleteCPAField(id string) *model.AppError DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError DeleteChannelBookmark(bookmarkId, connectionId string) (*model.ChannelBookmarkWithFileInfo, *model.AppError) DeleteCommand(commandID string) *model.AppError @@ -643,6 +645,8 @@ type AppIface interface { GetBookmark(bookmarkId string, includeDeleted bool) (*model.ChannelBookmarkWithFileInfo, *model.AppError) GetBrandImage(rctx request.CTX) ([]byte, *model.AppError) GetBulkReactionsForPosts(postIDs []string) (map[string][]*model.Reaction, *model.AppError) + GetCPAField(fieldID string) (*model.PropertyField, *model.AppError) + GetCPAValue(valueID string) (*model.PropertyValue, *model.AppError) GetChannel(c request.CTX, channelID string) (*model.Channel, *model.AppError) GetChannelBookmarks(channelId string, since int64) ([]*model.ChannelBookmarkWithFileInfo, *model.AppError) GetChannelByName(c request.CTX, channelName, teamID string, includeDeleted bool) (*model.Channel, *model.AppError) @@ -948,6 +952,8 @@ type AppIface interface { License() *model.License LimitedClientConfig() map[string]string ListAllCommands(teamID string, T i18n.TranslateFunc) ([]*model.Command, *model.AppError) + ListCPAFields() ([]*model.PropertyField, *model.AppError) + ListCPAValues(userID string) ([]*model.PropertyValue, *model.AppError) ListDirectory(path string) ([]string, *model.AppError) ListDirectoryRecursively(path string) ([]string, *model.AppError) ListExportDirectory(path string) ([]string, *model.AppError) @@ -973,6 +979,8 @@ type AppIface interface { OpenInteractiveDialog(c request.CTX, request model.OpenDialogRequest) *model.AppError OriginChecker() func(*http.Request) bool OutgoingOAuthConnections() einterfaces.OutgoingOAuthConnectionInterface + PatchCPAField(fieldID string, patch *model.PropertyFieldPatch) (*model.PropertyField, *model.AppError) + PatchCPAValue(userID string, fieldID string, value string) (*model.PropertyValue, *model.AppError) PatchChannel(c request.CTX, channel *model.Channel, patch *model.ChannelPatch, userID string) (*model.Channel, *model.AppError) PatchChannelMembersNotifyProps(c request.CTX, members []*model.ChannelMemberIdentifier, notifyProps map[string]string) ([]*model.ChannelMember, *model.AppError) PatchPost(c request.CTX, postID string, patch *model.PostPatch, patchPostOptions *model.UpdatePostOptions) (*model.Post, *model.AppError) diff --git a/server/channels/app/custom_profile_attributes.go b/server/channels/app/custom_profile_attributes.go new file mode 100644 index 00000000000..d99ca72ae95 --- /dev/null +++ b/server/channels/app/custom_profile_attributes.go @@ -0,0 +1,240 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/pkg/errors" +) + +const CustomProfileAttributesFieldLimit = 20 + +var cpaGroupID string + +// ToDo: we should explore moving this to the database cache layer +// instead of maintaining the ID cached at the application level +func (a *App) cpaGroupID() (string, error) { + if cpaGroupID != "" { + return cpaGroupID, nil + } + + cpaGroup, err := a.Srv().propertyService.RegisterPropertyGroup(model.CustomProfileAttributesPropertyGroupName) + if err != nil { + return "", errors.Wrap(err, "cannot register Custom Profile Attributes property group") + } + cpaGroupID = cpaGroup.ID + + return cpaGroupID, nil +} + +func (a *App) GetCPAField(fieldID string) (*model.PropertyField, *model.AppError) { + groupID, err := a.cpaGroupID() + if err != nil { + return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + field, err := a.Srv().propertyService.GetPropertyField(fieldID) + if err != nil { + return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if field.GroupID != groupID { + return nil, model.NewAppError("GetCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) + } + + return field, nil +} + +func (a *App) ListCPAFields() ([]*model.PropertyField, *model.AppError) { + groupID, err := a.cpaGroupID() + if err != nil { + return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + opts := model.PropertyFieldSearchOpts{ + GroupID: groupID, + Page: 0, + PerPage: CustomProfileAttributesFieldLimit, + } + + fields, err := a.Srv().propertyService.SearchPropertyFields(opts) + if err != nil { + return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return fields, nil +} + +func (a *App) CreateCPAField(field *model.PropertyField) (*model.PropertyField, *model.AppError) { + groupID, err := a.cpaGroupID() + if err != nil { + return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + existingFields, appErr := a.ListCPAFields() + if appErr != nil { + return nil, appErr + } + + if len(existingFields) >= CustomProfileAttributesFieldLimit { + return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.limit_reached.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) + } + + field.GroupID = groupID + newField, err := a.Srv().propertyService.CreatePropertyField(field) + if err != nil { + var appErr *model.AppError + switch { + case errors.As(err, &appErr): + return nil, appErr + default: + return nil, model.NewAppError("CreateCPAField", "app.custom_profile_attributes.create_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + return newField, nil +} + +func (a *App) PatchCPAField(fieldID string, patch *model.PropertyFieldPatch) (*model.PropertyField, *model.AppError) { + existingField, appErr := a.GetCPAField(fieldID) + if appErr != nil { + return nil, appErr + } + + // custom profile attributes doesn't use targets + patch.TargetID = nil + patch.TargetType = nil + existingField.Patch(patch) + + patchedField, err := a.Srv().propertyService.UpdatePropertyField(existingField) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("UpdateCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + default: + return nil, model.NewAppError("UpdateCPAField", "app.custom_profile_attributes.property_field_update.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + return patchedField, nil +} + +func (a *App) DeleteCPAField(id string) *model.AppError { + groupID, err := a.cpaGroupID() + if err != nil { + return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + existingField, err := a.Srv().propertyService.GetPropertyField(id) + if err != nil { + return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if existingField.GroupID != groupID { + return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) + } + + if err := a.Srv().propertyService.DeletePropertyField(id); err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + default: + return model.NewAppError("DeleteCPAField", "app.custom_profile_attributes.property_field_delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + return nil +} + +func (a *App) ListCPAValues(userID string) ([]*model.PropertyValue, *model.AppError) { + groupID, err := a.cpaGroupID() + if err != nil { + return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + opts := model.PropertyValueSearchOpts{ + GroupID: groupID, + TargetID: userID, + Page: 0, + PerPage: 999999, + IncludeDeleted: false, + } + fields, err := a.Srv().propertyService.SearchPropertyValues(opts) + if err != nil { + return nil, model.NewAppError("ListCPAValues", "app.custom_profile_attributes.list_property_values.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return fields, nil +} + +func (a *App) GetCPAValue(valueID string) (*model.PropertyValue, *model.AppError) { + groupID, err := a.cpaGroupID() + if err != nil { + return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + value, err := a.Srv().propertyService.GetPropertyValue(valueID) + if err != nil { + return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.get_property_field.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if value.GroupID != groupID { + return nil, model.NewAppError("GetCPAValue", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) + } + + return value, nil +} + +func (a *App) PatchCPAValue(userID string, fieldID string, value string) (*model.PropertyValue, *model.AppError) { + groupID, err := a.cpaGroupID() + if err != nil { + return nil, model.NewAppError("PatchCPAValues", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // make sure field exists in this group + existingField, appErr := a.GetCPAField(fieldID) + if appErr != nil { + return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound).Wrap(appErr) + } else if existingField.DeleteAt > 0 { + return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_field_not_found.app_error", nil, "", http.StatusNotFound) + } + + existingValues, appErr := a.ListCPAValues(userID) + if appErr != nil { + return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_value_list.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + var existingValue *model.PropertyValue + for key, value := range existingValues { + if value.FieldID == fieldID { + existingValue = existingValues[key] + break + } + } + + if existingValue != nil { + existingValue.Value = value + _, err = a.ch.srv.propertyService.UpdatePropertyValue(existingValue) + if err != nil { + return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_value_update.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } else { + propertyValue := &model.PropertyValue{ + GroupID: groupID, + TargetType: "user", + TargetID: userID, + FieldID: fieldID, + Value: value, + } + existingValue, err = a.ch.srv.propertyService.CreatePropertyValue(propertyValue) + if err != nil { + return nil, model.NewAppError("PatchCPAValue", "app.custom_profile_attributes.property_value_creation.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + return existingValue, nil +} diff --git a/server/channels/app/custom_profile_attributes_test.go b/server/channels/app/custom_profile_attributes_test.go new file mode 100644 index 00000000000..8af131dbace --- /dev/null +++ b/server/channels/app/custom_profile_attributes_test.go @@ -0,0 +1,462 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestGetCPAField(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + cpaGroupID, cErr := th.App.cpaGroupID() + require.NoError(t, cErr) + + t.Run("should fail when getting a non-existent field", func(t *testing.T) { + field, err := th.App.GetCPAField(model.NewId()) + require.NotNil(t, err) + require.Equal(t, "app.custom_profile_attributes.get_property_field.app_error", err.Id) + require.Empty(t, field) + }) + + t.Run("should fail when getting a field from a different group", func(t *testing.T) { + field := &model.PropertyField{ + GroupID: model.NewId(), + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, err := th.App.Srv().propertyService.CreatePropertyField(field) + require.NoError(t, err) + + fetchedField, appErr := th.App.GetCPAField(createdField.ID) + require.NotNil(t, appErr) + require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", appErr.Id) + require.Empty(t, fetchedField) + }) + + t.Run("should get an existing CPA field", func(t *testing.T) { + field := &model.PropertyField{ + GroupID: cpaGroupID, + Name: "Test Field", + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{"visibility": "hidden"}, + } + + createdField, err := th.App.CreateCPAField(field) + require.Nil(t, err) + require.NotEmpty(t, createdField.ID) + + fetchedField, err := th.App.GetCPAField(createdField.ID) + require.Nil(t, err) + require.Equal(t, createdField.ID, fetchedField.ID) + require.Equal(t, "Test Field", fetchedField.Name) + require.Equal(t, map[string]any{"visibility": "hidden"}, fetchedField.Attrs) + }) +} + +func TestListCPAFields(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + cpaGroupID, cErr := th.App.cpaGroupID() + require.NoError(t, cErr) + + t.Run("should list the CPA property fields", func(t *testing.T) { + field1 := &model.PropertyField{ + GroupID: cpaGroupID, + Name: "Field 1", + Type: model.PropertyFieldTypeText, + } + + _, err := th.App.Srv().propertyService.CreatePropertyField(field1) + require.NoError(t, err) + + field2 := &model.PropertyField{ + GroupID: model.NewId(), + Name: "Field 2", + Type: model.PropertyFieldTypeText, + } + _, err = th.App.Srv().propertyService.CreatePropertyField(field2) + require.NoError(t, err) + + field3 := &model.PropertyField{ + GroupID: cpaGroupID, + Name: "Field 3", + Type: model.PropertyFieldTypeText, + } + _, err = th.App.Srv().propertyService.CreatePropertyField(field3) + require.NoError(t, err) + + fields, appErr := th.App.ListCPAFields() + require.Nil(t, appErr) + require.Len(t, fields, 2) + + fieldNames := []string{} + for _, field := range fields { + fieldNames = append(fieldNames, field.Name) + } + require.ElementsMatch(t, []string{"Field 1", "Field 3"}, fieldNames) + }) +} + +func TestCreateCPAField(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + + cpaGroupID, cErr := th.App.cpaGroupID() + require.NoError(t, cErr) + + t.Run("should fail if the field is not valid", func(t *testing.T) { + field := &model.PropertyField{Name: model.NewId()} + + createdField, err := th.App.CreateCPAField(field) + require.NotNil(t, err) + require.Empty(t, createdField) + }) + + t.Run("should not be able to create a property field for a different feature", func(t *testing.T) { + field := &model.PropertyField{ + GroupID: model.NewId(), + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + + createdField, appErr := th.App.CreateCPAField(field) + require.Nil(t, appErr) + require.Equal(t, cpaGroupID, createdField.GroupID) + }) + + t.Run("should correctly create a CPA field", func(t *testing.T) { + field := &model.PropertyField{ + GroupID: cpaGroupID, + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{"visibility": "hidden"}, + } + + createdField, err := th.App.CreateCPAField(field) + require.Nil(t, err) + require.NotZero(t, createdField.ID) + require.Equal(t, cpaGroupID, createdField.GroupID) + require.Equal(t, map[string]any{"visibility": "hidden"}, createdField.Attrs) + + fetchedField, gErr := th.App.Srv().propertyService.GetPropertyField(createdField.ID) + require.NoError(t, gErr) + require.Equal(t, field.Name, fetchedField.Name) + require.NotZero(t, fetchedField.CreateAt) + require.Equal(t, fetchedField.CreateAt, fetchedField.UpdateAt) + }) + + // reset the server at this point to avoid polluting the state + th.TearDown() + + t.Run("CPA should honor the field limit", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Run("should not be able to create CPA fields above the limit", func(t *testing.T) { + // we create the rest of the fields required to reach the limit + for i := 1; i <= CustomProfileAttributesFieldLimit; i++ { + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, err := th.App.CreateCPAField(field) + require.Nil(t, err) + require.NotZero(t, createdField.ID) + } + + // then, we create a last one that would exceed the limit + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, err := th.App.CreateCPAField(field) + require.NotNil(t, err) + require.Equal(t, http.StatusUnprocessableEntity, err.StatusCode) + require.Zero(t, createdField) + }) + + t.Run("deleted fields should not count for the limit", func(t *testing.T) { + // we retrieve the list of fields and check we've reached the limit + fields, err := th.App.ListCPAFields() + require.Nil(t, err) + require.Len(t, fields, CustomProfileAttributesFieldLimit) + + // then we delete one field + require.Nil(t, th.App.DeleteCPAField(fields[0].ID)) + + // creating a new one should work now + field := &model.PropertyField{ + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, err := th.App.CreateCPAField(field) + require.Nil(t, err) + require.NotZero(t, createdField.ID) + }) + }) +} + +func TestPatchCPAField(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + cpaGroupID, cErr := th.App.cpaGroupID() + require.NoError(t, cErr) + + newField := &model.PropertyField{ + GroupID: cpaGroupID, + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + Attrs: map[string]any{"visibility": "hidden"}, + } + createdField, err := th.App.CreateCPAField(newField) + require.Nil(t, err) + + patch := &model.PropertyFieldPatch{ + Name: model.NewPointer("Patched name"), + Attrs: model.NewPointer(map[string]any{"visibility": "default"}), + TargetID: model.NewPointer(model.NewId()), + TargetType: model.NewPointer(model.NewId()), + } + + t.Run("should fail if the field doesn't exist", func(t *testing.T) { + updatedField, err := th.App.PatchCPAField(model.NewId(), patch) + require.NotNil(t, err) + require.Empty(t, updatedField) + }) + + t.Run("should not allow to patch a field outside of CPA", func(t *testing.T) { + newField := &model.PropertyField{ + GroupID: model.NewId(), + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + field, err := th.App.Srv().propertyService.CreatePropertyField(newField) + require.NoError(t, err) + + updatedField, uErr := th.App.PatchCPAField(field.ID, patch) + require.NotNil(t, uErr) + require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", uErr.Id) + require.Empty(t, updatedField) + }) + + t.Run("should correctly patch the CPA property field", func(t *testing.T) { + time.Sleep(10 * time.Millisecond) // ensure the UpdateAt is different than CreateAt + + updatedField, err := th.App.PatchCPAField(createdField.ID, patch) + require.Nil(t, err) + require.Equal(t, createdField.ID, updatedField.ID) + require.Equal(t, "Patched name", updatedField.Name) + require.Equal(t, "default", updatedField.Attrs["visibility"]) + require.Empty(t, updatedField.TargetID, "CPA should not allow to patch the field's target ID") + require.Empty(t, updatedField.TargetType, "CPA should not allow to patch the field's target type") + require.Greater(t, updatedField.UpdateAt, createdField.UpdateAt) + }) +} + +func TestDeleteCPAField(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + cpaGroupID, cErr := th.App.cpaGroupID() + require.NoError(t, cErr) + + newField := &model.PropertyField{ + GroupID: cpaGroupID, + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, err := th.App.CreateCPAField(newField) + require.Nil(t, err) + + for i := 0; i < 3; i++ { + newValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "user", + GroupID: cpaGroupID, + FieldID: createdField.ID, + Value: fmt.Sprintf("Value %d", i), + } + value, err := th.App.Srv().propertyService.CreatePropertyValue(newValue) + require.NoError(t, err) + require.NotZero(t, value.ID) + } + + t.Run("should fail if the field doesn't exist", func(t *testing.T) { + err := th.App.DeleteCPAField(model.NewId()) + require.NotNil(t, err) + require.Equal(t, "app.custom_profile_attributes.get_property_field.app_error", err.Id) + }) + + t.Run("should not allow to delete a field outside of CPA", func(t *testing.T) { + newField := &model.PropertyField{ + GroupID: model.NewId(), + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + field, err := th.App.Srv().propertyService.CreatePropertyField(newField) + require.NoError(t, err) + + dErr := th.App.DeleteCPAField(field.ID) + require.NotNil(t, dErr) + require.Equal(t, "app.custom_profile_attributes.property_field_not_found.app_error", dErr.Id) + }) + + t.Run("should correctly delete the field", func(t *testing.T) { + // check that we have the associated values to the field prior deletion + opts := model.PropertyValueSearchOpts{PerPage: 10, FieldID: createdField.ID} + values, err := th.App.Srv().propertyService.SearchPropertyValues(opts) + require.NoError(t, err) + require.Len(t, values, 3) + + // delete the field + require.Nil(t, th.App.DeleteCPAField(createdField.ID)) + + // check that it is marked as deleted + fetchedField, err := th.App.Srv().propertyService.GetPropertyField(createdField.ID) + require.NoError(t, err) + require.NotZero(t, fetchedField.DeleteAt) + + // ensure that the associated fields have been marked as deleted too + values, err = th.App.Srv().propertyService.SearchPropertyValues(opts) + require.NoError(t, err) + require.Len(t, values, 0) + + opts.IncludeDeleted = true + values, err = th.App.Srv().propertyService.SearchPropertyValues(opts) + require.NoError(t, err) + require.Len(t, values, 3) + for _, value := range values { + require.NotZero(t, value.DeleteAt) + } + }) +} + +func TestGetCPAValue(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + cpaGroupID, cErr := th.App.cpaGroupID() + require.NoError(t, cErr) + + fieldID := model.NewId() + + t.Run("should fail if the value doesn't exist", func(t *testing.T) { + pv, appErr := th.App.GetCPAValue(model.NewId()) + require.NotNil(t, appErr) + require.Nil(t, pv) + }) + + t.Run("should fail if the group id is invalid", func(t *testing.T) { + propertyValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "user", + GroupID: model.NewId(), + FieldID: fieldID, + Value: "Value", + } + propertyValue, err := th.App.Srv().propertyService.CreatePropertyValue(propertyValue) + require.NoError(t, err) + + pv, appErr := th.App.GetCPAValue(propertyValue.ID) + require.NotNil(t, appErr) + require.Nil(t, pv) + }) + + t.Run("should succeed if id exists", func(t *testing.T) { + propertyValue := &model.PropertyValue{ + TargetID: model.NewId(), + TargetType: "user", + GroupID: cpaGroupID, + FieldID: fieldID, + Value: "Value", + } + propertyValue, err := th.App.Srv().propertyService.CreatePropertyValue(propertyValue) + require.NoError(t, err) + + pv, appErr := th.App.GetCPAValue(propertyValue.ID) + require.Nil(t, appErr) + require.NotNil(t, pv) + }) +} + +func TestPatchCPAValue(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES") + th := Setup(t).InitBasic() + defer th.TearDown() + + cpaGroupID, cErr := th.App.cpaGroupID() + require.NoError(t, cErr) + + t.Run("should fail if the field doesn't exist", func(t *testing.T) { + invalidFieldID := model.NewId() + _, appErr := th.App.PatchCPAValue(model.NewId(), invalidFieldID, "fieldValue") + require.NotNil(t, appErr) + }) + + t.Run("should create value if new field value", func(t *testing.T) { + newField := &model.PropertyField{ + GroupID: cpaGroupID, + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, err := th.App.Srv().propertyService.CreatePropertyField(newField) + require.NoError(t, err) + + userID := model.NewId() + patchedValue, appErr := th.App.PatchCPAValue(userID, createdField.ID, "test value") + require.Nil(t, appErr) + require.NotNil(t, patchedValue) + require.Equal(t, "test value", patchedValue.Value) + require.Equal(t, userID, patchedValue.TargetID) + + t.Run("should correctly patch the CPA property value", func(t *testing.T) { + patch2, appErr := th.App.PatchCPAValue(userID, createdField.ID, "new patched value") + require.Nil(t, appErr) + require.NotNil(t, patch2) + require.Equal(t, patchedValue.ID, patch2.ID) + require.Equal(t, "new patched value", patch2.Value) + require.Equal(t, userID, patch2.TargetID) + }) + }) + + t.Run("should fail if field is deleted", func(t *testing.T) { + newField := &model.PropertyField{ + GroupID: cpaGroupID, + Name: model.NewId(), + Type: model.PropertyFieldTypeText, + } + createdField, err := th.App.Srv().propertyService.CreatePropertyField(newField) + require.NoError(t, err) + err = th.App.Srv().propertyService.DeletePropertyField(createdField.ID) + require.NoError(t, err) + + userID := model.NewId() + patchedValue, appErr := th.App.PatchCPAValue(userID, createdField.ID, "test value") + require.NotNil(t, appErr) + require.Nil(t, patchedValue) + }) +} diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 90a599339fe..c805588df10 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -2041,6 +2041,28 @@ func (a *OpenTracingAppLayer) CreateBot(rctx request.CTX, bot *model.Bot) (*mode return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CreateCPAField(field *model.PropertyField) (*model.PropertyField, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateCPAField") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.CreateCPAField(field) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) CreateChannel(c request.CTX, channel *model.Channel, addMember bool) (*model.Channel, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateChannel") @@ -3206,6 +3228,28 @@ func (a *OpenTracingAppLayer) DeleteBrandImage(rctx request.CTX) *model.AppError return resultVar0 } +func (a *OpenTracingAppLayer) DeleteCPAField(id string) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteCPAField") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.DeleteCPAField(id) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) DeleteChannel(c request.CTX, channel *model.Channel, userID string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteChannel") @@ -5484,6 +5528,50 @@ func (a *OpenTracingAppLayer) GetBulkReactionsForPosts(postIDs []string) (map[st return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetCPAField(fieldID string) (*model.PropertyField, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetCPAField") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetCPAField(fieldID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) GetCPAValue(valueID string) (*model.PropertyValue, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetCPAValue") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetCPAValue(valueID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetChannel(c request.CTX, channelID string) (*model.Channel, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetChannel") @@ -12677,6 +12765,50 @@ func (a *OpenTracingAppLayer) ListAutocompleteCommands(teamID string, T i18n.Tra return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) ListCPAFields() ([]*model.PropertyField, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ListCPAFields") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.ListCPAFields() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) ListCPAValues(userID string) ([]*model.PropertyValue, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ListCPAValues") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.ListCPAValues(userID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) ListDirectory(path string) ([]string, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ListDirectory") @@ -13367,6 +13499,50 @@ func (a *OpenTracingAppLayer) PatchBot(rctx request.CTX, botUserId string, botPa return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) PatchCPAField(fieldID string, patch *model.PropertyFieldPatch) (*model.PropertyField, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PatchCPAField") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.PatchCPAField(fieldID, patch) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + +func (a *OpenTracingAppLayer) PatchCPAValue(userID string, fieldID string, value string) (*model.PropertyValue, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PatchCPAValue") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.PatchCPAValue(userID, fieldID, value) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) PatchChannel(c request.CTX, channel *model.Channel, patch *model.ChannelPatch, userID string) (*model.Channel, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PatchChannel") diff --git a/server/channels/store/sqlstore/property_field_store.go b/server/channels/store/sqlstore/property_field_store.go index e3023a72cd4..55ac707d811 100644 --- a/server/channels/store/sqlstore/property_field_store.go +++ b/server/channels/store/sqlstore/property_field_store.go @@ -16,6 +16,10 @@ import ( ) func (s *SqlPropertyFieldStore) propertyFieldToInsertMap(field *model.PropertyField) (map[string]any, error) { + if field.Attrs == nil { + field.Attrs = make(map[string]any) + } + attrsJSON, err := json.Marshal(field.Attrs) if err != nil { return nil, errors.Wrap(err, "property_field_to_insert_map_marshal_attrs") @@ -39,6 +43,10 @@ func (s *SqlPropertyFieldStore) propertyFieldToInsertMap(field *model.PropertyFi } func (s *SqlPropertyFieldStore) propertyFieldToUpdateMap(field *model.PropertyField) (map[string]any, error) { + if field.Attrs == nil { + field.Attrs = make(map[string]any) + } + attrsJSON, err := json.Marshal(field.Attrs) if err != nil { return nil, errors.Wrap(err, "property_field_to_update_map_marshal_attrs") diff --git a/server/channels/web/context.go b/server/channels/web/context.go index cfe6c5fbdee..3e6a9720f18 100644 --- a/server/channels/web/context.go +++ b/server/channels/web/context.go @@ -685,6 +685,17 @@ func (c *Context) RequireRoleId() *Context { return c } +func (c *Context) RequireFieldId() *Context { + if c.Err != nil { + return c + } + + if !model.IsValidId(c.Params.FieldId) { + c.SetInvalidURLParam("field_id") + } + return c +} + func (c *Context) RequireSchemeId() *Context { if c.Err != nil { return c diff --git a/server/channels/web/params.go b/server/channels/web/params.go index e92054e8f90..ec573eb775e 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -111,6 +111,9 @@ type Params struct { // Cloud InvoiceId string + + // Custom Profile Attributes + FieldId string } func ParamsFromRequest(r *http.Request) *Params { @@ -178,6 +181,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.ExcludeHome, _ = strconv.ParseBool(query.Get("exclude_home")) params.ExcludeRemote, _ = strconv.ParseBool(query.Get("exclude_remote")) params.ChannelBookmarkId = props["bookmark_id"] + params.FieldId = props["field_id"] params.Scope = query.Get("scope") if val, err := strconv.Atoi(query.Get("page")); err != nil || val < 0 { diff --git a/server/i18n/en.json b/server/i18n/en.json index f116aaa755c..3290c5b2f51 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -4974,6 +4974,54 @@ "id": "app.custom_group.unique_name", "translation": "group name is not unique" }, + { + "id": "app.custom_profile_attributes.cpa_group_id.app_error", + "translation": "Cannot register Custom Profile Attributes property group" + }, + { + "id": "app.custom_profile_attributes.create_property_field.app_error", + "translation": "Unable to create Custom Profile Attribute field" + }, + { + "id": "app.custom_profile_attributes.get_property_field.app_error", + "translation": "Unable to get Custom Profile Attribute field" + }, + { + "id": "app.custom_profile_attributes.limit_reached.app_error", + "translation": "Custom Profile Attributes field limit reached" + }, + { + "id": "app.custom_profile_attributes.list_property_values.app_error", + "translation": "Unable to get custom profile attribute values" + }, + { + "id": "app.custom_profile_attributes.property_field_delete.app_error", + "translation": "Unable to delete Custom Profile Attribute field" + }, + { + "id": "app.custom_profile_attributes.property_field_not_found.app_error", + "translation": "Custom Profile Attribute field not found" + }, + { + "id": "app.custom_profile_attributes.property_field_update.app_error", + "translation": "Unable to update Custom Profile Attribute field" + }, + { + "id": "app.custom_profile_attributes.property_value_creation.app_error", + "translation": "Cannot create property value" + }, + { + "id": "app.custom_profile_attributes.property_value_list.app_error", + "translation": "Unable to retrieve property values" + }, + { + "id": "app.custom_profile_attributes.property_value_update.app_error", + "translation": "Cannot update property value" + }, + { + "id": "app.custom_profile_attributes.search_property_fields.app_error", + "translation": "Unable to search Custom Profile Attribute fields" + }, { "id": "app.delete_scheduled_post.delete_error", "translation": "Failed to delete scheduled post from database." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 09fafb7a5ee..fe8d5d70be2 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -600,6 +600,26 @@ func (c *Client4) limitsRoute() string { return "/limits" } +func (c *Client4) customProfileAttributesRoute() string { + return "/custom_profile_attributes" +} + +func (c *Client4) userCustomProfileAttributesRoute(userID string) string { + return fmt.Sprintf("%s/%s", c.userRoute(userID), c.customProfileAttributesRoute()) +} + +func (c *Client4) customProfileAttributeFieldsRoute() string { + return fmt.Sprintf("%s/fields", c.customProfileAttributesRoute()) +} + +func (c *Client4) customProfileAttributeFieldRoute(fieldID string) string { + return fmt.Sprintf("%s/%s", c.customProfileAttributeFieldsRoute(), fieldID) +} + +func (c *Client4) customProfileAttributeValuesRoute() string { + return fmt.Sprintf("%s/values", c.customProfileAttributesRoute()) +} + func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response, error) { r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/users", "") if err != nil { @@ -9383,3 +9403,96 @@ func (c *Client4) RestorePostVersion(ctx context.Context, postId, versionId stri } return restoredPost, BuildResponse(r), nil } + +func (c *Client4) CreateCPAField(ctx context.Context, field *PropertyField) (*PropertyField, *Response, error) { + buf, err := json.Marshal(field) + if err != nil { + return nil, nil, NewAppError("CreateCPAField", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPostBytes(ctx, c.customProfileAttributeFieldsRoute(), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var pf PropertyField + if err := json.NewDecoder(r.Body).Decode(&pf); err != nil { + return nil, nil, NewAppError("CreateCPAField", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &pf, BuildResponse(r), nil +} + +func (c *Client4) ListCPAFields(ctx context.Context) ([]*PropertyField, *Response, error) { + r, err := c.DoAPIGet(ctx, c.customProfileAttributeFieldsRoute(), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var fields []*PropertyField + if err := json.NewDecoder(r.Body).Decode(&fields); err != nil { + return nil, nil, NewAppError("ListCPAFields", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return fields, BuildResponse(r), nil +} + +func (c *Client4) PatchCPAField(ctx context.Context, fieldID string, patch *PropertyFieldPatch) (*PropertyField, *Response, error) { + buf, err := json.Marshal(patch) + if err != nil { + return nil, nil, NewAppError("PatchCPAField", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPatchBytes(ctx, c.customProfileAttributeFieldRoute(fieldID), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var pf PropertyField + if err := json.NewDecoder(r.Body).Decode(&pf); err != nil { + return nil, nil, NewAppError("PatchCPAField", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &pf, BuildResponse(r), nil +} + +func (c *Client4) DeleteCPAField(ctx context.Context, fieldID string) (*Response, error) { + r, err := c.DoAPIDelete(ctx, c.customProfileAttributeFieldRoute(fieldID)) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + +func (c *Client4) ListCPAValues(ctx context.Context, userID string) (map[string]string, *Response, error) { + r, err := c.DoAPIGet(ctx, c.userCustomProfileAttributesRoute(userID), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + fields := make(map[string]string) + if err := json.NewDecoder(r.Body).Decode(&fields); err != nil { + return nil, nil, NewAppError("ListCPAValues", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return fields, BuildResponse(r), nil +} + +func (c *Client4) PatchCPAValues(ctx context.Context, values map[string]string) (map[string]string, *Response, error) { + buf, err := json.Marshal(values) + if err != nil { + return nil, nil, NewAppError("PatchCPAValues", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPatchBytes(ctx, c.customProfileAttributeValuesRoute(), buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var patchedValues map[string]string + if err := json.NewDecoder(r.Body).Decode(&patchedValues); err != nil { + return nil, nil, NewAppError("PatchCPAValues", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return patchedValues, BuildResponse(r), nil +} diff --git a/server/public/model/custom_profile_attributes.go b/server/public/model/custom_profile_attributes.go new file mode 100644 index 00000000000..b155ffd05ae --- /dev/null +++ b/server/public/model/custom_profile_attributes.go @@ -0,0 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +const CustomProfileAttributesPropertyGroupName = "custom_profile_attributes" diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 93d9001ca71..e81e35a9acc 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -57,6 +57,8 @@ type FeatureFlags struct { ExperimentalAuditSettingsSystemConsoleUI bool ExperimentalCrossTeamSearch bool + + CustomProfileAttributes bool } func (f *FeatureFlags) SetDefaults() { @@ -81,6 +83,7 @@ func (f *FeatureFlags) SetDefaults() { f.NotificationMonitoring = true f.ExperimentalAuditSettingsSystemConsoleUI = false f.ExperimentalCrossTeamSearch = false + f.CustomProfileAttributes = false } // ToMap returns the feature flags as a map[string]string diff --git a/server/public/model/property_field.go b/server/public/model/property_field.go index 03f798710e0..8aaab551f32 100644 --- a/server/public/model/property_field.go +++ b/server/public/model/property_field.go @@ -3,7 +3,10 @@ package model -import "net/http" +import ( + "net/http" + "strings" +) type PropertyFieldType string @@ -29,6 +32,21 @@ type PropertyField struct { DeleteAt int64 `json:"delete_at"` } +func (pf *PropertyField) Auditable() map[string]interface{} { + return map[string]interface{}{ + "id": pf.ID, + "group_id": pf.GroupID, + "name": pf.Name, + "type": pf.Type, + "attrs": pf.Attrs, + "target_id": pf.TargetID, + "target_type": pf.TargetType, + "create_at": pf.CreateAt, + "update_at": pf.UpdateAt, + "delete_at": pf.DeleteAt, + } +} + func (pf *PropertyField) PreSave() { if pf.ID == "" { pf.ID = NewId() @@ -73,6 +91,56 @@ func (pf *PropertyField) IsValid() error { return nil } +func (pf *PropertyField) SanitizeInput() { + pf.Name = strings.TrimSpace(pf.Name) +} + +type PropertyFieldPatch struct { + Name *string `json:"name"` + Type *PropertyFieldType `json:"type"` + Attrs *map[string]any `json:"attrs"` + TargetID *string `json:"target_id"` + TargetType *string `json:"target_type"` +} + +func (pfp *PropertyFieldPatch) Auditable() map[string]interface{} { + return map[string]interface{}{ + "name": pfp.Name, + "type": pfp.Type, + "attrs": pfp.Attrs, + "target_id": pfp.TargetID, + "target_type": pfp.TargetType, + } +} + +func (pfp *PropertyFieldPatch) SanitizeInput() { + if pfp.Name != nil { + pfp.Name = NewPointer(strings.TrimSpace(*pfp.Name)) + } +} + +func (pf *PropertyField) Patch(patch *PropertyFieldPatch) { + if patch.Name != nil { + pf.Name = *patch.Name + } + + if patch.Type != nil { + pf.Type = *patch.Type + } + + if patch.Attrs != nil { + pf.Attrs = *patch.Attrs + } + + if patch.TargetID != nil { + pf.TargetID = *patch.TargetID + } + + if patch.TargetType != nil { + pf.TargetType = *patch.TargetType + } +} + type PropertyFieldSearchOpts struct { GroupID string TargetType string