MM-58349: Shared Channels in System Console (#28116)

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Miguel de la Cruz <miguel@mcrx.me>
This commit is contained in:
Caleb Roseland
2024-10-09 07:53:19 -05:00
committed by GitHub
parent 02e6851603
commit 0f200adbe0
39 changed files with 3338 additions and 96 deletions

View File

@@ -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":

View File

@@ -753,4 +753,10 @@ const defaultServerConfig: AdminConfig = {
MoveThreadFromDirectMessageChannelEnable: false,
MoveThreadFromGroupMessageChannelEnable: false,
},
ConnectedWorkspacesSettings: {
EnableSharedChannels: false,
EnableRemoteClusterService: false,
DisableSharedChannelsStatusSync: false,
MaxPostsPerSync: 50,
},
};

View File

@@ -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)
}

View File

@@ -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: {

View File

@@ -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<TableType extends TableMandatoryTypes>(
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<TableType extends TableMandatoryTypes>(
return (
<div className='adminConsoleListTableContainer'>
<div className='adminConsoleListTabletOptionalHead'>
{tableMeta.hasDualSidedPagination && (
<>
{tableMeta.paginationInfo}
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.loadingState === LoadingStates.Loading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
/>
</>
)}
</div>
{hasPagination && (
<div className='adminConsoleListTabletOptionalHead'>
{tableMeta.hasDualSidedPagination && (
<>
{tableMeta.paginationInfo}
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.loadingState === LoadingStates.Loading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
/>
</>
)}
</div>
)}
<table
id={tableMeta.tableId}
aria-colcount={colCount}
@@ -269,9 +274,9 @@ export function ListTable<TableType extends TableMandatoryTypes>(
))}
</tfoot>
</table>
<div className='adminConsoleListTabletOptionalFoot'>
{tableMeta.paginationInfo}
{handlePageSizeChange && (
{hasPagination && (
<div className='adminConsoleListTabletOptionalFoot'>
{tableMeta.paginationInfo}
<div
className='adminConsoleListTablePageSize'
aria-label={formatMessage({id: 'adminConsole.list.table.rowCount.label', defaultMessage: 'Show {count} rows per page'}, {count: selectedPageSize.label})}
@@ -302,15 +307,16 @@ export function ListTable<TableType extends TableMandatoryTypes>(
defaultMessage='rows per page'
/>
</div>
)}
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.loadingState === LoadingStates.Loading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
/>
</div>
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.loadingState === LoadingStates.Loading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
/>
</div>
)}
</div>
);
}

View File

@@ -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 (
<svg
width='133'
height='106'
viewBox='0 0 133 106'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<rect
x='32.5'
y='0.5'
width='68.5'
height='105'
rx='4'
fill='#FFBC1F'
/>
<rect
x='0.5'
y='37'
width='132.5'
height='68.5'
rx='4'
fill='#CC8F00'
/>
<path
d='M101 3.32155V103.178C100.83 104.887 100.246 105.827 99.2368 106H34.5181C33.3391 105.827 32.6699 104.887 32.5 103.178V3.32155C32.6699 1.61349 33.3391 0.672969 34.5181 0.5H99.2368C100.246 0.672969 100.83 1.61349 101 3.32155Z'
fill='#FFBC1F'
/>
<path
d='M80.0115 79.0244H53.7559V105.989H80.0115V79.0244Z'
fill='#E8E9ED'
/>
<rect
x='30.5'
y='0.5'
width='72.5'
height='7'
rx='1.5'
fill='#1E325C'
/>
<rect
x='51'
y='75.5'
width='31.5'
height='7'
rx='1.5'
fill='#1E325C'
/>
<rect
x='66'
y='82'
width='2'
height='24'
fill='#1E325C'
/>
<rect
x='6.5'
y='49'
width='8'
height='15.5'
rx='1.5'
fill='#7A5600'
/>
<rect
x='38'
y='14'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='73'
y='14'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='38'
y='38'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='38'
y='62'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='38'
y='75.5'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='38'
y='89'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='73'
y='38'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='73'
y='62'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='51'
y='14'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='86'
y='14'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='51'
y='38'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='51'
y='62'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='86'
y='38'
width='9'
height='19'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='86'
y='62'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='86'
y='75.5'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='86'
y='89'
width='9'
height='9'
rx='1.5'
fill='#CC8F00'
/>
<rect
x='107'
y='49'
width='8'
height='15.5'
rx='1.5'
fill='#7A5600'
/>
<rect
x='6.5'
y='70.5'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
<rect
x='107'
y='70.5'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
<rect
x='6.5'
y='85'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
<rect
x='107'
y='85'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
<rect
x='18.5'
y='49'
width='8'
height='15.5'
rx='1.5'
fill='#7A5600'
/>
<rect
x='119'
y='49'
width='8'
height='15.5'
rx='1.5'
fill='#7A5600'
/>
<rect
x='18.5'
y='70.5'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
<rect
x='119'
y='70.5'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
<rect
x='18.5'
y='85'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
<rect
x='119'
y='85'
width='8'
height='8'
rx='1.5'
fill='#7A5600'
/>
</svg>
);
}
export default BuildingSvg;

View File

@@ -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 (
<svg
width='106'
height='96'
viewBox='0 0 106 96'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g clipPath='url(#clip0_2795_18729)'>
<path
d='M53.3987 14.3074H96.044C97.3213 14.2983 98.5878 14.5437 99.7708 15.0297C100.954 15.5156 102.03 16.2324 102.938 17.139C103.846 18.0455 104.567 19.124 105.06 20.3125C105.554 21.501 105.81 22.7762 105.814 24.0648V68.6825C105.809 69.9703 105.552 71.2443 105.058 72.4317C104.564 73.6191 103.842 74.6964 102.934 75.6019C102.027 76.5074 100.951 77.2234 99.7686 77.7087C98.5862 78.194 97.3205 78.4391 96.044 78.43H85.7857V95.1851L70.3787 78.43H53.3987C52.1223 78.4391 50.8565 78.194 49.6742 77.7087C48.4918 77.2234 47.4159 76.5074 46.5083 75.6019C45.6007 74.6964 44.8792 73.6191 44.3851 72.4317C43.891 71.2443 43.634 69.9703 43.6289 68.6825V24.0648C43.6327 22.7762 43.8888 21.501 44.3823 20.3125C44.8758 19.124 45.5972 18.0455 46.5049 17.139C47.4126 16.2324 48.4889 15.5156 49.6719 15.0297C50.8549 14.5437 52.1214 14.2983 53.3987 14.3074Z'
fill='#1E325C'
/>
<path
d='M79.4755 0.893798H9.91436C8.63706 0.884709 7.37058 1.13017 6.18755 1.61609C5.00453 2.10201 3.92826 2.81882 3.02053 3.72539C2.1128 4.63196 1.39147 5.71046 0.89793 6.89897C0.404391 8.08747 0.148358 9.36258 0.144531 10.6512V55.2295C0.149641 56.5173 0.40662 57.7913 0.900733 58.9787C1.39485 60.1661 2.11639 61.2434 3.02399 62.1489C3.93159 63.0544 5.0074 63.7704 6.18979 64.2557C7.37218 64.741 8.63788 64.9861 9.91436 64.977H20.1825V81.7321L35.5895 64.977H79.4755C80.752 64.9861 82.0177 64.741 83.2001 64.2557C84.3824 63.7704 85.4583 63.0544 86.3659 62.1489C87.2735 61.2434 87.995 60.1661 88.4891 58.9787C88.9833 57.7913 89.2402 56.5173 89.2453 55.2295V10.6512C89.2415 9.36258 88.9855 8.08747 88.4919 6.89897C87.9984 5.71046 87.2771 4.63196 86.3693 3.72539C85.4616 2.81882 84.3853 2.10201 83.2023 1.61609C82.0193 1.13017 80.7528 0.884709 79.4755 0.893798Z'
fill='#FFBC1F'
/>
<path
d='M20.9636 26.5684C22.2031 26.5664 23.4152 26.9355 24.4465 27.629C25.4779 28.3224 26.2821 29.309 26.7573 30.4638C27.2325 31.6186 27.3573 32.8897 27.116 34.1161C26.8746 35.3425 26.278 36.4691 25.4016 37.3533C24.5252 38.2374 23.4085 38.8393 22.1928 39.0828C20.977 39.3262 19.717 39.2003 18.5723 38.7209C17.4276 38.2415 16.4497 37.4302 15.7623 36.3898C15.0749 35.3493 14.709 34.1265 14.7109 32.8762C14.7135 31.2041 15.3731 29.6011 16.5452 28.4188C17.7172 27.2364 19.3061 26.571 20.9636 26.5684Z'
fill='white'
/>
<path
d='M44.7136 26.5684C45.9531 26.5664 47.1652 26.9355 48.1965 27.629C49.2279 28.3224 50.0321 29.309 50.5073 30.4638C50.9825 31.6186 51.1073 32.8897 50.866 34.1161C50.6247 35.3425 50.028 36.4691 49.1516 37.3533C48.2752 38.2374 47.1585 38.8393 45.9428 39.0828C44.727 39.3262 43.467 39.2003 42.3223 38.7209C41.1776 38.2415 40.1997 37.4302 39.5123 36.3898C38.8249 35.3493 38.459 34.1265 38.4609 32.8762C38.4635 31.2041 39.1231 29.6011 40.2952 28.4188C41.4672 27.2364 43.0561 26.571 44.7136 26.5684Z'
fill='white'
/>
<path
d='M68.4636 26.5684C69.703 26.5664 70.9152 26.9355 71.9465 27.629C72.9779 28.3224 73.7821 29.309 74.2573 30.4638C74.7325 31.6186 74.8573 32.8897 74.616 34.1161C74.3747 35.3425 73.778 36.4691 72.9016 37.3533C72.0252 38.2374 70.9085 38.8393 69.6928 39.0828C68.477 39.3262 67.2171 39.2003 66.0724 38.7209C64.9276 38.2415 63.9497 37.4302 63.2623 36.3898C62.5749 35.3493 62.209 34.1265 62.2109 32.8762C62.2135 31.2041 62.8731 29.6011 64.0452 28.4188C65.2172 27.2364 66.8061 26.571 68.4636 26.5684Z'
fill='white'
/>
</g>
<defs>
<clipPath id='clip0_2795_18729'>
<rect
width='105.553'
height='94.2128'
fill='white'
transform='translate(0.222656 0.893555)'
/>
</clipPath>
</defs>
</svg>
);
}
export default ChatSvg;

