[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
This commit is contained in:
Devin Binnie 2024-06-19 09:19:24 -04:00 committed by GitHub
parent 7b1b25b6e0
commit 0d4800fd61
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 711 additions and 234 deletions

View file

@ -163,6 +163,14 @@
"renderer.components.newServerModal.error.serverUrlExists": "A server with the same URL already exists.", "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.urlIncorrectFormatting": "URL is not formatted correctly.",
"renderer.components.newServerModal.error.urlRequired": "URL is required.", "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 <link>here</link> to open the Camera Settings.",
"renderer.components.newServerModal.permissions.microphoneAndCamera.windowsMicrophoneaPermissions": "Microphone is disabled in Windows Settings. Click <link>here</link> 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 <link>here</link> to open the System Preferences.",
"renderer.components.newServerModal.permissions.notifications.windows": "You may also need to enable notifications in Windows for Mattermost. Click <link>here</link> to open the Notification Settings.",
"renderer.components.newServerModal.permissions.title": "Permissions",
"renderer.components.newServerModal.serverDisplayName": "Server Display Name", "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.serverDisplayName.description": "The name of the server displayed on your desktop app tab bar.",
"renderer.components.newServerModal.serverURL": "Server URL", "renderer.components.newServerModal.serverURL": "Server URL",

8
package-lock.json generated
View file

@ -10,7 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@mattermost/compass-icons": "0.1.43", "@mattermost/compass-icons": "0.1.45",
"auto-launch": "5.0.6", "auto-launch": "5.0.6",
"bootstrap": "4.6.1", "bootstrap": "4.6.1",
"bootstrap-dark": "1.0.3", "bootstrap-dark": "1.0.3",
@ -3730,9 +3730,9 @@
} }
}, },
"node_modules/@mattermost/compass-icons": { "node_modules/@mattermost/compass-icons": {
"version": "0.1.43", "version": "0.1.45",
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.43.tgz", "resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.45.tgz",
"integrity": "sha512-vQThJ4SAynnS2u94lQtZ9xANsStpVh8uTpsJascHJOWcavLuL2aDmMLgvg9EAx8Z1qRmTdP6hF5+IU5+9E9+Jg==" "integrity": "sha512-xNuQG6FpmIYh+7ZAP2Qs/kAgS/O23IWOMEymaVJHFvQq8buCLdQz/b/2WgJZSLeoJjdfqhRMDDJmgaG2UEQD1w=="
}, },
"node_modules/@mattermost/desktop-api": { "node_modules/@mattermost/desktop-api": {
"resolved": "api-types", "resolved": "api-types",

View file

@ -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-build-config": "tsc ./src/common/config/buildConfig.ts --outDir dist --baseUrl src --skipLibCheck && node scripts/check_build_config.js",
"check-types": "tsc", "check-types": "tsc",
"prune": "ts-prune", "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": "eslint --ext .js,.jsx,.tsx,.ts --cache .",
"lint:js-quiet": "npm run lint:js -- --quiet", "lint:js-quiet": "npm run lint:js -- --quiet",
"fix:js": "npm run lint:js-quiet -- --fix", "fix:js": "npm run lint:js-quiet -- --fix",
@ -159,7 +159,7 @@
"webpack-merge": "5.8.0" "webpack-merge": "5.8.0"
}, },
"dependencies": { "dependencies": {
"@mattermost/compass-icons": "0.1.43", "@mattermost/compass-icons": "0.1.45",
"auto-launch": "5.0.6", "auto-launch": "5.0.6",
"bootstrap": "4.6.1", "bootstrap": "4.6.1",
"bootstrap-dark": "1.0.3", "bootstrap-dark": "1.0.3",

View file

@ -5,6 +5,7 @@ import {MattermostServer} from 'common/servers/MattermostServer';
import ServerManager from 'common/servers/serverManager'; import ServerManager from 'common/servers/serverManager';
import {URLValidationStatus} from 'common/utils/constants'; import {URLValidationStatus} from 'common/utils/constants';
import {getDefaultViewsForConfigServer} from 'common/views/View'; import {getDefaultViewsForConfigServer} from 'common/views/View';
import PermissionsManager from 'main/permissionsManager';
import {ServerInfo} from 'main/server/serverInfo'; import {ServerInfo} from 'main/server/serverInfo';
import {getLocalURLString, getLocalPreload} from 'main/utils'; import {getLocalURLString, getLocalPreload} from 'main/utils';
import ModalManager from 'main/views/modalManager'; import ModalManager from 'main/views/modalManager';
@ -59,6 +60,10 @@ jest.mock('main/views/viewManager', () => ({
getView: jest.fn(), getView: jest.fn(),
showById: jest.fn(), showById: jest.fn(),
})); }));
jest.mock('main/permissionsManager', () => ({
getForServer: jest.fn(),
setForServer: jest.fn(),
}));
const tabs = [ const tabs = [
{ {
@ -243,6 +248,7 @@ describe('app/serverViewState', () => {
serversCopy = [newServer]; serversCopy = [newServer];
}); });
ServerManager.getAllServers.mockReturnValue(serversCopy.map((server) => ({...server, toUniqueServer: jest.fn()}))); 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', () => { it('should do nothing when the server cannot be found', () => {
@ -251,10 +257,10 @@ describe('app/serverViewState', () => {
}); });
it('should edit the existing server', async () => { it('should edit the existing server', async () => {
const promise = Promise.resolve({ const promise = Promise.resolve({server: {
name: 'new-server', name: 'new-server',
url: 'http://new-server.com', url: 'http://new-server.com',
}); }});
ModalManager.addModal.mockReturnValue(promise); ModalManager.addModal.mockReturnValue(promise);
serverViewState.showEditServerModal(null, 'server-1'); serverViewState.showEditServerModal(null, 'server-1');
@ -272,6 +278,32 @@ describe('app/serverViewState', () => {
tabs, 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', () => { describe('handleRemoveServerModal', () => {

View file

@ -26,13 +26,15 @@ import {MattermostServer} from 'common/servers/MattermostServer';
import ServerManager from 'common/servers/serverManager'; import ServerManager from 'common/servers/serverManager';
import {URLValidationStatus} from 'common/utils/constants'; import {URLValidationStatus} from 'common/utils/constants';
import {isValidURI, isValidURL, parseURL} from 'common/utils/url'; import {isValidURI, isValidURL, parseURL} from 'common/utils/url';
import PermissionsManager from 'main/permissionsManager';
import {ServerInfo} from 'main/server/serverInfo'; import {ServerInfo} from 'main/server/serverInfo';
import {getLocalPreload, getLocalURLString} from 'main/utils'; import {getLocalPreload, getLocalURLString} from 'main/utils';
import ModalManager from 'main/views/modalManager'; import ModalManager from 'main/views/modalManager';
import ViewManager from 'main/views/viewManager'; import ViewManager from 'main/views/viewManager';
import MainWindow from 'main/windows/mainWindow'; 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'; import type {URLValidationResult} from 'types/server';
const log = new Logger('App', 'ServerViewState'); const log = new Logger('App', 'ServerViewState');
@ -161,14 +163,17 @@ export class ServerViewState {
return; return;
} }
const modalPromise = ModalManager.addModal<UniqueServer, Server>( const modalPromise = ModalManager.addModal<UniqueServerWithPermissions, {server: Server; permissions: Permissions}>(
'editServer', 'editServer',
getLocalURLString('editServer.html'), getLocalURLString('editServer.html'),
getLocalPreload('internalAPI.js'), getLocalPreload('internalAPI.js'),
server.toUniqueServer(), {server: server.toUniqueServer(), permissions: PermissionsManager.getForServer(server) ?? {}},
mainWindow); 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 // e is undefined for user cancellation
if (e) { if (e) {
log.error(`there was an error in the edit server modal: ${e}`); log.error(`there was an error in the edit server modal: ${e}`);

View file

@ -184,5 +184,10 @@ export const BROWSER_HISTORY_STATUS_UPDATED = 'browser-history-status-updated';
export const NOTIFICATION_CLICKED = 'notification-clicked'; 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 // Legacy code remove signal
export const LEGACY_OFF = 'legacy-off'; export const LEGACY_OFF = 'legacy-off';

View file

@ -5,7 +5,7 @@ import {app, shell, Notification, ipcMain} from 'electron';
import isDev from 'electron-is-dev'; import isDev from 'electron-is-dev';
import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; 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 Config from 'common/config';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
@ -27,6 +27,10 @@ class NotificationManager {
private upgradeNotification?: NewVersionNotification; private upgradeNotification?: NewVersionNotification;
private restartToUpgradeNotification?: UpgradeNotification; 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) { 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}); log.debug('displayMention', {title, channelId, teamId, url, silent, soundName});
@ -209,6 +213,17 @@ class NotificationManager {
}); });
this.restartToUpgradeNotification.show(); 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() { export async function getDoNotDisturb() {

View file

@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {dialog} from 'electron'; import {dialog, systemPreferences} from 'electron';
import {parseURL, isTrustedURL} from 'common/utils/url'; import {parseURL, isTrustedURL} from 'common/utils/url';
import ViewManager from 'main/views/viewManager'; import ViewManager from 'main/views/viewManager';
@ -21,10 +21,15 @@ jest.mock('electron', () => ({
}, },
ipcMain: { ipcMain: {
on: jest.fn(), on: jest.fn(),
handle: jest.fn(),
}, },
dialog: { dialog: {
showMessageBox: jest.fn(), showMessageBox: jest.fn(),
}, },
systemPreferences: {
getMediaAccessStatus: jest.fn(),
askForMediaAccess: jest.fn(),
},
})); }));
jest.mock('common/utils/url', () => ({ jest.mock('common/utils/url', () => ({
@ -47,7 +52,21 @@ jest.mock('main/windows/mainWindow', () => ({
})); }));
describe('main/PermissionsManager', () => { describe('main/PermissionsManager', () => {
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');
});
});
describe('handlePermissionRequest', () => {
const env = process.env;
beforeEach(() => { beforeEach(() => {
process.env = {...env, NODE_ENV: 'jest'};
MainWindow.get.mockReturnValue({webContents: {id: 1}}); MainWindow.get.mockReturnValue({webContents: {id: 1}});
ViewManager.getViewByWebContentsId.mockImplementation((id) => { ViewManager.getViewByWebContentsId.mockImplementation((id) => {
if (id === 2) { if (id === 2) {
@ -69,6 +88,7 @@ describe('main/PermissionsManager', () => {
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
process.env = env;
}); });
it('should deny if the permission is not supported', async () => { it('should deny if the permission is not supported', async () => {
@ -212,4 +232,5 @@ describe('main/PermissionsManager', () => {
await permissionsManager.handlePermissionRequest({id: 2}, 'openExternal', cb, {requestingUrl: 'http://anyurl.com', externalURL: 'ms-excel://differenturl.com'}); await permissionsManager.handlePermissionRequest({id: 2}, 'openExternal', cb, {requestingUrl: 'http://anyurl.com', externalURL: 'ms-excel://differenturl.com'});
expect(dialog.showMessageBox).toHaveBeenCalled(); expect(dialog.showMessageBox).toHaveBeenCalled();
}); });
});
}); });

View file

@ -2,17 +2,26 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type { import type {
IpcMainInvokeEvent,
PermissionRequestHandlerHandlerDetails, PermissionRequestHandlerHandlerDetails,
WebContents} from 'electron'; WebContents} from 'electron';
import { import {
app, app,
dialog, dialog,
ipcMain, ipcMain,
shell,
systemPreferences,
} from 'electron'; } 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 JsonFileManager from 'common/JsonFileManager';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
import type {MattermostServer} from 'common/servers/MattermostServer';
import {isTrustedURL, parseURL} from 'common/utils/url'; import {isTrustedURL, parseURL} from 'common/utils/url';
import {t} from 'common/utils/util'; import {t} from 'common/utils/util';
import {permissionsJson} from 'main/constants'; import {permissionsJson} from 'main/constants';
@ -21,6 +30,8 @@ import ViewManager from 'main/views/viewManager';
import CallsWidgetWindow from 'main/windows/callsWidgetWindow'; import CallsWidgetWindow from 'main/windows/callsWidgetWindow';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
import type {Permissions} from 'types/permissions';
const log = new Logger('PermissionsManager'); const log = new Logger('PermissionsManager');
// supported permission types // supported permission types
@ -41,22 +52,21 @@ const authorizablePermissionTypes = [
'openExternal', 'openExternal',
]; ];
type Permissions = { type PermissionsByOrigin = {
[origin: string]: { [origin: string]: Permissions;
[permission: string]: {
allowed: boolean;
alwaysDeny?: boolean;
};
};
}; };
export class PermissionsManager extends JsonFileManager<Permissions> { export class PermissionsManager extends JsonFileManager<PermissionsByOrigin> {
private inflightPermissionChecks: Set<string>; private inflightPermissionChecks: Map<string, Promise<boolean>>;
constructor(file: string) { constructor(file: string) {
super(file); 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 ( handlePermissionRequest = async (
@ -72,6 +82,30 @@ export class PermissionsManager extends JsonFileManager<Permissions> {
)); ));
}; };
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 ( doPermissionRequest = async (
webContentsId: number, webContentsId: number,
permission: string, permission: string,
@ -141,12 +175,17 @@ export class PermissionsManager extends JsonFileManager<Permissions> {
// Make sure we don't pop multiple dialogs for the same permission check // Make sure we don't pop multiple dialogs for the same permission check
const permissionKey = `${parsedURL.origin}:${permission}`; const permissionKey = `${parsedURL.origin}:${permission}`;
if (this.inflightPermissionChecks.has(permissionKey)) { if (this.inflightPermissionChecks.has(permissionKey)) {
return false; return this.inflightPermissionChecks.get(permissionKey)!;
}
const promise = new Promise<boolean>((resolve) => {
if (process.env.NODE_ENV === 'test') {
resolve(false);
return;
} }
this.inflightPermissionChecks.add(permissionKey);
// Show the dialog to ask the user // Show the dialog to ask the user
const {response} = await dialog.showMessageBox(mainWindow, { dialog.showMessageBox(mainWindow, {
title: localizeMessage('main.permissionsManager.checkPermission.dialog.title', 'Permission Requested'), 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}), 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}), detail: localizeMessage(`main.permissionsManager.checkPermission.dialog.detail.${permission}`, 'Would you like to grant {appName} this permission?', {appName: app.name}),
@ -156,8 +195,7 @@ export class PermissionsManager extends JsonFileManager<Permissions> {
localizeMessage('label.denyPermanently', 'Deny Permanently'), localizeMessage('label.denyPermanently', 'Deny Permanently'),
localizeMessage('label.allow', 'Allow'), localizeMessage('label.allow', 'Allow'),
], ],
}); }).then(({response}) => {
// Save their response // Save their response
const newPermission = { const newPermission = {
allowed: response === 2, allowed: response === 2,
@ -172,13 +210,24 @@ export class PermissionsManager extends JsonFileManager<Permissions> {
this.inflightPermissionChecks.delete(permissionKey); this.inflightPermissionChecks.delete(permissionKey);
if (response < 2) { if (response < 2) {
return false; resolve(false);
} }
resolve(true);
});
});
this.inflightPermissionChecks.set(permissionKey, promise);
return promise;
} }
// We've checked everything so we're okay to grant the remaining cases // We've checked everything so we're okay to grant the remaining cases
return true; 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'); t('main.permissionsManager.checkPermission.dialog.message.media');

View file

@ -90,6 +90,10 @@ import {
SERVERS_UPDATE, SERVERS_UPDATE,
VALIDATE_SERVER_URL, VALIDATE_SERVER_URL,
GET_APP_INFO, GET_APP_INFO,
OPEN_NOTIFICATION_PREFERENCES,
OPEN_WINDOWS_CAMERA_PREFERENCES,
OPEN_WINDOWS_MICROPHONE_PREFERENCES,
GET_MEDIA_ACCESS_STATUS,
} from 'common/communication'; } from 'common/communication';
console.log('Preload initialized'); console.log('Preload initialized');
@ -175,6 +179,10 @@ contextBridge.exposeInMainWorld('desktop', {
onAppMenuWillClose: (listener) => ipcRenderer.on(APP_MENU_WILL_CLOSE, () => listener()), onAppMenuWillClose: (listener) => ipcRenderer.on(APP_MENU_WILL_CLOSE, () => listener()),
onFocusThreeDotMenu: (listener) => ipcRenderer.on(FOCUS_THREE_DOT_MENU, () => listener()), onFocusThreeDotMenu: (listener) => ipcRenderer.on(FOCUS_THREE_DOT_MENU, () => listener()),
updateURLViewWidth: (width) => ipcRenderer.send(UPDATE_URL_VIEW_WIDTH, width), 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: { downloadsDropdown: {
toggleDownloadsDropdownMenu: (payload) => ipcRenderer.send(TOGGLE_DOWNLOADS_DROPDOWN_MENU, payload), toggleDownloadsDropdownMenu: (payload) => ipcRenderer.send(TOGGLE_DOWNLOADS_DROPDOWN_MENU, payload),

View file

@ -8,6 +8,7 @@ import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, SET_ACTIVE_VIEW} from 'common/commun
import ServerManager from 'common/servers/serverManager'; import ServerManager from 'common/servers/serverManager';
import urlUtils from 'common/utils/url'; import urlUtils from 'common/utils/url';
import {TAB_MESSAGING} from 'common/views/View'; import {TAB_MESSAGING} from 'common/views/View';
import PermissionsManager from 'main/permissionsManager';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
import LoadingScreen from './loadingScreen'; import LoadingScreen from './loadingScreen';
@ -64,6 +65,11 @@ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
})); }));
jest.mock('main/permissionsManager', () => ({
getForServer: jest.fn(),
doPermissionRequest: jest.fn(),
}));
jest.mock('main/server/serverInfo', () => ({ jest.mock('main/server/serverInfo', () => ({
ServerInfo: jest.fn(), ServerInfo: jest.fn(),
})); }));
@ -129,6 +135,8 @@ describe('main/views/viewManager', () => {
once: onceFn, once: onceFn,
destroy: destroyFn, destroy: destroyFn,
id: view.id, id: view.id,
view,
webContentsId: 1,
})); }));
}); });
@ -143,19 +151,48 @@ describe('main/views/viewManager', () => {
expect(viewManager.closedViews.has('view1')).toBe(true); 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', () => { it('should add view to views map and add listeners', () => {
viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true}, 'http://server-1.com/subpath'); viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true}, 'http://server-1.com/subpath');
expect(viewManager.views.has('view1')).toBe(true); expect(viewManager.views.has('view1')).toBe(true);
expect(onceFn).toHaveBeenCalledWith(LOAD_SUCCESS, viewManager.activateView); expect(onceFn).toHaveBeenCalledWith(LOAD_SUCCESS, viewManager.activateView);
expect(loadFn).toHaveBeenCalledWith('http://server-1.com/subpath'); 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', () => { describe('reload', () => {

