Google & Microsoft calendar API integration (#3340)

* Refactor calendar-sync feature to be loaded on web.

For the web part it just adds new property to enable/disable calendar web integration, disabled by default.

* Initial implementation of retrieving google calendar events.

* Initial implementation of retrieving microsoft calendar events.

* Fixes comments.

* Rework to use the promise part of microsoft-graph-client api.

* Moves dispatching some actions, fixing comments.

* Makes sure we do not initializeClient google-api client multiple times.

* Do not try to login when fetching calendar entries.

The case where there is a calendar type google selected, but not logged in, trying to login on loading welcome page will show a warning that it tried to open a popup, which was denied by browser.

* Updates profile display data on sign in.

* Propagate google-api state to calendar-sync only if we use google cal.

* Adds sign out action.

* Clears the event listener when the popup closes.

* Clears calendarIntegrationInstance on signOut.

* WIP: UI for calendar settings, refactor auth flows

* Clean up some unused constants, functions and exports.

* break circular dependency of function and constant

* Exports only isCalendarEnabled from functions.

* Checks isSignedIn when doing fetchCalendarEntries on web.

* address comments

List microsoftApiApplicationClientID in undocument config.

remove unused SET_CALENDAR_TYPE action

use helper for calendar enabled in bootstrap

reorder actions

reorder imports

change order of signin -> set type -> update profile

add logging for signout error

reword setting dialog desc to avoid redundancy

add jsdoc to microsoft button props

reorder calendar constants

move default state to reducer (not reused anywhere)

update comment about calendar-sync due to removal of getCalendarState

update comment for getCalendarIntegration

remove vague comment

alpha order reducer, return default state on reset

alpha order persistence registry

remove unnecessary getType from apis

update comments in microsoftCalendar

alpha order google-api exports, use api.get in loadGoogleAPI

set jsdoc for google signin props

alpha order googleapi methods

fix calendartab docs

* Moves fetching calendar from APP_WILL_MOUNT to SET_CONFIG.

The web part needs configuration in order to refresh tokens (Microsoft).

* Fixes storing token expire time and refreshing tokens in Microsoft impl.

* Address comments

updateProfile changed to getCurrentEmail

rename result to results

stop storing integration in redux, store if ready for use

use existing helpers to parse redirect url

* update jsdocs, get google app id from redux

* clear integration instead of actual sign out
This commit is contained in:
Дамян Минков 2018-08-15 15:11:54 -05:00 committed by virtuacoplenny
parent 87c010a9bd
commit 7eda31315f
42 changed files with 1962 additions and 416 deletions

View File

@ -256,6 +256,10 @@ var config = {
// maintenance at 01:00 AM GMT,
// noticeMessage: '',
// Enables calendar integration, depends on googleApiApplicationClientID
// and microsoftApiApplicationClientID
// enableCalendarIntegration: false,
// Stats
//
@ -398,6 +402,7 @@ var config = {
googleApiApplicationClientID
iAmRecorder
iAmSipGateway
microsoftApiApplicationClientID
peopleSearchQueryTypes
peopleSearchUrl
requireDisplayName

View File

@ -34,39 +34,6 @@
color: $errorColor;
}
/**
* The Google sign in button must follow Google's design guidelines.
* See: https://developers.google.com/identity/branding-guidelines
*/
.google-sign-in {
background-color: #4285f4;
border-radius: 2px;
cursor: pointer;
display: inline-flex;
font-family: Roboto, arial, sans-serif;
font-size: 14px;
padding: 1px;
.google-cta {
color: white;
display: inline-block;
/**
* Hack the line height for vertical centering of text.
*/
line-height: 32px;
margin: 0 15px;
}
.google-logo {
background-color: white;
border-radius: 2px;
display: inline-block;
padding: 8px;
height: 18px;
width: 18px;
}
}
.google-panel {
align-items: center;
border-bottom: 2px solid rgba(0, 0, 0, 0.3);

View File

@ -82,4 +82,7 @@
@import 'deep-linking/main';
@import 'transcription-subtitles';
@import 'navigate_section_list';
@import 'third-party-branding/google';
@import 'third-party-branding/microsoft';
/* Modules END */

View File

@ -10,6 +10,7 @@
margin-bottom: 4px;
}
.calendar-tab,
.device-selection {
margin-top: 20px;
}
@ -22,6 +23,7 @@
padding: 20px 0px 4px 0px;
}
.calendar-tab,
.more-tab,
.profile-edit {
display: flex;
@ -40,4 +42,20 @@
.language-settings {
max-width: 50%;
}
.calendar-tab {
align-items: center;
flex-direction: column;
font-size: 14px;
min-height: 100px;
text-align: center;
}
.calendar-tab-sign-in {
margin-top: 20px;
}
.sign-out-cta {
margin-bottom: 20px;
}
}

View File

@ -0,0 +1,32 @@
/**
* The Google sign in button must follow Google's design guidelines.
* See: https://developers.google.com/identity/branding-guidelines
*/
.google-sign-in {
background-color: #4285f4;
border-radius: 2px;
cursor: pointer;
display: inline-flex;
font-family: Roboto, arial, sans-serif;
font-size: 14px;
padding: 1px;
.google-cta {
color: white;
display: inline-block;
/**
* Hack the line height for vertical centering of text.
*/
line-height: 32px;
margin: 0 15px;
}
.google-logo {
background-color: white;
border-radius: 2px;
display: inline-block;
padding: 8px;
height: 18px;
width: 18px;
}
}

View File

@ -0,0 +1,28 @@
/**
* The Microsoft sign in button must follow Microsoft's brand guidelines.
* See: https://docs.microsoft.com/en-us/azure/active-directory/
* develop/active-directory-branding-guidelines
*/
.microsoft-sign-in {
align-items: center;
background: #FFFFFF;
border: 1px solid #8C8C8C;
box-sizing: border-box;
cursor: pointer;
display: inline-flex;
font-family: Segoe UI, Roboto, arial, sans-serif;
height: 41px;
padding: 12px;
.microsoft-cta {
display: inline-block;
color: #5E5E5E;
font-size: 15px;
line-height: 41px;
}
.microsoft-logo {
display: inline-block;
margin-right: 12px;
}
}

1
images/microsoftLogo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21"><title>MS-SymbolLockup</title><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@ -157,8 +157,14 @@
},
"messagebox": "Enter text..."
},
"settings":
{
"settings": {
"calendar": {
"about": "The __appName__ calendar integration is used to securely access your calendar so it can read upcoming events.",
"disconnect": "Disconnect",
"microsoftSignIn": "Sign in with Microsoft",
"signedIn": "Currently accessing calendar events for __email__. Click the Disconnect button below to stop accessing calendar events.",
"title": "Calendar"
},
"title": "Settings",
"update": "Update",
"name": "Name",

19
package-lock.json generated
View File

@ -3003,6 +3003,15 @@
"sdp-transform": "2.3.0"
}
},
"@microsoft/microsoft-graph-client": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-1.1.0.tgz",
"integrity": "sha512-sDgchKZz1l3QJVNdkE1P1KpwTjupNt1mS9h1T0CiP+ayMN7IeFKfElB8IYtxFplNalZTmEq+iqoQFqUVpVMLfQ==",
"requires": {
"es6-promise": "^4.1.0",
"isomorphic-fetch": "^2.2.1"
}
},
"@webcomponents/url": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@webcomponents/url/-/url-0.7.1.tgz",
@ -6263,6 +6272,11 @@
"event-emitter": "~0.3.5"
}
},
"es6-promise": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.4.tgz",
"integrity": "sha512-/NdNZVJg+uZgtm9eS3O6lrOLYmQag2DjdEXuPaHlZ6RuVqgqaVZfgYCepEIKsLqwdQArOPtC3XzRLqGGfT8KQQ=="
},
"es6-set": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz",
@ -9643,6 +9657,11 @@
"verror": "1.10.0"
}
},
"jsrsasign": {
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-8.0.12.tgz",
"integrity": "sha1-Iqu5ZW00owuVMENnIINeicLlwxY="
},
"jssha": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/jssha/-/jssha-2.3.1.tgz",

View File

@ -34,6 +34,7 @@
"@atlaskit/tabs": "4.0.1",
"@atlaskit/theme": "2.4.0",
"@atlaskit/tooltip": "9.1.1",
"@microsoft/microsoft-graph-client": "1.1.0",
"@webcomponents/url": "0.7.1",
"autosize": "1.18.13",
"i18next": "8.4.3",
@ -46,6 +47,7 @@
"jquery-i18next": "1.2.0",
"js-md5": "0.6.1",
"jsc-android": "224109.1.0",
"jsrsasign": "8.0.12",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#4a28a196160411d657518022de8bded7c02ad679",
"libflacjs": "github:mmig/libflac.js#93d37e7f811f01cf7d8b6a603e38bd3c3810907d",

View File

