Add single-channel guests filter and channel count column to System Console Users (#35517)

* Add single-channel guests filter and channel count column to System Console Users

- Add guest_filter query parameter to Reports API with store-level
  filtering by guest channel membership count (all, single_channel,
  multi_channel)
- Add channel_count field to user report responses and CSV exports
- Add grouped guest role filter options in the filter popover
- Add toggleable Channel count column to the users table
- Add GuestFilter and SearchTerm to Go client GetUsersForReporting
- Add tests: API parsing, API integration, app job dedup, webapp utils,
  E2E column data rendering

Made-with: Cursor

* Fix gofmt alignment and isolate guest store tests

- Align GuestFilter constants to satisfy gofmt
- Move guest user/channel setup into a nested sub-test to avoid
  breaking existing ordering and role filter assertions

Made-with: Cursor

* Exclude archived channels from guest filter queries and ChannelCount

The ChannelMembers subqueries for guest_filter (single/multi channel)
and the ChannelCount column did not join with Channels to check
DeleteAt = 0. Since channel archival soft-deletes (sets DeleteAt) but
leaves ChannelMembers rows intact, archived channel memberships were
incorrectly counted, potentially misclassifying guests between
single-channel and multi-channel filters and inflating ChannelCount.

- Join ChannelMembers with Channels (DeleteAt = 0) in all three
  subqueries in applyUserReportFilter and GetUserReport
- Add store test covering archived channel exclusion
- Tighten existing guest filter test assertions with found-flags
  and exact count checks

Made-with: Cursor

* Exclude DM/GM from guest channel counts, validate GuestFilter, fix dropdown divider

- Scope ChannelCount and guest filter subqueries to Open/Private channel
  types only (exclude DM and GM), so a guest with one team channel plus
  a DM is correctly classified as single-channel
- Add GuestFilter validation in UserReportOptions.IsValid with
  AllowedGuestFilters whitelist
- Add API test for invalid guest_filter rejection (400)
- Add store regression test for DM/GM exclusion
- Fix role filter dropdown: hide the divider above the first group
  heading via CSS rule on DropDown__group:first-child
- Update E2E test label to match "Guests in a single channel" wording

Made-with: Cursor

* Add store test coverage for private and GM channel types

Private channels (type P) should be counted in ChannelCount and guest
filters, while GM channels (type G) should not. Add a test that creates
a guest with memberships in an open channel, a private channel, and a
GM, then asserts ChannelCount = 2, multi-channel filter includes the
guest, and single-channel filter excludes them.

Made-with: Cursor

* Add server i18n translation for invalid_guest_filter error

The new error ID model.user_report_options.is_valid.invalid_guest_filter
was missing from server/i18n/en.json, causing CI to fail.

Made-with: Cursor

* Make filter dropdown dividers full width

Remove the horizontal inset from grouped dropdown separators so the
system user role filter dividers span edge to edge across the menu.
Leave the unrelated webapp/package-lock.json change uncommitted.

Made-with: Cursor

* Optimize guest channel report filters.

Use per-user channel count subqueries for the single- and multi-channel guest filters so the report avoids aggregating all channel memberships before filtering guests.
This commit is contained in:
Maria A Nunez
2026-03-12 12:50:53 -04:00
committed by GitHub
parent 5646f4aa5c
commit 2efee7ec28
26 changed files with 1161 additions and 51 deletions
@@ -50,7 +50,13 @@ export class ColumnToggleMenu {
}
}
type RoleFilter = 'Any' | 'System Admin' | 'Member' | 'Guest';
type RoleFilter =
| 'Any'
| 'System Admin'
| 'Member'
| 'Guests (all)'
| 'Guests in a single channel'
| 'Guests in multiple channels';
type StatusFilter = 'Any' | 'Activated users' | 'Deactivated users';
/**
@@ -22,6 +22,7 @@ export class UsersTable {
readonly lastPostHeader: Locator;
readonly daysActiveHeader: Locator;
readonly messagesPostedHeader: Locator;
readonly channelCountHeader: Locator;
readonly actionsHeader: Locator;
constructor(container: Locator) {
@@ -38,6 +39,7 @@ export class UsersTable {
this.lastPostHeader = container.locator('#systemUsersTable-header-lastPostDateColumn');
this.daysActiveHeader = container.locator('#systemUsersTable-header-daysActiveColumn');
this.messagesPostedHeader = container.locator('#systemUsersTable-header-totalPostsColumn');
this.channelCountHeader = container.locator('#systemUsersTable-header-channelCountColumn');
this.actionsHeader = container.locator('#systemUsersTable-header-actionsColumn');
}
@@ -65,6 +67,7 @@ export class UsersTable {
'Last post': this.lastPostHeader,
'Days active': this.daysActiveHeader,
'Messages posted': this.messagesPostedHeader,
'Channel count': this.channelCountHeader,
Actions: this.actionsHeader,
};
const header = headerMap[columnName];
@@ -143,6 +146,7 @@ export class UserRow {
readonly lastPostCell: Locator;
readonly daysActiveCell: Locator;
readonly messagesPostedCell: Locator;
readonly channelCountCell: Locator;
readonly actionsCell: Locator;
// User details components
@@ -168,6 +172,7 @@ export class UserRow {
this.lastPostCell = container.locator('.lastPostDateColumn');
this.daysActiveCell = container.locator('.daysActiveColumn');
this.messagesPostedCell = container.locator('.totalPostsColumn');
this.channelCountCell = container.locator('.channelCountColumn');
this.actionsCell = container.locator('.actionsColumn');
this.profilePicture = this.userDetailsCell.locator('.profilePicture');
@@ -29,7 +29,7 @@ test('MM-T5523-3 Should list the column names with checkboxes in the correct ord
const menuItemsTexts = await menuItems.allInnerTexts();
// * Verify menu items exists in the correct order
expect(menuItemsTexts).toHaveLength(9);
expect(menuItemsTexts).toHaveLength(10);
expect(menuItemsTexts).toEqual([
'User details',
'Email',
@@ -39,6 +39,7 @@ test('MM-T5523-3 Should list the column names with checkboxes in the correct ord
'Last post',
'Days active',
'Messages posted',
'Channel count',
'Actions',
]);
});
@@ -124,3 +125,115 @@ test('MM-T5523-5 Should show/hide the columns which are toggled on/off', async (
// * Verify that however Last login column is still hidden as we did not check it on
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Last login'})).not.toBeVisible();
});
/**
* @objective Verify that the Channel count column displays a numeric value for a user with known channel memberships
*
* @precondition
* A guest user exists with exactly two channel memberships
*/
test(
'displays numeric channel count value when Channel count column is enabled',
{tag: '@system_users'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Create two channels
const ch1Name = `count-ch1-${await pw.random.id()}`;
const channel1 = await adminClient.createChannel({
team_id: team.id,
name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch1Name,
type: 'O',
});
const ch2Name = `count-ch2-${await pw.random.id()}`;
const channel2 = await adminClient.createChannel({
team_id: team.id,
name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch2Name,
type: 'O',
});
// # Create a guest user and add to exactly two channels
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
await adminClient.addToChannel(guestUser.id, channel1.id);
await adminClient.addToChannel(guestUser.id, channel2.id);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Enable Channel count column
const columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
await columnToggleMenu.clickMenuItem('Channel count');
await columnToggleMenu.close();
// * Verify Channel count column header is visible
await expect(
systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}),
).toBeVisible();
// # Search for the guest user
await systemConsolePage.users.searchUsers(guestUser.email);
await systemConsolePage.users.isLoadingComplete();
// * Verify the Channel count cell displays the expected numeric value
const firstRow = systemConsolePage.users.container.locator('tbody tr').first();
const channelCountCell = firstRow.locator('.channelCountColumn');
await expect(channelCountCell).toHaveText('2');
},
);
/**
* @objective Verify that the Channel count column can be toggled on and off
*/
test('toggles Channel count column visibility on and off', {tag: '@system_users'}, async ({pw}) => {
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the column toggle menu and enable Channel count
let columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
await columnToggleMenu.clickMenuItem('Channel count');
await columnToggleMenu.close();
// * Verify Channel count column header is visible
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'})).toBeVisible();
// # Open column toggle menu again and disable Channel count
columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
await columnToggleMenu.clickMenuItem('Channel count');
await columnToggleMenu.close();
// * Verify Channel count column header is hidden
await expect(
systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}),
).not.toBeVisible();
});
@@ -78,7 +78,7 @@ test('MM-T5521-8 Should be able to filter users with role filter', async ({pw})
const filterPopover = await systemConsolePage.users.openFilterPopover();
// # Open the role filter in the popover and select Guest
await filterPopover.filterByRole('Guest');
await filterPopover.filterByRole('Guests (all)');
// # Save the filter and close the popover
await filterPopover.save();
@@ -145,3 +145,177 @@ test('MM-T5521-9 Should be able to filter users with status filter', async ({pw}
// * Verify that regular user is not visible as 'Deactivated' status filter was applied
await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible();
});
/**
* @objective Verify that the role filter dropdown shows all guest filter variants
*/
test('displays all guest filter variants in the role filter dropdown', {tag: '@system_users'}, async ({pw}) => {
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover
const filterPopover = await systemConsolePage.users.openFilterPopover();
// # Open the role filter menu
await filterPopover.openRoleMenu();
// * Verify all 6 role filter options are present
const roleOptions = filterPopover.container.getByRole('option');
const roleTexts = await roleOptions.allInnerTexts();
expect(roleTexts).toEqual([
'Any',
'System Admin',
'Member',
'Guests (all)',
'Guests in a single channel',
'Guests in multiple channels',
]);
});
/**
* @objective Verify that filtering by single-channel guest filter returns only guests with exactly one channel membership
*
* @precondition
* A guest user exists with exactly one channel membership
*/
test(
'filters users by single-channel guest filter and shows only single-channel guests',
{tag: '@system_users'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Create a channel
const channelName = `guest-ch-${await pw.random.id()}`;
const channel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'O',
});
// # Create a guest user and add to exactly one channel
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover and filter by single-channel guests
const filterPopover = await systemConsolePage.users.openFilterPopover();
await filterPopover.filterByRole('Guests in a single channel');
await filterPopover.save();
await systemConsolePage.users.isLoadingComplete();
// # Search for the guest user
await systemConsolePage.users.searchUsers(guestUser.email);
// * Verify the single-channel guest is visible
await expect(systemConsolePage.users.container.getByText(guestUser.email)).toBeVisible();
},
);
/**
* @objective Verify that filtering by multi-channel guest filter returns only guests with more than one channel membership
*
* @precondition
* A guest user exists with two channel memberships and another with one
*/
test(
'filters users by multi-channel guest filter and excludes single-channel guests',
{tag: '@system_users'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Create two channels
const ch1Name = `guest-multi-1-${await pw.random.id()}`;
const channel1 = await adminClient.createChannel({
team_id: team.id,
name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch1Name,
type: 'O',
});
const ch2Name = `guest-multi-2-${await pw.random.id()}`;
const channel2 = await adminClient.createChannel({
team_id: team.id,
name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch2Name,
type: 'O',
});
// # Create a guest user with 2 channel memberships
const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest');
await adminClient.addToTeam(team.id, multiChannelGuest.id);
await adminClient.addToChannel(multiChannelGuest.id, channel1.id);
await adminClient.addToChannel(multiChannelGuest.id, channel2.id);
// # Create a guest user with only 1 channel membership
const singleChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(singleChannelGuest.id, 'system_guest');
await adminClient.addToTeam(team.id, singleChannelGuest.id);
await adminClient.addToChannel(singleChannelGuest.id, channel1.id);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover and filter by multi-channel guests
const filterPopover = await systemConsolePage.users.openFilterPopover();
await filterPopover.filterByRole('Guests in multiple channels');
await filterPopover.save();
await systemConsolePage.users.isLoadingComplete();
// # Search for the multi-channel guest
await systemConsolePage.users.searchUsers(multiChannelGuest.email);
// * Verify the multi-channel guest is visible
await expect(systemConsolePage.users.container.getByText(multiChannelGuest.email)).toBeVisible();
// # Search for the single-channel guest
await systemConsolePage.users.searchUsers(singleChannelGuest.email);
// * Verify the single-channel guest is NOT visible
await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible();
},
);
+1
View File
@@ -154,6 +154,7 @@ func fillUserReportOptions(values url.Values) (*model.UserReportOptions, *model.
HideActive: hideActive,
HideInactive: hideInactive,
SearchTerm: values.Get("search_term"),
GuestFilter: values.Get("guest_filter"),
}, nil
}
+102
View File
@@ -85,6 +85,69 @@ func TestGetUsersForReporting(t *testing.T) {
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("should filter by guest_filter single_channel and return channel_count", func(t *testing.T) {
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
// Create a guest user with exactly one channel membership
singleChannelGuest := th.CreateUser(t)
_, appErr := th.App.UpdateUserRoles(th.Context, singleChannelGuest.Id, model.SystemGuestRoleId, false)
require.Nil(t, appErr)
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, singleChannelGuest.Id, "")
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, singleChannelGuest, th.BasicChannel, false)
require.Nil(t, appErr)
// Create a guest user with two channel memberships
multiChannelGuest := th.CreateUser(t)
_, appErr = th.App.UpdateUserRoles(th.Context, multiChannelGuest.Id, model.SystemGuestRoleId, false)
require.Nil(t, appErr)
_, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, multiChannelGuest.Id, "")
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel, false)
require.Nil(t, appErr)
_, appErr = th.App.AddUserToChannel(th.Context, multiChannelGuest, th.BasicChannel2, false)
require.Nil(t, appErr)
options := &model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
PageSize: 100,
},
GuestFilter: model.GuestFilterSingleChannel,
}
userReports, resp, err := client.GetUsersForReporting(context.Background(), options)
require.NoError(t, err)
CheckOKStatus(t, resp)
foundSingle := false
for _, report := range userReports {
require.Contains(t, report.Roles, "system_guest")
require.NotNil(t, report.ChannelCount)
if report.Id == singleChannelGuest.Id {
foundSingle = true
require.Equal(t, 1, *report.ChannelCount)
}
require.NotEqual(t, multiChannelGuest.Id, report.Id)
}
require.True(t, foundSingle, "single-channel guest not found in results")
})
t.Run("should reject invalid guest_filter value", func(t *testing.T) {
th.AddPermissionToRole(t, model.PermissionSysconsoleReadUserManagementUsers.Id, model.SystemUserRoleId)
options := &model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
PageSize: 50,
},
GuestFilter: "invalid_value",
}
_, resp, err := client.GetUsersForReporting(context.Background(), options)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
}
func TestFillReportingBaseOptions(t *testing.T) {
@@ -182,6 +245,45 @@ func TestFillUserReportOptions(t *testing.T) {
require.Equal(t, validTeamID, options.Team)
})
t.Run("guest_filter all", func(t *testing.T) {
values := url.Values{}
values.Set("guest_filter", "all")
options, appErr := fillUserReportOptions(values)
require.Nil(t, appErr)
require.Equal(t, "all", options.GuestFilter)
})
t.Run("guest_filter single_channel", func(t *testing.T) {
values := url.Values{}
values.Set("guest_filter", "single_channel")
options, appErr := fillUserReportOptions(values)
require.Nil(t, appErr)
require.Equal(t, "single_channel", options.GuestFilter)
})
t.Run("guest_filter multi_channel", func(t *testing.T) {
values := url.Values{}
values.Set("guest_filter", "multi_channel")
options, appErr := fillUserReportOptions(values)
require.Nil(t, appErr)
require.Equal(t, "multi_channel", options.GuestFilter)
})
t.Run("guest_filter defaults to empty when not provided", func(t *testing.T) {
values := url.Values{}
options, appErr := fillUserReportOptions(values)
require.Nil(t, appErr)
require.Equal(t, "", options.GuestFilter)
})
}
func TestGetPostsForReporting(t *testing.T) {
+3 -1
View File
@@ -214,6 +214,7 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOption
"hide_inactive": strconv.FormatBool(ro.HideInactive),
"start_at": strconv.FormatInt(startAt, 10),
"end_at": strconv.FormatInt(endAt, 10),
"guest_filter": ro.GuestFilter,
}
// Check for existing jobs
@@ -269,7 +270,8 @@ func (a *App) checkForExistingJobs(rctx request.CTX, options map[string]string,
job.Data["role"] == options["role"] &&
job.Data["team"] == options["team"] &&
job.Data["hide_active"] == options["hide_active"] &&
job.Data["hide_inactive"] == options["hide_inactive"] {
job.Data["hide_inactive"] == options["hide_inactive"] &&
job.Data["guest_filter"] == options["guest_filter"] {
return true
}
}
+36
View File
@@ -120,6 +120,7 @@ func TestCheckForExistingJobs(t *testing.T) {
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
jobType := model.JobTypeExportUsersToCSV
@@ -147,6 +148,7 @@ func TestCheckForExistingJobs(t *testing.T) {
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
jobType := model.JobTypeExportUsersToCSV
@@ -178,6 +180,7 @@ func TestCheckForExistingJobs(t *testing.T) {
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
jobType := model.JobTypeExportUsersToCSV
@@ -189,6 +192,7 @@ func TestCheckForExistingJobs(t *testing.T) {
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "",
}
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, differentOptions)
@@ -199,4 +203,36 @@ func TestCheckForExistingJobs(t *testing.T) {
appErr := app.checkForExistingJobs(th.Context, options, jobType)
require.Nil(t, appErr)
})
t.Run("should not return error if existing job has different guest_filter", func(t *testing.T) {
app := th.App
options := map[string]string{
"date_range": "last_30_days",
"requesting_user_id": th.BasicUser.Id,
"role": "",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "single_channel",
}
jobType := model.JobTypeExportUsersToCSV
existingJobOptions := map[string]string{
"date_range": "last_30_days",
"requesting_user_id": th.BasicUser.Id,
"role": "",
"team": "",
"hide_active": "false",
"hide_inactive": "false",
"guest_filter": "multi_channel",
}
job, err := app.Srv().Jobs.CreateJob(th.Context, jobType, existingJobOptions)
require.Nil(t, err)
require.NotNil(t, job)
appErr := app.checkForExistingJobs(th.Context, options, jobType)
require.Nil(t, appErr)
})
}
@@ -43,6 +43,7 @@ func MakeWorker(jobServer *jobs.JobServer, store store.Store, app ExportUsersToC
"LastPostDate",
"DaysActive",
"TotalPosts",
"ChannelCount",
"DeletedAt",
},
getData(app),
@@ -90,6 +91,7 @@ func parseJobMetadata(data model.StringMap) (*model.UserReportOptions, error) {
HideActive: hideActive,
Role: data["role"],
Team: data["team"],
GuestFilter: data["guest_filter"],
}
return &options, nil
+15 -2
View File
@@ -2379,7 +2379,19 @@ func (us SqlUserStore) RefreshPostStatsForUsers() error {
}
func applyUserReportFilter(query sq.SelectBuilder, filter *model.UserReportOptions) sq.SelectBuilder {
query = applyRoleFilter(query, filter.Role)
switch filter.GuestFilter {
case model.GuestFilterAll:
query = applyRoleFilter(query, "system_guest")
case model.GuestFilterSingleChannel:
query = applyRoleFilter(query, "system_guest")
query = query.Where(sq.Expr("(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) = 1"))
case model.GuestFilterMultipleChannel:
query = applyRoleFilter(query, "system_guest")
query = query.Where(sq.Expr("(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) > 1"))
default:
query = applyRoleFilter(query, filter.Role)
}
if filter.HasNoTeam {
query = query.Where(sq.Expr("Users.Id NOT IN (SELECT UserId FROM TeamMembers WHERE DeleteAt = 0)"))
} else if filter.Team != "" {
@@ -2426,6 +2438,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.
"MAX(ps.LastPostDate) AS LastPostDate",
"COUNT(ps.Day) AS DaysActive",
"SUM(ps.NumPosts) AS TotalPosts",
"(SELECT COUNT(*) FROM ChannelMembers cm INNER JOIN Channels c ON c.Id = cm.ChannelId AND c.DeleteAt = 0 AND c.Type IN ('O','P') WHERE cm.UserId = Users.Id) AS ChannelCount",
)
sortDirection := "ASC"
@@ -2502,7 +2515,7 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.
}
parentQuery = us.getQueryBuilder().
Select(getUsersColumnsWithName("data", "LastStatusAt", "LastPostDate", "DaysActive", "TotalPosts")...).
Select(getUsersColumnsWithName("data", "LastStatusAt", "LastPostDate", "DaysActive", "TotalPosts", "ChannelCount")...).
FromSelect(query, "data").
OrderBy(filter.SortColumn+" "+reverseSortDirection, "Id")
}
@@ -6843,6 +6843,470 @@ func testGetUserReport(t *testing.T, rctx request.CTX, ss store.Store, s SqlStor
require.NoError(t, err)
require.Len(t, userReport, 11)
})
t.Run("guest channel count and filters", func(t *testing.T) {
guestChannel1, chErr := ss.Channel().Save(rctx, &model.Channel{
TeamId: team.Id,
DisplayName: "Guest Channel 1",
Name: "guest_channel_1_" + model.NewId(),
Type: model.ChannelTypeOpen,
}, 100)
require.NoError(t, chErr)
guestChannel2, chErr := ss.Channel().Save(rctx, &model.Channel{
TeamId: team.Id,
DisplayName: "Guest Channel 2",
Name: "guest_channel_2_" + model.NewId(),
Type: model.ChannelTypeOpen,
}, 100)
require.NoError(t, chErr)
guestNoChannels := &model.User{Username: "zguest_nochannel_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
guestNoChannels, gErr := ss.User().Save(rctx, guestNoChannels)
require.NoError(t, gErr)
guestOneChannel := &model.User{Username: "zguest_onechannel_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
guestOneChannel, gErr = ss.User().Save(rctx, guestOneChannel)
require.NoError(t, gErr)
guestTwoChannels := &model.User{Username: "zguest_twochannels_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
guestTwoChannels, gErr = ss.User().Save(rctx, guestTwoChannels)
require.NoError(t, gErr)
_, mErr := ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: guestChannel1.Id,
UserId: guestOneChannel.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: guestChannel1.Id,
UserId: guestTwoChannels.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: guestChannel2.Id,
UserId: guestTwoChannels.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
defer func() {
require.NoError(t, ss.User().PermanentDelete(rctx, guestNoChannels.Id))
require.NoError(t, ss.User().PermanentDelete(rctx, guestOneChannel.Id))
require.NoError(t, ss.User().PermanentDelete(rctx, guestTwoChannels.Id))
require.NoError(t, ss.Channel().PermanentDelete(rctx, guestChannel1.Id))
require.NoError(t, ss.Channel().PermanentDelete(rctx, guestChannel2.Id))
}()
t.Run("should return channel count for users", func(t *testing.T) {
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
})
require.NoError(t, rErr)
require.NotNil(t, userReport)
foundOne, foundTwo, foundNone := false, false, false
for _, report := range userReport {
if report.Username == guestOneChannel.Username {
foundOne = true
require.NotNil(t, report.ChannelCount)
require.Equal(t, 1, *report.ChannelCount)
}
if report.Username == guestTwoChannels.Username {
foundTwo = true
require.NotNil(t, report.ChannelCount)
require.Equal(t, 2, *report.ChannelCount)
}
if report.Username == guestNoChannels.Username {
foundNone = true
require.NotNil(t, report.ChannelCount)
require.Equal(t, 0, *report.ChannelCount)
}
}
require.True(t, foundOne, "guestOneChannel not found in report")
require.True(t, foundTwo, "guestTwoChannels not found in report")
require.True(t, foundNone, "guestNoChannels not found in report")
})
t.Run("guest filter all should return all guests", func(t *testing.T) {
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterAll,
})
require.NoError(t, rErr)
require.NotNil(t, userReport)
foundNone, foundOne, foundTwo := false, false, false
for _, report := range userReport {
require.Contains(t, report.Roles, "system_guest")
switch report.Username {
case guestNoChannels.Username:
foundNone = true
case guestOneChannel.Username:
foundOne = true
case guestTwoChannels.Username:
foundTwo = true
}
}
require.True(t, foundNone, "guestNoChannels not found in guest-all filter")
require.True(t, foundOne, "guestOneChannel not found in guest-all filter")
require.True(t, foundTwo, "guestTwoChannels not found in guest-all filter")
require.Equal(t, 3, len(userReport))
})
t.Run("guest filter single_channel should return guests with exactly 1 channel", func(t *testing.T) {
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterSingleChannel,
})
require.NoError(t, rErr)
require.NotNil(t, userReport)
found := false
for _, report := range userReport {
require.Contains(t, report.Roles, "system_guest")
if report.Username == guestOneChannel.Username {
found = true
require.NotNil(t, report.ChannelCount)
require.Equal(t, 1, *report.ChannelCount)
}
require.NotEqual(t, guestNoChannels.Username, report.Username)
require.NotEqual(t, guestTwoChannels.Username, report.Username)
}
require.True(t, found, "single-channel guest not found in results")
})
t.Run("guest filter multi_channel should return guests with more than 1 channel", func(t *testing.T) {
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterMultipleChannel,
})
require.NoError(t, rErr)
require.NotNil(t, userReport)
found := false
for _, report := range userReport {
require.Contains(t, report.Roles, "system_guest")
if report.Username == guestTwoChannels.Username {
found = true
require.NotNil(t, report.ChannelCount)
require.Equal(t, 2, *report.ChannelCount)
}
require.NotEqual(t, guestNoChannels.Username, report.Username)
require.NotEqual(t, guestOneChannel.Username, report.Username)
}
require.True(t, found, "multi-channel guest not found in results")
})
t.Run("archived channel should not count toward channel memberships", func(t *testing.T) {
archivedChannel, chErr := ss.Channel().Save(rctx, &model.Channel{
TeamId: team.Id,
DisplayName: "Archived Channel",
Name: "archived_channel_" + model.NewId(),
Type: model.ChannelTypeOpen,
}, 100)
require.NoError(t, chErr)
guestWithArchived := &model.User{Username: "zguest_archived_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
guestWithArchived, gErr = ss.User().Save(rctx, guestWithArchived)
require.NoError(t, gErr)
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: guestChannel1.Id,
UserId: guestWithArchived.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: archivedChannel.Id,
UserId: guestWithArchived.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
err := ss.Channel().Delete(archivedChannel.Id, model.GetMillis())
require.NoError(t, err)
defer func() {
require.NoError(t, ss.User().PermanentDelete(rctx, guestWithArchived.Id))
require.NoError(t, ss.Channel().PermanentDelete(rctx, archivedChannel.Id))
}()
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
})
require.NoError(t, rErr)
for _, report := range userReport {
if report.Username == guestWithArchived.Username {
require.NotNil(t, report.ChannelCount)
require.Equal(t, 1, *report.ChannelCount, "archived channel should not be counted")
}
}
singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterSingleChannel,
})
require.NoError(t, rErr)
found := false
for _, report := range singleReport {
if report.Username == guestWithArchived.Username {
found = true
}
}
require.True(t, found, "guest with one active channel and one archived channel should appear in single-channel filter")
multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterMultipleChannel,
})
require.NoError(t, rErr)
for _, report := range multiReport {
require.NotEqual(t, guestWithArchived.Username, report.Username,
"guest with one active channel and one archived channel should NOT appear in multi-channel filter")
}
})
t.Run("DM and GM channels should not count toward guest channel memberships", func(t *testing.T) {
guestWithDM := &model.User{Username: "zguest_dm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
guestWithDM, gErr = ss.User().Save(rctx, guestWithDM)
require.NoError(t, gErr)
otherUser := &model.User{Username: "zother_dm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_user"}
otherUser, gErr = ss.User().Save(rctx, otherUser)
require.NoError(t, gErr)
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: guestChannel1.Id,
UserId: guestWithDM.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
dmChannel := &model.Channel{
Name: model.GetDMNameFromIds(guestWithDM.Id, otherUser.Id),
Type: model.ChannelTypeDirect,
}
dmMember1 := &model.ChannelMember{
UserId: guestWithDM.Id,
ChannelId: dmChannel.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
}
dmMember2 := &model.ChannelMember{
UserId: otherUser.Id,
ChannelId: dmChannel.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
}
dmChannel, dmErr := ss.Channel().SaveDirectChannel(rctx, dmChannel, dmMember1, dmMember2)
require.NoError(t, dmErr)
defer func() {
require.NoError(t, ss.User().PermanentDelete(rctx, guestWithDM.Id))
require.NoError(t, ss.User().PermanentDelete(rctx, otherUser.Id))
require.NoError(t, ss.Channel().PermanentDelete(rctx, dmChannel.Id))
}()
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
})
require.NoError(t, rErr)
for _, report := range userReport {
if report.Username == guestWithDM.Username {
require.NotNil(t, report.ChannelCount)
require.Equal(t, 1, *report.ChannelCount, "DM channel should not be counted")
}
}
singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterSingleChannel,
})
require.NoError(t, rErr)
found := false
for _, report := range singleReport {
if report.Username == guestWithDM.Username {
found = true
}
}
require.True(t, found, "guest with one team channel and one DM should appear in single-channel filter")
multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterMultipleChannel,
})
require.NoError(t, rErr)
for _, report := range multiReport {
require.NotEqual(t, guestWithDM.Username, report.Username,
"guest with one team channel and one DM should NOT appear in multi-channel filter")
}
})
t.Run("private channels should count but GM channels should not", func(t *testing.T) {
privateChannel, chErr := ss.Channel().Save(rctx, &model.Channel{
TeamId: team.Id,
DisplayName: "Private Channel",
Name: "private_channel_" + model.NewId(),
Type: model.ChannelTypePrivate,
}, 100)
require.NoError(t, chErr)
gmChannel, chErr := ss.Channel().Save(rctx, &model.Channel{
DisplayName: "Group Message",
Name: "gm_channel_" + model.NewId(),
Type: model.ChannelTypeGroup,
}, -1)
require.NoError(t, chErr)
guestPrivateGM := &model.User{Username: "zguest_privgm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_guest"}
guestPrivateGM, gErr = ss.User().Save(rctx, guestPrivateGM)
require.NoError(t, gErr)
gmOtherUser := &model.User{Username: "zother_gm_" + model.NewId()[:8], Email: MakeEmail(), Roles: "system_user"}
gmOtherUser, gErr = ss.User().Save(rctx, gmOtherUser)
require.NoError(t, gErr)
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: guestChannel1.Id,
UserId: guestPrivateGM.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: privateChannel.Id,
UserId: guestPrivateGM.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
for _, uid := range []string{guestPrivateGM.Id, gmOtherUser.Id} {
_, mErr = ss.Channel().SaveMember(rctx, &model.ChannelMember{
ChannelId: gmChannel.Id,
UserId: uid,
NotifyProps: model.GetDefaultChannelNotifyProps(),
})
require.NoError(t, mErr)
}
defer func() {
require.NoError(t, ss.User().PermanentDelete(rctx, guestPrivateGM.Id))
require.NoError(t, ss.User().PermanentDelete(rctx, gmOtherUser.Id))
require.NoError(t, ss.Channel().PermanentDelete(rctx, privateChannel.Id))
require.NoError(t, ss.Channel().PermanentDelete(rctx, gmChannel.Id))
}()
userReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
})
require.NoError(t, rErr)
for _, report := range userReport {
if report.Username == guestPrivateGM.Username {
require.NotNil(t, report.ChannelCount)
require.Equal(t, 2, *report.ChannelCount, "should count open + private, not GM")
}
}
multiReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterMultipleChannel,
})
require.NoError(t, rErr)
found := false
for _, report := range multiReport {
if report.Username == guestPrivateGM.Username {
found = true
}
}
require.True(t, found, "guest with open + private channels should appear in multi-channel filter")
singleReport, rErr := ss.User().GetUserReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
PageSize: 200,
},
GuestFilter: model.GuestFilterSingleChannel,
})
require.NoError(t, rErr)
for _, report := range singleReport {
require.NotEqual(t, guestPrivateGM.Username, report.Username,
"guest with 2 team channels should NOT appear in single-channel filter")
}
})
t.Run("guest filter count query should match", func(t *testing.T) {
allCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
},
GuestFilter: model.GuestFilterAll,
})
require.NoError(t, rErr)
require.Equal(t, int64(3), allCount)
singleCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
},
GuestFilter: model.GuestFilterSingleChannel,
})
require.NoError(t, rErr)
require.Equal(t, int64(1), singleCount)
multiCount, rErr := ss.User().GetUserCountForReport(&model.UserReportOptions{
ReportingBaseOptions: model.ReportingBaseOptions{
SortColumn: "Username",
},
GuestFilter: model.GuestFilterMultipleChannel,
})
require.NoError(t, rErr)
require.Equal(t, int64(1), multiCount)
// guestNoChannels has 0 active channels, so it appears in "all" but
// neither "single" nor "multi"; the sum is strictly less than allCount.
require.Equal(t, int64(2), singleCount+multiCount)
require.Less(t, singleCount+multiCount, allCount)
})
})
}
func testMfaUsedTimestamps(t *testing.T, rctx request.CTX, ss store.Store) {
+4
View File
@@ -11656,6 +11656,10 @@
"id": "model.user_access_token.is_valid.user_id.app_error",
"translation": "Invalid user id."
},
{
"id": "model.user_report_options.is_valid.invalid_guest_filter",
"translation": "Provided guest filter is not valid."
},
{
"id": "model.user_report_options.is_valid.invalid_sort_column",
"translation": "Provided sort column is not valid."
+6
View File
@@ -1983,6 +1983,12 @@ func (c *Client4) GetUsersForReporting(ctx context.Context, options *UserReportO
if options.DateRange != "" {
values.Set("date_range", options.DateRange)
}
if options.GuestFilter != "" {
values.Set("guest_filter", options.GuestFilter)
}
if options.SearchTerm != "" {
values.Set("search_term", options.SearchTerm)
}
r, err := c.doAPIGetWithQuery(ctx, c.reportsRoute().Join("users"), values, "")
if err != nil {
+19
View File
@@ -17,12 +17,18 @@ const (
ReportDurationLast6Months = "last_6_months"
ReportingMaxPageSize = 100
GuestFilterAll = "all"
GuestFilterSingleChannel = "single_channel"
GuestFilterMultipleChannel = "multi_channel"
)
var (
ReportExportFormats = []string{"csv"}
UserReportSortColumns = []string{"CreateAt", "Username", "FirstName", "LastName", "Nickname", "Email", "Roles"}
AllowedGuestFilters = []string{GuestFilterAll, GuestFilterSingleChannel, GuestFilterMultipleChannel}
)
type ReportableObject interface {
@@ -76,11 +82,13 @@ func (options *ReportingBaseOptions) IsValid() *AppError {
type UserReportQuery struct {
User
UserPostStats
ChannelCount *int
}
type UserReport struct {
User
UserPostStats
ChannelCount *int `json:"channel_count,omitempty"`
}
func (u *UserReport) ToReport() []string {
@@ -100,6 +108,10 @@ func (u *UserReport) ToReport() []string {
if u.TotalPosts != nil {
totalPosts = strconv.Itoa(*u.TotalPosts)
}
channelCount := ""
if u.ChannelCount != nil {
channelCount = strconv.Itoa(*u.ChannelCount)
}
lastLogin := ""
if u.LastLogin > 0 {
lastLogin = time.UnixMilli(u.LastLogin).String()
@@ -122,6 +134,7 @@ func (u *UserReport) ToReport() []string {
lastPostDate,
daysActive,
totalPosts,
channelCount,
deleteAt,
}
}
@@ -134,6 +147,7 @@ type UserReportOptions struct {
HideActive bool
HideInactive bool
SearchTerm string
GuestFilter string
}
func (u *UserReportOptions) IsValid() *AppError {
@@ -146,6 +160,10 @@ func (u *UserReportOptions) IsValid() *AppError {
return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_sort_column", nil, "", http.StatusBadRequest)
}
if u.GuestFilter != "" && !slices.Contains(AllowedGuestFilters, u.GuestFilter) {
return NewAppError("UserReportOptions.IsValid", "model.user_report_options.is_valid.invalid_guest_filter", nil, "", http.StatusBadRequest)
}
return nil
}
@@ -154,6 +172,7 @@ func (u *UserReportQuery) ToReport() *UserReport {
return &UserReport{
User: u.User,
UserPostStats: u.UserPostStats,
ChannelCount: u.ChannelCount,
}
}
@@ -11,6 +11,7 @@ export enum ColumnNames {
lastPostDate = 'lastPostDateColumn',
daysActive = 'daysActiveColumn',
totalPosts = 'totalPostsColumn',
channelCount = 'channelCountColumn',
actions = 'actionsColumn',
}
@@ -24,7 +25,9 @@ export enum RoleFilters {
Any = 'any',
Admin = 'system_admin',
Member = 'system_user',
Guest = 'system_guest',
GuestAll = 'system_guest',
GuestSingleChannel = 'guest_single_channel',
GuestMultiChannel = 'guest_multi_channel',
}
export enum TeamFilters {
@@ -383,6 +383,21 @@ function SystemUsers(props: Props) {
enablePinning: false,
enableSorting: false,
},
{
id: ColumnNames.channelCount,
accessorKey: 'channel_count',
header: formatMessage({
id: 'admin.system_users.list.channelCount',
defaultMessage: 'Channel count',
}),
cell: (info: CellContext<UserReport, number | undefined>) => info.getValue() ?? null,
meta: {
isNumeric: true,
},
enableHiding: true,
enablePinning: false,
enableSorting: false,
},
{
id: ColumnNames.actions,
accessorKey: 'actions',
@@ -80,6 +80,13 @@ export function SystemUsersColumnTogglerMenu(props: Props) {
defaultMessage='Messages posted'
/>
);
case ColumnNames.channelCount:
return (
<FormattedMessage
id='admin.system_users.list.channelCount'
defaultMessage='Channel count'
/>
);
case ColumnNames.actions:
return (
<FormattedMessage
@@ -83,8 +83,12 @@ export function SystemUsersFilterPopover(props: Props) {
filterRole = RoleFilters.Admin;
} else if (roleFilter === RoleFilters.Member) {
filterRole = RoleFilters.Member;
} else if (roleFilter === RoleFilters.Guest) {
filterRole = RoleFilters.Guest;
} else if (roleFilter === RoleFilters.GuestAll) {
filterRole = RoleFilters.GuestAll;
} else if (roleFilter === RoleFilters.GuestSingleChannel) {
filterRole = RoleFilters.GuestSingleChannel;
} else if (roleFilter === RoleFilters.GuestMultiChannel) {
filterRole = RoleFilters.GuestMultiChannel;
}
setFilterState({...filterState, filterRole});
@@ -3,6 +3,7 @@
import React, {useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import type {GroupBase} from 'react-select';
import DropdownInput from 'components/dropdown_input';
@@ -24,40 +25,64 @@ type Props = {
export function SystemUsersFilterRole(props: Props) {
const {formatMessage} = useIntl();
const options = useMemo(() => {
return [
{
value: RoleFilters.Any,
label: formatMessage({
id: 'admin.system_users.filters.role.any',
defaultMessage: 'Any',
}),
},
{
value: RoleFilters.Admin,
label: formatMessage({
id: 'admin.system_users.filters.role.system_admin',
defaultMessage: 'System Admin',
}),
},
{
value: RoleFilters.Member,
label: formatMessage({
id: 'admin.system_users.filters.role.system_user',
defaultMessage: 'Member',
}),
},
{
value: RoleFilters.Guest,
label: formatMessage({
id: 'admin.system_users.filters.role.system_guest',
defaultMessage: 'Guest',
}),
},
];
}, []);
const anyOption: OptionType = useMemo(() => ({
value: RoleFilters.Any,
label: formatMessage({
id: 'admin.system_users.filters.role.any',
defaultMessage: 'Any',
}),
}), [formatMessage]);
const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, options));
const roleOptions: OptionType[] = useMemo(() => [
{
value: RoleFilters.Admin,
label: formatMessage({
id: 'admin.system_users.filters.role.system_admin',
defaultMessage: 'System Admin',
}),
},
{
value: RoleFilters.Member,
label: formatMessage({
id: 'admin.system_users.filters.role.system_user',
defaultMessage: 'Member',
}),
},
], [formatMessage]);
const guestOptions: OptionType[] = useMemo(() => [
{
value: RoleFilters.GuestAll,
label: formatMessage({
id: 'admin.system_users.filters.role.system_guest',
defaultMessage: 'Guests (all)',
}),
},
{
value: RoleFilters.GuestSingleChannel,
label: formatMessage({
id: 'admin.system_users.filters.role.guest_single_channel',
defaultMessage: 'Guests in a single channel',
}),
},
{
value: RoleFilters.GuestMultiChannel,
label: formatMessage({
id: 'admin.system_users.filters.role.guest_multi_channel',
defaultMessage: 'Guests in multiple channels',
}),
},
], [formatMessage]);
const flatOptions = useMemo(() => [anyOption, ...roleOptions, ...guestOptions], [anyOption, roleOptions, guestOptions]);
const groupedOptions: Array<GroupBase<OptionType>> = useMemo(() => [
{label: '', options: [anyOption]},
{label: '', options: roleOptions},
{label: '', options: guestOptions},
], [anyOption, roleOptions, guestOptions]);
const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, flatOptions));
function handleChange(value: OptionType) {
setValue(value);
@@ -70,7 +95,7 @@ export function SystemUsersFilterRole(props: Props) {
name='filterRole'
isSearchable={false}
legend={formatMessage({id: 'admin.system_users.filters.role.title', defaultMessage: 'Role'})}
options={options}
options={groupedOptions}
value={value}
onChange={handleChange}
/>
@@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GuestFilter} from '@mattermost/types/reports';
import type {UserReport} from '@mattermost/types/reports';
import {ColumnNames, StatusFilter} from '../constants';
import {ColumnNames, RoleFilters, StatusFilter} from '../constants';
import {getSortColumnForOptions, getSortDirectionForOptions, getSortableColumnValueBySortColumn, getStatusFilterOption} from './index';
import {getSortColumnForOptions, getSortDirectionForOptions, getSortableColumnValueBySortColumn, getStatusFilterOption, getRoleFilterOption, convertTableOptionsToUserReportOptions} from './index';
describe('getSortColumnForOptions', () => {
it('should return correct sort column for email', () => {
@@ -73,3 +74,72 @@ describe('getStatusFilterOption', () => {
expect(result).toEqual({});
});
});
describe('getRoleFilterOption', () => {
it('should return undefined for both when role is Any', () => {
const result = getRoleFilterOption(RoleFilters.Any);
expect(result).toEqual({role_filter: undefined, guest_filter: undefined});
});
it('should return undefined for both when role is not provided', () => {
const result = getRoleFilterOption();
expect(result).toEqual({role_filter: undefined, guest_filter: undefined});
});
it('should return role_filter for Admin', () => {
const result = getRoleFilterOption(RoleFilters.Admin);
expect(result).toEqual({role_filter: 'system_admin', guest_filter: undefined});
});
it('should return role_filter for Member', () => {
const result = getRoleFilterOption(RoleFilters.Member);
expect(result).toEqual({role_filter: 'system_user', guest_filter: undefined});
});
it('should return guest_filter all for GuestAll', () => {
const result = getRoleFilterOption(RoleFilters.GuestAll);
expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.All});
});
it('should return guest_filter single_channel for GuestSingleChannel', () => {
const result = getRoleFilterOption(RoleFilters.GuestSingleChannel);
expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.SingleChannel});
});
it('should return guest_filter multi_channel for GuestMultiChannel', () => {
const result = getRoleFilterOption(RoleFilters.GuestMultiChannel);
expect(result).toEqual({role_filter: undefined, guest_filter: GuestFilter.MultipleChannel});
});
});
describe('convertTableOptionsToUserReportOptions', () => {
it('should set guest_filter and not role_filter when filterRole is GuestSingleChannel', () => {
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestSingleChannel});
expect(result.guest_filter).toBe(GuestFilter.SingleChannel);
expect(result.role_filter).toBeUndefined();
});
it('should set guest_filter and not role_filter when filterRole is GuestMultiChannel', () => {
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestMultiChannel});
expect(result.guest_filter).toBe(GuestFilter.MultipleChannel);
expect(result.role_filter).toBeUndefined();
});
it('should set guest_filter and not role_filter when filterRole is GuestAll', () => {
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.GuestAll});
expect(result.guest_filter).toBe(GuestFilter.All);
expect(result.role_filter).toBeUndefined();
});
it('should set role_filter and not guest_filter when filterRole is Admin', () => {
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.Admin});
expect(result.role_filter).toBe('system_admin');
expect(result.guest_filter).toBeUndefined();
});
it('should not set role_filter or guest_filter when filterRole is Any', () => {
const result = convertTableOptionsToUserReportOptions({filterRole: RoleFilters.Any});
expect(result.role_filter).toBeUndefined();
expect(result.guest_filter).toBeUndefined();
});
});
@@ -5,7 +5,7 @@ import type {SortingState} from '@tanstack/react-table';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {UserReportSortColumns, ReportSortDirection} from '@mattermost/types/reports';
import {UserReportSortColumns, ReportSortDirection, GuestFilter} from '@mattermost/types/reports';
import type {UserReportOptions, UserReport} from '@mattermost/types/reports';
import type {Team} from '@mattermost/types/teams';
@@ -186,11 +186,20 @@ export function getDefaultSelectedTeam(teamId: Team['id'] | string, label?: stri
};
}
export function getRoleFilterOption(role?: string): Pick<UserReportOptions, 'role_filter'> {
export function getRoleFilterOption(role?: string): Pick<UserReportOptions, 'role_filter' | 'guest_filter'> {
if (!role || role === RoleFilters.Any) {
return {role_filter: undefined};
return {role_filter: undefined, guest_filter: undefined};
}
return {role_filter: role};
if (role === RoleFilters.GuestAll) {
return {role_filter: undefined, guest_filter: GuestFilter.All};
}
if (role === RoleFilters.GuestSingleChannel) {
return {role_filter: undefined, guest_filter: GuestFilter.SingleChannel};
}
if (role === RoleFilters.GuestMultiChannel) {
return {role_filter: undefined, guest_filter: GuestFilter.MultipleChannel};
}
return {role_filter: role, guest_filter: undefined};
}
export function getSearchFilterOption(search?: string): Pick<UserReportOptions, 'search_term'> {
@@ -33,6 +33,10 @@ $dropdown_input_index: 999999;
background-color: var(--center-channel-bg) !important;
}
.DropDown__menu-list > .DropDown__group:first-child .DropDown__group-heading {
display: none;
}
.DropDown__single-value {
color: var(--center-channel-color) !important;
}
@@ -56,6 +56,19 @@ const baseStyles = {
...provided,
zIndex: 100,
}),
group: (provided) => ({
...provided,
paddingTop: 0,
paddingBottom: 0,
}),
groupHeading: (provided) => ({
...provided,
height: 1,
margin: '4px 0',
padding: 0,
fontSize: 0,
backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.12)',
}),
} satisfies StylesConfig;
const IndicatorsContainer = (props: any) => {
+4 -1
View File
@@ -3040,8 +3040,10 @@
"admin.system_users.exportButton.notLicensed.hint": "This feature is available on the professional plan",
"admin.system_users.exportButton.notLicensed.title": "Professional feature",
"admin.system_users.filters.role.any": "Any",
"admin.system_users.filters.role.guest_multi_channel": "Guests in multiple channels",
"admin.system_users.filters.role.guest_single_channel": "Guests in a single channel",
"admin.system_users.filters.role.system_admin": "System Admin",
"admin.system_users.filters.role.system_guest": "Guest",
"admin.system_users.filters.role.system_guest": "Guests (all)",
"admin.system_users.filters.role.system_user": "Member",
"admin.system_users.filters.role.title": "Role",
"admin.system_users.filters.status.active": "Activated users",
@@ -3079,6 +3081,7 @@
"admin.system_users.list.actions.userGuest": "Guest",
"admin.system_users.list.actions.userMember": "Member",
"admin.system_users.list.caption": "System Users",
"admin.system_users.list.channelCount": "Channel count",
"admin.system_users.list.daysActive": "Days active",
"admin.system_users.list.email": "Email",
"admin.system_users.list.lastActivity": "Last activity",
+3 -1
View File
@@ -66,7 +66,9 @@ export const adminConsoleUserManagementTablePropertiesInitialState: AdminConsole
cursorDirection: CursorPaginationDirection.next,
cursorUserId: '',
cursorColumnValue: '',
columnVisibility: {},
columnVisibility: {
channelCountColumn: false,
},
searchTerm: '',
filterTeam: '',
filterTeamLabel: '',
+8
View File
@@ -24,6 +24,12 @@ export enum ReportDuration {
Last6Months = 'last_6_months',
}
export enum GuestFilter {
All = 'all',
SingleChannel = 'single_channel',
MultipleChannel = 'multi_channel',
}
export enum CursorPaginationDirection {
'prev' = 'prev',
'next' = 'next',
@@ -36,6 +42,7 @@ export type UserReportFilter = {
hide_active?: boolean;
hide_inactive?: boolean;
search_term?: string;
guest_filter?: string;
}
export type UserReportOptions = UserReportFilter & {
@@ -81,4 +88,5 @@ export type UserReport = UserProfile & {
last_post_date?: number;
days_active?: number;
total_posts?: number;
channel_count?: number;
}