diff --git a/src/browser/components/MainPage.jsx b/src/browser/components/MainPage.jsx index f9e50451..4528d02c 100644 --- a/src/browser/components/MainPage.jsx +++ b/src/browser/components/MainPage.jsx @@ -26,6 +26,7 @@ import TabBar from './TabBar.jsx'; import HoveringURL from './HoveringURL.jsx'; import Finder from './Finder.jsx'; import NewTeamModal from './NewTeamModal.jsx'; +import SelectCertificateModal from './SelectCertificateModal.jsx'; export default class MainPage extends React.Component { constructor(props) { @@ -50,6 +51,7 @@ export default class MainPage extends React.Component { mentionAtActiveCounts: new Array(this.props.teams.length), loginQueue: [], targetURL: '', + certificateRequests: [], maximized: false, }; } @@ -136,6 +138,20 @@ export default class MainPage extends React.Component { }); }); + ipcRenderer.on('select-user-certificate', (_, origin, certificateList) => { + const certificateRequests = self.state.certificateRequests; + certificateRequests.push({ + server: origin, + certificateList, + }); + self.setState({ + certificateRequests, + }); + if (certificateRequests.length === 1) { + self.switchToTabForCertificateRequest(origin); + } + }); + // can't switch tabs sequentially for some reason... ipcRenderer.on('switch-tab', (event, key) => { this.handleSelect(key); @@ -341,6 +357,18 @@ export default class MainPage extends React.Component { } } + switchToTabForCertificateRequest = (origin) => { + // origin is server name + port, if the port doesn't match the protocol, it is kept by URL + const originURL = new URL(`http://${origin.split(':')[0]}`); + const secureOriginURL = new URL(`https://${origin.split(':')[0]}`); + + const key = this.props.teams.findIndex((team) => { + const parsedURL = new URL(team.url); + return (parsedURL.origin === originURL.origin) || (parsedURL.origin === secureOriginURL.origin); + }); + this.handleSelect(key); + }; + handleMaximizeState = () => { const win = remote.getCurrentWindow(); this.setState({maximized: win.isMaximized()}); @@ -534,6 +562,24 @@ export default class MainPage extends React.Component { }); } + handleSelectCertificate = (certificate) => { + const certificateRequests = this.state.certificateRequests; + const current = certificateRequests.shift(); + this.setState({certificateRequests}); + ipcRenderer.send('selected-client-certificate', current.server, certificate); + if (certificateRequests.length > 0) { + this.switchToTabForCertificateRequest(certificateRequests[0].server); + } + } + handleCancelCertificate = () => { + const certificateRequests = this.state.certificateRequests; + const current = certificateRequests.shift(); + this.setState({certificateRequests}); + ipcRenderer.send('selected-client-certificate', current.server); + if (certificateRequests.length > 0) { + this.switchToTabForCertificateRequest(certificateRequests[0].server); + } + }; setDarkMode() { this.setState({ isDarkMode: this.props.setDarkMode(), @@ -727,6 +773,11 @@ export default class MainPage extends React.Component { onLogin={this.handleLogin} onCancel={this.handleLoginCancel} /> + { topRow } { viewsRow } diff --git a/src/browser/components/SelectCertificateModal.jsx b/src/browser/components/SelectCertificateModal.jsx new file mode 100644 index 00000000..a598eb44 --- /dev/null +++ b/src/browser/components/SelectCertificateModal.jsx @@ -0,0 +1,177 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {Fragment} from 'react'; +import PropTypes from 'prop-types'; +import {Modal, Button, Table, Row, Col} from 'react-bootstrap'; + +import ShowCertificateModal from './showCertificateModal.jsx'; + +const CELL_SIZE = 23; +const ELIPSIS_SIZE = 3; + +export default class SelectCertificateModal extends React.Component { + static propTypes = { + onSelect: PropTypes.func.isRequired, + onCancel: PropTypes.func, + certificateRequests: PropTypes.arrayOf(PropTypes.shape({ + server: PropTypes.string, + certificateList: PropTypes.array, + })), + } + + constructor(props) { + super(props); + this.state = { + selectedIndex: null, + showCertificate: null, + }; + } + + maxSize = (item, max) => { + if (!item || item.length <= max) { + return item; + } + const sub = item.substring(0, max - ELIPSIS_SIZE); + return `${sub}...`; + } + + selectfn = (index) => { + return (() => { + this.setState({selectedIndex: index}); + }); + }; + + renderCert = (cert, index) => { + const issuer = cert.issuer && cert.issuer.commonName ? cert.issuer.commonName : ''; + const subject = cert.subject && cert.subject.commonName ? cert.subject.commonName : ''; + const serial = cert.serialNumber || ''; + + const issuerShort = this.maxSize(cert.issuer.commonName, CELL_SIZE); + const subjectShort = this.maxSize(cert.subject.commonName, CELL_SIZE); + const serialShort = this.maxSize(cert.serialNumber, CELL_SIZE); + + const style = this.state.selectedIndex === index ? {background: '#457AB2', color: '#FFFFFF'} : {}; + return ( + + {issuerShort} + {subjectShort} + {serialShort} + ); + }; + + renderCerts = (certificateList) => { + if (certificateList) { + const certs = certificateList.map(this.renderCert); + return ( + + {certs} + + ); + } + return ({'No certificates available'}); + } + + getSelectedCert = () => { + return this.state.selectedIndex === null ? null : this.props.certificateRequests[0].certificateList[this.state.selectedIndex]; + }; + + handleOk = () => { + const cert = this.getSelectedCert(); + if (cert !== null) { + this.props.onSelect(cert); + } + } + + handleCertificateInfo = () => { + const certificate = this.getSelectedCert(); + this.setState({showCertificate: certificate}); + } + + certificateInfoClose = () => { + this.setState({showCertificate: null}); + } + + render() { + const certList = this.props.certificateRequests.length ? this.props.certificateRequests[0].certificateList : []; + const server = this.props.certificateRequests.length ? this.props.certificateRequests[0].server : ''; + if (this.state.showCertificate) { + return ( + + ); + } + return ( + + + {'Select a certificate'} + + +