@ -212,7 +212,7 @@ class DialogWithTabs extends Component<Props, State> {
const { onSubmit, tabs } = this.props;
tabs.forEach(({ submit }, idx) => {
submit(this.state.tabStates[idx]);
submit && submit(this.state.tabStates[idx]);
});
onSubmit();

View File

@ -1,5 +1,15 @@
// @flow
/**
* Resets the state of calendar integration so stored events and selected
* calendar type are cleared.
*
* {
* type: CLEAR_CALENDAR_INTEGRATION
* }
*/
export const CLEAR_CALENDAR_INTEGRATION = Symbol('CLEAR_CALENDAR_INTEGRATION');
/**
* Action to refresh (re-fetch) the entry list.
*
@ -32,3 +42,35 @@ export const SET_CALENDAR_AUTHORIZATION = Symbol('SET_CALENDAR_AUTHORIZATION');
* }
*/
export const SET_CALENDAR_EVENTS = Symbol('SET_CALENDAR_EVENTS');
/**
* Action to update calendar type to be used for web.
*
* {
* type: SET_CALENDAR_INTEGRATION,
* integrationReady: boolean,
* integrationType: string
* }
*/
export const SET_CALENDAR_INTEGRATION = Symbol('SET_CALENDAR_INTEGRATION');
/**
* The type of Redux action which changes Calendar API auth state.
*
* {
* type: SET_CALENDAR_AUTH_STATE
* }
* @public
*/
export const SET_CALENDAR_AUTH_STATE = Symbol('SET_CALENDAR_AUTH_STATE');
/**
* The type of Redux action which changes Calendar Profile email state.
*
* {
* type: SET_CALENDAR_PROFILE_EMAIL,
* email: string
* }
* @public
*/
export const SET_CALENDAR_PROFILE_EMAIL = Symbol('SET_CALENDAR_PROFILE_EMAIL');

View File

@ -1,10 +1,87 @@
// @flow
import { loadGoogleAPI } from '../google-api';
import {
CLEAR_CALENDAR_INTEGRATION,
REFRESH_CALENDAR,
SET_CALENDAR_AUTH_STATE,
SET_CALENDAR_AUTHORIZATION,
SET_CALENDAR_EVENTS
SET_CALENDAR_EVENTS,
SET_CALENDAR_INTEGRATION,
SET_CALENDAR_PROFILE_EMAIL
} from './actionTypes';
import { _getCalendarIntegration, isCalendarEnabled } from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Sets the initial state of calendar integration by loading third party APIs
* and filling out any data that needs to be fetched.
*
* @returns {Function}
*/
export function bootstrapCalendarIntegration(): Function {
return (dispatch, getState) => {
const {
googleApiApplicationClientID
} = getState()['features/base/config'];
const {
integrationReady,
integrationType
} = getState()['features/calendar-sync'];
if (!isCalendarEnabled()) {
return Promise.reject();
}
return Promise.resolve()
.then(() => {
if (googleApiApplicationClientID) {
return dispatch(
loadGoogleAPI(googleApiApplicationClientID));
}
})
.then(() => {
if (!integrationType || integrationReady) {
return;
}
const integrationToLoad
= _getCalendarIntegration(integrationType);
if (!integrationToLoad) {
dispatch(clearCalendarIntegration());
return;
}
return dispatch(integrationToLoad._isSignedIn())
.then(signedIn => {
if (signedIn) {
dispatch(setIntegrationReady(integrationType));
dispatch(updateProfile(integrationType));
} else {
dispatch(clearCalendarIntegration());
}
});
});
};
}
/**
* Resets the state of calendar integration so stored events and selected
* calendar type are cleared.
*
* @returns {{
* type: CLEAR_CALENDAR_INTEGRATION
* }}
*/
export function clearCalendarIntegration() {
return {
type: CLEAR_CALENDAR_INTEGRATION
};
}
/**
* Sends an action to refresh the entry list (fetches new data).
@ -28,6 +105,23 @@ export function refreshCalendar(
};
}
/**
* Sends an action to update the current calendar api auth state in redux.
* This is used only for microsoft implementation to store it auth state.
*
* @param {number} newState - The new state.
* @returns {{
* type: SET_CALENDAR_AUTH_STATE,
* msAuthState: Object
* }}
*/
export function setCalendarAPIAuthState(newState: ?Object) {
return {
type: SET_CALENDAR_AUTH_STATE,
msAuthState: newState
};
}
/**
* Sends an action to signal that a calendar access has been requested. For more
* info, see {@link SET_CALENDAR_AUTHORIZATION}.
@ -61,3 +155,90 @@ export function setCalendarEvents(events: Array<Object>) {
events
};
}
/**
* Sends an action to update the current calendar profile email state in redux.
*
* @param {number} newEmail - The new email.
* @returns {{
* type: SET_CALENDAR_PROFILE_EMAIL,
* email: string
* }}
*/
export function setCalendarProfileEmail(newEmail: ?string) {
return {
type: SET_CALENDAR_PROFILE_EMAIL,
email: newEmail
};
}
/**
* Sets the calendar integration type to be used by web and signals that the
* integration is ready to be used.
*
* @param {string|undefined} integrationType - The calendar type.
* @returns {{
* type: SET_CALENDAR_INTEGRATION,
* integrationReady: boolean,
* integrationType: string
* }}
*/
export function setIntegrationReady(integrationType: string) {
return {
type: SET_CALENDAR_INTEGRATION,
integrationReady: true,
integrationType
};
}
/**
* Signals signing in to the specified calendar integration.
*
* @param {string} calendarType - The calendar integration which should be
* signed into.
* @returns {Function}
*/
export function signIn(calendarType: string): Function {
return (dispatch: Dispatch<*>) => {
const integration = _getCalendarIntegration(calendarType);
if (!integration) {
return Promise.reject('No supported integration found');
}
return dispatch(integration.load())
.then(() => dispatch(integration.signIn()))
.then(() => dispatch(setIntegrationReady(calendarType)))
.then(() => dispatch(updateProfile(calendarType)))
.catch(error => {
logger.error(
'Error occurred while signing into calendar integration',
error);
return Promise.reject(error);
});
};
}
/**
* Signals to get current profile data linked to the current calendar
* integration that is in use.
*
* @param {string} calendarType - The calendar integration to which the profile
* should be updated.
* @returns {Function}
*/
export function updateProfile(calendarType: string): Function {
return (dispatch: Dispatch<*>) => {
const integration = _getCalendarIntegration(calendarType);
if (!integration) {
return Promise.reject('No integration found');
}
return dispatch(integration.getCurrentEmail())
.then(email => {
dispatch(setCalendarProfileEmail(email));
});
};
}

View File

@ -10,7 +10,7 @@ import { Icon } from '../../base/font-icons';
import { getLocalizedDateFormatter, translate } from '../../base/i18n';
import { ASPECT_RATIO_NARROW } from '../../base/responsive-ui';
import { CALENDAR_ENABLED } from '../constants';
import { isCalendarEnabled } from '../functions';
import styles from './styles';
const ALERT_MILLISECONDS = 5 * 60 * 1000;
@ -293,6 +293,6 @@ function _mapStateToProps(state: Object) {
};
}
export default CALENDAR_ENABLED
export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(ConferenceNotification))
: undefined;

View File

@ -10,7 +10,7 @@ import { NavigateSectionList } from '../../base/react';
import { openSettings } from '../../mobile/permissions';
import { refreshCalendar } from '../actions';
import { CALENDAR_ENABLED } from '../constants';
import { isCalendarEnabled } from '../functions';
import styles from './styles';
/**
@ -275,6 +275,6 @@ function _mapStateToProps(state: Object) {
};
}
export default CALENDAR_ENABLED
export default isCalendarEnabled()
? translate(connect(_mapStateToProps)(MeetingList))
: undefined;

View File

@ -0,0 +1,44 @@
// @flow
import React, { Component } from 'react';
/**
* The type of the React {@code Component} props of
* {@link MicrosoftSignInButton}.
*/
type Props = {
// The callback to invoke when {@code MicrosoftSignInButton} is clicked.
onClick: Function,
// The text to display within {@code MicrosoftSignInButton}.
text: string
};
/**
* A React Component showing a button to sign in with Microsoft.
*
* @extends Component
*/
export default class MicrosoftSignInButton extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<div
className = 'microsoft-sign-in'
onClick = { this.props.onClick }>
<img
className = 'microsoft-logo'
src = 'images/microsoftLogo.svg' />
<div className = 'microsoft-cta'>
{ this.props.text }
</div>
</div>
);
}
}

View File

@ -1,2 +1,3 @@
export { default as ConferenceNotification } from './ConferenceNotification';
export { default as MeetingList } from './MeetingList';
export { default as MicrosoftSignInButton } from './MicrosoftSignInButton';

View File