View File

@@ -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 (
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{props.children}
</div>
</div>
);
};
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 (
<FormFieldLabel>
{label && <InnerLabel>{label}</InnerLabel>}
{children}
{helpText && <HelpText>{helpText}</HelpText>}
</FormFieldLabel>
);
};
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 (
<ModalFieldsetWrapper>
{props.legend && <ModalLegend>{props.legend}</ModalLegend>}
{props.children}
</ModalFieldsetWrapper>
);
};
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 (
<FormattedMessage
tagName={PendingConnectionLabel}
id='admin.secure_connections.status_pending'
defaultMessage='Connection Pending'
/>
);
}
const status = isConnected(rc) ? (
<FormattedMessage
tagName={ConnectedLabel}
id='admin.secure_connections.status_connected'
defaultMessage='Connected'
/>
) : (
<FormattedMessage
tagName={OfflineConnectionLabel}
id='admin.secure_connections.status_offline'
defaultMessage='Offline'
/>
);
if (!rc.last_ping_at) {
return status;
}
return (
<WithTooltip
id='connection-status-tooltip'
placement='top'
title={(
<>
<FormattedMessage
id='admin.secure_connections.status_tooltip'
defaultMessage='Last ping: {timestamp}'
values={{
timestamp: (
<Timestamp
value={rc.last_ping_at}
ranges={LASTSYNC_TOOLTIP_RANGES}
/>
),
}}
/>
<br/>
<UrlWrapper>
{rc.site_url}
</UrlWrapper>
</>
)}
>
<div>
{status}
</div>
</WithTooltip>
);
};
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;
`;

View File

@@ -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;

View File

@@ -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<TLoadingState>(false);
const promptCreate = (patch: RemoteClusterPatch) => {
return new Promise<RemoteCluster | undefined>((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<TLoadingState>(false);
const promptCreateInvite = () => {
return new Promise<RemoteCluster>((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<TLoadingState>(false);
const promptAcceptInvite = () => {
return new Promise<RemoteCluster>((resolve, reject) => {
dispatch(openModal({
modalId: ModalIdentifiers.SECURE_CONNECTION_ACCEPT_INVITE,
dialogType: SecureConnectionAcceptInviteModal,
dialogProps: {
onConfirm: async (acceptInvite: PartialExcept<RemoteClusterAcceptInvite, 'display_name' | 'invite' | 'password'>) => {
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<StatusOK>};
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};
};

View File

@@ -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<RemoteClusterAcceptInvite, 'display_name' | 'invite' | 'password'>) => Promise<RemoteCluster>;
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<boolean | ClientError>(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<HTMLInputElement>) => {
setDisplayName(e.target.value);
};
const handleInviteCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInviteCode(e.target.value);
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<GenericModal
confirmButtonText={confirmButtonText}
isConfirmDisabled={!formFilled || isPendingState(saving)}
handleCancel={onCancel ?? noop}
handleConfirm={handleConfirm}
handleEnterKeyPress={handleConfirm}
modalHeaderText={title}
onExited={onExited}
compassDesign={true}
autoCloseOnConfirmButton={false}
errorText={isErrorState(saving) && (
<FormattedMessage
id='admin.secure_connections.accept_invite.saving_changes_error'
defaultMessage='There was an error while accepting the invite.'
/>
)}
>
{isPendingState(saving) ? (
<LoadingScreen/>
) : (
<>
<FormattedMessage
id={'admin.secure_connections.accept_invite.prompt'}
defaultMessage={'Accept a secure connection from another server'}
tagName={ModalParagraph}
/>
<ModalFieldset>
<Input
type='text'
name='display-name'
containerClassName='secure-connections-modal-input'
placeholder={formatMessage({
id: 'admin.secure_connections.accept_invite.organization_name',
defaultMessage: 'Organization name',
})}
value={displayName}
onChange={handleDisplayNameChange}
data-testid='display-name'
/>
<FormattedMessage
id={'admin.secure_connections.accept_invite.prompt_invite_password'}
defaultMessage={'Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.'}
tagName={ModalParagraph}
/>
<Input
type='text'
name='invite-code'
containerClassName='secure-connections-modal-input'
placeholder={formatMessage({
id: 'admin.secure_connections.accept_invite.invite_code',
defaultMessage: 'Encrypted invitation code',
})}
value={inviteCode}
onChange={handleInviteCodeChange}
data-testid='invite-code'
/>
<Input
type='text'
name='password'
containerClassName='secure-connections-modal-input'
placeholder={formatMessage({
id: 'admin.secure_connections.accept_invite.password',
defaultMessage: 'Password',
})}
value={password}
onChange={handlePasswordChange}
data-testid='password'
/>
</ModalFieldset>
</>
)}
</GenericModal>
);
}
export default SecureConnectionAcceptInviteModal;

View File

@@ -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<HTMLInputElement>) => {
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 = (
<FormattedMessage
id={'admin.secure_connections.create_invite.share.message'}
defaultMessage={'Please share the invitation code and password with the administrator of the server you want to connect with.'}
tagName={ModalParagraph}
/>
);
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 ? (
<ModalNoticeWrapper>
<SectionNotice
title={formatMessage({
id: 'admin.secure_connections.create_invite.create_invite.notice.title',
defaultMessage: 'Share these two separately to avoid a security compromise',
})}
type='warning'
/>
</ModalNoticeWrapper>
) : undefined;
return (
<GenericModal
confirmButtonText={confirmButtonText}
isConfirmDisabled={!done}
handleCancel={onCancel ?? noop}
handleConfirm={handleConfirm}
handleEnterKeyPress={handleConfirm}
modalHeaderText={title}
onExited={onExited}
compassDesign={true}
autoCloseOnConfirmButton={done}
backdrop='static'
>
{loading ? (
<LoadingScreen/>
) : (
<>
{message}
{notice}
<ModalFieldset
legend={done ? formatMessage({
id: 'admin.secure_connections.create_invite.share.label',
defaultMessage: 'Share this code and password',
}) : undefined}
>
{inviteCode && (
<Input
type='text'
name='invite-code'
containerClassName='secure-connections-modal-input'
placeholder={formatMessage({
id: 'admin.secure_connections.create_invite.share.invite_code',
defaultMessage: 'Encrypted invitation code',
})}
value={inviteCode}
data-testid='invite-code'
readOnly={true}
addon={(
<Button onClick={copyInvite}>
{inviteCopied ? copied : copy}
</Button>
)}
/>
)}
<Input
type='text'
name='password'
containerClassName='secure-connections-modal-input'
placeholder={formatMessage({
id: 'admin.secure_connections.create_invite.share.password',
defaultMessage: 'Password',
})}
value={password}
onChange={handlePasswordChange}
data-testid='password'
readOnly={done}
addon={done ? (
<Button onClick={copyPassword}>
{passwordCopied ? copied : copy}
</Button>
) : undefined}
/>
</ModalFieldset>
</>
)}
</GenericModal>
);
}
const copy = (
<>
<ContentCopyIcon size={18}/>
<FormattedMessage {...copymsg.copy}/>
</>
);
const copied = (
<>
<CheckIcon size={18}/>
<FormattedMessage {...copymsg.copied}/>
</>
);
export default SecureConnectionCreateInviteModal;

View File

@@ -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 = (
<FormattedMessage
id={'admin.secure_connections.confirm.delete.text'}
defaultMessage={'Are you sure you want to delete the secure connection <strong>{displayName}</strong>?'}
values={{
strong: (chunk: string) => <strong>{chunk}</strong>,
displayName,
}}
/>
);
return (
<GenericModal
confirmButtonText={confirmButtonText}
handleCancel={onCancel ?? noop}
handleConfirm={onConfirm}
modalHeaderText={title}
onExited={onExited}
compassDesign={true}
isDeleteModal={true}
>
{message}
</GenericModal>
);
}
export default SecureConnectionDeleteModal;

View File

@@ -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<SharedChannelsAddResult>;
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<ChannelWithTeamData[]>([]);
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<typeof errors>((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<typeof ChannelsInput<ChannelWithTeamData>>['formatOptionLabel'] = (channel) => {
return (
<>
<ChannelLabel channel={channel}/>
<SecondaryTextRight className='selected-hidden'>{'~'}{channel.name}</SecondaryTextRight>
<SecondaryTextRight className='selected-hidden'>{channel.team_display_name}</SecondaryTextRight>
</>
);
};
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 (
<GenericModal
modalHeaderText={(
<FormattedMessage
id='admin.secure_connections.shared_channels.add.title'
defaultMessage='Select channels'
/>
)}
confirmButtonText={done ? (
<FormattedMessage
id='admin.secure_connections.shared_channels.add.close.button'
defaultMessage='Close'
/>
) : (
<FormattedMessage
id='admin.secure_connections.shared_channels.add.confirm.button'
defaultMessage='Share'
/>
)}
handleCancel={onCancel ?? noop}
handleConfirm={handleConfirm}
autoCloseOnConfirmButton={false}
onExited={onExited}
compassDesign={true}
bodyPadding={false}
bodyOverflowVisible={true}
isConfirmDisabled={!channels.length}
>
<ModalBody>
<FormattedMessage
tagName={ModalParagraph}
id={'admin.secure_connections.shared_channels.add.message'}
defaultMessage={'Please select a team and channels to share'}
/>
<ChannelsInput
placeholder={
<FormattedMessage
id='admin.secure_connections.shared_channels.add.input_placeholder'
defaultMessage='e.g. {channel_name}'
values={{channel_name: Constants.DEFAULT_CHANNEL_UI_NAME}}
/>
}
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 (
<ChannelError
key={id}
id={id}
err={err}
/>
);
})}
</ModalBody>
</GenericModal>
);
}
const ChannelError = (props: {id: string; err: ServerError}) => {
const channel = useSelector((state: GlobalState) => getChannel(state, props.id));
const channelLabel = channel ? (
<ChannelLabel
bold={true}
channel={channel}
/>
) : props.id;
let message = (
<FormattedMessage
id='admin.secure_connections.shared_channels.add.error.inviting_remote_to_channel'
defaultMessage='{channel} could not be added to this connection.'
values={{channel: channelLabel}}
/>
);
if (props.err.server_error_id === 'api.command_share.channel_invite_not_home.error') {
message = (
<FormattedMessage
id='admin.secure_connections.shared_channels.add.error.channel_invite_not_home'
defaultMessage='{channel} could not be added to this connection because it originates from another connection.'
values={{channel: channelLabel}}
/>
);
}
return (
<SectionNotice
title={message}
type='danger'
/>
);
};
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 (
<ChannelLabelWrapper>
<ChannelIcon
channel={channel}
size={20}
color='rgba(var(--center-channel-color-rgb), 0.64)'
/>
<ChannelDisplayName>{channel?.display_name}</ChannelDisplayName>
</ChannelLabelWrapper>
);
};
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 (
<Icon
size={size}
{...otherProps}
/>
);
};
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 = <TArgs extends unknown[], TResult>(func: (signal: AbortSignal, ...args: TArgs) => Promise<TResult>, 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<TResult>((resolve, reject) => {
currentRequest.handler = setTimeout(async () => {
func(currentRequest.controller.signal, ...args).then(resolve, reject);
}, opts?.delay || TYPING_DELAY_MS);
});
}, [start, cancel, ...deps]);
};

View File

@@ -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 (
<GenericModal
modalHeaderText={(
<FormattedMessage
id='admin.secure_connections.shared_channels.confirm.remove.title'
defaultMessage='Remove channel'
/>
)}
handleCancel={onCancel ?? noop}
handleConfirm={handleConfirm}
confirmButtonText={(
<FormattedMessage
id='admin.secure_connections.shared_channels.confirm.remove.button'
defaultMessage='Remove'
/>
)}
onExited={onExited}
compassDesign={true}
isDeleteModal={true}
bodyPadding={false}
>
<ModalBody>
<FormattedMessage
tagName={ModalParagraph}
id={'admin.secure_connections.shared_channels.confirm.remove.message'}
defaultMessage={'The channel will be removed from this connection and will no longer be shared with it.'}
/>
</ModalBody>
</GenericModal>
);
}
export default SharedChannelsRemoveModal;

View File

@@ -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<Params>();
const isCreating = remoteId === 'create';
const {state: initRemoteCluster, ...location} = useLocation<RemoteCluster | undefined>();
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<HTMLInputElement>) => {
applyPatch({display_name: value});
};
const teams = useSelector(getActiveTeamsList);
const teamsById = useMemo(() => teams.reduce<IDMappedObjects<Team>>((teams, team) => ({...teams, [team.id]: team}), {}), [teams]);
const handleTeamChange = (teamId: string) => {
applyPatch({default_team_id: teamId});
};
const handleCreate = async () => {
if (!isFormValid) {
return;
}
const rc = await promptCreate(patch);
if (rc) {
history.replace(getEditLocation(rc));
}
};
return (
<div
className='wrapper--fixed'
data-testid='connectedOrganizationDetailsSection'
>
<AdminHeader withBackButton={true}>
<div>
<BlockableLink
to='/admin_console/environment/secure_connections'
className='fa fa-angle-left back'
/>
<FormattedMessage
id='admin.secure_connection_detail.page_title'
defaultMessage='Connection Configuration'
/>
</div>
</AdminHeader>
<AdminWrapper>
<AdminSection data-testid='connection_detail_section'>
<SectionHeader>
<hgroup>
<FormattedMessage
tagName={SectionHeading}
id='admin.secure_connections.details.title'
defaultMessage='Connection Details'
/>
<FormattedMessage
id='admin.secure_connections.details.subtitle'
defaultMessage='Connection name and other permissions'
/>
</hgroup>
{currentRemoteCluster && <ConnectionStatusLabel rc={currentRemoteCluster}/>}
</SectionHeader>
<SectionContent $compact={true}>
{isPendingState(loading) ? (
<LoadingScreen/>
) : (
<>
<FormField
label={formatMessage({
id: 'admin.secure_connections.details.org_name.label',
defaultMessage: 'Organization Name',
})}
helpText={formatMessage({
id: 'admin.secure_connections.details.org_name.help',
defaultMessage: 'Giving the connection a recognizable name will help you remember its purpose.',
})}
>
<Input
type='text'
value={remoteCluster?.display_name ?? ''}
onChange={handleNameChange}
autoFocus={isCreating}
/>
</FormField>
<FormField
label={formatMessage({
id: 'admin.secure_connections.details.team.label',
defaultMessage: 'Destination Team',
})}
helpText={formatMessage({
id: 'admin.secure_connections.details.team.help',
defaultMessage: 'Select the default team in which any shared channels will be placed. This can be updated later for specific shared channels.',
})}
>
<TeamSelector
value={remoteCluster.default_team_id ?? ''}
teamsById={teamsById}
onChange={handleTeamChange}
/>
</FormField>
</>
)}
</SectionContent>
</AdminSection>
{!isCreating && (
<AdminSection data-testid='shared_channels_section'>
<SharedChannelRemotes
remoteId={remoteId}
rc={currentRemoteCluster}
/>
</AdminSection>
)}
</AdminWrapper>
<SaveChangesPanel
saving={isCreating ? isPendingState(creating) : isPendingState(saving)}
cancelLink='/admin_console/environment/secure_connections'
saveNeeded={hasChanges && isFormValid}
onClick={isCreating ? handleCreate : save}
serverError={(isErrorState(saving) || isErrorState(creating)) ? (
<FormattedMessage
id='admin.secure_connections.details.saving_changes_error'
defaultMessage='There was an error while saving secure connection'
/>
) : undefined}
savingMessage={formatMessage({id: 'admin.secure_connections.details.saving_changes', defaultMessage: 'Saving secure connection…'})}
isDisabled={props.disabled}
/>
</div>
);
}
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<SelectCallback>((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 = <LoadingScreen/>;
} else if (data) {
content = (
<SharedChannelRemotesTable
data={data}
filter={filter ?? 'home'}
fetch={fetch}
/>
);
} else {
content = (
<Placeholder
filter={filter ?? 'home'}
rc={props.rc}
/>
);
}
return (
<>
<SectionHeader $borderless={true}>
<hgroup>
<FormattedMessage
tagName={SectionHeading}
id='admin.secure_connections.details.shared_channels.title'
defaultMessage='Shared Channels'
/>
<FormattedMessage
id='admin.secure_connections.details.shared_channels.subtitle'
defaultMessage="A list of all the channels shared with your organization and channels you're sharing externally."
/>
</hgroup>
<AddChannelsButton onClick={handleAdd}>
<PlusIcon size={18}/>
<FormattedMessage
id='admin.secure_connections.details.shared_channels.add_channels.button'
defaultMessage='Add channels'
/>
</AddChannelsButton>
</SectionHeader>
<TabsWrapper>
{showTabs && (
<Tabs
id='shared-channels'
className='tabs'
defaultActiveKey={'remote'}
activeKey={filter}
onSelect={handleChangeTab}
unmountOnExit={true}
>
<Tab
eventKey={'remote'}
title={props.rc?.display_name}
/>
<Tab
eventKey={'home'}
title={(
<FormattedMessage
id='admin.secure_connections.details.shared_channels.tabs.home'
defaultMessage='Your channels'
/>
)}
/>
</Tabs>
)}
<SectionContent $compact={Boolean(data)}>
{content}
</SectionContent>
</TabsWrapper>
</>
);
}
const Placeholder = (props: {filter: 'home' | 'remote'; rc: RemoteCluster}) => {
return (
<PlaceholderContainer>
<ChatSvg/>
<hgroup>
{props.filter === 'home' ? (
<>
<FormattedMessage
tagName={PlaceholderHeading}
id='admin.secure_connection_detail.shared_channels.placeholder.title_home'
defaultMessage="You haven't shared any channels"
/>
<FormattedMessage
tagName={PlaceholderParagraph}
id='admin.secure_connection_detail.shared_channels.placeholder.subtitle'
defaultMessage='Please add channels to start sharing'
/>
</>
) : (
<FormattedMessage
tagName={PlaceholderHeading}
id='admin.secure_connection_detail.shared_channels.placeholder.title_remote'
defaultMessage="{remote} hasn't shared any channels"
values={{
remote: props.rc.display_name,
}}
/>
)}
</hgroup>
</PlaceholderContainer>
);
};
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 = <GlobeIcon size={16}/>;
if (channel?.type === Constants.PRIVATE_CHANNEL) {
icon = <LockIcon size={16}/>;
}
if (isArchivedChannel(channel)) {
icon = <ArchiveOutlineIcon size={16}/>;
}
return (
<ChannelIconWrapper>
{icon}
</ChannelIconWrapper>
);
};
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<SharedChannelRemoteRow>();
const columns = useMemo<Array<ColumnDef<SharedChannelRemoteRow, any>>>(() => {
return [
col.accessor('display_name', {
header: () => (
<FormattedMessage
id='admin.secure_connection_detail.shared_channels.table.name'
defaultMessage='Name'
/>
),
cell: ({row, getValue}) => (
<>
<ChannelIcon channelId={row.original.channel_id}/>
<ChannelName>{getValue()}</ChannelName>
</>
),
enableHiding: false,
enableSorting: true,
}),
col.accessor('team_display_name', {
header: () => {
if (props.filter === 'home') {
return (
<FormattedMessage
id='admin.secure_connection_detail.shared_channels.table.team_home'
defaultMessage='Current Team'
/>
);
}
return (
<FormattedMessage
id='admin.secure_connection_detail.shared_channels.table.team_remote'
defaultMessage='Destination Team'
/>
);
},
cell: ({getValue}) => (
<TeamName>
{getValue()}
</TeamName>
),
enableHiding: false,
enableSorting: true,
}),
col.display({
id: 'actions',
cell: ({row}) => (
<RemoteActions
remote={row.original}
fetch={props.fetch}
/>
),
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<SharedChannelRemoteRow>(),
getSortedRowModel: getSortedRowModel<SharedChannelRemoteRow>(),
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 (
<TableWrapper>
<AdminConsoleListTable<SharedChannelRemoteRow> table={table}/>
</TableWrapper>
);
}
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 (
<RemoteActionsRoot>
<LinkButton
onClick={handleRemove}
$destructive={true}
>
<FormattedMessage
id='admin.secure_connection_detail.shared_channels.table.remote_actions.remove'
defaultMessage='Remove'
/>
</LinkButton>
</RemoteActionsRoot>
);
};
const RemoteActionsRoot = styled.div`
text-align: right;
`;

View File

@@ -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 (
<RowLink to={getEditLocation(rc)}>
<Title>{rc.display_name}</Title>
<Detail>
<ConnectionStatusLabel rc={rc}/>
<RowMenu {...props}/>
</Detail>
</RowLink>
);
}
const menuId = 'secure_connection_row_menu';
const RowMenu = ({remoteCluster: rc, onDeleteSuccess, disabled}: Props) => {
const {formatMessage} = useIntl();
const history = useHistory<RemoteCluster>();
const {promptDelete} = useRemoteClusterDelete(rc);
const {promptCreateInvite} = useRemoteClusterCreateInvite(rc);
const handleCreateInvite = () => {
promptCreateInvite();
};
const handleEdit = () => {
history.push(getEditLocation(rc));
};
const handleDelete = () => {
promptDelete().then(onDeleteSuccess);
};
return (
<Menu.Container
menuButton={{
id: `${menuId}-button`,
class: classNames('btn btn-tertiary btn-sm', {disabled}),
disabled,
children: !disabled && <DotsHorizontalIcon size={16}/>,
}}
menu={{
id: menuId,
'aria-label': formatMessage({id: 'admin.secure_connection_row.menu.aria_label', defaultMessage: 'secure connection row menu'}),
}}
>
{!isConfirmed(rc) && (
<Menu.Item
id={`${menuId}-generate_invite`}
leadingElement={<CodeTagsIcon size={18}/>}
labels={(
<FormattedMessage
id='admin.secure_connection_row.menu.share'
defaultMessage='Generate invitation code'
/>
)}
onClick={handleCreateInvite}
/>
)}
<Menu.Item
id={`${menuId}-edit`}
leadingElement={<PencilOutlineIcon size={18}/>}
labels={(
<FormattedMessage
id='admin.secure_connection_row.menu.edit'
defaultMessage='Edit'
/>
)}
onClick={handleEdit}
/>
<Menu.Item
id={`${menuId}-delete`}
isDestructive={true}
leadingElement={<TrashCanOutlineIcon size={18}/>}
labels={(
<FormattedMessage
id='admin.secure_connection_row.menu.delete'
defaultMessage='Delete'
/>
)}
onClick={handleDelete}
/>
</Menu.Container>
);
};
const RowLink = styled(Link<RemoteCluster>).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;
`;

