From fcded9559c9326e57d94a8d9f7c7961a5fa4fe02 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Fri, 18 Oct 2024 05:26:38 -0500 Subject: [PATCH] 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 --- api/v4/source/remoteclusters.yaml | 3 ++ .../connected_workspaces_management_spec.ts | 10 ++++++ server/channels/api4/remote_cluster.go | 12 ++++++- server/channels/api4/remote_cluster_test.go | 31 ++++++++++++++--- .../app/slashcommands/command_remote.go | 2 +- .../services/remotecluster/invitation.go | 17 +++++----- .../services/remotecluster/service.go | 2 +- server/public/model/remote_cluster.go | 9 ++--- .../secure_connections/controls.tsx | 8 +++++ .../secure_connections/modals/modal_utils.tsx | 2 +- .../secure_connection_accept_invite_modal.tsx | 34 +++++++++++++++++-- .../secure_connection_detail.tsx | 9 ++--- .../secure_connections/team_selector.tsx | 2 ++ .../admin_console/secure_connections/utils.ts | 12 +++++-- webapp/channels/src/i18n/en.json | 2 ++ webapp/platform/types/src/remote_clusters.ts | 1 + 16 files changed, 124 insertions(+), 32 deletions(-) diff --git a/api/v4/source/remoteclusters.yaml b/api/v4/source/remoteclusters.yaml index e7f0853f88..8c9b740094 100644 --- a/api/v4/source/remoteclusters.yaml +++ b/api/v4/source/remoteclusters.yaml @@ -277,6 +277,7 @@ required: - invite - name + - default_team_id - password properties: invite: @@ -285,6 +286,8 @@ type: string display_name: type: string + default_team_id: + type: string password: type: string description: The password to decrypt the invite code. diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/connected_workspaces_management_spec.ts b/e2e-tests/cypress/tests/integration/channels/system_console/connected_workspaces_management_spec.ts index d7ce5d28c1..84f9df7007 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/connected_workspaces_management_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/system_console/connected_workspaces_management_spec.ts @@ -84,6 +84,9 @@ describe('Connected Workspaces', () => { 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.'); + // * Verify accept disabled + cy.uiGetButton('Accept').should('be.disabled'); + // # Enter org name cy.findByRole('textbox', {name: 'Organization name'}).type(orgDisplayName); @@ -93,6 +96,13 @@ describe('Connected Workspaces', () => { // # Enter bad password 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 cy.uiGetButton('Accept').click(); diff --git a/server/channels/api4/remote_cluster.go b/server/channels/api4/remote_cluster.go index 3bc245a5d7..8183e21728 100644 --- a/server/channels/api4/remote_cluster.go +++ b/server/channels/api4/remote_cluster.go @@ -464,6 +464,16 @@ func remoteClusterAcceptInvite(c *Context, w http.ResponseWriter, r *http.Reques 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, "display_name", rcAcceptInvite.DisplayName) @@ -485,7 +495,7 @@ func remoteClusterAcceptInvite(c *Context, w http.ResponseWriter, r *http.Reques 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 { c.Err = model.NewAppError("remoteClusterAcceptInvite", "api.remote_cluster.accept_invitation_error", nil, "", http.StatusInternalServerError).Wrap(aErr) if appErr, ok := aErr.(*model.AppError); ok { diff --git a/server/channels/api4/remote_cluster_test.go b/server/channels/api4/remote_cluster_test.go index 24aece8f6f..e9a455def3 100644 --- a/server/channels/api4/remote_cluster_test.go +++ b/server/channels/api4/remote_cluster_test.go @@ -295,9 +295,10 @@ func TestCreateRemoteCluster(t *testing.T) { func TestRemoteClusterAcceptinvite(t *testing.T) { rcAcceptInvite := &model.RemoteClusterAcceptInvite{ - Name: "remotecluster", - Invite: "myinvitecode", - Password: "mysupersecret", + Name: "remotecluster", + Invite: "myinvitecode", + Password: "mysupersecret", + DefaultTeamId: "", } 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() defer th.TearDown() + rcAcceptInvite.DefaultTeamId = th.BasicTeam.Id + remoteId := model.NewId() invite := &model.RemoteClusterInvite{ RemoteId: remoteId, @@ -335,7 +338,7 @@ func TestRemoteClusterAcceptinvite(t *testing.T) { 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 = "" defer func() { rcAcceptInvite.Name = "remotecluster" }() @@ -345,6 +348,26 @@ func TestRemoteClusterAcceptinvite(t *testing.T) { 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) { rcAcceptInvite.Invite = "malformedinvite" // reset the invite after diff --git a/server/channels/app/slashcommands/command_remote.go b/server/channels/app/slashcommands/command_remote.go index a5be4e3a92..b1b73a8a8c 100644 --- a/server/channels/app/slashcommands/command_remote.go +++ b/server/channels/app/slashcommands/command_remote.go @@ -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")) } - rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, url) + rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, url, "") if err != nil { return responsef(args.T("api.command_remote.accept_invitation.error", map[string]any{"Error": err.Error()})) } diff --git a/server/platform/services/remotecluster/invitation.go b/server/platform/services/remotecluster/invitation.go index 508477438f..efa1e509d3 100644 --- a/server/platform/services/remotecluster/invitation.go +++ b/server/platform/services/remotecluster/invitation.go @@ -12,15 +12,16 @@ import ( ) // 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{ - RemoteId: invite.RemoteId, - Name: name, - DisplayName: displayName, - Token: model.NewId(), - RemoteToken: invite.Token, - SiteURL: invite.SiteURL, - CreatorId: creatorId, + RemoteId: invite.RemoteId, + Name: name, + DisplayName: displayName, + DefaultTeamId: defaultTeamId, + Token: model.NewId(), + RemoteToken: invite.Token, + SiteURL: invite.SiteURL, + CreatorId: creatorId, } rcSaved, err := rcs.server.GetStore().RemoteCluster().Save(rc) diff --git a/server/platform/services/remotecluster/service.go b/server/platform/services/remotecluster/service.go index 4241b6f781..a52eaff4e9 100644 --- a/server/platform/services/remotecluster/service.go +++ b/server/platform/services/remotecluster/service.go @@ -68,7 +68,7 @@ type RemoteClusterServiceIFace interface { 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 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 ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error) PingNow(rc *model.RemoteCluster) diff --git a/server/public/model/remote_cluster.go b/server/public/model/remote_cluster.go index 4140cab043..adf1eb308f 100644 --- a/server/public/model/remote_cluster.go +++ b/server/public/model/remote_cluster.go @@ -462,10 +462,11 @@ func (rci *RemoteClusterInvite) Decrypt(encrypted []byte, password string) error } type RemoteClusterAcceptInvite struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Invite string `json:"invite"` - Password string `json:"password"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + DefaultTeamId string `json:"default_team_id"` + Invite string `json:"invite"` + Password string `json:"password"` } // RemoteClusterQueryFilter provides filter criteria for RemoteClusterStore.GetAll diff --git a/webapp/channels/src/components/admin_console/secure_connections/controls.tsx b/webapp/channels/src/components/admin_console/secure_connections/controls.tsx index a160843277..9ba34793f9 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/controls.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/controls.tsx @@ -156,6 +156,14 @@ export const ModalFieldsetWrapper = styled.div` background: none !important; height: 34px !important; } + + .Input_container { + margin-bottom: 10px; + } + + .DropdownInput.Input_container { + margin-top: 0; + } `; const ModalLegend = styled.legend` diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.tsx index 287b2749aa..d40432374b 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.tsx @@ -116,7 +116,7 @@ export const useRemoteClusterAcceptInvite = () => { modalId: ModalIdentifiers.SECURE_CONNECTION_ACCEPT_INVITE, dialogType: SecureConnectionAcceptInviteModal, dialogProps: { - onConfirm: async (acceptInvite: PartialExcept) => { + onConfirm: async (acceptInvite: PartialExcept) => { try { setSaving(true); const rc = await Client4.acceptInviteRemoteCluster({ diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx index e71da92807..00fb91c12d 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx @@ -13,12 +13,13 @@ import LoadingScreen from 'components/loading_screen'; import Input from 'components/widgets/inputs/input/input'; import {ModalFieldset, ModalParagraph} from '../controls'; -import {isErrorState, isPendingState} from '../utils'; +import TeamSelector from '../team_selector'; +import {isErrorState, isPendingState, useTeamOptions} from '../utils'; type Props = { creating?: boolean; password?: string; - onConfirm: (accept: PartialExcept) => Promise; + onConfirm: (accept: PartialExcept) => Promise; onCancel?: () => void; onExited: () => void; onHide: () => void; @@ -34,12 +35,16 @@ function SecureConnectionAcceptInviteModal({ }: Props) { const {formatMessage} = useIntl(); const [displayName, setDisplayName] = useState(''); + const [defaultTeamId, setDefaultTeamId] = useState(''); const [inviteCode, setInviteCode] = useState(''); const [password, setPassword] = useState(''); const [saving, setSaving] = useState(false); + const teamsById = useTeamOptions(); + const need = { displayName: !displayName, + defaultTeamId: !defaultTeamId, inviteCode: !inviteCode, password: !password, }; @@ -50,7 +55,12 @@ function SecureConnectionAcceptInviteModal({ setSaving(true); try { - await onConfirm({display_name: displayName, invite: inviteCode, password}); + await onConfirm({ + display_name: displayName, + default_team_id: defaultTeamId, + invite: inviteCode, + password, + }); setSaving(false); onHide(); } catch (err) { @@ -90,6 +100,7 @@ function SecureConnectionAcceptInviteModal({ modalHeaderText={title} onExited={onExited} compassDesign={true} + bodyOverflowVisible={true} autoCloseOnConfirmButton={false} errorText={isErrorState(saving) && ( + + + + { // keep history cache up to date history.replace({...location, state: currentRemoteCluster}); @@ -87,8 +86,6 @@ export default function SecureConnectionDetail(props: Props) { applyPatch({display_name: value}); }; - const teams = useSelector(getActiveTeamsList); - const teamsById = useMemo(() => teams.reduce>((teams, team) => ({...teams, [team.id]: team}), {}), [teams]); const handleTeamChange = (teamId: string) => { applyPatch({default_team_id: teamId}); }; diff --git a/webapp/channels/src/components/admin_console/secure_connections/team_selector.tsx b/webapp/channels/src/components/admin_console/secure_connections/team_selector.tsx index 9af9129922..18b2c4a8c0 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/team_selector.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/team_selector.tsx @@ -15,6 +15,7 @@ export type Props = { teamsById: IDMappedObjects; onChange: (teamId: string) => void; testId: string; + legend?: string; } 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} options={teamValues} name='team_selector' + legend={props.legend} /> ); }; diff --git a/webapp/channels/src/components/admin_console/secure_connections/utils.ts b/webapp/channels/src/components/admin_console/secure_connections/utils.ts index 557bc990c1..246de1bbad 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/utils.ts +++ b/webapp/channels/src/components/admin_console/secure_connections/utils.ts @@ -3,8 +3,8 @@ import type {LocationDescriptor} from 'history'; import {DateTime, Interval} from 'luxon'; -import {useCallback, useEffect, useState} from 'react'; -import {useDispatch} from 'react-redux'; +import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; import type {ClientError} from '@mattermost/client'; 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 {Client4} from 'mattermost-redux/client'; 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 {GlobalState} from 'types/store'; @@ -228,6 +228,12 @@ export const useSharedChannelRemoteRows = (remoteId: string, opts: {filter: 'hom return [sharedChannelRemotes, {loading, error, fetch}] as const; }; +export const useTeamOptions = () => { + const teams = useSelector(getActiveTeamsList); + const teamsById = useMemo(() => teams.reduce>((teams, team) => ({...teams, [team.id]: team}), {}), [teams]); + return teamsById; +}; + export const getEditLocation = (rc: RemoteCluster): LocationDescriptor => { return {pathname: `/admin_console/environment/secure_connections/${rc.remote_id}`, state: rc}; }; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 452ada1d37..a698a867f6 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2222,6 +2222,8 @@ "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.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.confirm.delete.button": "Yes, delete", "admin.secure_connections.confirm.delete.text": "Are you sure you want to delete the secure connection {displayName}?", diff --git a/webapp/platform/types/src/remote_clusters.ts b/webapp/platform/types/src/remote_clusters.ts index df78bbcb1b..ce784d7e4c 100644 --- a/webapp/platform/types/src/remote_clusters.ts +++ b/webapp/platform/types/src/remote_clusters.ts @@ -11,6 +11,7 @@ export type RemoteClusterInvite = { export type RemoteClusterAcceptInvite = { name: string; display_name: string; + default_team_id: string; invite: string; password: string; }