@ -1,37 +1,26 @@
// @flow
import { NativeModules } from 'react-native';
/**
* The indicator which determines whether the calendar feature is enabled by the
* app.
* An enumeration of support calendar integration types.
*
* @type {boolean}
* @enum {string}
*/
export const CALENDAR_ENABLED = _isCalendarEnabled();
/**
* The default state of the calendar.
*
* NOTE: This is defined here, to be reusable by functions.js as well (see file
* for details).
*/
export const DEFAULT_STATE = {
authorization: undefined,
events: []
export const CALENDAR_TYPE = {
GOOGLE: 'google',
MICROSOFT: 'microsoft'
};
/**
* Determines whether the calendar feature is enabled by the app. For
* example, Apple through its App Store requires
* {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store
* rejects the app.
*
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
* otherwise, {@code false}.
* The number of days to fetch.
*/
function _isCalendarEnabled() {
const { calendarEnabled } = NativeModules.AppInfo;
export const FETCH_END_DAYS = 10;
return typeof calendarEnabled === 'undefined' ? true : calendarEnabled;
}
/**
* The number of days to go back when fetching.
*/
export const FETCH_START_DAYS = -1;
/**
* The max number of events to fetch from the calendar.
*/
export const MAX_LIST_LENGTH = 10;

View File

@ -0,0 +1,158 @@
// @flow
import md5 from 'js-md5';
import { setCalendarEvents } from './actions';
import { APP_LINK_SCHEME, parseURIString } from '../base/util';
import { MAX_LIST_LENGTH } from './constants';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Updates the calendar entries in redux when new list is received. The feature
* calendar-sync doesn't display all calendar events, it displays unique
* title, URL, and start time tuples i.e. it doesn't display subsequent
* occurrences of recurring events, and the repetitions of events coming from
* multiple calendars.
*
* XXX The function's {@code this} is the redux store.
*
* @param {Array<CalendarEntry>} events - The new event list.
* @private
* @returns {void}
*/
export function _updateCalendarEntries(events: Array<Object>) {
if (!events || !events.length) {
return;
}
// eslint-disable-next-line no-invalid-this
const { dispatch, getState } = this;
const knownDomains = getState()['features/base/known-domains'];
const now = Date.now();
const entryMap = new Map();
for (const event of events) {
const entry = _parseCalendarEntry(event, knownDomains);
if (entry && entry.endDate > now) {
// As was stated above, we don't display subsequent occurrences of
// recurring events, and the repetitions of events coming from
// multiple calendars.
const key = md5.hex(JSON.stringify([
// Obviously, we want to display different conference/meetings
// URLs. URLs are the very reason why we implemented the feature
// calendar-sync in the first place.
entry.url,
// We probably want to display one and the same URL to people if
// they have it under different titles in their Calendar.
// Because maybe they remember the title of the meeting, not the
// URL so they expect to see the title without realizing that
// they have the same URL already under a different title.
entry.title,
// XXX Eventually, given that the URL and the title are the
// same, what sets one event apart from another is the start
// time of the day (note the use of toTimeString() bellow)! The
// day itself is not important because we don't want multiple
// occurrences of a recurring event or repetitions of an even
// from multiple calendars.
new Date(entry.startDate).toTimeString()
]));
const existingEntry = entryMap.get(key);
// We want only the earliest occurrence (which hasn't ended in the
// past, that is) of a recurring event.
if (!existingEntry || existingEntry.startDate > entry.startDate) {
entryMap.set(key, entry);
}
}
}
dispatch(
setCalendarEvents(
Array.from(entryMap.values())
.sort((a, b) => a.startDate - b.startDate)
.slice(0, MAX_LIST_LENGTH)));
}
/**
* Updates the calendar entries in Redux when new list is received.
*
* @param {Object} event - An event returned from the native calendar.
* @param {Array<string>} knownDomains - The known domain list.
* @private
* @returns {CalendarEntry}
*/
function _parseCalendarEntry(event, knownDomains) {
if (event) {
const url = _getURLFromEvent(event, knownDomains);
if (url) {
const startDate = Date.parse(event.startDate);
const endDate = Date.parse(event.endDate);
if (isNaN(startDate) || isNaN(endDate)) {
logger.warn(
'Skipping invalid calendar event',
event.title,
event.startDate,
event.endDate
);
} else {
return {
endDate,
id: event.id,
startDate,
title: event.title,
url
};
}
}
}
return null;
}
/**
* Retrieves a Jitsi Meet URL from an event if present.
*
* @param {Object} event - The event to parse.
* @param {Array<string>} knownDomains - The known domain names.
* @private
* @returns {string}
*/
function _getURLFromEvent(event, knownDomains) {
const linkTerminatorPattern = '[^\\s<>$]';
const urlRegExp
= new RegExp(
`http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`,
'gi');
const schemeRegExp
= new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
const fieldsToSearch = [
event.title,
event.url,
event.location,
event.notes,
event.description
];
for (const field of fieldsToSearch) {
if (typeof field === 'string') {
const matches = urlRegExp.exec(field) || schemeRegExp.exec(field);
if (matches) {
const url = parseURIString(matches[0]);
if (url) {
return url.toString();
}
}
}
}
return null;
}

View File

@ -1,18 +0,0 @@
// @flow
import { toState } from '../base/redux';
import { CALENDAR_ENABLED, DEFAULT_STATE } from './constants';
/**
* Returns the calendar state, considering the enabled/disabled state of the
* feature. Since that is the normal Redux behaviour, this function will always
* return an object (the default state if the feature is disabled).
*
* @param {Object | Function} stateful - An object or a function that can be
* resolved to a Redux state by {@code toState}.
* @returns {Object}
*/
export function getCalendarState(stateful: Object | Function) {
return CALENDAR_ENABLED
? toState(stateful)['features/calendar-sync'] : DEFAULT_STATE;
}

View File

@ -0,0 +1,99 @@
import { NativeModules } from 'react-native';
import RNCalendarEvents from 'react-native-calendar-events';
import { setCalendarAuthorization } from './actions';
import { FETCH_END_DAYS, FETCH_START_DAYS } from './constants';
import { _updateCalendarEntries } from './functions';
export * from './functions.any';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* Determines whether the calendar feature is enabled by the app. For
* example, Apple through its App Store requires
* {@code NSCalendarsUsageDescription} in the app's Info.plist or App Store
* rejects the app.
*
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
* otherwise, {@code false}.
*/
export function isCalendarEnabled() {
const { calendarEnabled } = NativeModules.AppInfo;
return typeof calendarEnabled === 'undefined' ? true : calendarEnabled;
}
/**
* Reads the user's calendar and updates the stored entries if need be.
*
* @param {Object} store - The redux store.
* @param {boolean} maybePromptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {boolean|undefined} forcePermission - Whether to force to re-ask for
* the permission or not.
* @private
* @returns {void}
*/
export function _fetchCalendarEntries(
store,
maybePromptForPermission,
forcePermission) {
const { dispatch, getState } = store;
const promptForPermission
= (maybePromptForPermission
&& !getState()['features/calendar-sync'].authorization)
|| forcePermission;
_ensureCalendarAccess(promptForPermission, dispatch)
.then(accessGranted => {
if (accessGranted) {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
RNCalendarEvents.fetchAllEvents(
startDate.getTime(),
endDate.getTime(),
[])
.then(_updateCalendarEntries.bind(store))
.catch(error =>
logger.error('Error fetching calendar.', error));
} else {
logger.warn('Calendar access not granted.');
}
})
.catch(reason => logger.error('Error accessing calendar.', reason));
}
/**
* Ensures calendar access if possible and resolves the promise if it's granted.
*
* @param {boolean} promptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {Function} dispatch - The Redux dispatch function.
* @private
* @returns {Promise}
*/
function _ensureCalendarAccess(promptForPermission, dispatch) {
return new Promise((resolve, reject) => {
RNCalendarEvents.authorizationStatus()
.then(status => {
if (status === 'authorized') {
resolve(true);
} else if (promptForPermission) {
RNCalendarEvents.authorizeEventStore()
.then(result => {
dispatch(setCalendarAuthorization(result));
resolve(result === 'authorized');
})
.catch(reject);
} else {
resolve(false);
}
})
.catch(reject);
});
}

View File

@ -0,0 +1,93 @@
// @flow
export * from './functions.any';
import {
CALENDAR_TYPE,
FETCH_END_DAYS,
FETCH_START_DAYS
} from './constants';
import { _updateCalendarEntries } from './functions';
import { googleCalendarApi } from './web/googleCalendar';
import { microsoftCalendarApi } from './web/microsoftCalendar';
const logger = require('jitsi-meet-logger').getLogger(__filename);
declare var config: Object;
/**
* Determines whether the calendar feature is enabled by the web.
*
* @returns {boolean} If the app has enabled the calendar feature, {@code true};
* otherwise, {@code false}.
*/
export function isCalendarEnabled() {
return Boolean(
config.enableCalendarIntegration
&& (config.googleApiApplicationClientID
|| config.microsoftApiApplicationClientID));
}
/* eslint-disable no-unused-vars */
/**
* Reads the user's calendar and updates the stored entries if need be.
*
* @param {Object} store - The redux store.
* @param {boolean} maybePromptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {boolean|undefined} forcePermission - Whether to force to re-ask for
* the permission or not.
* @private
* @returns {void}
*/
export function _fetchCalendarEntries(
store,
maybePromptForPermission,
forcePermission) {
/* eslint-enable no-unused-vars */
const { dispatch, getState } = store;
const { integrationType } = getState()['features/calendar-sync'];
const integration = _getCalendarIntegration(integrationType);
if (!integration) {
logger.debug('No calendar type available');
return;
}
dispatch(integration.load())
.then(() => dispatch(integration._isSignedIn()))
.then(signedIn => {
if (signedIn) {
return Promise.resolve();
}
return Promise.reject('Not authorized, please sign in!');
})
.then(() => dispatch(integration.getCalendarEntries(
FETCH_START_DAYS, FETCH_END_DAYS)))
.then(events => _updateCalendarEntries.call({
dispatch,
getState
}, events))
.catch(error =>
logger.error('Error fetching calendar.', error));
}
/**
* Returns the calendar API implementation by specified type.
*
* @param {string} calendarType - The calendar type API as defined in
* the constant {@link CALENDAR_TYPE}.
* @private
* @returns {Object|undefined}
*/
export function _getCalendarIntegration(calendarType: string) {
switch (calendarType) {
case CALENDAR_TYPE.GOOGLE:
return googleCalendarApi;
case CALENDAR_TYPE.MICROSOFT:
return microsoftCalendarApi;
}
}

View File

@ -1,5 +1,7 @@
export * from './actions';
export * from './components';
export * from './functions';
export * from './constants';
export { isCalendarEnabled } from './functions';
import './middleware';
import './reducer';

View File

@ -1,36 +1,15 @@
// @flow
import md5 from 'js-md5';
import RNCalendarEvents from 'react-native-calendar-events';
import { APP_WILL_MOUNT } from '../base/app';
import { SET_CONFIG } from '../base/config';
import { ADD_KNOWN_DOMAINS, addKnownDomains } from '../base/known-domains';
import { MiddlewareRegistry } from '../base/redux';
import { APP_LINK_SCHEME, parseURIString } from '../base/util';
import { APP_STATE_CHANGED } from '../mobile/background';
import { equals, MiddlewareRegistry } from '../base/redux';
import { APP_STATE_CHANGED } from '../mobile/background/actionTypes';
import { setCalendarAuthorization, setCalendarEvents } from './actions';
import { setCalendarAuthorization } from './actions';
import { REFRESH_CALENDAR } from './actionTypes';
import { CALENDAR_ENABLED } from './constants';
import { _fetchCalendarEntries, isCalendarEnabled } from './functions';
const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The number of days to fetch.
*/
const FETCH_END_DAYS = 10;
/**
* The number of days to go back when fetching.
*/
const FETCH_START_DAYS = -1;
/**
* The max number of events to fetch from the calendar.
*/
const MAX_LIST_LENGTH = 10;
CALENDAR_ENABLED
isCalendarEnabled()
&& MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case ADD_KNOWN_DOMAINS: {
@ -41,7 +20,8 @@ CALENDAR_ENABLED
const result = next(action);
const newValue = getState()['features/base/known-domains'];
oldValue === newValue || _fetchCalendarEntries(store, false, false);
equals(oldValue, newValue)
|| _fetchCalendarEntries(store, false, false);
return result;
}
@ -54,7 +34,9 @@ CALENDAR_ENABLED
return result;
}
case APP_WILL_MOUNT: {
case SET_CONFIG: {
const result = next(action);
// For legacy purposes, we've allowed the deserialization of
// knownDomains and now we're to translate it to base/known-domains.
const state = store.getState()['features/calendar-sync'];
@ -69,7 +51,7 @@ CALENDAR_ENABLED
_fetchCalendarEntries(store, false, false);
return next(action);
return result;
}
case REFRESH_CALENDAR: {
@ -85,121 +67,6 @@ CALENDAR_ENABLED
return next(action);
});
/**
* Ensures calendar access if possible and resolves the promise if it's granted.
*
* @param {boolean} promptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {Function} dispatch - The Redux dispatch function.
* @private
* @returns {Promise}
*/
function _ensureCalendarAccess(promptForPermission, dispatch) {
return new Promise((resolve, reject) => {
RNCalendarEvents.authorizationStatus()
.then(status => {
if (status === 'authorized') {
resolve(true);
} else if (promptForPermission) {
RNCalendarEvents.authorizeEventStore()
.then(result => {
dispatch(setCalendarAuthorization(result));
resolve(result === 'authorized');
})
.catch(reject);
} else {
resolve(false);
}
})
.catch(reject);
});
}
/**
* Reads the user's calendar and updates the stored entries if need be.
*
* @param {Object} store - The redux store.
* @param {boolean} maybePromptForPermission - Flag to tell the app if it should
* prompt for a calendar permission if it wasn't granted yet.
* @param {boolean|undefined} forcePermission - Whether to force to re-ask for
* the permission or not.
* @private
* @returns {void}
*/
function _fetchCalendarEntries(
store,
maybePromptForPermission,
forcePermission) {
const { dispatch, getState } = store;
const promptForPermission
= (maybePromptForPermission
&& !getState()['features/calendar-sync'].authorization)
|| forcePermission;
_ensureCalendarAccess(promptForPermission, dispatch)
.then(accessGranted => {
if (accessGranted) {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + FETCH_START_DAYS);
endDate.setDate(endDate.getDate() + FETCH_END_DAYS);
RNCalendarEvents.fetchAllEvents(
startDate.getTime(),
endDate.getTime(),
[])
.then(_updateCalendarEntries.bind(store))
.catch(error =>
logger.error('Error fetching calendar.', error));
} else {
logger.warn('Calendar access not granted.');
}
})
.catch(reason => logger.error('Error accessing calendar.', reason));
}
/**
* Retrieves a Jitsi Meet URL from an event if present.
*
* @param {Object} event - The event to parse.
* @param {Array<string>} knownDomains - The known domain names.
* @private
* @returns {string}
*/
function _getURLFromEvent(event, knownDomains) {
const linkTerminatorPattern = '[^\\s<>$]';
const urlRegExp
= new RegExp(
`http(s)?://(${knownDomains.join('|')})/${linkTerminatorPattern}+`,
'gi');
const schemeRegExp
= new RegExp(`${APP_LINK_SCHEME}${linkTerminatorPattern}+`, 'gi');
const fieldsToSearch = [
event.title,
event.url,
event.location,
event.notes,
event.description
];
for (const field of fieldsToSearch) {
if (typeof field === 'string') {
const matches = urlRegExp.exec(field) || schemeRegExp.exec(field);
if (matches) {
const url = parseURIString(matches[0]);
if (url) {
return url.toString();
}
}
}
}
return null;
}
/**
* Clears the calendar access status when the app comes back from the
* background. This is needed as some users may never quit the app, but puts it
@ -215,111 +82,3 @@ function _maybeClearAccessStatus(store, { appState }) {
appState === 'background'
&& store.dispatch(setCalendarAuthorization(undefined));
}
/**
* Updates the calendar entries in Redux when new list is received.
*
* @param {Object} event - An event returned from the native calendar.
* @param {Array<string>} knownDomains - The known domain list.
* @private
* @returns {CalendarEntry}
*/
function _parseCalendarEntry(event, knownDomains) {
if (event) {
const url = _getURLFromEvent(event, knownDomains);
if (url) {
const startDate = Date.parse(event.startDate);
const endDate = Date.parse(event.endDate);
if (isNaN(startDate) || isNaN(endDate)) {
logger.warn(
'Skipping invalid calendar event',
event.title,
event.startDate,
event.endDate
);
} else {
return {
endDate,
id: event.id,
startDate,
title: event.title,
url
};
}
}
}
return null;
}
/**
* Updates the calendar entries in redux when new list is received. The feature
* calendar-sync doesn't display all calendar events, it displays unique
* title, URL, and start time tuples i.e. it doesn't display subsequent
* occurrences of recurring events, and the repetitions of events coming from
* multiple calendars.
*
* XXX The function's {@code this} is the redux store.
*
* @param {Array<CalendarEntry>} events - The new event list.
* @private
* @returns {void}
*/
function _updateCalendarEntries(events) {
if (!events || !events.length) {
return;
}
// eslint-disable-next-line no-invalid-this
const { dispatch, getState } = this;
const knownDomains = getState()['features/base/known-domains'];
const now = Date.now();
const entryMap = new Map();
for (const event of events) {
const entry = _parseCalendarEntry(event, knownDomains);
if (entry && entry.endDate > now) {
// As was stated above, we don't display subsequent occurrences of
// recurring events, and the repetitions of events coming from
// multiple calendars.
const key = md5.hex(JSON.stringify([
// Obviously, we want to display different conference/meetings
// URLs. URLs are the very reason why we implemented the feature
// calendar-sync in the first place.
entry.url,
// We probably want to display one and the same URL to people if
// they have it under different titles in their Calendar.
// Because maybe they remember the title of the meeting, not the
// URL so they expect to see the title without realizing that
// they have the same URL already under a different title.
entry.title,
// XXX Eventually, given that the URL and the title are the
// same, what sets one event apart from another is the start
// time of the day (note the use of toTimeString() bellow)! The
// day itself is not important because we don't want multiple
// occurrences of a recurring event or repetitions of an even
// from multiple calendars.
new Date(entry.startDate).toTimeString()
]));
const existingEntry = entryMap.get(key);
// We want only the earliest occurrence (which hasn't ended in the
// past, that is) of a recurring event.
if (!existingEntry || existingEntry.startDate > entry.startDate) {
entryMap.set(key, entry);
}
}
}
dispatch(
setCalendarEvents(
Array.from(entryMap.values())
.sort((a, b) => a.startDate - b.startDate)
.slice(0, MAX_LIST_LENGTH)));
}

