From 2241c02ee87c6e5b05fee0e7b1a9149b24dc4618 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:04:51 -0400 Subject: [PATCH 01/22] Ignore aider files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 670048d..444fc7d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ dist/ .idea/ processor/temp + +.aider* \ No newline at end of file From 511d72ec0aaf19a60c582604a2d37c6859acabc9 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:05:49 -0400 Subject: [PATCH 02/22] Add API to search for groups by display name --- server/api.go | 23 ++++++++++++++++ server/store/sqlstore/group.go | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 server/store/sqlstore/group.go diff --git a/server/api.go b/server/api.go index 62e6d76..4fcd896 100644 --- a/server/api.go +++ b/server/api.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "github.com/gorilla/mux" @@ -45,6 +46,7 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}", p.updateLegalHold).Methods(http.MethodPut) router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet) router.HandleFunc("/api/v1/test_amazon_s3_connection", p.testAmazonS3Connection).Methods(http.MethodPost) + router.HandleFunc("/api/v1/groups/search", p.searchGroups).Methods(http.MethodGet) // Other routes router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost) @@ -361,6 +363,27 @@ func (p *Plugin) testAmazonS3Connection(w http.ResponseWriter, _ *http.Request) } } +// searchGroups searches for groups by display name +func (p *Plugin) searchGroups(w http.ResponseWriter, r *http.Request) { + prefix := strings.TrimSpace(r.URL.Query().Get("prefix")) + if prefix == "" { + http.Error(w, "missing search prefix", http.StatusBadRequest) + return + } + + groups, err := p.SQLStore.SearchGroupsByPrefix(prefix) + if err != nil { + http.Error(w, "failed to search groups", http.StatusInternalServerError) + p.Client.Log.Error("failed to search groups", "error", err.Error()) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(groups); err != nil { + p.Client.Log.Error("failed to write http response", "error", err.Error()) + } +} + func RequireLegalHoldID(r *http.Request) (string, error) { props := mux.Vars(r) diff --git a/server/store/sqlstore/group.go b/server/store/sqlstore/group.go new file mode 100644 index 0000000..7287160 --- /dev/null +++ b/server/store/sqlstore/group.go @@ -0,0 +1,50 @@ +package sqlstore + +import ( + "strings" + + sq "github.com/Masterminds/squirrel" + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/v6/model" +) + +var escapeLikeSearchChar = []string{ + "%", + "_", +} + +func sanitizeSearchTerm(term string) string { + const escapeChar = "\\" + + term = strings.ReplaceAll(term, escapeChar, "") + + for _, c := range escapeLikeSearchChar { + term = strings.ReplaceAll(term, c, escapeChar+c) + } + + return term +} + +func (ss SQLStore) SearchGroupsByPrefix(prefix string) ([]*model.Group, error) { + sanitizedPrefix := strings.ToLower(sanitizeSearchTerm(prefix)) + query := ss.replicaBuilder. + Select("Id", "DisplayName", "DeleteAt"). + From("UserGroups"). + Where(sq.Like{"LOWER(DisplayName)": sanitizedPrefix + "%"}). + Where(sq.Eq{"DeleteAt": 0}). + OrderBy("DisplayName"). + Limit(10) + + sql, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "failed to build SQL query for searching groups") + } + + var groups []*model.Group + if err := ss.replica.Select(&groups, sql, args...); err != nil { + return nil, errors.Wrap(err, "failed to search groups") + } + + return groups, nil +} From afa23cdccd5863514d0a7f25a1161630acb64aa5 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:06:23 -0400 Subject: [PATCH 03/22] Don't loop forever on job execution error --- server/jobs/legal_hold_job.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/jobs/legal_hold_job.go b/server/jobs/legal_hold_job.go index 83a7059..01572a4 100644 --- a/server/jobs/legal_hold_job.go +++ b/server/jobs/legal_hold_job.go @@ -211,6 +211,7 @@ func (j *LegalHoldJob) run() { if end, err := lhe.Execute(); err != nil { j.client.Log.Error("An error occurred executing the legal hold.", err) + break } else { old, err := j.kvstore.GetLegalHoldByID(lh.ID) if err != nil { From 62319cf8db905dc9340e7cc300d997d4109d306c Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:08:18 -0400 Subject: [PATCH 04/22] Add group IDs to legal hold struct --- server/model/legal_hold.go | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/server/model/legal_hold.go b/server/model/legal_hold.go index 0007a5a..858b1a3 100644 --- a/server/model/legal_hold.go +++ b/server/model/legal_hold.go @@ -17,6 +17,7 @@ type LegalHold struct { CreateAt int64 `json:"create_at"` UpdateAt int64 `json:"update_at"` UserIDs []string `json:"user_ids"` + GroupIDs []string `json:"group_ids"` StartsAt int64 `json:"starts_at"` EndsAt int64 `json:"ends_at"` IncludePublicChannels bool `json:"include_public_channels"` @@ -50,6 +51,11 @@ func (lh *LegalHold) DeepCopy() LegalHold { copy(newLegalHold.UserIDs, lh.UserIDs) } + if len(lh.GroupIDs) > 0 { + newLegalHold.GroupIDs = make([]string, len(lh.GroupIDs)) + copy(newLegalHold.GroupIDs, lh.GroupIDs) + } + return newLegalHold } @@ -75,8 +81,8 @@ func (lh *LegalHold) IsValidForCreate() error { return errors.New("LegalHold display name must be between 2 and 64 characters in length") } - if lh.UserIDs == nil || len(lh.UserIDs) < 1 { - return errors.New("LegalHold must include at least 1 user") + if (lh.UserIDs == nil || len(lh.UserIDs) < 1) && (lh.GroupIDs == nil || len(lh.GroupIDs) < 1) { + return errors.New("LegalHold must include at least 1 user or 1 group") } for _, userID := range lh.UserIDs { @@ -85,6 +91,12 @@ func (lh *LegalHold) IsValidForCreate() error { } } + for _, groupID := range lh.GroupIDs { + if !mattermostModel.IsValidId(groupID) { + return errors.New("LegalHold groups must have valid IDs") + } + } + if lh.StartsAt < 1 { return errors.New("LegalHold must start at a valid time") } @@ -140,6 +152,7 @@ type CreateLegalHold struct { Name string `json:"name"` DisplayName string `json:"display_name"` UserIDs []string `json:"user_ids"` + GroupIDs []string `json:"group_ids"` StartsAt int64 `json:"starts_at"` EndsAt int64 `json:"ends_at"` IncludePublicChannels bool `json:"include_public_channels"` @@ -153,6 +166,7 @@ func NewLegalHoldFromCreate(lhc CreateLegalHold) LegalHold { Name: lhc.Name, DisplayName: lhc.DisplayName, UserIDs: lhc.UserIDs, + GroupIDs: lhc.GroupIDs, StartsAt: lhc.StartsAt, EndsAt: lhc.EndsAt, IncludePublicChannels: lhc.IncludePublicChannels, @@ -166,6 +180,7 @@ type UpdateLegalHold struct { ID string `json:"id"` DisplayName string `json:"display_name"` UserIDs []string `json:"user_ids"` + GroupIDs []string `json:"group_ids"` IncludePublicChannels bool `json:"include_public_channels"` EndsAt int64 `json:"ends_at"` } @@ -179,8 +194,8 @@ func (ulh UpdateLegalHold) IsValid() error { return errors.New("LegalHold display name must be between 2 and 64 characters in length") } - if ulh.UserIDs == nil || len(ulh.UserIDs) < 1 { - return errors.New("LegalHold must include at least 1 user") + if (ulh.UserIDs == nil || len(ulh.UserIDs) < 1) && (ulh.GroupIDs == nil || len(ulh.GroupIDs) < 1) { + return errors.New("LegalHold must include at least 1 user or 1 group") } for _, userID := range ulh.UserIDs { @@ -189,6 +204,12 @@ func (ulh UpdateLegalHold) IsValid() error { } } + for _, groupID := range ulh.GroupIDs { + if !mattermostModel.IsValidId(groupID) { + return errors.New("LegalHold groups must have valid IDs") + } + } + if ulh.EndsAt < 0 { return errors.New("LegalHold must end at a valid time or zero") } @@ -199,6 +220,7 @@ func (ulh UpdateLegalHold) IsValid() error { func (lh *LegalHold) ApplyUpdates(updates UpdateLegalHold) { lh.DisplayName = updates.DisplayName lh.UserIDs = updates.UserIDs + lh.GroupIDs = updates.GroupIDs lh.EndsAt = updates.EndsAt lh.IncludePublicChannels = updates.IncludePublicChannels } From 07e8c0da2c94dbb33157e30123d001263e3b358e Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:33:14 -0400 Subject: [PATCH 05/22] Process group members in legal hold execution --- server/legalhold/legal_hold.go | 62 +++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/server/legalhold/legal_hold.go b/server/legalhold/legal_hold.go index 5541a98..03ff31f 100644 --- a/server/legalhold/legal_hold.go +++ b/server/legalhold/legal_hold.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/gocarina/gocsv" + mm_model "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/shared/filestore" @@ -81,25 +82,50 @@ func (ex *Execution) Execute() (int64, error) { // GetChannels populates the list of channels that the Execution needs to cover within the // internal state of the Execution struct. func (ex *Execution) GetChannels() error { + targetUsers, appErr := getUsersForGroups(ex.papi, ex.LegalHold.GroupIDs) + if appErr != nil { + return appErr + } + for _, userID := range ex.LegalHold.UserIDs { user, appErr := ex.papi.GetUser(userID) if appErr != nil { return appErr } + targetUsers = append(targetUsers, user) + } + + // keep track of which users have been processed + processedUsersList := make(map[string]struct{}) + // processAndMarkUser is a helper function that will check if a user + // has been processed, and mark the user if they has not. + // Returns true if the user should be processed + processAndMarkUser := func(id string) bool { + if _, processed := processedUsersList[id]; processed { + return false + } + processedUsersList[id] = struct{}{} + return true + } + + for _, user := range targetUsers { + if !processAndMarkUser(user.Id) { + continue + } - channelIDs, err := ex.store.GetChannelIDsForUserDuring(userID, ex.ExecutionStartTime, ex.ExecutionEndTime, ex.LegalHold.IncludePublicChannels) + channelIDs, err := ex.store.GetChannelIDsForUserDuring(user.Id, ex.ExecutionStartTime, ex.ExecutionEndTime, ex.LegalHold.IncludePublicChannels) if err != nil { return err } ex.channelIDs = append(ex.channelIDs, channelIDs...) - ex.papi.LogDebug("Legal hold executor - GetChannels", "user_id", userID, "channel_count", len(channelIDs)) + ex.papi.LogDebug("Legal hold executor - GetChannels", "user_id", user.Id, "channel_count", len(channelIDs)) // Add to channels index for _, channelID := range channelIDs { - if idx, ok := ex.index.Users[userID]; !ok { - ex.index.Users[userID] = model.LegalHoldIndexUser{ + if idx, ok := ex.index.Users[user.Id]; !ok { + ex.index.Users[user.Id] = model.LegalHoldIndexUser{ Username: user.Username, Email: user.Email, Channels: []model.LegalHoldChannelMembership{ @@ -111,7 +137,7 @@ func (ex *Execution) GetChannels() error { }, } } else { - ex.index.Users[userID] = model.LegalHoldIndexUser{ + ex.index.Users[user.Id] = model.LegalHoldIndexUser{ Username: user.Username, Email: user.Email, Channels: append(idx.Channels, model.LegalHoldChannelMembership{ @@ -456,3 +482,29 @@ func hashFromReader(secret string, reader io.Reader) (string, error) { return fmt.Sprintf("%x", hasher.Sum(nil)), nil } + +func getUsersForGroups(api plugin.API, groupIDs []string) ([]*mm_model.User, error) { + const GroupPageLimit = 100 + const GroupPageSize = 50 + + var allUsers []*mm_model.User + for _, groupID := range groupIDs { + currPage := 0 + for { + users, appErr := api.GetGroupMemberUsers(groupID, currPage, GroupPageSize) + if appErr != nil { + return nil, appErr + } + if currPage > GroupPageLimit { + return nil, fmt.Errorf("cannot execute legal hold: a group (%s) exceeds the maximum number of members (%d)", groupID, GroupPageLimit*GroupPageSize) + } + if len(users) < 1 { + break + } + allUsers = append(allUsers, users...) + currPage++ + } + } + + return allUsers, nil +} From 522a5252dfc8b92b6eb8cf79974cec8abc70f0f1 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:35:51 -0400 Subject: [PATCH 06/22] Add group input component with name autocomplete --- .../components/groups_input/groups_input.jsx | 107 ++++++++++++++++++ webapp/src/components/groups_input/index.ts | 30 +++++ 2 files changed, 137 insertions(+) create mode 100644 webapp/src/components/groups_input/groups_input.jsx create mode 100644 webapp/src/components/groups_input/index.ts diff --git a/webapp/src/components/groups_input/groups_input.jsx b/webapp/src/components/groups_input/groups_input.jsx new file mode 100644 index 0000000..7e6afd8 --- /dev/null +++ b/webapp/src/components/groups_input/groups_input.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import debounce from 'lodash/debounce'; +import AsyncSelect from 'react-select/async'; + +export default class GroupsInput extends React.Component { + static propTypes = { + placeholder: PropTypes.string, + groups: PropTypes.array, + onChange: PropTypes.func, + actions: PropTypes.shape({ + searchGroups: PropTypes.func.isRequired, + }).isRequired, + }; + + onChange = (value) => { + if (this.props.onChange) { + this.props.onChange(value); + } + }; + + getOptionValue = (group) => { + if (group.id) { + return group.id; + } + return group; + }; + + formatOptionLabel = (option) => { + if (option.display_name) { + return ( + + {option.display_name} + + ); + } + return option; + }; + + debouncedSearchGroups = debounce((term, callback) => { + this.props.actions.searchGroups(term).then((data) => { + callback(data); + }).catch(() => { + // eslint-disable-next-line no-console + console.error('Error searching groups in legal hold settings dropdown.'); + callback([]); + }); + }, 150); + + groupsLoader = (term, callback) => { + try { + this.debouncedSearchGroups(term, callback); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + callback([]); + } + }; + + keyDownHandler = (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + } + }; + + render() { + return ( + null, IndicatorSeparator: () => null}} + styles={customStyles} + menuPortalTarget={document.body} + menuPosition={'fixed'} + onKeyDown={this.keyDownHandler} + /> + ); + } +} + +const customStyles = { + container: (base) => ({ + ...base, + }), + control: (base) => ({ + ...base, + minHeight: '46px', + }), + menuPortal: (base) => ({ + ...base, + zIndex: 9999, + }), + multiValue: (base) => ({ + ...base, + borderRadius: '50px', + }), +}; diff --git a/webapp/src/components/groups_input/index.ts b/webapp/src/components/groups_input/index.ts new file mode 100644 index 0000000..4e5c6a1 --- /dev/null +++ b/webapp/src/components/groups_input/index.ts @@ -0,0 +1,30 @@ +import {connect} from 'react-redux'; +import {AnyAction, bindActionCreators, Dispatch} from 'redux'; + +import GroupsInput from './groups_input.jsx'; + +// Function to search groups via the plugin API +const searchGroups = (term: string) => { + return async () => { + try { + const response = await fetch(`/plugins/com.mattermost.plugin-legal-hold/api/v1/groups/search?prefix=${encodeURIComponent(term)}`); + if (!response.ok) { + throw new Error('Failed to search groups'); + } + return await response.json(); + } catch (error) { + console.log(error); //eslint-disable-line no-console + throw error; + } + }; +}; + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + searchGroups, + }, dispatch), + }; +} + +export default connect(null, mapDispatchToProps)(GroupsInput); From 89759227495e53e8acd30df343c2b0e16e169ded Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:39:21 -0400 Subject: [PATCH 07/22] Add API call to retrieve relevant group info --- webapp/src/client.ts | 5 ++ .../src/components/legal_hold_table/index.ts | 61 +++++++++++++++++++ .../update_legal_hold_form/index.ts | 5 ++ 3 files changed, 71 insertions(+) diff --git a/webapp/src/client.ts b/webapp/src/client.ts index 0b0e9b9..2093bf9 100644 --- a/webapp/src/client.ts +++ b/webapp/src/client.ts @@ -32,6 +32,11 @@ class APIClient { return this.doWithBody(url, 'put', data); }; + getGroup = (id: string) => { + const url = `/api/v4/groups/${id}`; + return this.doGet(url); + }; + testAmazonS3Connection = () => { const url = `${this.url}/test_amazon_s3_connection`; return this.doWithBody(url, 'post', {}) as Promise<{message: string}>; diff --git a/webapp/src/components/legal_hold_table/index.ts b/webapp/src/components/legal_hold_table/index.ts index c72d418..4ca81df 100644 --- a/webapp/src/components/legal_hold_table/index.ts +++ b/webapp/src/components/legal_hold_table/index.ts @@ -1,16 +1,77 @@ import {connect} from 'react-redux'; import {AnyAction, bindActionCreators, Dispatch} from 'redux'; +import {logError} from 'mattermost-redux/actions/errors'; import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; +import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; +import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; +import GroupTypes from 'mattermost-redux/action_types/groups'; + +import Client from '@/client'; import LegalHoldTable from '@/components/legal_hold_table/legal_hold_table'; function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators({ getMissingProfilesByIds, + getMissingGroupsByIds, }, dispatch), }; } +// keep track of ongoing requests to ensure we don't try +// to query for the same groups simultaneously +const pendingGroupRequests = new Set(); + +export function getMissingGroupsByIds(groupIds: string[]): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const {groups} = state.entities.groups; + const missingIds: string[] = []; + + groupIds.forEach((id) => { + if (!groups[id] && !pendingGroupRequests.has(id)) { + missingIds.push(id); + } + }); + + if (missingIds.length == 0) { + return {data: []}; + } + + missingIds.forEach(id => pendingGroupRequests.add(id)); + + const fetchedGroups = []; + let lastError = null; + + for (const groupId of missingIds) { + try { + const group = await Client.getGroup(groupId); + fetchedGroups.push(group); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + lastError = error; + } + } + + missingIds.forEach(id => pendingGroupRequests.delete(id)); + + if (fetchedGroups.length > 0) { + dispatch({ + type: GroupTypes.RECEIVED_GROUPS, + data: fetchedGroups, + }); + return {data: fetchedGroups}; + } + + if (lastError) { + return {error: lastError}; + } + + return {data: []}; + }; +} + export default connect(null, mapDispatchToProps)(LegalHoldTable); diff --git a/webapp/src/components/update_legal_hold_form/index.ts b/webapp/src/components/update_legal_hold_form/index.ts index 248b1d4..292f060 100644 --- a/webapp/src/components/update_legal_hold_form/index.ts +++ b/webapp/src/components/update_legal_hold_form/index.ts @@ -5,9 +5,11 @@ import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; import {GlobalState} from 'mattermost-redux/types/store'; import {getUser} from 'mattermost-redux/selectors/entities/users'; +import {getGroup} from 'mattermost-redux/selectors/entities/groups'; import {LegalHold} from '@/types'; import UpdateLegalHoldForm from '@/components/update_legal_hold_form/update_legal_hold_form'; +import {getMissingGroupsByIds} from '@/components/legal_hold_table/index'; type OwnProps = { legalHold: LegalHold|null; @@ -21,8 +23,10 @@ function makeMapStateToProps() { }; } + const groups = ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); const users = ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); return { + groups, users, }; }; @@ -32,6 +36,7 @@ function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators({ getMissingProfilesByIds, + getMissingGroupsByIds, }, dispatch), }; } From 3cff45a4221bc5f19798b514ab8885652e64aa9a Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:40:24 -0400 Subject: [PATCH 08/22] Add group input fields to legalhold forms --- .../src/components/create_legal_hold_form.tsx | 15 +++++++++++++- .../update_legal_hold_form/index.ts | 3 ++- .../update_legal_hold_form.tsx | 20 +++++++++++++++++-- webapp/src/types/index.d.ts | 3 +++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/create_legal_hold_form.tsx b/webapp/src/components/create_legal_hold_form.tsx index 16c85f4..706b905 100644 --- a/webapp/src/components/create_legal_hold_form.tsx +++ b/webapp/src/components/create_legal_hold_form.tsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import {UserProfile} from 'mattermost-redux/types/users'; import UsersInput from '@/components/users_input'; +import GroupsInput from '@/components/groups_input'; import {CreateLegalHold} from '@/types'; import {GenericModal} from '@/components/mattermost-webapp/generic_modal/generic_modal'; import Input from '@/components/mattermost-webapp/input/input'; @@ -18,6 +19,7 @@ interface CreateLegalHoldFormProps { const CreateLegalHoldForm = (props: CreateLegalHoldFormProps) => { const [displayName, setDisplayName] = useState(''); const [users, setUsers] = useState(Array()); + const [groups, setGroups] = useState(Array()); const [startsAt, setStartsAt] = useState(''); const [endsAt, setEndsAt] = useState(''); const [saving, setSaving] = useState(false); @@ -45,6 +47,7 @@ const CreateLegalHoldForm = (props: CreateLegalHoldFormProps) => { setStartsAt(''); setEndsAt(''); setUsers([]); + setGroups([]); setSaving(false); setIncludePublicChannels(false); setServerError(''); @@ -58,6 +61,7 @@ const CreateLegalHoldForm = (props: CreateLegalHoldFormProps) => { const data = { user_ids: users.map((user) => user.id), + group_ids: groups.map((group) => group.id), ends_at: (new Date(endsAt)).getTime(), starts_at: (new Date(startsAt)).getTime(), display_name: displayName, @@ -91,7 +95,7 @@ const CreateLegalHoldForm = (props: CreateLegalHoldFormProps) => { return false; } - if (users.length < 1) { + if (users.length < 1 && groups.length < 1) { return false; } @@ -139,12 +143,21 @@ const CreateLegalHoldForm = (props: CreateLegalHoldFormProps) => { inputClassName={'create-legal-hold-input'} />
+
+
+ + +
{ if (ownProps.legalHold === null || ownProps.legalHold.user_ids === null) { return { + groups: [], users: [], }; } @@ -26,7 +27,7 @@ function makeMapStateToProps() { const groups = ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); const users = ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); return { - groups, + groups, users, }; }; diff --git a/webapp/src/components/update_legal_hold_form/update_legal_hold_form.tsx b/webapp/src/components/update_legal_hold_form/update_legal_hold_form.tsx index 195d82c..74d92fe 100644 --- a/webapp/src/components/update_legal_hold_form/update_legal_hold_form.tsx +++ b/webapp/src/components/update_legal_hold_form/update_legal_hold_form.tsx @@ -2,10 +2,12 @@ import dayjs from 'dayjs'; import React, {useEffect, useState} from 'react'; import {UserProfile} from 'mattermost-redux/types/users'; +import {Group} from 'mattermost-redux/types/groups'; import {GenericModal} from '@/components/mattermost-webapp/generic_modal/generic_modal'; import Input from '@/components/mattermost-webapp/input/input'; import UsersInput from '@/components/users_input'; +import GroupsInput from '@/components/groups_input'; import {LegalHold, UpdateLegalHold} from '@/types'; import '../create_legal_hold_form.scss'; @@ -16,12 +18,14 @@ interface UpdateLegalHoldFormProps { visible: boolean; legalHold: LegalHold | null; users: Array; + groups: Array; } const UpdateLegalHoldForm = (props: UpdateLegalHoldFormProps) => { const [id, setId] = useState(''); const [displayName, setDisplayName] = useState(''); const [users, setUsers] = useState(Array()); + const [groups, setGroups] = useState(Array()); const [startsAt, setStartsAt] = useState(''); const [endsAt, setEndsAt] = useState(''); const [saving, setSaving] = useState(false); @@ -45,6 +49,7 @@ const UpdateLegalHoldForm = (props: UpdateLegalHoldFormProps) => { setDisplayName(''); setEndsAt(''); setUsers([]); + setGroups([]); setServerError(''); setIncludePublicChannels(false); setSaving(false); @@ -60,6 +65,7 @@ const UpdateLegalHoldForm = (props: UpdateLegalHoldFormProps) => { setId(props.legalHold.id); setDisplayName(props.legalHold?.display_name); setUsers(props.users); + setGroups(props.groups); setIncludePublicChannels(props.legalHold.include_public_channels); if (props.legalHold.starts_at) { @@ -72,7 +78,7 @@ const UpdateLegalHoldForm = (props: UpdateLegalHoldFormProps) => { setEndsAt(endsAtString); } } - }, [props.legalHold, props.users, props.visible, id]); + }, [props.legalHold, props.users, props.groups, props.visible, id]); const onSave = () => { if (saving) { @@ -87,6 +93,7 @@ const UpdateLegalHoldForm = (props: UpdateLegalHoldFormProps) => { const data = { id: props.legalHold.id, user_ids: users.map((user) => user.id), + group_ids: groups.map((group) => group.id), ends_at: (new Date(endsAt)).getTime(), include_public_channels: includePublicChannels, display_name: displayName, @@ -114,7 +121,7 @@ const UpdateLegalHoldForm = (props: UpdateLegalHoldFormProps) => { return false; } - if (users.length < 1) { + if (users.length < 1 && groups.length < 1) { return false; } @@ -166,12 +173,21 @@ const UpdateLegalHoldForm = (props: UpdateLegalHoldFormProps) => { inputClassName={'create-legal-hold-input'} />
+
+
+ + +
; + group_ids?: Array; } export interface UpdateLegalHold { @@ -22,4 +24,5 @@ export interface UpdateLegalHold { display_name: string; ends_at: number; user_ids: Array; + group_ids?: Array; } From eea0ae04f67fad3fa3b11cc4037875448497fdff Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 17 Mar 2025 11:40:51 -0400 Subject: [PATCH 09/22] Show group information in legalhold table --- .../src/components/legal_hold_table/index.ts | 32 ++++++++----------- .../legal_hold_table/legal_hold_row/index.ts | 4 +++ .../legal_hold_row/legal_hold_row.tsx | 3 ++ .../legal_hold_table/legal_hold_table.tsx | 20 ++++++++++-- 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/webapp/src/components/legal_hold_table/index.ts b/webapp/src/components/legal_hold_table/index.ts index 4ca81df..88ecaed 100644 --- a/webapp/src/components/legal_hold_table/index.ts +++ b/webapp/src/components/legal_hold_table/index.ts @@ -36,27 +36,27 @@ export function getMissingGroupsByIds(groupIds: string[]): ActionFunc { } }); - if (missingIds.length == 0) { + if (missingIds.length === 0) { return {data: []}; } - missingIds.forEach(id => pendingGroupRequests.add(id)); + missingIds.forEach((id) => pendingGroupRequests.add(id)); - const fetchedGroups = []; - let lastError = null; - - for (const groupId of missingIds) { - try { - const group = await Client.getGroup(groupId); - fetchedGroups.push(group); - } catch (error) { - forceLogoutIfNecessary(error, dispatch, getState); - dispatch(logError(error)); - lastError = error; + let fetchedGroups = []; + + try { + const promises = []; + for (const groupId of missingIds) { + promises.push(Client.getGroup(groupId)); } + fetchedGroups = await Promise.all(promises); + } catch (error) { + forceLogoutIfNecessary(error as any, dispatch, getState); + dispatch(logError(error as any)); + return {error}; } - missingIds.forEach(id => pendingGroupRequests.delete(id)); + missingIds.forEach((id) => pendingGroupRequests.delete(id)); if (fetchedGroups.length > 0) { dispatch({ @@ -66,10 +66,6 @@ export function getMissingGroupsByIds(groupIds: string[]): ActionFunc { return {data: fetchedGroups}; } - if (lastError) { - return {error: lastError}; - } - return {data: []}; }; } diff --git a/webapp/src/components/legal_hold_table/legal_hold_row/index.ts b/webapp/src/components/legal_hold_table/legal_hold_row/index.ts index ed8487d..fd4ed5c 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_row/index.ts +++ b/webapp/src/components/legal_hold_table/legal_hold_row/index.ts @@ -2,6 +2,7 @@ import {connect} from 'react-redux'; import {GlobalState} from 'mattermost-redux/types/store'; import {getUser} from 'mattermost-redux/selectors/entities/users'; +import {getGroup} from 'mattermost-redux/selectors/entities/groups'; import LegalHoldRow from '@/components/legal_hold_table/legal_hold_row/legal_hold_row'; import {LegalHold} from '@/types'; @@ -14,12 +15,15 @@ function makeMapStateToProps() { return (state: GlobalState, ownProps: OwnProps) => { if (ownProps.legalHold.user_ids === null) { return { + groups: [], users: [], }; } + const groups = ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); const users = ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); return { + groups, users, }; }; diff --git a/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx b/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx index 1836ab3..38136c0 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx +++ b/webapp/src/components/legal_hold_table/legal_hold_row/legal_hold_row.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {UserProfile} from 'mattermost-redux/types/users'; +import {Group} from 'mattermost-redux/types/groups'; import {LegalHold} from '@/types'; import Client from '@/client'; @@ -15,6 +16,7 @@ import EyeLockIcon from './eye-outline_F06D0.svg'; interface LegalHoldRowProps { legalHold: LegalHold; users: UserProfile[]; + groups: Group[]; releaseLegalHold: Function; showUpdateModal: Function; showSecretModal: Function; @@ -40,6 +42,7 @@ const LegalHoldRow = (props: LegalHoldRowProps) => {
{startsAt}
{endsAt}
{props.users.length} {'users'}
+
{props.groups.length} {'groups'}
{ ), ); + const group_ids = Array.from( + new Set( + legalHolds.map((lh) => lh.group_ids).filter((i) => i !== null).reduce((prev, cur) => prev.concat(cur), []).filter((i) => i !== null), + ), + ); + useEffect(() => { props.actions.getMissingProfilesByIds( user_ids, ); - }, [props.actions, user_ids]); + props.actions.getMissingGroupsByIds( + group_ids, + ); + }, [props.actions, user_ids, group_ids]); return (
@@ -34,7 +44,7 @@ const LegalHoldTable = (props: LegalHoldTableProps) => { aria-label='Legal Holds Table' style={{ display: 'grid', - gridTemplateColumns: 'auto auto auto auto auto', + gridTemplateColumns: 'auto auto auto auto auto auto', columnGap: '10px', rowGap: '10px', alignItems: 'center', @@ -55,7 +65,11 @@ const LegalHoldTable = (props: LegalHoldTableProps) => {
{'Users'}
+ >{'Targeted Users'}
+
{'Targeted Groups'}
Date: Tue, 18 Mar 2025 16:55:55 -0400 Subject: [PATCH 10/22] Fix lint issues --- server/jobs/legal_hold_job.go | 36 ++++++++++--------- server/model/index.go | 26 +++++++------- server/model/legal_hold.go | 4 +-- .../src/components/legal_hold_table/index.ts | 5 ++- 4 files changed, 37 insertions(+), 34 deletions(-) diff --git a/server/jobs/legal_hold_job.go b/server/jobs/legal_hold_job.go index 01572a4..c6ddda5 100644 --- a/server/jobs/legal_hold_job.go +++ b/server/jobs/legal_hold_job.go @@ -209,25 +209,29 @@ func (j *LegalHoldJob) run() { j.client.Log.Debug(fmt.Sprintf("Creating Legal Hold Execution for legal hold: %s", lh.ID)) lhe := legalhold.NewExecution(lh, j.papi, j.sqlstore, j.filebackend) - if end, err := lhe.Execute(); err != nil { + end, err := lhe.Execute() + if err != nil { j.client.Log.Error("An error occurred executing the legal hold.", err) break - } else { - old, err := j.kvstore.GetLegalHoldByID(lh.ID) - if err != nil { - j.client.Log.Error("Failed to fetch the LegalHold prior to updating", err) - continue - } - lh = *old - lh.LastExecutionEndedAt = end - newLH, err := j.kvstore.UpdateLegalHold(lh, *old) - if err != nil { - j.client.Log.Error("Failed to update legal hold", err) - continue - } - lh = *newLH - j.client.Log.Info("legal hold executed", "legal_hold_id", lh.ID, "legal_hold_name", lh.Name) } + + old, err := j.kvstore.GetLegalHoldByID(lh.ID) + if err != nil { + j.client.Log.Error("Failed to fetch the LegalHold prior to updating", err) + continue + } + + lh = *old + lh.LastExecutionEndedAt = end + + newLH, err := j.kvstore.UpdateLegalHold(lh, *old) + if err != nil { + j.client.Log.Error("Failed to update legal hold", err) + continue + } + + lh = *newLH + j.client.Log.Info("legal hold executed", "legal_hold_id", lh.ID, "legal_hold_name", lh.Name) } } _ = ctx diff --git a/server/model/index.go b/server/model/index.go index 93a1264..822531d 100644 --- a/server/model/index.go +++ b/server/model/index.go @@ -59,14 +59,14 @@ func NewLegalHoldIndex() LegalHoldIndex { } // Merge merges the new LegalHoldIndex into this LegalHoldIndex. -func (lhi *LegalHoldIndex) Merge(new *LegalHoldIndex) { +func (lhi *LegalHoldIndex) Merge(newIndex *LegalHoldIndex) { // To merge the LegalHold data we overwrite the old struct in full // with the new one. - lhi.LegalHold = new.LegalHold + lhi.LegalHold = newIndex.LegalHold // Recursively merge the Teams (and their Channels) property, taking // the newest version for the union of both lists. - for _, newTeam := range new.Teams { + for _, newTeam := range newIndex.Teams { found := false for _, oldTeam := range lhi.Teams { if newTeam.ID == oldTeam.ID { @@ -81,15 +81,15 @@ func (lhi *LegalHoldIndex) Merge(new *LegalHoldIndex) { } } - lhi.Users.Merge(&new.Users) + lhi.Users.Merge(&newIndex.Users) } // Merge merges the new LegalHoldTeam into this LegalHoldTeam. -func (team *LegalHoldTeam) Merge(new *LegalHoldTeam) { - team.Name = new.Name - team.DisplayName = new.DisplayName +func (team *LegalHoldTeam) Merge(newTeam *LegalHoldTeam) { + team.Name = newTeam.Name + team.DisplayName = newTeam.DisplayName - for _, newChannel := range new.Channels { + for _, newChannel := range newTeam.Channels { found := false for _, oldChannel := range team.Channels { if newChannel.ID == oldChannel.ID { @@ -107,8 +107,8 @@ func (team *LegalHoldTeam) Merge(new *LegalHoldTeam) { } // Merge merges the new LegalHoldIndexUsers into this LegalHoldIndexUsers. -func (lhi *LegalHoldIndexUsers) Merge(new *LegalHoldIndexUsers) { - for userID, newUser := range *new { +func (lhi *LegalHoldIndexUsers) Merge(newUsers *LegalHoldIndexUsers) { + for userID, newUser := range *newUsers { if oldUser, ok := (*lhi)[userID]; !ok { (*lhi)[userID] = newUser } else { @@ -154,10 +154,10 @@ func getLegalHoldChannelMembership(channelMemberships []LegalHoldChannelMembersh // Combine combines the data from two LegalHoldChannelMembership instances and returns a new one // representing the combined data. -func (lhcm LegalHoldChannelMembership) Combine(new LegalHoldChannelMembership) LegalHoldChannelMembership { +func (lhcm LegalHoldChannelMembership) Combine(newMembership LegalHoldChannelMembership) LegalHoldChannelMembership { return LegalHoldChannelMembership{ ChannelID: lhcm.ChannelID, - StartTime: utils.Min(lhcm.StartTime, new.StartTime), - EndTime: utils.Max(lhcm.EndTime, new.EndTime), + StartTime: utils.Min(lhcm.StartTime, newMembership.StartTime), + EndTime: utils.Max(lhcm.EndTime, newMembership.EndTime), } } diff --git a/server/model/legal_hold.go b/server/model/legal_hold.go index 858b1a3..75e554f 100644 --- a/server/model/legal_hold.go +++ b/server/model/legal_hold.go @@ -81,7 +81,7 @@ func (lh *LegalHold) IsValidForCreate() error { return errors.New("LegalHold display name must be between 2 and 64 characters in length") } - if (lh.UserIDs == nil || len(lh.UserIDs) < 1) && (lh.GroupIDs == nil || len(lh.GroupIDs) < 1) { + if len(lh.UserIDs) < 1 && len(lh.GroupIDs) < 1 { return errors.New("LegalHold must include at least 1 user or 1 group") } @@ -194,7 +194,7 @@ func (ulh UpdateLegalHold) IsValid() error { return errors.New("LegalHold display name must be between 2 and 64 characters in length") } - if (ulh.UserIDs == nil || len(ulh.UserIDs) < 1) && (ulh.GroupIDs == nil || len(ulh.GroupIDs) < 1) { + if len(ulh.UserIDs) < 1 && len(ulh.GroupIDs) < 1 { return errors.New("LegalHold must include at least 1 user or 1 group") } diff --git a/webapp/src/components/legal_hold_table/index.ts b/webapp/src/components/legal_hold_table/index.ts index 88ecaed..ec8b0a4 100644 --- a/webapp/src/components/legal_hold_table/index.ts +++ b/webapp/src/components/legal_hold_table/index.ts @@ -51,9 +51,8 @@ export function getMissingGroupsByIds(groupIds: string[]): ActionFunc { } fetchedGroups = await Promise.all(promises); } catch (error) { - forceLogoutIfNecessary(error as any, dispatch, getState); - dispatch(logError(error as any)); - return {error}; + console.log(error); //eslint-disable-line no-console + throw error; } missingIds.forEach((id) => pendingGroupRequests.delete(id)); From a1eed90ef0f4f24d93ba2d39ca0005d2898d2484 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Thu, 20 Mar 2025 10:20:19 -0400 Subject: [PATCH 11/22] Fix failing test --- server/model/legal_hold_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/model/legal_hold_test.go b/server/model/legal_hold_test.go index d01f1a6..3f90e5a 100644 --- a/server/model/legal_hold_test.go +++ b/server/model/legal_hold_test.go @@ -475,7 +475,7 @@ func TestModel_UpdateLegalHold_IsValid(t *testing.T) { UserIDs: []string{}, EndsAt: 0, }, - expected: "LegalHold must include at least 1 user", + expected: "LegalHold must include at least 1 user or 1 group", }, { name: "InvalidUserIDs", From 88b47cdfe3054525683d1484b228eb1f0e499474 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Tue, 25 Mar 2025 12:31:49 -0400 Subject: [PATCH 12/22] Move searchGroups to client.ts --- webapp/src/client.ts | 5 +++++ webapp/src/components/groups_input/index.ts | 7 ++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/webapp/src/client.ts b/webapp/src/client.ts index 2093bf9..4e0d78d 100644 --- a/webapp/src/client.ts +++ b/webapp/src/client.ts @@ -42,6 +42,11 @@ class APIClient { return this.doWithBody(url, 'post', {}) as Promise<{message: string}>; }; + searchGroups = (term: string) => { + const url = `${this.url}/groups/search?prefix=${encodeURIComponent(term)}`; + return this.doGet(url); + }; + private doGet = async (url: string, headers = {}) => { const options = { method: 'get', diff --git a/webapp/src/components/groups_input/index.ts b/webapp/src/components/groups_input/index.ts index 4e5c6a1..f38096e 100644 --- a/webapp/src/components/groups_input/index.ts +++ b/webapp/src/components/groups_input/index.ts @@ -1,17 +1,14 @@ import {connect} from 'react-redux'; import {AnyAction, bindActionCreators, Dispatch} from 'redux'; +import Client from '@/client'; import GroupsInput from './groups_input.jsx'; // Function to search groups via the plugin API const searchGroups = (term: string) => { return async () => { try { - const response = await fetch(`/plugins/com.mattermost.plugin-legal-hold/api/v1/groups/search?prefix=${encodeURIComponent(term)}`); - if (!response.ok) { - throw new Error('Failed to search groups'); - } - return await response.json(); + return await Client.searchGroups(term); } catch (error) { console.log(error); //eslint-disable-line no-console throw error; From a1bcfab00dc88b64a45ce5b4529eec1bbceedc01 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Tue, 25 Mar 2025 12:31:58 -0400 Subject: [PATCH 13/22] Drop unused import --- webapp/src/components/legal_hold_table/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/src/components/legal_hold_table/index.ts b/webapp/src/components/legal_hold_table/index.ts index ec8b0a4..a81b3d1 100644 --- a/webapp/src/components/legal_hold_table/index.ts +++ b/webapp/src/components/legal_hold_table/index.ts @@ -3,7 +3,6 @@ import {AnyAction, bindActionCreators, Dispatch} from 'redux'; import {logError} from 'mattermost-redux/actions/errors'; import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; -import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import GroupTypes from 'mattermost-redux/action_types/groups'; From d8ed3c8bb866b038e390ca1d08048de0deb6a1d8 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Tue, 25 Mar 2025 12:32:09 -0400 Subject: [PATCH 14/22] Comment complex map/reduce/filter --- .../components/legal_hold_table/legal_hold_table.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/legal_hold_table/legal_hold_table.tsx b/webapp/src/components/legal_hold_table/legal_hold_table.tsx index 98e85a0..3ce58f2 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_table.tsx +++ b/webapp/src/components/legal_hold_table/legal_hold_table.tsx @@ -19,13 +19,21 @@ const LegalHoldTable = (props: LegalHoldTableProps) => { const user_ids = Array.from( new Set( - legalHolds.map((lh) => lh.user_ids).filter((i) => i !== null).reduce((prev, cur) => prev.concat(cur), []).filter((i) => i !== null), + legalHolds + .map((lh) => lh.user_ids) // Put each LH's array of user IDs into an array + .filter((i) => i !== null) // Drop any arrays that are null + .reduce((prev, cur) => prev.concat(cur), []) // Flatten the list into a single array + .filter((i) => i !== null), // Drop any IDs that are null ), ); const group_ids = Array.from( new Set( - legalHolds.map((lh) => lh.group_ids).filter((i) => i !== null).reduce((prev, cur) => prev.concat(cur), []).filter((i) => i !== null), + legalHolds + .map((lh) => lh.group_ids) // Put each LH's array of group IDs into an array + .filter((i) => i !== null) // Drop any arrays that are null + .reduce((prev, cur) => prev.concat(cur), []) // Flatten the list into a single array + .filter((i) => i !== null), // Drop any IDs that are null ), ); From 2b5368376cea309bb155b4ed62d2c2d321fa5349 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Tue, 25 Mar 2025 12:41:15 -0400 Subject: [PATCH 15/22] Fix lint errors --- webapp/src/components/groups_input/index.ts | 1 + .../legal_hold_table/legal_hold_table.tsx | 20 +++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/webapp/src/components/groups_input/index.ts b/webapp/src/components/groups_input/index.ts index f38096e..7ab5e9d 100644 --- a/webapp/src/components/groups_input/index.ts +++ b/webapp/src/components/groups_input/index.ts @@ -2,6 +2,7 @@ import {connect} from 'react-redux'; import {AnyAction, bindActionCreators, Dispatch} from 'redux'; import Client from '@/client'; + import GroupsInput from './groups_input.jsx'; // Function to search groups via the plugin API diff --git a/webapp/src/components/legal_hold_table/legal_hold_table.tsx b/webapp/src/components/legal_hold_table/legal_hold_table.tsx index 3ce58f2..dfc2207 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_table.tsx +++ b/webapp/src/components/legal_hold_table/legal_hold_table.tsx @@ -19,21 +19,21 @@ const LegalHoldTable = (props: LegalHoldTableProps) => { const user_ids = Array.from( new Set( - legalHolds - .map((lh) => lh.user_ids) // Put each LH's array of user IDs into an array - .filter((i) => i !== null) // Drop any arrays that are null - .reduce((prev, cur) => prev.concat(cur), []) // Flatten the list into a single array - .filter((i) => i !== null), // Drop any IDs that are null + legalHolds. + map((lh) => lh.user_ids). // Put each LH's array of user IDs into an array + filter((i) => i !== null). // Drop any arrays that are null + reduce((prev, cur) => prev.concat(cur), []). // Flatten the list into a single array + filter((i) => i !== null), // Drop any IDs that are null ), ); const group_ids = Array.from( new Set( - legalHolds - .map((lh) => lh.group_ids) // Put each LH's array of group IDs into an array - .filter((i) => i !== null) // Drop any arrays that are null - .reduce((prev, cur) => prev.concat(cur), []) // Flatten the list into a single array - .filter((i) => i !== null), // Drop any IDs that are null + legalHolds. + map((lh) => lh.group_ids). // Put each LH's array of group IDs into an array + filter((i) => i !== null). // Drop any arrays that are null + reduce((prev, cur) => prev.concat(cur), []). // Flatten the list into a single array + filter((i) => i !== null), // Drop any IDs that are null ), ); From 437fe94078ebc189d6ca90b32a9138bea589af05 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 31 Mar 2025 10:58:40 -0400 Subject: [PATCH 16/22] Search should only return LDAP groups --- server/api.go | 8 ++++---- server/store/sqlstore/group.go | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/server/api.go b/server/api.go index 4fcd896..a5cefc2 100644 --- a/server/api.go +++ b/server/api.go @@ -46,7 +46,7 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}", p.updateLegalHold).Methods(http.MethodPut) router.HandleFunc("/api/v1/legalholds/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet) router.HandleFunc("/api/v1/test_amazon_s3_connection", p.testAmazonS3Connection).Methods(http.MethodPost) - router.HandleFunc("/api/v1/groups/search", p.searchGroups).Methods(http.MethodGet) + router.HandleFunc("/api/v1/groups/search", p.searchLDAPGroups).Methods(http.MethodGet) // Other routes router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost) @@ -363,15 +363,15 @@ func (p *Plugin) testAmazonS3Connection(w http.ResponseWriter, _ *http.Request) } } -// searchGroups searches for groups by display name -func (p *Plugin) searchGroups(w http.ResponseWriter, r *http.Request) { +// searchLDAPGroups searches for groups by display name +func (p *Plugin) searchLDAPGroups(w http.ResponseWriter, r *http.Request) { prefix := strings.TrimSpace(r.URL.Query().Get("prefix")) if prefix == "" { http.Error(w, "missing search prefix", http.StatusBadRequest) return } - groups, err := p.SQLStore.SearchGroupsByPrefix(prefix) + groups, err := p.SQLStore.SearchLDAPGroupsByPrefix(prefix) if err != nil { http.Error(w, "failed to search groups", http.StatusInternalServerError) p.Client.Log.Error("failed to search groups", "error", err.Error()) diff --git a/server/store/sqlstore/group.go b/server/store/sqlstore/group.go index 7287160..6e67f51 100644 --- a/server/store/sqlstore/group.go +++ b/server/store/sqlstore/group.go @@ -26,13 +26,14 @@ func sanitizeSearchTerm(term string) string { return term } -func (ss SQLStore) SearchGroupsByPrefix(prefix string) ([]*model.Group, error) { +func (ss SQLStore) SearchLDAPGroupsByPrefix(prefix string) ([]*model.Group, error) { sanitizedPrefix := strings.ToLower(sanitizeSearchTerm(prefix)) query := ss.replicaBuilder. Select("Id", "DisplayName", "DeleteAt"). From("UserGroups"). Where(sq.Like{"LOWER(DisplayName)": sanitizedPrefix + "%"}). Where(sq.Eq{"DeleteAt": 0}). + Where(sq.Eq{"Source": "ldap"}). OrderBy("DisplayName"). Limit(10) From ba07b4578142cad9e1ad896215901a71648c2173 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Mon, 31 Mar 2025 11:00:44 -0400 Subject: [PATCH 17/22] Specify groups are LDAP in frontend labels --- webapp/src/components/create_legal_hold_form.tsx | 2 +- .../update_legal_hold_form/update_legal_hold_form.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/create_legal_hold_form.tsx b/webapp/src/components/create_legal_hold_form.tsx index 706b905..af0a60e 100644 --- a/webapp/src/components/create_legal_hold_form.tsx +++ b/webapp/src/components/create_legal_hold_form.tsx @@ -151,7 +151,7 @@ const CreateLegalHoldForm = (props: CreateLegalHoldFormProps) => { />
- + { />
- + Date: Mon, 31 Mar 2025 11:33:13 -0400 Subject: [PATCH 18/22] Support searching LDAP groups by @name --- server/store/sqlstore/group.go | 7 +++++-- webapp/src/components/groups_input/groups_input.jsx | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/store/sqlstore/group.go b/server/store/sqlstore/group.go index 6e67f51..c78b702 100644 --- a/server/store/sqlstore/group.go +++ b/server/store/sqlstore/group.go @@ -29,9 +29,12 @@ func sanitizeSearchTerm(term string) string { func (ss SQLStore) SearchLDAPGroupsByPrefix(prefix string) ([]*model.Group, error) { sanitizedPrefix := strings.ToLower(sanitizeSearchTerm(prefix)) query := ss.replicaBuilder. - Select("Id", "DisplayName", "DeleteAt"). + Select("Id", "Name", "DisplayName", "DeleteAt"). From("UserGroups"). - Where(sq.Like{"LOWER(DisplayName)": sanitizedPrefix + "%"}). + Where(sq.Or{ + sq.Like{"LOWER(DisplayName)": sanitizedPrefix + "%"}, + sq.Like{"LOWER(Name)": sanitizedPrefix + "%"}, + }). Where(sq.Eq{"DeleteAt": 0}). Where(sq.Eq{"Source": "ldap"}). OrderBy("DisplayName"). diff --git a/webapp/src/components/groups_input/groups_input.jsx b/webapp/src/components/groups_input/groups_input.jsx index 7e6afd8..ea56faa 100644 --- a/webapp/src/components/groups_input/groups_input.jsx +++ b/webapp/src/components/groups_input/groups_input.jsx @@ -31,6 +31,9 @@ export default class GroupsInput extends React.Component { return ( {option.display_name} + {option.name && option.name !== option.display_name && ( + {' - @' + option.name} + )} ); } From 1ea8ffbb0947e89c71ac5c0be1699c99eb648a36 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Wed, 2 Apr 2025 11:11:27 -0400 Subject: [PATCH 19/22] Update license request with new required fields --- e2e/playwright/tests/test.setup.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/playwright/tests/test.setup.ts b/e2e/playwright/tests/test.setup.ts index 67808dd..4d1f0a5 100644 --- a/e2e/playwright/tests/test.setup.ts +++ b/e2e/playwright/tests/test.setup.ts @@ -57,12 +57,15 @@ async function ensureLicense(adminClient: Client4) { async function requestTrialLicense(adminClient: Client4) { try { - // @ts-expect-error This may fail requesting for trial license await adminClient.requestTrialLicense({ receive_emails_accepted: true, terms_accepted: true, users: 100, company_country: 'US', + contact_email: process.env.MM_ADMIN_EMAIL ?? '', + contact_name: 'Test Mattermost', + company_name: 'MattermostTest', + company_size: '1-10', }); } catch (e) { // eslint-disable-next-line no-console From b627921de3350f892f7d2c94e1a631eda82f808b Mon Sep 17 00:00:00 2001 From: David Krauser Date: Thu, 10 Apr 2025 16:38:01 -0400 Subject: [PATCH 20/22] Revert "Update license request with new required fields" This reverts commit 1ea8ffbb0947e89c71ac5c0be1699c99eb648a36. --- e2e/playwright/tests/test.setup.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/e2e/playwright/tests/test.setup.ts b/e2e/playwright/tests/test.setup.ts index 4d1f0a5..67808dd 100644 --- a/e2e/playwright/tests/test.setup.ts +++ b/e2e/playwright/tests/test.setup.ts @@ -57,15 +57,12 @@ async function ensureLicense(adminClient: Client4) { async function requestTrialLicense(adminClient: Client4) { try { + // @ts-expect-error This may fail requesting for trial license await adminClient.requestTrialLicense({ receive_emails_accepted: true, terms_accepted: true, users: 100, company_country: 'US', - contact_email: process.env.MM_ADMIN_EMAIL ?? '', - contact_name: 'Test Mattermost', - company_name: 'MattermostTest', - company_size: '1-10', }); } catch (e) { // eslint-disable-next-line no-console From 38f7e445d29dd89a99efcc9093228a82a603e74b Mon Sep 17 00:00:00 2001 From: David Krauser Date: Thu, 19 Jun 2025 15:56:49 -0400 Subject: [PATCH 21/22] Fix broken bits from merge This fixes: - A compilation error (userID -> user.Id) - Broken grid layout on legal hold table view (needed an extra column) - Broken handling of null user ID array --- server/legalhold/legal_hold.go | 2 +- .../legal_hold_table/legal_hold_row/index.ts | 11 ++++++----- .../components/legal_hold_table/legal_hold_table.tsx | 2 +- webapp/src/components/update_legal_hold_form/index.ts | 11 ++++++----- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/server/legalhold/legal_hold.go b/server/legalhold/legal_hold.go index 26ac49e..c172cbb 100644 --- a/server/legalhold/legal_hold.go +++ b/server/legalhold/legal_hold.go @@ -152,7 +152,7 @@ func (ex *Execution) GetChannels() error { ex.papi.LogDebug( "Legal hold executor - GetChannels", - "user_id", userID, + "user_id", user.Id, "channel_count", len(channelIDs), "start_time", ex.ExecutionStartTime, "end_time", ex.ExecutionEndTime, diff --git a/webapp/src/components/legal_hold_table/legal_hold_row/index.ts b/webapp/src/components/legal_hold_table/legal_hold_row/index.ts index fd4ed5c..58b945d 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_row/index.ts +++ b/webapp/src/components/legal_hold_table/legal_hold_row/index.ts @@ -13,15 +13,16 @@ type OwnProps = { function makeMapStateToProps() { return (state: GlobalState, ownProps: OwnProps) => { - if (ownProps.legalHold.user_ids === null) { + if (ownProps.legalHold === null) { return { groups: [], users: [], }; - } - - const groups = ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); - const users = ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); + }; + const users = ownProps.legalHold.user_ids === null ? [] : + ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); + const groups = ownProps.legalHold.group_ids === null ? [] : + ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); return { groups, users, diff --git a/webapp/src/components/legal_hold_table/legal_hold_table.tsx b/webapp/src/components/legal_hold_table/legal_hold_table.tsx index f40b0b1..5d3e76f 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_table.tsx +++ b/webapp/src/components/legal_hold_table/legal_hold_table.tsx @@ -54,7 +54,7 @@ const LegalHoldTable = (props: LegalHoldTableProps) => { aria-label='Legal Holds Table' style={{ display: 'grid', - gridTemplateColumns: 'auto auto auto auto auto auto', + gridTemplateColumns: 'auto auto auto auto auto auto auto', columnGap: '10px', rowGap: '10px', alignItems: 'center', diff --git a/webapp/src/components/update_legal_hold_form/index.ts b/webapp/src/components/update_legal_hold_form/index.ts index cc086b0..de7b7c6 100644 --- a/webapp/src/components/update_legal_hold_form/index.ts +++ b/webapp/src/components/update_legal_hold_form/index.ts @@ -17,15 +17,16 @@ type OwnProps = { function makeMapStateToProps() { return (state: GlobalState, ownProps: OwnProps) => { - if (ownProps.legalHold === null || ownProps.legalHold.user_ids === null) { + if (ownProps.legalHold === null) { return { groups: [], users: [], }; - } - - const groups = ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); - const users = ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); + }; + const users = ownProps.legalHold.user_ids === null ? [] : + ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); + const groups = ownProps.legalHold.group_ids === null ? [] : + ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); return { groups, users, From fae7d237e1576f686a4932432be534757c3efa75 Mon Sep 17 00:00:00 2001 From: David Krauser Date: Fri, 20 Jun 2025 10:20:11 -0400 Subject: [PATCH 22/22] Fix lint issues --- .../src/components/legal_hold_table/legal_hold_row/index.ts | 4 ++-- webapp/src/components/update_legal_hold_form/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/legal_hold_table/legal_hold_row/index.ts b/webapp/src/components/legal_hold_table/legal_hold_row/index.ts index 58b945d..c2f3b80 100644 --- a/webapp/src/components/legal_hold_table/legal_hold_row/index.ts +++ b/webapp/src/components/legal_hold_table/legal_hold_row/index.ts @@ -18,8 +18,8 @@ function makeMapStateToProps() { groups: [], users: [], }; - }; - const users = ownProps.legalHold.user_ids === null ? [] : + } + const users = ownProps.legalHold.user_ids === null ? [] : ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); const groups = ownProps.legalHold.group_ids === null ? [] : ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id)); diff --git a/webapp/src/components/update_legal_hold_form/index.ts b/webapp/src/components/update_legal_hold_form/index.ts index de7b7c6..9f31600 100644 --- a/webapp/src/components/update_legal_hold_form/index.ts +++ b/webapp/src/components/update_legal_hold_form/index.ts @@ -22,8 +22,8 @@ function makeMapStateToProps() { groups: [], users: [], }; - }; - const users = ownProps.legalHold.user_ids === null ? [] : + } + const users = ownProps.legalHold.user_ids === null ? [] : ownProps.legalHold.user_ids.map((user_id) => getUser(state, user_id)); const groups = ownProps.legalHold.group_ids === null ? [] : ownProps.legalHold.group_ids.map((group_id) => getGroup(state, group_id));