View File

@@ -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 ? <LoadingScreen/> : (
<Placeholder
disabled={disabled}
serviceNotRunning={serviceNotRunning}
/>
);
return (
<div
className='wrapper--fixed'
data-testid='secureConnectionsSection'
>
<AdminHeader>
<FormattedMessage {...msg.pageTitle}/>
</AdminHeader>
<AdminWrapper>
<AdminSection>
<SectionHeader>
<hgroup>
<FormattedMessage
tagName={SectionHeading}
{...msg.title}
/>
<FormattedMessage {...msg.subtitle}/>
</hgroup>
<AddMenu disabled={disabled}/>
</SectionHeader>
{remoteClusters?.map((rc) => {
return (
<SecureConnectionRow
key={rc.remote_id}
remoteCluster={rc}
onDeleteSuccess={fetch}
disabled={disabled}
/>
);
}) ?? placeholder}
</AdminSection>
</AdminWrapper>
</div>
);
}
const AdminWrapper = (props: {children: ReactNode}) => {
return (
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{props.children}
</div>
</div>
);
};
const Placeholder = ({disabled, serviceNotRunning}: {disabled: boolean; serviceNotRunning: boolean}) => {
return (
<SectionContent>
{serviceNotRunning && (
<SectionNotice
type='danger'
title={(
<FormattedMessage {...msg.serviceNotRunning}/>
)}
/>
)}
<PlaceholderContainer>
<BuildingSvg/>
<hgroup>
<FormattedMessage
tagName={PlaceholderHeading}
{...msg.placeholderTitle}
/>
<FormattedMessage
tagName={'p'}
{...msg.placeholderSubtitle}
/>
</hgroup>
<AddMenu
buttonClassNames='btn-tertiary'
disabled={disabled}
/>
</PlaceholderContainer>
</SectionContent>
);
};
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 (
<Menu.Container
menuButton={{
id: `${menuId}-button`,
class: classNames('btn', buttonClassNames ?? 'btn-primary btn-sm', {disabled}),
disabled,
children: (
<>
<FormattedMessage {...msg.addConnection}/>
{!disabled && (
<i
aria-hidden='true'
className='icon icon-chevron-down'
/>
)}
</>
),
}}
menu={{
id: menuId,
'aria-label': formatMessage(msg.menuAriaLabel),
}}
>
<Menu.Item
id={`${menuId}-add_connection`}
labels={<FormattedMessage {...msg.createConnection}/>}
onClick={handleCreate}
/>
<Menu.Item
id={`${menuId}-accept_invitation`}
labels={<FormattedMessage {...msg.acceptInvitation}/>}
onClick={handleAccept}
/>
</Menu.Container>
);
};
const msg = defineMessages({
pageTitle: {id: 'admin.sidebar.secureConnections', defaultMessage: 'Connected Workspaces (Beta)'},
title: {id: 'admin.secure_connections.title', defaultMessage: 'Connected Workspaces'},
subtitle: {id: 'admin.secure_connections.subtitle', defaultMessage: 'Connected workspaces with this server'},
placeholderTitle: {id: 'admin.secure_connections.placeholder.title', defaultMessage: 'Share channels'},
placeholderSubtitle: {id: 'admin.secure_connections.placeholder.subtitle', defaultMessage: 'Connecting with an external workspace allows you to share channels with them'},
addConnection: {id: 'admin.secure_connections.menu.add_connection', defaultMessage: 'Add a connection'},
menuAriaLabel: {id: 'admin.secure_connections.menu.dropdownAriaLabel', defaultMessage: 'Connected workspaces actions menu'},
createConnection: {id: 'admin.secure_connections.menu.create_connection', defaultMessage: 'Create a connection'},
acceptInvitation: {id: 'admin.secure_connections.menu.accept_invitation', defaultMessage: 'Accept an invitation'},
serviceNotRunning: {id: 'admin.secure_connections.serviceNotRunning', defaultMessage: 'Service not running, please restart server.'},
});
export const searchableStrings: SearchableStrings = Object.values(msg);

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ComponentProps} from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import DropdownInput from 'components/dropdown_input';
export type Props = {
value: string;
teamsById: IDMappedObjects<Team>;
onChange: (teamId: string) => void;
}
const TeamSelector = (props: Props): JSX.Element => {
const value = props.teamsById[props.value];
const {locale} = useIntl();
const handleTeamChange: ComponentProps<typeof DropdownInput>['onChange'] = useCallback((e) => {
const teamId = e.value;
props.onChange(teamId);
}, []);
const teamValues = Object.values(props.teamsById).
map((team) => ({value: team.id, label: team.display_name})).
sort((teamA, teamB) => teamA.label.localeCompare(teamB.label, locale));
return (
<DropdownInput
className='team_selector'
required={true}
onChange={handleTeamChange}
value={value ? {label: value.display_name, value: value.id} : undefined}
options={teamValues}
name='team_selector'
/>
);
};
export default TeamSelector;

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {RemoteCluster} from '@mattermost/types/remote_clusters';
import {isConfirmed} from './utils';
describe('isConfirmed', () => {
it('should return true', () => {
const confirmed = isConfirmed({site_url: 'https://siteurl'} as RemoteCluster);
expect(confirmed).toBe(true);
});
it('should return false', () => {
const confirmed = isConfirmed({site_url: 'pending_https://siteurl'} as RemoteCluster);
expect(confirmed).toBe(false);
});
});