View File

@ -5,21 +5,36 @@ import { ReducerRegistry, set } from '../base/redux';
import { PersistenceRegistry } from '../base/storage';
import {
CLEAR_CALENDAR_INTEGRATION,
SET_CALENDAR_AUTH_STATE,
SET_CALENDAR_AUTHORIZATION,
SET_CALENDAR_EVENTS
SET_CALENDAR_EVENTS,
SET_CALENDAR_INTEGRATION,
SET_CALENDAR_PROFILE_EMAIL
} from './actionTypes';
import { CALENDAR_ENABLED, DEFAULT_STATE } from './constants';
import { isCalendarEnabled } from './functions';
/**
* The default state of the calendar feature.
*
* @type {Object}
*/
const DEFAULT_STATE = {
authorization: undefined,
events: [],
integrationReady: false,
integrationType: undefined,
msAuthState: undefined
};
/**
* Constant for the Redux subtree of the calendar feature.
*
* NOTE: Please do not access this subtree directly outside of this feature.
* This feature can be disabled (see {@code constants.js} for details), and in
* that case, accessing this subtree directly will return undefined and will
* need a bunch of repetitive type checks in other features. Use the
* {@code getCalendarState} function instead, or make sure you take care of
* those checks, or consider using the {@code CALENDAR_ENABLED} const to gate
* features if needed.
* NOTE: This feature can be disabled and in that case, accessing this subtree
* directly will return undefined and will need a bunch of repetitive type
* checks in other features. Make sure you take care of those checks, or
* consider using the {@code isCalendarEnabled} value to gate features if
* needed.
*/
const STORE_NAME = 'features/calendar-sync';
@ -31,12 +46,14 @@ const STORE_NAME = 'features/calendar-sync';
* runtime value to see if we need to re-request the calendar permission from
* the user.
*/
CALENDAR_ENABLED
isCalendarEnabled()
&& PersistenceRegistry.register(STORE_NAME, {
knownDomains: true
integrationType: true,
knownDomains: true,
msAuthState: true
});
CALENDAR_ENABLED
isCalendarEnabled()
&& ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => {
switch (action.type) {
case APP_WILL_MOUNT:
@ -49,11 +66,36 @@ CALENDAR_ENABLED
}
break;
case CLEAR_CALENDAR_INTEGRATION:
return DEFAULT_STATE;
case SET_CALENDAR_AUTH_STATE: {
if (!action.msAuthState) {
// received request to delete the state
return set(state, 'msAuthState', undefined);
}
return set(state, 'msAuthState', {
...state.msAuthState,
...action.msAuthState
});
}
case SET_CALENDAR_AUTHORIZATION:
return set(state, 'authorization', action.authorization);
case SET_CALENDAR_EVENTS:
return set(state, 'events', action.events);
case SET_CALENDAR_INTEGRATION:
return {
...state,
integrationReady: action.integrationReady,
integrationType: action.integrationType
};
case SET_CALENDAR_PROFILE_EMAIL:
return set(state, 'profileEmail', action.email);
}
return state;

