Custom profile attributes field endpoints (#29662)

* 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 e2735ab0f8.

* 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 <alejandro.garciamontoro@gmail.com>

* Standardize group ID access

* Avoid polluting the state between tests

* Use specific errors for the retrieval of CPA group

---------

Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>
This commit is contained in:
Miguel de la Cruz
2025-01-13 18:12:38 +01:00
committed by GitHub
parent c6984941f1
commit ca34c6a03f
21 changed files with 1997 additions and 1 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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])
})
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

View File

@@ -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 {

View File

@@ -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."

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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