View File

@@ -0,0 +1,252 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {LocationDescriptor} from 'history';
import {DateTime, Interval} from 'luxon';
import {useCallback, useEffect, useState} from 'react';
import {useDispatch} from 'react-redux';
import type {ClientError} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels';
import {isRemoteClusterPatch, type RemoteCluster, type RemoteClusterPatch} from '@mattermost/types/remote_clusters';
import type {SharedChannelRemote} from '@mattermost/types/shared_channels';
import type {Team} from '@mattermost/types/teams';
import type {IDMappedObjects, RelationOneToOne} from '@mattermost/types/utilities';
import {ChannelTypes} from 'mattermost-redux/action_types';
import {getChannel as fetchChannel} from 'mattermost-redux/actions/channels';
import {Client4} from 'mattermost-redux/client';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getTeam} from 'mattermost-redux/selectors/entities/teams';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import type {GlobalState} from 'types/store';
export const useRemoteClusters = () => {
const [remoteClusters, setRemoteClusters] = useState<RemoteCluster[]>();
const [loadingState, setLoadingState] = useState<boolean | ClientError>(true);
const {loading, error} = loadingStatus(loadingState);
const fetch = async () => {
setLoadingState(true);
try {
const data = await Client4.getRemoteClusters({excludePlugins: true});
setRemoteClusters(data?.length ? data : undefined);
setLoadingState(false);
} catch (err) {
setLoadingState(err);
}
};
useEffect(() => {
fetch();
}, []);
return [remoteClusters, {loading, fetch, error}] as const;
};
export const useRemoteClusterEdit = (remoteId: string | 'create', initRemoteCluster: RemoteCluster | undefined) => {
const editing = remoteId !== 'create';
const [currentRemoteCluster, setCurrentRemoteCluster] = useState<RemoteCluster | undefined>(initRemoteCluster);
const [patch, setPatch] = useState<Partial<RemoteClusterPatch>>({});
const [loading, setLoading] = useState<TLoadingState>(editing && !currentRemoteCluster);
const [saving, setSaving] = useState<TLoadingState>(false);
const hasChanges = Object.keys(patch).length > 0;
useEffect(() => {
if (!editing) {
return;
}
(async () => {
try {
const data = await Client4.getRemoteCluster(remoteId);
setCurrentRemoteCluster(data);
setLoading(false);
setPatch({});
} catch (err) {
setSaving(err);
}
})();
}, [remoteId]);
const applyPatch = (patch: Partial<RemoteClusterPatch>) => {
setPatch((current) => ({...current, ...patch}));
};
const save = async () => {
if (currentRemoteCluster && isRemoteClusterPatch(patch)) {
setSaving(true);
try {
const data = await Client4.patchRemoteCluster(remoteId, patch);
setCurrentRemoteCluster(data);
setSaving(false);
setPatch({});
} catch (err) {
setSaving(err);
}
setSaving(false);
}
};
return [
{...currentRemoteCluster, ...patch},
{
applyPatch,
save,
hasChanges,
loading,
saving,
currentRemoteCluster,
patch,
},
] as const;
};
export const useSharedChannelRemotes = (remoteId: string) => {
const [remotes, setRemotes] = useState<RelationOneToOne<Channel, SharedChannelRemote>>();
const [loadingState, setLoadingState] = useState<TLoadingState>(true);
const loading = isPendingState(loadingState);
const error = !loading && loadingState;
const fetch = async () => {
setLoadingState(true);
try {
const data = await Client4.getSharedChannelRemotes(remoteId, {include_deleted: true, include_unconfirmed: true});
setRemotes(data?.reduce<typeof remotes>((state, remote) => {
state![remote.channel_id] = remote;
return state;
}, {}));
setLoadingState(false);
} catch (error) {
setLoadingState(error);
}
};
useEffect(() => {
fetch();
}, [remoteId]);
return [remotes, {loading, error, fetch}] as const;
};
const remoteRow = (remote: SharedChannelRemote, channel: Channel, team?: Team) => {
return {...remote, display_name: channel.display_name, team_display_name: team?.display_name ?? ''};
};
export type SharedChannelRemoteRow = SharedChannelRemote & Pick<Channel, 'display_name'> & {team_display_name: string};
export const useSharedChannelRemoteRows = (remoteId: string, opts: {filter: 'home' | 'remote' | undefined}) => {
const [sharedChannelRemotes, setSharedChannelRemotes] = useState<SharedChannelRemoteRow[]>();
const [loadingState, setLoadingState] = useState<TLoadingState>(true);
const dispatch = useDispatch();
const loading = isPendingState(loadingState);
const error = !loading && loadingState;
const fetch = useCallback(async () => {
if (opts.filter === undefined) {
// wait for a filter
return;
}
setLoadingState(true);
dispatch<ActionFuncAsync<IDMappedObjects<SharedChannelRemoteRow>, GlobalState>>(async (dispatch, getState) => {
const collected: IDMappedObjects<SharedChannelRemoteRow> = {};
const missing: SharedChannelRemote[] = [];
try {
const data = await Client4.getSharedChannelRemotes(remoteId, {include_unconfirmed: true, exclude_remote: opts.filter === 'home', exclude_home: opts.filter === 'remote'});
let state = getState();
let getMyChannelsOnce: undefined | ((remote: SharedChannelRemote) => Promise<Channel | undefined>) = async (firstNotFound: SharedChannelRemote) => {
const channels = await Client4.getAllTeamsChannels();
dispatch({
type: ChannelTypes.RECEIVED_ALL_CHANNELS,
data: channels,
});
state = getState();
getMyChannelsOnce = undefined; // once-only
// return triggering not-found remote
return getChannel(state, firstNotFound.channel_id);
};
// first-past, get known channels or do initial load on first not-found channel
for (const remote of data) {
// eslint-disable-next-line no-await-in-loop
const channel = getChannel(state, remote.channel_id) ?? await getMyChannelsOnce?.(remote);
if (!channel) {
// collect all remotes with missing channels
missing?.push(remote);
continue;
}
const team = getTeam(state, channel.team_id);
collected[remote.id] = remoteRow(remote, channel, team);
}
// fetch missing channels individually
if (missing.length) {
// TODO: performance; consider adding sharedchannelremotes search param to api/v4/channels
await Promise.allSettled(missing.map((remote) => dispatch(fetchChannel(remote.channel_id))));
state = getState();
for (const remote of missing) {
const channel = getChannel(state, remote.channel_id);
if (!channel) {
continue;
}
const team = getTeam(state, channel.team_id);
collected[remote.id] = remoteRow(remote, channel, team);
}
}
const rows = Object.values(collected);
setSharedChannelRemotes(rows.length ? rows : undefined);
setLoadingState(false);
} catch (error) {
setLoadingState(error);
return {error};
}
return {data: collected};
});
}, [remoteId, opts.filter]);
useEffect(() => {
fetch();
}, [remoteId, opts.filter]);
return [sharedChannelRemotes, {loading, error, fetch}] as const;
};
export const getEditLocation = (rc: RemoteCluster): LocationDescriptor<RemoteCluster> => {
return {pathname: `/admin_console/environment/secure_connections/${rc.remote_id}`, state: rc};
};
export const getCreateLocation = (): LocationDescriptor<RemoteCluster> => {
return {pathname: '/admin_console/environment/secure_connections/create'};
};
const SiteURLPendingPrefix = 'pending_';
export const isConfirmed = (rc: RemoteCluster) => Boolean(rc.site_url && !rc.site_url.startsWith(SiteURLPendingPrefix));
export const isConnected = (rc: RemoteCluster) => Interval.before(DateTime.now(), {minutes: 5}).contains(DateTime.fromMillis(rc.last_ping_at));
export type TLoadingState<TError extends Error = ClientError> = boolean | TError;
export const isPendingState = <T extends Error>(loadingState: TLoadingState<T>) => loadingState === true;
export const isErrorState = <T extends Error>(loadingState: TLoadingState<T>): loadingState is T => loadingState instanceof Error;
const loadingStatus = <T extends Error>(loadingState: TLoadingState<T>) => {
const loading = isPendingState(loadingState);
const error = isErrorState(loadingState) ? loadingState : undefined;
return {error, loading};
};

