diff --git a/api/v4/source/sharedchannels.yaml b/api/v4/source/sharedchannels.yaml index f0d7042e47..2153806071 100644 --- a/api/v4/source/sharedchannels.yaml +++ b/api/v4/source/sharedchannels.yaml @@ -178,8 +178,12 @@ schema: type: string responses: - "204": + "200": description: Remote cluster invited successfully + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -212,8 +216,12 @@ schema: type: string responses: - "204": + "200": description: Remote cluster uninvited successfully + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" "401": $ref: "#/components/responses/Unauthorized" "403": diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index 52e1139a89..474d6141c8 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -753,4 +753,10 @@ const defaultServerConfig: AdminConfig = { MoveThreadFromDirectMessageChannelEnable: false, MoveThreadFromGroupMessageChannelEnable: false, }, + ConnectedWorkspacesSettings: { + EnableSharedChannels: false, + EnableRemoteClusterService: false, + DisableSharedChannelsStatusSync: false, + MaxPostsPerSync: 50, + }, }; diff --git a/server/channels/api4/shared_channel.go b/server/channels/api4/shared_channel.go index c2a89cb3cf..0cc5eab2d3 100644 --- a/server/channels/api4/shared_channel.go +++ b/server/channels/api4/shared_channel.go @@ -186,7 +186,7 @@ func inviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.Req } auditRec.Success() - w.WriteHeader(http.StatusNoContent) + ReturnStatusOK(w) } func uninviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.Request) { @@ -244,5 +244,5 @@ func uninviteRemoteClusterToChannel(c *Context, w http.ResponseWriter, r *http.R } auditRec.Success() - w.WriteHeader(http.StatusNoContent) + ReturnStatusOK(w) } diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 65b0114362..5e2b607118 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -87,6 +87,8 @@ import PermissionSystemSchemeSettings from './permission_schemes_settings/permis import PermissionTeamSchemeSettings from './permission_schemes_settings/permission_team_scheme_settings'; import {searchableStrings as pluginManagementSearchableStrings} from './plugin_management/plugin_management'; import PushNotificationsSettings, {searchableStrings as pushSearchableStrings} from './push_settings'; +import SecureConnections, {searchableStrings as secureConnectionsSearchableStrings} from './secure_connections'; +import SecureConnectionDetail from './secure_connections/secure_connection_detail'; import ServerLogs from './server_logs'; import {searchableStrings as serverLogsSearchableStrings} from './server_logs/logs'; import SessionLengthSettings, {searchableStrings as sessionLengthSearchableStrings} from './session_length_settings'; @@ -1883,6 +1885,44 @@ const AdminDefinition: AdminDefinitionType = { ], }, }, + + secure_connection_detail: { + url: `environment/secure_connections/:connection_id(create|${ID_PATH_PATTERN})`, + isHidden: it.any( + it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'), + it.configIsFalse('ConnectedWorkspacesSettings', 'EnableSharedChannels'), + it.configIsFalse('ConnectedWorkspacesSettings', 'EnableRemoteClusterService'), + it.not(it.any( + it.licensedForFeature('SharedChannels'), + it.licensedForSku(LicenseSkus.Enterprise), + it.licensedForSku(LicenseSkus.Professional), + )), + ), + schema: { + id: 'SecureConnectionDetail', + component: SecureConnectionDetail, + }, + }, + + secure_connections: { + url: 'environment/secure_connections', + title: defineMessage({id: 'admin.sidebar.secureConnections', defaultMessage: 'Connected Workspaces (Beta)'}), + searchableStrings: secureConnectionsSearchableStrings, + isHidden: it.any( + it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'), + it.configIsFalse('ConnectedWorkspacesSettings', 'EnableSharedChannels'), + it.configIsFalse('ConnectedWorkspacesSettings', 'EnableRemoteClusterService'), + it.not(it.any( + it.licensedForFeature('SharedChannels'), + it.licensedForSku(LicenseSkus.Enterprise), + it.licensedForSku(LicenseSkus.Professional), + )), + ), + schema: { + id: 'SecureConnections', + component: SecureConnections, + }, + }, }, }, site: { diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.tsx b/webapp/channels/src/components/admin_console/list_table/list_table.tsx index dea9cfa1bd..4b38b26d90 100644 --- a/webapp/channels/src/components/admin_console/list_table/list_table.tsx +++ b/webapp/channels/src/components/admin_console/list_table/list_table.tsx @@ -58,6 +58,7 @@ export type TableMeta = { onRowClick?: (row: string) => void; disablePrevPage?: boolean; disableNextPage?: boolean; + disablePaginationControls?: boolean; onPreviousPageClick?: () => void; onNextPageClick?: () => void; paginationInfo?: ReactNode; @@ -88,6 +89,8 @@ export function ListTable( const rowIdPrefix = `${tableMeta.tableId}-row-`; const cellIdPrefix = `${tableMeta.tableId}-cell-`; + const hasPagination = !tableMeta.disablePaginationControls; + const pageSizeOptions = useMemo(() => { return PAGE_SIZES.map((size) => { return { @@ -119,20 +122,22 @@ export function ListTable( return (
-
- {tableMeta.hasDualSidedPagination && ( - <> - {tableMeta.paginationInfo} - - - )} -
+ {hasPagination && ( +
+ {tableMeta.hasDualSidedPagination && ( + <> + {tableMeta.paginationInfo} + + + )} +
+ )} ( ))}
-
- {tableMeta.paginationInfo} - {handlePageSizeChange && ( + {hasPagination && ( +
+ {tableMeta.paginationInfo}
( defaultMessage='rows per page' />
- )} - -
+ + +
+ )}
); } diff --git a/webapp/channels/src/components/admin_console/secure_connections/building.svg.tsx b/webapp/channels/src/components/admin_console/secure_connections/building.svg.tsx new file mode 100644 index 0000000000..4512f28717 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/building.svg.tsx @@ -0,0 +1,291 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +function BuildingSvg() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default BuildingSvg; diff --git a/webapp/channels/src/components/admin_console/secure_connections/chat.svg.tsx b/webapp/channels/src/components/admin_console/secure_connections/chat.svg.tsx new file mode 100644 index 0000000000..f8a46aeded --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/chat.svg.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +function ChatSvg() { + return ( + + + + + + + + + + + + + + + ); +} + +export default ChatSvg; diff --git a/webapp/channels/src/components/admin_console/secure_connections/controls.tsx b/webapp/channels/src/components/admin_console/secure_connections/controls.tsx new file mode 100644 index 0000000000..a160843277 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/controls.tsx @@ -0,0 +1,286 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ReactNode} from 'react'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import styled, {css} from 'styled-components'; + +import type {RemoteCluster} from '@mattermost/types/remote_clusters'; + +import Timestamp, {RelativeRanges} from 'components/timestamp'; +import WithTooltip from 'components/with_tooltip'; + +import {isConfirmed, isConnected} from './utils'; + +export const SectionHeading = styled.h3` + &&& { + margin-bottom: 8px; + } +`; + +const FormFieldLabel = styled.label` + width: 100%; + + .DropdownInput.Input_container { + margin-top: 0; + } + + & + & { + margin-top: 30px; + } +`; + +export const SectionHeader = styled.header.attrs({className: 'header'})<{$borderless?: boolean}>` + &&& { + padding: 24px 32px; + ${({$borderless}) => !$borderless && css` + border-bottom: 1px solid var(--center-channel-color-12, rgba(63, 67, 80, 0.12)); + `} + } +`; + +export const SectionContent = styled.div.attrs({className: 'content'})<{$compact?: boolean}>` + &&& { + padding: ${({$compact}) => ($compact ? '24px 32px' : '48px 32px')}; + border-bottom: 1px solid var(--center-channel-color-12, rgba(63, 67, 80, 0.12)); + } +`; + +export const ModalBody = styled.div` + padding: 0 32px; + display: flex; + flex-direction: column; + gap: 20px; +`; + +export const AdminSection = styled.section.attrs({className: 'AdminPanel'})` + && { + overflow: visible; + } +`; + +export const PlaceholderHeading = styled.h4` + && { + font-size: 20px; + font-weight: 600; + line-height: 28px; + margin-bottom: 4px; + } +`; + +export const PlaceholderParagraph = styled.p` + && { + font-size: 14px; + } +`; + +export const ModalParagraph = styled.p` + && { + font-size: 12px; + line-height: 16px; + font-weight: 400; + color: rgba(var(--center-channel-color-rgb), 0.72); + } +`; + +export const PlaceholderContainer = styled.div` + display: flex; + place-items: center; + flex-direction: column; + gap: 5px; + + svg { + margin: 30px 30px 20px; + } + + hgroup { + text-align: center; + } +`; + +export const AdminWrapper = (props: {children: ReactNode}) => { + return ( +
+
+ {props.children} +
+
+ ); +}; + +const InnerLabel = styled.strong` + font-size: 14px; + line-height: 18px; + display: inline-block; + margin-bottom: 10px; +`; + +const HelpText = styled.small` + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: rgba(var(--center-channel-color-rgb), 0.72); + display: block; + margin-top: 10px; +`; + +export const Input = styled.input.attrs({className: 'form-control secure-connections-input'})` + font-weight: normal; +`; + +type FormFieldProps = { + label?: string; + children: ReactNode | ReactNode[]; + helpText?: string; +} + +export const FormField = ({label, children, helpText}: FormFieldProps) => { + return ( + + {label && {label}} + {children} + {helpText && {helpText}} + + ); +}; + +export const ModalFieldsetWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 14px; + + .secure-connections-modal-input .form-control { + border: none !important; + background: none !important; + height: 34px !important; + } +`; + +const ModalLegend = styled.legend` + font-size: 16px; + font-weight: 600; + line-height: 18px; + border-bottom: none; +`; + +export const ModalFieldset = (props: {legend?: string; children: ReactNode | ReactNode[]}) => { + return ( + + {props.legend && {props.legend}} + {props.children} + + ); +}; + +export const ModalNoticeWrapper = styled.div` + margin: 15px 0 25px 0; +`; + +export const Button = styled.button.attrs({className: 'btn btn-secondary'})` + margin: -1px -2px; +`; + +export const LinkButton = styled.button.attrs({className: 'btn btn-link'})<{$destructive?: boolean}>` + font-weight: normal; + ${({$destructive}) => $destructive && css` + && { + color: #D24B4E; + } + `}; +`; + +export const ConnectionStatusLabel = ({rc}: {rc: RemoteCluster}) => { + if (!isConfirmed(rc)) { + return ( + + ); + } + + const status = isConnected(rc) ? ( + + ) : ( + + ); + + if (!rc.last_ping_at) { + return status; + } + + return ( + + + ), + }} + /> +
+ + {rc.site_url} + + + )} + > +
+ {status} +
+
+ ); +}; + +const UrlWrapper = styled.div` + white-space: break-spaces; + word-wrap: none; +`; + +const LASTSYNC_TOOLTIP_RANGES = [ + RelativeRanges.STANDARD_UNITS.second, + RelativeRanges.STANDARD_UNITS.minute, + RelativeRanges.STANDARD_UNITS.hour, +]; + +const labelStyle = css` + font-size: 12px; + color: white; + border-radius: 4px; + padding: 2px 4px; +`; + +const ConnectedLabel = styled.strong` + ${labelStyle}; + background-color: #3DB887; +`; + +const PendingConnectionLabel = styled.strong` + ${labelStyle}; + background-color: #F5AB00; +`; + +const OfflineConnectionLabel = styled.strong` + ${labelStyle}; + background-color: #C43133; +`; diff --git a/webapp/channels/src/components/admin_console/secure_connections/index.ts b/webapp/channels/src/components/admin_console/secure_connections/index.ts new file mode 100644 index 0000000000..c9fa0b0f33 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import SecureConnections from './secure_connections'; + +export {searchableStrings} from './secure_connections'; +export {default as SecureConnectionDetail} from './secure_connection_detail'; + +export default SecureConnections; 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 new file mode 100644 index 0000000000..287b2749aa --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/modal_utils.tsx @@ -0,0 +1,214 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useState} from 'react'; +import {useDispatch} from 'react-redux'; + +import type {Channel} from '@mattermost/types/channels'; +import type {StatusOK} from '@mattermost/types/client4'; +import type {ServerError} from '@mattermost/types/errors'; +import type {RemoteClusterPatch, RemoteCluster, RemoteClusterAcceptInvite} from '@mattermost/types/remote_clusters'; +import type {PartialExcept} from '@mattermost/types/utilities'; + +import {Client4} from 'mattermost-redux/client'; + +import {openModal} from 'actions/views/modals'; + +import {ModalIdentifiers} from 'utils/constants'; +import {cleanUpUrlable} from 'utils/url'; + +import SecureConnectionAcceptInviteModal from './secure_connection_accept_invite_modal'; +import SecureConnectionCreateInviteModal from './secure_connection_create_invite_modal'; +import SecureConnectionDeleteModal from './secure_connection_delete_modal'; +import SharedChannelsAddModal from './shared_channels_add_modal'; +import SharedChannelsRemoveModal from './shared_channels_remove_modal'; + +import type {TLoadingState} from '../utils'; + +const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~_!@-#$^'; +const makePassword = () => { + return Array.from(window.crypto.getRandomValues(new Uint32Array(16))). + map((n) => chars[n % chars.length]). + join(''); +}; + +export const useRemoteClusterCreate = () => { + const dispatch = useDispatch(); + const [saving, setSaving] = useState(false); + + const promptCreate = (patch: RemoteClusterPatch) => { + return new Promise((resolve, reject) => { + dispatch(openModal({ + modalId: ModalIdentifiers.SECURE_CONNECTION_CREATE_INVITE, + dialogType: SecureConnectionCreateInviteModal, + dialogProps: { + creating: true, + onConfirm: async () => { + try { + setSaving(true); + const response = await Client4.createRemoteCluster({ + ...patch, + name: cleanUpUrlable(patch.display_name), + }); + setSaving(false); + + if (response) { + const {invite, password, remote_cluster: remoteCluster} = response; + + resolve(remoteCluster); + return {remoteCluster, share: {invite, password}}; + } + } catch (err) { + // handle create error + reject(err); + } + setSaving(false); + return undefined; + }, + }, + })); + }); + }; + + return {promptCreate, saving}; +}; + +export const useRemoteClusterCreateInvite = (remoteCluster: RemoteCluster) => { + const dispatch = useDispatch(); + const [saving, setSaving] = useState(false); + + const promptCreateInvite = () => { + return new Promise((resolve, reject) => { + dispatch(openModal({ + modalId: ModalIdentifiers.SECURE_CONNECTION_CREATE_INVITE, + dialogType: SecureConnectionCreateInviteModal, + dialogProps: { + onConfirm: async () => { + try { + const password = makePassword(); + setSaving(true); + const invite = await Client4.generateInviteRemoteCluster(remoteCluster.remote_id, {password}); + setSaving(false); + resolve(remoteCluster); + return {remoteCluster, share: {invite, password}}; + } catch (err) { + // handle create error + reject(err); + } + setSaving(false); + return undefined; + }, + }, + })); + }); + }; + + return {promptCreateInvite, saving} as const; +}; + +export const useRemoteClusterAcceptInvite = () => { + const dispatch = useDispatch(); + const [saving, setSaving] = useState(false); + + const promptAcceptInvite = () => { + return new Promise((resolve, reject) => { + dispatch(openModal({ + modalId: ModalIdentifiers.SECURE_CONNECTION_ACCEPT_INVITE, + dialogType: SecureConnectionAcceptInviteModal, + dialogProps: { + onConfirm: async (acceptInvite: PartialExcept) => { + try { + setSaving(true); + const rc = await Client4.acceptInviteRemoteCluster({ + ...acceptInvite, + name: cleanUpUrlable(acceptInvite.display_name), + }); + setSaving(false); + resolve(rc); + return rc; + } catch (err) { + // handle create error + reject(err); + setSaving(err); + throw (err); + } + }, + }, + })); + }); + }; + + return {promptAcceptInvite, saving} as const; +}; + +export const useRemoteClusterDelete = (rc: RemoteCluster) => { + const dispatch = useDispatch(); + const promptDelete = () => { + return new Promise((resolve, reject) => { + dispatch(openModal({ + modalId: ModalIdentifiers.SECURE_CONNECTION_DELETE, + dialogType: SecureConnectionDeleteModal, + dialogProps: { + displayName: rc.display_name, + onConfirm: () => Client4.deleteRemoteCluster(rc.remote_id).then(resolve, reject), + }, + })); + }); + }; + + return {promptDelete} as const; +}; + +export const useSharedChannelsRemove = (remoteId: string) => { + const dispatch = useDispatch(); + const promptRemove = (channelId: string) => { + return new Promise((resolve, reject) => { + dispatch(openModal({ + modalId: ModalIdentifiers.SHARED_CHANNEL_REMOTE_UNINVITE, + dialogType: SharedChannelsRemoveModal, + dialogProps: { + onConfirm: () => Client4.sharedChannelRemoteUninvite(remoteId, channelId).then(resolve, reject), + }, + })); + }); + }; + + return {promptRemove}; +}; + +export type SharedChannelsAddResult = { + data: {[channel_id: string]: PromiseSettledResult}; + errors: {[channel_id: string]: ServerError}; +} +export const useSharedChannelsAdd = (remoteId: string) => { + const dispatch = useDispatch(); + const promptAdd = () => { + return new Promise((resolve) => { + dispatch(openModal({ + modalId: ModalIdentifiers.SHARED_CHANNEL_REMOTE_INVITE, + dialogType: SharedChannelsAddModal, + dialogProps: { + remoteId, + onConfirm: async (channels: Channel[]) => { + const result: SharedChannelsAddResult = {data: {}, errors: {}}; + const {data, errors} = result; + + const requests = channels.map(({id}) => Client4.sharedChannelRemoteInvite(remoteId, id)); + (await Promise.allSettled(requests)).forEach((r, i) => { + if (r.status === 'rejected' && r.reason.server_error_id) { + errors[channels[i].id] = r.reason; + } else if (r.status === 'fulfilled') { + data[channels[i].id] = r; + } + }); + + resolve(result); + return result; + }, + }, + })); + }); + }; + + return {promptAdd}; +}; 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 new file mode 100644 index 0000000000..e71da92807 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx @@ -0,0 +1,159 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import type {ClientError} from '@mattermost/client'; +import {GenericModal} from '@mattermost/components'; +import type {RemoteCluster, RemoteClusterAcceptInvite} from '@mattermost/types/remote_clusters'; +import type {PartialExcept} from '@mattermost/types/utilities'; + +import LoadingScreen from 'components/loading_screen'; +import Input from 'components/widgets/inputs/input/input'; + +import {ModalFieldset, ModalParagraph} from '../controls'; +import {isErrorState, isPendingState} from '../utils'; + +type Props = { + creating?: boolean; + password?: string; + onConfirm: (accept: PartialExcept) => Promise; + onCancel?: () => void; + onExited: () => void; + onHide: () => void; +} + +const noop = () => {}; + +function SecureConnectionAcceptInviteModal({ + onExited, + onCancel, + onConfirm, + onHide, +}: Props) { + const {formatMessage} = useIntl(); + const [displayName, setDisplayName] = useState(''); + const [inviteCode, setInviteCode] = useState(''); + const [password, setPassword] = useState(''); + const [saving, setSaving] = useState(false); + + const need = { + displayName: !displayName, + inviteCode: !inviteCode, + password: !password, + }; + + const formFilled = Object.values(need).every((x) => !x); + + const handleConfirm = async () => { + setSaving(true); + + try { + await onConfirm({display_name: displayName, invite: inviteCode, password}); + setSaving(false); + onHide(); + } catch (err) { + setSaving(err); + } + }; + + const handleDisplayNameChange = (e: React.ChangeEvent) => { + setDisplayName(e.target.value); + }; + + const handleInviteCodeChange = (e: React.ChangeEvent) => { + setInviteCode(e.target.value); + }; + + const handlePasswordChange = (e: React.ChangeEvent) => { + setPassword(e.target.value); + }; + + const title = formatMessage({ + id: 'admin.secure_connections.accept_invite.share_title', + defaultMessage: 'Accept a connection invite', + }); + + const confirmButtonText = formatMessage({ + id: 'admin.secure_connections.accept_invite.confirm.done.button', + defaultMessage: 'Accept', + }); + + return ( + + )} + > + {isPendingState(saving) ? ( + + ) : ( + <> + + + + + + + + + )} + + ); +} + +export default SecureConnectionAcceptInviteModal; diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_create_invite_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_create_invite_modal.tsx new file mode 100644 index 0000000000..6fa10e8303 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_create_invite_modal.tsx @@ -0,0 +1,195 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {CheckIcon, ContentCopyIcon} from '@mattermost/compass-icons/components'; +import {GenericModal} from '@mattermost/components'; +import type {RemoteCluster} from '@mattermost/types/remote_clusters'; + +import useCopyText, {messages as copymsg} from 'components/common/hooks/useCopyText'; +import LoadingScreen from 'components/loading_screen'; +import SectionNotice from 'components/section_notice'; +import Input from 'components/widgets/inputs/input/input'; + +import {Button, ModalFieldset, ModalNoticeWrapper, ModalParagraph} from '../controls'; + +type Props = { + creating?: boolean; + onConfirm: () => Promise<{remoteCluster: RemoteCluster; share: {invite: string; password: string}} | undefined>; + onCancel?: () => void; + onExited: () => void; +} + +const noop = () => {}; + +function SecureConnectionCreateInviteModal({ + creating, + onExited, + onCancel, + onConfirm, +}: Props) { + const {formatMessage} = useIntl(); + const [inviteCode, setInviteCode] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const {copiedRecently: inviteCopied, onClick: copyInvite} = useCopyText({text: inviteCode}); + const {copiedRecently: passwordCopied, onClick: copyPassword} = useCopyText({text: password}); + + useEffect(() => { + handleConfirm(); + }, []); + + const done = Boolean(inviteCode && password); + + const handleConfirm = async () => { + if (done) { + return; + } + + setLoading(true); + + const result = await onConfirm(); + setLoading(false); + + if (result) { + const {share} = result; + setInviteCode(share.invite); + setPassword(share.password); + } + }; + + const handlePasswordChange = (e: React.ChangeEvent) => { + setPassword(e.target.value); + }; + + let title = formatMessage({ + id: 'admin.secure_connections.create_invite.share_title', + defaultMessage: 'Invitation code', + }); + + if (creating) { + title = done ? formatMessage({ + id: 'admin.secure_connections.create_invite.create_title_done', + defaultMessage: 'Connection created', + }) : formatMessage({ + id: 'admin.secure_connections.create_invite.create_title', + defaultMessage: 'Create connection', + }); + } + + const message = ( + + ); + + const confirmButtonText = done ? formatMessage({ + id: 'admin.secure_connections.create_invite.confirm.done.button', + defaultMessage: 'Done', + }) : formatMessage({ + id: 'admin.secure_connections.create_invite.confirm.save.button', + defaultMessage: 'Save', + }); + + const notice = done ? ( + + + + ) : undefined; + + return ( + + {loading ? ( + + ) : ( + <> + {message} + {notice} + + {inviteCode && ( + + {inviteCopied ? copied : copy} + + )} + /> + )} + + {passwordCopied ? copied : copy} + + ) : undefined} + + /> + + + )} + + ); +} + +const copy = ( + <> + + + +); + +const copied = ( + <> + + + +); + +export default SecureConnectionCreateInviteModal; diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_delete_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_delete_modal.tsx new file mode 100644 index 0000000000..3617ceb87d --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_delete_modal.tsx @@ -0,0 +1,62 @@ +// 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 {GenericModal} from '@mattermost/components'; + +type Props = { + displayName: string; + onConfirm: () => void; + onCancel?: () => void; + onExited: () => void; +} + +const noop = () => {}; + +function SecureConnectionDeleteModal({ + displayName, + onExited, + onCancel, + onConfirm, +}: Props) { + const {formatMessage} = useIntl(); + + const title = formatMessage({ + id: 'admin.secure_connections.confirm.delete.title', + defaultMessage: 'Delete secure connection', + }); + + const confirmButtonText = formatMessage({ + id: 'admin.secure_connections.confirm.delete.button', + defaultMessage: 'Yes, delete', + }); + + const message = ( + {displayName}?'} + values={{ + strong: (chunk: string) => {chunk}, + displayName, + }} + /> + ); + + return ( + + {message} + + ); +} + +export default SecureConnectionDeleteModal; diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx new file mode 100644 index 0000000000..e7af5f5b4c --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx @@ -0,0 +1,331 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ComponentProps, DependencyList} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import styled from 'styled-components'; + +import {ArchiveOutlineIcon, GlobeIcon, LockIcon} from '@mattermost/compass-icons/components'; +import type IconProps from '@mattermost/compass-icons/components/props'; +import {GenericModal} from '@mattermost/components'; +import type {Channel, ChannelWithTeamData} from '@mattermost/types/channels'; +import type {ServerError} from '@mattermost/types/errors'; + +import {searchAllChannels} from 'mattermost-redux/actions/channels'; +import {getChannel} from 'mattermost-redux/selectors/entities/channels'; + +import SectionNotice from 'components/section_notice'; +import ChannelsInput from 'components/widgets/inputs/channels_input'; + +import {isArchivedChannel} from 'utils/channel_utils'; +import Constants from 'utils/constants'; + +import type {GlobalState} from 'types/store'; + +import type {SharedChannelsAddResult} from './modal_utils'; + +import {ModalBody, ModalParagraph} from '../controls'; +import {useSharedChannelRemotes} from '../utils'; + +type Props = { + onConfirm: (channels: Channel[]) => Promise; + onCancel?: () => void; + onExited: () => void; + remoteId: string; + onHide: () => void; +} + +const noop = () => {}; + +function SharedChannelsAddModal({ + onExited, + onCancel, + onConfirm, + onHide: close, + remoteId, +}: Props) { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + const [remotesByChannelId] = useSharedChannelRemotes(remoteId); + + const [query, setQuery] = useState(''); + const [channels, setChannelsInner] = useState([]); + const [errors, setErrors] = useState<{[channel_id: string]: ServerError}>(); + const [done, setDone] = useState(false); + + const setChannels = useCallback((nextChannels: ChannelWithTeamData[] | undefined) => { + setErrors((errs) => { + if (!errs || !nextChannels?.length) { + return undefined; + } + + // keep any errors for selected channels; discard errors of deselected channels + return nextChannels.reduce((nextErrs, {id}) => { + if (!errs[id]) { + return nextErrs; + } + return {...nextErrs, [id]: errs[id]}; + }, {}); + }); + + setChannelsInner(nextChannels ?? []); + setDone(false); + }, []); + + const loadChannels = useLatest(async (signal, query: string) => { + if (!query) { + return []; + } + + const {data} = await dispatch(searchAllChannels(query, {page: 0, per_page: 20, signal})); + if (data) { + return data.channels.filter(({id}) => { + const remote = remotesByChannelId?.[id]; + + if (remote && remote.delete_at === 0) { + // exclude channels already shared with this remote + return false; + } + + if (remote && remote.delete_at !== 0) { + // include channels previously shared with this remote + return true; + } + + // include channels never associated with this remote + return true; + }); + } + + return []; + }, [searchAllChannels, remotesByChannelId], {delay: TYPING_DELAY_MS}); + + const formatLabel: ComponentProps>['formatOptionLabel'] = (channel) => { + return ( + <> + + {'~'}{channel.name} + {channel.team_display_name} + + ); + }; + + const handleConfirm = async () => { + if (done) { + close(); + return; + } + + const {errors: errs} = await onConfirm(channels); + + if (Object.keys(errs).length) { + setErrors(errs); + setDone(true); + } else { + close(); + } + }; + + return ( + + )} + confirmButtonText={done ? ( + + ) : ( + + )} + handleCancel={onCancel ?? noop} + handleConfirm={handleConfirm} + autoCloseOnConfirmButton={false} + onExited={onExited} + compassDesign={true} + bodyPadding={false} + bodyOverflowVisible={true} + isConfirmDisabled={!channels.length} + > + + + + + } + ariaLabel={formatMessage({ + id: 'admin.secure_connections.shared_channels.add.input_label', + defaultMessage: 'Search and add channels', + })} + channelsLoader={loadChannels} + inputValue={query} + onInputChange={setQuery} + value={channels} + onChange={setChannels} + autoFocus={true} + formatOptionLabel={formatLabel} + /> + {errors && Object.entries(errors).map(([id, err]) => { + return ( + + ); + })} + + + ); +} + +const ChannelError = (props: {id: string; err: ServerError}) => { + const channel = useSelector((state: GlobalState) => getChannel(state, props.id)); + + const channelLabel = channel ? ( + + ) : props.id; + + let message = ( + + ); + + if (props.err.server_error_id === 'api.command_share.channel_invite_not_home.error') { + message = ( + + ); + } + + return ( + + ); +}; + +const ChannelLabelWrapper = styled.span` + svg { + vertical-align: middle; + margin-left: 6px; + margin-right: 10px; + } + + .channels-input__multi-value__label & { + font-weight: 600; + } +`; + +const ChannelLabel = ({channel, bold}: {channel: Channel; bold?: boolean}) => { + const ChannelDisplayName = bold ? 'strong' : 'span'; + + return ( + + + {channel?.display_name} + + ); +}; + +const ChannelIcon = ({channel, size = 16, ...otherProps}: {channel: Channel} & IconProps) => { + let Icon = GlobeIcon; + + if (channel?.type === Constants.PRIVATE_CHANNEL) { + Icon = LockIcon; + } + + if (isArchivedChannel(channel)) { + Icon = ArchiveOutlineIcon; + } + + return ( + + ); +}; + +const SecondaryTextRight = styled.span` + color: rgba(var(--center-channel-color-rgb), 0.64); + margin-left: 5px; + &:last-child { + margin-left: auto; + } +`; + +export default SharedChannelsAddModal; + +const TYPING_DELAY_MS = 250; + +/** + * Auto-cancels any prior func calls that are still pending + * @param func cancelable func; the provided signal will be aborted if any subsequent func calls are made + */ +export const useLatest = (func: (signal: AbortSignal, ...args: TArgs) => Promise, deps: DependencyList, opts?: {delay: number}) => { + const r = useRef<{controller: AbortController; handler?: NodeJS.Timeout}>(); + + const start = useCallback(() => { + r.current = {controller: new AbortController()}; + return r.current; + }, []); + + const cancel = useCallback(() => { + if (!r.current) { + return; + } + const {controller: abort, handler} = r.current; + abort.abort(new DOMException('stale request')); + if (handler) { + clearTimeout(handler); + } + + r.current = undefined; + }, []); + + useEffect(() => cancel, [cancel]); + + return useCallback(async (...args: TArgs) => { + cancel(); + const currentRequest = start(); + + return new Promise((resolve, reject) => { + currentRequest.handler = setTimeout(async () => { + func(currentRequest.controller.signal, ...args).then(resolve, reject); + }, opts?.delay || TYPING_DELAY_MS); + }); + }, [start, cancel, ...deps]); +}; diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_remove_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_remove_modal.tsx new file mode 100644 index 0000000000..3eb5bd72a1 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_remove_modal.tsx @@ -0,0 +1,60 @@ +// 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 {GenericModal} from '@mattermost/components'; + +import {ModalBody, ModalParagraph} from '../controls'; + +type Props = { + onConfirm: () => void; + onCancel?: () => void; + onExited: () => void; +} + +const noop = () => {}; + +function SharedChannelsRemoveModal({ + onExited, + onCancel, + onConfirm, +}: Props) { + const handleConfirm = () => { + onConfirm(); + }; + + return ( + + )} + handleCancel={onCancel ?? noop} + handleConfirm={handleConfirm} + confirmButtonText={( + + )} + onExited={onExited} + compassDesign={true} + isDeleteModal={true} + bodyPadding={false} + > + + + + + ); +} + +export default SharedChannelsRemoveModal; diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx new file mode 100644 index 0000000000..baea27fda6 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx @@ -0,0 +1,598 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createColumnHelper, getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef} from '@tanstack/react-table'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {SelectCallback} from 'react-bootstrap'; +import {Tabs, Tab} from 'react-bootstrap'; +import {useIntl, FormattedMessage} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import {useHistory, useParams, useLocation} from 'react-router-dom'; +import styled from 'styled-components'; + +import {GlobeIcon, LockIcon, PlusIcon, ArchiveOutlineIcon} from '@mattermost/compass-icons/components'; +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 {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams'; + +import {setNavigationBlocked} from 'actions/admin_actions'; + +import BlockableLink from 'components/admin_console/blockable_link'; +import LoadingScreen from 'components/loading_screen'; +import AdminHeader from 'components/widgets/admin_console/admin_header'; + +import {isArchivedChannel} from 'utils/channel_utils'; +import Constants from 'utils/constants'; + +import type {GlobalState} from 'types/store'; + +import ChatSvg from './chat.svg'; +import { + AdminSection, + SectionHeader, + SectionHeading, + SectionContent, + PlaceholderContainer, + PlaceholderHeading, + AdminWrapper, + PlaceholderParagraph, + Input, + FormField, + ConnectionStatusLabel, + LinkButton, +} from './controls'; +import {useRemoteClusterCreate, useSharedChannelsAdd, useSharedChannelsRemove} from './modals/modal_utils'; +import TeamSelector from './team_selector'; +import type {SharedChannelRemoteRow} from './utils'; +import {getEditLocation, isConfirmed, isErrorState, isPendingState, useRemoteClusterEdit, useSharedChannelRemoteRows} from './utils'; + +import {AdminConsoleListTable} from '../list_table'; +import SaveChangesPanel from '../team_channel_settings/save_changes_panel'; + +type Params = { + connection_id: 'create' | RemoteCluster['remote_id']; +}; + +type Props = Params & { + disabled: boolean; +} + +export default function SecureConnectionDetail(props: Props) { + const {formatMessage} = useIntl(); + const {connection_id: remoteId} = useParams(); + const isCreating = remoteId === 'create'; + const {state: initRemoteCluster, ...location} = useLocation(); + const history = useHistory(); + const dispatch = useDispatch(); + + const [remoteCluster, {applyPatch, save, currentRemoteCluster, hasChanges, loading, saving, patch}] = useRemoteClusterEdit(remoteId, initRemoteCluster); + const isFormValid = isRemoteClusterPatch(patch) && (!isCreating || Boolean(patch.display_name && patch.default_team_id)); + + const {promptCreate, saving: creating} = useRemoteClusterCreate(); + + useEffect(() => { + // keep history cache up to date + history.replace({...location, state: currentRemoteCluster}); + }, [currentRemoteCluster]); + + useEffect(() => { + // block nav when changes are pending + dispatch(setNavigationBlocked(hasChanges)); + }, [hasChanges]); + + const handleNameChange = ({currentTarget: {value}}: React.FormEvent) => { + 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}); + }; + + const handleCreate = async () => { + if (!isFormValid) { + return; + } + const rc = await promptCreate(patch); + if (rc) { + history.replace(getEditLocation(rc)); + } + }; + + return ( +
+ +
+ + +
+
+ + + + +
+ + +
+ {currentRemoteCluster && } +
+ + {isPendingState(loading) ? ( + + ) : ( + <> + + + + + + + + )} + +
+ {!isCreating && ( + + + + )} +
+ + + ) : undefined} + savingMessage={formatMessage({id: 'admin.secure_connections.details.saving_changes', defaultMessage: 'Saving secure connection…'})} + isDisabled={props.disabled} + /> +
+ ); +} + +function SharedChannelRemotes(props: {remoteId: string; rc: RemoteCluster | undefined}) { + const [filter, setFilter] = useState<'home' | 'remote'>(); + const [data, {loading, fetch}] = useSharedChannelRemoteRows(props.remoteId, {filter}); + const {promptAdd} = useSharedChannelsAdd(props.remoteId); + const confirmed = props.rc ? isConfirmed(props.rc) : undefined; + const showTabs = confirmed ? true : !(confirmed === false && filter === 'home' && !data); + + useEffect(() => { + //once we know confirmation status, set default filter/tab + if (confirmed) { + setFilter('remote'); + } else if (confirmed === false) { + setFilter('home'); + } + }, [confirmed]); + + const handleChangeTab = useCallback((tabKey) => { + setFilter(tabKey); + }, []); + + const handleAdd = async () => { + await promptAdd(); + + // TODO server side async + setTimeout(() => { + if (filter === 'remote') { + setFilter('home'); + } else { + fetch(); + } + }, 500); + }; + + let content; + + if (loading || !props.rc) { + content = ; + } else if (data) { + content = ( + + ); + } else { + content = ( + + ); + } + + return ( + <> + +
+ + +
+ + + + +
+ + {showTabs && ( + + + + )} + /> + + )} + + {content} + + + + ); +} + +const Placeholder = (props: {filter: 'home' | 'remote'; rc: RemoteCluster}) => { + return ( + + +
+ {props.filter === 'home' ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +}; + +const AddChannelsButton = styled.button.attrs({className: 'btn btn-primary'})` + padding-left: 15px; +`; + +const TabsWrapper = styled.div` + .tabs { + display: flex; + width: 100%; + flex-direction: column; + + .nav-tabs { + border-bottom: 1px solid var(--center-channel-color-12, rgba(63, 67, 80, 0.12)); + } + } + + .nav-tabs { + padding: 0 32px; + margin: 0 0 8px; + + li { + margin-right: 0; + + a { + padding: 13px 12px; + border: none; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 14px; + font-weight: 600; + line-height: 20px; + transition: all 0.15s ease; + + &:hover, + &:active, + &:focus, + &:focus-within { + border: none; + border-radius: none; + background: transparent; + color: var(--center-channel-color); + } + } + + &.active { + border-bottom: 2px solid var(--denim-button-bg); + + a { + color: var(--denim-button-bg); + } + } + + &:not(:first-child) { + margin-left: 8px; + } + } + } +`; + +const ChannelIcon = ({channelId}: {channelId: string}) => { + const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); + let icon = ; + + if (channel?.type === Constants.PRIVATE_CHANNEL) { + icon = ; + } + + if (isArchivedChannel(channel)) { + icon = ; + } + + return ( + + {icon} + + ); +}; + +const ChannelIconWrapper = styled.span` + vertical-align: middle; + margin-right: 5px; +`; + +const ChannelName = styled.span` + font-size: 14px; + font-weight: 600; + line-height: 20px; +`; + +const TeamName = styled.span` + font-size: 14px; + font-weight: 400; + line-height: 20px; + color: rgba(var(--center-channel-color-rgb), 0.72); +`; + +function SharedChannelRemotesTable(props: {data: SharedChannelRemoteRow[]; filter: 'home' | 'remote'; fetch: () => void}) { + const col = createColumnHelper(); + + const columns = useMemo>>(() => { + return [ + col.accessor('display_name', { + header: () => ( + + ), + cell: ({row, getValue}) => ( + <> + + {getValue()} + + ), + enableHiding: false, + enableSorting: true, + }), + col.accessor('team_display_name', { + header: () => { + if (props.filter === 'home') { + return ( + + ); + } + + return ( + + ); + }, + cell: ({getValue}) => ( + + {getValue()} + + ), + enableHiding: false, + enableSorting: true, + }), + col.display({ + id: 'actions', + cell: ({row}) => ( + + ), + enableHiding: false, + enableSorting: false, + }), + ]; + }, [props.data, props.filter, props.fetch]); + + const table = useReactTable({ + data: props.data, + columns, + initialState: { + sorting: [ + { + id: 'display_name', + desc: false, + }, + ], + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSortingRemoval: false, + enableMultiSort: false, + renderFallbackValue: '', + meta: { + tableId: 'sharedChannelRemotes', + disablePaginationControls: true, + }, + manualPagination: true, + }); + + // TODO consider refactoring ChannelList to support shared channel actions and reuse here + return ( + + table={table}/> + + ); +} + +const TableWrapper = styled.div` + table.adminConsoleListTable { + + td, th { + &:after, &:before { + display: none; + } + } + + thead { + border-top: none; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + } + + tbody { + tr { + border-top: none; + td { + padding-block-end: 0; + padding-block-start: 0; + + } + } + } + + tfoot { + border-top: none; + } + } + .adminConsoleListTableContainer { + padding: 2px 0px; + } +`; + +const RemoteActions = ({remote, fetch}: {remote: SharedChannelRemoteRow; fetch: () => void}) => { + const {promptRemove} = useSharedChannelsRemove(remote.remote_id); + + const handleRemove = () => { + promptRemove(remote.channel_id).then(fetch); + }; + + return ( + + + + + + ); +}; + +const RemoteActionsRoot = styled.div` + text-align: right; +`; diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_row.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_row.tsx new file mode 100644 index 0000000000..f07b1fa996 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_row.tsx @@ -0,0 +1,142 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {Link, useHistory} from 'react-router-dom'; +import styled from 'styled-components'; + +import {DotsHorizontalIcon, CodeTagsIcon, PencilOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components'; +import type {RemoteCluster} from '@mattermost/types/remote_clusters'; + +import * as Menu from 'components/menu'; + +import {ConnectionStatusLabel} from './controls'; +import {useRemoteClusterCreateInvite, useRemoteClusterDelete} from './modals/modal_utils'; +import {getEditLocation, isConfirmed} from './utils'; + +type Props = { + remoteCluster: RemoteCluster; + onDeleteSuccess: () => void; + disabled: boolean; +}; + +export default function SecureConnectionRow(props: Props) { + const {remoteCluster: rc} = props; + + return ( + + {rc.display_name} + + + + + + ); +} + +const menuId = 'secure_connection_row_menu'; + +const RowMenu = ({remoteCluster: rc, onDeleteSuccess, disabled}: Props) => { + const {formatMessage} = useIntl(); + const history = useHistory(); + const {promptDelete} = useRemoteClusterDelete(rc); + const {promptCreateInvite} = useRemoteClusterCreateInvite(rc); + + const handleCreateInvite = () => { + promptCreateInvite(); + }; + + const handleEdit = () => { + history.push(getEditLocation(rc)); + }; + + const handleDelete = () => { + promptDelete().then(onDeleteSuccess); + }; + + return ( + , + }} + menu={{ + id: menuId, + 'aria-label': formatMessage({id: 'admin.secure_connection_row.menu.aria_label', defaultMessage: 'secure connection row menu'}), + }} + > + {!isConfirmed(rc) && ( + } + labels={( + + )} + onClick={handleCreateInvite} + /> + )} + } + labels={( + + )} + onClick={handleEdit} + /> + } + labels={( + + )} + onClick={handleDelete} + /> + + ); +}; + +const RowLink = styled(Link).attrs({className: 'secure-connection'})` + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 35px; + border-bottom: 1px solid var(--center-channel-color-12, rgba(63, 67, 80, 0.12)); + color: var(--center-channel-color); + + &:hover { + text-decoration: none; + color: var(--center-channel-color); + } + + &:last-child { + border-bottom: 0; + } + + #${menuId}-button { + padding: 0px 8px; + } +`; + +const Title = styled.strong` + font-size: 14px; +`; + +const Detail = styled.div` + display: flex; + gap: 20px; + align-items: center; +`; diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connections.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connections.tsx new file mode 100644 index 0000000000..53a4c58ec7 --- /dev/null +++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connections.tsx @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import type {ReactNode} from 'react'; +import React from 'react'; +import {useIntl, FormattedMessage, defineMessages} from 'react-intl'; +import {useHistory} from 'react-router-dom'; + +import LoadingScreen from 'components/loading_screen'; +import * as Menu from 'components/menu'; +import SectionNotice from 'components/section_notice'; +import AdminHeader from 'components/widgets/admin_console/admin_header'; + +import BuildingSvg from './building.svg'; +import {AdminSection, SectionHeader, SectionHeading, SectionContent, PlaceholderContainer, PlaceholderHeading} from './controls'; +import {useRemoteClusterAcceptInvite} from './modals/modal_utils'; +import SecureConnectionRow from './secure_connection_row'; +import {getCreateLocation, getEditLocation, useRemoteClusters} from './utils'; + +import type {SearchableStrings} from '../types'; + +export default function SecureConnections() { + const [remoteClusters, {loading, error, fetch}] = useRemoteClusters(); + + const serviceNotRunning = error?.server_error_id === 'api.remote_cluster.service_not_enabled.app_error'; + const disabled = loading || serviceNotRunning; + + const placeholder = loading ? : ( + + ); + + return ( +
+ + + + + + +
+ + +
+ +
+ {remoteClusters?.map((rc) => { + return ( + + ); + }) ?? placeholder} +
+
+
+ ); +} + +const AdminWrapper = (props: {children: ReactNode}) => { + return ( +
+
+ {props.children} +
+
+ ); +}; + +const Placeholder = ({disabled, serviceNotRunning}: {disabled: boolean; serviceNotRunning: boolean}) => { + return ( + + {serviceNotRunning && ( + + )} + /> + )} + + +
+ + +
+ +
+
+ ); +}; + +const menuId = 'secure_connections_add_menu'; + +const AddMenu = ({buttonClassNames, disabled}: {buttonClassNames?: string; disabled: boolean}) => { + const {formatMessage} = useIntl(); + const history = useHistory(); + const {promptAcceptInvite} = useRemoteClusterAcceptInvite(); + + const handleCreate = () => { + history.push(getCreateLocation()); + }; + + const handleAccept = async () => { + const rc = await promptAcceptInvite(); + if (rc) { + history.push(getEditLocation(rc)); + } + }; + + return ( + + + {!disabled && ( +