mirror of
https://github.com/mattermost/mattermost.git
synced 2026-08-30 17:06:34 +08:00
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:
@@ -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 > 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 > Users and Teams</strong></li><li><code>config.json</code> setting: <code>TeamSettings</code> > <code>LockProfileFieldsForEmailUsers</code> > <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 doesn’t change unless it’s 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%'}} />
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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('&', '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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});
|
||||
}
|
||||
|
||||
+4
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
+154
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()},
|
||||
|
||||
@@ -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, "&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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+247
@@ -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"
|
||||
|
||||
+116
@@ -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', () => {
|
||||
|
||||
+155
-9
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+97
-4
@@ -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'
|
||||
|
||||
@@ -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 doesn’t 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": "Let’s 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,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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})},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -142,3 +142,10 @@ export type TeamInviteWithError = {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MemberInviteProfile = {
|
||||
email: string;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user