View File

@@ -12,7 +12,7 @@ type Props = {
saveNeeded: boolean;
onClick: () => void;
cancelLink: string;
serverError?: JSX.Element;
serverError?: JSX.Element | string;
isDisabled?: boolean;
savingMessage?: string;
};

View File

@@ -1,7 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {MessageDescriptor} from 'react-intl';
import type {FormatXMLElementFn} from 'intl-messageformat';
import type {
MessageDescriptor,
PrimitiveType,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
IntlShape,
} from 'react-intl';
import type {CloudState, Product} from '@mattermost/types/cloud';
import type {AdminConfig, ClientLicense} from '@mattermost/types/config';
@@ -187,7 +193,7 @@ export type AdminDefinitionSubSectionSchema = AdminDefinitionConfigSchemaCompone
export type AdminDefinitionSubSection = {
url: string;
title?: string | MessageDescriptor;
searchableStrings?: Array<string|MessageDescriptor|[MessageDescriptor, {[key: string]: any}]>;
searchableStrings?: SearchableStrings;
isHidden?: Check;
isDiscovery?: boolean;
isDisabled?: Check;
@@ -203,6 +209,11 @@ export type AdminDefinitionSection = {
subsections: {[key: string]: AdminDefinitionSubSection};
}
/** From {@link IntlShape.formatMessage}. Cannot discriminate overloaded method signature. */
declare function formatMessageBasic(descriptor: MessageDescriptor, values?: Record<string, PrimitiveType | FormatXMLElementFn<string, string>>): string;
export type SearchableStrings = Array<string | MessageDescriptor | Parameters<typeof formatMessageBasic>>;
export type AdminDefinition = {[key: string]: AdminDefinitionSection}
export type Check = boolean | ((config: Partial<AdminConfig>, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState, isSystemAdmin?: boolean) => boolean)

View File

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {useRef, useCallback, useState} from 'react';
import {defineMessages} from 'react-intl';
type CopyOptions = {
successCopyTimeout?: number;
@@ -80,3 +81,7 @@ export default function useCopyText(options: CopyOptions): CopyResponse {
};
}
export const messages = defineMessages({
copy: {id: 'copy_text.copy', defaultMessage: 'Copy'},
copied: {id: 'copy_text.copied', defaultMessage: 'Copied'},
});

View File

@@ -106,6 +106,10 @@
font-size: 20px;
line-height: 28px;
}
&.noText {
margin-bottom: 0;
}
}
.sectionNoticeBody {

View File

@@ -46,8 +46,8 @@ describe('PluginAction', () => {
expect(secondaryButton).toBeInTheDocument();
expect(linkButton).toBeInTheDocument();
expect(closeButton).toBeInTheDocument();
expect(screen.queryByText(props.text)).toBeInTheDocument();
expect(screen.queryByText(props.title)).toBeInTheDocument();
expect(screen.queryByText(props.text as string)).toBeInTheDocument();
expect(screen.queryByText(props.title as string)).toBeInTheDocument();
fireEvent.click(primaryButton);
expect(props.primaryButton?.onClick).toHaveBeenCalledTimes(1);
fireEvent.click(secondaryButton);

View File

@@ -14,8 +14,8 @@ type Button = {
text: string;
}
type Props = {
title: string;
text: string;
title: string | React.ReactElement;
text?: string;
primaryButton?: Button;
secondaryButton?: Button;
linkButton?: Button;
@@ -51,35 +51,36 @@ const SectionNotice = ({
<div className={'sectionNoticeContent'}>
{icon && <i className={classNames('icon sectionNoticeIcon', icon, type)}/>}
<div className='sectionNoticeBody'>
<h4 className={classNames('sectionNoticeTitle', {welcome: type === 'welcome'})}>{title}</h4>
<Markdown message={text}/>
<div className='sectionNoticeActions'>
{primaryButton &&
<button
onClick={primaryButton.onClick}
className={classNames(buttonClass, 'btn-primary')}
>
{primaryButton.text}
</button>
}
{secondaryButton &&
<button
onClick={secondaryButton.onClick}
className={classNames(buttonClass, 'btn-secondary')}
>
{secondaryButton.text}
</button>
}
{linkButton &&
<button
onClick={linkButton.onClick}
className={classNames(buttonClass, 'btn-link')}
>
{linkButton.text}
</button>
}
</div>
<h4 className={classNames('sectionNoticeTitle', {welcome: type === 'welcome', noText: !text})}>{title}</h4>
{text && <Markdown message={text}/>}
{(primaryButton || secondaryButton || linkButton) && (
<div className='sectionNoticeActions'>
{primaryButton && (
<button
onClick={primaryButton.onClick}
className={classNames(buttonClass, 'btn-primary')}
>
{primaryButton.text}
</button>
)}
{secondaryButton && (
<button
onClick={secondaryButton.onClick}
className={classNames(buttonClass, 'btn-secondary')}
>
{secondaryButton.text}
</button>
)}
{linkButton && (
<button
onClick={linkButton.onClick}
className={classNames(buttonClass, 'btn-link')}
>
{linkButton.text}
</button>
)}
</div>
)}
</div>
</div>
{showDismiss &&

View File

@@ -217,6 +217,10 @@
.channel-name {
display: none;
}
.selected-hidden {
display: none;
}
}
.channels-input__multi-value__remove {

View File

@@ -3,7 +3,7 @@
import classNames from 'classnames';
import React from 'react';
import type {RefObject} from 'react';
import type {ComponentProps, RefObject} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {FormattedMessage, defineMessages} from 'react-intl';
import {components} from 'react-select';
@@ -23,20 +23,22 @@ import {Constants} from 'utils/constants';
import './channels_input.scss';
type Props = {
type Props<T extends Channel> = {
placeholder: React.ReactNode;
autoFocus?: boolean;
ariaLabel: string;
channelsLoader: (value: string, callback?: (channels: Channel[]) => void) => Promise<Channel[]>;
onChange: (channels: Channel[]) => void;
value: Channel[];
channelsLoader: (value: string, callback?: (channels: T[]) => void) => Promise<T[]>;
onChange: (channels: T[]) => void;
value: T[];
onInputChange: (change: string) => void;
inputValue: string;
loadingMessage?: MessageDescriptor;
noOptionsMessage?: MessageDescriptor;
formatOptionLabel?: ComponentProps<typeof AsyncSelect<T>>['formatOptionLabel'];
}
type State = {
options: Channel[];
type State<T> = {
options: T[];
};
const messages = defineMessages({
@@ -50,14 +52,14 @@ const messages = defineMessages({
},
});
export default class ChannelsInput extends React.PureComponent<Props, State> {
export default class ChannelsInput<T extends Channel> extends React.PureComponent<Props<T>, State<T>> {
static defaultProps = {
loadingMessage: messages.loading,
noOptionsMessage: messages.noOptions,
};
private selectRef: RefObject<Async<Channel> & {handleInputChange: (newValue: string, actionMeta: InputActionMeta | {action: 'custom'}) => string}>;
private selectRef: RefObject<Async<T> & {handleInputChange: (newValue: string, actionMeta: InputActionMeta | {action: 'custom'}) => string}>;
constructor(props: Props) {
constructor(props: Props<T>) {
super(props);
this.selectRef = React.createRef();
this.state = {
@@ -65,13 +67,13 @@ export default class ChannelsInput extends React.PureComponent<Props, State> {
};
}
getOptionValue = (channel: Channel) => channel.id;
getOptionValue = (channel: T) => channel.id;
handleInputChange = (inputValue: string, action: InputActionMeta) => {
if (action.action === 'input-blur' && inputValue !== '') {
for (const option of this.state.options) {
if (this.props.inputValue === option.name) {
this.onChange([...this.props.value, option], {} as ActionMeta<Channel>);
this.onChange([...this.props.value, option], {} as ActionMeta<T>);
this.props.onInputChange('');
return;
}
@@ -82,8 +84,8 @@ export default class ChannelsInput extends React.PureComponent<Props, State> {
}
};
optionsLoader = (_input: string, callback: (options: Channel[]) => void) => {
const customCallback = (options: Channel[]) => {
optionsLoader = (_input: string, callback: (options: T[]) => void) => {
const customCallback = (options: T[]) => {
this.setState({options});
callback(options);
};
@@ -122,7 +124,7 @@ export default class ChannelsInput extends React.PureComponent<Props, State> {
);
};
formatOptionLabel = (channel: Channel) => {
formatOptionLabel = (channel: T) => {
let icon = <PublicChannelIcon className='public-channel-icon'/>;
if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = <PrivateChannelIcon className='private-channel-icon'/>;
@@ -137,13 +139,13 @@ export default class ChannelsInput extends React.PureComponent<Props, State> {
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onChange = (value: Channel[], _meta: ActionMeta<Channel>) => {
onChange = (value: T[], _meta: ActionMeta<T>) => {
if (this.props.onChange) {
this.props.onChange(value);
}
};
MultiValueRemove = ({children, innerProps}: {children: React.ReactNode | React.ReactNodeArray; innerProps: Record<string, any>}) => (
MultiValueRemove = ({children, innerProps}: {children: React.ReactNode | React.ReactNode[]; innerProps: Record<string, any>}) => (
<div {...innerProps}>
{children || <CloseCircleSolidIcon/>}
</div>
@@ -173,7 +175,7 @@ export default class ChannelsInput extends React.PureComponent<Props, State> {
placeholder={this.props.placeholder}
components={this.components}
getOptionValue={this.getOptionValue}
formatOptionLabel={this.formatOptionLabel}
formatOptionLabel={this.props.formatOptionLabel ?? this.formatOptionLabel}
loadingMessage={this.loadingMessage}
defaultOptions={false}
defaultMenuIsOpen={false}
@@ -185,6 +187,7 @@ export default class ChannelsInput extends React.PureComponent<Props, State> {
tabSelectsValue={true}
value={this.props.value}
aria-label={this.props.ariaLabel}
autoFocus={this.props.autoFocus}
/>
);
}

View File

@@ -2203,6 +2203,75 @@
"admin.saml.verifyDescription": "When false, Mattermost will not verify that the signature sent from a SAML Response matches the Service Provider Login URL. Disabling verification is not recommended for production environments.",
"admin.saml.verifyTitle": "Verify Signature:",
"admin.saving": "Saving Config...",
"admin.secure_connection_detail.page_title": "Connection Configuration",
"admin.secure_connection_detail.shared_channels.placeholder.subtitle": "Please add channels to start sharing",
"admin.secure_connection_detail.shared_channels.placeholder.title_home": "You haven't shared any channels",
"admin.secure_connection_detail.shared_channels.placeholder.title_remote": "{remote} hasn't shared any channels",
"admin.secure_connection_detail.shared_channels.table.name": "Name",
"admin.secure_connection_detail.shared_channels.table.remote_actions.remove": "Remove",
"admin.secure_connection_detail.shared_channels.table.team_home": "Current Team",
"admin.secure_connection_detail.shared_channels.table.team_remote": "Destination Team",
"admin.secure_connection_row.menu.aria_label": "secure connection row menu",
"admin.secure_connection_row.menu.delete": "Delete",
"admin.secure_connection_row.menu.edit": "Edit",
"admin.secure_connection_row.menu.share": "Generate invitation code",
"admin.secure_connections.accept_invite.confirm.done.button": "Accept",
"admin.secure_connections.accept_invite.invite_code": "Encrypted invitation code",
"admin.secure_connections.accept_invite.organization_name": "Organization name",
"admin.secure_connections.accept_invite.password": "Password",
"admin.secure_connections.accept_invite.prompt": "Accept a secure connection from another server",
"admin.secure_connections.accept_invite.prompt_invite_password": "Enter the encrypted invitation code shared to you by the admin of the server you are connecting with.",
"admin.secure_connections.accept_invite.saving_changes_error": "There was an error while accepting the invite.",
"admin.secure_connections.accept_invite.share_title": "Accept a connection invite",
"admin.secure_connections.confirm.delete.button": "Yes, delete",
"admin.secure_connections.confirm.delete.text": "Are you sure you want to delete the secure connection <strong>{displayName}</strong>?",
"admin.secure_connections.confirm.delete.title": "Delete secure connection",
"admin.secure_connections.create_invite.confirm.done.button": "Done",
"admin.secure_connections.create_invite.confirm.save.button": "Save",
"admin.secure_connections.create_invite.create_invite.notice.title": "Share these two separately to avoid a security compromise",
"admin.secure_connections.create_invite.create_title": "Create connection",
"admin.secure_connections.create_invite.create_title_done": "Connection created",
"admin.secure_connections.create_invite.share_title": "Invitation code",
"admin.secure_connections.create_invite.share.invite_code": "Encrypted invitation code",
"admin.secure_connections.create_invite.share.label": "Share this code and password",
"admin.secure_connections.create_invite.share.message": "Please share the invitation code and password with the administrator of the server you want to connect with.",
"admin.secure_connections.create_invite.share.password": "Password",
"admin.secure_connections.details.org_name.help": "Giving the connection a recognizable name will help you remember its purpose.",
"admin.secure_connections.details.org_name.label": "Organization Name",
"admin.secure_connections.details.saving_changes": "Saving secure connection…",
"admin.secure_connections.details.saving_changes_error": "There was an error while saving secure connection",
"admin.secure_connections.details.shared_channels.add_channels.button": "Add channels",
"admin.secure_connections.details.shared_channels.subtitle": "A list of all the channels shared with your organization and channels you're sharing externally.",
"admin.secure_connections.details.shared_channels.tabs.home": "Your channels",
"admin.secure_connections.details.shared_channels.title": "Shared Channels",
"admin.secure_connections.details.subtitle": "Connection name and other permissions",
"admin.secure_connections.details.team.help": "Select the default team in which any shared channels will be placed. This can be updated later for specific shared channels.",
"admin.secure_connections.details.team.label": "Destination Team",
"admin.secure_connections.details.title": "Connection Details",
"admin.secure_connections.menu.accept_invitation": "Accept an invitation",
"admin.secure_connections.menu.add_connection": "Add a connection",
"admin.secure_connections.menu.create_connection": "Create a connection",
"admin.secure_connections.menu.dropdownAriaLabel": "Connected workspaces actions menu",
"admin.secure_connections.placeholder.subtitle": "Connecting with an external workspace allows you to share channels with them",
"admin.secure_connections.placeholder.title": "Share channels",
"admin.secure_connections.serviceNotRunning": "Service not running, please restart server.",
"admin.secure_connections.shared_channels.add.close.button": "Close",
"admin.secure_connections.shared_channels.add.confirm.button": "Share",
"admin.secure_connections.shared_channels.add.error.channel_invite_not_home": "{channel} could not be added to this connection because it originates from another connection.",
"admin.secure_connections.shared_channels.add.error.inviting_remote_to_channel": "{channel} could not be added to this connection.",
"admin.secure_connections.shared_channels.add.input_label": "Search and add channels",
"admin.secure_connections.shared_channels.add.input_placeholder": "e.g. {channel_name}",
"admin.secure_connections.shared_channels.add.message": "Please select a team and channels to share",
"admin.secure_connections.shared_channels.add.title": "Select channels",
"admin.secure_connections.shared_channels.confirm.remove.button": "Remove",
"admin.secure_connections.shared_channels.confirm.remove.message": "The channel will be removed from this connection and will no longer be shared with it.",
"admin.secure_connections.shared_channels.confirm.remove.title": "Remove channel",
"admin.secure_connections.status_connected": "Connected",
"admin.secure_connections.status_offline": "Offline",
"admin.secure_connections.status_pending": "Connection Pending",
"admin.secure_connections.status_tooltip": "Last ping: {timestamp}",
"admin.secure_connections.subtitle": "Connected workspaces with this server",
"admin.secure_connections.title": "Connected Workspaces",
"admin.security.password": "Password",
"admin.server_logs.CopyLog": "Copy log",
"admin.server_logs.DataCopied": "Data copied",
@@ -2374,6 +2443,7 @@
"admin.sidebar.reporting": "Reporting",
"admin.sidebar.restricted_indicator.tooltip.message.blocked": "This is {article} {minimumPlanRequiredForFeature} feature, available with an upgrade or free {trialLength}-day trial",
"admin.sidebar.saml": "SAML 2.0",
"admin.sidebar.secureConnections": "Connected Workspaces (Beta)",
"admin.sidebar.sessionLengths": "Session Lengths",
"admin.sidebar.signup": "Signup",
"admin.sidebar.site": "Site Configuration",
@@ -3344,6 +3414,8 @@
"convert_channel.question3": "Are you sure you want to convert **{display_name}** to a private channel?",
"convert_channel.title": "Convert {display_name} to a Private Channel?",
"copied.message": "Copied",
"copy_text.copied": "Copied",
"copy_text.copy": "Copy",
"copy.code.message": "Copy code",
"copy.text.message": "Copy text",
"copyTextTooltip.copy": "Copy",

View File

@@ -15,6 +15,7 @@ import type {
ChannelStats,
ChannelWithTeamData,
} from '@mattermost/types/channels';
import type {OptsSignalExt} from '@mattermost/types/client4';
import type {ServerError} from '@mattermost/types/errors';
import type {PreferenceType} from '@mattermost/types/preferences';
@@ -937,9 +938,9 @@ export function searchChannels(teamId: string, term: string, archived?: boolean)
};
}
export function searchAllChannels(term: string, opts: {page: number; per_page: number} & ChannelSearchOpts): ActionFuncAsync<ChannelsWithTotalCount>;
export function searchAllChannels(term: string, opts: Omit<ChannelSearchOpts, 'page' | 'per_page'> | undefined): ActionFuncAsync<ChannelWithTeamData[]>;
export function searchAllChannels(term: string, opts: ChannelSearchOpts = {}): ActionFuncAsync<Channel[] | ChannelsWithTotalCount> {
export function searchAllChannels(term: string, opts: {page: number; per_page: number} & ChannelSearchOpts & OptsSignalExt): ActionFuncAsync<ChannelsWithTotalCount>;
export function searchAllChannels(term: string, opts: Omit<ChannelSearchOpts, 'page' | 'per_page'> & OptsSignalExt | undefined): ActionFuncAsync<ChannelWithTeamData[]>;
export function searchAllChannels(term: string, opts: ChannelSearchOpts & OptsSignalExt = {}): ActionFuncAsync<Channel[] | ChannelsWithTotalCount> {
return async (dispatch, getState) => {
dispatch({type: ChannelTypes.GET_ALL_CHANNELS_REQUEST, data: null});
@@ -947,6 +948,9 @@ export function searchAllChannels(term: string, opts: ChannelSearchOpts = {}): A
try {
response = await Client4.searchAllChannels(term, opts);
} catch (error) {
if (opts.signal?.aborted) {
return {error};
}
forceLogoutIfNecessary(error, dispatch, getState);
dispatch({type: ChannelTypes.GET_ALL_CHANNELS_FAILURE, error});
dispatch(logError(error));

View File

@@ -462,6 +462,11 @@ export const ModalIdentifiers = {
CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete',
CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create',
CONFIRM_MANAGE_USER_SETTINGS_MODAL: 'confirm_switch_to_settings',
SECURE_CONNECTION_DELETE: 'secure_connection_delete',
SECURE_CONNECTION_CREATE_INVITE: 'secure_connection_create_invite',
SECURE_CONNECTION_ACCEPT_INVITE: 'secure_connection_accept_invite',
SHARED_CHANNEL_REMOTE_INVITE: 'shared_channel_remote_invite',
SHARED_CHANNEL_REMOTE_UNINVITE: 'shared_channel_remote_uninvite',
};
export const UserStatuses = {

View File

@@ -26,7 +26,7 @@ import type {
ChannelSearchOpts,
ServerChannel,
} from '@mattermost/types/channels';
import type {Options, StatusOK, ClientResponse, FetchPaginatedThreadOptions} from '@mattermost/types/client4';
import type {Options, StatusOK, ClientResponse, FetchPaginatedThreadOptions, OptsSignalExt} from '@mattermost/types/client4';
import {LogLevel} from '@mattermost/types/client4';
import type {
Address,
@@ -110,12 +110,14 @@ import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageRe
import type {PreferenceType} from '@mattermost/types/preferences';
import type {ProductNotices} from '@mattermost/types/product_notices';
import type {Reaction} from '@mattermost/types/reactions';
import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
import type {Role} from '@mattermost/types/roles';
import type {SamlCertificateStatus, SamlMetadataResponse} from '@mattermost/types/saml';
import type {Scheme} from '@mattermost/types/schemes';
import type {Session} from '@mattermost/types/sessions';
import type {CompleteOnboardingRequest} from '@mattermost/types/setup';
import type {SharedChannelRemote} from '@mattermost/types/shared_channels';
import type {
GetTeamMembersOpts,
Team,
@@ -140,7 +142,7 @@ import type {
GetFilteredUsersStatsOpts,
UserCustomStatus,
} from '@mattermost/types/users';
import type {DeepPartial, RelationOneToOne} from '@mattermost/types/utilities';
import type {DeepPartial, PartialExcept, RelationOneToOne} from '@mattermost/types/utilities';
import {cleanUrlForLogging} from './errors';
import {buildQueryString} from './helpers';
@@ -327,6 +329,14 @@ export default class Client4 {
return `${this.getBaseRoute()}/users/${userId}/teams/${teamId}/channels/categories`;
}
getRemoteClustersRoute() {
return `${this.getBaseRoute()}/remotecluster`;
}
getRemoteClusterRoute(remoteId: string) {
return `${this.getRemoteClustersRoute()}/${remoteId}`;
}
getPostsRoute() {
return `${this.getBaseRoute()}/posts`;
}
@@ -1942,9 +1952,9 @@ export default class Client4 {
);
};
searchAllChannels(term: string, opts: {page: number; per_page: number} & ChannelSearchOpts): Promise<ChannelsWithTotalCount>;
searchAllChannels(term: string, opts: Omit<ChannelSearchOpts, 'page' | 'per_page'> | undefined): Promise<ChannelWithTeamData[]>;
searchAllChannels(term: string, opts: ChannelSearchOpts = {}) {
searchAllChannels(term: string, opts: {page: number; per_page: number} & ChannelSearchOpts & OptsSignalExt): Promise<ChannelsWithTotalCount>;
searchAllChannels(term: string, opts: Omit<ChannelSearchOpts, 'page' | 'per_page'> & OptsSignalExt | undefined): Promise<ChannelWithTeamData[]>;
searchAllChannels(term: string, opts: ChannelSearchOpts & OptsSignalExt = {}) {
const body = {
term,
...opts,
@@ -1958,7 +1968,7 @@ export default class Client4 {
}
return this.doFetch<ChannelWithTeamData[] | ChannelsWithTotalCount>(
`${this.getChannelsRoute()}/search${buildQueryString(queryParams)}`,
{method: 'post', body: JSON.stringify(body)},
{method: 'post', body: JSON.stringify(body), signal: opts.signal},
);
}
@@ -2072,6 +2082,89 @@ export default class Client4 {
);
};
// Remote Clusters Routes
getRemoteClusters = (options: {excludePlugins: boolean}) => {
return this.doFetch<RemoteCluster[]>(
`${this.getRemoteClustersRoute()}${buildQueryString({exclude_plugins: options.excludePlugins})}`,
{method: 'GET'},
);
};
getRemoteCluster = (remoteId: string) => {
return this.doFetch<RemoteCluster>(
`${this.getRemoteClusterRoute(remoteId)}`,
{method: 'GET'},
);
};
createRemoteCluster = (remoteCluster: PartialExcept<RemoteClusterWithPassword, 'name' | 'display_name'>) => {
return this.doFetch<{invite: string; password: string; remote_cluster: RemoteCluster}>(
`${this.getRemoteClustersRoute()}`,
{method: 'POST', body: JSON.stringify(remoteCluster)},
);
};
patchRemoteCluster = (remoteId: string, patch: Partial<RemoteClusterPatch>) => {
return this.doFetch<RemoteCluster>(
`${this.getRemoteClusterRoute(remoteId)}`,
{method: 'PATCH', body: JSON.stringify(patch)},
);
};
deleteRemoteCluster = (remoteId: string) => {
return this.doFetch(
`${this.getRemoteClusterRoute(remoteId)}`,
{method: 'DELETE'},
);
};
acceptInviteRemoteCluster = (remoteClusterAcceptInvite: RemoteClusterAcceptInvite) => {
return this.doFetch<RemoteCluster>(
`${this.getRemoteClustersRoute()}/accept_invite`,
{method: 'POST', body: JSON.stringify(remoteClusterAcceptInvite)},
);
};
generateInviteRemoteCluster = (remoteId: string, remoteCluster: Partial<Pick<RemoteClusterWithPassword, 'password'>>) => {
return this.doFetch<string>(
`${this.getRemoteClusterRoute(remoteId)}/generate_invite`,
{method: 'POST', body: JSON.stringify(remoteCluster)},
);
};
// Shared Channels Routes
getSharedChannelRemotes = (
remoteId: string,
filters: {
exclude_remote?: boolean;
exclude_home?: boolean;
include_deleted?: boolean;
include_unconfirmed?: boolean;
exclude_confirmed?: boolean;
},
) => {
return this.doFetch<SharedChannelRemote[]>(
`${this.getRemoteClusterRoute(remoteId)}/sharedchannelremotes${buildQueryString(filters)}`,
{method: 'GET'},
);
};
sharedChannelRemoteInvite = (remoteId: string, channelId: string) => {
return this.doFetch<StatusOK>(
`${this.getRemoteClusterRoute(remoteId)}/channels/${channelId}/invite`,
{method: 'POST'},
);
};
sharedChannelRemoteUninvite = (remoteId: string, channelId: string) => {
return this.doFetch<StatusOK>(
`${this.getRemoteClusterRoute(remoteId)}/channels/${channelId}/uninvite`,
{method: 'POST'},
);
};
// Post Routes
createPost = async (post: Post) => {

View File

@@ -31,7 +31,7 @@ export type Props = {
ariaLabel?: string;
errorText?: string | React.ReactNode;
compassDesign?: boolean;
backdrop?: boolean;
backdrop?: boolean | 'static';
backdropClassName?: string;
tabIndex?: number;
children: React.ReactNode;
@@ -40,6 +40,7 @@ export type Props = {
headerInput?: React.ReactNode;
bodyPadding?: boolean;
bodyDivider?: boolean;
bodyOverflowVisible?: boolean;
footerContent?: React.ReactNode;
footerDivider?: boolean;
appendedContent?: React.ReactNode;
@@ -180,7 +181,14 @@ export class GenericModal extends React.PureComponent<Props, State> {
role='dialog'
aria-label={this.props.ariaLabel}
aria-labelledby={this.props.ariaLabel ? undefined : 'genericModalLabel'}
dialogClassName={classNames('a11y__modal GenericModal', {GenericModal__compassDesign: this.props.compassDesign}, this.props.className)}
dialogClassName={classNames(
'a11y__modal GenericModal',
{
GenericModal__compassDesign: this.props.compassDesign,
'modal--overflow': this.props.bodyOverflowVisible,
},
this.props.className,
)}
show={this.state.show}
restoreFocus={true}
enforceFocus={this.props.enforceFocus}
@@ -204,7 +212,7 @@ export class GenericModal extends React.PureComponent<Props, State> {
</>
)}
</Modal.Header>
<Modal.Body className={classNames({divider: this.props.bodyDivider})}>
<Modal.Body className={classNames({divider: this.props.bodyDivider, 'overflow-visible': this.props.bodyOverflowVisible})}>
{this.props.compassDesign ? (
this.props.errorText && (
<div className='genericModalError'>

View File

@@ -25,6 +25,8 @@ export type Options = {
duplex?: 'half'; /** Optional, but required for node clients. Must be 'half' for half-duplex fetch; 'full' is reserved for future use. See https://fetch.spec.whatwg.org/#dom-requestinit-duplex */
};
export type OptsSignalExt = {signal?: AbortSignal};
export type StatusOK = {
status: 'OK';
};

View File

@@ -509,6 +509,13 @@ export type WranglerSettings = {
MoveThreadFromGroupMessageChannelEnable: boolean;
};
export type ConnectedWorkspacesSettings = {
EnableSharedChannels: boolean;
EnableRemoteClusterService: boolean;
DisableSharedChannelsStatusSync: boolean;
MaxPostsPerSync: number;
}
export type FileSettings = {
EnableFileAttachments: boolean;
EnableMobileUpload: boolean;
@@ -981,6 +988,7 @@ export type AdminConfig = {
ImportSettings: ImportSettings;
ExportSettings: ExportSettings;
WranglerSettings: WranglerSettings;
ConnectedWorkspacesSettings: ConnectedWorkspacesSettings;
};
export type ReplicaLagSetting = {

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type RemoteClusterInvite = {
remote_id: string;
remote_team_id: string;
site_url: string;
token: string;
};
export type RemoteClusterAcceptInvite = {
name: string;
display_name: string;
invite: string;
password: string;
}
export type RemoteClusterPatch = Pick<RemoteCluster, 'display_name' | 'default_team_id'>
export function isRemoteClusterPatch(x: Partial<RemoteClusterPatch>): x is RemoteClusterPatch {
return (
(x.display_name !== undefined && x.display_name !== '') ||
x.default_team_id !== undefined
);
}
export type RemoteCluster = {
remote_id: string;
remote_team_id: string;
name: string;
display_name: string;
site_url: string;
create_at: number;
delete_at: number;
last_ping_at: number;
token?: string;
remote_token?: string;
topics: string;
creator_id: string;
plugin_id: string;
options: number;
default_team_id: string;
}
export type RemoteClusterWithPassword = RemoteCluster & {
password: string;
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type SharedChannelRemote = {
id: string;
channel_id: string;
creator_id: string;
create_at: number;
update_at: number;
delete_at: number;
is_invite_accepted: boolean;
is_invite_confirmed: boolean;
remote_id: string;
last_post_update_at: number;
last_post_id: string;
last_post_create_at: number;
last_post_create_id: string;
}

View File

@@ -43,3 +43,5 @@ Pick<T, Exclude<keyof T, Keys>> & {[K in Keys]-?: Required<Pick<T, K>> & Partial
export type Intersection<T1, T2> =
Omit<Omit<T1&T2, keyof(Omit<T1, keyof(T2)>)>, keyof(Omit<T2, keyof(T1)>)>;
export type PartialExcept<T extends Record<string, unknown>, TKeysNotPartial extends keyof T> = Partial<T> & Pick<T, TKeysNotPartial>;