From 0d4800fd61c76c78e1b1996b74eb1a3d65b3d7e1 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Wed, 19 Jun 2024 09:19:24 -0400 Subject: [PATCH] [MM-54863] Add permissions manager UI in Edit Server modal, improve permission checks to be less missable (#3059) * [MM-54863] Add permissions manager UI in Edit Server modal, improve permission checks to be less missable * Removing this for E2E (which was having issues anyways) * PR feedback * Disable permissions dialog for current E2E tests * Fixed the dark mode CSS * Update icon --- i18n/en.json | 8 + package-lock.json | 8 +- package.json | 4 +- src/app/serverViewState.test.js | 36 +- src/app/serverViewState.ts | 13 +- src/common/communication.ts | 5 + src/main/notifications/index.ts | 17 +- src/main/permissionsManager.test.js | 339 ++++++++++-------- src/main/permissionsManager.ts | 129 ++++--- src/main/preload/internalAPI.js | 8 + src/main/views/viewManager.test.js | 51 ++- src/main/views/viewManager.ts | 22 +- src/renderer/components/NewServerModal.tsx | 157 +++++++- src/renderer/components/Toggle.tsx | 33 ++ src/renderer/css/_css_variables.scss | 1 + src/renderer/css/components/Toggle.scss | 68 ++++ src/renderer/css/lazy/modals-dark.lazy.css | 8 + src/renderer/modals/editServer/editServer.tsx | 21 +- src/types/permissions.ts | 13 + src/types/window.ts | 4 + 20 files changed, 711 insertions(+), 234 deletions(-) create mode 100644 src/renderer/components/Toggle.tsx create mode 100644 src/renderer/css/components/Toggle.scss create mode 100644 src/types/permissions.ts diff --git a/i18n/en.json b/i18n/en.json index 7067dd2f..9a32d17f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -163,6 +163,14 @@ "renderer.components.newServerModal.error.serverUrlExists": "A server with the same URL already exists.", "renderer.components.newServerModal.error.urlIncorrectFormatting": "URL is not formatted correctly.", "renderer.components.newServerModal.error.urlRequired": "URL is required.", + "renderer.components.newServerModal.permissions.geolocation": "Location", + "renderer.components.newServerModal.permissions.microphoneAndCamera": "Microphone and Camera", + "renderer.components.newServerModal.permissions.microphoneAndCamera.windowsCameraPermissions": "Camera is disabled in Windows Settings. Click here to open the Camera Settings.", + "renderer.components.newServerModal.permissions.microphoneAndCamera.windowsMicrophoneaPermissions": "Microphone is disabled in Windows Settings. Click here to open the Microphone Settings.", + "renderer.components.newServerModal.permissions.notifications": "Notifications", + "renderer.components.newServerModal.permissions.notifications.mac": "You may also need to enable notifications in macOS for Mattermost. Click here to open the System Preferences.", + "renderer.components.newServerModal.permissions.notifications.windows": "You may also need to enable notifications in Windows for Mattermost. Click here to open the Notification Settings.", + "renderer.components.newServerModal.permissions.title": "Permissions", "renderer.components.newServerModal.serverDisplayName": "Server Display Name", "renderer.components.newServerModal.serverDisplayName.description": "The name of the server displayed on your desktop app tab bar.", "renderer.components.newServerModal.serverURL": "Server URL", diff --git a/package-lock.json b/package-lock.json index b2a0a733..6c5e714d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@mattermost/compass-icons": "0.1.43", + "@mattermost/compass-icons": "0.1.45", "auto-launch": "5.0.6", "bootstrap": "4.6.1", "bootstrap-dark": "1.0.3", @@ -3730,9 +3730,9 @@ } }, "node_modules/@mattermost/compass-icons": { - "version": "0.1.43", - "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.43.tgz", - "integrity": "sha512-vQThJ4SAynnS2u94lQtZ9xANsStpVh8uTpsJascHJOWcavLuL2aDmMLgvg9EAx8Z1qRmTdP6hF5+IU5+9E9+Jg==" + "version": "0.1.45", + "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.45.tgz", + "integrity": "sha512-xNuQG6FpmIYh+7ZAP2Qs/kAgS/O23IWOMEymaVJHFvQq8buCLdQz/b/2WgJZSLeoJjdfqhRMDDJmgaG2UEQD1w==" }, "node_modules/@mattermost/desktop-api": { "resolved": "api-types", diff --git a/package.json b/package.json index 7daf262b..9a1a6805 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "check-build-config": "tsc ./src/common/config/buildConfig.ts --outDir dist --baseUrl src --skipLibCheck && node scripts/check_build_config.js", "check-types": "tsc", "prune": "ts-prune", - "i18n-extract": "mmjstool -- i18n extract-desktop", + "i18n-extract": "mmjstool i18n extract-desktop", "lint:js": "eslint --ext .js,.jsx,.tsx,.ts --cache .", "lint:js-quiet": "npm run lint:js -- --quiet", "fix:js": "npm run lint:js-quiet -- --fix", @@ -159,7 +159,7 @@ "webpack-merge": "5.8.0" }, "dependencies": { - "@mattermost/compass-icons": "0.1.43", + "@mattermost/compass-icons": "0.1.45", "auto-launch": "5.0.6", "bootstrap": "4.6.1", "bootstrap-dark": "1.0.3", diff --git a/src/app/serverViewState.test.js b/src/app/serverViewState.test.js index b4989315..41343e88 100644 --- a/src/app/serverViewState.test.js +++ b/src/app/serverViewState.test.js @@ -5,6 +5,7 @@ import {MattermostServer} from 'common/servers/MattermostServer'; import ServerManager from 'common/servers/serverManager'; import {URLValidationStatus} from 'common/utils/constants'; import {getDefaultViewsForConfigServer} from 'common/views/View'; +import PermissionsManager from 'main/permissionsManager'; import {ServerInfo} from 'main/server/serverInfo'; import {getLocalURLString, getLocalPreload} from 'main/utils'; import ModalManager from 'main/views/modalManager'; @@ -59,6 +60,10 @@ jest.mock('main/views/viewManager', () => ({ getView: jest.fn(), showById: jest.fn(), })); +jest.mock('main/permissionsManager', () => ({ + getForServer: jest.fn(), + setForServer: jest.fn(), +})); const tabs = [ { @@ -243,6 +248,7 @@ describe('app/serverViewState', () => { serversCopy = [newServer]; }); ServerManager.getAllServers.mockReturnValue(serversCopy.map((server) => ({...server, toUniqueServer: jest.fn()}))); + PermissionsManager.getForServer.mockReturnValue({notifications: {allowed: true}}); }); it('should do nothing when the server cannot be found', () => { @@ -251,10 +257,10 @@ describe('app/serverViewState', () => { }); it('should edit the existing server', async () => { - const promise = Promise.resolve({ + const promise = Promise.resolve({server: { name: 'new-server', url: 'http://new-server.com', - }); + }}); ModalManager.addModal.mockReturnValue(promise); serverViewState.showEditServerModal(null, 'server-1'); @@ -272,6 +278,32 @@ describe('app/serverViewState', () => { tabs, })); }); + + it('should edit the permissions', async () => { + const promise = Promise.resolve({server: { + name: 'server-1', + url: 'http://server-1.com', + }, + permissions: { + notifications: { + alwaysDeny: true, + }, + }}); + ModalManager.addModal.mockReturnValue(promise); + + serverViewState.showEditServerModal(null, 'server-1'); + await promise; + expect(PermissionsManager.setForServer).toHaveBeenCalledWith(expect.objectContaining({ + id: 'server-1', + name: 'server-1', + url: 'http://server-1.com', + tabs, + }), { + notifications: { + alwaysDeny: true, + }, + }); + }); }); describe('handleRemoveServerModal', () => { diff --git a/src/app/serverViewState.ts b/src/app/serverViewState.ts index 1817e683..eb34342c 100644 --- a/src/app/serverViewState.ts +++ b/src/app/serverViewState.ts @@ -26,13 +26,15 @@ import {MattermostServer} from 'common/servers/MattermostServer'; import ServerManager from 'common/servers/serverManager'; import {URLValidationStatus} from 'common/utils/constants'; import {isValidURI, isValidURL, parseURL} from 'common/utils/url'; +import PermissionsManager from 'main/permissionsManager'; import {ServerInfo} from 'main/server/serverInfo'; import {getLocalPreload, getLocalURLString} from 'main/utils'; import ModalManager from 'main/views/modalManager'; import ViewManager from 'main/views/viewManager'; import MainWindow from 'main/windows/mainWindow'; -import type {UniqueServer, Server} from 'types/config'; +import type {Server} from 'types/config'; +import type {Permissions, UniqueServerWithPermissions} from 'types/permissions'; import type {URLValidationResult} from 'types/server'; const log = new Logger('App', 'ServerViewState'); @@ -161,14 +163,17 @@ export class ServerViewState { return; } - const modalPromise = ModalManager.addModal( + const modalPromise = ModalManager.addModal( 'editServer', getLocalURLString('editServer.html'), getLocalPreload('internalAPI.js'), - server.toUniqueServer(), + {server: server.toUniqueServer(), permissions: PermissionsManager.getForServer(server) ?? {}}, mainWindow); - modalPromise.then((data) => ServerManager.editServer(id, data)).catch((e) => { + modalPromise.then((data) => { + ServerManager.editServer(id, data.server); + PermissionsManager.setForServer(server, data.permissions); + }).catch((e) => { // e is undefined for user cancellation if (e) { log.error(`there was an error in the edit server modal: ${e}`); diff --git a/src/common/communication.ts b/src/common/communication.ts index 6be01066..a0e4d448 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -184,5 +184,10 @@ export const BROWSER_HISTORY_STATUS_UPDATED = 'browser-history-status-updated'; export const NOTIFICATION_CLICKED = 'notification-clicked'; +export const OPEN_NOTIFICATION_PREFERENCES = 'open-notification-preferences'; +export const OPEN_WINDOWS_CAMERA_PREFERENCES = 'open-windows-camera-preferences'; +export const OPEN_WINDOWS_MICROPHONE_PREFERENCES = 'open-windows-microphone-preferences'; +export const GET_MEDIA_ACCESS_STATUS = 'get-media-access-status'; + // Legacy code remove signal export const LEGACY_OFF = 'legacy-off'; diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index e7939c3c..41a5d73b 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -5,7 +5,7 @@ import {app, shell, Notification, ipcMain} from 'electron'; import isDev from 'electron-is-dev'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; -import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH} from 'common/communication'; +import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication'; import Config from 'common/config'; import {Logger} from 'common/log'; @@ -27,6 +27,10 @@ class NotificationManager { private upgradeNotification?: NewVersionNotification; private restartToUpgradeNotification?: UpgradeNotification; + constructor() { + ipcMain.on(OPEN_NOTIFICATION_PREFERENCES, this.openNotificationPreferences); + } + public async displayMention(title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, soundName: string) { log.debug('displayMention', {title, channelId, teamId, url, silent, soundName}); @@ -209,6 +213,17 @@ class NotificationManager { }); this.restartToUpgradeNotification.show(); } + + private openNotificationPreferences() { + switch (process.platform) { + case 'darwin': + shell.openExternal('x-apple.systempreferences:com.apple.preference.notifications?Notifications'); + break; + case 'win32': + shell.openExternal('ms-settings:notifications'); + break; + } + } } export async function getDoNotDisturb() { diff --git a/src/main/permissionsManager.test.js b/src/main/permissionsManager.test.js index d1025ee2..9118b9c0 100644 --- a/src/main/permissionsManager.test.js +++ b/src/main/permissionsManager.test.js @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {dialog} from 'electron'; +import {dialog, systemPreferences} from 'electron'; import {parseURL, isTrustedURL} from 'common/utils/url'; import ViewManager from 'main/views/viewManager'; @@ -21,10 +21,15 @@ jest.mock('electron', () => ({ }, ipcMain: { on: jest.fn(), + handle: jest.fn(), }, dialog: { showMessageBox: jest.fn(), }, + systemPreferences: { + getMediaAccessStatus: jest.fn(), + askForMediaAccess: jest.fn(), + }, })); jest.mock('common/utils/url', () => ({ @@ -47,169 +52,185 @@ jest.mock('main/windows/mainWindow', () => ({ })); describe('main/PermissionsManager', () => { - beforeEach(() => { - MainWindow.get.mockReturnValue({webContents: {id: 1}}); - ViewManager.getViewByWebContentsId.mockImplementation((id) => { - if (id === 2) { - return {view: {server: {url: new URL('http://anyurl.com')}}}; - } - - return null; + describe('setForServer', () => { + it('should ask for media permission when is not granted but the user explicitly granted it', () => { + systemPreferences.getMediaAccessStatus.mockReturnValue('denied'); + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.setForServer({url: new URL('http://anyurl.com')}, {media: {allowed: true}}); + expect(systemPreferences.askForMediaAccess).toHaveBeenNthCalledWith(1, 'microphone'); + expect(systemPreferences.askForMediaAccess).toHaveBeenNthCalledWith(2, 'camera'); }); - CallsWidgetWindow.isCallsWidget.mockImplementation((id) => id === 3); - parseURL.mockImplementation((url) => { - try { - return new URL(url); - } catch { + }); + + describe('handlePermissionRequest', () => { + const env = process.env; + + beforeEach(() => { + process.env = {...env, NODE_ENV: 'jest'}; + MainWindow.get.mockReturnValue({webContents: {id: 1}}); + ViewManager.getViewByWebContentsId.mockImplementation((id) => { + if (id === 2) { + return {view: {server: {url: new URL('http://anyurl.com')}}}; + } + return null; - } + }); + CallsWidgetWindow.isCallsWidget.mockImplementation((id) => id === 3); + parseURL.mockImplementation((url) => { + try { + return new URL(url); + } catch { + return null; + } + }); + isTrustedURL.mockImplementation((url, baseURL) => url.toString().startsWith(baseURL.toString())); }); - isTrustedURL.mockImplementation((url, baseURL) => url.toString().startsWith(baseURL.toString())); - }); - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should deny if the permission is not supported', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({}, 'some-other-permission', cb, {securityOrigin: 'http://anyurl.com'}); - expect(cb).toHaveBeenCalledWith(false); - }); - - it('should allow if the request came from the main window', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({id: 1}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(cb).toHaveBeenCalledWith(true); - }); - - it('should deny if the URL is malformed', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'abadurl!?'}); - expect(cb).toHaveBeenCalledWith(false); - }); - - it('should deny if the server URL can not be found', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({id: 4}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(cb).toHaveBeenCalledWith(false); - }); - - it('should deny if the URL is not trusted', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://wrongurl.com'}); - expect(cb).toHaveBeenCalledWith(false); - }); - - it('should allow if dialog is not required', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({id: 2}, 'fullscreen', cb, {requestingUrl: 'http://anyurl.com'}); - expect(cb).toHaveBeenCalledWith(true); - }); - - it('should allow if already confirmed by user', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.json = { - 'http://anyurl.com': { - media: { - allowed: true, - }, - }, - }; - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(cb).toHaveBeenCalledWith(true); - }); - - it('should deny if set to permanently deny', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.json = { - 'http://anyurl.com': { - media: { - alwaysDeny: true, - }, - }, - }; - const cb = jest.fn(); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(cb).toHaveBeenCalledWith(false); - }); - - it('should pop dialog and allow if the user allows, should save to file', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.writeToFile = jest.fn(); - const cb = jest.fn(); - dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(true); - expect(permissionsManager.writeToFile).toHaveBeenCalled(); - expect(cb).toHaveBeenCalledWith(true); - }); - - it('should pop dialog and deny if the user denies', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.writeToFile = jest.fn(); - const cb = jest.fn(); - dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 0})); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false); - expect(permissionsManager.writeToFile).toHaveBeenCalled(); - expect(cb).toHaveBeenCalledWith(false); - }); - - it('should pop dialog and deny permanently if the user chooses', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.writeToFile = jest.fn(); - const cb = jest.fn(); - dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 1})); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false); - expect(permissionsManager.json['http://anyurl.com'].media.alwaysDeny).toBe(true); - expect(permissionsManager.writeToFile).toHaveBeenCalled(); - expect(cb).toHaveBeenCalledWith(false); - }); - - it('should only pop dialog once upon multiple permission checks', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.writeToFile = jest.fn(); - const cb = jest.fn(); - dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); - await Promise.all([ - permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}), - permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}), - permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}), - ]); - expect(dialog.showMessageBox).toHaveBeenCalledTimes(1); - }); - - it('should still pop dialog for media requests from the servers origin', async () => { - ViewManager.getViewByWebContentsId.mockImplementation((id) => { - if (id === 2) { - return {view: {server: {url: new URL('http://anyurl.com/subpath')}}}; - } - - return null; + afterEach(() => { + jest.resetAllMocks(); + process.env = env; }); - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.writeToFile = jest.fn(); - const cb = jest.fn(); - dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); - await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); - expect(dialog.showMessageBox).toHaveBeenCalled(); - }); - it('should pop dialog for external applications', async () => { - const permissionsManager = new PermissionsManager('anyfile.json'); - permissionsManager.writeToFile = jest.fn(); - const cb = jest.fn(); - dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); - await permissionsManager.handlePermissionRequest({id: 2}, 'openExternal', cb, {requestingUrl: 'http://anyurl.com', externalURL: 'ms-excel://differenturl.com'}); - expect(dialog.showMessageBox).toHaveBeenCalled(); + it('should deny if the permission is not supported', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({}, 'some-other-permission', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should allow if the request came from the main window', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 1}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should deny if the URL is malformed', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'abadurl!?'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should deny if the server URL can not be found', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 4}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should deny if the URL is not trusted', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://wrongurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should allow if dialog is not required', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'fullscreen', cb, {requestingUrl: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should allow if already confirmed by user', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.json = { + 'http://anyurl.com': { + media: { + allowed: true, + }, + }, + }; + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should deny if set to permanently deny', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.json = { + 'http://anyurl.com': { + media: { + alwaysDeny: true, + }, + }, + }; + const cb = jest.fn(); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should pop dialog and allow if the user allows, should save to file', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(true); + expect(permissionsManager.writeToFile).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(true); + }); + + it('should pop dialog and deny if the user denies', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 0})); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false); + expect(permissionsManager.writeToFile).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should pop dialog and deny permanently if the user chooses', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 1})); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false); + expect(permissionsManager.json['http://anyurl.com'].media.alwaysDeny).toBe(true); + expect(permissionsManager.writeToFile).toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(false); + }); + + it('should only pop dialog once upon multiple permission checks', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); + await Promise.all([ + permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}), + permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}), + permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}), + ]); + expect(dialog.showMessageBox).toHaveBeenCalledTimes(1); + }); + + it('should still pop dialog for media requests from the servers origin', async () => { + ViewManager.getViewByWebContentsId.mockImplementation((id) => { + if (id === 2) { + return {view: {server: {url: new URL('http://anyurl.com/subpath')}}}; + } + + return null; + }); + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); + await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'}); + expect(dialog.showMessageBox).toHaveBeenCalled(); + }); + + it('should pop dialog for external applications', async () => { + const permissionsManager = new PermissionsManager('anyfile.json'); + permissionsManager.writeToFile = jest.fn(); + const cb = jest.fn(); + dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2})); + await permissionsManager.handlePermissionRequest({id: 2}, 'openExternal', cb, {requestingUrl: 'http://anyurl.com', externalURL: 'ms-excel://differenturl.com'}); + expect(dialog.showMessageBox).toHaveBeenCalled(); + }); }); }); diff --git a/src/main/permissionsManager.ts b/src/main/permissionsManager.ts index 7ec773e1..2755c7ea 100644 --- a/src/main/permissionsManager.ts +++ b/src/main/permissionsManager.ts @@ -2,17 +2,26 @@ // See LICENSE.txt for license information. import type { + IpcMainInvokeEvent, PermissionRequestHandlerHandlerDetails, WebContents} from 'electron'; import { app, dialog, ipcMain, + shell, + systemPreferences, } from 'electron'; -import {UPDATE_PATHS} from 'common/communication'; +import { + GET_MEDIA_ACCESS_STATUS, + OPEN_WINDOWS_CAMERA_PREFERENCES, + OPEN_WINDOWS_MICROPHONE_PREFERENCES, + UPDATE_PATHS, +} from 'common/communication'; import JsonFileManager from 'common/JsonFileManager'; import {Logger} from 'common/log'; +import type {MattermostServer} from 'common/servers/MattermostServer'; import {isTrustedURL, parseURL} from 'common/utils/url'; import {t} from 'common/utils/util'; import {permissionsJson} from 'main/constants'; @@ -21,6 +30,8 @@ import ViewManager from 'main/views/viewManager'; import CallsWidgetWindow from 'main/windows/callsWidgetWindow'; import MainWindow from 'main/windows/mainWindow'; +import type {Permissions} from 'types/permissions'; + const log = new Logger('PermissionsManager'); // supported permission types @@ -41,22 +52,21 @@ const authorizablePermissionTypes = [ 'openExternal', ]; -type Permissions = { - [origin: string]: { - [permission: string]: { - allowed: boolean; - alwaysDeny?: boolean; - }; - }; +type PermissionsByOrigin = { + [origin: string]: Permissions; }; -export class PermissionsManager extends JsonFileManager { - private inflightPermissionChecks: Set; +export class PermissionsManager extends JsonFileManager { + private inflightPermissionChecks: Map>; constructor(file: string) { super(file); - this.inflightPermissionChecks = new Set(); + this.inflightPermissionChecks = new Map(); + + ipcMain.on(OPEN_WINDOWS_CAMERA_PREFERENCES, this.openWindowsCameraPreferences); + ipcMain.on(OPEN_WINDOWS_MICROPHONE_PREFERENCES, this.openWindowsMicrophonePreferences); + ipcMain.handle(GET_MEDIA_ACCESS_STATUS, this.handleGetMediaAccessStatus); } handlePermissionRequest = async ( @@ -72,6 +82,30 @@ export class PermissionsManager extends JsonFileManager { )); }; + getForServer = (server: MattermostServer): Permissions | undefined => { + return this.getValue(server.url.origin); + }; + + setForServer = (server: MattermostServer, permissions: Permissions) => { + if (permissions.media?.allowed) { + this.checkMediaAccess('microphone'); + this.checkMediaAccess('camera'); + } + + return this.setValue(server.url.origin, permissions); + }; + + private checkMediaAccess = (mediaType: 'microphone' | 'camera') => { + if (systemPreferences.getMediaAccessStatus(mediaType) !== 'granted') { + // For windows, the user needs to enable these manually + if (process.platform === 'win32') { + log.warn(`${mediaType} access disabled in Windows settings`); + } + + systemPreferences.askForMediaAccess(mediaType); + } + }; + doPermissionRequest = async ( webContentsId: number, permission: string, @@ -141,44 +175,59 @@ export class PermissionsManager extends JsonFileManager { // Make sure we don't pop multiple dialogs for the same permission check const permissionKey = `${parsedURL.origin}:${permission}`; if (this.inflightPermissionChecks.has(permissionKey)) { - return false; + return this.inflightPermissionChecks.get(permissionKey)!; } - this.inflightPermissionChecks.add(permissionKey); - // Show the dialog to ask the user - const {response} = await dialog.showMessageBox(mainWindow, { - title: localizeMessage('main.permissionsManager.checkPermission.dialog.title', 'Permission Requested'), - message: localizeMessage(`main.permissionsManager.checkPermission.dialog.message.${permission}`, '{appName} ({url}) is requesting the "{permission}" permission.', {appName: app.name, url: parsedURL.origin, permission, externalURL: details.externalURL}), - detail: localizeMessage(`main.permissionsManager.checkPermission.dialog.detail.${permission}`, 'Would you like to grant {appName} this permission?', {appName: app.name}), - type: 'question', - buttons: [ - localizeMessage('label.deny', 'Deny'), - localizeMessage('label.denyPermanently', 'Deny Permanently'), - localizeMessage('label.allow', 'Allow'), - ], + const promise = new Promise((resolve) => { + if (process.env.NODE_ENV === 'test') { + resolve(false); + return; + } + + // Show the dialog to ask the user + dialog.showMessageBox(mainWindow, { + title: localizeMessage('main.permissionsManager.checkPermission.dialog.title', 'Permission Requested'), + message: localizeMessage(`main.permissionsManager.checkPermission.dialog.message.${permission}`, '{appName} ({url}) is requesting the "{permission}" permission.', {appName: app.name, url: parsedURL.origin, permission, externalURL: details.externalURL}), + detail: localizeMessage(`main.permissionsManager.checkPermission.dialog.detail.${permission}`, 'Would you like to grant {appName} this permission?', {appName: app.name}), + type: 'question', + buttons: [ + localizeMessage('label.deny', 'Deny'), + localizeMessage('label.denyPermanently', 'Deny Permanently'), + localizeMessage('label.allow', 'Allow'), + ], + }).then(({response}) => { + // Save their response + const newPermission = { + allowed: response === 2, + alwaysDeny: (response === 1) ? true : undefined, + }; + this.json[parsedURL.origin] = { + ...this.json[parsedURL.origin], + [permission]: newPermission, + }; + this.writeToFile(); + + this.inflightPermissionChecks.delete(permissionKey); + + if (response < 2) { + resolve(false); + } + + resolve(true); + }); }); - // Save their response - const newPermission = { - allowed: response === 2, - alwaysDeny: (response === 1) ? true : undefined, - }; - this.json[parsedURL.origin] = { - ...this.json[parsedURL.origin], - [permission]: newPermission, - }; - this.writeToFile(); - - this.inflightPermissionChecks.delete(permissionKey); - - if (response < 2) { - return false; - } + this.inflightPermissionChecks.set(permissionKey, promise); + return promise; } // We've checked everything so we're okay to grant the remaining cases return true; }; + + private openWindowsCameraPreferences = () => shell.openExternal('ms-settings:privacy-webcam'); + private openWindowsMicrophonePreferences = () => shell.openExternal('ms-settings:privacy-microphone'); + private handleGetMediaAccessStatus = (event: IpcMainInvokeEvent, mediaType: 'microphone' | 'camera' | 'screen') => systemPreferences.getMediaAccessStatus(mediaType); } t('main.permissionsManager.checkPermission.dialog.message.media'); diff --git a/src/main/preload/internalAPI.js b/src/main/preload/internalAPI.js index 6b759b24..935a9321 100644 --- a/src/main/preload/internalAPI.js +++ b/src/main/preload/internalAPI.js @@ -90,6 +90,10 @@ import { SERVERS_UPDATE, VALIDATE_SERVER_URL, GET_APP_INFO, + OPEN_NOTIFICATION_PREFERENCES, + OPEN_WINDOWS_CAMERA_PREFERENCES, + OPEN_WINDOWS_MICROPHONE_PREFERENCES, + GET_MEDIA_ACCESS_STATUS, } from 'common/communication'; console.log('Preload initialized'); @@ -175,6 +179,10 @@ contextBridge.exposeInMainWorld('desktop', { onAppMenuWillClose: (listener) => ipcRenderer.on(APP_MENU_WILL_CLOSE, () => listener()), onFocusThreeDotMenu: (listener) => ipcRenderer.on(FOCUS_THREE_DOT_MENU, () => listener()), updateURLViewWidth: (width) => ipcRenderer.send(UPDATE_URL_VIEW_WIDTH, width), + openNotificationPreferences: () => ipcRenderer.send(OPEN_NOTIFICATION_PREFERENCES), + openWindowsCameraPreferences: () => ipcRenderer.send(OPEN_WINDOWS_CAMERA_PREFERENCES), + openWindowsMicrophonePreferences: () => ipcRenderer.send(OPEN_WINDOWS_MICROPHONE_PREFERENCES), + getMediaAccessStatus: (mediaType) => ipcRenderer.invoke(GET_MEDIA_ACCESS_STATUS, mediaType), downloadsDropdown: { toggleDownloadsDropdownMenu: (payload) => ipcRenderer.send(TOGGLE_DOWNLOADS_DROPDOWN_MENU, payload), diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index dd32fad5..339ac874 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -8,6 +8,7 @@ import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, SET_ACTIVE_VIEW} from 'common/commun import ServerManager from 'common/servers/serverManager'; import urlUtils from 'common/utils/url'; import {TAB_MESSAGING} from 'common/views/View'; +import PermissionsManager from 'main/permissionsManager'; import MainWindow from 'main/windows/mainWindow'; import LoadingScreen from './loadingScreen'; @@ -64,6 +65,11 @@ jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), })); +jest.mock('main/permissionsManager', () => ({ + getForServer: jest.fn(), + doPermissionRequest: jest.fn(), +})); + jest.mock('main/server/serverInfo', () => ({ ServerInfo: jest.fn(), })); @@ -129,6 +135,8 @@ describe('main/views/viewManager', () => { once: onceFn, destroy: destroyFn, id: view.id, + view, + webContentsId: 1, })); }); @@ -143,19 +151,48 @@ describe('main/views/viewManager', () => { expect(viewManager.closedViews.has('view1')).toBe(true); }); - it('should remove from remove from closedViews when the view is open', () => { - viewManager.closedViews.set('view1', {}); - expect(viewManager.closedViews.has('view1')).toBe(true); - viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true}); - expect(viewManager.closedViews.has('view1')).toBe(false); - }); - it('should add view to views map and add listeners', () => { viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true}, 'http://server-1.com/subpath'); expect(viewManager.views.has('view1')).toBe(true); expect(onceFn).toHaveBeenCalledWith(LOAD_SUCCESS, viewManager.activateView); expect(loadFn).toHaveBeenCalledWith('http://server-1.com/subpath'); }); + + it('should force a permission check for new views', () => { + viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true, type: TAB_MESSAGING, server: {url: new URL('http://server-1.com')}}, 'http://server-1.com/subpath'); + expect(PermissionsManager.doPermissionRequest).toBeCalledWith( + 1, + 'notifications', + { + requestingUrl: 'http://server-1.com/', + isMainFrame: false, + }, + ); + }); + }); + + describe('openClosedView', () => { + const viewManager = new ViewManager(); + + beforeEach(() => { + viewManager.showById = jest.fn(); + MainWindow.get.mockReturnValue({}); + MattermostBrowserView.mockImplementation((view) => ({ + on: jest.fn(), + load: jest.fn(), + once: jest.fn(), + destroy: jest.fn(), + id: view.id, + view, + })); + }); + + it('should remove from closedViews when the view is open', () => { + viewManager.closedViews.set('view1', {srv: {id: 'server1'}, view: {id: 'view1'}}); + expect(viewManager.closedViews.has('view1')).toBe(true); + viewManager.openClosedView('view1'); + expect(viewManager.closedViews.has('view1')).toBe(false); + }); }); describe('reload', () => { diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 63e37729..936e5afd 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -45,6 +45,7 @@ import type {MattermostView} from 'common/views/View'; import {TAB_MESSAGING} from 'common/views/View'; import {flushCookiesStore} from 'main/app/utils'; import {localizeMessage} from 'main/i18nManager'; +import PermissionsManager from 'main/permissionsManager'; import MainWindow from 'main/windows/mainWindow'; import LoadingScreen from './loadingScreen'; @@ -269,8 +270,20 @@ export class ViewManager { private addView = (view: MattermostBrowserView): void => { this.views.set(view.id, view); - if (this.closedViews.has(view.id)) { - this.closedViews.delete(view.id); + + // Force a permission check for notifications + if (view.view.type === TAB_MESSAGING) { + const notificationPermission = PermissionsManager.getForServer(view.view.server)?.notifications; + if (!notificationPermission || (!notificationPermission.allowed && notificationPermission.alwaysDeny !== true)) { + PermissionsManager.doPermissionRequest( + view.webContentsId, + 'notifications', + { + requestingUrl: view.view.server.url.toString(), + isMainFrame: false, + }, + ); + } } }; @@ -435,7 +448,7 @@ export class ViewManager { // commit views this.views = new Map(); for (const x of views.values()) { - this.views.set(x.id, x); + this.addView(x); } // commit closed @@ -617,6 +630,9 @@ export class ViewManager { const {srv, view} = this.closedViews.get(id)!; view.isOpen = true; this.loadView(srv, view, url); + if (this.closedViews.has(view.id)) { + this.closedViews.delete(view.id); + } this.showById(id); const browserView = this.views.get(id)!; browserView.isVisible = true; diff --git a/src/renderer/components/NewServerModal.tsx b/src/renderer/components/NewServerModal.tsx index f540e95d..ce4fac64 100644 --- a/src/renderer/components/NewServerModal.tsx +++ b/src/renderer/components/NewServerModal.tsx @@ -8,16 +8,19 @@ import type {IntlShape} from 'react-intl'; import {FormattedMessage, injectIntl} from 'react-intl'; import {URLValidationStatus} from 'common/utils/constants'; +import Toggle from 'renderer/components/Toggle'; import type {UniqueServer} from 'types/config'; +import type {Permissions} from 'types/permissions'; import type {URLValidationResult} from 'types/server'; import 'renderer/css/components/NewServerModal.scss'; type Props = { onClose?: () => void; - onSave?: (server: UniqueServer) => void; + onSave?: (server: UniqueServer, permissions?: Permissions) => void; server?: UniqueServer; + permissions?: Permissions; editMode?: boolean; show?: boolean; restoreFocus?: boolean; @@ -34,6 +37,9 @@ type State = { saveStarted: boolean; validationStarted: boolean; validationResult?: URLValidationResult; + permissions: Permissions; + cameraDisabled: boolean; + microphoneDisabled: boolean; } class NewServerModal extends React.PureComponent { @@ -57,6 +63,9 @@ class NewServerModal extends React.PureComponent { serverOrder: props.currentOrder || 0, saveStarted: false, validationStarted: false, + permissions: {}, + cameraDisabled: false, + microphoneDisabled: false, }; } @@ -68,7 +77,10 @@ class NewServerModal extends React.PureComponent { this.mounted = false; } - initializeOnShow = () => { + initializeOnShow = async () => { + const cameraDisabled = window.process.platform === 'win32' && await window.desktop.getMediaAccessStatus('camera') !== 'granted'; + const microphoneDisabled = window.process.platform === 'win32' && await window.desktop.getMediaAccessStatus('microphone') !== 'granted'; + this.setState({ serverName: this.props.server ? this.props.server.name : '', serverUrl: this.props.server ? this.props.server.url : '', @@ -76,6 +88,9 @@ class NewServerModal extends React.PureComponent { saveStarted: false, validationStarted: false, validationResult: undefined, + permissions: this.props.permissions ?? {}, + cameraDisabled, + microphoneDisabled, }); if (this.props.editMode && this.props.server) { @@ -95,6 +110,20 @@ class NewServerModal extends React.PureComponent { this.validateServerURL(serverUrl); }; + handleChangePermission = (permissionKey: string) => { + return (e: React.ChangeEvent) => { + this.setState({ + permissions: { + ...this.state.permissions, + [permissionKey]: { + allowed: e.target.checked, + alwaysDeny: e.target.checked ? undefined : true, + }, + }, + }); + }; + }; + validateServerURL = (serverUrl: string) => { clearTimeout(this.validationTimeout as unknown as number); this.validationTimeout = setTimeout(() => { @@ -253,6 +282,18 @@ class NewServerModal extends React.PureComponent { ); }; + openNotificationPrefs = () => { + window.desktop.openNotificationPreferences(); + }; + + openWindowsCameraPrefs = () => { + window.desktop.openWindowsCameraPreferences(); + }; + + openWindowsMicrophonePrefs = () => { + window.desktop.openWindowsMicrophonePreferences(); + }; + getServerNameMessage = () => { if (!this.state.serverName.length) { return ( @@ -287,7 +328,7 @@ class NewServerModal extends React.PureComponent { url: this.state.serverUrl, name: this.state.serverName, id: this.state.serverId, - }); + }, this.state.permissions); }); }; @@ -331,6 +372,17 @@ class NewServerModal extends React.PureComponent { } this.wasShown = this.props.show; + const notificationValues = { + link: (msg: React.ReactNode) => ( + + {msg} + + ), + }; + return ( { {this.getServerNameMessage()} {this.getServerURLMessage()} + {this.props.editMode && + <> +
+
+ +
+ + +
+ + {this.state.cameraDisabled && + + ( + + {msg} + + ), + }} + /> + + } + {this.state.microphoneDisabled && + + ( + + {msg} + + ), + }} + /> + + } +
+
+ + +
+ + {window.process.platform === 'darwin' && + + + + } + {window.process.platform === 'win32' && + + + + } +
+
+ + + + + + } diff --git a/src/renderer/components/Toggle.tsx b/src/renderer/components/Toggle.tsx new file mode 100644 index 00000000..d6d4ad3e --- /dev/null +++ b/src/renderer/components/Toggle.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; + +import 'renderer/css/components/Toggle.scss'; + +interface ToggleProps { + children?: React.ReactNode; + isChecked: boolean; + disabled?: boolean; + onChange: React.ChangeEventHandler; +} + +export default function Toggle({children, isChecked, disabled, onChange}: ToggleProps) { + return ( + + ); +} diff --git a/src/renderer/css/_css_variables.scss b/src/renderer/css/_css_variables.scss index 26a07fab..fa6a2f3b 100644 --- a/src/renderer/css/_css_variables.scss +++ b/src/renderer/css/_css_variables.scss @@ -15,6 +15,7 @@ --title-color-indigo-500: #1e325c; --button-color-rgb: 255, 255, 255; + --center-channel-bg-rgb: 255, 255, 255; --center-channel-color-rgb: 61, 60, 64; --center-channel-text-rgb: 63, 67, 80; --link-color-inverted-rgb: 129, 163, 239; diff --git a/src/renderer/css/components/Toggle.scss b/src/renderer/css/components/Toggle.scss new file mode 100644 index 00000000..c5af8f85 --- /dev/null +++ b/src/renderer/css/components/Toggle.scss @@ -0,0 +1,68 @@ +@import url("../_css_variables.scss"); + +.Toggle { + display: flex; + column-gap: 12px; + font-weight: inherit; + line-height: 16px; + cursor: pointer; + margin-bottom: 0; + padding: 10px 0; + + &.disabled { + cursor: default; + } + + .Toggle___input { + display: none; + } + + .Toggle___switch { + position: relative; + display: inline-block; + top: 0; + left: 0; + right: 0; + bottom: 0; + transition: .4s; + margin-left: auto; + + // Outer rectangle + min-width: 40px; + height: 24px; + border-radius: 14px; + background: rgba(var(--center-channel-color-rgb), 0.24); + + &.disabled { + background: rgba(var(--center-channel-color-rgb), 0.08); + } + + // Inner circle + &::before { + position: absolute; + width: 20px; + height: 20px; + left: 2px; + top: calc(50% - 20px/2); + + border-radius: 50%; + background: var(--center-channel-bg); + box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.08); + + content: ""; + transition: .4s; + } + + &.isChecked { + background-color: var(--button-bg); + + &::before { + transform: translateX(16px); + } + + &.disabled { + background-color: var(--button-bg-30); + } + } + } + } \ No newline at end of file diff --git a/src/renderer/css/lazy/modals-dark.lazy.css b/src/renderer/css/lazy/modals-dark.lazy.css index 32085b26..68da8d89 100644 --- a/src/renderer/css/lazy/modals-dark.lazy.css +++ b/src/renderer/css/lazy/modals-dark.lazy.css @@ -25,4 +25,12 @@ body { overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--light) rgba(255, 255, 255, 0); +} + +.Toggle .Toggle___switch { + background: rgba(var(--center-channel-bg-rgb), 0.24); +} + +.Toggle .Toggle___switch.disabled { + background: rgba(var(--center-channel-bg-rgb), 0.08); } \ No newline at end of file diff --git a/src/renderer/modals/editServer/editServer.tsx b/src/renderer/modals/editServer/editServer.tsx index bd488d75..576cf6be 100644 --- a/src/renderer/modals/editServer/editServer.tsx +++ b/src/renderer/modals/editServer/editServer.tsx @@ -9,7 +9,8 @@ import ReactDOM from 'react-dom'; import IntlProvider from 'renderer/intl_provider'; -import type {UniqueServer} from 'types/config'; +import type {Server} from 'types/config'; +import type {Permissions, UniqueServerWithPermissions} from 'types/permissions'; import NewServerModal from '../../components/NewServerModal'; import setupDarkMode from '../darkMode'; @@ -20,17 +21,18 @@ const onClose = () => { window.desktop.modals.cancelModal(); }; -const onSave = (data: UniqueServer) => { - window.desktop.modals.finishModal(data); +const onSave = (server: Server, permissions?: Permissions) => { + window.desktop.modals.finishModal({server, permissions}); }; const EditServerModalWrapper: React.FC = () => { - const [server, setServer] = useState(); + const [data, setData] = useState(); useEffect(() => { - window.desktop.modals.getModalInfo().then((server) => { - setServer(server); - }); + window.desktop.modals.getModalInfo(). + then((data) => { + setData(data); + }); }, []); return ( @@ -39,8 +41,9 @@ const EditServerModalWrapper: React.FC = () => { onClose={onClose} onSave={onSave} editMode={true} - show={Boolean(server)} - server={server} + show={Boolean(data?.server)} + server={data?.server} + permissions={data?.permissions} /> ); diff --git a/src/types/permissions.ts b/src/types/permissions.ts new file mode 100644 index 00000000..692890bc --- /dev/null +++ b/src/types/permissions.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {UniqueServer} from './config'; + +export type Permissions = { + [permission: string]: { + allowed: boolean; + alwaysDeny?: boolean; + }; +}; + +export type UniqueServerWithPermissions = {server: UniqueServer; permissions: Permissions}; diff --git a/src/types/window.ts b/src/types/window.ts index 9a09f122..e56dd817 100644 --- a/src/types/window.ts +++ b/src/types/window.ts @@ -96,6 +96,10 @@ declare global { onFocusThreeDotMenu: (listener: () => void) => void; updateURLViewWidth: (width?: number) => void; + openNotificationPreferences: () => void; + openWindowsCameraPreferences: () => void; + openWindowsMicrophonePreferences: () => void; + getMediaAccessStatus: (mediaType: 'microphone' | 'camera' | 'screen') => Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'>; modals: { cancelModal: (data?: T) => void;