Files
Maria A NunezandGitHub 07d005a494 Add single-channel guest tracking and reporting (#35451)
* Add single-channel guest tracking and reporting

- Add AnalyticsGetSingleChannelGuestCount store method to count guests in exactly one channel
- Exclude single-channel guests from active user seat count in GetServerLimits
- Add single-channel guest count to standard analytics response
- Add Single-channel Guests card to System Statistics page with overage warning
- Add Single-channel guests row to Edition and License page with overage styling
- Add dismissible admin-only banner when single-channel guest limit is exceeded
- Gate feature behind non-Entry SKU and guest accounts enabled checks
- Re-fetch server limits on config changes for reactive UI updates
- Fix label alignment in license details panel

Made-with: Cursor

* Refine single-channel guest tracking

- Remove license GuestAccounts feature check from shouldTrackSingleChannelGuests (only config matters)
- Re-add getServerLimits calls on page mount for fresh data
- Remove config-change reactivity code (componentDidUpdate, useEffect)
- Add server i18n translations for error strings
- Sync webapp i18n via extract
- Add inline comments for business logic
- Restore struct field comments in ServerLimits model
- Add Playwright E2E tests for single-channel guest feature
- Fix label alignment in license details panel

Made-with: Cursor

* Guests over limit fixes and PR feedback

* Fix linter issues and code quality improvements

- Use max() builtin to clamp adjusted user count instead of if-statement (modernize linter)
- Change banner type from ADVISOR to CRITICAL for proper red color styling

Made-with: Cursor

* Fix overage warnings incorrectly counting single-channel guests

Single-channel guests are free and should not trigger license seat
overage warnings. Update all overage checks to use
serverLimits.activeUserCount (seat-adjusted, excluding SCG) instead
of the raw total_users_count or TOTAL_USERS analytics stat.

- UserSeatAlertBanner on License page: use serverLimits.activeUserCount
- UserSeatAlertBanner on Site Statistics page: use serverLimits.activeUserCount
- ActivatedUserCard display and overage check: use serverLimits.activeUserCount
- OverageUsersBanner: use serverLimits.activeUserCount

Made-with: Cursor

* Use license.Users as fallback for singleChannelGuestLimit before limits load

This prevents the SingleChannelGuestsCard from showing a false overage
state before serverLimits has been fetched, while still rendering the
card immediately on page load.

Made-with: Cursor

* Fix invite modal overage banner incorrectly counting single-channel guests

Made-with: Cursor

* Fix invitation modal tests missing limits entity in mock state

Made-with: Cursor

* Fix tests

* Add E2E test for single-channel guest exceeded limit scenario

Made-with: Cursor

* Fix TypeScript errors in single channel guests E2E test

Made-with: Cursor

* Fix channel name validation error caused by unawaited async getRandomId()

Made-with: Cursor

* Add contextual tooltips to stat cards when guest accounts are enabled

Made-with: Cursor

* Code review feedback: query builder, readability, tooltips, and alignment fixes

Made-with: Cursor

* Fix license page tooltip alignment, width, and SaveLicense SCG exclusion

Made-with: Cursor

* Fix banner dismiss, license spacing, and add dismiss test

Made-with: Cursor

* Exclude DM/GM channels from single-channel guest count and fix E2E tests

Filter the AnalyticsGetSingleChannelGuestCount query to only count
memberships in public/private channels, excluding DMs and GMs. Update
store tests with DM-only, GM-only, and mixed membership cases. Fix E2E
overage test to mock the server limits API instead of skipping, and
correct banner locator to use data-testid.

Made-with: Cursor
2026-03-10 10:31:10 -04:00

386 lines
16 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test.beforeEach(async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
});
/**
* @objective Verify the Single-channel Guests stat card appears on the Site Statistics page when guests are enabled
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'displays single-channel guests card on site statistics page when guest accounts are enabled',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = true;
await adminClient.updateConfig(config);
// # Create a guest user and add to 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);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Log in as admin and navigate to site statistics
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the single-channel guests card is visible
const singleChannelGuestsCard = systemConsolePage.page.getByTestId('singleChannelGuests');
await expect(singleChannelGuestsCard).toBeVisible();
// * Verify the count is at least 1
const countText = await singleChannelGuestsCard.textContent();
const match = countText?.match(/(\d+)/);
expect(match).toBeTruthy();
expect(Number(match![1])).toBeGreaterThanOrEqual(1);
},
);
/**
* @objective Verify the Single-channel Guests row appears on the Edition and License page when guests are enabled
*
* @precondition
* Server has a non-Entry license with guest accounts enabled and a single-channel guest limit configured
*/
test(
'displays single-channel guests row on edition and license page when guest accounts are enabled',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = true;
await adminClient.updateConfig(config);
// # Create a guest user and add to 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);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Log in as admin and navigate to edition and license page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.page.goto('/admin_console/about/license');
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the single-channel guests row is visible
await expect(systemConsolePage.page.getByText('SINGLE-CHANNEL GUESTS:')).toBeVisible();
},
);
/**
* @objective Verify the Single-channel Guests stat card is not shown when guest accounts are disabled
*/
test(
'hides single-channel guests card on site statistics page when guest accounts are disabled',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Disable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = false;
await adminClient.updateConfig(config);
// # Log in as admin and navigate to site statistics
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the single-channel guests card is not in the DOM
await expect(systemConsolePage.page.getByTestId('singleChannelGuests')).not.toBeVisible();
},
);
/**
* @objective Verify the server limits API returns single-channel guest count and limit for admin users
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'returns single-channel guest data from server limits API for admin users',
{tag: '@system_console'},
async ({pw}) => {
const {adminClient} = await pw.initSetup();
// # Enable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = true;
await adminClient.updateConfig(config);
// # Fetch server limits
const {data: limits} = await adminClient.getServerLimits();
// * Verify the response includes single-channel guest fields
expect(limits).toHaveProperty('singleChannelGuestCount');
expect(limits).toHaveProperty('singleChannelGuestLimit');
expect(typeof limits.singleChannelGuestCount).toBe('number');
expect(typeof limits.singleChannelGuestLimit).toBe('number');
expect(limits.singleChannelGuestCount).toBeGreaterThanOrEqual(0);
expect(limits.singleChannelGuestLimit).toBeGreaterThanOrEqual(0);
},
);
/**
* @objective Verify the single-channel guests card does not show error styling when count is within limit
*
* @precondition
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
*/
test(
'shows no error styling on single-channel guests card when count is within limit',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = true;
await adminClient.updateConfig(config);
// # Create a single-channel guest (count will be well within any license limit)
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'guest-no-overage',
displayName: 'Guest No Overage',
unique: true,
}),
);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Navigate to site statistics
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the card title does NOT have error class (count is within limit)
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
await expect(cardTitle).toBeVisible();
await expect(cardTitle).not.toHaveClass(/team_statistics--error/);
},
);
/**
* @objective Verify the dismissible banner is not shown when single-channel guest count is within limit
*
* @precondition
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
*/
test('does not show guest limit banner when count is within limit', {tag: '@system_console'}, async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = true;
await adminClient.updateConfig(config);
// # Navigate to any page as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// * Verify the guest limit banner is not visible (count is within limit)
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).not.toBeVisible();
});
/**
* @objective Verify error styling appears on the single-channel guests card and dismissible banner shows when single-channel guest count exceeds the limit
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'shows error styling on guests card and banner when single-channel guest count exceeds limit',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = true;
await adminClient.updateConfig(config);
// # Create multiple single-channel guests so the analytics count exceeds the mocked limit
for (let i = 0; i < 3; i++) {
const guest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guest.id, 'system_guest');
await adminClient.addToTeam(team.id, guest.id);
const ch = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: `scg-overage-${i}`,
displayName: `SCG Overage ${i}`,
unique: true,
}),
);
await adminClient.addToChannel(guest.id, ch.id);
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Mock the server limits API to simulate overage by returning a limit of 1
await systemConsolePage.page.route('**/api/v4/limits/server', async (route) => {
const response = await route.fetch();
const json = await response.json();
json.singleChannelGuestLimit = 1;
await route.fulfill({response, json});
});
// # Navigate to site statistics page
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the card title has error styling
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
await expect(cardTitle).toBeVisible();
await expect(cardTitle).toHaveClass(/team_statistics--error/);
// * Verify the dismissible guest limit banner is visible
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).toBeVisible();
},
);
/**
* @objective Verify that a guest in multiple channels is not counted as a single-channel guest
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'does not count multi-channel guest as single-channel guest on site statistics page',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts
const config = await adminClient.getConfig();
config.GuestAccountsSettings.Enable = true;
await adminClient.updateConfig(config);
// # Create a guest user and add to TWO channels
const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest');
await adminClient.addToTeam(team.id, multiChannelGuest.id);
const channelA = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'guest-channel-a',
displayName: 'Guest Channel A',
unique: true,
}),
);
const channelB = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'guest-channel-b',
displayName: 'Guest Channel B',
unique: true,
}),
);
await adminClient.addToChannel(multiChannelGuest.id, channelA.id);
await adminClient.addToChannel(multiChannelGuest.id, channelB.id);
// # Log in as admin and navigate to site statistics
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.page.goto('/admin_console/reporting/system_analytics');
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the single-channel guests card is visible
const singleChannelGuestsCard = systemConsolePage.page.getByTestId('singleChannelGuests');
await expect(singleChannelGuestsCard).toBeVisible();
// * Verify the count text is present — multi-channel guest should not increment it
const countText = await singleChannelGuestsCard.textContent();
const match = countText?.match(/(\d+)/);
expect(match).toBeTruthy();
const singleChannelGuestCount = Number(match![1]);
// # Now create a single-channel guest to confirm baseline counting works
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, channelA.id);
// # Reload page to get updated stats
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the count increased by exactly 1 for the new single-channel guest
const updatedCountText = await singleChannelGuestsCard.textContent();
const updatedMatch = updatedCountText?.match(/(\d+)/);
expect(updatedMatch).toBeTruthy();
expect(Number(updatedMatch![1])).toBe(singleChannelGuestCount + 1);
},
);