View File

@ -0,0 +1,66 @@
/* @flow */
import {
getCalendarEntries,
googleApi,
loadGoogleAPI,
signIn,
updateProfile
} from '../../google-api';
/**
* A stateless collection of action creators that implements the expected
* interface for interacting with the Google API in order to get calendar data.
*
* @type {Object}
*/
export const googleCalendarApi = {
/**
* Retrieves the current calendar events.
*
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {function(): Promise<CalendarEntries>}
*/
getCalendarEntries,
/**
* Returns the email address for the currently logged in user.
*
* @returns {function(Dispatch<*>): Promise<string|never>}
*/
getCurrentEmail() {
return updateProfile();
},
/**
* Initializes the google api if needed.
*
* @returns {function(Dispatch<*>, Function): Promise<void>}
*/
load() {
return (dispatch: Dispatch<*>, getState: Function) => {
const { googleApiApplicationClientID }
= getState()['features/base/config'];
return dispatch(loadGoogleAPI(googleApiApplicationClientID));
};
},
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string|never>}
*/
signIn,
/**
* Returns whether or not the user is currently signed in.
*
* @returns {function(): Promise<boolean>}
*/
_isSignedIn() {
return () => googleApi.isSignedIn();
}
};

View File

@ -0,0 +1,531 @@
/* @flow */
import { Client } from '@microsoft/microsoft-graph-client';
import rs from 'jsrsasign';
import { createDeferred } from '../../../../modules/util/helpers';
import parseURLParams from '../../base/config/parseURLParams';
import { parseStandardURIString } from '../../base/util';
import { setCalendarAPIAuthState } from '../actions';
/**
* Constants used for interacting with the Microsoft API.
*
* @private
* @type {object}
*/
const MS_API_CONFIGURATION = {
/**
* The URL to use when authenticating using Microsoft API.
* @type {string}
*/
AUTH_ENDPOINT:
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?',
CALENDAR_ENDPOINT: '/me/calendars',
/**
* The Microsoft API scopes to request access for calendar.
*
* @type {string}
*/
MS_API_SCOPES: 'openid profile Calendars.Read',
/**
* See https://docs.microsoft.com/en-us/azure/active-directory/develop/
* v2-oauth2-implicit-grant-flow#send-the-sign-in-request. This value is
* needed for passing in the proper domain_hint value when trying to refresh
* a token silently.
*
*
* @type {string}
*/
MS_CONSUMER_TENANT: '9188040d-6c67-4c5b-b112-36a304b66dad',
/**
* The redirect URL to be used by the Microsoft API on successful
* authentication.
*
* @type {string}
*/
REDIRECT_URI: `${window.location.origin}/static/msredirect.html`
};
/**
* Store the window from an auth request. That way it can be reused if a new
* request comes in and it can be used to indicate a request is in progress.
*
* @private
* @type {Object|null}
*/
let popupAuthWindow = null;
/**
* A stateless collection of action creators that implements the expected
* interface for interacting with the Microsoft API in order to get calendar
* data.
*
* @type {Object}
*/
export const microsoftCalendarApi = {
/**
* Retrieves the current calendar events.
*
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {function(Dispatch<*>, Function): Promise<CalendarEntries>}
*/
getCalendarEntries(fetchStartDays: ?number, fetchEndDays: ?number) {
return (dispatch: Dispatch<*>, getState: Function): Promise<*> => {
const state = getState()['features/calendar-sync'] || {};
const token = state.msAuthState && state.msAuthState.accessToken;
if (!token) {
return Promise.reject('Not authorized, please sign in!');
}
const client = Client.init({
authProvider: done => done(null, token)
});
return client
.api(MS_API_CONFIGURATION.CALENDAR_ENDPOINT)
.get()
.then(response => {
const calendarIds = response.value.map(en => en.id);
const getEventsPromises = calendarIds.map(id =>
requestCalendarEvents(
client, id, fetchStartDays, fetchEndDays));
return Promise.all(getEventsPromises);
})
// get .value of every element from the array of results,
// which is an array of events and flatten it to one array
// of events
.then(result => [].concat(...result.map(en => en.value)))
.then(entries => entries.map(e => formatCalendarEntry(e)));
};
},
/**
* Returns the email address for the currently logged in user.
*
* @returns {function(Dispatch<*, Function>): Promise<string>}
*/
getCurrentEmail(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
const { msAuthState = {} }
= getState()['features/calendar-sync'] || {};
const email = msAuthState.userSigninName || '';
return Promise.resolve(email);
};
},
/**
* Sets the application ID to use for interacting with the Microsoft API.
*
* @returns {function(): Promise<void>}
*/
load(): Function {
return () => Promise.resolve();
},
/**
* Prompts the participant to sign in to the Microsoft API Client Library.
*
* @returns {function(Dispatch<*>, Function): Promise<void>}
*/
signIn(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
// Ensure only one popup window at a time.
if (popupAuthWindow) {
popupAuthWindow.focus();
return Promise.reject('Sign in already in progress.');
}
const signInDeferred = createDeferred();
const guids = {
authState: generateGuid(),
authNonce: generateGuid()
};
dispatch(setCalendarAPIAuthState(guids));
const { microsoftApiApplicationClientID }
= getState()['features/base/config'];
const authUrl = getAuthUrl(
microsoftApiApplicationClientID,
guids.authState,
guids.authNonce);
const h = 600;
const w = 480;
popupAuthWindow = window.open(
authUrl,
'Auth M$',
`width=${w}, height=${h}, top=${
(screen.height / 2) - (h / 2)}, left=${
(screen.width / 2) - (w / 2)}`);
const windowCloseCheck = setInterval(() => {
if (popupAuthWindow && popupAuthWindow.closed) {
signInDeferred.reject(
'Popup closed before completing auth.');
popupAuthWindow = null;
window.removeEventListener('message', handleAuth);
clearInterval(windowCloseCheck);
} else if (!popupAuthWindow) {
// This case probably happened because the user completed
// auth.
clearInterval(windowCloseCheck);
}
}, 500);
/**
* Callback with scope access to other variables that are part of
* the sign in request.
*
* @param {Object} event - The event from the post message.
* @private
* @returns {void}
*/
function handleAuth({ data }) {
if (!data || data.type !== 'ms-login') {
return;
}
window.removeEventListener('message', handleAuth);
popupAuthWindow && popupAuthWindow.close();
popupAuthWindow = null;
const params = getParamsFromHash(data.url);
const tokenParts = getValidatedTokenParts(
params, guids, microsoftApiApplicationClientID);
if (!tokenParts) {
signInDeferred.reject('Invalid token received');
return;
}
dispatch(setCalendarAPIAuthState({
authState: undefined,
accessToken: tokenParts.accessToken,
idToken: tokenParts.idToken,
tokenExpires: params.tokenExpires,
userDomainType: tokenParts.userDomainType,
userSigninName: tokenParts.userSigninName
}));
signInDeferred.resolve();
}
window.addEventListener('message', handleAuth);
return signInDeferred.promise;
};
},
/**
* Returns whether or not the user is currently signed in.
*
* @returns {function(Dispatch<*>, Function): Promise<boolean>}
*/
_isSignedIn(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
const now = new Date().getTime();
const state
= getState()['features/calendar-sync'].msAuthState || {};
const tokenExpires = parseInt(state.tokenExpires, 10);
const isExpired = now > tokenExpires && !isNaN(tokenExpires);
if (state.accessToken && isExpired) {
// token expired, let's refresh it
return dispatch(this._refreshAuthToken())
.then(() => true)
.catch(() => false);
}
return Promise.resolve(state.accessToken && !isExpired);
};
},
/**
* Renews an existing auth token so it can continue to be used.
*
* @private
* @returns {function(Dispatch<*>, Function): Promise<void>}
*/
_refreshAuthToken(): Function {
return (dispatch: Dispatch<*>, getState: Function) => {
const { microsoftApiApplicationClientID }
= getState()['features/base/config'];
const { msAuthState = {} }
= getState()['features/calendar-sync'] || {};
const refreshAuthUrl = getAuthRefreshUrl(
microsoftApiApplicationClientID,
msAuthState.userDomainType,
msAuthState.userSigninName);
const iframe = document.createElement('iframe');
iframe.setAttribute('id', 'auth-iframe');
iframe.setAttribute('name', 'auth-iframe');
iframe.setAttribute('style', 'display: none');
iframe.setAttribute('src', refreshAuthUrl);
const signInPromise = new Promise(resolve => {
iframe.onload = () => {
resolve(iframe.contentWindow.location.hash);
};
});
// The check for body existence is done for flow, which also runs
// against native where document.body may not be defined.
if (!document.body) {
return Promise.reject(
'Cannot refresh auth token in this environment');
}
document.body.appendChild(iframe);
return signInPromise.then(hash => {
const params = getParamsFromHash(hash);
dispatch(setCalendarAPIAuthState({
accessToken: params.access_token,
idToken: params.id_token,
tokenExpires: params.tokenExpires
}));
});
};
}
};
/**
* Parses the Microsoft calendar entries to a known format.
*
* @param {Object} entry - The Microsoft calendar entry.
* @private
* @returns {{
* description: string,
* endDate: string,
* id: string,
* location: string,
* startDate: string,
* title: string
* }}
*/
function formatCalendarEntry(entry) {
return {
description: entry.body.content,
endDate: entry.end.dateTime,
id: entry.id,
location: entry.location.displayName,
startDate: entry.start.dateTime,
title: entry.subject
};
}
/**
* Generate a guid to be used for verifying token validity.
*
* @private
* @returns {string} The generated string.
*/
function generateGuid() {
const buf = new Uint16Array(8);
window.crypto.getRandomValues(buf);
return `${s4(buf[0])}${s4(buf[1])}-${s4(buf[2])}-${s4(buf[3])}-${
s4(buf[4])}-${s4(buf[5])}${s4(buf[6])}${s4(buf[7])}`;
}
/**
* Constructs and returns the URL to use for renewing an auth token.
*
* @param {string} appId - The Microsoft application id to log into.
* @param {string} userDomainType - The domain type of the application as
* provided by Microsoft.
* @param {string} userSigninName - The email of the user signed into the
* integration with Microsoft.
* @private
* @returns {string} - The auth URL.
*/
function getAuthRefreshUrl(appId, userDomainType, userSigninName) {
return [
getAuthUrl(appId, 'undefined', 'undefined'),
'prompt=none',
`domain_hint=${userDomainType}`,
`login_hint=${userSigninName}`
].join('&');
}
/**
* Constructs and returns the auth URL to use for login.
*
* @param {string} appId - The Microsoft application id to log into.
* @param {string} authState - The authState guid to use.
* @param {string} authNonce - The authNonce guid to use.
* @private
* @returns {string} - The auth URL.
*/
function getAuthUrl(appId, authState, authNonce) {
const authParams = [
'response_type=id_token+token',
`client_id=${appId}`,
`redirect_uri=${MS_API_CONFIGURATION.REDIRECT_URI}`,
`scope=${MS_API_CONFIGURATION.MS_API_SCOPES}`,
`state=${authState}`,
`nonce=${authNonce}`,
'response_mode=fragment'
].join('&');
return `${MS_API_CONFIGURATION.AUTH_ENDPOINT}${authParams}`;
}
/**
* Converts a url from an auth redirect into an object of parameters passed
* into the url.
*
* @param {string} url - The string to parse.
* @private
* @returns {Object}
*/
function getParamsFromHash(url) {
const params = parseURLParams(parseStandardURIString(url), true, 'hash');
// Get the number of seconds the token is valid for, subtract 5 minutes
// to account for differences in clock settings and convert to ms.
const expiresIn = (parseInt(params.expires_in, 10) - 300) * 1000;
const now = new Date();
const expireDate = new Date(now.getTime() + expiresIn);
params.tokenExpires = expireDate.getTime().toString();
return params;
}
/**
* Converts the parameters from a Microsoft auth redirect into an object of
* token parts. The value "null" will be returned if the params do not produce
* a valid token.
*
* @param {Object} tokenInfo - The token object.
* @param {Object} guids - The guids for authState and authNonce that should
* match in the token.
* @param {Object} appId - The Microsoft application this token is for.
* @private
* @returns {Object|null}
*/
function getValidatedTokenParts(tokenInfo, guids, appId) {
// Make sure the token matches the request source by matching the GUID.
if (tokenInfo.state !== guids.authState) {
return null;
}
const idToken = tokenInfo.id_token;
// A token must exist to be valid.
if (!idToken) {
return null;
}
const tokenParts = idToken.split('.');
if (tokenParts.length !== 3) {
return null;
}
const payload
= rs.KJUR.jws.JWS.readSafeJSONString(rs.b64utoutf8(tokenParts[1]));
if (payload.nonce !== guids.authNonce
|| payload.aud !== appId
|| payload.iss
!== `https://login.microsoftonline.com/${payload.tid}/v2.0`) {
return null;
}
const now = new Date();
// Adjust by 5 minutes to allow for inconsistencies in system clocks.
const notBefore = new Date((payload.nbf - 300) * 1000);
const expires = new Date((payload.exp + 300) * 1000);
if (now < notBefore || now > expires) {
return null;
}
return {
accessToken: tokenInfo.access_token,
idToken,
userDisplayName: payload.name,
userDomainType:
payload.tid === MS_API_CONFIGURATION.MS_CONSUMER_TENANT
? 'consumers' : 'organizations',
userSigninName: payload.preferred_username
};
}
/**
* Retrieves calendar entries from a specific calendar.
*
* @param {Object} client - The Microsoft-graph-client initialized.
* @param {string} calendarId - The calendar ID to use.
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {Promise<any> | Promise}
* @private
*/
function requestCalendarEvents( // eslint-disable-line max-params
client,
calendarId,
fetchStartDays,
fetchEndDays): Promise<*> {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + fetchStartDays);
endDate.setDate(endDate.getDate() + fetchEndDays);
const filter = `Start/DateTime ge '${
startDate.toISOString()}' and End/DateTime lt '${
endDate.toISOString()}'`;
return client
.api(`/me/calendars/${calendarId}/events`)
.filter(filter)
.select('id,subject,start,end,location,body')
.orderby('createdDateTime DESC')
.get();
}
/**
* Converts the passed in number to a string and ensure it is at least 4
* characters in length, prepending 0's as needed.
*
* @param {number} num - The number to pad and convert to a string.
* @private
* @returns {string} - The number converted to a string.
*/
function s4(num) {
let ret = num.toString(16);
while (ret.length < 4) {
ret = `0${ret}`;
}
return ret;
}

