Add admin-locked profile fields for email users and pre-provisioned names on invites (#37458)

* Add TeamSettings.LockProfileFieldsForEmailUsers with server-side enforcement

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Add API tests for LockProfileFieldsForEmailUsers enforcement

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Hide admin-locked profile fields in user settings and add System Console dropdown

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Support pre-set username and name on team email invites

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Add tests for invite profiles; fix resend worker channel-list parsing

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Add pre-set profile inputs to member invite modal

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Prefill and lock pre-set username on signup page

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Add first/last name editing to System Console user detail and document new setting

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Fix lint issues in invite modal profile inputs

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Fix double outline on invite modal profile inputs inside GenericModal

Co-authored-by: Nick Misasi <nick13misasi@gmail.com>

* Refactor invite emails to InviteEmailData struct and harden invite profile validation

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Centralize profile-lock permission exemption in app layer and add config coverage

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Per-field name locking in profile settings, typed lock setting, and shared invite profile helpers

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Add Playwright E2E coverage for locked profile fields and pre-set invite profiles

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Sync playwright package-lock with merged workspace versions

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Restore upstream playwright package-lock (fix npm ci drift)

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Assert invite input cleared instead of chip text after adding email

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Fix invite modal scroll, username error layout, and clipped autocomplete

Keep the footer pinned while tall profile rows scroll, show username
validation full-width after blur, and portal select menus so they are not
clipped by the scroll container.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fixes for autocomplete items not aligning properly

* Make invite autocomplete menu portal opt-in and fix modal chrome

Confine document.body menu portaling to the invite modal via a menuPortal
prop, and restore click-away, slide-in animation, and header alignment for
the scrolling invite modal layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix eslint lines-around-comment on menuPortal props

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix stylelint property order in invitation modal SCSS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix stylelint property order in invitation_modal.scss

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Adapt invite modal E2E to portaled autocomplete menus

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Simplify locked profile invite implementation

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Fix invite modal review and E2E feedback

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Retry flaky enterprise CI

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Retry Docker image export CI

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Minimize locked profile fields diff

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Fix locked profile E2E documentation

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Address minimized test review feedback

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Fix email test whitespace

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Retry OpenSearch download CI

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Retry flaky Cypress thread navigation

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Preserve legacy invite behavior without profiles

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Clarify invite profile validation

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

* Retry flaky enterprise E2E

Co-authored-by: nick.misasi <nick.misasi@mattermost.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matthew Birtch <2040554+matthewbirtch@users.noreply.github.com>
This commit is contained in:
Nick Misasi
2026-07-17 14:29:44 -04:00
committed by GitHub
parent 91a1c3f1e5
commit 2851af059d
68 changed files with 3674 additions and 332 deletions
+48 -4
View File
@@ -1405,14 +1405,58 @@
required: true
schema:
type: string
- name: graceful
in: query
description: When provided with a non-empty value, returns an array with both successful invites and errors instead of aborting on the first error. Required when using `profiles`.
required: false
schema:
type: boolean
requestBody:
content:
application/json:
schema:
type: array
items:
type: string
description: List of user's email
oneOf:
- type: array
items:
type: string
- type: object
required:
- emails
properties:
emails:
type: array
items:
type: string
description: List of user's email
channelIds:
type: array
items:
type: string
description: List of channel IDs to invite the users to
message:
type: string
description: Custom message included in the invitation email
profiles:
type: array
description: |
Profile fields to pre-set on the accounts created from these invitations. Each entry must reference an email present in `emails`. Requires a non-empty `graceful` query parameter, an Enterprise license, and locked profile fields to be enabled.
__Minimum server version__: 11.11
items:
type: object
required:
- email
- username
properties:
email:
type: string
username:
type: string
first_name:
type: string
last_name:
type: string
description: List of user's email, or an object with emails and invitation options
required: true
responses:
"200":
@@ -758,6 +758,23 @@ Access the following configuration settings in the System Console by going to **
</tbody>
</table>
### Lock profile fields for email users
<PlanAvailability slug="ent-plus" />
<table>
<colgroup>
<col style={{width: '56%'}} />
<col style={{width: '44%'}} />
</colgroup>
<tbody>
<tr>
<td><p>This setting controls whether users who sign in with email and password can change their own profile fields under <strong>Settings &gt; Profile</strong>. System admins are always exempt and can edit any user's profile through the System Console or the API. Users authenticating through an external provider (AD/LDAP, SAML, or OAuth) aren't affected; their fields remain governed by the provider's attribute settings.</p><ul><li><strong>Don't lock profile fields</strong>: <strong>(Default)</strong> Users can change all of their profile fields. <code>config.json</code> option: <code>"none"</code>.</li><li><strong>Lock name and username</strong>: Users cannot change their first name, last name, or username. <code>config.json</code> option: <code>"name_and_username"</code>.</li><li><strong>Lock entire profile</strong>: Additionally locks the nickname, position, and profile picture. Email stays editable because it's the sign-in credential, protected by password re-entry and verification. <code>config.json</code> option: <code>"all"</code>.</li></ul><p>Empty first and last names can be filled in once by the user, so people who join through a team invite link or open server signup aren't left without a name. Once set, the name is locked.</p><p>When this setting is enabled, anyone with the <strong>Invite Users</strong> permission can pre-set the first name, last name, and username for each email invitation they send. The invited person sees the pre-set username on the account creation page and cannot change it. We recommend restricting the <strong>Invite Users</strong> permission through <a href="mm-doc:%2Fadministration-guide%2Fonboard%2Fadvanced-permissions">advanced permissions</a> to people trusted to enter this information correctly.</p><p>For a display name convention of "First Last" across the workspace, combine this setting with <strong>Teammate Name Display</strong> set to <strong>Show first and last name</strong> and <strong>Lock Teammate Name Display</strong> set to <strong>true</strong>.</p></td>
<td><ul><li>System Config path: <strong>Site Configuration &gt; Users and Teams</strong></li><li><code>config.json</code> setting: <code>TeamSettings</code> &gt; <code>LockProfileFieldsForEmailUsers</code> &gt; <code>none</code></li><li>Environment variable: <code>MM_TEAMSETTINGS_LOCKPROFILEFIELDSFOREMAILUSERS</code></li></ul></td>
</tr>
</tbody>
</table>
### Allow users to view archived channels
<table>
@@ -53,6 +53,7 @@ Anyone can invite people to Mattermost teams and channels, unless your system ad
- An invite link can be used by anyone and doesnt change unless its re-generated or revoked by a system admin or team admin via **Team Settings \> Access \> Invite Code**.
- Your system admin must [enable email invitations](/administration-guide/configure/authentication-configuration-settings#enable-email-invitations) and configure [email](/administration-guide/configure/environment-configuration-settings#smtp) for Mattermost to send email-based invitations.
- Invitation links sent by email expire after 48 hours and can only be used once.
- When profile fields are managed by your system admin, email invitations may include fields to preset the invitee's name and username. These values are applied during signup and become admin-managed.
- Your system admin can [cancel all email invitations](/administration-guide/configure/authentication-configuration-settings#invalidate-pending-email-invites) that haven't yet been accepted within the System Console.
</Note>
@@ -7,6 +7,8 @@ Select your profile picture and select **Profile** to manage the details of your
Your Mattermost system admin may [define custom user profile fields](/administration-guide/manage/admin/user-attributes) that you can personalize. Additionally, some of your profile information may be pulled from another source, which means you won't be able to modify it in Mattermost. Contact your Mattermost system admin for assistance.
Your system admin may also manage profile fields for accounts that sign in with email and password. Empty first and last names can each be entered once and become locked after you save them. Other managed fields remain locked, while your email address remains editable.
<table style={{width: '99%'}}>
<colgroup>
<col style={{width: '14%'}} />
@@ -151,7 +151,7 @@ describe('Guest Account - Guest User Invitation Flow', () => {
});
// # Close the Modal
cy.get('#closeIcon').should('be.visible').click();
cy.findByTestId('invitationModal').findByRole('button', {name: 'Close'}).should('be.visible').click();
// # Enable Guest Accounts
// # Disable Email Invitations
+1
View File
@@ -102,6 +102,7 @@ All environment variables are optional with sensible defaults.
| `PW_ADMIN_EMAIL` | Admin email | `sysadmin@sample.mattermost.com` |
| `PW_ENSURE_PLUGINS_INSTALLED` | Comma-separated list of plugins to install | `[]` |
| `PW_RESET_BEFORE_TEST` | Reset server before test | `false` |
| `PW_SMTP_URL` | Inbucket HTTP API URL | `http://localhost:9001` |
#### High Availability Cluster Settings
+3
View File
@@ -16,10 +16,13 @@ export {
getAdminClient,
mergeWithOnPremServerConfig,
getOnPremServerConfig,
getRecentEmail,
extractEmailLink,
isWebhookTestServerReachable,
setupWebhookTestServer,
PlaywrightClient4,
} from './server';
export type {InbucketEmail} from './server';
export {
ChannelsPage,
@@ -247,6 +247,7 @@ const defaultServerConfig: AdminConfig = {
TeammateNameDisplay: 'username',
ExperimentalEnableAutomaticReplies: false,
LockTeammateNameDisplay: false,
LockProfileFieldsForEmailUsers: 'none',
ExperimentalPrimaryTeam: '',
ExperimentalDefaultChannels: [],
},
@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {testConfig} from '@/test_config';
export type InbucketEmail = {
id: string;
to: string[];
date: string;
subject: string;
body: {
text: string;
html: string;
};
};
type InbucketEmailSummary = Pick<InbucketEmail, 'id' | 'date' | 'subject' | 'to'>;
type GetRecentEmailOptions = {
receivedAfter?: Date;
timeout?: number;
};
const DEFAULT_EMAIL_TIMEOUT = 30_000;
const EMAIL_POLL_INTERVAL = 500;
/**
* Returns the newest email addressed to the exact recipient, waiting for Inbucket
* when mail delivery is still in progress.
*/
export async function getRecentEmail(
recipient: string,
{receivedAfter, timeout = DEFAULT_EMAIL_TIMEOUT}: GetRecentEmailOptions = {},
): Promise<InbucketEmail> {
const mailbox = recipient.split('@')[0];
const mailboxURL = `${testConfig.smtpURL}/api/v1/mailbox/${encodeURIComponent(mailbox)}`;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const email = await getNewestMatchingEmail(mailboxURL, recipient, receivedAfter);
if (email) {
return email;
}
await new Promise((resolve) => setTimeout(resolve, EMAIL_POLL_INTERVAL));
}
throw new Error(`Timed out waiting for email to ${recipient}`);
}
export function extractEmailLink(email: InbucketEmail, pathname: string): string {
const escapedPathname = pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const link = email.body.text.match(new RegExp(`https?://[^\\s<>"')]+${escapedPathname}[^\\s<>"')]+`))?.[0];
if (!link) {
throw new Error(`Email to ${email.to.join(', ')} does not contain a link for ${pathname}`);
}
return link.replaceAll('&amp;', '&');
}
/**
* Extracts the bare email address from a mailbox `to` entry, which Inbucket
* reports as `addr@host`, `<addr@host>`, or `Display Name <addr@host>`.
*/
function parseEmailAddress(entry: string): string {
const angleBracketMatch = entry.match(/<([^<>]+)>\s*$/);
return (angleBracketMatch ? angleBracketMatch[1] : entry).trim().toLowerCase();
}
async function getNewestMatchingEmail(
mailboxURL: string,
recipient: string,
receivedAfter?: Date,
): Promise<InbucketEmail | undefined> {
const response = await fetch(mailboxURL);
if (!response.ok) {
return undefined;
}
const summaries = (await response.json()) as InbucketEmailSummary[];
const normalizedRecipient = recipient.toLowerCase();
const receivedAfterTime = receivedAfter?.getTime();
const matchingSummaries = summaries.filter((summary) => {
const addressedToRecipient = summary.to.some((address) => parseEmailAddress(address) === normalizedRecipient);
const arrivedInTime = receivedAfterTime === undefined || new Date(summary.date).getTime() >= receivedAfterTime;
return addressedToRecipient && arrivedInTime;
});
for (const summary of matchingSummaries.reverse()) {
const messageResponse = await fetch(`${mailboxURL}/${encodeURIComponent(summary.id)}`);
if (!messageResponse.ok) {
continue;
}
return (await messageResponse.json()) as InbucketEmail;
}
return undefined;
}
@@ -9,6 +9,8 @@ export {initSetup, getAdminClient} from './init';
export {createRandomPost} from './post';
export {createNewTeam, createRandomTeam} from './team';
export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user';
export {extractEmailLink, getRecentEmail} from './email';
export type {InbucketEmail} from './email';
export {
enableAIBridgeTestMode,
configureAIBridgeMock,
@@ -23,6 +23,7 @@ export class TestConfig {
workers: number;
snapshotEnabled: boolean;
percyEnabled: boolean;
smtpURL: string;
/** Base URL of the Cypress/Playwright webhook sidecar (`e2e-tests/cypress`: `npm run start:webhook`). */
webhookBaseUrl: string;
@@ -51,6 +52,8 @@ export class TestConfig {
// Visual tests
this.snapshotEnabled = parseBool(process.env.PW_SNAPSHOT_ENABLE, false);
this.percyEnabled = parseBool(process.env.PW_PERCY_ENABLE, false);
// Email
this.smtpURL = process.env.PW_SMTP_URL || 'http://localhost:9001';
this.webhookBaseUrl = process.env.PW_WEBHOOK_BASE_URL || 'http://localhost:3000';
}
}
@@ -29,22 +29,35 @@ export default class InvitePeopleModal {
await this.closeButton.click();
}
/**
* Types an email or username into the react-select invite input,
* waits for a selectable option to load, selects it, then clicks the invite button.
*/
async inviteByEmail(email: string) {
async addEmail(email: string) {
await expect(this.inviteInput).toBeVisible();
await this.inviteInput.click();
await this.inviteInput.pressSequentially(email, {delay: 50});
// Wait for react-select to finish loading and show a selectable option.
// Use a longer timeout (15 s) to tolerate slow email-validation responses in CI.
const listbox = this.container.getByRole('listbox');
await expect(listbox.getByRole('option').first()).toBeVisible({timeout: 15000});
await this.inviteInput.press('Enter');
await expect(this.inviteInput).toHaveValue('');
}
async submitInvites() {
await expect(this.inviteButton).toBeEnabled();
await this.inviteButton.click();
}
async inviteByEmail(email: string) {
await this.addEmail(email);
await this.submitInvites();
}
getProfileRow(email: string) {
const row = this.container.getByTestId(`MemberProfileInputs__row-${email.toLowerCase()}`);
return {
container: row,
firstNameInput: row.getByRole('textbox', {name: 'First name'}),
lastNameInput: row.getByRole('textbox', {name: 'Last name'}),
usernameInput: row.getByRole('textbox', {name: 'Username'}),
};
}
}
@@ -4,6 +4,8 @@
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export type ProfileSection = 'name' | 'username';
export default class ProfileModal {
readonly container: Locator;
@@ -16,6 +18,11 @@ export default class ProfileModal {
readonly closeButton;
readonly saveButton;
readonly cancelButton;
readonly managedByAdminMessage;
readonly firstNameInput;
readonly lastNameInput;
readonly usernameInput;
readonly sectionHeadings;
constructor(container: Locator) {
@@ -30,6 +37,13 @@ export default class ProfileModal {
this.closeButton = container.getByRole('button', {name: 'Close'});
this.saveButton = container.getByRole('button', {name: 'Save'});
this.cancelButton = container.getByRole('button', {name: 'Cancel'});
this.managedByAdminMessage = container.getByText(
'This field is managed by your System Admin. Contact them to request a change.',
);
this.firstNameInput = container.getByRole('textbox', {name: 'First Name'});
this.lastNameInput = container.getByRole('textbox', {name: 'Last Name'});
this.usernameInput = container.getByRole('textbox', {name: 'Username'});
this.sectionHeadings = this.profileSettingsTab.container.getByTestId('section-min').getByRole('heading');
}
@@ -60,6 +74,21 @@ export default class ProfileModal {
await expect(this.container).not.toBeVisible();
}
getSectionEditButton(section: ProfileSection) {
return this.container.locator(`#${section}Edit`);
}
async openSection(section: ProfileSection) {
const editButton = this.getSectionEditButton(section);
await expect(editButton).toBeVisible();
await editButton.click();
}
async closeSection() {
await expect(this.cancelButton).toBeVisible();
await this.cancelButton.click();
}
getAttributeSection(label: string) {
return this.profileSettingsTab.container.getByTestId('section-min').filter({hasText: label});
}
@@ -124,6 +124,8 @@ class AdminUserCard {
// System field inputs (scoped via wrapping <label> to avoid substring ambiguity)
readonly usernameInput: Locator;
readonly emailInput: Locator;
readonly firstNameInput: Locator;
readonly lastNameInput: Locator;
readonly authDataInput: Locator;
readonly authenticationMethod: Locator;
@@ -150,6 +152,8 @@ class AdminUserCard {
// System fields — use exact label text to avoid substring matches (e.g., "Email" vs "Work Email")
this.usernameInput = this.getFieldInputByExactLabel('Username');
this.emailInput = this.getFieldInputByExactLabel('Email');
this.firstNameInput = this.getFieldInputByExactLabel('First Name');
this.lastNameInput = this.getFieldInputByExactLabel('Last Name');
this.authDataInput = this.getFieldInputByExactLabel('Auth Data');
this.authenticationMethod =
this.getFieldColumn('Authentication Method').getByTestId('authenticationMethodValue');
@@ -25,6 +25,8 @@ export default class SignupPage {
readonly emailError;
readonly usernameError;
readonly passwordError;
readonly adminChosenUsernameMessage;
readonly presetName;
readonly header;
readonly footer;
@@ -46,6 +48,8 @@ export default class SignupPage {
'Usernames have to begin with a lowercase letter and be 3-22 characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.',
);
this.passwordError = page.getByText(/Must be \d+-72 characters long\./);
this.adminChosenUsernameMessage = page.getByText('Your username was chosen by your admin.');
this.presetName = page.getByTestId('signup-body-card-preset-name');
const signupBodyCard = page.getByTestId('signup-body-card');
this.termsAndPrivacyCheckBox = signupBodyCard.getByRole('checkbox', {
@@ -68,8 +72,8 @@ export default class SignupPage {
await expect(this.passwordInput).toBeVisible();
}
async goto() {
await this.page.goto('/signup_user_complete');
async goto(url = '/signup_user_complete') {
await this.page.goto(url);
}
async create(user: {email: string; username: string; password: string}, waitForRedirect = true) {
@@ -83,4 +87,11 @@ export default class SignupPage {
await this.page.waitForNavigation();
}
}
async createInvitedUser(password: string) {
await this.passwordInput.fill(password);
await this.termsAndPrivacyCheckBox.check();
await this.createAccountButton.click();
await this.page.waitForURL((url) => !url.pathname.startsWith('/signup_user_complete'));
}
}
@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import {ChannelsPage, expect, extractEmailLink, getRecentEmail, test} from '@mattermost/playwright-lib';
type LockSetting = 'none' | 'name_and_username' | 'all';
test.beforeEach(async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
});
/**
* @objective Verify pre-set invite profile data survives email signup and is locked for the new member.
* @precondition An Enterprise license, Inbucket, and email invitations are available.
*/
test(
'carries admin-provisioned profile data from invite email through signup and enforces the lock',
{tag: '@locked_profile_fields'},
async ({pw, page}) => {
test.setTimeout(90_000);
// # Invite and register a member with administrator-provided profile details.
const {adminUser, adminClient, team} = await pw.initSetup();
await setLockConfig(adminClient, 'name_and_username');
const uniqueId = pw.random.id(6);
const invitedEmail = `new.user@${uniqueId}.example.com`;
const invitedUsername = `jane.doe.${uniqueId}`;
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
const inviteModal = await openInviteModal(adminChannelsPage, team);
await inviteModal.addEmail(invitedEmail);
const profile = inviteModal.getProfileRow(invitedEmail);
await profile.firstNameInput.fill('Jane');
await profile.lastNameInput.fill('Doe');
await profile.usernameInput.fill(invitedUsername);
const invitationStarted = new Date(Date.now() - 5_000);
await inviteModal.submitInvites();
await (await adminChannelsPage.getMembersInvitedModal(team.display_name)).toBeVisible();
const invitationEmail = await getRecentEmail(invitedEmail, {receivedAfter: invitationStarted});
const signupLink = extractEmailLink(invitationEmail, '/signup_user_complete/');
await pw.hasSeenLandingPage();
await pw.signupPage.goto(signupLink);
await pw.signupPage.toBeVisible();
await expect(pw.signupPage.usernameInput).toHaveValue(invitedUsername);
await expect(pw.signupPage.usernameInput).toBeDisabled();
await expect(pw.signupPage.adminChosenUsernameMessage).toBeVisible();
await expect(pw.signupPage.presetName).toHaveText("You'll join as Jane Doe.");
await pw.signupPage.createInvitedUser(pw.newTestPassword());
const channelsPage = new ChannelsPage(page);
await channelsPage.toBeVisible();
const createdUser = await adminClient.getUserByEmail(invitedEmail);
expect(createdUser.username).toBe(invitedUsername);
expect(createdUser.first_name).toBe('Jane');
expect(createdUser.last_name).toBe('Doe');
// * The resulting member cannot edit the managed name or username fields.
await adminClient.savePreferences(createdUser.id, [
{user_id: createdUser.id, category: 'tutorial_step', name: createdUser.id, value: '999'},
{
user_id: createdUser.id,
category: 'onboarding_task_list',
name: 'onboarding_task_list_show',
value: 'false',
},
]);
await page.reload();
await channelsPage.toBeVisible();
const profileModal = await channelsPage.openProfileModal();
await profileModal.openSection('name');
await expect(profileModal.managedByAdminMessage).toBeVisible();
await expect(profileModal.firstNameInput).not.toBeVisible();
await expect(profileModal.lastNameInput).not.toBeVisible();
await expect(profileModal.saveButton).not.toBeVisible();
await profileModal.closeSection();
await profileModal.openSection('username');
await expect(profileModal.managedByAdminMessage).toBeVisible();
await expect(profileModal.usernameInput).not.toBeVisible();
await expect(profileModal.saveButton).not.toBeVisible();
},
);
/**
* @objective Verify a System Admin can edit an email user's locked first and last names.
* @precondition An Enterprise license is available.
*/
test(
'allows a System Admin to edit locked first and last names from the user detail page',
{tag: '@locked_profile_fields'},
async ({pw}) => {
// # Edit the locked member's name from the System Console.
const {user, adminUser, adminClient} = await pw.initSetup();
await setLockConfig(adminClient, 'name_and_username');
const newFirstName = `AdminFirst${pw.random.id(5)}`;
const newLastName = `AdminLast${pw.random.id(5)}`;
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(`/admin_console/user_management/user/${user.id}`);
const {userDetail} = systemConsolePage.users;
await userDetail.toBeVisible();
await expect(userDetail.userCard.firstNameInput).toBeEnabled();
await expect(userDetail.userCard.lastNameInput).toBeEnabled();
await userDetail.userCard.firstNameInput.fill(newFirstName);
await userDetail.userCard.lastNameInput.fill(newLastName);
await userDetail.save();
await userDetail.saveChangesModal.confirm();
// * The administrator's changes persist in the UI and API.
await expect(userDetail.userCard.firstNameInput).toHaveValue(newFirstName);
await expect(userDetail.userCard.lastNameInput).toHaveValue(newLastName);
const updatedUser = await adminClient.getUser(user.id);
expect(updatedUser.first_name).toBe(newFirstName);
expect(updatedUser.last_name).toBe(newLastName);
},
);
async function setLockConfig(adminClient: Client4, lockSetting: LockSetting) {
await adminClient.patchConfig({
AnnouncementSettings: {AdminNoticesEnabled: false, UserNoticesEnabled: false},
ServiceSettings: {EnableEmailInvitations: true},
TeamSettings: {LockProfileFieldsForEmailUsers: lockSetting},
});
await expect
.poll(async () => {
const config = await adminClient.getConfig();
return {
emailInvitations: config.ServiceSettings?.EnableEmailInvitations,
profileLock: config.TeamSettings?.LockProfileFieldsForEmailUsers,
};
})
.toEqual({emailInvitations: true, profileLock: lockSetting});
}
async function openInviteModal(channelsPage: ChannelsPage, team: Team) {
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await channelsPage.sidebarLeft.teamMenuButton.click();
await channelsPage.teamMenu.toBeVisible();
await channelsPage.teamMenu.clickInvitePeople();
const inviteModal = await channelsPage.getInvitePeopleModal(team.display_name);
await inviteModal.toBeVisible();
return inviteModal;
}
+31 -17
View File
@@ -1744,7 +1744,12 @@ func inviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) {
}
for i := range emailList {
emailList[i] = strings.ToLower(emailList[i])
emailList[i] = model.NormalizeEmail(emailList[i])
}
if !graceful && len(memberInvite.Profiles) > 0 {
c.Err = model.NewAppError("Api4.inviteUsersToTeam", "api.team.invite_members.profiles_graceful.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventInviteUsersToTeam, model.AuditStatusFail)
@@ -1783,24 +1788,33 @@ func inviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
// we get the emailList after it has finished checks like the emails over the list
scheduledAt := model.GetMillis()
jobData := map[string]string{
"emailList": model.ArrayToJSON(emailList),
"teamID": c.Params.TeamId,
"senderID": c.AppContext.Session().UserId,
"scheduledAt": strconv.FormatInt(scheduledAt, 10),
}
if len(emailList) > 0 {
scheduledAt := model.GetMillis()
jobData := map[string]string{
"emailList": model.ArrayToJSON(emailList),
"teamID": c.Params.TeamId,
"senderID": c.AppContext.Session().UserId,
"scheduledAt": strconv.FormatInt(scheduledAt, 10),
}
if len(memberInvite.ChannelIds) > 0 {
jobData["channelList"] = model.ArrayToJSON(memberInvite.ChannelIds)
}
if len(memberInvite.ChannelIds) > 0 {
jobData["channelList"] = model.ArrayToJSON(memberInvite.ChannelIds)
}
// we then manually schedule the job to send another invite after 48 hours
_, appErr = c.App.Srv().Jobs.CreateJob(c.AppContext, model.JobTypeResendInvitationEmail, jobData)
if appErr != nil {
c.Err = model.NewAppError("Api4.inviteUsersToTeam", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr)
return
if len(memberInvite.Profiles) > 0 {
profilesJSON, jsonErr := json.Marshal(memberInvite.Profiles)
if jsonErr != nil {
c.Err = model.NewAppError("Api4.inviteUsersToTeam", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
jobData["profilesList"] = string(profilesJSON)
}
_, appErr = c.App.Srv().Jobs.CreateJob(c.AppContext, model.JobTypeResendInvitationEmail, jobData)
if appErr != nil {
c.Err = model.NewAppError("Api4.inviteUsersToTeam", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr)
return
}
}
// in graceful mode we return both the successful ones and the failed ones
+75 -36
View File
@@ -92,7 +92,7 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request)
return
}
for i := range emailList {
email := strings.ToLower(emailList[i])
email := model.NormalizeEmail(emailList[i])
if !model.IsValidEmail(email) {
c.Err = model.NewAppError("localInviteUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Address": email}, "", http.StatusBadRequest)
return
@@ -100,6 +100,12 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request)
emailList[i] = email
}
graceful := r.URL.Query().Get("graceful") != ""
if !graceful && len(memberInvite.Profiles) > 0 {
c.Err = model.NewAppError("Api4.localInviteUsersToTeam", "api.team.invite_members.profiles_graceful.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalInviteUsersToTeam, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "member_invite", memberInvite)
defer c.LogAuditRec(auditRec)
@@ -134,43 +140,69 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request)
}
}
if r.URL.Query().Get("graceful") != "" {
if graceful {
var invitesWithErrors []*model.EmailInviteWithError
var goodEmails, errList []string
for _, email := range emailList {
invite := &model.EmailInviteWithError{
Email: email,
Error: nil,
}
if !isEmailAddressAllowed(email, allowedDomains) {
invite.Error = model.NewAppError("localInviteUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": email}, "", http.StatusBadRequest)
errList = append(errList, model.EmailInviteWithErrorToString(invite))
} else {
goodEmails = append(goodEmails, email)
}
invitesWithErrors = append(invitesWithErrors, invite)
}
auditRec.AddMeta("errors", errList)
if len(goodEmails) > 0 {
var invitesWithErrors2 []*model.EmailInviteWithError
if len(channels) > 0 {
invitesWithErrors2, err = c.App.Srv().EmailService.SendInviteEmailsToTeamAndChannels(c.AppContext, team, channels, "Administrator", "mmctl "+model.NewId(), nil, goodEmails, *c.App.Config().ServiceSettings.SiteURL, nil, memberInvite.Message, true, true, false)
invitesWithErrors = append(invitesWithErrors, invitesWithErrors2...)
} else {
err = c.App.Srv().EmailService.SendInviteEmails(c.AppContext, team, "Administrator", "mmctl "+model.NewId(), goodEmails, *c.App.Config().ServiceSettings.SiteURL, nil, false, true, false)
}
if err != nil {
switch {
case errors.Is(err, email.NoRateLimiterError):
c.Err = model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("team_id=%s", team.Id), http.StatusInternalServerError).Wrap(err)
case errors.Is(err, email.SetupRateLimiterError):
c.Err = model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusInternalServerError).Wrap(err)
default:
c.Err = model.NewAppError("SendInviteEmails", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusRequestEntityTooLarge).Wrap(err)
}
var errList []string
if len(memberInvite.Profiles) > 0 {
var appErr *model.AppError
invitesWithErrors, appErr = c.App.InviteNewUsersToTeamGracefullyForLocal(c.AppContext, memberInvite, team, channels)
if appErr != nil {
c.Err = appErr
return
}
for _, invite := range invitesWithErrors {
if invite.Error != nil {
errList = append(errList, model.EmailInviteWithErrorToString(invite))
}
}
auditRec.AddMeta("errors", errList)
} else {
var goodEmails []string
for _, emailAddress := range emailList {
invite := &model.EmailInviteWithError{Email: emailAddress}
if !isEmailAddressAllowed(emailAddress, allowedDomains) {
invite.Error = model.NewAppError("localInviteUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": emailAddress}, "", http.StatusBadRequest)
errList = append(errList, model.EmailInviteWithErrorToString(invite))
} else {
goodEmails = append(goodEmails, emailAddress)
}
invitesWithErrors = append(invitesWithErrors, invite)
}
auditRec.AddMeta("errors", errList)
if len(goodEmails) > 0 {
inviteData := email.InviteEmailData{
Team: team,
Channels: channels,
SenderName: "Administrator",
SenderUserID: "mmctl " + model.NewId(),
Invites: goodEmails,
SiteURL: *c.App.Config().ServiceSettings.SiteURL,
Message: memberInvite.Message,
ErrorWhenNotSent: true,
IsSystemAdmin: true,
}
var sendErrors []*model.EmailInviteWithError
if len(channels) > 0 {
sendErrors, err = c.App.Srv().EmailService.SendInviteEmailsToTeamAndChannels(c.AppContext, inviteData)
invitesWithErrors = append(invitesWithErrors, sendErrors...)
} else {
inviteData.ErrorWhenNotSent = false
err = c.App.Srv().EmailService.SendInviteEmails(c.AppContext, inviteData)
}
if err != nil {
switch {
case errors.Is(err, email.NoRateLimiterError):
c.Err = model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("team_id=%s", team.Id), http.StatusInternalServerError).Wrap(err)
case errors.Is(err, email.SetupRateLimiterError):
c.Err = model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusInternalServerError).Wrap(err)
default:
c.Err = model.NewAppError("SendInviteEmails", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusRequestEntityTooLarge).Wrap(err)
}
return
}
}
}
// in graceful mode we return both the successful ones and the failed ones
@@ -196,7 +228,14 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request)
c.Err = model.NewAppError("localInviteUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": s}, "", http.StatusBadRequest)
return
}
err := c.App.Srv().EmailService.SendInviteEmails(c.AppContext, team, "Administrator", "mmctl "+model.NewId(), emailList, *c.App.Config().ServiceSettings.SiteURL, nil, false, true, false)
err := c.App.Srv().EmailService.SendInviteEmails(c.AppContext, email.InviteEmailData{
Team: team,
SenderName: "Administrator",
SenderUserID: "mmctl " + model.NewId(),
Invites: emailList,
SiteURL: *c.App.Config().ServiceSettings.SiteURL,
IsSystemAdmin: true,
})
if err != nil {
switch {
case errors.Is(err, email.NoRateLimiterError):
+206
View File
@@ -4285,6 +4285,212 @@ func TestInviteUsersToTeam(t *testing.T) {
}, "rate limits")
}
func TestInviteUsersToTeamWithProfiles(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableEmailInvitations = true })
newInvite := func() *model.MemberInvite {
email := th.GenerateTestEmail()
return &model.MemberInvite{
Emails: []string{email},
Profiles: []*model.MemberInviteProfile{{
Email: email,
Username: "un_" + model.NewId(),
FirstName: "Pre",
LastName: "Set",
}},
}
}
findResendJob := func(t *testing.T, email string) *model.Job {
t.Helper()
resendJobs, err := th.App.Srv().Store().Job().GetAllByType(th.Context, model.JobTypeResendInvitationEmail)
require.NoError(t, err)
for _, job := range resendJobs {
if job.Data["teamID"] != th.BasicTeam.Id {
continue
}
var emails []string
if json.Unmarshal([]byte(job.Data["emailList"]), &emails) != nil {
continue
}
for _, jobEmail := range emails {
if model.NormalizeEmail(jobEmail) == model.NormalizeEmail(email) {
return job
}
}
}
require.FailNow(t, "resend job not found", "team=%s email=%s", th.BasicTeam.Id, email)
return nil
}
t.Run("rejected without an Enterprise license", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
_, resp, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, newInvite())
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.team.invite_members.profiles_license.app_error")
})
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
t.Run("rejected when locked profile fields are disabled", func(t *testing.T) {
_, resp, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, newInvite())
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.team.invite_members.profiles_disabled.app_error")
})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.LockProfileFieldsForEmailUsers = model.TeamSettingsLockProfileFieldsNameAndUsername
})
t.Run("rejected without graceful mode", func(t *testing.T) {
invite := newInvite()
inviteJSON, err := json.Marshal(invite)
require.NoError(t, err)
resp, err := th.Client.DoAPIPost(context.Background(), "/teams/"+th.BasicTeam.Id+"/invite/email", string(inviteJSON))
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
CheckErrorID(t, err, "api.team.invite_members.profiles_graceful.app_error")
})
t.Run("accepted from a regular member with invite permission", func(t *testing.T) {
invite := newInvite()
invitesWithErrors, _, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.NoError(t, err)
require.Len(t, invitesWithErrors, 1)
require.Nil(t, invitesWithErrors[0].Error)
resendJob := findResendJob(t, invite.Emails[0])
var profiles []*model.MemberInviteProfile
require.NoError(t, json.Unmarshal([]byte(resendJob.Data["profilesList"]), &profiles))
require.Equal(t, invite.Profiles, profiles)
})
t.Run("accepted from a system admin", func(t *testing.T) {
invitesWithErrors, _, err := th.SystemAdminClient.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, newInvite())
require.NoError(t, err)
require.Len(t, invitesWithErrors, 1)
require.Nil(t, invitesWithErrors[0].Error)
})
t.Run("accepted from local mode with profile data in the token", func(t *testing.T) {
invite := newInvite()
invitesWithErrors, _, err := th.LocalClient.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.NoError(t, err)
require.Len(t, invitesWithErrors, 1)
require.Nil(t, invitesWithErrors[0].Error)
tokens, err := th.App.Srv().Store().Token().GetAllTokensByType(model.TokenTypeTeamInvitation)
require.NoError(t, err)
var tokenData map[string]string
for _, token := range tokens {
var data map[string]string
require.NoError(t, json.Unmarshal([]byte(token.Extra), &data))
if data["email"] == invite.Emails[0] {
tokenData = data
break
}
}
require.NotNil(t, tokenData)
require.Equal(t, invite.Profiles[0].Username, tokenData["username"])
require.Equal(t, invite.Profiles[0].FirstName, tokenData["first_name"])
require.Equal(t, invite.Profiles[0].LastName, tokenData["last_name"])
})
t.Run("invalid username is rejected in local mode", func(t *testing.T) {
invite := newInvite()
invite.Profiles[0].Username = "inv@lid username"
_, resp, err := th.LocalClient.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "model.member.is_valid.profile_username.app_error")
})
t.Run("profile email must be in the invited email list", func(t *testing.T) {
invite := newInvite()
invite.Profiles[0].Email = th.GenerateTestEmail()
_, resp, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "model.member.is_valid.profile_email.app_error")
})
t.Run("taken username produces a per-email graceful error", func(t *testing.T) {
invite := newInvite()
invite.Profiles[0].Username = th.BasicUser2.Username
invitesWithErrors, _, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.NoError(t, err)
require.Len(t, invitesWithErrors, 1)
require.NotNil(t, invitesWithErrors[0].Error)
require.Equal(t, "api.team.invite_members.username_taken.app_error", invitesWithErrors[0].Error.Id)
})
t.Run("group name collision produces a per-email graceful error", func(t *testing.T) {
invite := newInvite()
username := invite.Profiles[0].Username
group, appErr := th.App.CreateGroup(&model.Group{
Name: &username,
DisplayName: "Username collision",
Source: model.GroupSourceLdap,
RemoteId: model.NewPointer("ri_" + model.NewId()),
})
require.Nil(t, appErr)
t.Cleanup(func() {
_, appErr := th.App.DeleteGroup(group.Id)
require.Nil(t, appErr)
})
invitesWithErrors, _, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.NoError(t, err)
require.Len(t, invitesWithErrors, 1)
require.NotNil(t, invitesWithErrors[0].Error)
require.Equal(t, "api.team.invite_members.username_taken.app_error", invitesWithErrors[0].Error.Id)
})
t.Run("mixed valid and taken usernames only fails the taken one", func(t *testing.T) {
goodEmail := th.GenerateTestEmail()
badEmail := th.GenerateTestEmail()
invite := &model.MemberInvite{
Emails: []string{goodEmail, badEmail},
Profiles: []*model.MemberInviteProfile{
{Email: goodEmail, Username: "un_" + model.NewId()},
{Email: badEmail, Username: th.BasicUser2.Username},
},
}
invitesWithErrors, _, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.NoError(t, err)
require.Len(t, invitesWithErrors, 2)
byEmail := make(map[string]*model.EmailInviteWithError, 2)
for _, invited := range invitesWithErrors {
byEmail[invited.Email] = invited
}
require.Nil(t, byEmail[goodEmail].Error)
require.NotNil(t, byEmail[badEmail].Error)
})
t.Run("uppercase profile emails and usernames are normalized", func(t *testing.T) {
email := th.GenerateTestEmail()
invite := &model.MemberInvite{
Emails: []string{email},
Profiles: []*model.MemberInviteProfile{{
Email: strings.ToUpper(email),
Username: "UN_" + model.NewId(),
}},
}
invitesWithErrors, _, err := th.Client.InviteMembersToTeamGracefully(context.Background(), th.BasicTeam.Id, invite)
require.NoError(t, err)
require.Len(t, invitesWithErrors, 1)
require.Nil(t, invitesWithErrors[0].Error)
})
}
func TestInviteGuestsToTeam(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+28
View File
@@ -672,6 +672,13 @@ func setProfileImage(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if c.App.IsProfileImageLockedForUser(*c.AppContext.Session(), user) {
c.Err = model.NewAppError(
"uploadProfileImage", "api.user.upload_profile_user.profile_field_locked.app_error",
nil, "", http.StatusConflict)
return
}
imageData := imageArray[0]
if err := c.App.SetProfileImage(c.AppContext, c.Params.UserId, imageData); err != nil {
c.Err = err
@@ -711,6 +718,13 @@ func setDefaultProfileImage(c *Context, w http.ResponseWriter, r *http.Request)
}
model.AddEventParameterAuditableToAuditRec(auditRec, "user", user)
if c.App.IsProfileImageLockedForUser(*c.AppContext.Session(), user) {
c.Err = model.NewAppError(
"setDefaultProfileImage", "api.user.upload_profile_user.profile_field_locked.app_error",
nil, "", http.StatusConflict)
return
}
if err := c.App.SetDefaultProfileImage(c.AppContext, user); err != nil {
c.Err = err
return
@@ -1523,6 +1537,13 @@ func updateUser(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if lockedField := c.App.CheckLockedProfileFields(*c.AppContext.Session(), ouser, user.ToPatch()); lockedField != "" {
c.Err = model.NewAppError(
"updateUser", "api.user.update_user.profile_field_locked.app_error",
map[string]any{"Field": lockedField}, "", http.StatusConflict)
return
}
// If eMail update is attempted by the currently logged in user, check if correct password was provided
if user.Email != "" && ouser.Email != user.Email && c.AppContext.Session().UserId == c.Params.UserId {
err = c.App.DoubleCheckPassword(c.AppContext, ouser, user.Password)
@@ -1601,6 +1622,13 @@ func patchUser(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if lockedField := c.App.CheckLockedProfileFields(*c.AppContext.Session(), ouser, &patch); lockedField != "" {
c.Err = model.NewAppError(
"patchUser", "api.user.patch_user.profile_field_locked.app_error",
map[string]any{"Field": lockedField}, "", http.StatusConflict)
return
}
// If eMail update is attempted by the currently logged in user, check if correct password was provided
if patch.Email != nil && ouser.Email != *patch.Email && c.AppContext.Session().UserId == c.Params.UserId {
if patch.Password == nil {
+219
View File
@@ -602,6 +602,36 @@ func TestCreateUserWithToken(t *testing.T) {
require.Equal(t, th.BasicTeam.Id, teams[0].Id, "The user joined team must be the team provided.")
})
t.Run("token profile overrides tampered request fields", func(t *testing.T) {
email := th.GenerateTestEmail()
tokenUsername := GenerateTestUsername()
token := model.NewToken(
model.TokenTypeTeamInvitation,
model.MapToJSON(map[string]string{
"teamId": th.BasicTeam.Id,
"email": email,
"username": tokenUsername,
"first_name": "TokenFirst",
"last_name": "TokenLast",
}),
)
require.NoError(t, th.App.Srv().Store().Token().Save(token))
user := &model.User{
Email: email,
Password: model.NewTestPassword(),
Username: GenerateTestUsername(),
FirstName: "TamperedFirst",
LastName: "TamperedLast",
}
createdUser, resp, err := th.Client.CreateUserWithToken(context.Background(), user, token.Token)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.Equal(t, tokenUsername, createdUser.Username)
require.Equal(t, "TokenFirst", createdUser.FirstName)
require.Equal(t, "TokenLast", createdUser.LastName)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
user := model.User{Email: th.GenerateTestEmail(), Nickname: "Corey Hulen", Password: model.NewTestPassword(), Username: GenerateTestUsername(), Roles: model.SystemAdminRoleId + " " + model.SystemUserRoleId}
token := model.NewToken(
@@ -9891,6 +9921,195 @@ func TestSetProfileImageWithProviderAttributes(t *testing.T) {
})
}
func TestLockProfileFieldsForEmailUsers(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
setLock := func(value string) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.LockProfileFieldsForEmailUsers = value
})
}
setNames := func(firstName, lastName string) {
_, _, err := th.SystemAdminClient.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
FirstName: &firstName,
LastName: &lastName,
})
require.NoError(t, err)
}
t.Run("setting off, email user can change all fields", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsNone)
setNames("First", "Last")
_, _, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
Username: new("un_" + model.NewId()),
FirstName: new("NewFirst"),
LastName: new("NewLast"),
Nickname: new("NewNick"),
Position: new("NewPosition"),
})
require.NoError(t, err)
})
t.Run("setting on without Enterprise license is inert", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
setLock(model.TeamSettingsLockProfileFieldsAll)
setNames("First", "Last")
_, _, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
Username: new("un_" + model.NewId()),
FirstName: new("NewFirst"),
Nickname: new("NewNick"),
})
require.NoError(t, err)
})
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
t.Run("name_and_username locks username and non-empty names", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsNameAndUsername)
setNames("First", "Last")
for name, patch := range map[string]*model.UserPatch{
"username": {Username: new("un_" + model.NewId())},
"first name": {FirstName: new("Changed")},
"last name": {LastName: new("Changed")},
} {
_, resp, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, patch)
require.Error(t, err, "expected %s change to be rejected", name)
checkHTTPStatus(t, resp, http.StatusConflict)
CheckErrorID(t, err, "api.user.patch_user.profile_field_locked.app_error")
}
// Nickname and position stay editable.
_, _, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
Nickname: new("NewNick"),
Position: new("NewPosition"),
})
require.NoError(t, err)
})
t.Run("full PUT with unchanged locked fields does not conflict", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsNameAndUsername)
setNames("First", "Last")
user, appErr := th.App.GetUser(th.BasicUser.Id)
require.Nil(t, appErr)
user.Nickname = "UpdatedNick"
_, _, err := th.Client.UpdateUser(context.Background(), user)
require.NoError(t, err)
user.Username = "un_" + model.NewId()
_, resp, err := th.Client.UpdateUser(context.Background(), user)
require.Error(t, err)
checkHTTPStatus(t, resp, http.StatusConflict)
CheckErrorID(t, err, "api.user.update_user.profile_field_locked.app_error")
})
t.Run("empty names can be filled once", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsNameAndUsername)
setNames("", "")
_, _, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
FirstName: new("FilledFirst"),
LastName: new("FilledLast"),
})
require.NoError(t, err)
_, resp, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
FirstName: new("ChangedAgain"),
})
require.Error(t, err)
checkHTTPStatus(t, resp, http.StatusConflict)
})
t.Run("fill once applies independently to each name field", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsNameAndUsername)
setNames("ExistingFirst", "")
_, _, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
LastName: new("FilledLast"),
})
require.NoError(t, err)
_, resp, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
FirstName: new("ChangedFirst"),
})
require.Error(t, err)
checkHTTPStatus(t, resp, http.StatusConflict)
CheckErrorID(t, err, "api.user.patch_user.profile_field_locked.app_error")
})
t.Run("all additionally locks nickname, position and profile image", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsAll)
for name, patch := range map[string]*model.UserPatch{
"nickname": {Nickname: new("Changed")},
"position": {Position: new("Changed")},
} {
_, resp, err := th.Client.PatchUser(context.Background(), th.BasicUser.Id, patch)
require.Error(t, err, "expected %s change to be rejected", name)
checkHTTPStatus(t, resp, http.StatusConflict)
}
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
resp, err := th.Client.SetProfileImage(context.Background(), th.BasicUser.Id, data)
require.Error(t, err)
checkHTTPStatus(t, resp, http.StatusConflict)
CheckErrorID(t, err, "api.user.upload_profile_user.profile_field_locked.app_error")
resp, err = th.Client.SetDefaultProfileImage(context.Background(), th.BasicUser.Id)
require.Error(t, err)
checkHTTPStatus(t, resp, http.StatusConflict)
})
t.Run("system admin is exempt", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsAll)
// Editing another user.
_, _, err := th.SystemAdminClient.PatchUser(context.Background(), th.BasicUser.Id, &model.UserPatch{
Username: new("un_" + model.NewId()),
FirstName: new("AdminSetFirst"),
LastName: new("AdminSetLast"),
Nickname: new("AdminSetNick"),
})
require.NoError(t, err)
// Editing their own profile.
_, _, err = th.SystemAdminClient.PatchUser(context.Background(), th.SystemAdminUser.Id, &model.UserPatch{
FirstName: new("AdminOwnFirst"),
})
require.NoError(t, err)
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
_, err = th.SystemAdminClient.SetProfileImage(context.Background(), th.BasicUser.Id, data)
require.NoError(t, err)
info := &model.FileInfo{Path: "users/" + th.BasicUser.Id + "/profile.png"}
require.NoError(t, th.cleanupTestFile(info))
})
t.Run("users with a login provider are not affected", func(t *testing.T) {
setLock(model.TeamSettingsLockProfileFieldsAll)
ldapUser := th.CreateUserWithAuth(t, model.UserAuthServiceLdap)
require.Empty(t, th.App.CheckLockedProfileFields(*th.Context.Session(), ldapUser, &model.UserPatch{
Username: new("un_" + model.NewId()),
FirstName: new("Changed"),
Nickname: new("Changed"),
}))
require.False(t, th.App.IsProfileImageLockedForUser(*th.Context.Session(), ldapUser))
// The provider check still wins for provider-managed users.
require.Equal(t, "username", th.App.CheckProviderAttributes(th.Context, ldapUser, &model.UserPatch{
Username: new("un_" + model.NewId()),
}))
})
}
func TestGetUsersWithInvalidEmails(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
+76 -87
View File
@@ -421,63 +421,72 @@ func (es *Service) SendMfaChangeEmail(email string, activated bool, locale, site
return nil
}
func (es *Service) SendInviteEmails(
rctx request.CTX,
team *model.Team,
senderName string,
senderUserId string,
invites []string,
siteURL string,
reminderData *model.TeamInviteReminderData,
errorWhenNotSent bool,
isSystemAdmin bool,
isFirstAdmin bool,
) error {
func buildInviteTokenData(team *model.Team, invite string, profile *model.MemberInviteProfile) (map[string]string, map[string]string) {
tokenExtra := map[string]string{"teamId": team.Id, "email": invite}
tokenProps := map[string]string{
"email": invite,
"display_name": team.DisplayName,
"name": team.Name,
}
// The token extra is authoritative at signup; the link props are prefill only.
if profile != nil {
for key, value := range map[string]string{
"username": profile.Username,
"first_name": profile.FirstName,
"last_name": profile.LastName,
} {
tokenExtra[key] = value
tokenProps[key] = value
}
}
return tokenExtra, tokenProps
}
func (es *Service) SendInviteEmails(rctx request.CTX, inviteData InviteEmailData) error {
if es.perHourEmailRateLimiter == nil {
return NoRateLimiterError
}
rateLimited, result, err := es.perHourEmailRateLimiter.RateLimitCtx(rctx.Context(), senderUserId, len(invites))
rateLimited, result, err := es.perHourEmailRateLimiter.RateLimitCtx(rctx.Context(), inviteData.SenderUserID, len(inviteData.Invites))
if err != nil {
return SetupRateLimiterError
}
if rateLimited {
mlog.Error("rate limit exceeded", mlog.Duration("RetryAfter", result.RetryAfter), mlog.Duration("ResetAfter", result.ResetAfter), mlog.String("user_id", senderUserId),
mlog.String("team_id", team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds())))
mlog.Error("rate limit exceeded", mlog.Duration("RetryAfter", result.RetryAfter), mlog.Duration("ResetAfter", result.ResetAfter), mlog.String("user_id", inviteData.SenderUserID),
mlog.String("team_id", inviteData.Team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds())))
return RateLimitExceededError
}
for _, invite := range invites {
for _, invite := range inviteData.Invites {
if invite != "" {
subject := i18n.T("api.templates.invite_subject",
map[string]any{"SenderName": senderName,
"TeamDisplayName": team.DisplayName,
map[string]any{"SenderName": inviteData.SenderName,
"TeamDisplayName": inviteData.Team.DisplayName,
"SiteName": es.config().TeamSettings.SiteName})
data := es.NewEmailTemplateData("")
data.Props["SiteURL"] = siteURL
data.Props["SiteURL"] = inviteData.SiteURL
data.Props["SubTitle"] = i18n.T("api.templates.invite_body.subTitle")
data.Props["Button"] = i18n.T("api.templates.invite_body.button")
data.Props["SenderName"] = senderName
data.Props["SenderName"] = inviteData.SenderName
data.Props["InviteFooterTitle"] = i18n.T("api.templates.invite_body_footer.title")
data.Props["InviteFooterInfo"] = i18n.T("api.templates.invite_body_footer.info")
data.Props["InviteFooterLearnMore"] = i18n.T("api.templates.invite_body_footer.learn_more")
tokenExtra, tokenProps := buildInviteTokenData(inviteData.Team, invite, inviteData.Profiles[invite])
token := model.NewToken(
TokenTypeTeamInvitation,
model.MapToJSON(map[string]string{"teamId": team.Id, "email": invite}),
model.MapToJSON(tokenExtra),
)
tokenProps := make(map[string]string)
tokenProps["email"] = invite
tokenProps["display_name"] = team.DisplayName
tokenProps["name"] = team.Name
title := i18n.T("api.templates.invite_body.title", map[string]any{"SenderName": senderName, "TeamDisplayName": team.DisplayName})
if reminderData != nil {
title := i18n.T("api.templates.invite_body.title", map[string]any{"SenderName": inviteData.SenderName, "TeamDisplayName": inviteData.Team.DisplayName})
if inviteData.ReminderData != nil {
reminder := i18n.T("api.templates.invite_body.title.reminder")
title = fmt.Sprintf("%s: %s", reminder, title)
tokenProps["reminder_interval"] = reminderData.Interval
tokenProps["reminder_interval"] = inviteData.ReminderData.Interval
}
data.Props["Title"] = title
@@ -493,8 +502,8 @@ func (es *Service) SendInviteEmails(
queryString.Add("d", tokenData)
queryString.Add("t", token.Token)
queryString.Add("md", "email")
queryString.Add("sbr", es.GetTrackFlowStartedByRole(isFirstAdmin, isSystemAdmin))
data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?%s", siteURL, queryString.Encode())
queryString.Add("sbr", es.GetTrackFlowStartedByRole(inviteData.IsFirstAdmin, inviteData.IsSystemAdmin))
data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?%s", inviteData.SiteURL, queryString.Encode())
body, err := es.templatesContainer.RenderToString("invite_body", data)
if err != nil {
@@ -503,7 +512,7 @@ func (es *Service) SendInviteEmails(
if err := es.sendMail(invite, subject, body, "InviteEmail"); err != nil {
mlog.Error("Failed to send invite email successfully ", mlog.Err(err))
if errorWhenNotSent {
if inviteData.ErrorWhenNotSent {
return SendMailError
}
}
@@ -717,108 +726,88 @@ func (es *Service) SendMagicLinkEmailSelfService(
return nil
}
func (es *Service) SendInviteEmailsToTeamAndChannels(
rctx request.CTX,
team *model.Team,
channels []*model.Channel,
senderName string,
senderUserId string,
senderProfileImage []byte,
invites []string,
siteURL string,
reminderData *model.TeamInviteReminderData,
message string,
errorWhenNotSent bool,
isSystemAdmin bool,
isFirstAdmin bool,
) ([]*model.EmailInviteWithError, error) {
func (es *Service) SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData InviteEmailData) ([]*model.EmailInviteWithError, error) {
if es.perHourEmailRateLimiter == nil {
return nil, NoRateLimiterError
}
rateLimited, result, err := es.perHourEmailRateLimiter.RateLimitCtx(rctx.Context(), senderUserId, len(invites))
rateLimited, result, err := es.perHourEmailRateLimiter.RateLimitCtx(rctx.Context(), inviteData.SenderUserID, len(inviteData.Invites))
if err != nil {
return nil, SetupRateLimiterError
}
if rateLimited {
mlog.Error("rate limit exceeded", mlog.Duration("RetryAfter", result.RetryAfter), mlog.Duration("ResetAfter", result.ResetAfter), mlog.String("user_id", senderUserId),
mlog.String("team_id", team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds())))
mlog.Error("rate limit exceeded", mlog.Duration("RetryAfter", result.RetryAfter), mlog.Duration("ResetAfter", result.ResetAfter), mlog.String("user_id", inviteData.SenderUserID),
mlog.String("team_id", inviteData.Team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds())))
return nil, RateLimitExceededError
}
channelsLen := len(channels)
channelsLen := len(inviteData.Channels)
subject := i18n.T("api.templates.invite_team_and_channels_subject", map[string]any{
"SenderName": senderName,
"TeamDisplayName": team.DisplayName,
"SenderName": inviteData.SenderName,
"TeamDisplayName": inviteData.Team.DisplayName,
"ChannelsLen": channelsLen,
"SiteName": es.config().TeamSettings.SiteName})
title := i18n.T("api.templates.invite_team_and_channels_body.title", map[string]any{
"SenderName": senderName,
"SenderName": inviteData.SenderName,
"ChannelsLen": channelsLen,
"TeamDisplayName": team.DisplayName})
"TeamDisplayName": inviteData.Team.DisplayName})
if channelsLen == 1 {
channelName := channels[0].DisplayName
channelName := inviteData.Channels[0].DisplayName
subject = i18n.T("api.templates.invite_team_and_channel_subject",
map[string]any{"SenderName": senderName,
"TeamDisplayName": team.DisplayName,
map[string]any{"SenderName": inviteData.SenderName,
"TeamDisplayName": inviteData.Team.DisplayName,
"ChannelName": channelName,
"SiteName": es.config().TeamSettings.SiteName},
)
title = i18n.T("api.templates.invite_team_and_channel_body.title", map[string]any{
"SenderName": senderName,
"SenderName": inviteData.SenderName,
"ChannelName": channelName,
"TeamDisplayName": team.DisplayName,
"TeamDisplayName": inviteData.Team.DisplayName,
})
}
var invitesWithErrors []*model.EmailInviteWithError
for _, invite := range invites {
for _, invite := range inviteData.Invites {
if invite == "" {
continue
}
channelIDs := []string{}
for _, channel := range channels {
for _, channel := range inviteData.Channels {
channelIDs = append(channelIDs, channel.Id)
}
data := es.NewEmailTemplateData("")
data.Props["SiteURL"] = siteURL
data.Props["SiteURL"] = inviteData.SiteURL
data.Props["SubTitle"] = i18n.T("api.templates.invite_body.subTitle")
data.Props["Button"] = i18n.T("api.templates.invite_body.button")
data.Props["SenderName"] = senderName
data.Props["SenderName"] = inviteData.SenderName
data.Props["InviteFooterTitle"] = i18n.T("api.templates.invite_body_footer.title")
data.Props["InviteFooterInfo"] = i18n.T("api.templates.invite_body_footer.info")
data.Props["InviteFooterLearnMore"] = i18n.T("api.templates.invite_body_footer.learn_more")
if message != "" {
message = bluemonday.NewPolicy().Sanitize(message)
if inviteData.Message != "" {
inviteData.Message = bluemonday.NewPolicy().Sanitize(inviteData.Message)
}
data.Props["Message"] = message
data.Props["Message"] = inviteData.Message
tokenExtra, tokenProps := buildInviteTokenData(inviteData.Team, invite, inviteData.Profiles[invite])
tokenExtra["channels"] = strings.Join(channelIDs, " ")
tokenExtra["senderId"] = inviteData.SenderUserID
token := model.NewToken(
TokenTypeTeamInvitation,
model.MapToJSON(map[string]string{
"teamId": team.Id,
"email": invite,
"channels": strings.Join(channelIDs, " "),
"senderId": senderUserId,
}),
model.MapToJSON(tokenExtra),
)
tokenProps := make(map[string]string)
tokenProps["email"] = invite
tokenProps["display_name"] = team.DisplayName
tokenProps["name"] = team.Name
if reminderData != nil {
if inviteData.ReminderData != nil {
reminder := i18n.T("api.templates.invite_body.title.reminder")
title = fmt.Sprintf("%s: %s", reminder, title)
tokenProps["reminder_interval"] = reminderData.Interval
tokenProps["reminder_interval"] = inviteData.ReminderData.Interval
}
data.Props["Title"] = title
@@ -830,21 +819,21 @@ func (es *Service) SendInviteEmailsToTeamAndChannels(
continue
}
data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?d=%s&t=%s&sbr=%s", siteURL, url.QueryEscape(tokenData), url.QueryEscape(token.Token), es.GetTrackFlowStartedByRole(isFirstAdmin, isSystemAdmin))
data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?d=%s&t=%s&sbr=%s", inviteData.SiteURL, url.QueryEscape(tokenData), url.QueryEscape(token.Token), es.GetTrackFlowStartedByRole(inviteData.IsFirstAdmin, inviteData.IsSystemAdmin))
senderPhoto := ""
embeddedFiles := make(map[string]io.Reader)
if message != "" {
if senderProfileImage != nil {
if inviteData.Message != "" {
if inviteData.SenderProfileImage != nil {
senderPhoto = "user-avatar.png"
embeddedFiles = map[string]io.Reader{
senderPhoto: bytes.NewReader(senderProfileImage),
senderPhoto: bytes.NewReader(inviteData.SenderProfileImage),
}
}
}
pData := postData{
SenderName: senderName,
Message: template.HTML(message),
SenderName: inviteData.SenderName,
Message: template.HTML(inviteData.Message),
SenderPhoto: senderPhoto,
}
@@ -857,7 +846,7 @@ func (es *Service) SendInviteEmailsToTeamAndChannels(
if nErr := es.SendMailWithEmbeddedFiles(invite, subject, body, embeddedFiles, "", "", "", "InviteEmailToTeamsAndChannels"); nErr != nil {
mlog.Error("Failed to send invite email successfully", mlog.Err(nErr))
if errorWhenNotSent {
if inviteData.ErrorWhenNotSent {
inviteWithError := &model.EmailInviteWithError{
Email: invite,
Error: &model.AppError{Message: nErr.Error()},
+85 -39
View File
@@ -4,6 +4,9 @@
package email
import (
"html"
"net/url"
"regexp"
"strings"
"testing"
@@ -119,6 +122,16 @@ func TestSendInviteEmails(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
newInviteData := func() InviteEmailData {
return InviteEmailData{
Team: th.BasicTeam,
SenderName: "test-user",
SenderUserID: th.BasicUser.Id,
Invites: []string{emailTo},
SiteURL: "http://testserver",
}
}
retrieveEmail := func(t *testing.T) mail.JSONMessageInbucket {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
@@ -154,7 +167,7 @@ func TestSendInviteEmails(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(th.Context, th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver", nil, false, false, false)
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
verifyMailbox(t)
@@ -172,10 +185,12 @@ func TestSendInviteEmails(t *testing.T) {
*cfg.EmailSettings.SMTPServerTimeout = originalTimeout
})
err := th.service.SendInviteEmails(th.Context, th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver", nil, true, false, false)
inviteData := newInviteData()
inviteData.ErrorWhenNotSent = true
err := th.service.SendInviteEmails(th.Context, inviteData)
require.Error(t, err)
err = th.service.SendInviteEmails(th.Context, th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver", nil, false, false, false)
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
})
@@ -282,18 +297,7 @@ func TestSendInviteEmails(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(
th.Context,
th.BasicTeam,
"test-user",
th.BasicUser.Id,
[]string{emailTo},
"http://testserver",
nil,
false,
false,
false,
)
err = th.service.SendInviteEmails(th.Context, newInviteData())
require.NoError(t, err)
email := retrieveEmail(t)
@@ -304,18 +308,9 @@ func TestSendInviteEmails(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(
th.Context,
th.BasicTeam,
"test-user",
th.BasicUser.Id,
[]string{emailTo},
"http://testserver",
nil,
false,
true,
false,
)
inviteData := newInviteData()
inviteData.IsSystemAdmin = true
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
@@ -326,23 +321,74 @@ func TestSendInviteEmails(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
err = th.service.SendInviteEmails(
th.Context,
th.BasicTeam,
"test-user",
th.BasicUser.Id,
[]string{emailTo},
"http://testserver",
nil,
false,
true,
true,
)
inviteData := newInviteData()
inviteData.IsSystemAdmin = true
inviteData.IsFirstAdmin = true
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=fa")
})
t.Run("SendInviteEmails with profiles should put profile fields into token extra and link data", func(t *testing.T) {
err := mail.DeleteMailBox(emailTo)
require.NoError(t, err, "Failed to delete mailbox")
profiles := map[string]*model.MemberInviteProfile{
emailTo: {
Email: emailTo,
Username: "dave.roberts",
FirstName: "Dave",
LastName: "Roberts",
},
}
inviteData := newInviteData()
inviteData.Profiles = profiles
err = th.service.SendInviteEmails(th.Context, inviteData)
require.NoError(t, err)
email := retrieveEmail(t)
token := findTokenFromEmail(t, th, email.Body.HTML)
tokenData := model.MapFromJSON(strings.NewReader(token.Extra))
require.Equal(t, emailTo, tokenData["email"])
require.Equal(t, "dave.roberts", tokenData["username"])
require.Equal(t, "Dave", tokenData["first_name"])
require.Equal(t, "Roberts", tokenData["last_name"])
linkData := findLinkDataFromEmail(t, email.Body.HTML)
require.Equal(t, "dave.roberts", linkData["username"])
require.Equal(t, "Dave", linkData["first_name"])
require.Equal(t, "Roberts", linkData["last_name"])
})
}
// findSignupQueryFromEmail extracts the signup_user_complete query parameters from an invite email body.
func findSignupQueryFromEmail(t *testing.T, emailHTML string) url.Values {
t.Helper()
re := regexp.MustCompile(`signup_user_complete/\?([^"]*)`)
matches := re.FindStringSubmatch(html.UnescapeString(emailHTML))
require.Len(t, matches, 2, "invite email should contain a signup link")
queryString, err := url.ParseQuery(matches[1])
require.NoError(t, err)
return queryString
}
// findTokenFromEmail loads the invitation token referenced by an invite email body.
func findTokenFromEmail(t *testing.T, th *TestHelper, emailHTML string) *model.Token {
t.Helper()
queryString := findSignupQueryFromEmail(t, emailHTML)
token, err := th.service.store.Token().GetByToken(queryString.Get("t"))
require.NoError(t, err)
return token
}
// findLinkDataFromEmail parses the d prefill param of the signup link in an invite email body.
func findLinkDataFromEmail(t *testing.T, emailHTML string) map[string]string {
t.Helper()
queryString := findSignupQueryFromEmail(t, emailHTML)
return model.MapFromJSON(strings.NewReader(queryString.Get("d")))
}
func TestSendCloudWelcomeEmail(t *testing.T) {
@@ -10,6 +10,7 @@ import (
model "github.com/mattermost/mattermost/server/public/model"
i18n "github.com/mattermost/mattermost/server/public/shared/i18n"
request "github.com/mattermost/mattermost/server/public/shared/request"
email "github.com/mattermost/mattermost/server/v8/channels/app/email"
store "github.com/mattermost/mattermost/server/v8/channels/store"
templates "github.com/mattermost/mattermost/server/v8/platform/shared/templates"
mock "github.com/stretchr/testify/mock"
@@ -265,17 +266,17 @@ func (_m *ServiceInterface) SendIPFiltersChangedEmail(_a0 string, userWhoChanged
return r0
}
// SendInviteEmails provides a mock function with given fields: rctx, team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin
func (_m *ServiceInterface) SendInviteEmails(rctx request.CTX, team *model.Team, senderName string, senderUserId string, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error {
ret := _m.Called(rctx, team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
// SendInviteEmails provides a mock function with given fields: rctx, inviteData
func (_m *ServiceInterface) SendInviteEmails(rctx request.CTX, inviteData email.InviteEmailData) error {
ret := _m.Called(rctx, inviteData)
if len(ret) == 0 {
panic("no return value specified for SendInviteEmails")
}
var r0 error
if rf, ok := ret.Get(0).(func(request.CTX, *model.Team, string, string, []string, string, *model.TeamInviteReminderData, bool, bool, bool) error); ok {
r0 = rf(rctx, team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) error); ok {
r0 = rf(rctx, inviteData)
} else {
r0 = ret.Error(0)
}
@@ -283,9 +284,9 @@ func (_m *ServiceInterface) SendInviteEmails(rctx request.CTX, team *model.Team,
return r0
}
// SendInviteEmailsToTeamAndChannels provides a mock function with given fields: rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin
func (_m *ServiceInterface) SendInviteEmailsToTeamAndChannels(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) ([]*model.EmailInviteWithError, error) {
ret := _m.Called(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
// SendInviteEmailsToTeamAndChannels provides a mock function with given fields: rctx, inviteData
func (_m *ServiceInterface) SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData email.InviteEmailData) ([]*model.EmailInviteWithError, error) {
ret := _m.Called(rctx, inviteData)
if len(ret) == 0 {
panic("no return value specified for SendInviteEmailsToTeamAndChannels")
@@ -293,19 +294,19 @@ func (_m *ServiceInterface) SendInviteEmailsToTeamAndChannels(rctx request.CTX,
var r0 []*model.EmailInviteWithError
var r1 error
if rf, ok := ret.Get(0).(func(request.CTX, *model.Team, []*model.Channel, string, string, []byte, []string, string, *model.TeamInviteReminderData, string, bool, bool, bool) ([]*model.EmailInviteWithError, error)); ok {
return rf(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) ([]*model.EmailInviteWithError, error)); ok {
return rf(rctx, inviteData)
}
if rf, ok := ret.Get(0).(func(request.CTX, *model.Team, []*model.Channel, string, string, []byte, []string, string, *model.TeamInviteReminderData, string, bool, bool, bool) []*model.EmailInviteWithError); ok {
r0 = rf(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
if rf, ok := ret.Get(0).(func(request.CTX, email.InviteEmailData) []*model.EmailInviteWithError); ok {
r0 = rf(rctx, inviteData)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.EmailInviteWithError)
}
}
if rf, ok := ret.Get(1).(func(request.CTX, *model.Team, []*model.Channel, string, string, []byte, []string, string, *model.TeamInviteReminderData, string, bool, bool, bool) error); ok {
r1 = rf(rctx, team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
if rf, ok := ret.Get(1).(func(request.CTX, email.InviteEmailData) error); ok {
r1 = rf(rctx, inviteData)
} else {
r1 = ret.Error(1)
}
+18 -2
View File
@@ -131,6 +131,22 @@ func (es *Service) setUpRateLimiters() error {
return nil
}
type InviteEmailData struct {
Team *model.Team
Channels []*model.Channel
SenderName string
SenderUserID string
SenderProfileImage []byte
Invites []string
Profiles map[string]*model.MemberInviteProfile
SiteURL string
ReminderData *model.TeamInviteReminderData
Message string
ErrorWhenNotSent bool
IsSystemAdmin bool
IsFirstAdmin bool
}
type ServiceInterface interface {
NewEmailTemplateData(locale string) templates.Data
SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) error
@@ -144,10 +160,10 @@ type ServiceInterface interface {
SendUserAccessTokenRotatedEmail(email, locale, siteURL string) error
SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, error)
SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error
SendInviteEmails(rctx request.CTX, team *model.Team, senderName string, senderUserId string, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error
SendInviteEmails(rctx request.CTX, inviteData InviteEmailData) error
SendGuestInviteEmails(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool, isGuestMagicLink bool) error
SendMagicLinkEmailSelfService(rctx request.CTX, invite string, siteURL string) error
SendInviteEmailsToTeamAndChannels(rctx request.CTX, team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) ([]*model.EmailInviteWithError, error)
SendInviteEmailsToTeamAndChannels(rctx request.CTX, inviteData InviteEmailData) ([]*model.EmailInviteWithError, error)
SendDeactivateAccountEmail(email string, locale, siteURL string) error
SendNotificationMail(to, subject, htmlBody string) error
SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error
+110 -20
View File
@@ -1561,6 +1561,15 @@ func (a *App) prepareInviteNewUsersToTeam(teamID, senderId string, channelIds []
return user, team, channels, nil
}
// isPreSetUsernameAvailable reports whether a username pre-set on an invite is not
// already taken by an existing user or group.
func (a *App) isPreSetUsernameAvailable(username string) bool {
if _, err := a.GetUserByUsername(username); err == nil {
return false
}
return a.isUniqueToGroupNames(username) == nil
}
func (a *App) InviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) {
if !*a.Config().ServiceSettings.EnableEmailInvitations {
return nil, model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.disabled.app_error", nil, "", http.StatusNotImplemented)
@@ -1576,18 +1585,95 @@ func (a *App) InviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite *mod
if err != nil {
return nil, err
}
allowedDomains := a.ch.srv.teamService.GetAllowedDomains(user, team)
nameFormat := *a.Config().TeamSettings.TeammateNameDisplay
senderProfileImage, _, imageErr := a.GetProfileImage(user)
if imageErr != nil {
rctx.Logger().Warn("Unable to get the sender user profile image.", mlog.String("user_id", user.Id), mlog.String("team_id", team.Id), mlog.Err(imageErr))
}
inviteData := email.InviteEmailData{
Team: team,
Channels: channels,
SenderName: user.GetDisplayName(nameFormat),
SenderUserID: user.Id,
SenderProfileImage: senderProfileImage,
SiteURL: a.GetSiteURL(),
Message: memberInvite.Message,
ErrorWhenNotSent: true,
IsSystemAdmin: user.IsSystemAdmin(),
IsFirstAdmin: a.UserIsFirstAdmin(rctx, user),
}
return a.sendInviteNewUsersToTeamGracefully(rctx, memberInvite, inviteData, a.ch.srv.teamService.GetAllowedDomains(user, team), reminderInterval)
}
func (a *App) InviteNewUsersToTeamGracefullyForLocal(rctx request.CTX, memberInvite *model.MemberInvite, team *model.Team, channels []*model.Channel) ([]*model.EmailInviteWithError, *model.AppError) {
inviteData := email.InviteEmailData{
Team: team,
Channels: channels,
SenderName: "Administrator",
SenderUserID: "mmctl " + model.NewId(),
SiteURL: a.GetSiteURL(),
Message: memberInvite.Message,
ErrorWhenNotSent: len(channels) > 0,
IsSystemAdmin: true,
}
allowedDomains := []string{team.AllowedDomains, *a.Config().TeamSettings.RestrictCreationToDomains}
return a.sendInviteNewUsersToTeamGracefully(rctx, memberInvite, inviteData, allowedDomains, "")
}
func (a *App) validateAndNormalizeMemberInviteProfiles(memberInvite *model.MemberInvite) *model.AppError {
if len(memberInvite.Profiles) == 0 {
return nil
}
if !model.MinimumEnterpriseLicense(a.License()) {
return model.NewAppError("InviteNewUsersToTeamGracefully", "api.team.invite_members.profiles_license.app_error", nil, "", http.StatusBadRequest)
}
if *a.Config().TeamSettings.LockProfileFieldsForEmailUsers == model.TeamSettingsLockProfileFieldsNone {
return model.NewAppError("InviteNewUsersToTeamGracefully", "api.team.invite_members.profiles_disabled.app_error", nil, "", http.StatusBadRequest)
}
if appErr := memberInvite.IsValid(); appErr != nil {
return appErr
}
for _, profile := range memberInvite.Profiles {
profile.Email = model.NormalizeEmail(profile.Email)
profile.Username = model.NormalizeUsername(profile.Username)
}
return nil
}
func (a *App) sendInviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite *model.MemberInvite, inviteData email.InviteEmailData, allowedDomains []string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) {
if appErr := a.validateAndNormalizeMemberInviteProfiles(memberInvite); appErr != nil {
return nil, appErr
}
profilesByEmail := make(map[string]*model.MemberInviteProfile, len(memberInvite.Profiles))
for _, profile := range memberInvite.Profiles {
profilesByEmail[model.NormalizeEmail(profile.Email)] = profile
}
var inviteListWithErrors []*model.EmailInviteWithError
var goodEmails []string
for _, email := range emailList {
for _, invitedEmail := range memberInvite.Emails {
invitedEmail = model.NormalizeEmail(invitedEmail)
invite := &model.EmailInviteWithError{
Email: email,
Email: invitedEmail,
Error: nil,
}
if !teams.IsEmailAddressAllowed(email, allowedDomains) {
invite.Error = model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": email}, "", http.StatusBadRequest)
if !teams.IsEmailAddressAllowed(invitedEmail, allowedDomains) {
invite.Error = model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.invalid_email.app_error", map[string]any{"Addresses": invitedEmail}, "", http.StatusBadRequest)
} else if profile := profilesByEmail[invitedEmail]; profile != nil && !a.isPreSetUsernameAvailable(profile.Username) {
// Catch taken usernames at invite time so the invitee doesn't dead-end at signup.
invite.Error = model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.username_taken.app_error", map[string]any{"Username": profile.Username}, "", http.StatusBadRequest)
} else {
goodEmails = append(goodEmails, email)
goodEmails = append(goodEmails, invitedEmail)
}
inviteListWithErrors = append(inviteListWithErrors, invite)
}
@@ -1598,20 +1684,16 @@ func (a *App) InviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite *mod
}
if len(goodEmails) > 0 {
nameFormat := *a.Config().TeamSettings.TeammateNameDisplay
senderProfileImage, _, err := a.GetProfileImage(user)
if err != nil {
rctx.Logger().Warn("Unable to get the sender user profile image.", mlog.String("user_id", user.Id), mlog.String("team_id", team.Id), mlog.Err(err))
}
userIsFirstAdmin := a.UserIsFirstAdmin(rctx, user)
inviteData.Invites = goodEmails
inviteData.Profiles = profilesByEmail
inviteData.ReminderData = reminderData
var eErr error
var invitesWithErrors2 []*model.EmailInviteWithError
if len(channels) > 0 {
invitesWithErrors2, eErr = a.Srv().EmailService.SendInviteEmailsToTeamAndChannels(rctx, team, channels, user.GetDisplayName(nameFormat), user.Id, senderProfileImage, goodEmails, a.GetSiteURL(), reminderData, memberInvite.Message, true, user.IsSystemAdmin(), userIsFirstAdmin)
if len(inviteData.Channels) > 0 {
invitesWithErrors2, eErr = a.Srv().EmailService.SendInviteEmailsToTeamAndChannels(rctx, inviteData)
inviteListWithErrors = append(inviteListWithErrors, invitesWithErrors2...)
} else {
eErr = a.Srv().EmailService.SendInviteEmails(rctx, team, user.GetDisplayName(nameFormat), user.Id, goodEmails, a.GetSiteURL(), reminderData, true, user.IsSystemAdmin(), userIsFirstAdmin)
eErr = a.Srv().EmailService.SendInviteEmails(rctx, inviteData)
}
if eErr != nil {
switch {
@@ -1626,11 +1708,11 @@ func (a *App) InviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite *mod
}
}
case errors.Is(eErr, email.NoRateLimiterError):
return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", user.Id, team.Id), http.StatusInternalServerError)
return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", inviteData.SenderUserID, inviteData.Team.Id), http.StatusInternalServerError)
case errors.Is(eErr, email.SetupRateLimiterError):
return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", user.Id, team.Id, eErr), http.StatusInternalServerError)
return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", inviteData.SenderUserID, inviteData.Team.Id, eErr), http.StatusInternalServerError)
default:
return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", user.Id, team.Id, eErr), http.StatusRequestEntityTooLarge)
return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", inviteData.SenderUserID, inviteData.Team.Id, eErr), http.StatusRequestEntityTooLarge)
}
}
}
@@ -1805,7 +1887,15 @@ func (a *App) InviteNewUsersToTeam(rctx request.CTX, emailList []string, teamID,
}
nameFormat := *a.Config().TeamSettings.TeammateNameDisplay
eErr := a.Srv().EmailService.SendInviteEmails(rctx, team, user.GetDisplayName(nameFormat), user.Id, emailList, a.GetSiteURL(), nil, false, user.IsSystemAdmin(), a.UserIsFirstAdmin(rctx, user))
eErr := a.Srv().EmailService.SendInviteEmails(rctx, email.InviteEmailData{
Team: team,
SenderName: user.GetDisplayName(nameFormat),
SenderUserID: user.Id,
Invites: emailList,
SiteURL: a.GetSiteURL(),
IsSystemAdmin: user.IsSystemAdmin(),
IsFirstAdmin: a.UserIsFirstAdmin(rctx, user),
})
if eErr != nil {
switch {
case errors.Is(eErr, email.NoRateLimiterError):
+73 -39
View File
@@ -2104,10 +2104,35 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableEmailInvitations = true
*cfg.TeamSettings.LockProfileFieldsForEmailUsers = model.TeamSettingsLockProfileFieldsNameAndUsername
})
inviteDataMatches := func(memberInvite *model.MemberInvite) any {
return mock.MatchedBy(func(inviteData email.InviteEmailData) bool {
if inviteData.Team.Id != th.BasicTeam.Id ||
inviteData.ErrorWhenNotSent != true ||
len(inviteData.Invites) != len(memberInvite.Emails) ||
len(inviteData.Channels) != len(memberInvite.ChannelIds) ||
len(inviteData.Profiles) != len(memberInvite.Profiles) {
return false
}
for i, invite := range inviteData.Invites {
if invite != memberInvite.Emails[i] {
return false
}
}
for _, profile := range memberInvite.Profiles {
if inviteData.Profiles[profile.Email] != profile {
return false
}
}
return true
})
}
t.Run("it return list of email with no error on success", func(t *testing.T) {
emailServiceMock := emailmocks.ServiceInterface{}
memberInvite := &model.MemberInvite{
@@ -2115,15 +2140,7 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) {
}
emailServiceMock.On("SendInviteEmails",
mock.Anything,
mock.AnythingOfType("*model.Team"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
memberInvite.Emails,
"",
mock.Anything,
true,
false,
false,
inviteDataMatches(memberInvite),
).Once().Return(nil)
emailServiceMock.On("Stop").Once().Return()
th.App.Srv().EmailService = &emailServiceMock
@@ -2141,15 +2158,7 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) {
}
emailServiceMock.On("SendInviteEmails",
mock.Anything,
mock.AnythingOfType("*model.Team"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
memberInvite.Emails,
"",
mock.Anything,
true,
false,
false,
inviteDataMatches(memberInvite),
).Once().Return(email.SendMailError)
emailServiceMock.On("Stop").Once().Return()
th.App.Srv().EmailService = &emailServiceMock
@@ -2168,18 +2177,7 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) {
}
emailServiceMock.On("SendInviteEmailsToTeamAndChannels",
mock.Anything,
mock.AnythingOfType("*model.Team"),
mock.AnythingOfType("[]*model.Channel"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
mock.AnythingOfType("[]uint8"),
memberInvite.Emails,
"",
mock.Anything,
mock.AnythingOfType("string"),
true,
false,
false,
inviteDataMatches(memberInvite),
).Once().Return([]*model.EmailInviteWithError{}, nil)
emailServiceMock.On("Stop").Once().Return()
th.App.Srv().EmailService = &emailServiceMock
@@ -2197,15 +2195,7 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) {
}
emailServiceMock.On("SendInviteEmails",
mock.Anything,
mock.AnythingOfType("*model.Team"),
mock.AnythingOfType("string"),
mock.AnythingOfType("string"),
[]string{"idontexist@mattermost.com"},
"",
mock.Anything,
true,
false,
false,
inviteDataMatches(memberInvite),
).Once().Return(nil)
emailServiceMock.On("Stop").Once().Return()
th.App.Srv().EmailService = &emailServiceMock
@@ -2215,6 +2205,50 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) {
require.Len(t, res, 1)
require.Nil(t, res[0].Error)
})
t.Run("it passes the invite profiles keyed by email to the email service", func(t *testing.T) {
emailServiceMock := emailmocks.ServiceInterface{}
memberInvite := &model.MemberInvite{
Emails: []string{"idontexist@mattermost.com"},
Profiles: []*model.MemberInviteProfile{{
Email: "idontexist@mattermost.com",
Username: "un_" + model.NewId(),
FirstName: "Pre",
LastName: "Set",
}},
}
emailServiceMock.On("SendInviteEmails",
mock.Anything,
inviteDataMatches(memberInvite),
).Once().Return(nil)
emailServiceMock.On("Stop").Once().Return()
th.App.Srv().EmailService = &emailServiceMock
res, err := th.App.InviteNewUsersToTeamGracefully(th.Context, memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "")
require.Nil(t, err)
require.Len(t, res, 1)
require.Nil(t, res[0].Error)
})
t.Run("it fails the email gracefully when the pre-set username is taken", func(t *testing.T) {
emailServiceMock := emailmocks.ServiceInterface{}
memberInvite := &model.MemberInvite{
Emails: []string{"idontexist@mattermost.com"},
Profiles: []*model.MemberInviteProfile{{
Email: "idontexist@mattermost.com",
Username: th.BasicUser2.Username,
}},
}
emailServiceMock.On("Stop").Once().Return()
th.App.Srv().EmailService = &emailServiceMock
res, err := th.App.InviteNewUsersToTeamGracefully(th.Context, memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "")
require.Nil(t, err)
require.Len(t, res, 1)
require.NotNil(t, res[0].Error)
require.Equal(t, "api.team.invite_members.username_taken.app_error", res[0].Error.Id)
emailServiceMock.AssertNotCalled(t, "SendInviteEmails")
})
}
func TestInviteGuestsToChannelsGracefully(t *testing.T) {
+72 -4
View File
@@ -34,6 +34,12 @@ import (
const (
ImageProfilePixelDimension = 128
lockedProfileFieldUsername = "username"
lockedProfileFieldFirstName = "first name"
lockedProfileFieldLastName = "last name"
lockedProfileFieldNickname = "nickname"
lockedProfileFieldPosition = "position"
)
func (a *App) CreateUserWithToken(rctx request.CTX, user *model.User, token *model.Token) (*model.User, *model.AppError) {
@@ -86,6 +92,17 @@ func (a *App) CreateUserWithToken(rctx request.CTX, user *model.User, token *mod
user.Email = tokenData["email"]
user.EmailVerified = true
// Profile fields pre-set by the inviter are authoritative over client-supplied values.
if username := tokenData["username"]; username != "" {
user.Username = strings.ToLower(username)
}
if firstName := tokenData["first_name"]; firstName != "" {
user.FirstName = firstName
}
if lastName := tokenData["last_name"]; lastName != "" {
user.LastName = lastName
}
var ruser *model.User
var err *model.AppError
if token.Type == model.TokenTypeTeamInvitation {
@@ -1362,14 +1379,14 @@ func (a *App) UpdateUserAsUser(rctx request.CTX, user *model.User, asAdmin bool)
return updatedUser, nil
}
func tryingToChange(userValue *string, patchValue *string) bool {
return patchValue != nil && *patchValue != *userValue
}
// CheckProviderAttributes returns the empty string if the patch can be applied without
// overriding attributes set by the user's login provider; otherwise, the name of the offending
// field is returned.
func (a *App) CheckProviderAttributes(rctx request.CTX, user *model.User, patch *model.UserPatch) string {
tryingToChange := func(userValue *string, patchValue *string) bool {
return patchValue != nil && *patchValue != *userValue
}
// If any login provider is used, then the username may not be changed
if user.AuthService != "" && tryingToChange(&user.Username, patch.Username) {
return "username"
@@ -1393,6 +1410,57 @@ func (a *App) CheckProviderAttributes(rctx request.CTX, user *model.User, patch
return conflictField
}
// CheckLockedProfileFields returns the name of the first profile field in the patch that
// conflicts with TeamSettings.LockProfileFieldsForEmailUsers, or "" when there is no conflict.
// It only applies to email/password users on Enterprise-licensed servers and exempts sessions
// with the edit_other_users permission.
func (a *App) CheckLockedProfileFields(session model.Session, user *model.User, patch *model.UserPatch) string {
if a.SessionHasPermissionTo(session, model.PermissionEditOtherUsers) ||
!model.MinimumEnterpriseLicense(a.License()) ||
user.AuthService != "" {
return ""
}
setting := *a.Config().TeamSettings.LockProfileFieldsForEmailUsers
if setting != model.TeamSettingsLockProfileFieldsNameAndUsername && setting != model.TeamSettingsLockProfileFieldsAll {
return ""
}
if tryingToChange(&user.Username, patch.Username) {
return lockedProfileFieldUsername
}
// Empty first/last names may be filled in once, so users who signed up without
// pre-provisioned names (e.g. via a team invite link) aren't stuck nameless.
if user.FirstName != "" && tryingToChange(&user.FirstName, patch.FirstName) {
return lockedProfileFieldFirstName
}
if user.LastName != "" && tryingToChange(&user.LastName, patch.LastName) {
return lockedProfileFieldLastName
}
if setting == model.TeamSettingsLockProfileFieldsAll {
if tryingToChange(&user.Nickname, patch.Nickname) {
return lockedProfileFieldNickname
}
if tryingToChange(&user.Position, patch.Position) {
return lockedProfileFieldPosition
}
}
return ""
}
// IsProfileImageLockedForUser returns true when TeamSettings.LockProfileFieldsForEmailUsers
// locks the profile picture of the given email/password user, unless the session has the
// edit_other_users permission.
func (a *App) IsProfileImageLockedForUser(session model.Session, user *model.User) bool {
return !a.SessionHasPermissionTo(session, model.PermissionEditOtherUsers) &&
model.MinimumEnterpriseLicense(a.License()) &&
user.AuthService == "" &&
*a.Config().TeamSettings.LockProfileFieldsForEmailUsers == model.TeamSettingsLockProfileFieldsAll
}
func (a *App) PatchUser(rctx request.CTX, userID string, patch *model.UserPatch, asAdmin bool) (*model.User, *model.AppError) {
user, err := a.GetUser(userID)
if err != nil {
+62
View File
@@ -1241,6 +1241,68 @@ func TestCreateUserWithToken(t *testing.T) {
assert.Len(t, members, 2)
})
t.Run("token profile fields override client-supplied user data", func(t *testing.T) {
invitationEmail := strings.ToLower(model.NewId()) + "other-email@test.com"
presetUsername := "preset" + model.NewId()
// The client-supplied payload simulates a tampered signup request.
u := model.User{Email: invitationEmail, Username: "tampered" + model.NewId(), FirstName: "Tampered", LastName: "Values", Password: model.NewTestPassword(), AuthService: ""}
token := model.NewToken(
model.TokenTypeTeamInvitation,
model.MapToJSON(map[string]string{
"teamId": th.BasicTeam.Id,
"email": invitationEmail,
"username": presetUsername,
"first_name": "Dave",
"last_name": "Roberts",
}),
)
require.NoError(t, th.App.Srv().Store().Token().Save(token))
newUser, err := th.App.CreateUserWithToken(th.Context, &u, token)
require.Nil(t, err, "Should create the user. err=%v", err)
require.Equal(t, presetUsername, newUser.Username, "The username must be the pre-set one")
require.Equal(t, "Dave", newUser.FirstName, "The first name must be the pre-set one")
require.Equal(t, "Roberts", newUser.LastName, "The last name must be the pre-set one")
})
t.Run("token username is lowercased", func(t *testing.T) {
invitationEmail := strings.ToLower(model.NewId()) + "other-email@test.com"
presetUsername := "preset" + model.NewId()
u := model.User{Email: invitationEmail, Username: "vader" + model.NewId(), Password: model.NewTestPassword(), AuthService: ""}
token := model.NewToken(
model.TokenTypeTeamInvitation,
model.MapToJSON(map[string]string{
"teamId": th.BasicTeam.Id,
"email": invitationEmail,
"username": strings.ToUpper(presetUsername),
}),
)
require.NoError(t, th.App.Srv().Store().Token().Save(token))
newUser, err := th.App.CreateUserWithToken(th.Context, &u, token)
require.Nil(t, err, "Should create the user. err=%v", err)
require.Equal(t, presetUsername, newUser.Username)
})
t.Run("token with taken username fails with username_exists", func(t *testing.T) {
invitationEmail := strings.ToLower(model.NewId()) + "other-email@test.com"
u := model.User{Email: invitationEmail, Username: "vader" + model.NewId(), Password: model.NewTestPassword(), AuthService: ""}
token := model.NewToken(
model.TokenTypeTeamInvitation,
model.MapToJSON(map[string]string{
"teamId": th.BasicTeam.Id,
"email": invitationEmail,
"username": th.BasicUser.Username,
}),
)
require.NoError(t, th.App.Srv().Store().Token().Save(token))
defer func() {
appErr := th.App.DeleteToken(token)
require.Nil(t, appErr)
}()
_, err := th.App.CreateUserWithToken(th.Context, &u, token)
require.NotNil(t, err)
require.Equal(t, "app.user.save.username_exists.app_error", err.Id)
})
t.Run("valid guest request", func(t *testing.T) {
invitationEmail := strings.ToLower(model.NewId()) + "other-email@test.com"
token := model.NewToken(
@@ -91,7 +91,10 @@ func (rseworker *ResendInvitationEmailWorker) DoJob(job *model.Job) {
elapsedTimeSinceSchedule, DurationInMillis := rseworker.GetDurations(job)
if elapsedTimeSinceSchedule > DurationInMillis {
rseworker.ResendEmails(logger, job, "48")
if appErr := rseworker.ResendEmails(logger, job, "48"); appErr != nil {
rseworker.setJobError(logger, job, appErr)
return
}
rseworker.TearDown(logger, job)
}
}
@@ -175,28 +178,33 @@ func (rseworker *ResendInvitationEmailWorker) TearDown(logger mlog.LoggerIFace,
rseworker.setJobSuccess(logger, job)
}
func (rseworker *ResendInvitationEmailWorker) ResendEmails(logger mlog.LoggerIFace, job *model.Job, interval string) {
func (rseworker *ResendInvitationEmailWorker) ResendEmails(logger mlog.LoggerIFace, job *model.Job, interval string) *model.AppError {
rctx := request.EmptyContext(logger)
teamID := job.Data["teamID"]
emailListData := job.Data["emailList"]
channelListData := job.Data["channelList"]
emailList, err := rseworker.cleanEmailData(emailListData)
if err != nil {
appErr := model.NewAppError("worker: "+rseworker.name, "job_id: "+job.Id, nil, "", http.StatusInternalServerError).Wrap(err)
logger.Error("Worker: Failed to clean emails string data", mlog.Err(appErr))
rseworker.setJobError(logger, job, appErr)
return appErr
}
channelList, err := rseworker.cleanChannelsData(channelListData)
if err != nil {
appErr := model.NewAppError("worker: "+rseworker.name, "job_id: "+job.Id, nil, "", http.StatusInternalServerError).Wrap(err)
logger.Error("Worker: Failed to clean channel string data", mlog.Err(appErr))
rseworker.setJobError(logger, job, appErr)
var channelList []string
if channelListData := job.Data["channelList"]; channelListData != "" {
channelList, err = rseworker.cleanChannelsData(channelListData)
if err != nil {
appErr := model.NewAppError("worker: "+rseworker.name, "job_id: "+job.Id, nil, "", http.StatusInternalServerError).Wrap(err)
logger.Error("Worker: Failed to clean channel string data", mlog.Err(appErr))
return appErr
}
}
emailList = rseworker.removeAlreadyJoined(teamID, emailList)
if len(emailList) == 0 {
return nil
}
memberInvite := model.MemberInvite{
Emails: emailList,
@@ -206,9 +214,39 @@ func (rseworker *ResendInvitationEmailWorker) ResendEmails(logger mlog.LoggerIFa
memberInvite.ChannelIds = channelList
}
_, appErr := rseworker.app.InviteNewUsersToTeamGracefully(rctx, &memberInvite, teamID, job.Data["senderID"], interval)
if profileListData := job.Data["profilesList"]; profileListData != "" {
var profiles []*model.MemberInviteProfile
if err := json.Unmarshal([]byte(profileListData), &profiles); err != nil {
appErr := model.NewAppError("worker: "+rseworker.name, "job_id: "+job.Id, nil, "", http.StatusInternalServerError).Wrap(err)
logger.Error("Worker: Failed to clean profiles string data", mlog.Err(appErr))
return appErr
}
remainingEmails := make(map[string]struct{}, len(emailList))
for _, email := range emailList {
remainingEmails[model.NormalizeEmail(email)] = struct{}{}
}
for _, profile := range profiles {
if profile == nil {
memberInvite.Profiles = append(memberInvite.Profiles, profile)
continue
}
if _, ok := remainingEmails[model.NormalizeEmail(profile.Email)]; ok {
memberInvite.Profiles = append(memberInvite.Profiles, profile)
}
}
}
invitesWithErrors, appErr := rseworker.app.InviteNewUsersToTeamGracefully(rctx, &memberInvite, teamID, job.Data["senderID"], interval)
if appErr != nil {
logger.Error("Worker: Failed to send emails", mlog.Err(appErr))
rseworker.setJobError(logger, job, appErr)
return appErr
}
for _, invite := range invitesWithErrors {
if invite != nil && invite.Error != nil {
logger.Error("Worker: Failed to resend invitation", mlog.String("email", invite.Email), mlog.Err(invite.Error))
return invite.Error
}
}
return nil
}
@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package resend_invitation_email
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
type fakeResendApp struct {
invitedWith *model.MemberInvite
joinedEmail string
}
func (a *fakeResendApp) Config() *model.Config { return &model.Config{} }
func (a *fakeResendApp) AddConfigListener(func(old, cur *model.Config)) string { return "" }
func (a *fakeResendApp) RemoveConfigListener(string) {}
func (a *fakeResendApp) GetUserByEmail(email string) (*model.User, *model.AppError) {
if email == a.joinedEmail {
return &model.User{Id: email}, nil
}
return nil, model.NewAppError("GetUserByEmail", "app.user.missing_account.const", nil, "", http.StatusNotFound)
}
func (a *fakeResendApp) GetTeamMembersByIds(teamID string, userIDs []string, restrictions *model.ViewUsersRestrictions) ([]*model.TeamMember, *model.AppError) {
if len(userIDs) == 1 && userIDs[0] == a.joinedEmail {
return []*model.TeamMember{{UserId: a.joinedEmail}}, nil
}
return nil, nil
}
func (a *fakeResendApp) InviteNewUsersToTeamGracefully(rctx request.CTX, memberInvite *model.MemberInvite, teamID, senderID, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) {
a.invitedWith = memberInvite
return nil, nil
}
func newTestWorker(t *testing.T, app AppIface) *ResendInvitationEmailWorker {
t.Helper()
return &ResendInvitationEmailWorker{
name: "ResendInvitationEmail",
logger: mlog.CreateConsoleTestLogger(t),
app: app,
}
}
func TestResendEmailsCarriesProfilesForPendingUsers(t *testing.T) {
profiles := []*model.MemberInviteProfile{
{Email: "joined@example.com", Username: "joined.user"},
{Email: "waiting@example.com", Username: "waiting.user", FirstName: "Waiting", LastName: "User"},
}
profilesJSON, err := json.Marshal(profiles)
require.NoError(t, err)
app := &fakeResendApp{joinedEmail: "joined@example.com"}
worker := newTestWorker(t, app)
appErr := worker.ResendEmails(worker.logger, &model.Job{
Id: model.NewId(),
Data: map[string]string{
"emailList": model.ArrayToJSON([]string{"joined@example.com", "waiting@example.com"}),
"teamID": model.NewId(),
"senderID": model.NewId(),
"profilesList": string(profilesJSON),
},
}, "48")
require.Nil(t, appErr)
require.Equal(t, []string{"waiting@example.com"}, app.invitedWith.Emails)
require.Equal(t, profiles[1:], app.invitedWith.Profiles)
}
func TestResendEmailsRejectsMalformedJobData(t *testing.T) {
for _, key := range []string{"emailList", "channelList", "profilesList"} {
t.Run(key, func(t *testing.T) {
app := &fakeResendApp{}
worker := newTestWorker(t, app)
data := map[string]string{
"emailList": model.ArrayToJSON([]string{"user@example.com"}),
"teamID": model.NewId(),
"senderID": model.NewId(),
key: "not-json",
}
appErr := worker.ResendEmails(worker.logger, &model.Job{Id: model.NewId(), Data: data}, "48")
require.NotNil(t, appErr)
require.Nil(t, app.invitedWith)
})
}
}
+2
View File
@@ -22,6 +22,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["RestrictDirectMessage"] = *c.TeamSettings.RestrictDirectMessage
props["TeammateNameDisplay"] = *c.TeamSettings.TeammateNameDisplay
props["LockTeammateNameDisplay"] = strconv.FormatBool(*c.TeamSettings.LockTeammateNameDisplay)
props["LockProfileFieldsForEmailUsers"] = model.TeamSettingsLockProfileFieldsNone
props["ExperimentalPrimaryTeam"] = *c.TeamSettings.ExperimentalPrimaryTeam
props["EnableJoinLeaveMessageByDefault"] = strconv.FormatBool(*c.TeamSettings.EnableJoinLeaveMessageByDefault)
props["EnableChannelCategorySorting"] = strconv.FormatBool(*c.TeamSettings.EnableChannelCategorySorting)
@@ -243,6 +244,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
props["MobilePreventScreenCapture"] = strconv.FormatBool(*c.NativeAppSettings.MobilePreventScreenCapture)
props["MobileJailbreakProtection"] = strconv.FormatBool(*c.NativeAppSettings.MobileJailbreakProtection)
props["ExperimentalEnableWatermark"] = strconv.FormatBool(*c.ExperimentalSettings.EnableWatermark)
props["LockProfileFieldsForEmailUsers"] = *c.TeamSettings.LockProfileFieldsForEmailUsers
}
if model.MinimumEnterpriseAdvancedLicense(license) {
+20
View File
@@ -799,6 +799,26 @@ func TestGetClientConfig(t *testing.T) {
}
}
func TestGenerateClientConfigLockProfileFieldsForEmailUsers(t *testing.T) {
for name, testCase := range map[string]struct {
license *model.License
expected string
}{
"unlicensed": {expected: model.TeamSettingsLockProfileFieldsNone},
"professional": {license: model.NewTestLicenseSKU(model.LicenseShortSkuProfessional), expected: model.TeamSettingsLockProfileFieldsNone},
"enterprise": {license: model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise), expected: model.TeamSettingsLockProfileFieldsAll},
} {
t.Run(name, func(t *testing.T) {
config := &model.Config{}
config.SetDefaults()
config.TeamSettings.LockProfileFieldsForEmailUsers = model.NewPointer(model.TeamSettingsLockProfileFieldsAll)
clientConfig := GenerateClientConfig(config, "", testCase.license)
assert.Equal(t, testCase.expected, clientConfig["LockProfileFieldsForEmailUsers"])
})
}
}
func TestGetLimitedClientConfig(t *testing.T) {
t.Parallel()
testCases := []struct {
+60
View File
@@ -3910,6 +3910,18 @@
"id": "api.team.invite_members.no_one.app_error",
"translation": "No one to invite."
},
{
"id": "api.team.invite_members.profiles_disabled.app_error",
"translation": "Pre-setting profile fields on invitations requires locked profile fields to be enabled."
},
{
"id": "api.team.invite_members.profiles_graceful.app_error",
"translation": "Pre-setting profile fields on invitations requires graceful mode."
},
{
"id": "api.team.invite_members.profiles_license.app_error",
"translation": "Pre-setting profile fields on invitations requires an Enterprise license."
},
{
"id": "api.team.invite_members.unable_to_send_email.app_error",
"translation": "Error while sending the email"
@@ -3918,6 +3930,10 @@
"id": "api.team.invite_members.unable_to_send_email_with_defaults.app_error",
"translation": "SMTP is not configured in System Console"
},
{
"id": "api.team.invite_members.username_taken.app_error",
"translation": "The username {{.Username}} is already taken."
},
{
"id": "api.team.invite_members_to_team_and_channels.invalid_body.app_error",
"translation": "Invalid request body."
@@ -5050,6 +5066,10 @@
"id": "api.user.patch_user.login_provider_attribute_set.app_error",
"translation": "Field '{{.Field}}' must be set through user's login provider."
},
{
"id": "api.user.patch_user.profile_field_locked.app_error",
"translation": "The {{.Field}} field is managed by your System Admin and cannot be changed."
},
{
"id": "api.user.promote_guest_to_user.magic_link_enabled.app_error",
"translation": "Unable to convert the guest to regular user because the guest uses magic link authentication."
@@ -5202,6 +5222,10 @@
"id": "api.user.update_user.login_provider_attribute_set.app_error",
"translation": "Field '{{.Field}}' must be set through user's login provider."
},
{
"id": "api.user.update_user.profile_field_locked.app_error",
"translation": "The {{.Field}} field is managed by your System Admin and cannot be changed."
},
{
"id": "api.user.update_user_auth.invalid_request",
"translation": "Request is missing either AuthData or AuthService parameter."
@@ -5242,6 +5266,10 @@
"id": "api.user.upload_profile_user.parse.app_error",
"translation": "Could not parse multipart form."
},
{
"id": "api.user.upload_profile_user.profile_field_locked.app_error",
"translation": "The profile picture is managed by your System Admin and cannot be changed."
},
{
"id": "api.user.upload_profile_user.storage.app_error",
"translation": "Unable to upload file. Image storage is not configured."
@@ -12010,6 +12038,10 @@
"id": "model.config.is_valid.localization.available_locales.app_error",
"translation": "Available Languages must contain Default Client Language."
},
{
"id": "model.config.is_valid.lock_profile_fields.app_error",
"translation": "Invalid lock profile fields setting. Must be 'none', 'name_and_username' or 'all'."
},
{
"id": "model.config.is_valid.log.advanced_logging.json",
"translation": "Failed to parse JSON: {{.Error}}"
@@ -12626,6 +12658,34 @@
"id": "model.member.is_valid.emails.app_error",
"translation": "Email list is empty"
},
{
"id": "model.member.is_valid.profile_email.app_error",
"translation": "Profile email is not in the invited email list"
},
{
"id": "model.member.is_valid.profile_email_duplicate.app_error",
"translation": "Invitation profiles must have unique emails"
},
{
"id": "model.member.is_valid.profile_first_name.app_error",
"translation": "Invalid first name in invitation profile"
},
{
"id": "model.member.is_valid.profile_last_name.app_error",
"translation": "Invalid last name in invitation profile"
},
{
"id": "model.member.is_valid.profile_nil.app_error",
"translation": "Invitation profile cannot be null"
},
{
"id": "model.member.is_valid.profile_username.app_error",
"translation": "Invalid username in invitation profile"
},
{
"id": "model.member.is_valid.profile_username_duplicate.app_error",
"translation": "Invitation profiles must have unique usernames"
},
{
"id": "model.oauth.is_valid.app_id.app_error",
"translation": "Invalid app id."
+13
View File
@@ -2727,6 +2727,19 @@ func (c *Client4) InviteUsersToTeamAndChannelsGracefully(ctx context.Context, te
return DecodeJSONFromResponse[[]*EmailInviteWithError](r)
}
// InviteMembersToTeamGracefully invite users by email to the team, optionally carrying
// per-email profile fields to pre-set on the accounts created from the invitations.
func (c *Client4) InviteMembersToTeamGracefully(ctx context.Context, teamId string, memberInvite *MemberInvite) ([]*EmailInviteWithError, *Response, error) {
values := url.Values{}
values.Set("graceful", c.boolString(true))
r, err := c.doAPIPostJSONWithQuery(ctx, c.teamRoute(teamId).Join("invite", "email"), values, memberInvite)
if err != nil {
return nil, BuildResponse(r), err
}
defer closeBody(r)
return DecodeJSONFromResponse[[]*EmailInviteWithError](r)
}
// InviteGuestsToTeam invite guest by email to some channels in a team.
func (c *Client4) InviteGuestsToTeamGracefully(ctx context.Context, teamId string, userEmails []string, channels []string, message string) ([]*EmailInviteWithError, *Response, error) {
guestsInvite := GuestsInvite{
+13
View File
@@ -146,6 +146,10 @@ const (
TeamSettingsDefaultCustomDescriptionText = ""
TeamSettingsDefaultUserStatusAwayTimeout = 300
TeamSettingsLockProfileFieldsNone = "none"
TeamSettingsLockProfileFieldsNameAndUsername = "name_and_username"
TeamSettingsLockProfileFieldsAll = "all"
SqlSettingsDefaultDataSource = "postgres://mmuser:mostest@localhost/mattermost_test?sslmode=disable&connect_timeout=10&binary_parameters=yes"
FileSettingsDefaultDirectory = "./data/"
@@ -2559,6 +2563,7 @@ type TeamSettings struct {
ExperimentalViewArchivedChannels *bool `access:"experimental_features,site_users_and_teams"`
ExperimentalEnableAutomaticReplies *bool `access:"experimental_features"`
LockTeammateNameDisplay *bool `access:"site_users_and_teams"`
LockProfileFieldsForEmailUsers *string `access:"site_users_and_teams"`
ExperimentalPrimaryTeam *string `access:"experimental_features"`
ExperimentalDefaultChannels []string `access:"experimental_features"`
}
@@ -2659,6 +2664,10 @@ func (s *TeamSettings) SetDefaults() {
if s.LockTeammateNameDisplay == nil {
s.LockTeammateNameDisplay = new(false)
}
if s.LockProfileFieldsForEmailUsers == nil {
s.LockProfileFieldsForEmailUsers = new(TeamSettingsLockProfileFieldsNone)
}
}
type ClientRequirements struct {
@@ -4546,6 +4555,10 @@ func (s *TeamSettings) isValid() *AppError {
return NewAppError("Config.IsValid", "model.config.is_valid.experimental_view_archived_channels.app_error", nil, "", http.StatusBadRequest)
}
if !(*s.LockProfileFieldsForEmailUsers == TeamSettingsLockProfileFieldsNone || *s.LockProfileFieldsForEmailUsers == TeamSettingsLockProfileFieldsNameAndUsername || *s.LockProfileFieldsForEmailUsers == TeamSettingsLockProfileFieldsAll) {
return NewAppError("Config.IsValid", "model.config.is_valid.lock_profile_fields.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
+24
View File
@@ -858,6 +858,30 @@ func TestTeamSettingsIsValidSiteNameEmpty(t *testing.T) {
require.Nil(t, c1.TeamSettings.isValid())
}
func TestTeamSettingsLockProfileFieldsForEmailUsersIsValid(t *testing.T) {
for name, testCase := range map[string]struct {
value string
expectsError bool
}{
"none": {value: TeamSettingsLockProfileFieldsNone},
"name and username": {value: TeamSettingsLockProfileFieldsNameAndUsername},
"all": {value: TeamSettingsLockProfileFieldsAll},
"invalid": {value: "invalid", expectsError: true},
} {
t.Run(name, func(t *testing.T) {
config := Config{}
config.SetDefaults()
config.TeamSettings.LockProfileFieldsForEmailUsers = new(testCase.value)
if testCase.expectsError {
require.NotNil(t, config.TeamSettings.isValid())
} else {
require.Nil(t, config.TeamSettings.isValid())
}
})
}
}
func TestTeamSettingsDefaultJoinLeaveMessage(t *testing.T) {
c1 := Config{}
c1.SetDefaults()
+63 -5
View File
@@ -6,18 +6,30 @@ package model
import (
"encoding/json"
"net/http"
"unicode/utf8"
)
type MemberInvite struct {
Emails []string `json:"emails"`
ChannelIds []string `json:"channelIds,omitempty"`
Message string `json:"message"`
Emails []string `json:"emails"`
ChannelIds []string `json:"channelIds,omitempty"`
Message string `json:"message"`
Profiles []*MemberInviteProfile `json:"profiles,omitempty"`
}
// MemberInviteProfile carries admin-chosen profile fields for a single invited email,
// applied to the account created from that invitation.
type MemberInviteProfile struct {
Email string `json:"email"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
func (i *MemberInvite) Auditable() map[string]any {
return map[string]any{
"emails": i.Emails,
"channel_ids": i.ChannelIds,
"emails": i.Emails,
"channel_ids": i.ChannelIds,
"profile_count": len(i.Profiles),
}
}
@@ -35,6 +47,52 @@ func (i *MemberInvite) IsValid() *AppError {
}
}
// Profiles must reference distinct invited emails and use unique usernames, case-insensitively.
invitedEmails := make(map[string]struct{}, len(i.Emails))
for _, email := range i.Emails {
invitedEmails[NormalizeEmail(email)] = struct{}{}
}
seenProfileEmails := make(map[string]struct{}, len(i.Profiles))
seenUsernames := make(map[string]struct{}, len(i.Profiles))
for _, profile := range i.Profiles {
if profile == nil {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.profile_nil.app_error", nil, "", http.StatusBadRequest)
}
email := NormalizeEmail(profile.Email)
if _, exists := invitedEmails[email]; !exists {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.profile_email.app_error", nil, "email="+profile.Email, http.StatusBadRequest)
}
if _, exists := seenProfileEmails[email]; exists {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.profile_email_duplicate.app_error", nil, "email="+profile.Email, http.StatusBadRequest)
}
seenProfileEmails[email] = struct{}{}
username := NormalizeUsername(profile.Username)
if !IsValidUsername(username) {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.profile_username.app_error", nil, "username="+profile.Username, http.StatusBadRequest)
}
if _, exists := seenUsernames[username]; exists {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.profile_username_duplicate.app_error", nil, "username="+profile.Username, http.StatusBadRequest)
}
seenUsernames[username] = struct{}{}
if appErr := validateMemberInviteProfileNames(profile); appErr != nil {
return appErr
}
}
return nil
}
func validateMemberInviteProfileNames(profile *MemberInviteProfile) *AppError {
if utf8.RuneCountInString(profile.FirstName) > UserFirstNameMaxRunes {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.profile_first_name.app_error", nil, "email="+profile.Email, http.StatusBadRequest)
}
if utf8.RuneCountInString(profile.LastName) > UserLastNameMaxRunes {
return NewAppError("MemberInvite.IsValid", "model.member.is_valid.profile_last_name.app_error", nil, "email="+profile.Email, http.StatusBadRequest)
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMemberInviteUnmarshalJSON(t *testing.T) {
t.Run("raw email array", func(t *testing.T) {
var invite MemberInvite
err := json.Unmarshal([]byte(`["user1@example.com","user2@example.com"]`), &invite)
require.NoError(t, err)
require.Equal(t, []string{"user1@example.com", "user2@example.com"}, invite.Emails)
require.Empty(t, invite.ChannelIds)
require.Empty(t, invite.Profiles)
})
t.Run("object with emails and channels", func(t *testing.T) {
var invite MemberInvite
err := json.Unmarshal([]byte(`{"emails":["user1@example.com"],"channelIds":["junk"],"message":"hi"}`), &invite)
require.NoError(t, err)
require.Equal(t, []string{"user1@example.com"}, invite.Emails)
require.Equal(t, []string{"junk"}, invite.ChannelIds)
require.Equal(t, "hi", invite.Message)
require.Empty(t, invite.Profiles)
})
t.Run("object with profiles", func(t *testing.T) {
var invite MemberInvite
err := json.Unmarshal([]byte(`{"emails":["user1@example.com"],"profiles":[{"email":"user1@example.com","username":"user.one","first_name":"User","last_name":"One"}]}`), &invite)
require.NoError(t, err)
require.Equal(t, []string{"user1@example.com"}, invite.Emails)
require.Len(t, invite.Profiles, 1)
require.Equal(t, "user1@example.com", invite.Profiles[0].Email)
require.Equal(t, "user.one", invite.Profiles[0].Username)
require.Equal(t, "User", invite.Profiles[0].FirstName)
require.Equal(t, "One", invite.Profiles[0].LastName)
})
}
func TestMemberInviteIsValid(t *testing.T) {
validInvite := func() *MemberInvite {
return &MemberInvite{
Emails: []string{"user1@example.com"},
Profiles: []*MemberInviteProfile{{
Email: "user1@example.com",
Username: "user.one",
FirstName: "User",
LastName: "One",
}},
}
}
tests := []struct {
name string
mutate func(*MemberInvite)
expectedErr string
}{
{
name: "valid without profiles",
mutate: func(invite *MemberInvite) { invite.Profiles = nil },
},
{name: "valid with profiles"},
{
name: "no emails",
mutate: func(invite *MemberInvite) { invite.Emails = nil },
expectedErr: "model.member.is_valid.emails.app_error",
},
{
name: "invalid channel",
mutate: func(invite *MemberInvite) { invite.ChannelIds = []string{"junk"} },
expectedErr: "model.member.is_valid.channel.app_error",
},
{
name: "case-insensitive profile email",
mutate: func(invite *MemberInvite) { invite.Emails[0] = "USER1@EXAMPLE.COM" },
},
{
name: "profile email not invited",
mutate: func(invite *MemberInvite) { invite.Profiles[0].Email = "other@example.com" },
expectedErr: "model.member.is_valid.profile_email.app_error",
},
{
name: "duplicate profile email",
mutate: func(invite *MemberInvite) {
invite.Profiles = append(invite.Profiles, &MemberInviteProfile{Email: "USER1@EXAMPLE.COM", Username: "user.two"})
},
expectedErr: "model.member.is_valid.profile_email_duplicate.app_error",
},
{
name: "nil profile",
mutate: func(invite *MemberInvite) { invite.Profiles = []*MemberInviteProfile{nil} },
expectedErr: "model.member.is_valid.profile_nil.app_error",
},
{
name: "duplicate usernames",
mutate: func(invite *MemberInvite) {
invite.Emails = append(invite.Emails, "user2@example.com")
invite.Profiles = append(invite.Profiles, &MemberInviteProfile{Email: "user2@example.com", Username: "USER.ONE"})
},
expectedErr: "model.member.is_valid.profile_username_duplicate.app_error",
},
{
name: "invalid username",
mutate: func(invite *MemberInvite) { invite.Profiles[0].Username = "inv@lid" },
expectedErr: "model.member.is_valid.profile_username.app_error",
},
{
name: "uppercase username",
mutate: func(invite *MemberInvite) { invite.Profiles[0].Username = "User.One" },
},
{
name: "first name too long",
mutate: func(invite *MemberInvite) {
invite.Profiles[0].FirstName = strings.Repeat("a", UserFirstNameMaxRunes+1)
},
expectedErr: "model.member.is_valid.profile_first_name.app_error",
},
{
name: "last name too long",
mutate: func(invite *MemberInvite) { invite.Profiles[0].LastName = strings.Repeat("a", UserLastNameMaxRunes+1) },
expectedErr: "model.member.is_valid.profile_last_name.app_error",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
invite := validInvite()
if test.mutate != nil {
test.mutate(invite)
}
appErr := invite.IsValid()
if test.expectedErr == "" {
require.Nil(t, appErr)
} else {
require.NotNil(t, appErr)
require.Equal(t, test.expectedErr, appErr.Id)
}
})
}
}
func TestMemberInviteAuditable(t *testing.T) {
invite := &MemberInvite{
Emails: []string{"user1@example.com"},
ChannelIds: []string{"channel1"},
Profiles: []*MemberInviteProfile{{Email: "user1@example.com", Username: "user.one"}},
}
auditable := invite.Auditable()
require.Equal(t, []string{"user1@example.com"}, auditable["emails"])
require.Equal(t, []string{"channel1"}, auditable["channel_ids"])
require.Equal(t, 1, auditable["profile_count"])
}
@@ -33,9 +33,12 @@ jest.mock('mattermost-redux/actions/channels', () => ({
},
}));
const mockSendEmailInvitesCalls: Array<{team: string; emails: string[]; profiles?: unknown}> = [];
jest.mock('mattermost-redux/actions/teams', () => ({
getTeamMembersByIds: () => ({type: 'MOCK_RECEIVED_ME'}),
sendEmailInvitesToTeamGracefully: (team: string, emails: string[]) => {
sendEmailInvitesToTeamGracefully: (team: string, emails: string[], profiles?: unknown) => {
mockSendEmailInvitesCalls.push({team, emails, profiles});
if (team === 'incorrect-default-smtp') {
return ({type: 'MOCK_RECEIVED_ME', data: emails.map((email) => ({email, error: {message: '(From server) SMTP is not configured in System Console.', id: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error'}}))});
} else if (emails.length > 21) { // Poor attempt to mock rate limiting.
@@ -165,6 +168,46 @@ describe('actions/invite_actions', () => {
});
});
it('should pass only the filled-in profiles of invited emails to the invite request', async () => {
mockSendEmailInvitesCalls.length = 0;
const emails = ['email-one@email-one.com', 'email-two@email-two.com'];
const profiles = {
'email-one@email-one.com': {
email: 'email-one@email-one.com',
username: 'email.one',
first_name: 'Email',
last_name: 'One',
},
'email-two@email-two.com': {
email: 'email-two@email-two.com',
username: '',
first_name: '',
last_name: '',
},
'not-invited@example.com': {
email: 'not-invited@example.com',
username: 'not.invited',
first_name: 'Not',
last_name: 'Invited',
},
};
await store.dispatch(sendMembersInvites('correct', [], emails, profiles));
expect(mockSendEmailInvitesCalls).toHaveLength(1);
expect(mockSendEmailInvitesCalls[0].profiles).toEqual([{
email: 'email-one@email-one.com',
username: 'email.one',
first_name: 'Email',
last_name: 'One',
}]);
});
it('should pass no profiles when none are provided', async () => {
mockSendEmailInvitesCalls.length = 0;
await store.dispatch(sendMembersInvites('correct', [], ['email-one@email-one.com']));
expect(mockSendEmailInvitesCalls).toHaveLength(1);
expect(mockSendEmailInvitesCalls[0].profiles).toEqual([]);
});
it('should generate list of failures for emails on invite fails', async () => {
const emails = ['email-one@email-one.com', 'email-two@email-two.com', 'email-three@email-three.com'];
const response = await store.dispatch(sendMembersInvites('error', [], emails));
@@ -4,7 +4,7 @@
import {defineMessage} from 'react-intl';
import type {Channel, ChannelMembership} from '@mattermost/types/channels';
import type {TeamMemberWithError, TeamInviteWithError} from '@mattermost/types/teams';
import type {TeamMemberWithError, TeamInviteWithError, MemberInviteProfile} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import type {RelationOneToOne} from '@mattermost/types/utilities';
@@ -21,10 +21,11 @@ import type {InviteResult} from 'components/invitation_modal/result_table';
import type {InviteResults} from 'components/invitation_modal/result_view';
import {ConsolePages} from 'utils/constants';
import {filterProfilesForEmails} from 'utils/member_invite_profiles';
import type {DispatchFunc, ActionFuncAsync} from 'types/store';
export function sendMembersInvites(teamId: string, users: UserProfile[], emails: string[]): ActionFuncAsync<InviteResults> {
export function sendMembersInvites(teamId: string, users: UserProfile[], emails: string[], profiles?: Record<string, MemberInviteProfile>): ActionFuncAsync<InviteResults> {
return async (dispatch, getState) => {
if (users.length > 0) {
await dispatch(TeamActions.getTeamMembersByIds(teamId, users.map((u) => u.id)));
@@ -85,7 +86,7 @@ export function sendMembersInvites(teamId: string, users: UserProfile[], emails:
if (emails.length > 0) {
let response;
try {
response = await dispatch(TeamActions.sendEmailInvitesToTeamGracefully(teamId, emails));
response = await dispatch(TeamActions.sendEmailInvitesToTeamGracefully(teamId, emails, filterProfilesForEmails(profiles, emails)));
} catch {
response = {
data: emails.map((email) => ({
@@ -341,6 +342,7 @@ export function sendMembersInvitesToChannels(
users: UserProfile[],
emails: string[],
message: string,
profiles?: Record<string, MemberInviteProfile>,
): ActionFuncAsync<InviteResults> {
return async (dispatch, getState) => {
if (users.length > 0) {
@@ -413,6 +415,7 @@ export function sendMembersInvitesToChannels(
channels.map((x) => x.id),
emails,
message,
filterProfilesForEmails(profiles, emails),
),
);
} catch {
@@ -3221,6 +3221,28 @@ const AdminDefinition: AdminDefinitionType = {
isHidden: it.not(it.licensedForFeature('LockTeammateNameDisplay')),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)),
},
{
type: 'dropdown',
key: 'TeamSettings.LockProfileFieldsForEmailUsers',
label: defineMessage({id: 'admin.team.lockProfileFieldsForEmailUsers', defaultMessage: 'Lock Profile Fields for Email Users:'}),
help_text: defineMessage({id: 'admin.team.lockProfileFieldsForEmailUsersDesc', defaultMessage: 'Applies only to accounts that sign in with email and password; System Admins are always exempt. When enabled, users cannot change the locked fields themselves, empty first and last names can be filled in once by the user, and anyone with the "Invite Users" permission can pre-set names and usernames when sending email invites. Consider restricting that permission via permission schemes to people trusted to enter this information correctly.'}),
options: [
{
value: Constants.LOCK_PROFILE_FIELDS.NONE,
display_name: defineMessage({id: 'admin.team.lockProfileFields.none', defaultMessage: "Don't lock profile fields (default)"}),
},
{
value: Constants.LOCK_PROFILE_FIELDS.NAME_AND_USERNAME,
display_name: defineMessage({id: 'admin.team.lockProfileFields.nameAndUsername', defaultMessage: 'Lock name and username'}),
},
{
value: Constants.LOCK_PROFILE_FIELDS.ALL,
display_name: defineMessage({id: 'admin.team.lockProfileFields.all', defaultMessage: 'Lock entire profile'}),
},
],
isHidden: it.not(it.minLicenseTier(LicenseSkus.Enterprise)),
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.USERS_AND_TEAMS)),
},
{
type: 'bool',
key: 'PrivacySettings.ShowEmailAddress',
@@ -139,6 +139,41 @@ exports[`SystemUserDetail should match default snapshot 1`] = `
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
>
<div
class="field-column left"
data-testid="fieldColumn"
>
<label>
First Name
<input
class="form-control"
maxlength="64"
placeholder="Enter first name"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
data-testid="fieldColumn"
>
<label>
Last Name
<input
class="form-control"
maxlength="64"
placeholder="Enter last name"
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
@@ -407,6 +442,41 @@ exports[`SystemUserDetail should match snapshot if MFA is enabled 1`] = `
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
>
<div
class="field-column left"
data-testid="fieldColumn"
>
<label>
First Name
<input
class="form-control"
maxlength="64"
placeholder="Enter first name"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
data-testid="fieldColumn"
>
<label>
Last Name
<input
class="form-control"
maxlength="64"
placeholder="Enter last name"
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
@@ -675,6 +745,41 @@ exports[`SystemUserDetail should not fetch CPA data if disabled 1`] = `
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
>
<div
class="field-column left"
data-testid="fieldColumn"
>
<label>
First Name
<input
class="form-control"
maxlength="64"
placeholder="Enter first name"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
data-testid="fieldColumn"
>
<label>
Last Name
<input
class="form-control"
maxlength="64"
placeholder="Enter last name"
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
@@ -943,6 +1048,41 @@ exports[`SystemUserDetail should not show manage user settings button when user
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
>
<div
class="field-column left"
data-testid="fieldColumn"
>
<label>
First Name
<input
class="form-control"
maxlength="64"
placeholder="Enter first name"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
data-testid="fieldColumn"
>
<label>
Last Name
<input
class="form-control"
maxlength="64"
placeholder="Enter last name"
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
@@ -1211,6 +1351,41 @@ exports[`SystemUserDetail should show manage user settings button as activated 1
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
>
<div
class="field-column left"
data-testid="fieldColumn"
>
<label>
First Name
<input
class="form-control"
maxlength="64"
placeholder="Enter first name"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
data-testid="fieldColumn"
>
<label>
Last Name
<input
class="form-control"
maxlength="64"
placeholder="Enter last name"
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
@@ -1485,6 +1660,41 @@ exports[`SystemUserDetail should show manage user settings button as disabled wh
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
>
<div
class="field-column left"
data-testid="fieldColumn"
>
<label>
First Name
<input
class="form-control"
maxlength="64"
placeholder="Enter first name"
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
data-testid="fieldColumn"
>
<label>
Last Name
<input
class="form-control"
maxlength="64"
placeholder="Enter last name"
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
@@ -1755,6 +1965,43 @@ exports[`SystemUserDetail should show the activate user button as disabled when
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
>
<div
class="field-column left"
data-testid="fieldColumn"
>
<label>
First Name
<input
class="form-control"
disabled=""
placeholder="Enter first name"
readonly=""
type="text"
value=""
/>
</label>
</div>
<div
class="field-column right"
data-testid="fieldColumn"
>
<label>
Last Name
<input
class="form-control"
disabled=""
placeholder="Enter last name"
readonly=""
type="text"
value=""
/>
</label>
</div>
</div>
<div
class="field-row"
data-testid="fieldRow"
@@ -181,6 +181,122 @@ describe('SystemUserDetail', () => {
await userEventInstance.type(usernameInput, 'newusername');
expect(defaultProps.setNavigationBlocked).toHaveBeenCalledWith(true);
});
test.each([
['first name', 'Enter first name'],
['last name', 'Enter last name'],
])('should detect %s changes and enable save', async (_fieldName, placeholder) => {
const userEventInstance = userEvent.setup();
const setNavigationBlocked = jest.fn();
renderWithContext(
<SystemUserDetail
{...defaultProps}
setNavigationBlocked={setNavigationBlocked}
/>,
);
await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner'));
const input = screen.getByPlaceholderText(placeholder);
await userEventInstance.clear(input);
await userEventInstance.type(input, 'New Name');
expect(screen.getByRole('button', {name: 'Save'})).toBeEnabled();
expect(setNavigationBlocked).toHaveBeenCalledWith(true);
});
});
describe('name editing', () => {
const nameUser = {
...user,
first_name: 'Old First',
last_name: 'Old Last',
};
test('should show name changes, trim values, and patch the user on save', async () => {
const userEventInstance = userEvent.setup();
const getNameUser = jest.fn().mockResolvedValue({data: nameUser, error: null});
const patchUser = jest.fn().mockImplementation((updatedUser: UserProfile) => Promise.resolve({data: updatedUser, error: null}));
renderWithContext(
<SystemUserDetail
{...defaultProps}
getUser={getNameUser}
patchUser={patchUser}
/>,
);
await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner'));
const firstNameInput = screen.getByPlaceholderText('Enter first name');
const lastNameInput = screen.getByPlaceholderText('Enter last name');
await userEventInstance.clear(firstNameInput);
await userEventInstance.type(firstNameInput, ' New First ');
await userEventInstance.clear(lastNameInput);
await userEventInstance.type(lastNameInput, ' New Last ');
await userEventInstance.click(screen.getByRole('button', {name: 'Save'}));
const changesList = await screen.findByTestId('changesList');
expect(changesList).toHaveTextContent('First Name: Old First → New First');
expect(changesList).toHaveTextContent('Last Name: Old Last → New Last');
await userEventInstance.click(screen.getByRole('button', {name: 'Save Changes'}));
await waitFor(() => {
expect(patchUser).toHaveBeenCalledWith(expect.objectContaining({
first_name: 'New First',
last_name: 'New Last',
}));
});
});
test('should translate empty values in the name change summary', async () => {
const userEventInstance = userEvent.setup();
const getNameUser = jest.fn().mockResolvedValue({data: nameUser, error: null});
renderWithContext(
<SystemUserDetail
{...defaultProps}
getUser={getNameUser}
/>,
{},
{
intlMessages: {
'admin.userDetail.saveChangesModal.empty': '(translated empty)',
},
},
);
await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner'));
await userEventInstance.clear(screen.getByPlaceholderText('Enter first name'));
await userEventInstance.click(screen.getByRole('button', {name: 'Save'}));
expect(await screen.findByTestId('changesList')).toHaveTextContent('First Name: Old First → (translated empty)');
});
test('should reset first and last names on cancel', async () => {
const userEventInstance = userEvent.setup();
const getNameUser = jest.fn().mockResolvedValue({data: nameUser, error: null});
renderWithContext(
<SystemUserDetail
{...defaultProps}
getUser={getNameUser}
/>,
);
await waitForElementToBeRemoved(() => screen.queryAllByTestId('loadingSpinner'));
const firstNameInput = screen.getByPlaceholderText('Enter first name');
const lastNameInput = screen.getByPlaceholderText('Enter last name');
await userEventInstance.clear(firstNameInput);
await userEventInstance.type(firstNameInput, 'New First');
await userEventInstance.clear(lastNameInput);
await userEventInstance.type(lastNameInput, 'New Last');
await userEventInstance.click(screen.getByRole('button', {name: 'Cancel'}));
expect(firstNameInput).toHaveValue('Old First');
expect(lastNameInput).toHaveValue('Old Last');
expect(screen.getByRole('button', {name: 'Save'})).toBeDisabled();
});
});
describe('email validation', () => {
@@ -301,6 +301,8 @@ export type State = {
user?: UserProfile;
usernameField: string;
usernameError: string | null;
firstNameField: string;
lastNameField: string;
emailField: string;
emailError: string | null;
authDataField: string;
@@ -329,6 +331,8 @@ export class SystemUserDetail extends PureComponent<Props, State> {
this.state = {
usernameField: '',
usernameError: null,
firstNameField: '',
lastNameField: '',
emailField: '',
emailError: null,
authDataField: '',
@@ -367,6 +371,8 @@ export class SystemUserDetail extends PureComponent<Props, State> {
user: userResult.data,
emailField: userResult.data.email, // Set emailField to the email of the user for editing purposes
usernameField: userResult.data.username,
firstNameField: userResult.data.first_name,
lastNameField: userResult.data.last_name,
authDataField: userResult.data.auth_data || '',
customProfileAttributeValues: cpaValues,
originalCpaValues: {...cpaValues}, // Deep copy for change tracking
@@ -440,10 +446,18 @@ export class SystemUserDetail extends PureComponent<Props, State> {
const emailChanged = state.emailField !== state.user.email;
const usernameChanged = state.usernameField !== state.user.username;
const nameChanged = this.hasNameChanges(state);
const authDataChanged = state.authDataField !== (state.user.auth_data || '');
const cpaChanged = this.hasCpaChanges(state);
return emailChanged || usernameChanged || authDataChanged || cpaChanged;
return emailChanged || usernameChanged || nameChanged || authDataChanged || cpaChanged;
};
private hasNameChanges = (state: State = this.state): boolean => {
if (!state.user) {
return false;
}
return state.firstNameField !== state.user.first_name || state.lastNameField !== state.user.last_name;
};
private hasCpaChanges = (state: State = this.state): boolean => {
@@ -480,10 +494,17 @@ export class SystemUserDetail extends PureComponent<Props, State> {
return currentValue !== originalValue;
};
private formatEmptyValue = (): string => {
return this.props.intl.formatMessage({
id: 'admin.userDetail.saveChangesModal.empty',
defaultMessage: '(empty)',
});
};
// Resolves option IDs to display names for select/multiselect/rank CPA fields.
private resolveOptionNames = (field: UserPropertyField, value: string | string[] | undefined): string => {
if (!value) {
return '(empty)';
return this.formatEmptyValue();
}
const options = field.attrs?.options || [];
@@ -496,7 +517,7 @@ export class SystemUserDetail extends PureComponent<Props, State> {
// Multiselect: resolve each ID to its name
if (value.length === 0) {
return '(empty)';
return this.formatEmptyValue();
}
const names = value.map((id) => {
@@ -673,6 +694,28 @@ export class SystemUserDetail extends PureComponent<Props, State> {
});
};
handleFirstNameChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!this.state.user) {
return;
}
this.setState({
firstNameField: event.target.value,
error: null, // Clear any errors when user starts editing
});
};
handleLastNameChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!this.state.user) {
return;
}
this.setState({
lastNameField: event.target.value,
error: null, // Clear any errors when user starts editing
});
};
handleAuthDataChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!this.state.user) {
return;
@@ -962,6 +1005,73 @@ export class SystemUserDetail extends PureComponent<Props, State> {
</label>,
);
const nameField = (fieldKey: 'firstNameField' | 'lastNameField', label: React.ReactNode, onChange: (event: ChangeEvent<HTMLInputElement>) => void, placeholder: string, maxLength: number) => (
<label key={fieldKey}>
{label}
{this.state.user?.auth_service ? (
<WithTooltip
title={this.props.intl.formatMessage({
id: 'admin.userManagement.userDetail.managedByProvider.title',
defaultMessage: 'Managed by login provider',
})}
hint={this.props.intl.formatMessage({
id: 'admin.userManagement.userDetail.managedByProvider.name',
defaultMessage: 'This name is managed by the {authService} login provider and cannot be changed here.',
}, {
authService: this.state.user.auth_service.toUpperCase(),
})}
>
<input
className='form-control'
type='text'
value={this.state[fieldKey]}
disabled={true}
readOnly={true}
placeholder={placeholder}
/>
</WithTooltip>
) : (
<input
className='form-control'
type='text'
value={this.state[fieldKey]}
onChange={onChange}
disabled={this.state.isSaving}
maxLength={maxLength}
placeholder={placeholder}
/>
)}
</label>
);
fields.push(nameField(
'firstNameField',
<FormattedMessage
id='admin.userManagement.userDetail.firstName'
defaultMessage='First Name'
/>,
this.handleFirstNameChange,
this.props.intl.formatMessage({
id: 'admin.userManagement.userDetail.firstName.input',
defaultMessage: 'Enter first name',
}),
Constants.MAX_FIRSTNAME_LENGTH,
));
fields.push(nameField(
'lastNameField',
<FormattedMessage
id='admin.userManagement.userDetail.lastName'
defaultMessage='Last Name'
/>,
this.handleLastNameChange,
this.props.intl.formatMessage({
id: 'admin.userManagement.userDetail.lastName.input',
defaultMessage: 'Enter last name',
}),
Constants.MAX_LASTNAME_LENGTH,
));
fields.push(
<label key='authMethod'>
<FormattedMessage
@@ -1127,14 +1237,40 @@ export class SystemUserDetail extends PureComponent<Props, State> {
);
}
if (this.state.user && this.state.firstNameField !== this.state.user.first_name) {
fields.push(
<FormattedMessage
id='admin.userDetail.saveChangesModal.firstNameChange'
defaultMessage='First Name: {oldFirstName} → {newFirstName}'
values={{
oldFirstName: this.state.user.first_name || this.formatEmptyValue(),
newFirstName: this.state.firstNameField || this.formatEmptyValue(),
}}
/>,
);
}
if (this.state.user && this.state.lastNameField !== this.state.user.last_name) {
fields.push(
<FormattedMessage
id='admin.userDetail.saveChangesModal.lastNameChange'
defaultMessage='Last Name: {oldLastName} → {newLastName}'
values={{
oldLastName: this.state.user.last_name || this.formatEmptyValue(),
newLastName: this.state.lastNameField || this.formatEmptyValue(),
}}
/>,
);
}
if (this.state.user && this.state.authDataField !== (this.state.user.auth_data || '')) {
fields.push(
<FormattedMessage
id='admin.userDetail.saveChangesModal.authDataChange'
defaultMessage='Auth Data: {oldAuthData} → {newAuthData}'
values={{
oldAuthData: this.state.user.auth_data || '(empty)',
newAuthData: this.state.authDataField || '(empty)',
oldAuthData: this.state.user.auth_data || this.formatEmptyValue(),
newAuthData: this.state.authDataField || this.formatEmptyValue(),
}}
/>,
);
@@ -1237,6 +1373,8 @@ export class SystemUserDetail extends PureComponent<Props, State> {
this.setState({
usernameField: this.state?.user?.username || '',
usernameError: null,
firstNameField: this.state.user?.first_name || '',
lastNameField: this.state.user?.last_name || '',
emailField: this.state.user?.email || '',
emailError: null,
authDataField: this.state.user?.auth_data || '',
@@ -1292,11 +1430,12 @@ export class SystemUserDetail extends PureComponent<Props, State> {
// Track what changes are being made
const emailChanged = !this.state.user.auth_service && this.state.emailField !== this.state.user.email;
const usernameChanged = !this.state.user.auth_service && this.state.usernameField !== this.state.user.username;
const nameChanged = !this.state.user.auth_service && this.hasNameChanges();
const authDataChanged = this.state.authDataField !== (this.state.user.auth_data || '');
const cpaChanged = this.hasCpaChanges();
// Update user profile if email or username changed
if (usernameChanged || emailChanged) {
// Update user profile if email, username or name changed
if (usernameChanged || emailChanged || nameChanged) {
if (emailChanged) {
updatedUser.email = this.state.emailField.trim().toLowerCase();
}
@@ -1305,6 +1444,11 @@ export class SystemUserDetail extends PureComponent<Props, State> {
updatedUser.username = this.state.usernameField.trim();
}
if (nameChanged) {
updatedUser.first_name = this.state.firstNameField.trim();
updatedUser.last_name = this.state.lastNameField.trim();
}
// If editing own email, include password for verification
if (this.isEditingOwnEmail()) {
if (!this.state.confirmPassword) {
@@ -1343,8 +1487,8 @@ export class SystemUserDetail extends PureComponent<Props, State> {
// Handle results
let resultIndex = 0;
// Handle user update result if email or username changed
if (emailChanged || usernameChanged) {
// Handle user update result if email, username or name changed
if (emailChanged || usernameChanged || nameChanged) {
const userResult = results[resultIndex] as ActionResult<UserProfile, ServerError>;
if (userResult.data) {
updatedUser = userResult.data;
@@ -1405,6 +1549,8 @@ export class SystemUserDetail extends PureComponent<Props, State> {
user: updatedUser,
usernameField: updatedUser.username,
usernameError: null,
firstNameField: updatedUser.first_name,
lastNameField: updatedUser.last_name,
emailField: updatedUser.email,
emailError: null,
authDataField: updatedUser.auth_data || '',
@@ -274,4 +274,22 @@ describe('mapStateToProps', () => {
const props = mapStateToProps(testState, {});
expect(props.canInviteGuestsWithMagicLink).toBe(false);
});
test('normalizes an unknown profile lock setting to none', () => {
const testState = {
...initialState,
entities: {
...initialState.entities,
general: {
...initialState.entities.general,
config: {
...initialState.entities.general.config,
LockProfileFieldsForEmailUsers: 'unexpected',
},
},
},
} as unknown as GlobalState;
expect(mapStateToProps(testState, {}).lockProfileFieldsForEmailUsers).toBe('none');
});
});
@@ -27,7 +27,7 @@ import {
import {makeAsyncComponent} from 'components/async_load';
import {Constants} from 'utils/constants';
import {Constants, normalizeLockProfileFieldsSetting} from 'utils/constants';
import type {GlobalState} from 'types/store';
@@ -70,6 +70,7 @@ export function mapStateToProps(state: GlobalState, props: OwnProps) {
});
const guestAccountsEnabled = config.EnableGuestAccounts === 'true';
const emailInvitationsEnabled = config.EnableEmailInvitations === 'true';
const lockProfileFieldsForEmailUsers = normalizeLockProfileFieldsSetting(config.LockProfileFieldsForEmailUsers);
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
const isGroupConstrained = Boolean(currentTeam?.group_constrained);
const calculatedCanInviteGuests = !isGroupConstrained && isEnterpriseReady && guestAccountsEnabled && haveICurrentTeamPermission(state, Permissions.INVITE_GUEST);
@@ -88,6 +89,7 @@ export function mapStateToProps(state: GlobalState, props: OwnProps) {
canInviteGuests,
canAddUsers,
emailInvitationsEnabled,
lockProfileFieldsForEmailUsers,
isCloud,
isAdmin: isAdmin(getCurrentUser(state).roles),
currentChannel,
@@ -37,6 +37,7 @@ const defaultProps: Props = deepFreeze({
},
invitableChannels: [],
emailInvitationsEnabled: true,
lockProfileFieldsForEmailUsers: 'none',
isAdmin: false,
isCloud: false,
canAddUsers: true,
@@ -6,7 +6,8 @@ import {defineMessages} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {LockProfileFieldsSetting} from '@mattermost/types/config';
import type {MemberInviteProfile, Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {debounce} from 'mattermost-redux/actions/helpers';
@@ -18,6 +19,8 @@ import {filterProfilesStartingWithTerm} from 'mattermost-redux/utils/user_utils'
import {focusElement} from 'utils/a11y_utils';
import {isMembershipPolicyEnforced} from 'utils/channel_utils';
import {Constants} from 'utils/constants';
import {getEmailsToPreset, getProfileForEmail, setProfileForEmail, suggestMemberInviteProfile} from 'utils/member_invite_profiles';
import {InviteType} from './invite_as';
import InviteView, {initializeInviteState} from './invite_view';
@@ -62,6 +65,7 @@ export type Props = {
teamId: string,
users: UserProfile[],
emails: string[],
profiles?: Record<string, MemberInviteProfile>,
) => Promise<ActionResult<InviteResults>>;
sendMembersInvitesToChannels: (
teamId: string,
@@ -69,6 +73,7 @@ export type Props = {
users: UserProfile[],
emails: string[],
message: string,
profiles?: Record<string, MemberInviteProfile>,
) => Promise<ActionResult<InviteResults>>;
};
currentTeam?: Team;
@@ -76,6 +81,7 @@ export type Props = {
townSquareDisplayName: string;
invitableChannels: Channel[];
emailInvitationsEnabled: boolean;
lockProfileFieldsForEmailUsers: LockProfileFieldsSetting;
isAdmin: boolean;
isCloud: boolean;
canAddUsers: boolean;
@@ -285,6 +291,7 @@ export default class InvitationModal extends React.PureComponent<Props, State> {
}
let invites: InviteResults = {notSent: [], sent: []};
if (inviteAs === InviteType.MEMBER) {
const profiles = this.presetProfilesEnabled() ? this.state.invite.profiles : undefined;
if (this.props.channelToInvite) {
// this call is to invite as member but to (a) channel(s) directly
const result = await this.props.actions.sendMembersInvitesToChannels(
@@ -293,10 +300,11 @@ export default class InvitationModal extends React.PureComponent<Props, State> {
users,
emails,
this.state.invite.customMessage.open ? this.state.invite.customMessage.message : '',
profiles,
);
invites = result.data!;
} else {
const result = await this.props.actions.sendMembersInvites(this.props.currentTeam.id, users, emails);
const result = await this.props.actions.sendMembersInvites(this.props.currentTeam.id, users, emails, profiles);
invites = result.data!;
}
} else if (inviteAs === InviteType.GUEST) {
@@ -454,12 +462,39 @@ export default class InvitationModal extends React.PureComponent<Props, State> {
}
};
presetProfilesEnabled = () => {
return this.props.emailInvitationsEnabled &&
this.props.lockProfileFieldsForEmailUsers !== Constants.LOCK_PROFILE_FIELDS.NONE;
};
onChangeUsersEmails = (usersEmails: Array<UserProfile | string>) => {
this.setState((state: State) => {
let profiles = state.invite.profiles;
if (this.presetProfilesEnabled()) {
// Seed newly added emails with a profile suggested from the email local-part.
for (const email of getEmailsToPreset(usersEmails)) {
if (!getProfileForEmail(profiles, email)) {
profiles = setProfileForEmail(profiles, email, suggestMemberInviteProfile(email));
}
}
}
return {
...state,
invite: {
...state.invite,
usersEmails,
profiles,
},
};
});
};
onProfileChange = (profile: MemberInviteProfile) => {
this.setState((state: State) => ({
...state,
invite: {
...state.invite,
usersEmails,
profiles: setProfileForEmail(state.invite.profiles, profile.email, profile),
},
}));
};
@@ -528,6 +563,8 @@ export default class InvitationModal extends React.PureComponent<Props, State> {
channelToInvite={this.props.channelToInvite}
useGuestMagicLink={this.state.useGuestMagicLink}
toggleGuestMagicLink={this.toggleGuestMagicLink}
lockProfileFieldsForEmailUsers={this.props.lockProfileFieldsForEmailUsers}
onProfileChange={this.onProfileChange}
{...this.state.invite}
/>
);
@@ -73,6 +73,9 @@ const defaultProps: Props = deepFreeze({
canInviteGuestsWithMagicLink: false,
useGuestMagicLink: false,
toggleGuestMagicLink: jest.fn(),
lockProfileFieldsForEmailUsers: 'none',
profiles: {},
onProfileChange: jest.fn(),
});
let props = defaultProps;
@@ -355,6 +358,97 @@ describe('InviteView', () => {
expect(screen.getByTestId('inviteButton')).toBeDisabled();
});
describe('pre-set member profiles', () => {
it('hides the profile inputs when the lock setting is none', () => {
renderWithContext(
<InviteView
{...defaultProps}
usersEmails={['one@example.com']}
/>,
state,
);
expect(screen.queryByTestId('MemberProfileInputs')).not.toBeInTheDocument();
});
it('shows the profile inputs when the lock setting is enabled', () => {
renderWithContext(
<InviteView
{...defaultProps}
lockProfileFieldsForEmailUsers='name_and_username'
usersEmails={['one@example.com']}
/>,
state,
);
expect(screen.getByTestId('MemberProfileInputs')).toBeInTheDocument();
});
it('hides the profile inputs when inviting guests', () => {
renderWithContext(
<InviteView
{...defaultProps}
lockProfileFieldsForEmailUsers='name_and_username'
inviteType={InviteType.GUEST}
usersEmails={['one@example.com']}
/>,
state,
);
expect(screen.queryByTestId('MemberProfileInputs')).not.toBeInTheDocument();
});
it('hides the profile inputs when email invitations are disabled', () => {
renderWithContext(
<InviteView
{...defaultProps}
lockProfileFieldsForEmailUsers='name_and_username'
emailInvitationsEnabled={false}
usersEmails={['one@example.com']}
/>,
state,
);
expect(screen.queryByTestId('MemberProfileInputs')).not.toBeInTheDocument();
});
it('disables invite when a pre-set profile has an invalid username', () => {
renderWithContext(
<InviteView
{...defaultProps}
lockProfileFieldsForEmailUsers='name_and_username'
usersEmails={['one@example.com']}
profiles={{
'one@example.com': {
email: 'one@example.com',
username: 'inv@lid',
first_name: 'One',
last_name: 'Example',
},
}}
/>,
state,
);
expect(screen.getByTestId('inviteButton')).toBeDisabled();
});
it('keeps invite enabled when pre-set profiles are empty or valid', () => {
renderWithContext(
<InviteView
{...defaultProps}
lockProfileFieldsForEmailUsers='name_and_username'
usersEmails={['one@example.com', 'two@example.com']}
profiles={{
'one@example.com': {
email: 'one@example.com',
username: 'one.example',
first_name: 'One',
last_name: 'Example',
},
}}
/>,
state,
);
expect(screen.getByTestId('inviteButton')).toBeEnabled();
});
});
it('shows the membership-policy notice, attribute tags, and invite-link warning on a governed team', () => {
props = {
...defaultProps,
@@ -8,7 +8,8 @@ import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import {Button} from '@mattermost/shared/components/button';
import type {Channel} from '@mattermost/types/channels';
import type {Team} from '@mattermost/types/teams';
import type {LockProfileFieldsSetting} from '@mattermost/types/config';
import type {MemberInviteProfile, Team} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import deepFreeze from 'mattermost-redux/utils/deep_freeze';
@@ -22,11 +23,14 @@ import TagGroup from 'components/widgets/tag/tag_group';
import {Constants} from 'utils/constants';
import {formatAttributeName} from 'utils/format_attribute_name';
import {getEmailsToPreset, getProfileForEmail, profileHasInput} from 'utils/member_invite_profiles';
import {getSiteURL} from 'utils/url';
import {isValidUsername} from 'utils/utils';
import AddToChannels, {defaultCustomMessage, defaultInviteChannels} from './add_to_channels';
import type {CustomMessageProps, InviteChannels} from './add_to_channels';
import InviteAs, {InviteType} from './invite_as';
import MemberProfileInputs from './member_profile_inputs';
import OverageUsersBannerNotice from './overage_users_banner_notice';
import './invite_view.scss';
@@ -39,6 +43,7 @@ export const initializeInviteState = (initialSearchValue = '', inviteAsGuest = f
usersEmails: [],
usersEmailsSearch: initialSearchValue,
canInviteGuestsWithMagicLink,
profiles: {},
});
};
@@ -49,6 +54,7 @@ export type InviteState = {
usersEmails: Array<UserProfile | string>;
usersEmailsSearch: string;
canInviteGuestsWithMagicLink: boolean;
profiles: Record<string, MemberInviteProfile>;
};
export type Props = InviteState & {
@@ -79,6 +85,8 @@ export type Props = InviteState & {
onPaste?: (e: ClipboardEvent) => void;
useGuestMagicLink: boolean;
toggleGuestMagicLink: () => void;
lockProfileFieldsForEmailUsers: LockProfileFieldsSetting;
onProfileChange: (profile: MemberInviteProfile) => void;
};
export default function InviteView(props: Props) {
@@ -198,12 +206,32 @@ export default function InviteView(props: Props) {
validAddressMessage = messages.validAddressGuest;
}
const showMemberProfileInputs = props.inviteType === InviteType.MEMBER &&
props.emailInvitationsEnabled &&
props.lockProfileFieldsForEmailUsers !== Constants.LOCK_PROFILE_FIELDS.NONE;
const arePresetProfilesValid = useMemo(() => {
if (!showMemberProfileInputs) {
return true;
}
// A row may be left fully empty, but any pre-set fields need a valid username to
// pass server-side invite validation.
return getEmailsToPreset(props.usersEmails).every((email) => {
const profile = getProfileForEmail(props.profiles, email);
if (!profile || !profileHasInput(profile)) {
return true;
}
return isValidUsername(profile.username.toLowerCase()) === undefined;
});
}, [showMemberProfileInputs, props.usersEmails, props.profiles]);
const isInviteValid = useMemo(() => {
if (props.inviteType === InviteType.GUEST) {
return props.inviteChannels.channels.length > 0 && props.usersEmails.length > 0;
}
return props.usersEmails.length > 0;
}, [props.inviteType, props.inviteChannels.channels, props.usersEmails]);
return props.usersEmails.length > 0 && arePresetProfilesValid;
}, [props.inviteType, props.inviteChannels.channels, props.usersEmails, arePresetProfilesValid]);
const inviteModalPeople = formatMessage({
id: 'invite_modal.people',
@@ -301,6 +329,13 @@ export default function InviteView(props: Props) {
canInviteGuests={props.canInviteGuests}
/>
}
{showMemberProfileInputs && (
<MemberProfileInputs
usersEmails={props.usersEmails}
profiles={props.profiles}
onProfileChange={props.onProfileChange}
/>
)}
{(props.inviteType === InviteType.GUEST || (props.inviteType === InviteType.MEMBER && props.channelToInvite)) && (
<AddToChannels
setCustomMessage={props.setCustomMessage}
@@ -0,0 +1,65 @@
// The GenericModal gives every .form-control in its body a border and a fixed height,
// which double-outlines the common Input widget (its fieldset already draws the border).
// Unset them, same approach as bookmark_create_modal.scss.
.app__body .modal .GenericModal .modal-body .MemberProfileInputs .form-control,
.console__body .modal .GenericModal .modal-body .MemberProfileInputs .form-control {
height: 34px;
border: unset;
}
.MemberProfileInputs {
max-height: 40vh;
overflow-y: auto;
&__help {
margin-bottom: 12px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
}
&__row {
margin-bottom: 16px;
}
&__email {
overflow: hidden;
margin-bottom: 8px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
&__fields {
display: flex;
gap: 8px;
.Input_container {
min-width: 0;
flex: 1;
}
}
// Full-width under the field row long username rules don't fit in one column.
&__error {
margin-top: 8px;
.Input___error {
display: flex;
color: var(--error-text);
font-size: 12px;
line-height: 16px;
text-align: left;
i {
height: 14px;
align-self: baseline;
margin-right: 7px;
font-size: 14px;
&::before {
margin: 0;
}
}
}
}
}
@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {UserProfile} from '@mattermost/types/users';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import MemberProfileInputs from './member_profile_inputs';
describe('MemberProfileInputs', () => {
const baseProps = {
usersEmails: ['dave.roberts@gmail.com'],
profiles: {},
onProfileChange: jest.fn(),
};
test('renders a row per plain email entry', () => {
renderWithContext(
<MemberProfileInputs
{...baseProps}
usersEmails={['one@example.com', 'two@example.com']}
/>,
);
expect(screen.getByTestId('MemberProfileInputs__row-one@example.com')).toBeInTheDocument();
expect(screen.getByTestId('MemberProfileInputs__row-two@example.com')).toBeInTheDocument();
});
test('skips existing users and non-email entries', () => {
const existingUser: UserProfile = TestHelper.getUserMock({username: 'existing'});
const {container} = renderWithContext(
<MemberProfileInputs
{...baseProps}
usersEmails={[existingUser, 'not-an-email', 'one@example.com']}
/>,
);
expect(container.querySelectorAll('.MemberProfileInputs__row')).toHaveLength(1);
});
test('renders nothing without any plain email entries', () => {
const {container} = renderWithContext(
<MemberProfileInputs
{...baseProps}
usersEmails={[TestHelper.getUserMock({username: 'existing'})]}
/>,
);
expect(container.querySelector('.MemberProfileInputs')).toBeNull();
});
test('shows the stored profile values', () => {
renderWithContext(
<MemberProfileInputs
{...baseProps}
profiles={{
'dave.roberts@gmail.com': {
email: 'dave.roberts@gmail.com',
username: 'dave.roberts',
first_name: 'Dave',
last_name: 'Roberts',
},
}}
/>,
);
expect(screen.getByDisplayValue('Dave')).toBeInTheDocument();
expect(screen.getByDisplayValue('Roberts')).toBeInTheDocument();
expect(screen.getByDisplayValue('dave.roberts')).toBeInTheDocument();
});
test('reports edits through onProfileChange', async () => {
const onProfileChange = jest.fn();
renderWithContext(
<MemberProfileInputs
{...baseProps}
onProfileChange={onProfileChange}
/>,
);
await userEvent.type(screen.getAllByPlaceholderText('First name')[0], 'D');
expect(onProfileChange).toHaveBeenCalledWith({
email: 'dave.roberts@gmail.com',
username: '',
first_name: 'D',
last_name: '',
});
});
test('does not show a username error while typing', async () => {
const onProfileChange = jest.fn();
renderWithContext(
<MemberProfileInputs
{...baseProps}
onProfileChange={onProfileChange}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Username'), 'a');
expect(screen.queryByText(/Usernames have to begin with a lowercase letter/)).not.toBeInTheDocument();
});
test('normalizes a mixed-case username on blur', async () => {
const onProfileChange = jest.fn();
renderWithContext(
<MemberProfileInputs
{...baseProps}
onProfileChange={onProfileChange}
profiles={{
'dave.roberts@gmail.com': {
email: 'dave.roberts@gmail.com',
username: 'Dave.Roberts',
first_name: 'Dave',
last_name: 'Roberts',
},
}}
/>,
);
await userEvent.click(screen.getByPlaceholderText('Username'));
await userEvent.tab();
expect(onProfileChange).toHaveBeenCalledWith({
email: 'dave.roberts@gmail.com',
username: 'dave.roberts',
first_name: 'Dave',
last_name: 'Roberts',
});
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
test('shows a full-width username error after blur', async () => {
const onProfileChange = jest.fn();
renderWithContext(
<MemberProfileInputs
{...baseProps}
onProfileChange={onProfileChange}
profiles={{
'dave.roberts@gmail.com': {
email: 'dave.roberts@gmail.com',
username: 'inv@lid',
first_name: '',
last_name: '',
},
}}
/>,
);
const usernameInput = screen.getByPlaceholderText('Username');
expect(screen.queryByText(/Usernames have to begin with a lowercase letter/)).not.toBeInTheDocument();
await userEvent.click(usernameInput);
await userEvent.tab();
const error = screen.getByRole('alert');
expect(error).toHaveTextContent(/Usernames have to begin with a lowercase letter/);
expect(error).toHaveClass('MemberProfileInputs__error');
expect(error.parentElement).toHaveClass('MemberProfileInputs__row');
});
});
@@ -0,0 +1,160 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {MemberInviteProfile} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import InputError from 'components/input_error';
import Input from 'components/widgets/inputs/input/input';
import {Constants, ValidationErrors} from 'utils/constants';
import {emptyMemberInviteProfile, getEmailsToPreset, getProfileForEmail} from 'utils/member_invite_profiles';
import {isValidUsername} from 'utils/utils';
import './member_profile_inputs.scss';
type Props = {
usersEmails: Array<UserProfile | string>;
profiles: Record<string, MemberInviteProfile>;
onProfileChange: (profile: MemberInviteProfile) => void;
};
function getUsernameErrorMessage(username: string, formatMessage: ReturnType<typeof useIntl>['formatMessage']): string | undefined {
if (!username) {
return undefined;
}
const usernameError = isValidUsername(username);
if (!usernameError) {
return undefined;
}
if (usernameError.id === ValidationErrors.RESERVED_NAME) {
return formatMessage({
id: 'invite_modal.preset_profile.username_reserved',
defaultMessage: 'This username is reserved.',
});
}
return formatMessage({
id: 'invite_modal.preset_profile.username_invalid',
defaultMessage: 'Usernames have to begin with a lowercase letter and be {min}-{max} characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.',
}, {min: Constants.MIN_USERNAME_LENGTH, max: Constants.MAX_USERNAME_LENGTH});
}
type RowProps = {
email: string;
profile: MemberInviteProfile;
onProfileChange: (profile: MemberInviteProfile) => void;
};
function MemberProfileInputRow({email, profile, onProfileChange}: RowProps) {
const {formatMessage} = useIntl();
const [usernameError, setUsernameError] = useState<string | undefined>();
const emailKey = email.toLowerCase();
const usernameErrorId = `error_preset-username-${emailKey}`;
const updateField = (field: 'username' | 'first_name' | 'last_name') => (event: React.ChangeEvent<HTMLInputElement>) => {
if (field === 'username') {
setUsernameError(undefined);
}
onProfileChange({...profile, [field]: event.target.value});
};
const handleUsernameBlur = (event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const username = event.target.value.toLowerCase();
if (username !== profile.username) {
onProfileChange({...profile, username});
}
setUsernameError(getUsernameErrorMessage(username, formatMessage));
};
return (
<div
className='MemberProfileInputs__row'
data-testid={`MemberProfileInputs__row-${emailKey}`}
>
<div className='MemberProfileInputs__email'>{email}</div>
<div className='MemberProfileInputs__fields'>
<Input
name={`preset-first-name-${emailKey}`}
type='text'
value={profile.first_name}
onChange={updateField('first_name')}
maxLength={Constants.MAX_FIRSTNAME_LENGTH}
placeholder={formatMessage({id: 'invite_modal.preset_profile.first_name', defaultMessage: 'First name'})}
aria-label={formatMessage({id: 'invite_modal.preset_profile.first_name', defaultMessage: 'First name'})}
/>
<Input
name={`preset-last-name-${emailKey}`}
type='text'
value={profile.last_name}
onChange={updateField('last_name')}
maxLength={Constants.MAX_LASTNAME_LENGTH}
placeholder={formatMessage({id: 'invite_modal.preset_profile.last_name', defaultMessage: 'Last name'})}
aria-label={formatMessage({id: 'invite_modal.preset_profile.last_name', defaultMessage: 'Last name'})}
/>
<Input
name={`preset-username-${emailKey}`}
type='text'
value={profile.username}
onChange={updateField('username')}
onBlur={handleUsernameBlur}
maxLength={Constants.MAX_USERNAME_LENGTH}
autoCapitalize='off'
placeholder={formatMessage({id: 'invite_modal.preset_profile.username', defaultMessage: 'Username'})}
aria-label={formatMessage({id: 'invite_modal.preset_profile.username', defaultMessage: 'Username'})}
hasError={Boolean(usernameError)}
aria-describedby={usernameError ? usernameErrorId : undefined}
/>
</div>
{usernameError && (
<div
id={usernameErrorId}
className='MemberProfileInputs__error'
role='alert'
>
<InputError message={usernameError}/>
</div>
)}
</div>
);
}
export default function MemberProfileInputs(props: Props) {
const emails = getEmailsToPreset(props.usersEmails);
if (emails.length === 0) {
return null;
}
return (
<div
className='MemberProfileInputs'
data-testid='MemberProfileInputs'
>
<div className='InviteView__sectionTitle'>
<FormattedMessage
id='invite_modal.preset_profile.title'
defaultMessage='Set profile details for invited members'
/>
</div>
<div className='MemberProfileInputs__help'>
<FormattedMessage
id='invite_modal.preset_profile.help'
defaultMessage='These fields are locked for members once they join, so double-check them before sending. Leave a row empty to let that person fill in their own details.'
/>
</div>
{emails.map((email) => {
const profile = getProfileForEmail(props.profiles, email) ?? emptyMemberInviteProfile(email);
return (
<MemberProfileInputRow
key={email.toLowerCase()}
email={email}
profile={profile}
onProfileChange={props.onProfileChange}
/>
);
})}
</div>
);
}
@@ -151,6 +151,13 @@
line-height: 28px;
}
.signup-body-card-preset-name {
margin: 8px 0 0;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 14px;
line-height: 20px;
}
.signup-body-custom-branding-markdown,
.signup-body-message-subtitle {
display: none;
@@ -215,6 +215,98 @@ describe('components/signup/Signup', () => {
expect(mockHistoryPush).toHaveBeenCalledWith('/should_verify_email?email=jdoe%40mm.com&teamname=teamName');
});
it('should prefill and lock the username when pre-set by the inviter', () => {
mockLocation.search = 'd=' + encodeURIComponent(JSON.stringify({
email: 'dave.roberts@gmail.com',
name: 'teamName',
username: 'dave.roberts',
first_name: 'Dave',
last_name: 'Roberts',
}));
renderWithContext(
<Signup/>,
);
const usernameInput = screen.getByLabelText('Choose a Username');
expect(usernameInput).toHaveValue('dave.roberts');
expect(usernameInput).toBeDisabled();
expect(screen.getByText('Your username was chosen by your admin.')).toBeInTheDocument();
expect(screen.getByTestId('signup-body-card-preset-name')).toHaveTextContent("You'll join as Dave Roberts.");
});
it('should focus the first editable field when signup values are pre-set', () => {
mockLocation.search = 'd=' + encodeURIComponent(JSON.stringify({
email: 'dave.roberts@gmail.com',
}));
const {unmount} = renderWithContext(<Signup/>);
expect(screen.getByLabelText('Email address')).toBeDisabled();
expect(screen.getByLabelText('Choose a Username')).toHaveFocus();
unmount();
mockLocation.search = 'd=' + encodeURIComponent(JSON.stringify({
email: 'dave.roberts@gmail.com',
username: 'dave.roberts',
}));
renderWithContext(<Signup/>);
expect(screen.getByLabelText('Email address')).toBeDisabled();
expect(screen.getByLabelText('Choose a Username')).toBeDisabled();
expect(screen.getByLabelText('Choose a Password')).toHaveFocus();
});
it('should show an invalid invite state when a pre-set username becomes unavailable', async () => {
mockLocation.search = 'd=' + encodeURIComponent(JSON.stringify({
email: 'dave.roberts@gmail.com',
username: 'dave.roberts',
}));
mockDispatch = jest.fn().mockResolvedValue({
error: {
server_error_id: 'app.user.save.username_exists.app_error',
message: 'Username already exists',
},
});
renderWithContext(<Signup/>);
await userEvent.type(screen.getByLabelText('Choose a Password'), 'password123');
await userEvent.click(screen.getByRole('checkbox', {name: /terms and privacy policy checkbox/i}));
await userEvent.click(screen.getByRole('button', {name: 'Create account'}));
expect(await screen.findByText('This invite link is invalid')).toBeInTheDocument();
expect(screen.getByText('Please speak with your Administrator to receive an invitation.')).toBeInTheDocument();
expect(screen.queryByLabelText('Choose a Username')).not.toBeInTheDocument();
});
it('should show the pre-set name line with only a first name', () => {
mockLocation.search = 'd=' + encodeURIComponent(JSON.stringify({
email: 'dave.roberts@gmail.com',
first_name: 'Dave',
}));
renderWithContext(
<Signup/>,
);
expect(screen.getByLabelText('Choose a Username')).not.toBeDisabled();
expect(screen.getByTestId('signup-body-card-preset-name')).toHaveTextContent("You'll join as Dave.");
});
it('should keep the username editable without pre-set profile data', () => {
mockLocation.search = 'd=' + encodeURIComponent(JSON.stringify({
email: 'dave.roberts@gmail.com',
name: 'teamName',
}));
renderWithContext(
<Signup/>,
);
expect(screen.getByLabelText('Choose a Username')).not.toBeDisabled();
expect(screen.queryByTestId('signup-body-card-preset-name')).not.toBeInTheDocument();
expect(screen.getByText('You can use lowercase letters, numbers, periods, dashes, and underscores.')).toBeInTheDocument();
});
it('should create user, log in and redirect to default team', async () => {
mockDispatch = jest.fn().
mockResolvedValueOnce({}). // removeGlobalItem
@@ -74,7 +74,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
const inviteId = params.get('id') ?? '';
const data = params.get('d');
const parsedData: Record<string, string> = data ? JSON.parse(data) : {};
const {email: parsedEmail, name: parsedTeamName} = parsedData;
const {email: parsedEmail, name: parsedTeamName, username: parsedUsername, first_name: parsedFirstName, last_name: parsedLastName} = parsedData;
const config = useSelector(getConfig);
const {
@@ -126,7 +126,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
const noOpenServer = !inviteId && !token && !enableOpenServer && !noAccounts && !enableUserCreation;
const [email, setEmail] = useState(parsedEmail ?? '');
const [name, setName] = useState('');
const [name, setName] = useState(parsedUsername ?? '');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(Boolean(inviteId));
const [isWaiting, setIsWaiting] = useState(false);
@@ -249,6 +249,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
}
setServerError(errorMessage || formatMessage({id: 'signup_user_completed.invalid_invite.title', defaultMessage: 'This invite link is invalid'}));
setIsWaiting(false);
setLoading(false);
};
@@ -367,6 +368,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
}
}, [emailError, nameError, passwordError, submitClicked]);
useEffect(() => {
if (parsedEmail && parsedUsername) {
passwordInput.current?.focus();
}
}, [parsedEmail, parsedUsername]);
if (loading) {
return (<LoadingScreen/>);
}
@@ -409,6 +416,22 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
);
};
const getUsernameCustomMessage = (): CustomMessageInputType => {
if (nameError) {
return {type: ItemStatus.ERROR, value: nameError};
}
if (parsedUsername) {
return {
type: ItemStatus.INFO,
value: formatMessage({id: 'signup_user_completed.usernameIs', defaultMessage: 'Your username was chosen by your admin.'}),
};
}
return {
type: ItemStatus.INFO,
value: formatMessage({id: 'signup_user_completed.userHelp', defaultMessage: 'You can use lowercase letters, numbers, periods, dashes, and underscores.'}),
};
};
const handleEmailOnChange = ({target: {value: email}}: React.ChangeEvent<HTMLInputElement>) => {
setEmail(email);
dismissAlert();
@@ -567,6 +590,11 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
const {data, error} = await dispatch(createUser(user, token, inviteId, redirectTo));
if (error) {
if (parsedUsername && error.server_error_id === 'app.user.save.username_exists.app_error') {
handleInvalidInvite(error);
return;
}
setAlertBanner({
mode: 'danger' as ModeType,
title: (error as ServerError).message,
@@ -692,6 +720,17 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
<h2 className='signup-body-card-title'>
{getCardTitle()}
</h2>
{(parsedFirstName || parsedLastName) && (
<p
className='signup-body-card-preset-name'
data-testid='signup-body-card-preset-name'
>
{formatMessage(
{id: 'signup_user_completed.presetName', defaultMessage: "You'll join as {fullName}."},
{fullName: [parsedFirstName, parsedLastName].filter(Boolean).join(' ')},
)}
</p>
)}
{enableCustomBrand && getMessageSubtitle()}
{alertBanner && (
<AlertBanner
@@ -716,7 +755,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
defaultMessage: 'Email address',
})}
disabled={isWaiting || Boolean(parsedEmail)}
autoFocus={true}
autoFocus={!parsedEmail}
customMessage={emailCustomLabelForInput}
/>
<Input
@@ -731,14 +770,9 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
id: 'signup_user_completed.chooseUser',
defaultMessage: 'Choose a Username',
})}
disabled={isWaiting}
autoFocus={Boolean(parsedEmail)}
customMessage={
nameError ? {type: ItemStatus.ERROR, value: nameError} : {
type: ItemStatus.INFO,
value: formatMessage({id: 'signup_user_completed.userHelp', defaultMessage: 'You can use lowercase letters, numbers, periods, dashes, and underscores.'}),
}
}
disabled={isWaiting || Boolean(parsedUsername)}
autoFocus={Boolean(parsedEmail) && !parsedUsername}
customMessage={getUsernameCustomMessage()}
/>
<PasswordInput
ref={passwordInput}
@@ -14,10 +14,13 @@ import {
saveCustomProfileAttribute,
getCustomProfileAttributeValues,
} from 'mattermost-redux/actions/users';
import {Permissions} from 'mattermost-redux/constants';
import {getConfig, getCustomProfileAttributes, getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general';
import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles';
import {getIsMobileView} from 'selectors/views/browser';
import {normalizeLockProfileFieldsSetting} from 'utils/constants';
import {isEnterpriseLicense} from 'utils/license_utils';
import type {GlobalState} from 'types/store';
@@ -39,6 +42,7 @@ function mapStateToProps(state: GlobalState) {
const samlPositionAttributeSet = config.SamlPositionAttributeSet === 'true';
const ldapPositionAttributeSet = config.LdapPositionAttributeSet === 'true';
const ldapPictureAttributeSet = config.LdapPictureAttributeSet === 'true';
const lockProfileFieldsForEmailUsers = normalizeLockProfileFieldsSetting(config.LockProfileFieldsForEmailUsers);
const license = getLicense(state);
const isEnterprise = isEnterpriseLicense(license);
@@ -58,6 +62,8 @@ function mapStateToProps(state: GlobalState) {
samlPositionAttributeSet,
ldapPositionAttributeSet,
ldapPictureAttributeSet,
lockProfileFieldsForEmailUsers,
canEditOtherUsers: haveISystemPermission(state, {permission: Permissions.EDIT_OTHER_USERS}),
enableCustomProfileAttributes,
};
}
@@ -57,6 +57,8 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
ldapPositionAttributeSet: false,
samlPositionAttributeSet: false,
ldapPictureAttributeSet: false,
lockProfileFieldsForEmailUsers: 'none' as const,
canEditOtherUsers: false,
enableCustomProfileAttributes: false,
};
@@ -80,7 +82,7 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
},
};
test('submitUser() should have called updateMe', () => {
test('submitUser() should have called updateMe', async () => {
const updateMe = jest.fn().mockResolvedValue({data: true});
const props = {...requiredProps, actions: {...requiredProps.actions, updateMe}};
const ref = React.createRef<UserSettingsGeneralTab>();
@@ -91,7 +93,9 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
/>,
);
ref.current!.submitUser(requiredProps.user, false);
await act(async () => {
ref.current!.submitUser(requiredProps.user, false);
});
expect(updateMe).toHaveBeenCalledTimes(1);
expect(updateMe).toHaveBeenCalledWith(requiredProps.user);
});
@@ -182,7 +186,7 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
expect(container.querySelectorAll('#position').length).toBe(0);
});
test('should not show image field when LDAP picture attribute is set', () => {
test('should show the current image without edit actions when LDAP picture attribute is set', () => {
const props = {...requiredProps};
props.user = {...user};
props.user.auth_service = 'ldap';
@@ -198,7 +202,96 @@ describe('components/user_settings/general/UserSettingsGeneral', () => {
rerender(
<UserSettingsGeneral {...{...props, ldapPictureAttributeSet: true}}/>,
);
expect(container.querySelector('.profile-img')).toBeFalsy();
expect(container.querySelector('.profile-img')).toBeTruthy();
expect(screen.queryByTestId('inputSettingPictureButton')).not.toBeInTheDocument();
expect(screen.queryByTestId('saveSettingPicture')).not.toBeInTheDocument();
expect(screen.queryByTestId('removeSettingPicture')).not.toBeInTheDocument();
});
describe('locked profile fields for email users', () => {
const lockedProps = {
...requiredProps,
user: {...user},
lockProfileFieldsForEmailUsers: 'all' as const,
};
test('should hide fully locked field editors', () => {
const {rerender} = renderWithContext(
<UserSettingsGeneral
{...lockedProps}
activeSection='name'
/>,
);
expect(screen.queryByLabelText('First Name')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Last Name')).not.toBeInTheDocument();
expect(screen.getByText('This field is managed by your System Admin. Contact them to request a change.')).toBeInTheDocument();
rerender(
<UserSettingsGeneral
{...lockedProps}
activeSection='username'
/>,
);
expect(screen.queryByLabelText('Username')).not.toBeInTheDocument();
rerender(
<UserSettingsGeneral
{...lockedProps}
activeSection='nickname'
/>,
);
expect(screen.queryByLabelText('Nickname')).not.toBeInTheDocument();
rerender(
<UserSettingsGeneral
{...lockedProps}
activeSection='position'
/>,
);
expect(screen.queryByLabelText('Position')).not.toBeInTheDocument();
});
test('should allow an empty last name to be filled once', () => {
renderWithContext(
<UserSettingsGeneral
{...lockedProps}
user={{...user, first_name: 'First', last_name: ''}}
activeSection='name'
/>,
);
expect(screen.getByLabelText('First Name')).toBeDisabled();
expect(screen.getByLabelText('Last Name')).toBeEnabled();
});
test('should keep the current picture visible without edit actions when all fields are locked', () => {
const {container} = renderWithContext(
<UserSettingsGeneral
{...lockedProps}
activeSection='picture'
/>,
);
expect(container.querySelector('.profile-img')).toBeTruthy();
expect(screen.queryByTestId('inputSettingPictureButton')).not.toBeInTheDocument();
expect(screen.queryByTestId('saveSettingPicture')).not.toBeInTheDocument();
expect(screen.queryByTestId('removeSettingPicture')).not.toBeInTheDocument();
});
test('should show the login provider message instead of the admin lock', () => {
renderWithContext(
<UserSettingsGeneral
{...lockedProps}
user={{...user, auth_service: 'ldap'}}
ldapFirstNameAttributeSet={true}
activeSection='name'
/>,
);
expect(screen.getByText('This field is handled through your login provider. If you want to change it, you need to do so through your login provider.')).toBeInTheDocument();
expect(screen.queryByText('This field is managed by your System Admin. Contact them to request a change.')).not.toBeInTheDocument();
});
});
test('it should display an error about a username conflicting with a group name', async () => {
@@ -10,6 +10,7 @@ import {useSelector} from 'react-redux';
import type {OnChangeValue, ActionMeta, StylesConfig} from 'react-select';
import ReactSelect from 'react-select';
import type {LockProfileFieldsSetting} from '@mattermost/types/config';
import {supportsOptions, type PropertyFieldOption} from '@mattermost/types/properties';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import type {UserProfile} from '@mattermost/types/users';
@@ -171,6 +172,8 @@ export type Props = {
ldapPositionAttributeSet?: boolean;
samlPositionAttributeSet?: boolean;
ldapPictureAttributeSet?: boolean;
lockProfileFieldsForEmailUsers: LockProfileFieldsSetting;
canEditOtherUsers: boolean;
enableCustomProfileAttributes: boolean;
};
@@ -936,6 +939,29 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
);
}
isFieldLockedByAdmin = (field: 'name' | 'username' | 'nickname' | 'position' | 'picture'): boolean => {
if (this.props.user.auth_service !== '' || this.props.canEditOtherUsers) {
return false;
}
const setting = this.props.lockProfileFieldsForEmailUsers;
if (setting === Constants.LOCK_PROFILE_FIELDS.NAME_AND_USERNAME) {
return field === 'name' || field === 'username';
}
return setting === Constants.LOCK_PROFILE_FIELDS.ALL;
};
createFieldManagedByAdminMessage = () => {
return (
<span>
<FormattedMessage
id='user.settings.general.field_locked_by_admin'
defaultMessage='This field is managed by your System Admin. Contact them to request a change.'
/>
</span>
);
};
createNameSection = () => {
const user = this.props.user;
const {formatMessage} = this.props.intl;
@@ -947,6 +973,9 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
let extraInfo;
let submit = null;
const lockNameFields = this.isFieldLockedByAdmin('name');
const firstNameLocked = lockNameFields && user.first_name !== '';
const lastNameLocked = lockNameFields && user.last_name !== '';
if (
(this.props.user.auth_service === Constants.LDAP_SERVICE &&
(this.props.ldapFirstNameAttributeSet || this.props.ldapLastNameAttributeSet)) ||
@@ -962,6 +991,8 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
/>
</span>
);
} else if (firstNameLocked && lastNameLocked) {
extraInfo = this.createFieldManagedByAdminMessage();
} else {
inputs.push(
<div
@@ -984,6 +1015,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
autoFocus={true}
type='text'
onChange={this.updateFirstName}
disabled={firstNameLocked}
maxLength={Constants.MAX_FIRSTNAME_LENGTH}
value={this.state.firstName}
onFocus={Utils.moveCursorToEnd}
@@ -1013,6 +1045,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
name='lastName'
type='text'
onChange={this.updateLastName}
disabled={lastNameLocked}
maxLength={Constants.MAX_LASTNAME_LENGTH}
value={this.state.lastName}
aria-label={formatMessage({id: 'user.settings.general.lastName', defaultMessage: 'Last Name'})}
@@ -1039,7 +1072,8 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
</a>
);
extraInfo = (
// Each empty name may be filled once even when the other name is already locked.
extraInfo = firstNameLocked || lastNameLocked ? this.createFieldManagedByAdminMessage() : (
<span>
<FormattedMessage
id='user.settings.general.notificationsExtra'
@@ -1125,6 +1159,8 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
/>
</span>
);
} else if (this.isFieldLockedByAdmin('nickname')) {
extraInfo = this.createFieldManagedByAdminMessage();
} else {
let nicknameLabel: JSX.Element | string = (
<FormattedMessage
@@ -1226,7 +1262,9 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
let extraInfo;
let submit = null;
if (this.props.user.auth_service === '') {
if (this.isFieldLockedByAdmin('username')) {
extraInfo = this.createFieldManagedByAdminMessage();
} else if (this.props.user.auth_service === '') {
let usernameLabel: JSX.Element | string = (
<FormattedMessage
id='user.settings.general.username'
@@ -1343,6 +1381,8 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
/>
</span>
);
} else if (this.isFieldLockedByAdmin('position')) {
extraInfo = this.createFieldManagedByAdminMessage();
} else {
let positionLabel: JSX.Element | string = (
<FormattedMessage
@@ -1711,7 +1751,7 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
let submit = null;
let setDefault = null;
let helpText = null;
let imgSrc = null;
const imgSrc = Utils.imageURLForUser(user.id, user.last_picture_update);
if ((this.props.user.auth_service === Constants.LDAP_SERVICE || this.props.user.auth_service === Constants.SAML_SERVICE) && this.props.ldapPictureAttributeSet) {
helpText = (
@@ -1722,10 +1762,11 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
/>
</span>
);
} else if (this.isFieldLockedByAdmin('picture')) {
helpText = this.createFieldManagedByAdminMessage();
} else {
submit = this.submitPicture;
setDefault = user.last_picture_update > 0 ? this.setDefaultProfilePicture : null;
imgSrc = Utils.imageURLForUser(user.id, user.last_picture_update);
helpText = (
<FormattedMessage
id='setting_picture.help.profile'
+23
View File
@@ -3718,6 +3718,11 @@
"admin.team.invalidateEmailInvitesTitle": "Invalidate pending email invites",
"admin.team.lastActiveTimeDescription": "When enabled, last active time allows users to see when someone was last online.",
"admin.team.lastActiveTimeTitle": "Enable last active time: ",
"admin.team.lockProfileFields.all": "Lock entire profile",
"admin.team.lockProfileFields.nameAndUsername": "Lock name and username",
"admin.team.lockProfileFields.none": "Don't lock profile fields (default)",
"admin.team.lockProfileFieldsForEmailUsers": "Lock Profile Fields for Email Users:",
"admin.team.lockProfileFieldsForEmailUsersDesc": "Applies only to accounts that sign in with email and password; System Admins are always exempt. When enabled, users cannot change the locked fields themselves, empty first and last names can be filled in once by the user, and anyone with the \"Invite Users\" permission can pre-set names and usernames when sending email invites. Consider restricting that permission via permission schemes to people trusted to enter this information correctly.",
"admin.team.maxChannelsDescription": "Maximum total number of channels per team, including both active and archived channels.",
"admin.team.maxChannelsExample": "E.g.: \"100\"",
"admin.team.maxChannelsTitle": "Max Channels Per Team:",
@@ -3810,7 +3815,10 @@
"admin.userDetail.saveChangesModal.authDataChange": "Auth Data: {oldAuthData} → {newAuthData}",
"admin.userDetail.saveChangesModal.cpaFieldChange": "{fieldName}: {oldValue} → {newValue}",
"admin.userDetail.saveChangesModal.emailChange": "Email: {oldEmail} → {newEmail}",
"admin.userDetail.saveChangesModal.empty": "(empty)",
"admin.userDetail.saveChangesModal.firstNameChange": "First Name: {oldFirstName} → {newFirstName}",
"admin.userDetail.saveChangesModal.incorrectPassword": "Incorrect password. Please try again.",
"admin.userDetail.saveChangesModal.lastNameChange": "Last Name: {oldLastName} → {newLastName}",
"admin.userDetail.saveChangesModal.message": "You are about to save the following changes to {username}:",
"admin.userDetail.saveChangesModal.passwordEmpty": "Password is required to change your email address",
"admin.userDetail.saveChangesModal.passwordPlaceholder": "Enter your password",
@@ -3827,10 +3835,15 @@
"admin.userManagement.userDetail.cpaField": "{fieldName}",
"admin.userManagement.userDetail.customProfileAttributes": "User Attributes",
"admin.userManagement.userDetail.email": "Email",
"admin.userManagement.userDetail.firstName": "First Name",
"admin.userManagement.userDetail.firstName.input": "Enter first name",
"admin.userManagement.userDetail.lastName": "Last Name",
"admin.userManagement.userDetail.lastName.input": "Enter last name",
"admin.userManagement.userDetail.ldap": "AD/LDAP: {propertyName}",
"admin.userManagement.userDetail.magicLink": "Magic Link",
"admin.userManagement.userDetail.managedByPlugin": "Managed by plugin: {pluginId}",
"admin.userManagement.userDetail.managedByProvider.email": "This email is managed by the {authService} login provider and cannot be changed here.",
"admin.userManagement.userDetail.managedByProvider.name": "This name is managed by the {authService} login provider and cannot be changed here.",
"admin.userManagement.userDetail.managedByProvider.title": "Managed by login provider",
"admin.userManagement.userDetail.managedByProvider.username": "This username is managed by the {authService} login provider and cannot be changed here.",
"admin.userManagement.userDetail.mfa": "MFA",
@@ -5689,6 +5702,13 @@
"invite_modal.policy_enforced.description": "Only users who meet the membership requirements can be added to this team.",
"invite_modal.policy_enforced.link_warning": "People who use this link must meet the membership requirements to join.",
"invite_modal.policy_enforced.title": "Team access is restricted by user attributes",
"invite_modal.preset_profile.first_name": "First name",
"invite_modal.preset_profile.help": "These fields are locked for members once they join, so double-check them before sending. Leave a row empty to let that person fill in their own details.",
"invite_modal.preset_profile.last_name": "Last name",
"invite_modal.preset_profile.title": "Set profile details for invited members",
"invite_modal.preset_profile.username": "Username",
"invite_modal.preset_profile.username_invalid": "Usernames have to begin with a lowercase letter and be {min}-{max} characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.",
"invite_modal.preset_profile.username_reserved": "This username is reserved.",
"invite_modal.restricted_invite_guest.post_trial_description": "Collaborate with users outside of your organization while tightly controlling their access to channels and team members. Upgrade to the Professional plan to create unlimited user groups.",
"invite_modal.restricted_invite_guest.post_trial_title": "Upgrade to invite guest",
"invite_modal.restricted_invite_guest.pre_trial_description": "Collaborate with users outside of your organization while tightly controlling their access to channels and team members. Get the full experience of Enterprise when you start a free, {trialLength} day trial.",
@@ -6906,6 +6926,7 @@
"signup_user_completed.invalid_invite.title": "This invite link is invalid",
"signup_user_completed.no_open_server.title": "This server doesnt allow open signups",
"signup_user_completed.or": "or create an account with",
"signup_user_completed.presetName": "You'll join as {fullName}.",
"signup_user_completed.required": "This field is required",
"signup_user_completed.reserved": "This username is reserved, please choose a new one.",
"signup_user_completed.return": "Return to log in",
@@ -6914,6 +6935,7 @@
"signup_user_completed.subtitle": "Create your Mattermost account to start collaborating with your team",
"signup_user_completed.title": "Lets get started",
"signup_user_completed.userHelp": "You can use lowercase letters, numbers, periods, dashes, and underscores.",
"signup_user_completed.usernameIs": "Your username was chosen by your admin.",
"signup_user_completed.usernameLength": "Usernames have to begin with a lowercase letter and be {min}-{max} characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.",
"signup_user_completed.validEmail": "Please enter a valid email address",
"signup.ldap": "AD/LDAP Credentials",
@@ -7446,6 +7468,7 @@
"user.settings.general.emptyPassword": "Please enter your current password.",
"user.settings.general.emptyPosition": "Click 'Edit' to add your job title / position",
"user.settings.general.field_handled_externally": "This field is handled through your login provider. If you want to change it, you need to do so through your login provider.",
"user.settings.general.field_locked_by_admin": "This field is managed by your System Admin. Contact them to request a change.",
"user.settings.general.field_managed_by_admin": "This field can only be changed by an administrator.",
"user.settings.general.field_managed_by_plugin": "This field is managed by a plugin and cannot be edited.",
"user.settings.general.field_managed_externally": "This field is managed by an external integration and cannot be edited here.",
@@ -6,7 +6,7 @@ import {batchActions} from 'redux-batched-actions';
import type {AccessControlAttributes} from '@mattermost/types/access_control';
import type {ServerError} from '@mattermost/types/errors';
import type {Team, TeamMembership, TeamMemberWithError, GetTeamMembersOpts, TeamsWithCount, TeamSearchOpts, NotPagedTeamSearchOpts, PagedTeamSearchOpts} from '@mattermost/types/teams';
import type {Team, TeamMembership, TeamMemberWithError, GetTeamMembersOpts, TeamsWithCount, TeamSearchOpts, NotPagedTeamSearchOpts, PagedTeamSearchOpts, MemberInviteProfile} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {ChannelTypes, TeamTypes, UserTypes} from 'mattermost-redux/action_types';
@@ -605,12 +605,13 @@ export function sendEmailGuestInvitesToChannels(teamId: string, channelIds: stri
],
});
}
export function sendEmailInvitesToTeamGracefully(teamId: string, emails: string[]) {
export function sendEmailInvitesToTeamGracefully(teamId: string, emails: string[], profiles?: MemberInviteProfile[]) {
return bindClientFunc({
clientFunc: Client4.sendEmailInvitesToTeamGracefully,
params: [
teamId,
emails,
profiles,
],
});
}
@@ -633,6 +634,7 @@ export function sendEmailInvitesToTeamAndChannelsGracefully(
channelIds: string[],
emails: string[],
message: string,
profiles?: MemberInviteProfile[],
) {
return bindClientFunc({
clientFunc: Client4.sendEmailInvitesToTeamAndChannelsGracefully,
@@ -641,6 +643,7 @@ export function sendEmailInvitesToTeamAndChannelsGracefully(
channelIds,
emails,
message,
profiles,
],
});
}
+15
View File
@@ -9,6 +9,7 @@ import githubCSS from 'highlight.js/styles/github.css';
import monokaiCSS from 'highlight.js/styles/monokai.css';
import {defineMessage, defineMessages} from 'react-intl';
import type {LockProfileFieldsSetting} from '@mattermost/types/config';
import {CustomStatusDuration} from '@mattermost/types/users';
import {Preferences as ReduxPreferences} from 'mattermost-redux/constants';
@@ -57,6 +58,19 @@ export const InviteTypes = {
INVITE_GUEST: 'guest',
};
export const LOCK_PROFILE_FIELDS = {
NONE: 'none',
NAME_AND_USERNAME: 'name_and_username',
ALL: 'all',
} as const satisfies Record<string, LockProfileFieldsSetting>;
export function normalizeLockProfileFieldsSetting(value: unknown): LockProfileFieldsSetting {
if (value === LOCK_PROFILE_FIELDS.NAME_AND_USERNAME || value === LOCK_PROFILE_FIELDS.ALL) {
return value;
}
return LOCK_PROFILE_FIELDS.NONE;
}
export const PreviousViewedTypes = {
CHANNELS: 'channels',
THREADS: 'threads',
@@ -2063,6 +2077,7 @@ export const Constants = {
SHOW_NICKNAME_FULLNAME: 'nickname_full_name',
SHOW_FULLNAME: 'full_name',
},
LOCK_PROFILE_FIELDS,
SEARCH_POST: 'searchpost',
CHANNEL_ID_LENGTH: 26,
TRANSPARENT_PIXEL: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MemberInviteProfile} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {TestHelper} from 'utils/test_helper';
import {
canPresetMemberInviteProfiles,
filterProfilesForEmails,
getEmailsToPreset,
getProfileForEmail,
profileHasInput,
setProfileForEmail,
suggestMemberInviteProfile,
} from './member_invite_profiles';
describe('member_invite_profiles', () => {
test('suggests names and a username from a first.last address and normalizes case', () => {
expect(suggestMemberInviteProfile('Dave.ROBERTS@Gmail.com')).toEqual({
email: 'dave.roberts@gmail.com',
username: 'dave.roberts',
first_name: 'Dave',
last_name: 'Roberts',
});
});
test('leaves personal, shorthand, and multi-part addresses empty', () => {
expect(suggestMemberInviteProfile('djr1985@gmail.com')).toEqual({
email: 'djr1985@gmail.com',
username: '',
first_name: '',
last_name: '',
});
expect(suggestMemberInviteProfile('a.b.c@example.com').username).toBe('');
});
test('detects filled and empty profiles', () => {
expect(profileHasInput(undefined)).toBe(false);
expect(profileHasInput({email: 'a@b.c', username: '', first_name: '', last_name: ''})).toBe(false);
expect(profileHasInput({email: 'a@b.c', username: 'user', first_name: '', last_name: ''})).toBe(true);
expect(profileHasInput({email: 'a@b.c', username: '', first_name: 'First', last_name: ''})).toBe(true);
});
test('keeps only plain valid email entries', () => {
const existingUser: UserProfile = TestHelper.getUserMock({username: 'existing'});
expect(getEmailsToPreset([existingUser, 'not-an-email', 'one@example.com'])).toEqual(['one@example.com']);
});
test('enables preset profiles only when email invitations and profile locking are enabled', () => {
expect(canPresetMemberInviteProfiles(true, 'name_and_username')).toBe(true);
expect(canPresetMemberInviteProfiles(true, 'all')).toBe(true);
expect(canPresetMemberInviteProfiles(true, 'none')).toBe(false);
expect(canPresetMemberInviteProfiles(false, 'all')).toBe(false);
});
test('gets and immutably sets profiles using normalized email keys', () => {
const profile: MemberInviteProfile = {
email: 'User@Example.com',
username: 'user',
first_name: 'Test',
last_name: 'User',
};
const originalProfiles = {};
const profiles = setProfileForEmail(originalProfiles, profile.email, profile);
expect(originalProfiles).toEqual({});
expect(profiles).toEqual({
'user@example.com': {
...profile,
email: 'user@example.com',
},
});
expect(getProfileForEmail(profiles, 'USER@EXAMPLE.COM')).toEqual(profiles['user@example.com']);
});
test('filters profiles to invited addresses with input using normalized lookups', () => {
const profiles = {
'filled@example.com': {
email: 'filled@example.com',
username: 'filled',
first_name: 'Filled',
last_name: 'Profile',
},
'empty@example.com': {
email: 'empty@example.com',
username: '',
first_name: '',
last_name: '',
},
'not-invited@example.com': {
email: 'not-invited@example.com',
username: 'other',
first_name: '',
last_name: '',
},
};
expect(filterProfilesForEmails(profiles, ['FILLED@example.com', 'empty@example.com'])).toEqual([{
email: 'filled@example.com',
username: 'filled',
first_name: 'Filled',
last_name: 'Profile',
}]);
expect(filterProfilesForEmails(undefined, ['filled@example.com'])).toEqual([]);
});
});
@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {LockProfileFieldsSetting} from '@mattermost/types/config';
import type {MemberInviteProfile} from '@mattermost/types/teams';
import type {UserProfile} from '@mattermost/types/users';
import {isEmail} from 'mattermost-redux/utils/helpers';
import {Constants} from 'utils/constants';
type MemberInviteProfiles = Record<string, MemberInviteProfile>;
const normalizeEmail = (email: string) => email.toLowerCase();
export const emptyMemberInviteProfile = (email: string): MemberInviteProfile => ({
email: normalizeEmail(email),
username: '',
first_name: '',
last_name: '',
});
// Derives a profile from a first.last@domain email local-part. Anything else
// yields an empty profile for manual entry.
export const suggestMemberInviteProfile = (email: string): MemberInviteProfile => {
const profile = emptyMemberInviteProfile(email);
const match = (/^([a-z]+)\.([a-z]+)$/i).exec(email.split('@')[0]);
if (match) {
const capitalize = (part: string) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
profile.first_name = capitalize(match[1]);
profile.last_name = capitalize(match[2]);
profile.username = `${match[1]}.${match[2]}`.toLowerCase();
}
return profile;
};
export const profileHasInput = (profile?: MemberInviteProfile): boolean => {
return Boolean(profile && (profile.username || profile.first_name || profile.last_name));
};
export const getEmailsToPreset = (usersEmails: Array<UserProfile | string>): string[] => {
return usersEmails.filter((userOrEmail): userOrEmail is string => typeof userOrEmail === 'string' && isEmail(userOrEmail));
};
export const canPresetMemberInviteProfiles = (emailInvitationsEnabled: boolean, lockProfileFields: LockProfileFieldsSetting): boolean => {
return emailInvitationsEnabled && lockProfileFields !== Constants.LOCK_PROFILE_FIELDS.NONE;
};
export const getProfileForEmail = (profiles: MemberInviteProfiles | undefined, email: string): MemberInviteProfile | undefined => {
return profiles?.[normalizeEmail(email)];
};
export const setProfileForEmail = (profiles: MemberInviteProfiles, email: string, profile: MemberInviteProfile): MemberInviteProfiles => {
const normalizedEmail = normalizeEmail(email);
return {
...profiles,
[normalizedEmail]: {
...profile,
email: normalizedEmail,
},
};
};
// Keeps only filled profiles belonging to the addresses being invited.
export const filterProfilesForEmails = (profiles: MemberInviteProfiles | undefined, emails: string[]): MemberInviteProfile[] => {
if (!profiles) {
return [];
}
const result: MemberInviteProfile[] = [];
for (const email of emails) {
const profile = getProfileForEmail(profiles, email);
if (profile && profileHasInput(profile)) {
result.push({...profile, email: normalizeEmail(email)});
}
}
return result;
};
+7 -3
View File
@@ -127,6 +127,7 @@ import type {CompleteOnboardingRequest} from '@mattermost/types/setup';
import type {RemoteClusterInfo, SharedChannelRemote} from '@mattermost/types/shared_channels';
import type {
GetTeamMembersOpts,
MemberInviteProfile,
Team,
TeamInviteWithError,
TeamMembership,
@@ -1642,10 +1643,12 @@ export default class Client4 {
);
};
sendEmailInvitesToTeamGracefully = (teamId: string, emails: string[]) => {
sendEmailInvitesToTeamGracefully = (teamId: string, emails: string[], profiles?: MemberInviteProfile[]) => {
// Keep the historical raw-array body unless profiles are provided.
const body = profiles?.length ? JSON.stringify({emails, profiles}) : JSON.stringify(emails);
return this.doFetch<TeamInviteWithError[]>(
`${this.getTeamRoute(teamId)}/invite/email?graceful=true`,
{method: 'post', body: JSON.stringify(emails)},
{method: 'post', body},
);
};
@@ -1654,10 +1657,11 @@ export default class Client4 {
channelIds: string[],
emails: string[],
message: string,
profiles?: MemberInviteProfile[],
) => {
return this.doFetch<TeamInviteWithError[]>(
`${this.getTeamRoute(teamId)}/invite/email?graceful=true`,
{method: 'post', body: JSON.stringify({emails, channelIds, message})},
{method: 'post', body: JSON.stringify({emails, channelIds, message, profiles: profiles?.length ? profiles : undefined})},
);
};
+4
View File
@@ -3,6 +3,8 @@
import type {ContentFlaggingEvent, NotificationTarget} from './content_flagging';
export type LockProfileFieldsSetting = 'none' | 'name_and_username' | 'all';
export type ClientConfig = {
AboutLink: string;
AllowBannerDismissal: string;
@@ -160,6 +162,7 @@ export type ClientConfig = {
LdapPositionAttributeSet: string;
LdapPictureAttributeSet: string;
LockTeammateNameDisplay: string;
LockProfileFieldsForEmailUsers: LockProfileFieldsSetting;
ManagedResourcePaths: string;
MaxFileSize: string;
MaxPostSize: string;
@@ -471,6 +474,7 @@ export type TeamSettings = {
TeammateNameDisplay: string;
ExperimentalEnableAutomaticReplies: boolean;
LockTeammateNameDisplay: boolean;
LockProfileFieldsForEmailUsers: LockProfileFieldsSetting;
ExperimentalPrimaryTeam: string;
ExperimentalDefaultChannels: string[];
EnableLastActiveTime: boolean;
+7
View File
@@ -142,3 +142,10 @@ export type TeamInviteWithError = {
message: string;
};
};
export type MemberInviteProfile = {
email: string;
username: string;
first_name: string;
last_name: string;
};