View file

@ -45,6 +45,7 @@ import type {MattermostView} from 'common/views/View';
import {TAB_MESSAGING} from 'common/views/View'; import {TAB_MESSAGING} from 'common/views/View';
import {flushCookiesStore} from 'main/app/utils'; import {flushCookiesStore} from 'main/app/utils';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import PermissionsManager from 'main/permissionsManager';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
import LoadingScreen from './loadingScreen'; import LoadingScreen from './loadingScreen';
@ -269,8 +270,20 @@ export class ViewManager {
private addView = (view: MattermostBrowserView): void => { private addView = (view: MattermostBrowserView): void => {
this.views.set(view.id, view); 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 // commit views
this.views = new Map(); this.views = new Map();
for (const x of views.values()) { for (const x of views.values()) {
this.views.set(x.id, x); this.addView(x);
} }
// commit closed // commit closed
@ -617,6 +630,9 @@ export class ViewManager {
const {srv, view} = this.closedViews.get(id)!; const {srv, view} = this.closedViews.get(id)!;
view.isOpen = true; view.isOpen = true;
this.loadView(srv, view, url); this.loadView(srv, view, url);
if (this.closedViews.has(view.id)) {
this.closedViews.delete(view.id);
}
this.showById(id); this.showById(id);
const browserView = this.views.get(id)!; const browserView = this.views.get(id)!;
browserView.isVisible = true; browserView.isVisible = true;

View file

@ -8,16 +8,19 @@ import type {IntlShape} from 'react-intl';
import {FormattedMessage, injectIntl} from 'react-intl'; import {FormattedMessage, injectIntl} from 'react-intl';
import {URLValidationStatus} from 'common/utils/constants'; import {URLValidationStatus} from 'common/utils/constants';
import Toggle from 'renderer/components/Toggle';
import type {UniqueServer} from 'types/config'; import type {UniqueServer} from 'types/config';
import type {Permissions} from 'types/permissions';
import type {URLValidationResult} from 'types/server'; import type {URLValidationResult} from 'types/server';
import 'renderer/css/components/NewServerModal.scss'; import 'renderer/css/components/NewServerModal.scss';
type Props = { type Props = {
onClose?: () => void; onClose?: () => void;
onSave?: (server: UniqueServer) => void; onSave?: (server: UniqueServer, permissions?: Permissions) => void;
server?: UniqueServer; server?: UniqueServer;
permissions?: Permissions;
editMode?: boolean; editMode?: boolean;
show?: boolean; show?: boolean;
restoreFocus?: boolean; restoreFocus?: boolean;
@ -34,6 +37,9 @@ type State = {
saveStarted: boolean; saveStarted: boolean;
validationStarted: boolean; validationStarted: boolean;
validationResult?: URLValidationResult; validationResult?: URLValidationResult;
permissions: Permissions;
cameraDisabled: boolean;
microphoneDisabled: boolean;
} }
class NewServerModal extends React.PureComponent<Props, State> { class NewServerModal extends React.PureComponent<Props, State> {
@ -57,6 +63,9 @@ class NewServerModal extends React.PureComponent<Props, State> {
serverOrder: props.currentOrder || 0, serverOrder: props.currentOrder || 0,
saveStarted: false, saveStarted: false,
validationStarted: false, validationStarted: false,
permissions: {},
cameraDisabled: false,
microphoneDisabled: false,
}; };
} }
@ -68,7 +77,10 @@ class NewServerModal extends React.PureComponent<Props, State> {
this.mounted = false; 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({ this.setState({
serverName: this.props.server ? this.props.server.name : '', serverName: this.props.server ? this.props.server.name : '',
serverUrl: this.props.server ? this.props.server.url : '', serverUrl: this.props.server ? this.props.server.url : '',
@ -76,6 +88,9 @@ class NewServerModal extends React.PureComponent<Props, State> {
saveStarted: false, saveStarted: false,
validationStarted: false, validationStarted: false,
validationResult: undefined, validationResult: undefined,
permissions: this.props.permissions ?? {},
cameraDisabled,
microphoneDisabled,
}); });
if (this.props.editMode && this.props.server) { if (this.props.editMode && this.props.server) {
@ -95,6 +110,20 @@ class NewServerModal extends React.PureComponent<Props, State> {
this.validateServerURL(serverUrl); this.validateServerURL(serverUrl);
}; };
handleChangePermission = (permissionKey: string) => {
return (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({
permissions: {
...this.state.permissions,
[permissionKey]: {
allowed: e.target.checked,
alwaysDeny: e.target.checked ? undefined : true,
},
},
});
};
};
validateServerURL = (serverUrl: string) => { validateServerURL = (serverUrl: string) => {
clearTimeout(this.validationTimeout as unknown as number); clearTimeout(this.validationTimeout as unknown as number);
this.validationTimeout = setTimeout(() => { this.validationTimeout = setTimeout(() => {
@ -253,6 +282,18 @@ class NewServerModal extends React.PureComponent<Props, State> {
); );
}; };
openNotificationPrefs = () => {
window.desktop.openNotificationPreferences();
};
openWindowsCameraPrefs = () => {
window.desktop.openWindowsCameraPreferences();
};
openWindowsMicrophonePrefs = () => {
window.desktop.openWindowsMicrophonePreferences();
};
getServerNameMessage = () => { getServerNameMessage = () => {
if (!this.state.serverName.length) { if (!this.state.serverName.length) {
return ( return (
@ -287,7 +328,7 @@ class NewServerModal extends React.PureComponent<Props, State> {
url: this.state.serverUrl, url: this.state.serverUrl,
name: this.state.serverName, name: this.state.serverName,
id: this.state.serverId, id: this.state.serverId,
}); }, this.state.permissions);
}); });
}; };
@ -331,6 +372,17 @@ class NewServerModal extends React.PureComponent<Props, State> {
} }
this.wasShown = this.props.show; this.wasShown = this.props.show;
const notificationValues = {
link: (msg: React.ReactNode) => (
<a
href='#'
onClick={this.openNotificationPrefs}
>
{msg}
</a>
),
};
return ( return (
<Modal <Modal
bsClass='modal' bsClass='modal'
@ -428,6 +480,105 @@ class NewServerModal extends React.PureComponent<Props, State> {
{this.getServerNameMessage()} {this.getServerNameMessage()}
{this.getServerURLMessage()} {this.getServerURLMessage()}
</div> </div>
{this.props.editMode &&
<>
<hr/>
<h5>
<FormattedMessage
id='renderer.components.newServerModal.permissions.title'
defaultMessage='Permissions'
/>
</h5>
<Toggle
isChecked={this.state.permissions.media?.allowed}
onChange={this.handleChangePermission('media')}
>
<i className='icon icon-microphone'/>
<div>
<FormattedMessage
id='renderer.components.newServerModal.permissions.microphoneAndCamera'
defaultMessage='Microphone and Camera'
/>
{this.state.cameraDisabled &&
<FormText>
<FormattedMessage
id='renderer.components.newServerModal.permissions.microphoneAndCamera.windowsCameraPermissions'
defaultMessage='Camera is disabled in Windows Settings. Click <link>here</link> to open the Camera Settings.'
values={{
link: (msg: React.ReactNode) => (
<a
href='#'
onClick={this.openWindowsCameraPrefs}
>
{msg}
</a>
),
}}
/>
</FormText>
}
{this.state.microphoneDisabled &&
<FormText>
<FormattedMessage
id='renderer.components.newServerModal.permissions.microphoneAndCamera.windowsMicrophoneaPermissions'
defaultMessage='Microphone is disabled in Windows Settings. Click <link>here</link> to open the Microphone Settings.'
values={{
link: (msg: React.ReactNode) => (
<a
href='#'
onClick={this.openWindowsMicrophonePrefs}
>
{msg}
</a>
),
}}
/>
</FormText>
}
</div>
</Toggle>
<Toggle
isChecked={this.state.permissions.notifications?.allowed}
onChange={this.handleChangePermission('notifications')}
>
<i className='icon icon-bell-outline'/>
<div>
<FormattedMessage
id='renderer.components.newServerModal.permissions.notifications'
defaultMessage='Notifications'
/>
{window.process.platform === 'darwin' &&
<FormText>
<FormattedMessage
id='renderer.components.newServerModal.permissions.notifications.mac'
defaultMessage='You may also need to enable notifications in macOS for Mattermost. Click <link>here</link> to open the System Preferences.'
values={notificationValues}
/>
</FormText>
}
{window.process.platform === 'win32' &&
<FormText>
<FormattedMessage
id='renderer.components.newServerModal.permissions.notifications.windows'
defaultMessage='You may also need to enable notifications in Windows for Mattermost. Click <link>here</link> to open the Notification Settings.'
values={notificationValues}
/>
</FormText>
}
</div>
</Toggle>
<Toggle
isChecked={this.state.permissions.geolocation?.allowed}
onChange={this.handleChangePermission('geolocation')}
>
<i className='icon icon-map-marker-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.permissions.geolocation'
defaultMessage='Location'
/>
</Toggle>
</>
}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>

View file

@ -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<HTMLInputElement>;
}
export default function Toggle({children, isChecked, disabled, onChange}: ToggleProps) {
return (
<label
className={classNames('Toggle', {disabled})}
tabIndex={0}
>
{children}
<input
className={classNames('Toggle___input', {disabled})}
type='checkbox'
onChange={onChange}
checked={isChecked}
disabled={disabled}
/>
<span className={classNames('Toggle___switch', {disabled, isChecked})}/>
</label>
);
}

View file

@ -15,6 +15,7 @@
--title-color-indigo-500: #1e325c; --title-color-indigo-500: #1e325c;
--button-color-rgb: 255, 255, 255; --button-color-rgb: 255, 255, 255;
--center-channel-bg-rgb: 255, 255, 255;
--center-channel-color-rgb: 61, 60, 64; --center-channel-color-rgb: 61, 60, 64;
--center-channel-text-rgb: 63, 67, 80; --center-channel-text-rgb: 63, 67, 80;
--link-color-inverted-rgb: 129, 163, 239; --link-color-inverted-rgb: 129, 163, 239;

View file

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

View file

@ -26,3 +26,11 @@ body {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--light) rgba(255, 255, 255, 0); 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);
}

View file

@ -9,7 +9,8 @@ import ReactDOM from 'react-dom';
import IntlProvider from 'renderer/intl_provider'; 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 NewServerModal from '../../components/NewServerModal';
import setupDarkMode from '../darkMode'; import setupDarkMode from '../darkMode';
@ -20,16 +21,17 @@ const onClose = () => {
window.desktop.modals.cancelModal(); window.desktop.modals.cancelModal();
}; };
const onSave = (data: UniqueServer) => { const onSave = (server: Server, permissions?: Permissions) => {
window.desktop.modals.finishModal(data); window.desktop.modals.finishModal({server, permissions});
}; };
const EditServerModalWrapper: React.FC = () => { const EditServerModalWrapper: React.FC = () => {
const [server, setServer] = useState<UniqueServer>(); const [data, setData] = useState<UniqueServerWithPermissions>();
useEffect(() => { useEffect(() => {
window.desktop.modals.getModalInfo<UniqueServer>().then((server) => { window.desktop.modals.getModalInfo<UniqueServerWithPermissions>().
setServer(server); then((data) => {
setData(data);
}); });
}, []); }, []);
@ -39,8 +41,9 @@ const EditServerModalWrapper: React.FC = () => {
onClose={onClose} onClose={onClose}
onSave={onSave} onSave={onSave}
editMode={true} editMode={true}
show={Boolean(server)} show={Boolean(data?.server)}
server={server} server={data?.server}
permissions={data?.permissions}
/> />
</IntlProvider> </IntlProvider>
); );

13
src/types/permissions.ts Normal file
View file

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

View file

@ -96,6 +96,10 @@ declare global {
onFocusThreeDotMenu: (listener: () => void) => void; onFocusThreeDotMenu: (listener: () => void) => void;
updateURLViewWidth: (width?: number) => void; updateURLViewWidth: (width?: number) => void;
openNotificationPreferences: () => void;
openWindowsCameraPreferences: () => void;
openWindowsMicrophonePreferences: () => void;
getMediaAccessStatus: (mediaType: 'microphone' | 'camera' | 'screen') => Promise<'not-determined' | 'granted' | 'denied' | 'restricted' | 'unknown'>;
modals: { modals: {
cancelModal: <T>(data?: T) => void; cancelModal: <T>(data?: T) => void;