View File

@ -7,6 +7,21 @@ import {
import { GOOGLE_API_STATES } from './constants';
import googleApi from './googleApi';
/**
* Retrieves the current calendar events.
*
* @param {number} fetchStartDays - The number of days to go back when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {function(Dispatch<*>): Promise<CalendarEntries>}
*/
export function getCalendarEntries(
fetchStartDays: ?number, fetchEndDays: ?number) {
return () =>
googleApi.get()
.then(() =>
googleApi._getCalendarEntries(fetchStartDays, fetchEndDays));
}
/**
* Loads Google API.
*
@ -14,9 +29,16 @@ import googleApi from './googleApi';
* @returns {Function}
*/
export function loadGoogleAPI(clientId: string) {
return (dispatch: Dispatch<*>) =>
return (dispatch: Dispatch<*>, getState: Function) =>
googleApi.get()
.then(() => googleApi.initializeClient(clientId))
.then(() => {
if (getState()['features/google-api'].googleAPIState
=== GOOGLE_API_STATES.NEEDS_LOADING) {
return googleApi.initializeClient(clientId);
}
return Promise.resolve();
})
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.LOADED }))
@ -30,39 +52,6 @@ export function loadGoogleAPI(clientId: string) {
});
}
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signIn() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}));
}
/**
* Updates the profile data that is currently used.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function updateProfile() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}))
.then(() => googleApi.getCurrentUserProfile())
.then(profile => dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: profile.getEmail()
}));
}
/**
* Executes a request for a list of all YouTube broadcasts associated with
* user currently signed in to the Google API Client Library.
@ -137,3 +126,61 @@ export function showAccountSelection() {
return () =>
googleApi.showAccountSelection();
}
/**
* Prompts the participant to sign in to the Google API Client Library.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signIn() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}));
}
/**
* Logs out the user.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function signOut() {
return (dispatch: Dispatch<*>) =>
googleApi.get()
.then(() => googleApi.signOut())
.then(() => {
dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.LOADED
});
dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: ''
});
});
}
/**
* Updates the profile data that is currently used.
*
* @returns {function(Dispatch<*>): Promise<string | never>}
*/
export function updateProfile() {
return (dispatch: Dispatch<*>) => googleApi.get()
.then(() => googleApi.signInIfNotSignedIn())
.then(() => dispatch({
type: SET_GOOGLE_API_STATE,
googleAPIState: GOOGLE_API_STATES.SIGNED_IN
}))
.then(() => googleApi.getCurrentUserProfile())
.then(profile => {
dispatch({
type: SET_GOOGLE_API_PROFILE,
profileEmail: profile.getEmail()
});
return profile.getEmail();
});
}