{`Select a certificate to authenticate yourself to ${server}`}

+ + + + + + + + + + {this.renderCerts(certList)} + + +
{'Subject'}{'Issuer'}{'Serial'}
+
+ + + + + + + + + + + +
+ ); + } +} diff --git a/src/browser/components/showCertificateModal.jsx b/src/browser/components/showCertificateModal.jsx new file mode 100644 index 00000000..d249afec --- /dev/null +++ b/src/browser/components/showCertificateModal.jsx @@ -0,0 +1,97 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {Fragment} from 'react'; +import PropTypes from 'prop-types'; +import {Modal, Button, Row} from 'react-bootstrap'; + +export default class ShowCertificateModal extends React.Component { + static propTypes = { + certificate: PropTypes.object, + onOk: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + certificate: props.certificate, + }; + } + + handleOk = () => { + this.setState({certificate: null}); + this.props.onOk(); + } + + render() { + const certificateItem = (descriptor, value) => { + const ddclass = value ? '' : 'emtpyDescriptor'; + const val = value ? `${value}` : ; + return ( + +
{descriptor}
+
{val}
+
+ ); + }; + + if (this.state.certificate === null) { + return ( + + + {'No certificate Selected'} + + + ); + } + + const utcSeconds = (date) => { + const d = new Date(0); + d.setUTCSeconds(date); + return d; + }; + + const expiration = utcSeconds(this.state.certificate.validExpiry); + const creation = utcSeconds(this.state.certificate.validStart); + const dateDisplayOptions = {dateStyle: 'full', timeStyle: 'full'}; + const dateLocale = 'en-US'; + return ( + + + {'Certificate Information'} + + +

{`${this.state.certificate.subject.commonName}`}

+

{`Issued by: ${this.state.certificate.issuer.commonName}`}

+

{`Expires: ${expiration.toLocaleString(dateLocale, dateDisplayOptions)}`}

+

{'Details'}

+
+ {certificateItem('Subject Name')} + {certificateItem('Common Name', this.state.certificate.subject.commonName)} + {certificateItem('Issuer Name')} + {certificateItem('Common Name', this.state.certificate.issuer.commonName)} + {certificateItem('Serial Number', this.state.certificate.serialNumber)} + {certificateItem('Not Valid Before', creation.toLocaleString(dateLocale, dateDisplayOptions))} + {certificateItem('Not Valid After', expiration.toLocaleString(dateLocale, dateDisplayOptions))} + {certificateItem('Public Key Info')} + {certificateItem('Algorithm', this.state.certificate.fingerprint.split('/')[0])} +
+
+ + + + + +
+ ); + } +} \ No newline at end of file diff --git a/src/browser/css/components/CertificateModal.css b/src/browser/css/components/CertificateModal.css new file mode 100644 index 00000000..dbdfc11e --- /dev/null +++ b/src/browser/css/components/CertificateModal.css @@ -0,0 +1,121 @@ +.certificateModal dialog { + background-color: aliceblue; +} + +.certificateList thead { + width: 557.89px; + height: 22px; +} +.certificateList thead>tr>th { + font-family: Helvetica Neue; + font-style: normal; + font-weight: normal; + font-size: 12px; + line-height: 14px; + padding: 2px; + border-bottom: 1px solid #CCCCCC; + color: #333333; +} + +.certificateList tbody>tr>td { + width: 227.17px; + height: 47px; + font-style: normal; + font-weight: normal; + font-size: 14px; + line-height: 17px; + + color: #555555; +} + +table.certificateList { + background: #FFFFFF; + border: 1px solid #CCCCCC; + box-sizing: border-box; + box-shadow: inset 0px 1px 1px rgba(0, 0, 0, 0.0008); + border-radius: 4px; + border-collapse: unset; + height: 150px; +} + +.certificateModal button { + background: #FFFFFF; + border: 1px solid #CCCCCC; + box-sizing: border-box; + border-radius: 4px; + padding: 9px 12px; +} + +.certificateModal button:disabled { + opacity: 0.5; +} + +.certificateModal button.primary { + background: #457AB2; + color: #FFFFFF; + border: 1px solid #2E6DA4; +} +.certificateModal button.info { + color: #457AB2; +} + +.certificateModal button.primary:hover { + background: #659AD2; +} + +.certificateModal .subtitle { + color: #737373; + margin: 0px 15px 10px; +} + +.certificateModal .bottomBorder { + padding-bottom: 10px; + border-bottom: 1px solid #CCCCCC; +} + +.certificateModal .topBorder { + border-top: 1px solid #CCCCCC; + margin: 0 1px 0 1px; + padding-top: 15px; +} +.certificateModal .noBorder { + border: none; +} + +.divider { + border-right: 1px solid #CCCCCC; + display: block; +} + +.certificateModal dt, dd { + float: left; + margin: 5px; +} + +.certificateModal dt { clear:both } + +.certificateModal dl { + overflow-y: auto; +} + +.certificateKey { + font-style: normal; + font-weight: normal; + font-size: 14px; + line-height: 17px; + + color: #737373; +} + +.certInfo { + font-family: Helvetica Neue; + font-style: normal; + font-weight: normal; + font-size: 12px; + line-height: 18px; + color: #333333; +} + +.emtpyDescriptor { + border-bottom: 1px solid #CCCCCC; +} \ No newline at end of file diff --git a/src/browser/css/components/index.css b/src/browser/css/components/index.css index 542c7e89..44417fde 100644 --- a/src/browser/css/components/index.css +++ b/src/browser/css/components/index.css @@ -8,3 +8,4 @@ @import url("TeamListItem.css"); @import url("Finder.css"); @import url("UpdaterPage.css"); +@import url("CertificateModal.css"); \ No newline at end of file diff --git a/src/main.js b/src/main.js index 46bb2d0b..b0721260 100644 --- a/src/main.js +++ b/src/main.js @@ -50,6 +50,7 @@ const { const criticalErrorHandler = new CriticalErrorHandler(); const assetsDir = path.resolve(app.getAppPath(), 'assets'); const loginCallbackMap = new Map(); +const certificateRequests = new Map(); const userActivityMonitor = new UserActivityMonitor(); // Keep a global reference of the window object, if you don't, the window will @@ -158,6 +159,7 @@ function initializeAppEventListeners() { app.on('activate', handleAppActivate); app.on('before-quit', handleAppBeforeQuit); app.on('certificate-error', handleAppCertificateError); + app.on('select-client-certificate', handleSelectCertificate); app.on('gpu-process-crashed', handleAppGPUProcessCrashed); app.on('login', handleAppLogin); app.on('will-finish-launching', handleAppWillFinishLaunching); @@ -214,6 +216,8 @@ function initializeInterCommunicationEventListeners() { ipcMain.on('get-spelling-suggestions', handleGetSpellingSuggestionsEvent); ipcMain.on('get-spellchecker-locale', handleGetSpellcheckerLocaleEvent); ipcMain.on('reply-on-spellchecker-is-ready', handleReplyOnSpellcheckerIsReadyEvent); + ipcMain.on('selected-client-certificate', handleSelectedCertificate); + if (shouldShowTrayIcon()) { ipcMain.on('update-unread', handleUpdateUnreadEvent); } @@ -307,6 +311,32 @@ function handleAppBeforeQuit() { global.willAppQuit = true; } +function handleSelectCertificate(event, webContents, url, list, callback) { + event.preventDefault(); // prevent the app from getting the first certificate available + // store callback so it can be called with selected certificate + certificateRequests.set(url, callback); + + // open modal for selecting certificate + mainWindow.webContents.send('select-user-certificate', url, list); +} + +function handleSelectedCertificate(event, server, cert) { + const callback = certificateRequests.get(server); + if (!callback) { + console.error(`there was no callback associated with: ${server}`); + return; + } + try { + if (typeof cert === 'undefined') { + callback(); //user cancelled, so we use the callback without certificate. + } else { + callback(cert); + } + } catch (e) { + console.log(`There was a problem using the selected certificate: ${e}`); + } +} + function handleAppCertificateError(event, webContents, url, error, certificate, callback) { if (certificateStore.isTrusted(url, certificate)) { event.preventDefault(); @@ -912,6 +942,7 @@ function isTrustedURL(url) { if (!parsedURL) { return false; } + const teamURLs = config.teams.reduce((urls, team) => { const parsedTeamURL = parseURL(team.url); if (parsedTeamURL) {