diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 2d8cb12c58..3e8e5ed881 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -665,7 +665,6 @@ const defaultServerConfig: AdminConfig = { BoardsFeatureFlags: '', BoardsDataRetention: false, NormalizeLdapDNs: false, - UseCaseOnboarding: true, GraphQL: false, InsightsEnabled: true, CommandPalette: false, diff --git a/server/channels/api4/system_test.go b/server/channels/api4/system_test.go index 5921e32802..25574e4400 100644 --- a/server/channels/api4/system_test.go +++ b/server/channels/api4/system_test.go @@ -892,6 +892,7 @@ func TestCompleteOnboarding(t *testing.T) { req := &model.CompleteOnboardingRequest{ InstallPlugins: []string{"testplugin2"}, + Organization: "my-org", } t.Run("as a regular user", func(t *testing.T) { diff --git a/server/channels/app/onboarding.go b/server/channels/app/onboarding.go index 2dd85749d9..3b76aefe53 100644 --- a/server/channels/app/onboarding.go +++ b/server/channels/app/onboarding.go @@ -28,6 +28,24 @@ func (a *App) markAdminOnboardingComplete(c *request.Context) *model.AppError { } func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError { + isCloud := a.Srv().License() != nil && *a.Srv().License().Features.Cloud + + if !isCloud && request.Organization == "" { + mlog.Error("No organization name provided for self hosted onboarding") + return model.NewAppError("CompleteOnboarding", "api.error_no_organization_name_provided_for_self_hosted_onboarding", nil, "", http.StatusBadRequest) + } + + if request.Organization != "" { + err := a.Srv().Store().System().SaveOrUpdate(&model.System{ + Name: model.SystemOrganizationName, + Value: request.Organization, + }) + if err != nil { + // don't block onboarding because of that. + a.Log().Error("failed to save organization name", mlog.Err(err)) + } + } + pluginsEnvironment := a.Channels().GetPluginsEnvironment() if pluginsEnvironment == nil { return a.markAdminOnboardingComplete(c) diff --git a/server/channels/app/onboarding_test.go b/server/channels/app/onboarding_test.go new file mode 100644 index 0000000000..cf8462cf28 --- /dev/null +++ b/server/channels/app/onboarding_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/server/v8/channels/app/request" + mm_model "github.com/mattermost/mattermost-server/server/v8/model" +) + +func TestOnboardingSavesOrganizationName(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + err := th.App.CompleteOnboarding(&request.Context{}, &mm_model.CompleteOnboardingRequest{ + Organization: "Mattermost In Tests", + }) + require.Nil(t, err) + defer func() { + th.App.Srv().Store().System().PermanentDeleteByName(mm_model.SystemOrganizationName) + }() + + sys, storeErr := th.App.Srv().Store().System().GetByName(mm_model.SystemOrganizationName) + require.NoError(t, storeErr) + require.Equal(t, "Mattermost In Tests", sys.Value) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index 598462a448..40f626291f 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1777,6 +1777,10 @@ "id": "api.error_get_first_admin_visit_marketplace_status", "translation": "Error trying to retrieve the first admin visit marketplace status from the store." }, + { + "id": "api.error_no_organization_name_provided_for_self_hosted_onboarding", + "translation": "Error no organization name provided for self hosted onboarding." + }, { "id": "api.error_set_first_admin_complete_setup", "translation": "Error trying to save first admin complete setup in the store." diff --git a/server/model/onboarding.go b/server/model/onboarding.go index 797bea7c1d..0fe5e91ffa 100644 --- a/server/model/onboarding.go +++ b/server/model/onboarding.go @@ -10,6 +10,7 @@ import ( // CompleteOnboardingRequest describes parameters of the requested plugin. type CompleteOnboardingRequest struct { + Organization string `json:"organization"` // Organization is the name of the organization InstallPlugins []string `json:"install_plugins"` // InstallPlugins is a list of plugins to be installed } diff --git a/server/model/system.go b/server/model/system.go index fbc2aaa684..24b4fce9c9 100644 --- a/server/model/system.go +++ b/server/model/system.go @@ -16,6 +16,7 @@ const ( SystemAsymmetricSigningKeyKey = "AsymmetricSigningKey" SystemPostActionCookieSecretKey = "PostActionCookieSecret" SystemInstallationDateKey = "InstallationDate" + SystemOrganizationName = "OrganizationName" SystemFirstServerRunTimestampKey = "FirstServerRunTimestamp" SystemClusterEncryptionKey = "ClusterEncryptionKey" SystemUpgradedFromTeId = "UpgradedFromTE" diff --git a/webapp/channels/src/actions/global_actions.tsx b/webapp/channels/src/actions/global_actions.tsx index cb22542fa4..6ac09280ab 100644 --- a/webapp/channels/src/actions/global_actions.tsx +++ b/webapp/channels/src/actions/global_actions.tsx @@ -14,7 +14,7 @@ import {Preferences} from 'mattermost-redux/constants'; import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; import {getBool, isCollapsedThreadsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUser, getCurrentUserId, isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels, getChannelMessageCount} from 'mattermost-redux/selectors/entities/channels'; import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {ChannelTypes} from 'mattermost-redux/action_types'; @@ -367,11 +367,19 @@ export async function redirectUserToDefaultTeam() { return; } + // if the user is the first admin + const isUserFirstAdmin = isFirstAdmin(state); + const locale = getCurrentLocale(state); const teamId = LocalStorageStore.getPreviousTeamId(user.id); let myTeams = getMyTeams(state); if (myTeams.length === 0) { + if (isUserFirstAdmin) { + getHistory().push('/preparing-workspace'); + return; + } + getHistory().push('/select_team'); return; } diff --git a/webapp/channels/src/components/do_verify_email/do_verify_email.tsx b/webapp/channels/src/components/do_verify_email/do_verify_email.tsx index 7b03d9e7d5..0e81ead2c7 100644 --- a/webapp/channels/src/components/do_verify_email/do_verify_email.tsx +++ b/webapp/channels/src/components/do_verify_email/do_verify_email.tsx @@ -6,7 +6,6 @@ import {useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {useLocation, useHistory} from 'react-router-dom'; -import {redirectUserToDefaultTeam} from 'actions/global_actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; import LaptopAlertSVG from 'components/common/svg_images_components/laptop_alert_svg'; @@ -15,7 +14,6 @@ import LoadingScreen from 'components/loading_screen'; import {clearErrors, logError} from 'mattermost-redux/actions/errors'; import {verifyUserEmail, getMe} from 'mattermost-redux/actions/users'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {DispatchFunc} from 'mattermost-redux/types/actions'; @@ -40,7 +38,6 @@ const DoVerifyEmail = () => { const token = params.get('token') ?? ''; const loggedIn = Boolean(useSelector(getCurrentUserId)); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const [verifyStatus, setVerifyStatus] = useState(VerifyStatus.PENDING); const [serverError, setServerError] = useState(''); @@ -52,16 +49,11 @@ const DoVerifyEmail = () => { const handleRedirect = () => { if (loggedIn) { - if (useCaseOnboarding) { - // need info about whether admin or not, - // and whether admin has already completed - // first time onboarding. Instead of fetching and orchestrating that here, - // let the default root component handle it. - history.push('/'); - return; - } - - redirectUserToDefaultTeam(); + // need info about whether admin or not, + // and whether admin has already completed + // first time onboarding. Instead of fetching and orchestrating that here, + // let the default root component handle it. + history.push('/'); return; } diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts index a59ff532cc..5a2ac01c35 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts @@ -8,7 +8,6 @@ import {withRouter} from 'react-router-dom'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {GenericAction} from 'mattermost-redux/types/actions'; import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getUserGuideDropdownPluginMenuItems} from 'selectors/plugins'; @@ -32,7 +31,6 @@ function mapStateToProps(state: GlobalState) { teamUrl: getCurrentRelativeTeamUrl(state), pluginMenuItems: getUserGuideDropdownPluginMenuItems(state), isFirstAdmin: isFirstAdmin(state), - useCaseOnboarding: getUseCaseOnboarding(state), }; } diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx index effe92c1ad..aa1ac2e833 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx @@ -34,7 +34,6 @@ describe('components/channel_header/components/UserGuideDropdown', () => { }, pluginMenuItems: [], isFirstAdmin: false, - useCaseOnboarding: false, }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index bed62d7eed..c0e154e561 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -13,7 +13,7 @@ import {UserProfile} from '@mattermost/types/users'; import {Client4} from 'mattermost-redux/client'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getTeamByName, getMyTeamMember} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {isSystemAdmin} from 'mattermost-redux/utils/user_utils'; @@ -104,7 +104,6 @@ const Login = ({onCustomizeHeader}: LoginProps) => { const currentUser = useSelector(getCurrentUser); const experimentalPrimaryTeam = useSelector((state: GlobalState) => (ExperimentalPrimaryTeam ? getTeamByName(state, ExperimentalPrimaryTeam) : undefined)); const experimentalPrimaryTeamMember = useSelector((state: GlobalState) => getMyTeamMember(state, experimentalPrimaryTeam?.id ?? '')); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const isCloud = useSelector(isCurrentLicenseCloud); const graphQLEnabled = useSelector(isGraphQLEnabled); @@ -631,14 +630,12 @@ const Login = ({onCustomizeHeader}: LoginProps) => { } else if (experimentalPrimaryTeamMember.team_id) { // Only set experimental team if user is on that team history.push(`/${ExperimentalPrimaryTeam}`); - } else if (useCaseOnboarding) { + } else { // need info about whether admin or not, // and whether admin has already completed // first time onboarding. Instead of fetching and orchestrating that here, // let the default root component handle it. history.push('/'); - } else { - redirectUserToDefaultTeam(); } }; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap new file mode 100644 index 0000000000..4aa2442f0a --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InviteMembers component should match snapshot 1`] = ` +
+
+
+
+
+
+ Previous step +
+

+ + Invite your team members + +

+

+ + Collaboration is tough by yourself. Invite a few team members using the invitation link below. + +

+
+ +
+
+ +
+
+
+
+
+
+`; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap new file mode 100644 index 0000000000..0610646391 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/preparing-workspace/invite_members_link should match snapshot 1`] = ` +
+ +
+`; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap new file mode 100644 index 0000000000..cec545b0bd --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/preparing-workspace/organization_status should match snapshot 1`] = ` +
+`; diff --git a/webapp/channels/src/components/preparing_workspace/index.tsx b/webapp/channels/src/components/preparing_workspace/index.tsx index a3ab4aa606..c454fd28ac 100644 --- a/webapp/channels/src/components/preparing_workspace/index.tsx +++ b/webapp/channels/src/components/preparing_workspace/index.tsx @@ -5,7 +5,7 @@ import {connect} from 'react-redux'; import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {Action} from 'mattermost-redux/types/actions'; -import {checkIfTeamExists, createTeam} from 'mattermost-redux/actions/teams'; +import {checkIfTeamExists, createTeam, updateTeam} from 'mattermost-redux/actions/teams'; import {getProfiles} from 'mattermost-redux/actions/users'; import PreparingWorkspace, {Actions} from './preparing_workspace'; @@ -13,6 +13,7 @@ import PreparingWorkspace, {Actions} from './preparing_workspace'; function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators, Actions>({ + updateTeam, createTeam, getProfiles, checkIfTeamExists, diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.scss b/webapp/channels/src/components/preparing_workspace/invite_members.scss new file mode 100644 index 0000000000..dc914d42b1 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.scss @@ -0,0 +1,51 @@ +@import 'utils/mixins'; + +.InviteMembers-body { + display: flex; + // page width - channels preview width - progress dots width - people overlap width + max-width: calc(100vw - 600px - 120px - 30px); + + .UsersEmailsInput { + max-width: 420px; + } +} + +.InviteMembers { + &__submit { + display: flex; + align-items: center; + justify-content: flex-start; + } +} + +@include simple-in-and-out-before("InviteMembers"); + +.ChannelsPreview--enter-from-after { + &-enter { + transform: translateX(-100vw); + } + + &-enter-active { + transform: translateX(0); + transition: transform 300ms ease-in-out; + } + + &-enter-done { + transform: translateX(0); + } +} + +.ChannelsPreview--exit-to-after { + &-exit { + transform: translateX(0); + } + + &-exit-active { + transform: translateX(-100vw); + transition: transform 300ms ease-in-out; + } + + &-exit-done { + transform: translateX(-100vw); + } +} diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx b/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx new file mode 100644 index 0000000000..54fe45f374 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ComponentProps} from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +import InviteMembers from './invite_members'; + +describe('InviteMembers component', () => { + let defaultProps: ComponentProps; + + beforeEach(() => { + defaultProps = { + disableEdits: false, + browserSiteUrl: 'https://my-org.mattermost.com', + formUrl: 'https://my-org.mattermost.com/signup', + teamInviteId: '1234', + className: 'test-class', + configSiteUrl: 'https://my-org.mattermost.com/config', + onPageView: jest.fn(), + previous:
{'Previous step'}
, + next: jest.fn(), + show: true, + transitionDirection: 'forward', + }; + }); + + it('should match snapshot', () => { + const component = withIntl(); + const {container} = render(component); + expect(container).toMatchSnapshot(); + }); + + it('renders invite URL', () => { + const component = withIntl(); + render(component); + const inviteLink = screen.getByTestId('shareLinkInput'); + expect(inviteLink).toHaveAttribute( + 'value', + 'https://my-org.mattermost.com/config/signup_user_complete/?id=1234', + ); + }); + + it('renders submit button with correct text', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + expect(button).toBeInTheDocument(); + }); + + it('button is disabled when disableEdits is true', () => { + const component = withIntl( + , + ); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + expect(button).toBeDisabled(); + }); + + it('invokes next prop on button click', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + fireEvent.click(button); + expect(defaultProps.next).toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.tsx b/webapp/channels/src/components/preparing_workspace/invite_members.tsx new file mode 100644 index 0000000000..a018c2a446 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo, useEffect} from 'react'; +import {CSSTransition} from 'react-transition-group'; +import {FormattedMessage} from 'react-intl'; + +import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps'; + +import Title from './title'; +import Description from './description'; +import PageBody from './page_body'; +import SingleColumnLayout from './single_column_layout'; + +import InviteMembersLink from './invite_members_link'; +import PageLine from './page_line'; +import './invite_members.scss'; + +type Props = PreparingWorkspacePageProps & { + disableEdits: boolean; + className?: string; + teamInviteId?: string; + formUrl: Form['url']; + configSiteUrl?: string; + browserSiteUrl: string; +} + +const InviteMembers = (props: Props) => { + let className = 'InviteMembers-body'; + if (props.className) { + className += ' ' + props.className; + } + + useEffect(props.onPageView, []); + + const inviteURL = useMemo(() => { + let urlBase = ''; + if (props.configSiteUrl && !props.configSiteUrl.includes('localhost')) { + urlBase = props.configSiteUrl; + } else if (props.formUrl && !props.formUrl.includes('localhost')) { + urlBase = props.formUrl; + } else { + urlBase = props.browserSiteUrl; + } + return `${urlBase}/signup_user_complete/?id=${props.teamInviteId}`; + }, [props.teamInviteId, props.configSiteUrl, props.browserSiteUrl, props.formUrl]); + + const description = ( + + ); + + const inviteInteraction = ; + + return ( + +
+ + + {props.previous} + + <FormattedMessage + id={'onboarding_wizard.invite_members.title'} + defaultMessage='Invite your team members' + /> + + + {description} + + + {inviteInteraction} + +
+ +
+ +
+
+
+ ); +}; + +export default InviteMembers; diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx new file mode 100644 index 0000000000..26b28e9b6f --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx @@ -0,0 +1,838 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {SVGProps} from 'react'; + +const InviteMembersIllustration = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default InviteMembersIllustration; diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.scss b/webapp/channels/src/components/preparing_workspace/invite_members_link.scss new file mode 100644 index 0000000000..09b229f264 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.scss @@ -0,0 +1,51 @@ +.InviteMembersLink { + display: flex; + + &__input { + height: 48px; + flex-grow: 1; + padding: 12px 14px; + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + border-right: 0; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + border-left: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + background: rgba(var(--center-channel-color-rgb), 0.04); + border-radius: 4px 0 0 4px; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 16px; + } + + &__button { + display: flex; + width: 180px; + max-width: 382px; + height: 48px; + flex-grow: 0; + align-items: center; + justify-content: center; + border: 1px solid var(--button-bg); + background: var(--center-channel-bg); + border-radius: 0 4px 4px 0; + color: var(--button-bg); + font-size: 16px; + font-weight: 600; + + &:hover { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &:active { + background: rgba(var(--button-bg-rgb), 0.08); + } + + span { + display: inline-block; + height: 24px; + margin-right: 9px; + } + + svg { + fill: var(--button-bg); + } + } +} diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx new file mode 100644 index 0000000000..d74b81d493 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {trackEvent} from 'actions/telemetry_actions'; +import InviteMembersLink from './invite_members_link'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +jest.mock('actions/telemetry_actions', () => ({ + trackEvent: jest.fn(), +})); + +describe('components/preparing-workspace/invite_members_link', () => { + const inviteURL = 'https://invite-url.mattermost.com'; + + it('should match snapshot', () => { + const component = withIntl(); + + const {container} = render(component); + expect(container).toMatchSnapshot(); + }); + + it('renders an input field with the invite URL', () => { + const component = withIntl(); + render(component); + const input = screen.getByDisplayValue(inviteURL); + expect(input).toBeInTheDocument(); + }); + + it('renders a button to copy the invite URL', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + expect(button).toBeInTheDocument(); + }); + + it('calls the trackEvent function when the copy button is clicked', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + fireEvent.click(button); + expect(trackEvent).toHaveBeenCalledWith( + 'first_admin_setup', + 'admin_setup_click_copy_invite_link', + ); + }); + + it('changes the button text to "Link Copied" when the URL is copied', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + const originalText = 'Copy Link'; + const linkCopiedText = 'Link Copied'; + expect(button).toHaveTextContent(originalText); + + fireEvent.click(button); + + expect(button).toHaveTextContent(linkCopiedText); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx new file mode 100644 index 0000000000..f6491809ca --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import useCopyText from 'components/common/hooks/useCopyText'; +import {trackEvent} from 'actions/telemetry_actions'; + +import './invite_members_link.scss'; + +type Props = { + inviteURL: string; +} + +const InviteMembersLink = (props: Props) => { + const copyText = useCopyText({ + trackCallback: () => trackEvent('first_admin_setup', 'admin_setup_click_copy_invite_link'), + text: props.inviteURL, + }); + const intl = useIntl(); + + return ( +
+ + +
+ ); +}; + +export default InviteMembersLink; diff --git a/webapp/channels/src/components/preparing_workspace/mixins.scss b/webapp/channels/src/components/preparing_workspace/mixins.scss new file mode 100644 index 0000000000..b3ca03bce8 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/mixins.scss @@ -0,0 +1,12 @@ +@mixin input { + width: 452px; + padding: 12px 16px; + border: 2px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + font-size: 16px; + + &:active, + &:focus { + border: 2px solid var(--button-bg); + } +} diff --git a/webapp/channels/src/components/preparing_workspace/organization.scss b/webapp/channels/src/components/preparing_workspace/organization.scss new file mode 100644 index 0000000000..c063010404 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization.scss @@ -0,0 +1,63 @@ +@import 'utils/variables'; +@import 'utils/mixins'; +@import './mixins'; + +.Organization-body { + display: flex; +} + +.Organization-form-wrapper { + position: relative; +} + +.Organization-left-col { + width: 210px; + min-width: 210px; +} + +.Organization-right-col { + display: flex; + flex-direction: column; + justify-content: center; +} + +.Organization { + &__input { + @include input; + } + + &__status { + display: flex; + align-items: center; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + + &--error { + margin-top: 8px; + color: var(--dnd-indicator); + } + } + + &__progress-path { + position: absolute; + top: -25px; + left: -55px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + text-align: center; + } + + &__content { + margin-left: 200px; + } +} + +@media screen and (max-width: 700px) { + .Organization-left-col { + display: none; + } +} + +@include simple-in-and-out("Organization"); diff --git a/webapp/channels/src/components/preparing_workspace/organization.tsx b/webapp/channels/src/components/preparing_workspace/organization.tsx new file mode 100644 index 0000000000..684c6dc4d9 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization.tsx @@ -0,0 +1,206 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, useRef, ChangeEvent} from 'react'; +import {CSSTransition} from 'react-transition-group'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import debounce from 'lodash/debounce'; + +import OrganizationSVG from 'components/common/svg_images_components/organization-building_svg'; +import QuickInput from 'components/quick_input'; + +import {trackEvent} from 'actions/telemetry_actions'; + +import {getTeams} from 'mattermost-redux/actions/teams'; +import {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams'; +import {Team} from '@mattermost/types/teams'; + +import {teamNameToUrl} from 'utils/url'; +import Constants from 'utils/constants'; + +import OrganizationStatus, {TeamApiError} from './organization_status'; +import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps'; +import PageLine from './page_line'; +import Title from './title'; +import Description from './description'; +import PageBody from './page_body'; + +import './organization.scss'; + +type Props = PreparingWorkspacePageProps & { + organization: Form['organization']; + setOrganization: (organization: Form['organization']) => void; + className?: string; + createTeam: (OrganizationName: string) => Promise<{error: string | null; newTeam: Team | null}>; + updateTeam: (teamToUpdate: Team) => Promise<{error: string | null; updatedTeam: Team | null}>; + setInviteId: (inviteId: string) => void; +} + +const reportValidationError = debounce(() => { + trackEvent('first_admin_setup', 'validate_organization_error'); +}, 700, {leading: false}); + +const Organization = (props: Props) => { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + + const [triedNext, setTriedNext] = useState(false); + const inputRef = useRef(); + const validation = teamNameToUrl(props.organization || ''); + const teamApiError = useRef(null); + + useEffect(props.onPageView, []); + + const teams = useSelector(getActiveTeamsList); + useEffect(() => { + if (!teams) { + dispatch(getTeams(0, 60)); + } + }, [teams]); + + const setApiCallError = () => { + teamApiError.current = TeamApiError; + }; + + const updateTeamNameFromOrgName = async () => { + if (!inputRef.current?.value) { + return; + } + const name = inputRef.current?.value.trim(); + + const currentTeam = teams[0]; + + if (currentTeam && name && name !== currentTeam.display_name) { + const {error} = await props.updateTeam({...currentTeam, display_name: name}); + if (error !== null) { + setApiCallError(); + } + } + }; + + const createTeamFromOrgName = async () => { + if (!inputRef.current?.value) { + return; + } + const name = inputRef.current?.value.trim(); + + if (name) { + const {error, newTeam} = await props.createTeam(name); + if (error !== null || newTeam === null) { + props.setInviteId(''); + setApiCallError(); + return; + } + props.setInviteId(newTeam.invite_id); + } + }; + + const handleOnChange = (e: ChangeEvent) => { + props.setOrganization(e.target.value); + teamApiError.current = null; + }; + + const onNext = (e?: React.KeyboardEvent | React.MouseEvent) => { + if (e && (e as React.KeyboardEvent).key) { + if ((e as React.KeyboardEvent).key !== Constants.KeyCodes.ENTER[0]) { + return; + } + } + if (!triedNext) { + setTriedNext(true); + } + + // if there is already a team, maybe because a page reload, then just update the teamname + const thereIsAlreadyATeam = teams.length > 0; + teamApiError.current = null; + + if (!validation.error && !thereIsAlreadyATeam) { + createTeamFromOrgName(); + } else if (!validation.error && thereIsAlreadyATeam) { + updateTeamNameFromOrgName(); + } + + if (validation.error || teamApiError.current) { + reportValidationError(); + return; + } + props.next?.(); + }; + + let className = 'Organization-body'; + if (props.className) { + className += ' ' + props.className; + } + return ( + +
+
+
+
+ + +
+
+ {props.previous} + + <FormattedMessage + id={'onboarding_wizard.organization.title'} + defaultMessage='What’s the name of your organization?' + /> + + + + + + handleOnChange(e)} + onKeyUp={onNext} + autoFocus={true} + ref={inputRef as unknown as any} + /> + {triedNext ? : null} + + +
+
+
+
+
+ ); +}; +export default Organization; diff --git a/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx b/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx new file mode 100644 index 0000000000..e7d65bfd6b --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {render} from '@testing-library/react'; +import {BadUrlReasons} from 'utils/url'; +import OrganizationStatus, {TeamApiError} from './organization_status'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +describe('components/preparing-workspace/organization_status', () => { + const defaultProps = { + error: null, + }; + + it('should match snapshot', () => { + const {container} = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render no error message when error prop is null', () => { + const {queryByText, container} = render(); + expect((container.getElementsByClassName('Organization__status').length)).toBe(1); + expect(queryByText(/empty/i)).not.toBeInTheDocument(); + expect(queryByText(/team api error/i)).not.toBeInTheDocument(); + expect(queryByText(/length/i)).not.toBeInTheDocument(); + expect(queryByText(/reserved/i)).not.toBeInTheDocument(); + }); + + it('should render an error message for an empty organization name', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/You must enter an organization name/i)).toBeInTheDocument(); + }); + + it('should render an error message for a team API error', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/There was an error, please try again/i)).toBeInTheDocument(); + }); + + it('should render an error message for an organization name with invalid length', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/Organization name must be between 2 and 64 characters/i)).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/organization_status.tsx b/webapp/channels/src/components/preparing_workspace/organization_status.tsx new file mode 100644 index 0000000000..d695a2ad26 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization_status.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {BadUrlReasons, UrlValidationCheck} from 'utils/url'; +import Constants, {DocLinks} from 'utils/constants'; +import ExternalLink from 'components/external_link'; + +export const TeamApiError = 'team_api_error'; + +const OrganizationStatus = (props: {error: (UrlValidationCheck['error'] | typeof TeamApiError | null)}): JSX.Element => { + let children = null; + let className = 'Organization__status'; + if (props.error) { + className += ' Organization__status--error'; + switch (props.error) { + case BadUrlReasons.Empty: + children = ( + + ); + break; + case TeamApiError: + children = ( + + ); + break; + case BadUrlReasons.Length: + children = ( + + ); + break; + case BadUrlReasons.Reserved: + children = ( + ( + + {chunks} + + ), + }} + /> + ); + break; + default: + children = ( + + ); + break; + } + } + return
{children}
; +}; + +export default OrganizationStatus; diff --git a/webapp/channels/src/components/preparing_workspace/page_line.scss b/webapp/channels/src/components/preparing_workspace/page_line.scss new file mode 100644 index 0000000000..12801e1f67 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/page_line.scss @@ -0,0 +1,10 @@ +.PageLine { + position: relative; + left: 100px; + width: 1px; + background-color: rgba(var(--center-channel-color-rgb), 0.24); + + &--no-left { + left: initial; + } +} diff --git a/webapp/channels/src/components/preparing_workspace/page_line.tsx b/webapp/channels/src/components/preparing_workspace/page_line.tsx new file mode 100644 index 0000000000..ebbb9ee024 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/page_line.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import './page_line.scss'; + +type Props = { + style?: Record; + noLeft?: boolean; +} +const PageLine = (props: Props) => { + let className = 'PageLine'; + if (props.noLeft) { + className += ' PageLine--no-left'; + } + const styles: Record = {}; + if (props?.style) { + Object.assign(styles, props.style); + } + if (!styles.height) { + styles.height = '100vh'; + } + if ((!props.style?.height && styles.height === '100vh') && !styles.marginTop) { + styles.marginTop = '50px'; + } + return ( +
+ ); +}; + +export default PageLine; diff --git a/webapp/channels/src/components/preparing_workspace/plugins.scss b/webapp/channels/src/components/preparing_workspace/plugins.scss index fa74dc5718..0a5465564e 100644 --- a/webapp/channels/src/components/preparing_workspace/plugins.scss +++ b/webapp/channels/src/components/preparing_workspace/plugins.scss @@ -4,6 +4,9 @@ margin-top: 24px; } +.plugins-skip-btn { + margin-left: 8px; +} // preempt cards wrapping @media screen and (max-width: 900px) { .Plugins-body { diff --git a/webapp/channels/src/components/preparing_workspace/plugins.tsx b/webapp/channels/src/components/preparing_workspace/plugins.tsx index b3b1168015..caf04e794e 100644 --- a/webapp/channels/src/components/preparing_workspace/plugins.tsx +++ b/webapp/channels/src/components/preparing_workspace/plugins.tsx @@ -21,15 +21,16 @@ import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps import Title from './title'; import Description from './description'; import PageBody from './page_body'; - import SingleColumnLayout from './single_column_layout'; +import PageLine from './page_line'; import './plugins.scss'; type Props = PreparingWorkspacePageProps & { options: Form['plugins']; setOption: (option: keyof Form['plugins']) => void; className?: string; + isSelfHosted: boolean; } const Plugins = (props: Props) => { const {formatMessage} = useIntl(); @@ -44,6 +45,34 @@ const Plugins = (props: Props) => { if (props.className) { className += ' ' + props.className; } + + let title = ( + + ); + let description = ( + + ); + if (props.isSelfHosted) { + title = ( + + ); + description = ( + + ); + } + return ( { >
+ {props.previous} - <FormattedMessage - id={'onboarding_wizard.plugins.title'} - defaultMessage='Welcome to Mattermost!' - /> - <div className='subtitle'> - <CelebrateSVG/> - <FormattedMessage - id={'onboarding_wizard.plugins.subtitle'} - defaultMessage='(almost there!)' - /> - </div> + {title} + {!props.isSelfHosted && ( + <div className='subtitle'> + <CelebrateSVG/> + <FormattedMessage + id={'onboarding_wizard.cloud_plugins.subtitle'} + defaultMessage='(almost there!)' + /> + </div> + + )} - - - + {description} { />
+
diff --git a/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss b/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss index c91dd0a1fe..99187c301b 100644 --- a/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss +++ b/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss @@ -63,6 +63,21 @@ .primary-button { @include primary-button; @include button-medium; + + box-sizing: border-box; + border: 2px solid var(--button-bg); + } + + .primary-button[disabled] { + box-sizing: border-box; + border: 2px solid rgba(var(--center-channel-color-rgb), 0.01); + } + + .link-style { + @include link; + + background: transparent; + font-size: 14px; } .child-page { @@ -70,6 +85,43 @@ position: absolute; height: 100vh; } + + &__invite-members-illustration { + position: absolute; + top: 25%; + right: -651px; + animation-duration: 0.3s; + animation-fill-mode: forwards; + animation-timing-function: ease-in-out; + } +} + +.enter { + animation-name: slideInRight; +} + +.exit { + animation-name: slideOutRight; +} + +@keyframes slideInRight { + from { + right: -651px; + } + + to { + right: 0; + } +} + +@keyframes slideOutRight { + from { + right: 0; + } + + to { + right: -651px; + } } .PreparingWorkspacePageContainer { diff --git a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx index d554090d68..268e21c55e 100644 --- a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx +++ b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx @@ -1,23 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useCallback, useEffect, useRef} from 'react'; +import React, {useState, useCallback, useEffect, useRef, useMemo} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {RouterProps} from 'react-router-dom'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {GeneralTypes} from 'mattermost-redux/action_types'; import {General} from 'mattermost-redux/constants'; import {getFirstAdminSetupComplete as getFirstAdminSetupCompleteAction} from 'mattermost-redux/actions/general'; import {ActionResult} from 'mattermost-redux/types/actions'; import {Team} from '@mattermost/types/teams'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeam, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; -import {getFirstAdminSetupComplete, getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getFirstAdminSetupComplete, getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {Client4} from 'mattermost-redux/client'; import Constants from 'utils/constants'; +import {getSiteURL, teamNameToUrl} from 'utils/url'; +import {makeNewTeam} from 'utils/team_utils'; import {pageVisited, trackEvent} from 'actions/telemetry_actions'; @@ -35,10 +36,14 @@ import { mapStepToPageView, mapStepToSubmitFail, PLUGIN_NAME_TO_ID_MAP, + mapStepToPrevious, } from './steps'; +import Organization from './organization'; import Plugins from './plugins'; import Progress from './progress'; +import InviteMembers from './invite_members'; +import InviteMembersIllustration from './invite_members_illustration'; import LaunchingWorkspace, {START_TRANSITIONING_OUT} from './launching_workspace'; import './preparing_workspace.scss'; @@ -58,6 +63,7 @@ const WAIT_FOR_REDIRECT_TIME = 2000 - START_TRANSITIONING_OUT; export type Actions = { createTeam: (team: Team) => ActionResult; + updateTeam: (team: Team) => ActionResult; checkIfTeamExists: (teamName: string) => ActionResult; getProfiles: (page: number, perPage: number, options: Record) => ActionResult; } @@ -81,12 +87,16 @@ function makeSubmitFail(step: WizardStep) { } const trackSubmitFail = { + [WizardSteps.Organization]: makeSubmitFail(WizardSteps.Organization), [WizardSteps.Plugins]: makeSubmitFail(WizardSteps.Plugins), + [WizardSteps.InviteMembers]: makeSubmitFail(WizardSteps.InviteMembers), [WizardSteps.LaunchingWorkspace]: makeSubmitFail(WizardSteps.LaunchingWorkspace), }; const onPageViews = { + [WizardSteps.Organization]: makeOnPageView(WizardSteps.Organization), [WizardSteps.Plugins]: makeOnPageView(WizardSteps.Plugins), + [WizardSteps.InviteMembers]: makeOnPageView(WizardSteps.InviteMembers), [WizardSteps.LaunchingWorkspace]: makeOnPageView(WizardSteps.LaunchingWorkspace), }; @@ -98,28 +108,35 @@ const PreparingWorkspace = (props: Props) => { defaultMessage: 'Something went wrong. Please try again.', }); const isUserFirstAdmin = useSelector(isFirstAdmin); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const currentTeam = useSelector(getCurrentTeam); const myTeams = useSelector(getMyTeams); // In cloud instances created from portal, // new admin user has a team in myTeams but not in currentTeam. - const team = currentTeam || myTeams?.[0]; + let team = currentTeam || myTeams?.[0]; const config = useSelector(getConfig); const pluginsEnabled = config.PluginsEnabled === 'true'; const showOnMountTimeout = useRef(); + const configSiteUrl = config.SiteURL; + const isSelfHosted = useSelector(getLicense).Cloud !== 'true'; const stepOrder = [ + isSelfHosted && WizardSteps.Organization, pluginsEnabled && WizardSteps.Plugins, + isSelfHosted && WizardSteps.InviteMembers, WizardSteps.LaunchingWorkspace, ].filter((x) => Boolean(x)) as WizardStep[]; + // first steporder that is not false + const firstShowablePage = stepOrder[0]; + const firstAdminSetupComplete = useSelector(getFirstAdminSetupComplete); const [[mostRecentStep, currentStep], setStepHistory] = useState<[WizardStep, WizardStep]>([stepOrder[0], stepOrder[0]]); const [submissionState, setSubmissionState] = useState(SubmissionStates.Presubmit); + const browserSiteUrl = useMemo(getSiteURL, []); const [form, setForm] = useState({ ...emptyForm, }); @@ -188,13 +205,44 @@ const PreparingWorkspace = (props: Props) => { trackSubmitFail[redirectTo](); }, []); + const createTeam = async (OrganizationName: string): Promise<{error: string | null; newTeam: Team | null}> => { + const data = await props.actions.createTeam(makeNewTeam(OrganizationName, teamNameToUrl(OrganizationName || '').url)); + if (data.error) { + return {error: genericSubmitError, newTeam: null}; + } + return {error: null, newTeam: data.data}; + }; + + const updateTeam = async (teamToUpdate: Team): Promise<{error: string | null; updatedTeam: Team | null}> => { + const data = await props.actions.updateTeam(teamToUpdate); + if (data.error) { + return {error: genericSubmitError, updatedTeam: null}; + } + return {error: null, updatedTeam: data.data}; + }; + const sendForm = async () => { const sendFormStart = Date.now(); setSubmissionState(SubmissionStates.Submitting); + if (form.organization && !isSelfHosted) { + try { + const {error, newTeam} = await createTeam(form.organization); + if (error !== null) { + redirectWithError(WizardSteps.Organization, genericSubmitError); + return; + } + team = newTeam as Team; + } catch (e) { + redirectWithError(WizardSteps.Organization, genericSubmitError); + return; + } + } + // send plugins const {skipped: skippedPlugins, ...pluginChoices} = form.plugins; let pluginsToSetup: string[] = []; + if (!skippedPlugins) { pluginsToSetup = Object.entries(pluginChoices).reduce( (acc: string[], [k, v]): string[] => (v ? [...acc, PLUGIN_NAME_TO_ID_MAP[k as keyof Omit]] : acc), [], @@ -204,8 +252,10 @@ const PreparingWorkspace = (props: Props) => { // This endpoint sets setup complete state, so we need to make this request // even if admin skipped submitting plugins. const completeSetupRequest = { + organization: form.organization, install_plugins: pluginsToSetup, }; + try { await Client4.completeSetup(completeSetupRequest); dispatch({type: GeneralTypes.FIRST_ADMIN_COMPLETE_SETUP_RECEIVED, data: true}); @@ -221,6 +271,7 @@ const PreparingWorkspace = (props: Props) => { const sendFormEnd = Date.now(); const timeToWait = WAIT_FOR_REDIRECT_TIME - (sendFormEnd - sendFormStart); + if (timeToWait > 0) { setTimeout(goToChannels, timeToWait); } else { @@ -236,7 +287,8 @@ const PreparingWorkspace = (props: Props) => { }, [submissionState]); const adminRevisitedPage = firstAdminSetupComplete && submissionState === SubmissionStates.Presubmit; - const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage || !useCaseOnboarding; + const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage; + useEffect(() => { if (shouldRedirect) { props.history.push('/'); @@ -256,6 +308,24 @@ const PreparingWorkspace = (props: Props) => { return stepIndex > currentStepIndex ? Animations.Reasons.ExitToBefore : Animations.Reasons.ExitToAfter; }; + const goPrevious = useCallback((e?: React.KeyboardEvent | React.MouseEvent) => { + if (e && (e as React.KeyboardEvent).key) { + const key = (e as React.KeyboardEvent).key; + if (key !== Constants.KeyCodes.ENTER[0] && key !== Constants.KeyCodes.SPACE[0]) { + return; + } + } + if (submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail) { + return; + } + const stepIndex = stepOrder.indexOf(currentStep); + if (stepIndex <= 0) { + return; + } + trackEvent('first_admin_setup', mapStepToPrevious(currentStep)); + setStepHistory([currentStep, stepOrder[stepIndex - 1]]); + }, [currentStep]); + const skipPlugins = useCallback((skipped: boolean) => { if (skipped === form.plugins.skipped) { return; @@ -269,6 +339,46 @@ const PreparingWorkspace = (props: Props) => { }); }, [form]); + const skipTeamMembers = useCallback((skipped: boolean) => { + if (skipped === form.teamMembers.skipped) { + return; + } + setForm({ + ...form, + teamMembers: { + ...form.teamMembers, + skipped, + }, + }); + }, [form]); + + const getInviteMembersAnimationClass = useCallback(() => { + if (currentStep === WizardSteps.InviteMembers) { + return 'enter'; + } else if (mostRecentStep === WizardSteps.InviteMembers) { + return 'exit'; + } + return ''; + }, [currentStep]); + + let previous: React.ReactNode = ( +
+ + +
+ ); + if (currentStep === firstShowablePage) { + previous = null; + } + return (
{submissionState === SubmissionStates.SubmitFail && submitError && ( @@ -291,17 +401,49 @@ const PreparingWorkspace = (props: Props) => { transitionSpeed={Animations.PAGE_SLIDE} />
+ { + setForm({ + ...form, + organization, + }); + }} + setInviteId={(inviteId: string) => { + setForm({ + ...form, + teamMembers: { + ...form.teamMembers, + inviteId, + }, + }); + }} + className='child-page' + createTeam={createTeam} + updateTeam={updateTeam} + /> + { const pluginChoices = {...form.plugins}; delete pluginChoices.skipped; - setSubmissionState(SubmissionStates.UserRequested); + if (!isSelfHosted) { + setSubmissionState(SubmissionStates.UserRequested); + } makeNext(WizardSteps.Plugins)(pluginChoices); skipPlugins(false); }} skip={() => { - setSubmissionState(SubmissionStates.UserRequested); + if (!isSelfHosted) { + setSubmissionState(SubmissionStates.UserRequested); + } makeNext(WizardSteps.Plugins, true)(); skipPlugins(true); }} @@ -319,12 +461,40 @@ const PreparingWorkspace = (props: Props) => { transitionDirection={getTransitionDirection(WizardSteps.Plugins)} className='child-page' /> + { + skipTeamMembers(false); + const inviteMembersTracking = { + inviteCount: form.teamMembers.invites.length, + }; + setSubmissionState(SubmissionStates.UserRequested); + makeNext(WizardSteps.InviteMembers)(inviteMembersTracking); + }} + skip={() => { + skipTeamMembers(true); + setSubmissionState(SubmissionStates.UserRequested); + makeNext(WizardSteps.InviteMembers, true)(); + }} + previous={previous} + show={shouldShowPage(WizardSteps.InviteMembers)} + transitionDirection={getTransitionDirection(WizardSteps.InviteMembers)} + disableEdits={submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail} + className='child-page' + teamInviteId={team?.invite_id || form.teamMembers.inviteId} + configSiteUrl={configSiteUrl} + formUrl={form.url} + browserSiteUrl={browserSiteUrl} + />
+
+ +
); }; diff --git a/webapp/channels/src/components/preparing_workspace/single_column_layout.scss b/webapp/channels/src/components/preparing_workspace/single_column_layout.scss index 357faabdf4..afff27dcfe 100644 --- a/webapp/channels/src/components/preparing_workspace/single_column_layout.scss +++ b/webapp/channels/src/components/preparing_workspace/single_column_layout.scss @@ -4,5 +4,4 @@ height: 100vh; flex-direction: column; align-items: flex-start; - justify-content: center; } diff --git a/webapp/channels/src/components/preparing_workspace/steps.ts b/webapp/channels/src/components/preparing_workspace/steps.ts index ed52d984af..cbb78da5b6 100644 --- a/webapp/channels/src/components/preparing_workspace/steps.ts +++ b/webapp/channels/src/components/preparing_workspace/steps.ts @@ -4,7 +4,9 @@ import deepFreeze from 'mattermost-redux/utils/deep_freeze'; export const WizardSteps = { + Organization: 'Organization', Plugins: 'Plugins', + InviteMembers: 'InviteMembers', LaunchingWorkspace: 'LaunchingWorkspace', } as const; @@ -20,8 +22,12 @@ export const Animations = { export function mapStepToNextName(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_next_organization'; case WizardSteps.Plugins: return 'admin_onboarding_next_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_next_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_next_transitioning_out'; default: @@ -31,8 +37,12 @@ export function mapStepToNextName(step: WizardStep): string { export function mapStepToPrevious(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_previous_organization'; case WizardSteps.Plugins: return 'admin_onboarding_previous_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_previous_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_previous_transitioning_out'; default: @@ -42,8 +52,12 @@ export function mapStepToPrevious(step: WizardStep): string { export function mapStepToPageView(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'pageview_admin_onboarding_organization'; case WizardSteps.Plugins: return 'pageview_admin_onboarding_plugins'; + case WizardSteps.InviteMembers: + return 'pageview_admin_onboarding_invite_members'; case WizardSteps.LaunchingWorkspace: return 'pageview_admin_onboarding_transitioning_out'; default: @@ -53,8 +67,12 @@ export function mapStepToPageView(step: WizardStep): string { export function mapStepToSubmitFail(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_organization_submit_fail'; case WizardSteps.Plugins: return 'admin_onboarding_plugins_submit_fail'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_invite_members_submit_fail'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_transitioning_out_submit_fail'; default: @@ -64,8 +82,12 @@ export function mapStepToSubmitFail(step: WizardStep): string { export function mapStepToSkipName(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_skip_organization'; case WizardSteps.Plugins: return 'admin_onboarding_skip_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_skip_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_skip_transitioning_out'; default: @@ -128,12 +150,14 @@ export type Form = { skipped: boolean; }; teamMembers: { + inviteId: string; invites: string[]; skipped: boolean; }; } export const emptyForm = deepFreeze({ + organization: '', inferredProtocol: null, urlSkipped: false, useCase: { @@ -156,6 +180,7 @@ export const emptyForm = deepFreeze({ skipped: false, }, teamMembers: { + inviteId: '', invites: [], skipped: false, }, @@ -165,7 +190,7 @@ export type PreparingWorkspacePageProps = { transitionDirection: AnimationReason; next?: () => void; skip?: () => void; - previous?: JSX.Element; + previous?: React.ReactNode; show: boolean; onPageView: () => void; } diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index eb41c2d0fe..73f78f6af6 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -10,7 +10,7 @@ import classNames from 'classnames'; import {Client4} from 'mattermost-redux/client'; import {rudderAnalytics, RudderTelemetryHandler} from 'mattermost-redux/client/rudder'; import {General} from 'mattermost-redux/constants'; -import {Theme, getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUser, isCurrentUserSystemAdmin, checkIsFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {setUrl} from 'mattermost-redux/actions/general'; @@ -89,6 +89,8 @@ import {ActionResult} from 'mattermost-redux/types/actions'; import WelcomePostRenderer from 'components/welcome_post_renderer'; +import {getMyTeams} from 'mattermost-redux/selectors/entities/teams'; + import {applyLuxonDefaults} from './effects'; import RootProvider from './root_provider'; @@ -358,8 +360,8 @@ export default class Root extends React.PureComponent { return; } - const useCaseOnboarding = getUseCaseOnboarding(storeState); - if (!useCaseOnboarding) { + const myTeams = getMyTeams(storeState); + if (myTeams.length > 0) { GlobalActions.redirectUserToDefaultTeam(); return; } diff --git a/webapp/channels/src/components/root/root_redirect/index.ts b/webapp/channels/src/components/root/root_redirect/index.ts index 7575f7c5a4..eca15abc20 100644 --- a/webapp/channels/src/components/root/root_redirect/index.ts +++ b/webapp/channels/src/components/root/root_redirect/index.ts @@ -6,7 +6,6 @@ import {connect} from 'react-redux'; import {getFirstAdminSetupComplete} from 'mattermost-redux/actions/general'; import {getCurrentUserId, isCurrentUserSystemAdmin, isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {GenericAction} from 'mattermost-redux/types/actions'; import {GlobalState} from 'types/store'; @@ -14,11 +13,7 @@ import {GlobalState} from 'types/store'; import RootRedirect, {Props} from './root_redirect'; function mapStateToProps(state: GlobalState) { - const useCaseOnboarding = getUseCaseOnboarding(state); - let isElegibleForFirstAdmingOnboarding = useCaseOnboarding; - if (isElegibleForFirstAdmingOnboarding) { - isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state); - } + const isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state); return { currentUserId: getCurrentUserId(state), isElegibleForFirstAdmingOnboarding, diff --git a/webapp/channels/src/components/signup/signup.test.tsx b/webapp/channels/src/components/signup/signup.test.tsx index dcc56032a0..900e8de8b7 100644 --- a/webapp/channels/src/components/signup/signup.test.tsx +++ b/webapp/channels/src/components/signup/signup.test.tsx @@ -7,8 +7,6 @@ import {IntlProvider} from 'react-intl'; import {BrowserRouter} from 'react-router-dom'; import {act, screen} from '@testing-library/react'; -import * as global_actions from 'actions/global_actions'; - import {mountWithIntl} from 'tests/helpers/intl-test-helper'; import Signup from 'components/signup/signup'; @@ -197,9 +195,6 @@ describe('components/signup/Signup', () => { mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser mockResolvedValueOnce({error: {server_error_id: 'api.user.login.not_verified.app_error'}}); // loginById - const mockRedirectUserToDefaultTeam = jest.fn(); - jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam); - const wrapper = mountWithIntl( @@ -228,7 +223,6 @@ describe('components/signup/Signup', () => { expect(wrapper.find('#input_name').first().props().disabled).toEqual(true); expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true); - expect(mockRedirectUserToDefaultTeam).not.toHaveBeenCalled(); expect(mockHistoryPush).toHaveBeenCalledWith('/should_verify_email?email=jdoe%40mm.com&teamname=teamName'); }); @@ -238,9 +232,6 @@ describe('components/signup/Signup', () => { mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser mockResolvedValueOnce({}); // loginById - const mockRedirectUserToDefaultTeam = jest.fn(); - jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam); - const wrapper = mountWithIntl( @@ -268,8 +259,6 @@ describe('components/signup/Signup', () => { expect(wrapper.find(Input).first().props().disabled).toEqual(true); expect(wrapper.find('#input_name').first().props().disabled).toEqual(true); expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true); - - expect(mockRedirectUserToDefaultTeam).toHaveBeenCalled(); }); it('should add user to team and redirect when team invite valid and logged in', async () => { diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index 454d5cd8ae..0407a74895 100644 --- a/webapp/channels/src/components/signup/signup.tsx +++ b/webapp/channels/src/components/signup/signup.tsx @@ -17,7 +17,7 @@ import {getTeamInviteInfo} from 'mattermost-redux/actions/teams'; import {createUser, loadMe, loadMeREST} from 'mattermost-redux/actions/users'; import {DispatchFunc} from 'mattermost-redux/types/actions'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {isEmail} from 'mattermost-redux/utils/helpers'; @@ -25,7 +25,6 @@ import {GlobalState} from 'types/store'; import {getGlobalItem} from 'selectors/storage'; -import {redirectUserToDefaultTeam} from 'actions/global_actions'; import {removeGlobalItem, setGlobalItem} from 'actions/storage'; import {addUserToTeamFromInvite} from 'actions/team_actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; @@ -104,7 +103,6 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } = config; const {IsLicensed, Cloud} = useSelector(getLicense); const loggedIn = Boolean(useSelector(getCurrentUserId)); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const usedBefore = useSelector((state: GlobalState) => (!inviteId && !loggedIn && token ? getGlobalItem(state, token, null) : undefined)); const graphQLEnabled = useSelector(isGraphQLEnabled); @@ -310,15 +308,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } else if (inviteId) { getInviteInfo(inviteId); } else if (loggedIn) { - if (useCaseOnboarding) { - // need info about whether admin or not, - // and whether admin has already completed - // first tiem onboarding. Instead of fetching and orchestrating that here, - // let the default root component handle it. - history.push('/'); - } else { - redirectUserToDefaultTeam(); - } + history.push('/'); } } @@ -461,14 +451,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { if (redirectTo) { history.push(redirectTo); - } else if (useCaseOnboarding) { + } else { // need info about whether admin or not, // and whether admin has already completed // first tiem onboarding. Instead of fetching and orchestrating that here, // let the default root component handle it. history.push('/'); - } else { - redirectUserToDefaultTeam(); } }; diff --git a/webapp/channels/src/components/terms_of_service/index.ts b/webapp/channels/src/components/terms_of_service/index.ts index 95faca09a1..c22fba415f 100644 --- a/webapp/channels/src/components/terms_of_service/index.ts +++ b/webapp/channels/src/components/terms_of_service/index.ts @@ -6,7 +6,6 @@ import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux'; import {getTermsOfService, updateMyTermsOfServiceStatus} from 'mattermost-redux/actions/users'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {GlobalState} from '@mattermost/types/store'; import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions'; @@ -26,9 +25,7 @@ type Actions = { function mapStateToProps(state: GlobalState) { const config = getConfig(state); - const useCaseOnboarding = getUseCaseOnboarding(state); return { - useCaseOnboarding, termsEnabled: config.EnableCustomTermsOfService === 'true', emojiMap: getEmojiMap(state), }; diff --git a/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx b/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx index d05c91cd84..5c9f58faab 100644 --- a/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx +++ b/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx @@ -27,7 +27,6 @@ describe('components/terms_of_service/TermsOfService', () => { location: {search: ''}, termsEnabled: true, emojiMap: {} as EmojiMap, - useCaseOnboarding: false, }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/terms_of_service/terms_of_service.tsx b/webapp/channels/src/components/terms_of_service/terms_of_service.tsx index 992086d561..f885830f9c 100644 --- a/webapp/channels/src/components/terms_of_service/terms_of_service.tsx +++ b/webapp/channels/src/components/terms_of_service/terms_of_service.tsx @@ -38,7 +38,6 @@ export interface TermsOfServiceProps { ) => {data: UpdateMyTermsOfServiceStatusResponse}; }; emojiMap: EmojiMap; - useCaseOnboarding: boolean; } interface TermsOfServiceState { @@ -111,14 +110,12 @@ export default class TermsOfService extends React.PureComponentstart with a reserved word.", + "onboarding_wizard.organization.team_api_error": "There was an error, please try again.", + "onboarding_wizard.organization.title": "What’s the name of your organization?", "onboarding_wizard.plugins.github": "GitHub", "onboarding_wizard.plugins.github.tooltip": "Subscribe to repositories, stay up to date with reviews, assignments", "onboarding_wizard.plugins.gitlab": "GitLab", @@ -4329,13 +4345,14 @@ "onboarding_wizard.plugins.jira": "Jira", "onboarding_wizard.plugins.jira.tooltip": "Create Jira tickets from messages in Mattermost, get notified of important updates in Jira", "onboarding_wizard.plugins.marketplace": "More tools can be added once your workspace is set up. To see all available integrations, visit the Marketplace.", - "onboarding_wizard.plugins.subtitle": "(almost there!)", - "onboarding_wizard.plugins.title": "Welcome to Mattermost!", "onboarding_wizard.plugins.todo": "To do", "onboarding_wizard.plugins.todo.tooltip": "A plugin to track Todo issues in a list and send you daily reminders about your Todo list", "onboarding_wizard.plugins.zoom": "Zoom", "onboarding_wizard.plugins.zoom.tooltip": "Start Zoom audio and video conferencing calls in Mattermost with a single click", - "onboarding_wizard.skip": "Skip for now", + "onboarding_wizard.previous": "Previous", + "onboarding_wizard.self_hosted_plugins.description": "Choose the tools you work with, and we'll add them to your workspace. Additional set up may be needed later.", + "onboarding_wizard.self_hosted_plugins.title": "What tools do you use?", + "onboarding_wizard.skip-button": "Skip", "onboarding_wizard.submit_error.generic": "Something went wrong. Please try again.", "onboardingTask.checklist.completed_subtitle": "We hope Mattermost is more familiar now.", "onboardingTask.checklist.completed_title": "Well done. You’ve completed all of the tasks!", diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts index 2ef11c99d3..b2d3c1a672 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts @@ -245,10 +245,6 @@ export function isCustomGroupsEnabled(state: GlobalState): boolean { return getConfig(state).EnableCustomGroups === 'true'; } -export function getUseCaseOnboarding(state: GlobalState): boolean { - return getFeatureFlagValue(state, 'UseCaseOnboarding') === 'true' && getLicense(state)?.Cloud === 'true'; -} - export function insightsAreEnabled(state: GlobalState): boolean { const isConfiguredForFeature = getConfig(state).InsightsEnabled === 'true'; const featureIsEnabled = getFeatureFlagValue(state, 'InsightsEnabled') === 'true'; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 62fc2e8fdd..7f1dce9244 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -1099,6 +1099,7 @@ export const DocLinks = { ONBOARD_LDAP: 'https://docs.mattermost.com/onboard/ad-ldap.html', ONBOARD_SSO: 'https://docs.mattermost.com/onboard/sso-saml.html', TRUE_UP_REVIEW: 'https://mattermost.com/pl/true-up-documentation', + ABOUT_TEAMS: 'https://docs.mattermost.com/welcome/about-teams.html#team-url', }; export const LicenseLinks = { diff --git a/webapp/platform/types/src/setup.ts b/webapp/platform/types/src/setup.ts index 980aa05dce..085527a434 100644 --- a/webapp/platform/types/src/setup.ts +++ b/webapp/platform/types/src/setup.ts @@ -2,5 +2,6 @@ // See LICENSE.txt for license information. export type CompleteOnboardingRequest = { + organization: string; install_plugins: string[]; }