View File

@ -1,29 +1,25 @@
import PropTypes from 'prop-types';
// @flow
import React, { Component } from 'react';
/**
* The type of the React {@code Component} props of {@link GoogleSignInButton}.
*/
type Props = {
// The callback to invoke when {@code GoogleSignInButton} is clicked.
onClick: Function,
// The text to display within {@code GoogleSignInButton}.
text: string
};
/**
* A React Component showing a button to sign in with Google.
*
* @extends Component
*/
export default class GoogleSignInButton extends Component {
/**
* {@code GoogleSignInButton} component's property types.
*
* @static
*/
static propTypes = {
/**
* The callback to invoke when the button is clicked.
*/
onClick: PropTypes.func,
/**
* The text to display in the button.
*/
text: PropTypes.string
};
export default class GoogleSignInButton extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*

View File

@ -0,0 +1 @@
export { default as GoogleSignInButton } from './GoogleSignInButton';

View File

@ -1,14 +1,23 @@
// @flow
/**
* The Google API scopes to request access to for streaming.
* The Google API scopes to request access for streaming and calendar.
*
* @type {Array<string>}
*/
export const GOOGLE_API_SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly'
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/calendar'
];
/**
* Array of API discovery doc URLs for APIs used by the googleApi.
*
* @type {string[]}
*/
export const DISCOVERY_DOCS
= [ 'https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest' ];
/**
* An enumeration of the different states the Google API can be in.
*

View File

@ -1,4 +1,4 @@
import { GOOGLE_API_SCOPES } from './constants';
import { GOOGLE_API_SCOPES, DISCOVERY_DOCS } from './constants';
const GOOGLE_API_CLIENT_LIBRARY_URL = 'https://apis.google.com/js/api.js';
@ -67,6 +67,7 @@ const googleApi = {
setTimeout(() => {
api.client.init({
clientId,
discoveryDocs: DISCOVERY_DOCS,
scope: GOOGLE_API_SCOPES.join(' ')
})
.then(resolve)
@ -86,6 +87,7 @@ const googleApi = {
.then(api => Boolean(api
&& api.auth2
&& api.auth2.getAuthInstance
&& api.auth2.getAuthInstance()
&& api.auth2.getAuthInstance().isSignedIn
&& api.auth2.getAuthInstance().isSignedIn.get()));
},
@ -183,6 +185,99 @@ const googleApi = {
});
},
/**
* Sign out from the Google API Client Library.
*
* @returns {Promise}
*/
signOut() {
return this.get()
.then(api =>
api.auth2
&& api.auth2.getAuthInstance
&& api.auth2.getAuthInstance()
&& api.auth2.getAuthInstance().signOut());
},
/**
* Parses the google calendar entries to a known format.
*
* @param {Object} entry - The google calendar entry.
* @returns {{
* id: string,
* startDate: string,
* endDate: string,
* title: string,
* location: string,
* description: string}}
* @private
*/
_convertCalendarEntry(entry) {
return {
id: entry.id,
startDate: entry.start.dateTime,
endDate: entry.end.dateTime,
title: entry.summary,
location: entry.location,
description: entry.description
};
},
/**
* Retrieves calendar entries from all available calendars.
*
* @param {number} fetchStartDays - The number of days to go back
* when fetching.
* @param {number} fetchEndDays - The number of days to fetch.
* @returns {Promise<CalendarEntry>}
* @private
*/
_getCalendarEntries(fetchStartDays, fetchEndDays) {
return this.get()
.then(() => this.isSignedIn())
.then(isSignedIn => {
if (!isSignedIn) {
return null;
}
return this._getGoogleApiClient()
.client.calendar.calendarList.list();
})
.then(calendarList => {
// no result, maybe not signed in
if (!calendarList) {
return Promise.resolve();
}
const calendarIds
= calendarList.result.items.map(en => en.id);
const promises = calendarIds.map(id => {
const startDate = new Date();
const endDate = new Date();
startDate.setDate(startDate.getDate() + fetchStartDays);
endDate.setDate(endDate.getDate() + fetchEndDays);
return this._getGoogleApiClient()
.client.calendar.events.list({
'calendarId': id,
'timeMin': startDate.toISOString(),
'timeMax': endDate.toISOString(),
'showDeleted': false,
'singleEvents': true,
'orderBy': 'startTime'
});
});
return Promise.all(promises)
.then(results =>
[].concat(...results.map(rItem => rItem.result.items)))
.then(entries =>
entries.map(e => this._convertCalendarEntry(e)));
});
},
/**
* Returns the global Google API Client Library object. Direct use of this
* method is discouraged; instead use the {@link get} method.

View File

@ -1,5 +1,6 @@
export { GOOGLE_API_STATES } from './constants';
export * from './googleApi';
export { default as googleApi } from './googleApi';
export * from './actions';
export * from './components';
import './reducer';

View File

@ -7,13 +7,14 @@ import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import {
updateProfile,
GOOGLE_API_STATES,
GoogleSignInButton,
loadGoogleAPI,
requestAvailableYouTubeBroadcasts,
requestLiveStreamsForYouTubeBroadcast,
showAccountSelection,
signIn
signIn,
updateProfile
} from '../../../google-api';
import AbstractStartLiveStreamDialog, {
@ -21,7 +22,6 @@ import AbstractStartLiveStreamDialog, {
type Props
} from './AbstractStartLiveStreamDialog';
import BroadcastsDropdown from './BroadcastsDropdown';
import GoogleSignInButton from './GoogleSignInButton';
import StreamKeyForm from './StreamKeyForm';
/**

View File

@ -0,0 +1,301 @@
// @flow
import Button from '@atlaskit/button';
import Spinner from '@atlaskit/spinner';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n';
import {
CALENDAR_TYPE,
MicrosoftSignInButton,
clearCalendarIntegration,
bootstrapCalendarIntegration,
isCalendarEnabled,
signIn
} from '../../../calendar-sync';
import { GoogleSignInButton } from '../../../google-api';
const logger = require('jitsi-meet-logger').getLogger(__filename);
declare var interfaceConfig: Object;
/**
* The type of the React {@code Component} props of {@link CalendarTab}.
*/
type Props = {
/**
* The name given to this Jitsi Application.
*/
_appName: string,
/**
* Whether or not to display a button to sign in to Google.
*/
_enableGoogleIntegration: boolean,
/**
* Whether or not to display a button to sign in to Microsoft.
*/
_enableMicrosoftIntegration: boolean,
/**
* The current calendar integration in use, if any.
*/
_isConnectedToCalendar: boolean,
/**
* The email address associated with the calendar integration in use.
*/
_profileEmail: string,
/**
* Invoked to change the configured calendar integration.
*/
dispatch: Function,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* The type of the React {@code Component} state of {@link CalendarTab}.
*/
type State = {
/**
* Whether or not any third party APIs are being loaded.
*/
loading: boolean
};
/**
* React {@code Component} for modifying calendar integration.
*
* @extends Component
*/
class CalendarTab extends Component<Props, State> {
/**
* Initializes a new {@code CalendarTab} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this.state = {
loading: true
};
// Bind event handlers so they are only bound once for every instance.
this._onClickDisconnect = this._onClickDisconnect.bind(this);
this._onClickGoogle = this._onClickGoogle.bind(this);
this._onClickMicrosoft = this._onClickMicrosoft.bind(this);
}
/**
* Loads third party APIs as needed and bootstraps the initial calendar
* state if not already set.
*
* @inheritdoc
*/
componentDidMount() {
this.props.dispatch(bootstrapCalendarIntegration())
.catch(err => logger.error('CalendarTab bootstrap failed', err))
.then(() => this.setState({ loading: false }));
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
let view;
if (this.state.loading) {
view = this._renderLoadingState();
} else if (this.props._isConnectedToCalendar) {
view = this._renderSignOutState();
} else {
view = this._renderSignInState();
}
return (
<div className = 'calendar-tab'>
{ view }
</div>
);
}
/**
* Dispatches the action to start the sign in flow for a given calendar
* integration type.
*
* @param {string} type - The calendar type to try integrating with.
* @private
* @returns {void}
*/
_attemptSignIn(type) {
this.props.dispatch(signIn(type));
}
_onClickDisconnect: (Object) => void;
/**
* Dispatches an action to sign out of the currently connected third party
* used for calendar integration.
*
* @private
* @returns {void}
*/
_onClickDisconnect() {
// We clear the integration state instead of actually signing out. This
// is for two primary reasons. Microsoft does not support a sign out and
// instead relies on clearing of local auth data. Google signout can
// also sign the user out of YouTube. So for now we've decided not to
// do an actual sign out.
this.props.dispatch(clearCalendarIntegration());
}
_onClickGoogle: () => void;
/**
* Starts the sign in flow for Google calendar integration.
*
* @private
* @returns {void}
*/
_onClickGoogle() {
this._attemptSignIn(CALENDAR_TYPE.GOOGLE);
}
_onClickMicrosoft: () => void;
/**
* Starts the sign in flow for Microsoft calendar integration.
*
* @private
* @returns {void}
*/
_onClickMicrosoft() {
this._attemptSignIn(CALENDAR_TYPE.MICROSOFT);
}
/**
* Render a React Element to indicate third party APIs are being loaded.
*
* @private
* @returns {ReactElement}
*/
_renderLoadingState() {
return (
<Spinner
isCompleting = { false }
size = 'medium' />
);
}
/**
* Render a React Element to sign into a third party for calendar
* integration.
*
* @private
* @returns {ReactElement}
*/
_renderSignInState() {
const {
_appName,
_enableGoogleIntegration,
_enableMicrosoftIntegration,
t
} = this.props;
return (
<div>
<p>
{ t('settings.calendar.about',
{ appName: _appName || '' }) }
</p>
{ _enableGoogleIntegration
&& <div className = 'calendar-tab-sign-in'>
<GoogleSignInButton
onClick = { this._onClickGoogle }
text = { t('liveStreaming.signIn') } />
</div> }
{ _enableMicrosoftIntegration
&& <div className = 'calendar-tab-sign-in'>
<MicrosoftSignInButton
onClick = { this._onClickMicrosoft }
text = { t('settings.calendar.microsoftSignIn') } />
</div> }
</div>
);
}
/**
* Render a React Element to sign out of the currently connected third
* party used for calendar integration.
*
* @private
* @returns {ReactElement}
*/
_renderSignOutState() {
const { _profileEmail, t } = this.props;
return (
<div>
<div className = 'sign-out-cta'>
{ t('settings.calendar.signedIn',
{ email: _profileEmail }) }
</div>
<Button
appearance = 'primary'
id = 'calendar_logout'
onClick = { this._onClickDisconnect }
type = 'button'>
{ t('settings.calendar.disconnect') }
</Button>
</div>
);
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code CalendarTab} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _appName: string,
* _enableGoogleIntegration: boolean,
* _enableMicrosoftIntegration: boolean,
* _isConnectedToCalendar: boolean,
* _profileEmail: string
* }}
*/
function _mapStateToProps(state) {
const calendarState = state['features/calendar-sync'] || {};
const {
googleApiApplicationClientID,
microsoftApiApplicationClientID
} = state['features/base/config'];
const calendarEnabled = isCalendarEnabled();
return {
_appName: interfaceConfig.APP_NAME,
_enableGoogleIntegration: Boolean(
calendarEnabled && googleApiApplicationClientID),
_enableMicrosoftIntegration: Boolean(
calendarEnabled && microsoftApiApplicationClientID),
_isConnectedToCalendar: calendarState.integrationReady,
_profileEmail: calendarState.profileEmail
};
}
export default translate(connect(_mapStateToProps)(CalendarTab));

