[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:
parent
7b1b25b6e0
commit
0d4800fd61
|
@ -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
8
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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}`);
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
33
src/renderer/components/Toggle.tsx
Normal file
33
src/renderer/components/Toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
68
src/renderer/css/components/Toggle.scss
Normal file
68
src/renderer/css/components/Toggle.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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
13
src/types/permissions.ts
Normal 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};
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue