MM-61032: Add default_team_id to accept invite flow (#28841)

* add default_team_id to accept invite api

* add team selector to accept invite flow UI

* e2e

* lint/i18n
This commit is contained in:
Caleb Roseland 2024-10-18 05:26:38 -05:00 committed by GitHub
parent 5a923e0b94
commit fcded9559c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 124 additions and 32 deletions

View File

@ -277,6 +277,7 @@
required: required:
- invite - invite
- name - name
- default_team_id
- password - password
properties: properties:
invite: invite:
@ -285,6 +286,8 @@
type: string type: string
display_name: display_name:
type: string type: string
default_team_id:
type: string
password: password:
type: string type: string
description: The password to decrypt the invite code. description: The password to decrypt the invite code.

View File

@ -84,6 +84,9 @@ describe('Connected Workspaces', () => {
cy.findByText('Accept a secure connection from another server'); cy.findByText('Accept a secure connection from another server');
cy.findByText('Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.'); cy.findByText('Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.');
// * Verify accept disabled
cy.uiGetButton('Accept').should('be.disabled');
// # Enter org name // # Enter org name
cy.findByRole('textbox', {name: 'Organization name'}).type(orgDisplayName); cy.findByRole('textbox', {name: 'Organization name'}).type(orgDisplayName);
@ -93,6 +96,13 @@ describe('Connected Workspaces', () => {
// # Enter bad password // # Enter bad password
cy.findByRole('textbox', {name: 'Password'}).type('123abc'); cy.findByRole('textbox', {name: 'Password'}).type('123abc');
// * Verify accept still disabled
cy.uiGetButton('Accept').should('be.disabled');
// # Select team
cy.findByTestId('destination-team-input').click().
findByRole('textbox').type(`${testTeam2.display_name}{enter}`);
// # Try accept // # Try accept
cy.uiGetButton('Accept').click(); cy.uiGetButton('Accept').click();

View File

@ -464,6 +464,16 @@ func remoteClusterAcceptInvite(c *Context, w http.ResponseWriter, r *http.Reques
return return
} }
if rcAcceptInvite.DefaultTeamId == "" {
c.SetInvalidParam("remoteCluster.default_team_id")
return
}
if _, teamErr := c.App.GetTeam(rcAcceptInvite.DefaultTeamId); teamErr != nil {
c.SetInvalidParamWithErr("remoteCluster.default_team_id", teamErr)
return
}
audit.AddEventParameter(auditRec, "name", rcAcceptInvite.Name) audit.AddEventParameter(auditRec, "name", rcAcceptInvite.Name)
audit.AddEventParameter(auditRec, "display_name", rcAcceptInvite.DisplayName) audit.AddEventParameter(auditRec, "display_name", rcAcceptInvite.DisplayName)
@ -485,7 +495,7 @@ func remoteClusterAcceptInvite(c *Context, w http.ResponseWriter, r *http.Reques
return return
} }
rc, aErr := rcs.AcceptInvitation(invite, rcAcceptInvite.Name, rcAcceptInvite.DisplayName, c.AppContext.Session().UserId, url) rc, aErr := rcs.AcceptInvitation(invite, rcAcceptInvite.Name, rcAcceptInvite.DisplayName, c.AppContext.Session().UserId, url, rcAcceptInvite.DefaultTeamId)
if aErr != nil { if aErr != nil {
c.Err = model.NewAppError("remoteClusterAcceptInvite", "api.remote_cluster.accept_invitation_error", nil, "", http.StatusInternalServerError).Wrap(aErr) c.Err = model.NewAppError("remoteClusterAcceptInvite", "api.remote_cluster.accept_invitation_error", nil, "", http.StatusInternalServerError).Wrap(aErr)
if appErr, ok := aErr.(*model.AppError); ok { if appErr, ok := aErr.(*model.AppError); ok {

View File

@ -295,9 +295,10 @@ func TestCreateRemoteCluster(t *testing.T) {
func TestRemoteClusterAcceptinvite(t *testing.T) { func TestRemoteClusterAcceptinvite(t *testing.T) {
rcAcceptInvite := &model.RemoteClusterAcceptInvite{ rcAcceptInvite := &model.RemoteClusterAcceptInvite{
Name: "remotecluster", Name: "remotecluster",
Invite: "myinvitecode", Invite: "myinvitecode",
Password: "mysupersecret", Password: "mysupersecret",
DefaultTeamId: "",
} }
t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) {
@ -313,6 +314,8 @@ func TestRemoteClusterAcceptinvite(t *testing.T) {
th := setupForSharedChannels(t).InitBasic() th := setupForSharedChannels(t).InitBasic()
defer th.TearDown() defer th.TearDown()
rcAcceptInvite.DefaultTeamId = th.BasicTeam.Id
remoteId := model.NewId() remoteId := model.NewId()
invite := &model.RemoteClusterInvite{ invite := &model.RemoteClusterInvite{
RemoteId: remoteId, RemoteId: remoteId,
@ -335,7 +338,7 @@ func TestRemoteClusterAcceptinvite(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" }) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" })
t.Run("should fail if the parameters are not valid", func(t *testing.T) { t.Run("should fail if the name parameter is not valid", func(t *testing.T) {
rcAcceptInvite.Name = "" rcAcceptInvite.Name = ""
defer func() { rcAcceptInvite.Name = "remotecluster" }() defer func() { rcAcceptInvite.Name = "remotecluster" }()
@ -345,6 +348,26 @@ func TestRemoteClusterAcceptinvite(t *testing.T) {
require.Empty(t, rc) require.Empty(t, rc)
}) })
t.Run("should fail if the default team parameter is empty", func(t *testing.T) {
rcAcceptInvite.DefaultTeamId = ""
defer func() { rcAcceptInvite.DefaultTeamId = th.BasicTeam.Id }()
rc, resp, err := th.SystemAdminClient.RemoteClusterAcceptInvite(context.Background(), rcAcceptInvite)
CheckBadRequestStatus(t, resp)
require.Error(t, err)
require.Empty(t, rc)
})
t.Run("should fail if the default team provided doesn't exist", func(t *testing.T) {
rcAcceptInvite.DefaultTeamId = model.NewId()
defer func() { rcAcceptInvite.DefaultTeamId = th.BasicTeam.Id }()
rc, resp, err := th.SystemAdminClient.RemoteClusterAcceptInvite(context.Background(), rcAcceptInvite)
CheckBadRequestStatus(t, resp)
require.Error(t, err)
require.Empty(t, rc)
})
t.Run("should fail with the correct status code if the invite returns an app error", func(t *testing.T) { t.Run("should fail with the correct status code if the invite returns an app error", func(t *testing.T) {
rcAcceptInvite.Invite = "malformedinvite" rcAcceptInvite.Invite = "malformedinvite"
// reset the invite after // reset the invite after

View File

@ -190,7 +190,7 @@ func (rp *RemoteProvider) doAccept(a *app.App, args *model.CommandArgs, margs ma
return responsef(args.T("api.command_remote.site_url_not_set")) return responsef(args.T("api.command_remote.site_url_not_set"))
} }
rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, url) rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, url, "")
if err != nil { if err != nil {
return responsef(args.T("api.command_remote.accept_invitation.error", map[string]any{"Error": err.Error()})) return responsef(args.T("api.command_remote.accept_invitation.error", map[string]any{"Error": err.Error()}))
} }

View File

@ -12,15 +12,16 @@ import (
) )
// AcceptInvitation is called when accepting an invitation to connect with a remote cluster. // AcceptInvitation is called when accepting an invitation to connect with a remote cluster.
func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName, creatorId string, siteURL string) (*model.RemoteCluster, error) { func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error) {
rc := &model.RemoteCluster{ rc := &model.RemoteCluster{
RemoteId: invite.RemoteId, RemoteId: invite.RemoteId,
Name: name, Name: name,
DisplayName: displayName, DisplayName: displayName,
Token: model.NewId(), DefaultTeamId: defaultTeamId,
RemoteToken: invite.Token, Token: model.NewId(),
SiteURL: invite.SiteURL, RemoteToken: invite.Token,
CreatorId: creatorId, SiteURL: invite.SiteURL,
CreatorId: creatorId,
} }
rcSaved, err := rcs.server.GetStore().RemoteCluster().Save(rc) rcSaved, err := rcs.server.GetStore().RemoteCluster().Save(rc)

View File

@ -68,7 +68,7 @@ type RemoteClusterServiceIFace interface {
SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error
SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error
SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error
AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string) (*model.RemoteCluster, error) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error)
ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response
ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error) ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error)
PingNow(rc *model.RemoteCluster) PingNow(rc *model.RemoteCluster)

View File

@ -462,10 +462,11 @@ func (rci *RemoteClusterInvite) Decrypt(encrypted []byte, password string) error
} }
type RemoteClusterAcceptInvite struct { type RemoteClusterAcceptInvite struct {
Name string `json:"name"` Name string `json:"name"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Invite string `json:"invite"` DefaultTeamId string `json:"default_team_id"`
Password string `json:"password"` Invite string `json:"invite"`
Password string `json:"password"`
} }
// RemoteClusterQueryFilter provides filter criteria for RemoteClusterStore.GetAll // RemoteClusterQueryFilter provides filter criteria for RemoteClusterStore.GetAll

View File

@ -156,6 +156,14 @@ export const ModalFieldsetWrapper = styled.div`
background: none !important; background: none !important;
height: 34px !important; height: 34px !important;
} }
.Input_container {
margin-bottom: 10px;
}
.DropdownInput.Input_container {
margin-top: 0;
}
`; `;
const ModalLegend = styled.legend` const ModalLegend = styled.legend`

View File

@ -116,7 +116,7 @@ export const useRemoteClusterAcceptInvite = () => {
modalId: ModalIdentifiers.SECURE_CONNECTION_ACCEPT_INVITE, modalId: ModalIdentifiers.SECURE_CONNECTION_ACCEPT_INVITE,
dialogType: SecureConnectionAcceptInviteModal, dialogType: SecureConnectionAcceptInviteModal,
dialogProps: { dialogProps: {
onConfirm: async (acceptInvite: PartialExcept<RemoteClusterAcceptInvite, 'display_name' | 'invite' | 'password'>) => { onConfirm: async (acceptInvite: PartialExcept<RemoteClusterAcceptInvite, 'display_name' | 'default_team_id' | 'invite' | 'password'>) => {
try { try {
setSaving(true); setSaving(true);
const rc = await Client4.acceptInviteRemoteCluster({ const rc = await Client4.acceptInviteRemoteCluster({

View File

@ -13,12 +13,13 @@ import LoadingScreen from 'components/loading_screen';
import Input from 'components/widgets/inputs/input/input'; import Input from 'components/widgets/inputs/input/input';
import {ModalFieldset, ModalParagraph} from '../controls'; import {ModalFieldset, ModalParagraph} from '../controls';
import {isErrorState, isPendingState} from '../utils'; import TeamSelector from '../team_selector';
import {isErrorState, isPendingState, useTeamOptions} from '../utils';
type Props = { type Props = {
creating?: boolean; creating?: boolean;
password?: string; password?: string;
onConfirm: (accept: PartialExcept<RemoteClusterAcceptInvite, 'display_name' | 'invite' | 'password'>) => Promise<RemoteCluster>; onConfirm: (accept: PartialExcept<RemoteClusterAcceptInvite, 'display_name' | 'default_team_id' | 'invite' | 'password'>) => Promise<RemoteCluster>;
onCancel?: () => void; onCancel?: () => void;
onExited: () => void; onExited: () => void;
onHide: () => void; onHide: () => void;
@ -34,12 +35,16 @@ function SecureConnectionAcceptInviteModal({
}: Props) { }: Props) {
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const [displayName, setDisplayName] = useState(''); const [displayName, setDisplayName] = useState('');
const [defaultTeamId, setDefaultTeamId] = useState('');
const [inviteCode, setInviteCode] = useState(''); const [inviteCode, setInviteCode] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [saving, setSaving] = useState<boolean | ClientError>(false); const [saving, setSaving] = useState<boolean | ClientError>(false);
const teamsById = useTeamOptions();
const need = { const need = {
displayName: !displayName, displayName: !displayName,
defaultTeamId: !defaultTeamId,
inviteCode: !inviteCode, inviteCode: !inviteCode,
password: !password, password: !password,
}; };
@ -50,7 +55,12 @@ function SecureConnectionAcceptInviteModal({
setSaving(true); setSaving(true);
try { try {
await onConfirm({display_name: displayName, invite: inviteCode, password}); await onConfirm({
display_name: displayName,
default_team_id: defaultTeamId,
invite: inviteCode,
password,
});
setSaving(false); setSaving(false);
onHide(); onHide();
} catch (err) { } catch (err) {
@ -90,6 +100,7 @@ function SecureConnectionAcceptInviteModal({
modalHeaderText={title} modalHeaderText={title}
onExited={onExited} onExited={onExited}
compassDesign={true} compassDesign={true}
bodyOverflowVisible={true}
autoCloseOnConfirmButton={false} autoCloseOnConfirmButton={false}
errorText={isErrorState(saving) && ( errorText={isErrorState(saving) && (
<FormattedMessage <FormattedMessage
@ -120,6 +131,23 @@ function SecureConnectionAcceptInviteModal({
onChange={handleDisplayNameChange} onChange={handleDisplayNameChange}
data-testid='display-name' data-testid='display-name'
/> />
<FormattedMessage
id={'admin.secure_connections.accept_invite.select_team'}
defaultMessage={'Please select the destination team where channels will be placed.'}
tagName={ModalParagraph}
/>
<TeamSelector
testId='destination-team-input'
value={defaultTeamId}
teamsById={teamsById}
onChange={setDefaultTeamId}
legend={formatMessage({
id: 'admin.secure_connections.accept_invite.select_team.legend',
defaultMessage: 'Select a team',
})}
/>
<FormattedMessage <FormattedMessage
id={'admin.secure_connections.accept_invite.prompt_invite_password'} id={'admin.secure_connections.accept_invite.prompt_invite_password'}
defaultMessage={'Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.'} defaultMessage={'Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.'}

View File

@ -12,11 +12,8 @@ import styled from 'styled-components';
import {GlobeIcon, LockIcon, PlusIcon, ArchiveOutlineIcon} from '@mattermost/compass-icons/components'; import {GlobeIcon, LockIcon, PlusIcon, ArchiveOutlineIcon} from '@mattermost/compass-icons/components';
import {isRemoteClusterPatch, type RemoteCluster} from '@mattermost/types/remote_clusters'; import {isRemoteClusterPatch, type RemoteCluster} from '@mattermost/types/remote_clusters';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams';
import {setNavigationBlocked} from 'actions/admin_actions'; import {setNavigationBlocked} from 'actions/admin_actions';
@ -47,7 +44,7 @@ import {
import {useRemoteClusterCreate, useSharedChannelsAdd, useSharedChannelsRemove} from './modals/modal_utils'; import {useRemoteClusterCreate, useSharedChannelsAdd, useSharedChannelsRemove} from './modals/modal_utils';
import TeamSelector from './team_selector'; import TeamSelector from './team_selector';
import type {SharedChannelRemoteRow} from './utils'; import type {SharedChannelRemoteRow} from './utils';
import {getEditLocation, isConfirmed, isErrorState, isPendingState, useRemoteClusterEdit, useSharedChannelRemoteRows} from './utils'; import {getEditLocation, isConfirmed, isErrorState, isPendingState, useRemoteClusterEdit, useSharedChannelRemoteRows, useTeamOptions} from './utils';
import {AdminConsoleListTable} from '../list_table'; import {AdminConsoleListTable} from '../list_table';
import SaveChangesPanel from '../team_channel_settings/save_changes_panel'; import SaveChangesPanel from '../team_channel_settings/save_changes_panel';
@ -73,6 +70,8 @@ export default function SecureConnectionDetail(props: Props) {
const {promptCreate, saving: creating} = useRemoteClusterCreate(); const {promptCreate, saving: creating} = useRemoteClusterCreate();
const teamsById = useTeamOptions();
useEffect(() => { useEffect(() => {
// keep history cache up to date // keep history cache up to date
history.replace({...location, state: currentRemoteCluster}); history.replace({...location, state: currentRemoteCluster});
@ -87,8 +86,6 @@ export default function SecureConnectionDetail(props: Props) {
applyPatch({display_name: value}); applyPatch({display_name: value});
}; };
const teams = useSelector(getActiveTeamsList);
const teamsById = useMemo(() => teams.reduce<IDMappedObjects<Team>>((teams, team) => ({...teams, [team.id]: team}), {}), [teams]);
const handleTeamChange = (teamId: string) => { const handleTeamChange = (teamId: string) => {
applyPatch({default_team_id: teamId}); applyPatch({default_team_id: teamId});
}; };

View File

@ -15,6 +15,7 @@ export type Props = {
teamsById: IDMappedObjects<Team>; teamsById: IDMappedObjects<Team>;
onChange: (teamId: string) => void; onChange: (teamId: string) => void;
testId: string; testId: string;
legend?: string;
} }
const TeamSelector = (props: Props): JSX.Element => { const TeamSelector = (props: Props): JSX.Element => {
@ -40,6 +41,7 @@ const TeamSelector = (props: Props): JSX.Element => {
value={value ? {label: value.display_name, value: value.id} : undefined} value={value ? {label: value.display_name, value: value.id} : undefined}
options={teamValues} options={teamValues}
name='team_selector' name='team_selector'
legend={props.legend}
/> />
); );
}; };

View File

@ -3,8 +3,8 @@
import type {LocationDescriptor} from 'history'; import type {LocationDescriptor} from 'history';
import {DateTime, Interval} from 'luxon'; import {DateTime, Interval} from 'luxon';
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
import {useDispatch} from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import type {ClientError} from '@mattermost/client'; import type {ClientError} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels'; import type {Channel} from '@mattermost/types/channels';
@ -17,7 +17,7 @@ import {ChannelTypes} from 'mattermost-redux/action_types';
import {getChannel as fetchChannel} from 'mattermost-redux/actions/channels'; import {getChannel as fetchChannel} from 'mattermost-redux/actions/channels';
import {Client4} from 'mattermost-redux/client'; import {Client4} from 'mattermost-redux/client';
import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTeam} from 'mattermost-redux/selectors/entities/teams'; import {getActiveTeamsList, getTeam} from 'mattermost-redux/selectors/entities/teams';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import type {GlobalState} from 'types/store'; import type {GlobalState} from 'types/store';
@ -228,6 +228,12 @@ export const useSharedChannelRemoteRows = (remoteId: string, opts: {filter: 'hom
return [sharedChannelRemotes, {loading, error, fetch}] as const; return [sharedChannelRemotes, {loading, error, fetch}] as const;
}; };
export const useTeamOptions = () => {
const teams = useSelector(getActiveTeamsList);
const teamsById = useMemo(() => teams.reduce<IDMappedObjects<Team>>((teams, team) => ({...teams, [team.id]: team}), {}), [teams]);
return teamsById;
};
export const getEditLocation = (rc: RemoteCluster): LocationDescriptor<RemoteCluster> => { export const getEditLocation = (rc: RemoteCluster): LocationDescriptor<RemoteCluster> => {
return {pathname: `/admin_console/environment/secure_connections/${rc.remote_id}`, state: rc}; return {pathname: `/admin_console/environment/secure_connections/${rc.remote_id}`, state: rc};
}; };

View File

@ -2222,6 +2222,8 @@
"admin.secure_connections.accept_invite.prompt": "Accept a secure connection from another server", "admin.secure_connections.accept_invite.prompt": "Accept a secure connection from another server",
"admin.secure_connections.accept_invite.prompt_invite_password": "Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.", "admin.secure_connections.accept_invite.prompt_invite_password": "Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.",
"admin.secure_connections.accept_invite.saving_changes_error": "There was an error while accepting the invite.", "admin.secure_connections.accept_invite.saving_changes_error": "There was an error while accepting the invite.",
"admin.secure_connections.accept_invite.select_team": "Please select the destination team where channels will be placed.",
"admin.secure_connections.accept_invite.select_team.legend": "Select a team",
"admin.secure_connections.accept_invite.share_title": "Accept a connection invite", "admin.secure_connections.accept_invite.share_title": "Accept a connection invite",
"admin.secure_connections.confirm.delete.button": "Yes, delete", "admin.secure_connections.confirm.delete.button": "Yes, delete",
"admin.secure_connections.confirm.delete.text": "Are you sure you want to delete the secure connection <strong>{displayName}</strong>?", "admin.secure_connections.confirm.delete.text": "Are you sure you want to delete the secure connection <strong>{displayName}</strong>?",

View File

@ -11,6 +11,7 @@ export type RemoteClusterInvite = {
export type RemoteClusterAcceptInvite = { export type RemoteClusterAcceptInvite = {
name: string; name: string;
display_name: string; display_name: string;
default_team_id: string;
invite: string; invite: string;
password: string; password: string;
} }