View File

@ -5,12 +5,14 @@ import { connect } from 'react-redux';
import { getAvailableDevices } from '../../../base/devices';
import { DialogWithTabs, hideDialog } from '../../../base/dialog';
import { isCalendarEnabled } from '../../../calendar-sync';
import {
DeviceSelection,
getDeviceSelectionDialogProps,
submitDeviceSelectionTab
} from '../../../device-selection';
import CalendarTab from './CalendarTab';
import MoreTab from './MoreTab';
import ProfileTab from './ProfileTab';
import { getMoreTabProps, getProfileTabProps } from '../../functions';
@ -40,7 +42,7 @@ type Props = {
/**
* Invoked to save changed settings.
*/
dispatch: Function,
dispatch: Function
};
/**
@ -81,7 +83,8 @@ class SettingsDialog extends Component<Props> {
onMount: tab.onMount
? (...args) => dispatch(tab.onMount(...args))
: undefined,
submit: (...args) => dispatch(tab.submit(...args))
submit: (...args) => tab.submit
&& dispatch(tab.submit(...args))
};
});
@ -129,7 +132,8 @@ function _mapStateToProps(state) {
const { showModeratorSettings, showLanguageSettings } = moreTabProps;
const showProfileSettings
= configuredTabs.includes('profile') && jwt.isGuest;
const showCalendarSettings
= configuredTabs.includes('calendar') && isCalendarEnabled();
const tabs = [];
if (showDeviceSettings) {
@ -169,6 +173,15 @@ function _mapStateToProps(state) {
});
}
if (showCalendarSettings) {
tabs.push({
name: SETTINGS_TABS.CALENDAR,
component: CalendarTab,
label: 'settings.calendar.title',
styles: 'settings-pane calendar-pane'
});
}
if (showModeratorSettings || showLanguageSettings) {
tabs.push({
name: SETTINGS_TABS.MORE,

View File

@ -1,4 +1,5 @@
export const SETTINGS_TABS = {
CALENDAR: 'calendar_tab',
DEVICES: 'devices_tab',
MORE: 'more_tab',
PROFILE: 'profile_tab'

12
static/msredirect.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<body>
<script>
window.opener
&& window.opener.postMessage({
type: 'ms-login',
url: window.location.href
}, window.location.origin);
</script>
</body>
</html>