mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-02-12 08:50:20 +00:00
Compare commits
13 Commits
debug-test
...
authentica
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc639a705 | ||
|
|
91ea707c85 | ||
|
|
2e4961eca3 | ||
|
|
7a93da75f8 | ||
|
|
7cbc6385d1 | ||
|
|
7424f0368c | ||
|
|
1efc5e40e1 | ||
|
|
dc84826d9c | ||
|
|
b4a64ebc44 | ||
|
|
dbb3ccc274 | ||
|
|
9a6ed65cb1 | ||
|
|
21ea67b29c | ||
|
|
ab4be2366f |
@@ -1600,6 +1600,8 @@ var config = {
|
||||
// An option to get for user info (name, picture, email) in the token outside the user context.
|
||||
// Can be used with Firebase tokens.
|
||||
// tokenGetUserInfoOutOfContext: false,
|
||||
// An option to pass the token in the iframe API directly instead of using the redirect flow.
|
||||
// tokenAuthInline: false,
|
||||
|
||||
// You can put an array of values to target different entity types in the invite dialog.
|
||||
// Valid values are "phone", "room", "sip", "user", "videosipgw" and "email"
|
||||
|
||||
@@ -227,6 +227,9 @@
|
||||
"video_ssrc": "Video SSRC:",
|
||||
"yes": "yes"
|
||||
},
|
||||
"customPanel": {
|
||||
"close": "Close"
|
||||
},
|
||||
"dateUtils": {
|
||||
"earlier": "Earlier",
|
||||
"today": "Today",
|
||||
@@ -380,6 +383,8 @@
|
||||
"lockRoom": "Add meeting $t(lockRoomPassword)",
|
||||
"lockTitle": "Lock failed",
|
||||
"login": "Login",
|
||||
"loginFailed": "Login failed.",
|
||||
"loginOnResume": "Your authentication session has expired. You need to login again to continue the meeting.",
|
||||
"loginQuestion": "Are you sure you want to login and leave the conference?",
|
||||
"logoutQuestion": "Are you sure you want to logout and leave the conference?",
|
||||
"logoutTitle": "Logout",
|
||||
@@ -1315,6 +1320,7 @@
|
||||
"chat": "Open / Close chat",
|
||||
"clap": "Clap",
|
||||
"closeChat": "Close chat",
|
||||
"closeCustomPanel": "Close",
|
||||
"closeMoreActions": "Close more actions menu",
|
||||
"closeParticipantsPane": "Close participants pane",
|
||||
"closedCaptions": "Closed captions",
|
||||
@@ -1420,9 +1426,11 @@
|
||||
"chat": "Open / Close chat",
|
||||
"clap": "Clap",
|
||||
"closeChat": "Close chat",
|
||||
"closeCustomPanel": "Close",
|
||||
"closeParticipantsPane": "Close participants pane",
|
||||
"closeReactionsMenu": "Close reactions menu",
|
||||
"closedCaptions": "Closed captions",
|
||||
"copilot": "Copilot",
|
||||
"disableNoiseSuppression": "Disable extra noise suppression",
|
||||
"disableReactionSounds": "You can disable reaction sounds for this meeting",
|
||||
"documentClose": "Close shared document",
|
||||
|
||||
1301
package-lock.json
generated
1301
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -164,12 +164,12 @@
|
||||
"@types/w3c-image-capture": "1.0.6",
|
||||
"@types/w3c-web-hid": "1.0.3",
|
||||
"@types/zxcvbn": "4.4.1",
|
||||
"@wdio/allure-reporter": "9.22.0",
|
||||
"@wdio/cli": "9.22.0",
|
||||
"@wdio/globals": "9.17.0",
|
||||
"@wdio/junit-reporter": "9.21.0",
|
||||
"@wdio/local-runner": "9.22.0",
|
||||
"@wdio/mocha-framework": "9.22.0",
|
||||
"@wdio/allure-reporter": "9.23.2",
|
||||
"@wdio/cli": "9.23.2",
|
||||
"@wdio/globals": "9.23.0",
|
||||
"@wdio/junit-reporter": "9.23.2",
|
||||
"@wdio/local-runner": "9.23.2",
|
||||
"@wdio/mocha-framework": "9.23.2",
|
||||
"babel-loader": "9.1.0",
|
||||
"babel-plugin-optional-require": "0.3.1",
|
||||
"circular-dependency-plugin": "5.2.0",
|
||||
|
||||
@@ -119,6 +119,7 @@ export function maybeRedirectToTokenAuthUrl(
|
||||
|
||||
// if tokenAuthUrl check jwt if is about to expire go through the url to get new token
|
||||
const jwt = state['features/base/jwt'].jwt;
|
||||
const refreshToken = state['features/base/jwt'].refreshToken;
|
||||
const expirationDate = getJwtExpirationDate(jwt);
|
||||
|
||||
// if there is jwt and its expiration time is less than 3 minutes away
|
||||
@@ -137,7 +138,8 @@ export function maybeRedirectToTokenAuthUrl(
|
||||
videoMuted
|
||||
},
|
||||
room,
|
||||
tenant
|
||||
tenant,
|
||||
refreshToken
|
||||
)
|
||||
.then((tokenAuthServiceUrl: string | undefined) => {
|
||||
if (!tokenAuthServiceUrl) {
|
||||
|
||||
@@ -11,8 +11,9 @@ const route = {
|
||||
* store.
|
||||
*
|
||||
* @param {any} _stateful - Used on web.
|
||||
* @param {any} _dispatch - Used on web.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function _getRouteToRender(_stateful?: any) {
|
||||
export function _getRouteToRender(_stateful?: any): Promise<object> {
|
||||
return Promise.resolve(route);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// @ts-expect-error
|
||||
import { generateRoomWithoutSeparator } from '@jitsi/js-utils/random';
|
||||
|
||||
import { getTokenAuthUrl } from '../authentication/functions.web';
|
||||
import { IStateful } from '../base/app/types';
|
||||
import { isRoomValid } from '../base/conference/functions';
|
||||
import { isSupportedBrowser } from '../base/environment/environment';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { parseURIString } from '../base/util/uri';
|
||||
import Conference from '../conference/components/web/Conference';
|
||||
import { getDeepLinkingPage } from '../deep-linking/functions';
|
||||
import UnsupportedDesktopBrowser from '../unsupported-browser/components/UnsupportedDesktopBrowser';
|
||||
@@ -23,9 +20,10 @@ import { IReduxState } from './types';
|
||||
*
|
||||
* @param {(Function|Object)} stateful - THe redux store, state, or
|
||||
* {@code getState} function.
|
||||
* @param {Dispatch} dispatch - The Redux dispatch function.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function _getRouteToRender(stateful: IStateful) {
|
||||
export function _getRouteToRender(stateful: IStateful): Promise<object> {
|
||||
const state = toState(stateful);
|
||||
|
||||
return _getWebConferenceRoute(state) || _getWebWelcomePageRoute(state);
|
||||
@@ -36,9 +34,10 @@ export function _getRouteToRender(stateful: IStateful) {
|
||||
* a valid conference is being joined.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Dispatch} dispatch - The Redux dispatch function.
|
||||
* @returns {Promise|undefined}
|
||||
*/
|
||||
function _getWebConferenceRoute(state: IReduxState) {
|
||||
function _getWebConferenceRoute(state: IReduxState): Promise<any> | undefined {
|
||||
const room = state['features/base/conference'].room;
|
||||
|
||||
if (!isRoomValid(room)) {
|
||||
@@ -46,36 +45,6 @@ function _getWebConferenceRoute(state: IReduxState) {
|
||||
}
|
||||
|
||||
const route = _getEmptyRoute();
|
||||
const config = state['features/base/config'];
|
||||
|
||||
// if we have auto redirect enabled, and we have previously logged in successfully
|
||||
// let's redirect to the auth url to get the token and login again
|
||||
if (!browser.isElectron() && config.tokenAuthUrl && config.tokenAuthUrlAutoRedirect
|
||||
&& state['features/authentication'].tokenAuthUrlSuccessful
|
||||
&& !state['features/base/jwt'].jwt && room) {
|
||||
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
|
||||
const { tenant } = parseURIString(locationURL.href) || {};
|
||||
const { startAudioOnly } = config;
|
||||
|
||||
return getTokenAuthUrl(
|
||||
config,
|
||||
locationURL,
|
||||
{
|
||||
audioMuted: false,
|
||||
audioOnlyEnabled: startAudioOnly,
|
||||
skipPrejoin: false,
|
||||
videoMuted: false
|
||||
},
|
||||
room,
|
||||
tenant
|
||||
)
|
||||
.then((url: string | undefined) => {
|
||||
route.href = url;
|
||||
|
||||
return route;
|
||||
})
|
||||
.catch(() => Promise.resolve(route));
|
||||
}
|
||||
|
||||
// Update the location if it doesn't match. This happens when a room is
|
||||
// joined from the welcome page. The reason for doing this instead of using
|
||||
|
||||
@@ -26,5 +26,6 @@ import '../face-landmarks/middleware';
|
||||
import '../gifs/middleware';
|
||||
import '../whiteboard/middleware.web';
|
||||
import '../file-sharing/middleware.web';
|
||||
import '../custom-panel/middleware.web';
|
||||
|
||||
import './middlewares.any';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../base/devices/reducer';
|
||||
import '../base/premeeting/reducer';
|
||||
import '../custom-panel/reducer';
|
||||
import '../base/tooltip/reducer';
|
||||
import '../e2ee/reducer';
|
||||
import '../face-landmarks/reducer';
|
||||
|
||||
@@ -31,6 +31,7 @@ import { IUserInteractionState } from '../base/user-interaction/reducer';
|
||||
import { IBreakoutRoomsState } from '../breakout-rooms/reducer';
|
||||
import { ICalendarSyncState } from '../calendar-sync/reducer';
|
||||
import { IChatState } from '../chat/reducer';
|
||||
import { ICustomPanelState } from '../custom-panel/reducer';
|
||||
import { IDeepLinkingState } from '../deep-linking/reducer';
|
||||
import { IDropboxState } from '../dropbox/reducer';
|
||||
import { IDynamicBrandingState } from '../dynamic-branding/reducer';
|
||||
@@ -121,6 +122,7 @@ export interface IReduxState {
|
||||
'features/calendar-sync': ICalendarSyncState;
|
||||
'features/call-integration': ICallIntegrationState;
|
||||
'features/chat': IChatState;
|
||||
'features/custom-panel': ICustomPanelState;
|
||||
'features/deep-linking': IDeepLinkingState;
|
||||
'features/dropbox': IDropboxState;
|
||||
'features/dynamic-branding': IDynamicBrandingState;
|
||||
|
||||
@@ -88,3 +88,13 @@ export function openTokenAuthUrl(tokenAuthServiceUrl: string) {
|
||||
Linking.openURL(tokenAuthServiceUrl);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used.
|
||||
*
|
||||
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
||||
* @returns {Promise<any>} Resolves.
|
||||
*/
|
||||
export function loginWithPopup(tokenAuthServiceUrl: string): Promise<any> {
|
||||
return Promise.resolve(tokenAuthServiceUrl);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { maybeRedirectToWelcomePage } from '../app/actions.web';
|
||||
import { IStore } from '../app/types';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import { setJWT } from '../base/jwt/actions';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { showErrorNotification } from '../notifications/actions';
|
||||
|
||||
import { CANCEL_LOGIN } from './actionTypes';
|
||||
import LoginQuestionDialog from './components/web/LoginQuestionDialog';
|
||||
import { isTokenAuthInline } from './functions.any';
|
||||
import logger from './logger';
|
||||
|
||||
export * from './actions.any';
|
||||
|
||||
@@ -46,6 +50,115 @@ export function redirectToDefaultLocation() {
|
||||
return (dispatch: IStore['dispatch']) => dispatch(maybeRedirectToWelcomePage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cryptographic nonce.
|
||||
*
|
||||
* @returns {string} The generated nonce.
|
||||
*/
|
||||
function generateNonce(): string {
|
||||
const array = new Uint8Array(32);
|
||||
|
||||
crypto.getRandomValues(array);
|
||||
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs login with a popup window.
|
||||
*
|
||||
* @param {string} tokenAuthServiceUrl - Authentication service URL.
|
||||
* @returns {Promise<any>} A promise that resolves with the authentication
|
||||
* result or rejects with an error.
|
||||
*/
|
||||
export function loginWithPopup(tokenAuthServiceUrl: string): Promise<any> {
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
// Open popup
|
||||
const width = 500;
|
||||
const height = 600;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
const nonce = generateNonce();
|
||||
|
||||
sessionStorage.setItem('oauth_nonce', nonce);
|
||||
|
||||
const popup = window.open(
|
||||
`${tokenAuthServiceUrl}&nonce=${nonce}`,
|
||||
`Auth-${Date.now()}`,
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
reject(new Error('Popup blocked'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = (handler: any) => {
|
||||
window.removeEventListener('message', handler);
|
||||
popup.close();
|
||||
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const handler = event => {
|
||||
// Verify origin
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'oauth-success') {
|
||||
cleanup(handler);
|
||||
|
||||
resolve({
|
||||
accessToken: event.data.accessToken,
|
||||
idToken: event.data.idToken,
|
||||
refreshToken: event.data.refreshToken
|
||||
});
|
||||
} else if (event.data.type === 'oauth-error') {
|
||||
cleanup(handler);
|
||||
|
||||
reject(new Error(event.data.error));
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for messages from the popup
|
||||
window.addEventListener('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs silent logout by loading the token authentication logout service URL in an
|
||||
* invisible iframe.
|
||||
*
|
||||
* @param {string} tokenAuthLogoutServiceUrl - Logout service URL.
|
||||
* @returns {Promise<any>} A promise that resolves when logout is complete.
|
||||
*/
|
||||
export function silentLogout(tokenAuthLogoutServiceUrl: string): any {
|
||||
return new Promise<void>(resolve => {
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = tokenAuthLogoutServiceUrl;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
// Listen for logout completion
|
||||
const handler = (event: any) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
if (event.data.type === 'logout-success') {
|
||||
window.removeEventListener('message', handler);
|
||||
document.body.removeChild(iframe);
|
||||
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens token auth URL page.
|
||||
*
|
||||
@@ -63,6 +176,42 @@ export function openTokenAuthUrl(tokenAuthServiceUrl: string): any {
|
||||
}
|
||||
};
|
||||
|
||||
if (!browser.isElectron() && isTokenAuthInline(getState()['features/base/config'])) {
|
||||
loginWithPopup(tokenAuthServiceUrl)
|
||||
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => {
|
||||
// @ts-ignore
|
||||
const token: string = result.accessToken;
|
||||
const idToken: string = result.idToken;
|
||||
const refreshToken: string | undefined = result.refreshToken;
|
||||
|
||||
// @ts-ignore
|
||||
dispatch(setJWT(token, idToken, refreshToken));
|
||||
|
||||
logger.info('Reconnecting to conference with new token.');
|
||||
|
||||
const { connection } = getState()['features/base/connection'];
|
||||
|
||||
connection?.refreshToken(token).then(
|
||||
() => {
|
||||
const { membersOnly } = getState()['features/base/conference'];
|
||||
|
||||
membersOnly?.join();
|
||||
})
|
||||
.catch((err: any) => {
|
||||
dispatch(setJWT());
|
||||
logger.error(err);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'dialog.loginFailed'
|
||||
}));
|
||||
logger.error(err);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show warning for leaving conference only when in a conference.
|
||||
if (!browser.isElectron() && getState()['features/base/conference'].conference) {
|
||||
dispatch(openDialog('LoginQuestionDialog', LoginQuestionDialog, {
|
||||
|
||||
@@ -11,6 +11,15 @@ import { getBackendSafeRoomName } from '../base/util/uri';
|
||||
export const isTokenAuthEnabled = (config: IConfig): boolean =>
|
||||
typeof config.tokenAuthUrl === 'string' && config.tokenAuthUrl.length > 0;
|
||||
|
||||
/**
|
||||
* Checks if the token authentication should be done inline.
|
||||
*
|
||||
* @param {Object} config - Configuration state object from store.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTokenAuthInline = (config: IConfig): boolean =>
|
||||
config.tokenAuthInline === true;
|
||||
|
||||
/**
|
||||
* Returns the state that we can add as a parameter to the tokenAuthUrl.
|
||||
*
|
||||
@@ -23,6 +32,7 @@ export const isTokenAuthEnabled = (config: IConfig): boolean =>
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
* @param {string?} refreshToken - The refresh token if available.
|
||||
*
|
||||
* @returns {Object} The state object.
|
||||
*/
|
||||
@@ -35,13 +45,18 @@ export const _getTokenAuthState = (
|
||||
videoMuted: boolean | undefined;
|
||||
},
|
||||
roomName: string | undefined,
|
||||
tenant: string | undefined): object => {
|
||||
const state = {
|
||||
tenant: string | undefined,
|
||||
refreshToken?: string | undefined): object => {
|
||||
const state: any = {
|
||||
room: roomName,
|
||||
roomSafe: getBackendSafeRoomName(roomName),
|
||||
tenant
|
||||
};
|
||||
|
||||
if (refreshToken) {
|
||||
state.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
audioOnlyEnabled = false,
|
||||
|
||||
@@ -23,6 +23,7 @@ export * from './functions.any';
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
* @param {string?} refreshToken - The refreshToken if any.
|
||||
*
|
||||
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
|
||||
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
|
||||
@@ -39,7 +40,9 @@ export const getTokenAuthUrl = (
|
||||
},
|
||||
roomName: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
tenant: string | undefined): Promise<string | undefined> => {
|
||||
tenant: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
refreshToken?: string | undefined): Promise<string | undefined> => {
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
@@ -64,7 +67,8 @@ export const getTokenAuthUrl = (
|
||||
videoMuted
|
||||
},
|
||||
roomName,
|
||||
tenant
|
||||
tenant,
|
||||
refreshToken
|
||||
);
|
||||
|
||||
// Append ios=true or android=true to the token URL.
|
||||
|
||||
@@ -41,6 +41,7 @@ function _cryptoRandom() {
|
||||
* }.
|
||||
* @param {string?} roomName - The room name.
|
||||
* @param {string?} tenant - The tenant name if any.
|
||||
* @param {string?} refreshToken - The refresh token if available.
|
||||
*
|
||||
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
|
||||
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
|
||||
@@ -57,7 +58,9 @@ export const getTokenAuthUrl = (
|
||||
},
|
||||
roomName: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
tenant: string | undefined): Promise<string | undefined> => {
|
||||
tenant: string | undefined,
|
||||
// eslint-disable-next-line max-params
|
||||
refreshToken?: string | undefined): Promise<string | undefined> => {
|
||||
|
||||
const {
|
||||
audioMuted = false,
|
||||
@@ -82,7 +85,8 @@ export const getTokenAuthUrl = (
|
||||
videoMuted
|
||||
},
|
||||
roomName,
|
||||
tenant
|
||||
tenant,
|
||||
refreshToken
|
||||
);
|
||||
|
||||
if (browser.isElectron()) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IStore } from '../app/types';
|
||||
import { APP_WILL_NAVIGATE } from '../base/app/actionTypes';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
@@ -17,6 +16,7 @@ import { MEDIA_TYPE } from '../base/media/constants';
|
||||
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
|
||||
import { isLocalTrackMuted } from '../base/tracks/functions.any';
|
||||
import { parseURIString } from '../base/util/uri';
|
||||
import { PREJOIN_JOINING_IN_PROGRESS } from '../prejoin/actionTypes';
|
||||
import { openLogoutDialog } from '../settings/actions';
|
||||
|
||||
import {
|
||||
@@ -187,7 +187,11 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
break;
|
||||
}
|
||||
|
||||
case APP_WILL_NAVIGATE: {
|
||||
case PREJOIN_JOINING_IN_PROGRESS: {
|
||||
if (!action.value) {
|
||||
break;
|
||||
}
|
||||
|
||||
const { dispatch, getState } = store;
|
||||
const state = getState();
|
||||
const config = state['features/base/config'];
|
||||
@@ -288,6 +292,7 @@ function _handleLogin({ dispatch, getState }: IStore) {
|
||||
const { enabled: audioOnlyEnabled } = state['features/base/audio-only'];
|
||||
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
|
||||
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
|
||||
const refreshToken = state['features/base/jwt'].refreshToken;
|
||||
|
||||
if (!room) {
|
||||
logger.warn('Cannot handle login, room is undefined!');
|
||||
@@ -311,7 +316,8 @@ function _handleLogin({ dispatch, getState }: IStore) {
|
||||
videoMuted
|
||||
},
|
||||
room,
|
||||
tenant
|
||||
tenant,
|
||||
refreshToken
|
||||
)
|
||||
.then((tokenAuthServiceUrl: string | undefined) => {
|
||||
if (!tokenAuthServiceUrl) {
|
||||
|
||||
@@ -354,7 +354,7 @@ export function e2eRttChanged(participant: Object, rtt: number) {
|
||||
* authLogin: string
|
||||
* }}
|
||||
*/
|
||||
export function authStatusChanged(authEnabled: boolean, authLogin: string) {
|
||||
export function authStatusChanged(authEnabled: boolean, authLogin?: string) {
|
||||
return {
|
||||
type: AUTH_STATUS_CHANGED,
|
||||
authEnabled,
|
||||
|
||||
@@ -402,7 +402,8 @@ async function _connectionEstablished({ dispatch, getState }: IStore, next: Func
|
||||
email = getLocalParticipant(getState())?.email;
|
||||
}
|
||||
|
||||
dispatch(authStatusChanged(true, email || ''));
|
||||
// it may happen to be already set (silent login)
|
||||
dispatch(authStatusChanged(true, email || getState()['features/base/conference'].authLogin || ''));
|
||||
}
|
||||
|
||||
// FIXME: Workaround for the web version. Currently, the creation of the
|
||||
|
||||
@@ -616,6 +616,7 @@ export interface IConfig {
|
||||
disabled?: boolean;
|
||||
numberOfVisibleTiles?: number;
|
||||
};
|
||||
tokenAuthInline?: boolean;
|
||||
tokenAuthUrl?: string;
|
||||
tokenAuthUrlAutoRedirect?: string;
|
||||
tokenGetUserInfoOutOfContext?: boolean;
|
||||
|
||||
@@ -51,6 +51,16 @@ export const CONNECTION_PROPERTIES_UPDATED = 'CONNECTION_PROPERTIES_UPDATED';
|
||||
*/
|
||||
export const CONNECTION_WILL_CONNECT = 'CONNECTION_WILL_CONNECT';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals that the token for a connection is expired.
|
||||
*
|
||||
* {
|
||||
* type: CONNECTION_TOKEN_EXPIRED,
|
||||
* connection: JitsiConnection
|
||||
* }
|
||||
*/
|
||||
export const CONNECTION_TOKEN_EXPIRED = 'CONNECTION_TOKEN_EXPIRED';
|
||||
|
||||
/**
|
||||
* The type of (redux) action which sets the location URL of the application,
|
||||
* connection, conference, etc.
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
CONNECTION_ESTABLISHED,
|
||||
CONNECTION_FAILED,
|
||||
CONNECTION_PROPERTIES_UPDATED,
|
||||
CONNECTION_TOKEN_EXPIRED,
|
||||
CONNECTION_WILL_CONNECT,
|
||||
SET_LOCATION_URL,
|
||||
SET_PREFER_VISITOR
|
||||
@@ -239,6 +240,9 @@ export function _connectInternal(id?: string, password?: string) {
|
||||
connection.addEventListener(
|
||||
JitsiConnectionEvents.PROPERTIES_UPDATED,
|
||||
_onPropertiesUpdate);
|
||||
connection.addEventListener(
|
||||
JitsiConnectionEvents.CONNECTION_TOKEN_EXPIRED,
|
||||
_onTokenExpired);
|
||||
|
||||
/**
|
||||
* Unsubscribe the connection instance from
|
||||
@@ -323,6 +327,16 @@ export function _connectInternal(id?: string, password?: string) {
|
||||
dispatch(redirect(vnode, focusJid, username));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection will resume.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onTokenExpired(): void {
|
||||
dispatch(_connectionTokenExpired(connection));
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection properties were updated.
|
||||
*
|
||||
@@ -364,6 +378,23 @@ function _connectionWillConnect(connection: Object) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an action for when a connection token is expired.
|
||||
*
|
||||
* @param {JitsiConnection} connection - The {@code JitsiConnection} token is expired.
|
||||
* @private
|
||||
* @returns {{
|
||||
* type: CONNECTION_TOKEN_EXPIRED,
|
||||
* connection: JitsiConnection
|
||||
* }}
|
||||
*/
|
||||
function _connectionTokenExpired(connection: Object) {
|
||||
return {
|
||||
type: CONNECTION_TOKEN_EXPIRED,
|
||||
connection
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an action for when connection properties are updated.
|
||||
*
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface IConnectionState {
|
||||
getJid: () => string;
|
||||
getLogs: () => Object;
|
||||
initJitsiConference: Function;
|
||||
refreshToken: Function;
|
||||
removeFeature: Function;
|
||||
};
|
||||
error?: ConnectionFailedError;
|
||||
|
||||
1
react/features/base/icons/svg/AI.svg
Normal file
1
react/features/base/icons/svg/AI.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles-icon lucide-sparkles"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z"/><path d="M20 2v4"/><path d="M22 4h-4"/><circle cx="4" cy="20" r="2"/></svg>
|
||||
|
After Width: | Height: | Size: 582 B |
@@ -1,3 +1,4 @@
|
||||
import { default as IconAI } from './AI.svg';
|
||||
import { default as IconRecordAccount } from './account-record.svg';
|
||||
import { default as IconAddUser } from './add-user.svg';
|
||||
import { default as IconArrowBack } from './arrow-back.svg';
|
||||
@@ -112,6 +113,7 @@ import { default as IconYahoo } from './yahoo.svg';
|
||||
*/
|
||||
export const DEFAULT_ICON: Record<string, any> = {
|
||||
IconAddUser,
|
||||
IconAI,
|
||||
IconArrowBack,
|
||||
IconArrowDown,
|
||||
IconArrowDownLarge,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DEFAULT_ICON } from './constants';
|
||||
|
||||
const {
|
||||
IconAddUser,
|
||||
IconAI,
|
||||
IconArrowBack,
|
||||
IconArrowDown,
|
||||
IconArrowDownLarge,
|
||||
@@ -123,6 +124,7 @@ const {
|
||||
|
||||
export {
|
||||
IconAddUser,
|
||||
IconAI,
|
||||
IconArrowBack,
|
||||
IconArrowDown,
|
||||
IconArrowDownLarge,
|
||||
|
||||
@@ -20,15 +20,21 @@ export function setDelayedLoadOfAvatarUrl(avatarUrl?: string) {
|
||||
* Stores a specific JSON Web Token (JWT) into the redux store.
|
||||
*
|
||||
* @param {string} [jwt] - The JSON Web Token (JWT) to store.
|
||||
* @param {string} idToken - The ID Token to store.
|
||||
* @param {string} refreshToken - The Refresh Token to store.
|
||||
* @returns {{
|
||||
* type: SET_JWT,
|
||||
* jwt: (string|undefined)
|
||||
* jwt: (string|undefined),
|
||||
* idToken: (string|undefined),
|
||||
* refreshToken: (string|undefined)
|
||||
* }}
|
||||
*/
|
||||
export function setJWT(jwt?: string) {
|
||||
export function setJWT(jwt?: string, idToken?: string, refreshToken?: string) {
|
||||
return {
|
||||
type: SET_JWT,
|
||||
jwt
|
||||
jwt,
|
||||
idToken,
|
||||
refreshToken
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,18 @@ import jwtDecode from 'jwt-decode';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { loginWithPopup } from '../../authentication/actions';
|
||||
import LoginQuestionDialog from '../../authentication/components/web/LoginQuestionDialog';
|
||||
import { getTokenAuthUrl, isTokenAuthInline } from '../../authentication/functions';
|
||||
import { isVpaasMeeting } from '../../jaas/functions';
|
||||
import { hideNotification, showNotification } from '../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../notifications/constants';
|
||||
import { authStatusChanged } from '../conference/actions.any';
|
||||
import { getCurrentConference } from '../conference/functions';
|
||||
import { SET_CONFIG } from '../config/actionTypes';
|
||||
import { CONNECTION_ESTABLISHED, SET_LOCATION_URL } from '../connection/actionTypes';
|
||||
import { CONNECTION_ESTABLISHED, CONNECTION_TOKEN_EXPIRED, SET_LOCATION_URL } from '../connection/actionTypes';
|
||||
import { openDialog } from '../dialog/actions';
|
||||
import { browser } from '../lib-jitsi-meet';
|
||||
import { participantUpdated } from '../participants/actions';
|
||||
import { getLocalParticipant } from '../participants/functions';
|
||||
import { IParticipant } from '../participants/types';
|
||||
@@ -16,9 +24,12 @@ import { parseURIString } from '../util/uri';
|
||||
|
||||
import { SET_JWT } from './actionTypes';
|
||||
import { setDelayedLoadOfAvatarUrl, setJWT, setKnownAvatarUrl } from './actions';
|
||||
import { parseJWTFromURLParams } from './functions';
|
||||
import { JWT_VALIDATION_ERRORS } from './constants';
|
||||
import { parseJWTFromURLParams, validateJwt } from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
const PROMPT_LOGIN_NOTIFICATION_ID = 'PROMPT_LOGIN_NOTIFICATION_ID';
|
||||
|
||||
/**
|
||||
* Set up a state change listener to perform maintenance tasks when the conference
|
||||
* is left or failed, e.g. Clear any delayed load avatar url.
|
||||
@@ -39,14 +50,93 @@ StateListenerRegistry.register(
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const state = store.getState();
|
||||
|
||||
switch (action.type) {
|
||||
case SET_CONFIG:
|
||||
case SET_LOCATION_URL:
|
||||
// XXX The JSON Web Token (JWT) is not the only piece of state that we
|
||||
// have decided to store in the feature jwt
|
||||
return _setConfigOrLocationURL(store, next, action);
|
||||
case CONNECTION_TOKEN_EXPIRED: {
|
||||
const jwt = state['features/base/jwt'].jwt;
|
||||
const refreshToken = state['features/base/jwt'].refreshToken;
|
||||
|
||||
if (typeof APP !== 'undefined' && jwt
|
||||
&& validateJwt(jwt).find((e: any) => e.key === JWT_VALIDATION_ERRORS.TOKEN_EXPIRED)) {
|
||||
const { connection, locationURL = { href: '' } as URL } = state['features/base/connection'];
|
||||
const { tenant } = parseURIString(locationURL.href) || {};
|
||||
const room = state['features/base/conference'].room;
|
||||
const dispatch = store.dispatch;
|
||||
|
||||
getTokenAuthUrl(
|
||||
state['features/base/config'],
|
||||
locationURL,
|
||||
{
|
||||
audioMuted: false,
|
||||
audioOnlyEnabled: false,
|
||||
skipPrejoin: false,
|
||||
videoMuted: false
|
||||
},
|
||||
room,
|
||||
tenant,
|
||||
refreshToken
|
||||
)
|
||||
.then((url: string | undefined) => {
|
||||
if (url) {
|
||||
// only if it is inline token auth and token is about to expire
|
||||
// if not expired yet use it to refresh the token
|
||||
dispatch(showNotification({
|
||||
descriptionKey: 'dialog.loginOnResume',
|
||||
titleKey: 'dialog.login',
|
||||
uid: PROMPT_LOGIN_NOTIFICATION_ID,
|
||||
customActionNameKey: [ 'dialog.login' ],
|
||||
customActionHandler: [ () => {
|
||||
store.dispatch(hideNotification(PROMPT_LOGIN_NOTIFICATION_ID));
|
||||
|
||||
if (isTokenAuthInline(state['features/base/config'])) {
|
||||
// Use refresh token if available, otherwise fall back to silent login
|
||||
loginWithPopup(url)
|
||||
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => {
|
||||
// @ts-ignore
|
||||
const token: string = result.accessToken;
|
||||
const idToken: string = result.idToken;
|
||||
const newRefreshToken: string | undefined = result.refreshToken;
|
||||
|
||||
// @ts-ignore
|
||||
dispatch(setJWT(token, idToken, newRefreshToken || refreshToken));
|
||||
|
||||
connection?.refreshToken(token)
|
||||
.catch((err: any) => {
|
||||
dispatch(setJWT());
|
||||
logger.error(err);
|
||||
});
|
||||
}).catch(logger.error);
|
||||
} else {
|
||||
dispatch(openDialog('LoginQuestionDialog', LoginQuestionDialog, {
|
||||
handler: () => {
|
||||
// Give time for the dialog to close.
|
||||
setTimeout(() => {
|
||||
if (browser.isElectron()) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
} ],
|
||||
appearance: NOTIFICATION_TYPE.ERROR
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
|
||||
}
|
||||
})
|
||||
.catch(logger.error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CONNECTION_ESTABLISHED: {
|
||||
const state = store.getState();
|
||||
const delayedLoadOfAvatarUrl = state['features/base/jwt'].delayedLoadOfAvatarUrl;
|
||||
|
||||
if (delayedLoadOfAvatarUrl) {
|
||||
@@ -56,6 +146,7 @@ MiddlewareRegistry.register(store => next => action => {
|
||||
store.dispatch(setDelayedLoadOfAvatarUrl());
|
||||
store.dispatch(setKnownAvatarUrl(delayedLoadOfAvatarUrl));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SET_JWT:
|
||||
return _setJWT(store, next, action);
|
||||
@@ -149,7 +240,7 @@ function _setConfigOrLocationURL({ dispatch, getState }: IStore, next: Function,
|
||||
*/
|
||||
function _setJWT(store: IStore, next: Function, action: AnyAction) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { jwt, type, ...actionPayload } = action;
|
||||
const { idToken, jwt, refreshToken, type, ...actionPayload } = action;
|
||||
|
||||
if (!Object.keys(actionPayload).length) {
|
||||
const state = store.getState();
|
||||
@@ -210,24 +301,32 @@ function _setJWT(store: IStore, next: Function, action: AnyAction) {
|
||||
if (context.user && context.user.role === 'visitor') {
|
||||
action.preferVisitor = true;
|
||||
}
|
||||
} else if (tokenGetUserInfoOutOfContext
|
||||
&& (jwtPayload.name || jwtPayload.picture || jwtPayload.email)) {
|
||||
// there are some tokens (firebase) having picture and name on the main level.
|
||||
_overwriteLocalParticipant(store, {
|
||||
avatarURL: jwtPayload.picture,
|
||||
name: jwtPayload.name,
|
||||
email: jwtPayload.email
|
||||
});
|
||||
} else if (jwtPayload.name || jwtPayload.picture || jwtPayload.email) {
|
||||
if (tokenGetUserInfoOutOfContext) {
|
||||
// there are some tokens (firebase) having picture and name on the main level.
|
||||
_overwriteLocalParticipant(store, {
|
||||
avatarURL: jwtPayload.picture,
|
||||
name: jwtPayload.name,
|
||||
email: jwtPayload.email
|
||||
});
|
||||
}
|
||||
|
||||
store.dispatch(authStatusChanged(true, jwtPayload.email));
|
||||
}
|
||||
}
|
||||
} else if (typeof APP === 'undefined') {
|
||||
// The logic of restoring JWT overrides make sense only on mobile.
|
||||
// On Web it should eventually be restored from storage, but there's
|
||||
// no such use case yet.
|
||||
} else {
|
||||
if (typeof APP === 'undefined') {
|
||||
// The logic of restoring JWT overrides make sense only on mobile.
|
||||
// On Web it should eventually be restored from storage, but there's
|
||||
// no such use case yet.
|
||||
|
||||
const { user } = state['features/base/jwt'];
|
||||
const { user } = state['features/base/jwt'];
|
||||
|
||||
user && _undoOverwriteLocalParticipant(store, user);
|
||||
user && _undoOverwriteLocalParticipant(store, user);
|
||||
}
|
||||
|
||||
// clears authLogin
|
||||
store.dispatch(authStatusChanged(true));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ export interface IJwtState {
|
||||
};
|
||||
delayedLoadOfAvatarUrl?: string;
|
||||
group?: string;
|
||||
idToken?: string;
|
||||
jwt?: string;
|
||||
knownAvatarUrl?: string;
|
||||
refreshToken?: string;
|
||||
server?: string;
|
||||
tenant?: string;
|
||||
user?: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { batch } from 'react-redux';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { CHAT_SIZE } from '../../chat/constants';
|
||||
import { getCustomPanelWidth } from '../../custom-panel/functions';
|
||||
import { getParticipantsPaneWidth } from '../../participants-pane/functions';
|
||||
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export function clientResized(clientWidth: number, clientHeight: number) {
|
||||
}
|
||||
|
||||
availableWidth -= getParticipantsPaneWidth(state);
|
||||
availableWidth -= getCustomPanelWidth(state);
|
||||
|
||||
reducedUIEnabled && dispatch(setReducedUI(availableWidth, clientHeight));
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ const DialogWithTabs = ({
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [ selectedTabIndex, tabStates ]);
|
||||
}, [ selectedTabIndex, tabStates, tabs ]);
|
||||
|
||||
const closeIcon = useMemo(() => (
|
||||
<ClickableIcon
|
||||
|
||||
@@ -14,6 +14,7 @@ import { setColorAlpha } from '../../../base/util/helpers';
|
||||
import { openChat, setFocusedTab } from '../../../chat/actions.web';
|
||||
import Chat from '../../../chat/components/web/Chat';
|
||||
import { ChatTabs } from '../../../chat/constants';
|
||||
import CustomPanel from '../../../custom-panel/components/web/CustomPanel';
|
||||
import { isFileUploadingEnabled, processFiles } from '../../../file-sharing/functions.any';
|
||||
import MainFilmstrip from '../../../filmstrip/components/web/MainFilmstrip';
|
||||
import ScreenshareFilmstrip from '../../../filmstrip/components/web/ScreenshareFilmstrip';
|
||||
@@ -326,6 +327,7 @@ class Conference extends AbstractConference<IProps, any> {
|
||||
{ _showVisitorsQueue && <VisitorsQueue />}
|
||||
</div>
|
||||
<ParticipantsPane />
|
||||
<CustomPanel />
|
||||
<ReactionAnimations />
|
||||
</div>
|
||||
);
|
||||
|
||||
14
react/features/custom-panel/actionTypes.ts
Normal file
14
react/features/custom-panel/actionTypes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Action type to signal the closing of the custom panel.
|
||||
*/
|
||||
export const CUSTOM_PANEL_CLOSE = 'CUSTOM_PANEL_CLOSE';
|
||||
|
||||
/**
|
||||
* Action type to signal the opening of the custom panel.
|
||||
*/
|
||||
export const CUSTOM_PANEL_OPEN = 'CUSTOM_PANEL_OPEN';
|
||||
|
||||
/**
|
||||
* Action type to enable or disable the custom panel dynamically.
|
||||
*/
|
||||
export const SET_CUSTOM_PANEL_ENABLED = 'SET_CUSTOM_PANEL_ENABLED';
|
||||
40
react/features/custom-panel/actions.web.ts
Normal file
40
react/features/custom-panel/actions.web.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
CUSTOM_PANEL_CLOSE,
|
||||
CUSTOM_PANEL_OPEN,
|
||||
SET_CUSTOM_PANEL_ENABLED
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Action to close the custom panel.
|
||||
*
|
||||
* @returns {Object} The action object.
|
||||
*/
|
||||
export function close() {
|
||||
return {
|
||||
type: CUSTOM_PANEL_CLOSE
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to open the custom panel.
|
||||
*
|
||||
* @returns {Object} The action object.
|
||||
*/
|
||||
export function open() {
|
||||
return {
|
||||
type: CUSTOM_PANEL_OPEN
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to enable or disable the custom panel dynamically.
|
||||
*
|
||||
* @param {boolean} enabled - Whether the custom panel should be enabled.
|
||||
* @returns {Object} The action object.
|
||||
*/
|
||||
export function setCustomPanelEnabled(enabled: boolean) {
|
||||
return {
|
||||
type: SET_CUSTOM_PANEL_ENABLED,
|
||||
enabled
|
||||
};
|
||||
}
|
||||
10
react/features/custom-panel/components/web/CustomPanel.tsx
Normal file
10
react/features/custom-panel/components/web/CustomPanel.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Custom panel placeholder component.
|
||||
* This file is overridden by jitsi-meet-branding at build time
|
||||
* to provide the actual panel implementation with iframe content.
|
||||
*
|
||||
* @returns {null} This placeholder renders nothing.
|
||||
*/
|
||||
const CustomPanel = (): null => null;
|
||||
|
||||
export default CustomPanel;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Custom panel button placeholder component.
|
||||
* This file is overridden by jitsi-meet-branding at build time
|
||||
* to provide the actual button implementation with custom icon.
|
||||
*
|
||||
* @returns {null} This placeholder renders nothing.
|
||||
*/
|
||||
const CustomPanelButton = (): null => null;
|
||||
|
||||
export default CustomPanelButton;
|
||||
4
react/features/custom-panel/constants.ts
Normal file
4
react/features/custom-panel/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Default width for the custom panel in pixels.
|
||||
*/
|
||||
export const DEFAULT_CUSTOM_PANEL_WIDTH = 315;
|
||||
67
react/features/custom-panel/functions.ts
Normal file
67
react/features/custom-panel/functions.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
|
||||
import { DEFAULT_CUSTOM_PANEL_WIDTH } from './constants';
|
||||
|
||||
/**
|
||||
* Returns whether the custom panel is enabled based on Redux state.
|
||||
* The feature is disabled by default and can be enabled dynamically via console.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state.
|
||||
* @returns {boolean} Whether the custom panel is enabled.
|
||||
*/
|
||||
export function isCustomPanelEnabled(state: IReduxState): boolean {
|
||||
return Boolean(state['features/custom-panel']?.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom panel URL.
|
||||
* Override to provide the actual URL.
|
||||
*
|
||||
* @returns {string} The custom panel URL.
|
||||
*/
|
||||
export function getCustomPanelUrl(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the custom panel button icon.
|
||||
* Override to provide the actual icon.
|
||||
*
|
||||
* @returns {Function | undefined} The icon component.
|
||||
*/
|
||||
export function getCustomPanelIcon(): Function | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured panel width.
|
||||
*
|
||||
* @returns {number} The panel width in pixels.
|
||||
*/
|
||||
export function getCustomPanelConfiguredWidth(): number {
|
||||
return DEFAULT_CUSTOM_PANEL_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the custom panel is currently open.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state.
|
||||
* @returns {boolean} Whether the custom panel is open.
|
||||
*/
|
||||
export function getCustomPanelOpen(state: IReduxState): boolean {
|
||||
return Boolean(state['features/custom-panel']?.isOpen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current panel width (0 if closed or disabled).
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state.
|
||||
* @returns {number} The panel width in pixels.
|
||||
*/
|
||||
export function getCustomPanelWidth(state: IReduxState): number {
|
||||
if (!isCustomPanelEnabled(state)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getCustomPanelOpen(state) ? getCustomPanelConfiguredWidth() : 0;
|
||||
}
|
||||
29
react/features/custom-panel/hooks.web.ts
Normal file
29
react/features/custom-panel/hooks.web.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import CustomPanelButton from './components/web/CustomPanelButton';
|
||||
import { isCustomPanelEnabled } from './functions';
|
||||
|
||||
/**
|
||||
* Configuration for the custom panel toolbar button.
|
||||
*/
|
||||
const customPanel = {
|
||||
key: 'custom-panel',
|
||||
Content: CustomPanelButton,
|
||||
group: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* A hook that returns the custom panel button if the feature is enabled.
|
||||
* Uses useSelector for reactive updates when the feature is toggled dynamically.
|
||||
*
|
||||
* @returns {Object | undefined} The button configuration or undefined if disabled.
|
||||
*/
|
||||
export function useCustomPanelButton() {
|
||||
const enabled = useSelector(isCustomPanelEnabled);
|
||||
|
||||
if (enabled) {
|
||||
return customPanel;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
4
react/features/custom-panel/middleware.web.ts
Normal file
4
react/features/custom-panel/middleware.web.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Custom panel middleware placeholder.
|
||||
* Override to add custom panel functionality.
|
||||
*/
|
||||
59
react/features/custom-panel/reducer.ts
Normal file
59
react/features/custom-panel/reducer.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import ReducerRegistry from '../base/redux/ReducerRegistry';
|
||||
|
||||
import {
|
||||
CUSTOM_PANEL_CLOSE,
|
||||
CUSTOM_PANEL_OPEN,
|
||||
SET_CUSTOM_PANEL_ENABLED
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* The state of the custom panel feature.
|
||||
*/
|
||||
export interface ICustomPanelState {
|
||||
|
||||
/**
|
||||
* Whether the custom panel feature is enabled.
|
||||
* This can be toggled dynamically via console.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the custom panel is currently open.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: ICustomPanelState = {
|
||||
enabled: false,
|
||||
isOpen: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen for actions that mutate the custom panel state.
|
||||
*/
|
||||
ReducerRegistry.register(
|
||||
'features/custom-panel', (state: ICustomPanelState = DEFAULT_STATE, action): ICustomPanelState => {
|
||||
switch (action.type) {
|
||||
case CUSTOM_PANEL_CLOSE:
|
||||
return {
|
||||
...state,
|
||||
isOpen: false
|
||||
};
|
||||
|
||||
case CUSTOM_PANEL_OPEN:
|
||||
return {
|
||||
...state,
|
||||
isOpen: true
|
||||
};
|
||||
|
||||
case SET_CUSTOM_PANEL_ENABLED:
|
||||
return {
|
||||
...state,
|
||||
enabled: action.enabled
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -5,9 +5,14 @@ import { connect, useDispatch } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { loginWithPopup } from '../../../authentication/actions.web';
|
||||
import { getTokenAuthUrl, isTokenAuthInline } from '../../../authentication/functions.web';
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import { IConfig } from '../../../base/config/configType';
|
||||
import { isNameReadOnly } from '../../../base/config/functions.web';
|
||||
import { IconArrowDown, IconArrowUp, IconPhoneRinging, IconVolumeOff } from '../../../base/icons/svg';
|
||||
import { setJWT } from '../../../base/jwt/actions';
|
||||
import { browser } from '../../../base/lib-jitsi-meet';
|
||||
import { isVideoMutedByUser } from '../../../base/media/functions';
|
||||
import { getLocalParticipant } from '../../../base/participants/functions';
|
||||
import Popover from '../../../base/popover/components/Popover.web';
|
||||
@@ -20,6 +25,7 @@ import Button from '../../../base/ui/components/web/Button';
|
||||
import Input from '../../../base/ui/components/web/Input';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import isInsecureRoomName from '../../../base/util/isInsecureRoomName';
|
||||
import { parseURIString } from '../../../base/util/uri';
|
||||
import { openDisplayNamePrompt } from '../../../display-name/actions';
|
||||
import { isUnsafeRoomWarningEnabled } from '../../../prejoin/functions';
|
||||
import {
|
||||
@@ -121,6 +127,16 @@ interface IProps {
|
||||
*/
|
||||
showUnsafeRoomWarning: boolean;
|
||||
|
||||
/**
|
||||
* The configuration for token pre-authentication, if applicable.
|
||||
*/
|
||||
tokenPreAuthConfig?: {
|
||||
config: IConfig;
|
||||
locationURL: URL;
|
||||
refreshToken: string | undefined;
|
||||
room: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the user has approved to join a room with unsafe name.
|
||||
*/
|
||||
@@ -226,6 +242,7 @@ const Prejoin = ({
|
||||
showErrorOnJoin,
|
||||
showRecordingWarning,
|
||||
showUnsafeRoomWarning,
|
||||
tokenPreAuthConfig,
|
||||
unsafeRoomConsent,
|
||||
updateSettings: dispatchUpdateSettings,
|
||||
videoTrack
|
||||
@@ -259,7 +276,56 @@ const Prejoin = ({
|
||||
|
||||
logger.info('Prejoin join button clicked.');
|
||||
|
||||
joinConference();
|
||||
// if we have auto redirect enabled, and we have previously logged in successfully
|
||||
// let's redirect to the auth url to get the token and login again
|
||||
if (tokenPreAuthConfig) {
|
||||
const { tenant } = parseURIString(tokenPreAuthConfig.locationURL.href) || {};
|
||||
const { startAudioOnly } = tokenPreAuthConfig.config;
|
||||
const refreshToken = tokenPreAuthConfig.refreshToken;
|
||||
|
||||
getTokenAuthUrl(
|
||||
config,
|
||||
tokenPreAuthConfig.locationURL,
|
||||
{
|
||||
audioMuted: false,
|
||||
audioOnlyEnabled: startAudioOnly,
|
||||
skipPrejoin: false,
|
||||
videoMuted: false
|
||||
},
|
||||
tokenPreAuthConfig.room,
|
||||
tenant,
|
||||
refreshToken
|
||||
)
|
||||
.then((url: string | undefined) => {
|
||||
if (isTokenAuthInline(config)) {
|
||||
if (url) {
|
||||
return loginWithPopup(url)
|
||||
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => {
|
||||
// @ts-ignore
|
||||
const token: string = result.accessToken;
|
||||
const idToken: string = result.idToken;
|
||||
const newRefreshToken: string | undefined = result.refreshToken;
|
||||
|
||||
// @ts-ignore
|
||||
dispatch(setJWT(token, idToken, newRefreshToken || refreshToken));
|
||||
})
|
||||
.then(() => joinConference());
|
||||
}
|
||||
} else {
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
} else {
|
||||
joinConference();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Error in silent login', err);
|
||||
joinConference();
|
||||
});
|
||||
} else {
|
||||
joinConference();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -502,7 +568,12 @@ function mapStateToProps(state: IReduxState) {
|
||||
const { joiningInProgress } = state['features/prejoin'];
|
||||
const { room } = state['features/base/conference'];
|
||||
const { unsafeRoomConsent } = state['features/base/premeeting'];
|
||||
const { showPrejoinWarning: showRecordingWarning } = state['features/base/config'].recordings ?? {};
|
||||
const config = state['features/base/config'];
|
||||
const { showPrejoinWarning: showRecordingWarning } = config.recordings ?? {};
|
||||
const preTokenAuthenticate = !browser.isElectron() && config.tokenAuthUrl
|
||||
&& config.tokenAuthUrlAutoRedirect && state['features/authentication'].tokenAuthUrlSuccessful
|
||||
&& !state['features/base/jwt'].jwt && room;
|
||||
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
|
||||
|
||||
return {
|
||||
deviceStatusVisible: isDeviceStatusVisible(state),
|
||||
@@ -518,6 +589,12 @@ function mapStateToProps(state: IReduxState) {
|
||||
showErrorOnJoin,
|
||||
showRecordingWarning: Boolean(showRecordingWarning),
|
||||
showUnsafeRoomWarning: isInsecureRoomName(room) && isUnsafeRoomWarningEnabled(state),
|
||||
tokenPreAuthConfig: preTokenAuthenticate ? {
|
||||
config,
|
||||
locationURL,
|
||||
refreshToken: state['features/base/jwt'].refreshToken,
|
||||
room
|
||||
} : undefined,
|
||||
unsafeRoomConsent,
|
||||
videoTrack: getLocalJitsiVideoTrack(state)
|
||||
};
|
||||
|
||||
@@ -248,7 +248,7 @@ export function getRecordButtonProps(state: IReduxState) {
|
||||
// a button can be disabled/enabled if enableFeaturesBasedOnToken
|
||||
// is on or if the livestreaming is running.
|
||||
let disabled = false;
|
||||
let tooltip = '';
|
||||
let tooltip = isRecordingRunning(state) ? 'dialog.stopRecording' : 'dialog.startRecording';
|
||||
|
||||
// If the containing component provides the visible prop, that is one
|
||||
// above all, but if not, the button should be autonomus and decide on
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { batch } from 'react-redux';
|
||||
|
||||
import { IStore } from '../app/types';
|
||||
import { setTokenAuthUrlSuccess } from '../authentication/actions.web';
|
||||
import { isTokenAuthEnabled } from '../authentication/functions';
|
||||
import { setTokenAuthUrlSuccess, silentLogout } from '../authentication/actions.web';
|
||||
import { isTokenAuthEnabled, isTokenAuthInline } from '../authentication/functions';
|
||||
import {
|
||||
setStartMutedPolicy,
|
||||
setStartReactionsMuted
|
||||
@@ -11,6 +11,7 @@ import { getConferenceState } from '../base/conference/functions';
|
||||
import { hangup } from '../base/connection/actions.web';
|
||||
import { openDialog } from '../base/dialog/actions';
|
||||
import i18next from '../base/i18n/i18next';
|
||||
import { setJWT } from '../base/jwt/actions';
|
||||
import { browser } from '../base/lib-jitsi-meet';
|
||||
import { getNormalizedDisplayName } from '../base/participants/functions';
|
||||
import { updateSettings } from '../base/settings/actions';
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
getProfileTabProps,
|
||||
getShortcutsTabProps
|
||||
} from './functions.web';
|
||||
import logger from './logger';
|
||||
|
||||
/**
|
||||
* Opens {@code LogoutDialog}.
|
||||
@@ -51,7 +53,24 @@ export function openLogoutDialog() {
|
||||
const logoutUrl = config.tokenLogoutUrl;
|
||||
|
||||
const { conference } = state['features/base/conference'];
|
||||
const { jwt } = state['features/base/jwt'];
|
||||
const { jwt, idToken } = state['features/base/jwt'];
|
||||
|
||||
if (!browser.isElectron() && logoutUrl && isTokenAuthInline(config)) {
|
||||
let url = logoutUrl;
|
||||
|
||||
if (idToken) {
|
||||
url += `${logoutUrl.indexOf('?') === -1 ? '?' : '&'}id_token_hint=${idToken}`;
|
||||
}
|
||||
|
||||
silentLogout(url)
|
||||
.then(() => {
|
||||
dispatch(setJWT());
|
||||
dispatch(setTokenAuthUrlSuccess(false));
|
||||
})
|
||||
.catch(() => logger.error('logout failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(openDialog('LogoutDialog', LogoutDialog, {
|
||||
onLogout() {
|
||||
|
||||
@@ -272,6 +272,13 @@ function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
component: ProfileTab,
|
||||
labelKey: 'profile.title',
|
||||
props: getProfileTabProps(state),
|
||||
propsUpdateFunction: (tabState: any, newProps: ReturnType<typeof getProfileTabProps>) => {
|
||||
return {
|
||||
...newProps,
|
||||
displayName: tabState?.displayName,
|
||||
email: tabState?.email
|
||||
};
|
||||
},
|
||||
submit: submitProfileTab,
|
||||
icon: IconUser
|
||||
});
|
||||
|
||||
@@ -123,7 +123,8 @@ export const MAIN_TOOLBAR_BUTTONS_PRIORITY = [
|
||||
'embedmeeting',
|
||||
'feedback',
|
||||
'download',
|
||||
'help'
|
||||
'help',
|
||||
'custom-panel'
|
||||
];
|
||||
|
||||
export const TOOLBAR_TIMEOUT = 4000;
|
||||
@@ -148,6 +149,7 @@ export const TOOLBAR_BUTTONS: ToolbarButton[] = [
|
||||
'camera',
|
||||
'chat',
|
||||
'closedcaptions',
|
||||
'custom-panel',
|
||||
'desktop',
|
||||
'download',
|
||||
'embedmeeting',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isToggleCameraEnabled } from '../base/tracks/functions.web';
|
||||
import { toggleChat } from '../chat/actions.web';
|
||||
import { isChatDisabled } from '../chat/functions';
|
||||
import { useChatButton } from '../chat/hooks.web';
|
||||
import { useCustomPanelButton } from '../custom-panel/hooks.web';
|
||||
import { useEmbedButton } from '../embed-meeting/hooks';
|
||||
import { useEtherpadButton } from '../etherpad/hooks';
|
||||
import { useFeedbackButton } from '../feedback/hooks.web';
|
||||
@@ -292,6 +293,7 @@ export function useToolboxButtons(
|
||||
const feedback = useFeedbackButton();
|
||||
const _download = useDownloadButton();
|
||||
const _help = useHelpButton();
|
||||
const customPanel = useCustomPanelButton();
|
||||
|
||||
const buttons: { [key in ToolbarButton]?: IToolboxButton; } = {
|
||||
microphone,
|
||||
@@ -326,7 +328,8 @@ export function useToolboxButtons(
|
||||
embedmeeting: embed,
|
||||
feedback,
|
||||
download: _download,
|
||||
help: _help
|
||||
help: _help,
|
||||
'custom-panel': customPanel
|
||||
};
|
||||
const buttonKeys = Object.keys(buttons) as ToolbarButton[];
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface IToolboxNativeButton {
|
||||
export type ToolbarButton = 'camera' |
|
||||
'chat' |
|
||||
'closedcaptions' |
|
||||
'custom-panel' |
|
||||
'desktop' |
|
||||
'download' |
|
||||
'embedmeeting' |
|
||||
|
||||
@@ -89,5 +89,27 @@
|
||||
- speakerStats - A table containing speaker statistics for occupants in the room. The keys are occupant JIDs and the values are objects with properties like dominantSpeakerId, faceLandmarks, and sessionId. Used by mod_speakerstats_component.lua to manage speaker statistics in the room.
|
||||
- visitors_destroy_timer - A timer used to destroy the room when there are no main occupants or visitors left. It is set by mod_fmuc.lua to clean up the room after a certain period of inactivity.
|
||||
|
||||
# session fields added by jitsi
|
||||
- jitsi_meet_context_user - The context from the jwt token, added after token verify.
|
||||
- jitsi_meet_context_group - The group from the jwt context, added after token verify.
|
||||
- jitsi_meet_context_features - The features from the context, added after token verify.
|
||||
- jitsi_meet_context_room - The room settings from the jwt context, added after token verify.
|
||||
- jitsi_meet_room - The room name in jwt token, added after token verify.
|
||||
- jitsi_meet_str_tenant - The tenant in the context. Added after token verify.
|
||||
- jitsi_meet_domain - The domain in the jwt ('sub' claim). Added after token verify. Can be the domain if not tenant is used or the tenant itself in lowercase.
|
||||
- customusername - from a query parameter to be used with combination with "pre-jitsi-authentication" event to pre-set a known jid to a session.
|
||||
- jitsi_web_query_room - room name from the query.
|
||||
- jitsi_web_query_prefix - the tenant from the query specified as a param named 'prefix'.
|
||||
- auth_token - The token, set before verify and cleared if verification fails.
|
||||
- jitsi_meet_tenant_mismatch - The tenant field from the token and the query param for tenant do not match.
|
||||
- previd - Used for stream resumption.
|
||||
- user_region - the region header from the http request received.
|
||||
- user_agent_header - the user agent header from the http request received.
|
||||
- jitsi_throttle - used by rate limit module.
|
||||
- jitsi_throttle_counter - used by rate limit module.
|
||||
- force_permissions_update - Indicate that on next self-presence update the permissions should be resent to the client. Used by mod_jitsi_permissions.lua to manage permissions updates for the session.
|
||||
- granted_jitsi_meet_context_user_id - when affiliation was changed (grant moderation) this holds the id of the actor.
|
||||
- granted_jitsi_meet_context_group_id - when affiliation was changed (grant moderation) this holds the group of the actor.
|
||||
|
||||
#### Notes:
|
||||
When modules need to store data they should do it in the room object in _data or directly. The data needs to be a simple as strings or table of strings, they should not add objects like room, sessions or occupants that cannot be serialized. Attaching data to the room object makes reloading modules safe and guarantees data will be wiped once the room is destroyed.
|
||||
|
||||
@@ -7,6 +7,8 @@ local new_sasl = require "util.sasl".new;
|
||||
local sasl = require "util.sasl";
|
||||
local sessions = prosody.full_sessions;
|
||||
|
||||
module:depends("jitsi_session");
|
||||
|
||||
-- define auth provider
|
||||
local provider = {};
|
||||
|
||||
@@ -38,10 +40,13 @@ function provider.get_sasl_handler(session)
|
||||
-- Custom session matching so we can resume session even with randomly
|
||||
-- generated user IDs.
|
||||
local function get_username(self, message)
|
||||
|
||||
local resuming = false;
|
||||
if (session.previd ~= nil) then
|
||||
for _, session1 in pairs(sessions) do
|
||||
if (session1.resumption_token == session.previd) then
|
||||
self.username = session1.username;
|
||||
resuming = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
@@ -49,6 +54,10 @@ function provider.get_sasl_handler(session)
|
||||
self.username = message;
|
||||
end
|
||||
|
||||
if not resuming then
|
||||
session.auth_token = nil;
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
|
||||
@@ -26,41 +26,6 @@ local provider = {};
|
||||
|
||||
local host = module.host;
|
||||
|
||||
-- Extract 'token' param from URL when session is created
|
||||
function init_session(event)
|
||||
local session, request = event.session, event.request;
|
||||
local query = request.url.query;
|
||||
|
||||
local token = nil;
|
||||
|
||||
-- extract token from Authorization header
|
||||
if request.headers["authorization"] then
|
||||
-- assumes the header value starts with "Bearer "
|
||||
token = request.headers["authorization"]:sub(8,#request.headers["authorization"])
|
||||
end
|
||||
|
||||
-- allow override of token via query parameter
|
||||
if query ~= nil then
|
||||
local params = formdecode(query);
|
||||
|
||||
-- The following fields are filled in the session, by extracting them
|
||||
-- from the query and no validation is being done.
|
||||
-- After validating auth_token will be cleaned in case of error and few
|
||||
-- other fields will be extracted from the token and set in the session
|
||||
|
||||
if params and params.token then
|
||||
token = params.token;
|
||||
end
|
||||
end
|
||||
|
||||
-- in either case set auth_token in the session
|
||||
session.auth_token = token;
|
||||
session.user_agent_header = request.headers['user_agent'];
|
||||
end
|
||||
|
||||
module:hook_global("bosh-session", init_session);
|
||||
module:hook_global("websocket-session", init_session);
|
||||
|
||||
module:hook("pre-resource-unbind", function (e)
|
||||
local error, session = e.error, e.session;
|
||||
|
||||
@@ -95,41 +60,60 @@ function provider.delete_user(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function first_stage_auth(session)
|
||||
-- retrieve custom public key from server and save it on the session
|
||||
local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session);
|
||||
if pre_event_result ~= nil and pre_event_result.res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason);
|
||||
session.auth_token = nil;
|
||||
measure_pre_fetch_fail(1);
|
||||
return pre_event_result;
|
||||
end
|
||||
|
||||
local res, error, reason = token_util:process_and_verify_token(session);
|
||||
if res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s",
|
||||
error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room,
|
||||
session.user_agent_header);
|
||||
session.auth_token = nil;
|
||||
measure_verify_fail(1);
|
||||
return { res = res, error = error, reason = reason };
|
||||
end
|
||||
|
||||
local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session);
|
||||
if shouldAllow == false then
|
||||
module:log("warn", "user is banned")
|
||||
measure_ban(1);
|
||||
return { res = false, error = "not-allowed", reason = "user is banned" };
|
||||
end
|
||||
|
||||
return { verify_result = res, custom_username = prosody.events.fire_event("pre-jitsi-authentication", session) };
|
||||
end
|
||||
|
||||
function second_stage_auth(session)
|
||||
local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session);
|
||||
if post_event_result ~= nil and post_event_result.res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason);
|
||||
session.auth_token = nil;
|
||||
measure_post_auth_fail(1);
|
||||
return post_event_result;
|
||||
end
|
||||
end
|
||||
|
||||
function provider.get_sasl_handler(session)
|
||||
|
||||
local function get_username_from_token(self, message)
|
||||
|
||||
-- retrieve custom public key from server and save it on the session
|
||||
local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session);
|
||||
if pre_event_result ~= nil and pre_event_result.res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason);
|
||||
session.auth_token = nil;
|
||||
measure_pre_fetch_fail(1);
|
||||
return pre_event_result.res, pre_event_result.error, pre_event_result.reason;
|
||||
local s1_result = first_stage_auth(session);
|
||||
if s1_result.res == false then
|
||||
return s1_result.res, s1_result.error, s1_result.reason;
|
||||
end
|
||||
|
||||
local res, error, reason = token_util:process_and_verify_token(session);
|
||||
if res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s",
|
||||
error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room,
|
||||
session.user_agent_header);
|
||||
session.auth_token = nil;
|
||||
measure_verify_fail(1);
|
||||
return res, error, reason;
|
||||
end
|
||||
|
||||
local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session);
|
||||
if shouldAllow == false then
|
||||
module:log("warn", "user is banned")
|
||||
measure_ban(1);
|
||||
return false, "not-allowed", "user is banned";
|
||||
end
|
||||
|
||||
local customUsername = prosody.events.fire_event("pre-jitsi-authentication", session);
|
||||
if customUsername then
|
||||
self.username = customUsername;
|
||||
if s1_result.custom_username then
|
||||
self.username = s1_result.custom_username;
|
||||
elseif session.previd ~= nil then
|
||||
for _, session1 in pairs(sessions) do
|
||||
if (session1.resumption_token == session.previd) then
|
||||
@@ -141,17 +125,14 @@ function provider.get_sasl_handler(session)
|
||||
self.username = message;
|
||||
end
|
||||
|
||||
local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session);
|
||||
if post_event_result ~= nil and post_event_result.res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason);
|
||||
session.auth_token = nil;
|
||||
measure_post_auth_fail(1);
|
||||
return post_event_result.res, post_event_result.error, post_event_result.reason;
|
||||
local s2_result = second_stage_auth(session);
|
||||
if s2_result and s2_result.res ~= nil then
|
||||
return s2_result.res, s2_result.error, s2_result.reason;
|
||||
end
|
||||
|
||||
measure_success(1);
|
||||
return res;
|
||||
session._jitsi_auth_done = true;
|
||||
return s1_result.verify_result;
|
||||
end
|
||||
|
||||
return new_sasl(host, { anonymous = get_username_from_token });
|
||||
@@ -177,3 +158,47 @@ local function anonymous(self, message)
|
||||
end
|
||||
|
||||
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);
|
||||
|
||||
module:hook_global('c2s-session-updated', function (event)
|
||||
local session, from_session = event.session, event.from_session;
|
||||
|
||||
if not from_session.auth_token then
|
||||
return;
|
||||
end
|
||||
|
||||
-- we care to handle sessions from other hosts (anonymous hosts)
|
||||
if module.host ~= event.from_session.host then
|
||||
-- Handle session updates (e.g., when a session is resumed on some anonymous host with a token we need to do all the checks here)
|
||||
session.auth_token = event.from_session.auth_token;
|
||||
|
||||
local s1_result = first_stage_auth(session);
|
||||
if s1_result.res == false then
|
||||
event.session:close();
|
||||
return;
|
||||
end
|
||||
|
||||
local s2_result = second_stage_auth(session);
|
||||
if s2_result and s2_result.res == false then
|
||||
event.session:close();
|
||||
return;
|
||||
end
|
||||
session._jitsi_auth_done = true;
|
||||
end
|
||||
|
||||
if not session._jitsi_auth_done then
|
||||
module:log('warn', 'Impossible case hit where session did not pass auth flow');
|
||||
event.session:close();
|
||||
return;
|
||||
end
|
||||
|
||||
-- copy all the custom fields we set in the session
|
||||
session.auth_token = from_session.auth_token;
|
||||
session.jitsi_meet_context_user = from_session.jitsi_meet_context_user;
|
||||
session.jitsi_meet_context_group = from_session.jitsi_meet_context_group;
|
||||
session.jitsi_meet_context_features = from_session.jitsi_meet_context_features;
|
||||
session.jitsi_meet_context_room = from_session.jitsi_meet_context_room;
|
||||
session.jitsi_meet_room = from_session.jitsi_meet_room;
|
||||
session.jitsi_meet_str_tenant = from_session.jitsi_meet_str_tenant;
|
||||
session.jitsi_meet_domain = from_session.jitsi_meet_domain;
|
||||
session.jitsi_meet_tenant_mismatch = from_session.jitsi_meet_tenant_mismatch;
|
||||
end, 1);
|
||||
|
||||
@@ -11,6 +11,14 @@ function init_session(event)
|
||||
local session, request = event.session, event.request;
|
||||
local query = request.url.query;
|
||||
|
||||
local token = nil;
|
||||
|
||||
-- extract token from Authorization header
|
||||
if request.headers["authorization"] then
|
||||
-- assumes the header value starts with "Bearer "
|
||||
token = request.headers["authorization"]:sub(8,#request.headers["authorization"])
|
||||
end
|
||||
|
||||
if query ~= nil then
|
||||
local params = formdecode(query);
|
||||
|
||||
@@ -24,9 +32,23 @@ function init_session(event)
|
||||
-- The room name and optional prefix from the web query
|
||||
session.jitsi_web_query_room = params.room;
|
||||
session.jitsi_web_query_prefix = params.prefix or "";
|
||||
|
||||
-- The following fields are filled in the session, by extracting them
|
||||
-- from the query and no validation is being done.
|
||||
-- After validating auth_token will be cleaned in case of error and few
|
||||
-- other fields will be extracted from the token and set in the session
|
||||
|
||||
if params and params.token then
|
||||
token = params.token;
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
session.user_region = request.headers[region_header_name];
|
||||
|
||||
-- in either case set auth_token in the session
|
||||
session.auth_token = token;
|
||||
session.user_agent_header = request.headers['user_agent'];
|
||||
end
|
||||
|
||||
module:hook_global("bosh-session", init_session, 1);
|
||||
|
||||
@@ -40,13 +40,18 @@ module:hook('muc-occupant-left', function (event)
|
||||
end
|
||||
|
||||
-- seems the room only has jibri and transcriber, add a timeout to destroy the room
|
||||
if room.empty_destroy_timer then
|
||||
room.empty_destroy_timer:stop();
|
||||
end
|
||||
room.empty_destroy_timer = module:add_timer(EMPTY_TIMEOUT, function()
|
||||
if room.destroying then return end
|
||||
room:destroy(nil, 'Empty room with recording and/or transcribing.');
|
||||
|
||||
module:log('info',
|
||||
'the conference terminated %s as being empty for %s seconds with recording/transcribing enabled',
|
||||
room.jid, EMPTY_TIMEOUT);
|
||||
'the conference terminated %s as being empty for %s seconds with recording/transcribing enabled. By %s',
|
||||
room.jid, EMPTY_TIMEOUT, room.empty_destroy_timer);
|
||||
end)
|
||||
module:log('info', 'Added room destroy timer %s for %s', room.empty_destroy_timer, room.jid);
|
||||
end, -100); -- the last thing to execute
|
||||
|
||||
module:hook('muc-room-destroyed', function (event)
|
||||
|
||||
@@ -16,6 +16,7 @@ local is_transcriber = util.is_transcriber;
|
||||
local QUEUE_MAX_SIZE = 500;
|
||||
|
||||
module:depends("jitsi_permissions");
|
||||
module:depends("jitsi_session");
|
||||
|
||||
-- Common module for all logic that can be loaded under the conference muc component.
|
||||
--
|
||||
@@ -212,39 +213,39 @@ local function filterTranscriptionResult(event)
|
||||
-- Do not fire the event, but forward the message
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if msg_obj.transcript ~= nil then
|
||||
local transcription = msg_obj;
|
||||
if msg_obj.transcript ~= nil then
|
||||
local transcription = msg_obj;
|
||||
|
||||
-- in case of the string matching optimization above failed
|
||||
if transcription.is_interim then
|
||||
return;
|
||||
-- in case of the string matching optimization above failed
|
||||
if transcription.is_interim then
|
||||
return;
|
||||
end
|
||||
|
||||
-- TODO what if we have multiple alternative transcriptions not just 1
|
||||
local text_message = transcription.transcript[1].text;
|
||||
--do not send empty messages
|
||||
if text_message == '' then
|
||||
return;
|
||||
end
|
||||
|
||||
local user_id = transcription.participant.id;
|
||||
local who = room:get_occupant_by_nick(jid.bare(room.jid)..'/'..user_id);
|
||||
|
||||
transcription.jid = who and who.jid;
|
||||
transcription.session_id = room._data.meetingId;
|
||||
|
||||
local tenant, conference_name, id = extract_subdomain(jid.node(room.jid));
|
||||
if tenant then
|
||||
transcription.fqn = tenant..'/'..conference_name;
|
||||
else
|
||||
transcription.fqn = conference_name;
|
||||
end
|
||||
transcription.customer_id = id;
|
||||
|
||||
return module:fire_event('jitsi-transcript-received', {
|
||||
room = room, occupant = occupant, transcription = transcription, stanza = stanza });
|
||||
end
|
||||
|
||||
-- TODO what if we have multiple alternative transcriptions not just 1
|
||||
local text_message = transcription.transcript[1].text;
|
||||
--do not send empty messages
|
||||
if text_message == '' then
|
||||
return;
|
||||
end
|
||||
|
||||
local user_id = transcription.participant.id;
|
||||
local who = room:get_occupant_by_nick(jid.bare(room.jid)..'/'..user_id);
|
||||
|
||||
transcription.jid = who and who.jid;
|
||||
transcription.session_id = room._data.meetingId;
|
||||
|
||||
local tenant, conference_name, id = extract_subdomain(jid.node(room.jid));
|
||||
if tenant then
|
||||
transcription.fqn = tenant..'/'..conference_name;
|
||||
else
|
||||
transcription.fqn = conference_name;
|
||||
end
|
||||
transcription.customer_id = id;
|
||||
|
||||
return module:fire_event('jitsi-transcript-received', {
|
||||
room = room, occupant = occupant, transcription = transcription, stanza = stanza });
|
||||
end
|
||||
|
||||
return module:fire_event('jitsi-endpoint-message-received', {
|
||||
|
||||
@@ -57,7 +57,10 @@ module:hook('muc-occupant-pre-join', function (event)
|
||||
|
||||
local has_host = false;
|
||||
for _, o in room:each_occupant() do
|
||||
if jid.host(o.bare_jid) == muc_domain_base then
|
||||
-- the main virtual host that requires tokens
|
||||
if jid.host(o.bare_jid) == muc_domain_base
|
||||
-- or this is anonymous that upgraded by passing token which we validated
|
||||
or prosody.full_sessions[o.jid].auth_token then
|
||||
room.has_host = true;
|
||||
end
|
||||
end
|
||||
|
||||
43
static/logout.html
Normal file
43
static/logout.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Logged Out</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.success {
|
||||
color: #27ae60;
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✓</div>
|
||||
<p>You have been logged out successfully.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Notify parent window that logout is complete
|
||||
if (window.parent) {
|
||||
window.parent.postMessage({
|
||||
type: 'logout-success'
|
||||
}, window.location.origin);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
373
static/sso.html
Normal file
373
static/sso.html
Normal file
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SSO Authentication</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.spinner {
|
||||
display: none;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.error {
|
||||
display: none;
|
||||
color: #e74c3c;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.success {
|
||||
display: none;
|
||||
color: #27ae60;
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="spinner" class="spinner"></div>
|
||||
<p id="spinner-msg" style="display: none;">Completing authentication...</p>
|
||||
<div id="success" class="success">✓</div>
|
||||
<p id="message-for-opener" style="display: none;">Authentication successful! You can close this window.</p>
|
||||
<div id="error" class="error"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<!--#include virtual="/config.js" -->
|
||||
(function() {
|
||||
/**
|
||||
* Decodes a JWT token and returns the payload
|
||||
* @param {string} token - The JWT token to decode
|
||||
* @returns {object} The decoded payload
|
||||
*/
|
||||
function decodeJWT(token) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (error) {
|
||||
throw new Error('Failed to decode JWT: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the nonce in the ID token matches the stored nonce
|
||||
* @param {string} idToken - The ID token to validate
|
||||
* @param {string} expectedNonce - The nonce stored in sessionStorage
|
||||
*/
|
||||
function validateNonce(idToken, expectedNonce) {
|
||||
const payload = decodeJWT(idToken);
|
||||
if (!payload.nonce) {
|
||||
throw new Error('ID token does not contain a nonce claim');
|
||||
}
|
||||
if (payload.nonce !== expectedNonce) {
|
||||
throw new Error('Nonce validation failed: ID token nonce does not match stored nonce');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthorizationCode() {
|
||||
try {
|
||||
// Parse query parameters (Authorization Code Flow uses query params, not hash)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
console.log('sso received:', window.location.search);
|
||||
// Get state parameter first to check for refresh token
|
||||
const stateParam = urlParams.get('state');
|
||||
if (!stateParam) {
|
||||
throw new Error('No state parameter received');
|
||||
}
|
||||
|
||||
const state = JSON.parse(decodeURIComponent(stateParam));
|
||||
|
||||
// Get SSO configuration from config
|
||||
const { sso } = config;
|
||||
if (!sso || !sso.tokenService || !sso.clientId) {
|
||||
throw new Error('Missing SSO configuration (tokenService or clientId)');
|
||||
}
|
||||
|
||||
// Check if we have a refresh token in state - use it to refresh tokens
|
||||
if (state.refreshToken) {
|
||||
console.log('Using refresh token to get new tokens');
|
||||
|
||||
// Show spinner while refreshing tokens
|
||||
document.getElementById('spinner').style.display = 'block';
|
||||
document.getElementById('spinner-msg').style.display = 'block';
|
||||
|
||||
// Use refresh token to get new tokens
|
||||
const tokenResponse = await fetch(`https://${sso.tokenService}/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: state.refreshToken,
|
||||
client_id: sso.clientId
|
||||
})
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json().catch(() => ({}));
|
||||
throw new Error(errorData.error_description || errorData.error || 'Token refresh failed');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
const accessToken = tokens.access_token;
|
||||
const idToken = tokens.id_token;
|
||||
const refreshToken = tokens.refresh_token || state.refreshToken; // Use new refresh token if provided, otherwise keep old one
|
||||
|
||||
if (!accessToken || !idToken) {
|
||||
throw new Error('Missing tokens in refresh response');
|
||||
}
|
||||
|
||||
// Validate nonce if available in sessionStorage (optional for refresh flows)
|
||||
const refreshNonce = sessionStorage.getItem('oauth_nonce');
|
||||
if (refreshNonce) {
|
||||
try {
|
||||
validateNonce(idToken, refreshNonce);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
} catch (nonceError) {
|
||||
console.warn('Nonce validation failed during refresh:', nonceError.message);
|
||||
// Don't fail the refresh flow if nonce validation fails
|
||||
}
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'oauth-success',
|
||||
accessToken: accessToken,
|
||||
idToken: idToken,
|
||||
refreshToken: refreshToken
|
||||
};
|
||||
|
||||
// Send message to parent window
|
||||
if (window.opener) {
|
||||
document.getElementById('spinner').style.display = 'none';
|
||||
document.getElementById('spinner-msg').style.display = 'none';
|
||||
document.getElementById('success').style.display = 'block';
|
||||
document.getElementById('message-for-opener').style.display = 'block';
|
||||
|
||||
window.opener.postMessage(message, window.location.origin);
|
||||
setTimeout(() => window.close(), 1000);
|
||||
} else if (window.parent && window.parent !== window) {
|
||||
console.log('Sending refreshed tokens to parent (iframe)');
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
} else {
|
||||
// Standalone page - redirect to room
|
||||
const tenant = state.tenant || '';
|
||||
const roomSafe = state.roomSafe || state.room || '';
|
||||
|
||||
if (!roomSafe) {
|
||||
throw new Error('No room specified in state');
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
const path = tenant ? `/${tenant}/${roomSafe}` : `/${roomSafe}`;
|
||||
const hashParams = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(state)) {
|
||||
if (key.startsWith('config.')) {
|
||||
hashParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
hashParams.append('jwt', `"${accessToken}"`);
|
||||
const redirectUrl = `${protocol}//${host}${path}#${hashParams.toString()}`;
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
|
||||
return; // Exit after handling refresh token
|
||||
}
|
||||
|
||||
// No refresh token in state - proceed with authorization code flow
|
||||
// Check for OAuth errors
|
||||
const error = urlParams.get('error');
|
||||
if (error) {
|
||||
const errorDescription = urlParams.get('error_description');
|
||||
console.error('OAuth error:', error, errorDescription);
|
||||
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({
|
||||
type: 'oauth-error',
|
||||
error: errorDescription || error
|
||||
}, window.location.origin);
|
||||
} else if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'oauth-error',
|
||||
error: errorDescription || error
|
||||
}, window.location.origin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get authorization code
|
||||
const code = urlParams.get('code');
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received');
|
||||
}
|
||||
|
||||
// Retrieve code_verifier and oauth_nonce from sessionStorage (set during login initiation)
|
||||
const codeVerifier = sessionStorage.getItem('code_verifier');
|
||||
if (!codeVerifier) {
|
||||
throw new Error('No PKCE code verifier found in session');
|
||||
}
|
||||
|
||||
const oauthNonce = sessionStorage.getItem('oauth_nonce');
|
||||
if (!oauthNonce) {
|
||||
throw new Error('No OAuth nonce found in session');
|
||||
}
|
||||
|
||||
// Show spinner while exchanging code for tokens
|
||||
document.getElementById('spinner').style.display = 'block';
|
||||
document.getElementById('spinner-msg').style.display = 'block';
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const tokenResponse = await fetch(`https://${sso.tokenService}/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: window.location.origin + window.location.pathname,
|
||||
client_id: sso.clientId,
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.json().catch(() => ({}));
|
||||
throw new Error(errorData.error_description || errorData.error || 'Token exchange failed');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
const accessToken = tokens.access_token;
|
||||
const idToken = tokens.id_token;
|
||||
const refreshToken = tokens.refresh_token;
|
||||
|
||||
if (!accessToken || !idToken) {
|
||||
throw new Error('Missing tokens in response');
|
||||
}
|
||||
|
||||
// Validate nonce in ID token
|
||||
validateNonce(idToken, oauthNonce);
|
||||
|
||||
// Clean up PKCE verifier and OAuth nonce
|
||||
sessionStorage.removeItem('code_verifier');
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
|
||||
const message = {
|
||||
type: 'oauth-success',
|
||||
accessToken: accessToken,
|
||||
idToken: idToken,
|
||||
refreshToken: refreshToken
|
||||
};
|
||||
|
||||
// Send message to parent window
|
||||
if (window.opener) {
|
||||
// Opened via window.open() - popup
|
||||
document.getElementById('spinner').style.display = 'none';
|
||||
document.getElementById('spinner-msg').style.display = 'none';
|
||||
document.getElementById('success').style.display = 'block';
|
||||
document.getElementById('message-for-opener').style.display = 'block';
|
||||
|
||||
window.opener.postMessage(message, window.location.origin);
|
||||
|
||||
// Auto-close after a short delay
|
||||
setTimeout(() => window.close(), 1000);
|
||||
} else if (window.parent && window.parent !== window) {
|
||||
// Loaded in iframe
|
||||
console.log('Sending message to parent (iframe)');
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
} else {
|
||||
// Standalone page - redirect to room
|
||||
// Extract required values
|
||||
const tenant = state.tenant || '';
|
||||
const roomSafe = state.roomSafe || state.room || '';
|
||||
|
||||
if (!roomSafe) {
|
||||
throw new Error('No room specified in state');
|
||||
}
|
||||
|
||||
// Build the new URL
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
|
||||
// Construct path: /tenant/roomSafe
|
||||
const path = tenant ? `/${tenant}/${roomSafe}` : `/${roomSafe}`;
|
||||
|
||||
// Build hash parameters - only config.* values and jwt
|
||||
const hashParams = new URLSearchParams();
|
||||
|
||||
// Add only config.* parameters from state
|
||||
for (const [key, value] of Object.entries(state)) {
|
||||
if (key.startsWith('config.')) {
|
||||
hashParams.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Add access_token as jwt with quotes
|
||||
hashParams.append('jwt', `"${accessToken}"`);
|
||||
|
||||
// Build final URL
|
||||
const redirectUrl = `${protocol}//${host}${path}#${hashParams.toString()}`;
|
||||
|
||||
console.log('Redirecting to:', redirectUrl);
|
||||
|
||||
// Redirect
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('SSO Error:', error);
|
||||
const errorMessage = {
|
||||
type: 'oauth-error',
|
||||
error: error.message
|
||||
};
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(errorMessage, window.location.origin);
|
||||
} else if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(errorMessage, window.location.origin);
|
||||
} else {
|
||||
document.getElementById('error').textContent = `Error: ${error.message}`;
|
||||
document.getElementById('error').style.display = 'block';
|
||||
document.getElementById('spinner').style.display = 'none';
|
||||
document.getElementById('spinner-msg').style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start authentication flow
|
||||
handleAuthorizationCode();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -674,6 +674,10 @@ export class Participant {
|
||||
return new IframeAPI(this);
|
||||
}
|
||||
|
||||
async getRoomMetadata() {
|
||||
return this.execute(() => window.APP?.conference?._room?.getMetadataHandler()?.getMetadata());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hangups the participant by leaving the page. base.html is an empty page on all deployments.
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,8 @@ export type ITestProperties = {
|
||||
description?: string;
|
||||
/** The test requires the webhook proxy to be available. */
|
||||
requireWebhookProxy: boolean;
|
||||
/** Whether the test should be retried. */
|
||||
retry: boolean;
|
||||
/** The test requires jaas, it should be skipped when the jaas configuration is not enabled. */
|
||||
useJaas: boolean;
|
||||
/** The test uses the webhook proxy if available. */
|
||||
@@ -18,6 +20,7 @@ export type ITestProperties = {
|
||||
const defaultProperties: ITestProperties = {
|
||||
useWebhookProxy: false,
|
||||
requireWebhookProxy: false,
|
||||
retry: false,
|
||||
useJaas: false,
|
||||
usesBrowsers: [ 'p1' ]
|
||||
};
|
||||
|
||||
@@ -176,6 +176,7 @@ export default class WebhookProxy {
|
||||
* @param value
|
||||
*/
|
||||
set defaultMeetingSettings(value: {
|
||||
asyncTranscriptions?: boolean;
|
||||
autoAudioRecording?: boolean;
|
||||
autoTranscriptions?: boolean;
|
||||
autoVideoRecording?: boolean;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
|
||||
|
||||
setTestProperties(__filename, {
|
||||
requireWebhookProxy: true,
|
||||
retry: true,
|
||||
useJaas: true
|
||||
});
|
||||
|
||||
|
||||
@@ -8,182 +8,201 @@ import { joinJaasMuc, generateJaasToken as t } from '../../helpers/jaas';
|
||||
|
||||
setTestProperties(__filename, {
|
||||
requireWebhookProxy: true,
|
||||
retry: true,
|
||||
useJaas: true,
|
||||
usesBrowsers: [ 'p1', 'p2' ]
|
||||
});
|
||||
|
||||
describe('Transcription', () => {
|
||||
let p1: Participant, p2: Participant;
|
||||
let webhooksProxy: WebhookProxy;
|
||||
for (const asyncTranscriptions of [ false, true ]) {
|
||||
describe(`Transcription (async=${asyncTranscriptions})`, () => {
|
||||
let p1: Participant, p2: Participant;
|
||||
let webhooksProxy: WebhookProxy;
|
||||
|
||||
it('setup', async () => {
|
||||
const room = ctx.roomName;
|
||||
it('setup', async () => {
|
||||
const room = ctx.roomName;
|
||||
|
||||
webhooksProxy = ctx.webhooksProxy;
|
||||
webhooksProxy = ctx.webhooksProxy;
|
||||
webhooksProxy.defaultMeetingSettings = { asyncTranscriptions };
|
||||
|
||||
p1 = await joinJaasMuc({
|
||||
name: 'p1',
|
||||
token: t({ room, moderator: true }),
|
||||
iFrameApi: true });
|
||||
p1 = await joinJaasMuc({
|
||||
name: 'p1',
|
||||
token: t({ room, moderator: true }),
|
||||
iFrameApi: true
|
||||
});
|
||||
|
||||
const transcriptionEnabled = await p1.execute(() => config.transcription?.enabled);
|
||||
const transcriptionEnabled = await p1.execute(() => config.transcription?.enabled);
|
||||
|
||||
expect(transcriptionEnabled).toBe(expectations.jaas.transcriptionEnabled);
|
||||
expect(transcriptionEnabled).toBe(expectations.jaas.transcriptionEnabled);
|
||||
|
||||
p2 = await joinJaasMuc({
|
||||
name: 'p2',
|
||||
token: t({ room }),
|
||||
iFrameApi: true }, {
|
||||
configOverwrite: {
|
||||
startWithAudioMuted: true
|
||||
const roomMetadata = await p1.getRoomMetadata();
|
||||
|
||||
if (asyncTranscriptions) {
|
||||
expect(roomMetadata.asyncTranscription).toBe(true);
|
||||
} else {
|
||||
expect(roomMetadata.asyncTranscription).toBeFalsy();
|
||||
}
|
||||
|
||||
p2 = await joinJaasMuc({
|
||||
name: 'p2',
|
||||
token: t({ room }),
|
||||
iFrameApi: true
|
||||
}, {
|
||||
configOverwrite: {
|
||||
startWithAudioMuted: true
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
p1.switchToMainFrame(),
|
||||
p2.switchToMainFrame(),
|
||||
]);
|
||||
|
||||
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
|
||||
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
p1.switchToMainFrame(),
|
||||
p2.switchToMainFrame(),
|
||||
]);
|
||||
it('toggle subtitles', async () => {
|
||||
await p1.getIframeAPI().addEventListener('transcriptionChunkReceived');
|
||||
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
|
||||
await p1.getIframeAPI().executeCommand('toggleSubtitles');
|
||||
|
||||
expect(await p1.getIframeAPI().getEventResult('isModerator')).toBe(true);
|
||||
expect(await p1.getIframeAPI().getEventResult('videoConferenceJoined')).toBeDefined();
|
||||
});
|
||||
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
|
||||
|
||||
it('toggle subtitles', async () => {
|
||||
await p1.getIframeAPI().addEventListener('transcriptionChunkReceived');
|
||||
await p2.getIframeAPI().addEventListener('transcriptionChunkReceived');
|
||||
await p1.getIframeAPI().executeCommand('toggleSubtitles');
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
|
||||
|
||||
await checkReceivingChunks(p1, p2, webhooksProxy);
|
||||
await p1.getIframeAPI().executeCommand('toggleSubtitles');
|
||||
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
await p1.getIframeAPI().addEventListener('transcribingStatusChanged');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('toggleSubtitles');
|
||||
|
||||
await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received by p1'
|
||||
});
|
||||
});
|
||||
|
||||
it('set subtitles on and off', async () => {
|
||||
// we need to clear results or the last one will be used, from the previous time subtitles were on
|
||||
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
|
||||
|
||||
await checkReceivingChunks(p1, p2, webhooksProxy);
|
||||
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('setSubtitles', false);
|
||||
|
||||
await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received by p1'
|
||||
});
|
||||
});
|
||||
|
||||
it('start/stop transcriptions via recording', async () => {
|
||||
// we need to clear results or the last one will be used, from the previous time subtitles were on
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
|
||||
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('startRecording', { transcription: true });
|
||||
|
||||
let allTranscriptionStatusChanged: Promise<any>[] = [];
|
||||
|
||||
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p1'
|
||||
}));
|
||||
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received by p1'
|
||||
});
|
||||
});
|
||||
|
||||
it('set subtitles on and off', async () => {
|
||||
// we need to clear results or the last one will be used, from the previous time subtitles were on
|
||||
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('setSubtitles', true, true);
|
||||
|
||||
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
|
||||
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('setSubtitles', false);
|
||||
|
||||
await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p2'
|
||||
}));
|
||||
|
||||
let result = await Promise.allSettled(allTranscriptionStatusChanged);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
result.forEach(e => {
|
||||
// @ts-ignore
|
||||
expect(e.value.on).toBe(true);
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received by p1'
|
||||
});
|
||||
});
|
||||
|
||||
await checkReceivingChunks(p1, p2, webhooksProxy);
|
||||
it('start/stop transcriptions via recording', async () => {
|
||||
// we need to clear results or the last one will be used, from the previous time subtitles were on
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
await p1.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
await p2.getIframeAPI().clearEventResults('transcriptionChunkReceived');
|
||||
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
await p2.getIframeAPI().addEventListener('transcribingStatusChanged');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('stopRecording', 'file', true);
|
||||
await p1.getIframeAPI().executeCommand('startRecording', { transcription: true });
|
||||
|
||||
allTranscriptionStatusChanged = [];
|
||||
let allTranscriptionStatusChanged: Promise<any>[] = [];
|
||||
|
||||
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p1'
|
||||
}));
|
||||
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p2'
|
||||
}));
|
||||
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p1'
|
||||
}));
|
||||
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p2'
|
||||
}));
|
||||
|
||||
result = await Promise.allSettled(allTranscriptionStatusChanged);
|
||||
let result = await Promise.allSettled(allTranscriptionStatusChanged);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
result.forEach(e => {
|
||||
// @ts-ignore
|
||||
expect(e.value.on).toBe(false);
|
||||
result.forEach(e => {
|
||||
// @ts-ignore
|
||||
expect(e.value.on).toBe(true);
|
||||
});
|
||||
|
||||
await checkReceivingChunks(p1, p2, webhooksProxy, !asyncTranscriptions);
|
||||
|
||||
await p1.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
await p2.getIframeAPI().clearEventResults('transcribingStatusChanged');
|
||||
|
||||
await p1.getIframeAPI().executeCommand('stopRecording', 'file', true);
|
||||
|
||||
allTranscriptionStatusChanged = [];
|
||||
|
||||
allTranscriptionStatusChanged.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p1'
|
||||
}));
|
||||
allTranscriptionStatusChanged.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
|
||||
.getEventResult('transcribingStatusChanged'), {
|
||||
timeout: 10000,
|
||||
timeoutMsg: 'transcribingStatusChanged event not received on p2'
|
||||
}));
|
||||
|
||||
result = await Promise.allSettled(allTranscriptionStatusChanged);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
|
||||
result.forEach(e => {
|
||||
// @ts-ignore
|
||||
expect(e.value.on).toBe(false);
|
||||
});
|
||||
|
||||
await p1.getIframeAPI().executeCommand('hangup');
|
||||
await p2.getIframeAPI().executeCommand('hangup');
|
||||
|
||||
// sometimes events are not immediately received,
|
||||
// let's wait for destroy event before waiting for those that depends on it
|
||||
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
|
||||
|
||||
const event: {
|
||||
data: {
|
||||
preAuthenticatedLink: string;
|
||||
};
|
||||
eventType: string;
|
||||
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
|
||||
|
||||
expect(event.eventType).toBe('TRANSCRIPTION_UPLOADED');
|
||||
expect(event.data.preAuthenticatedLink).toBeDefined();
|
||||
});
|
||||
|
||||
await p1.getIframeAPI().executeCommand('hangup');
|
||||
await p2.getIframeAPI().executeCommand('hangup');
|
||||
|
||||
// sometimes events are not immediately received,
|
||||
// let's wait for destroy event before waiting for those that depends on it
|
||||
await webhooksProxy.waitForEvent('ROOM_DESTROYED');
|
||||
|
||||
const event: {
|
||||
data: {
|
||||
preAuthenticatedLink: string;
|
||||
};
|
||||
eventType: string;
|
||||
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_UPLOADED');
|
||||
|
||||
expect('TRANSCRIPTION_UPLOADED').toBe(event.eventType);
|
||||
expect(event.data.preAuthenticatedLink).toBeDefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy) {
|
||||
const allTranscripts: Promise<any>[] = [];
|
||||
|
||||
allTranscripts.push(await p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
/**
|
||||
*
|
||||
* @param p1
|
||||
* @param p2
|
||||
* @param webhooksProxy
|
||||
* @param expectName Whether to expect the events to contain the name of the participant. Currently, async
|
||||
* transcriptions do not include the name. TODO: remove this parameter when async transcription events are fixed.
|
||||
*/
|
||||
async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksProxy: WebhookProxy, expectName = true) {
|
||||
const p1Promise = p1.driver.waitUntil(() => p1.getIframeAPI()
|
||||
.getEventResult('transcriptionChunkReceived'), {
|
||||
timeout: 60000,
|
||||
timeoutMsg: 'transcriptionChunkReceived event not received on p1 side'
|
||||
}));
|
||||
});
|
||||
|
||||
allTranscripts.push(await p2.driver.waitUntil(() => p2.getIframeAPI()
|
||||
const p2Promise = p2.driver.waitUntil(() => p2.getIframeAPI()
|
||||
.getEventResult('transcriptionChunkReceived'), {
|
||||
timeout: 60000,
|
||||
timeoutMsg: 'transcriptionChunkReceived event not received on p2 side'
|
||||
}));
|
||||
});
|
||||
|
||||
// TRANSCRIPTION_CHUNK_RECEIVED webhook
|
||||
allTranscripts.push((async () => {
|
||||
const webhookPromise = async () => {
|
||||
const event: {
|
||||
data: {
|
||||
final: string;
|
||||
@@ -198,42 +217,36 @@ async function checkReceivingChunks(p1: Participant, p2: Participant, webhooksPr
|
||||
eventType: string;
|
||||
} = await webhooksProxy.waitForEvent('TRANSCRIPTION_CHUNK_RECEIVED');
|
||||
|
||||
expect('TRANSCRIPTION_CHUNK_RECEIVED').toBe(event.eventType);
|
||||
expect(event.eventType).toBe('TRANSCRIPTION_CHUNK_RECEIVED');
|
||||
|
||||
event.data.stable = event.data.final;
|
||||
|
||||
return event;
|
||||
})());
|
||||
};
|
||||
|
||||
const result = await Promise.allSettled(allTranscripts);
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// @ts-ignore
|
||||
const firstEntryData = result[0].value.data;
|
||||
const stable = firstEntryData.stable || firstEntryData.final;
|
||||
const language = firstEntryData.language;
|
||||
const messageID = firstEntryData.messageID;
|
||||
const [ p1Event, p2Event, webhookEvent ] = await Promise.all([ p1Promise, p2Promise, await webhookPromise() ]);
|
||||
const p1Id = await p1.getEndpointId();
|
||||
|
||||
result.map(r => {
|
||||
// @ts-ignore
|
||||
const v = r.value;
|
||||
const p1Transcript = p1Event.data.stable || p1Event.data.final;
|
||||
const p2Transcript = p2Event.data.stable || p2Event.data.final;
|
||||
const webhookTranscript = webhookEvent.data.stable || webhookEvent.data.final;
|
||||
|
||||
expect(v).toBeDefined();
|
||||
expect(p2Transcript.includes(p1Transcript) || p1Transcript.includes(p2Transcript)).toBe(true);
|
||||
expect(webhookTranscript.includes(p1Transcript) || p1Transcript.includes(webhookTranscript)).toBe(true);
|
||||
|
||||
return v.data;
|
||||
}).forEach(tr => {
|
||||
const checkTranscripts = stable.includes(tr.stable || tr.final) || (tr.stable || tr.final).includes(stable);
|
||||
expect(p2Event.data.language).toBe(p1Event.data.language);
|
||||
expect(webhookEvent.data.language).toBe(p1Event.data.language);
|
||||
|
||||
if (!checkTranscripts) {
|
||||
console.log('received events', JSON.stringify(result));
|
||||
}
|
||||
expect(p2Event.data.messageID).toBe(p1Event.data.messageID);
|
||||
expect(webhookEvent.data.messageID).toBe(p1Event.data.messageID);
|
||||
|
||||
expect(checkTranscripts).toBe(true);
|
||||
expect(tr.language).toBe(language);
|
||||
expect(tr.messageID).toBe(messageID);
|
||||
expect(tr.participant.id).toBe(p1Id);
|
||||
expect(tr.participant.name).toBe(p1.name);
|
||||
});
|
||||
expect(p1Event.data.participant.id).toBe(p1Id);
|
||||
expect(p2Event.data.participant.id).toBe(p1Id);
|
||||
expect(webhookEvent.data.participant.id).toBe(p1Id);
|
||||
|
||||
if (expectName) {
|
||||
expect(p1Event.data.participant.name).toBe(p1.name);
|
||||
expect(p2Event.data.participant.name).toBe(p1.name);
|
||||
expect(webhookEvent.data.participant.name).toBe(p1.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../../helpers/participants';
|
||||
|
||||
setTestProperties(__filename, {
|
||||
retry: true,
|
||||
usesBrowsers: [ 'p1', 'p2', 'p3' ]
|
||||
});
|
||||
|
||||
|
||||
@@ -288,9 +288,23 @@ export const config: WebdriverIO.MultiremoteConfig = {
|
||||
keepAlive.forEach(clearInterval);
|
||||
},
|
||||
|
||||
beforeSession(c, capabilities_, specs_, cid) {
|
||||
async beforeSession(c, capabilities_, spec, cid) {
|
||||
const originalBefore = c.before;
|
||||
|
||||
if (spec && spec.length == 1) {
|
||||
const testFilePath = spec[0].replace(/^file:\/\//, '');
|
||||
const testProperties = await getTestProperties(testFilePath);
|
||||
|
||||
if (testProperties.retry) {
|
||||
c.specFileRetries = 1;
|
||||
c.specFileRetriesDeferred = true;
|
||||
c.specFileRetriesDelay = 1;
|
||||
console.log(`Enabling retry for ${testFilePath}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No test file or multiple test files specified, will not enable retries');
|
||||
}
|
||||
|
||||
if (!originalBefore || !Array.isArray(originalBefore) || originalBefore.length !== 1) {
|
||||
console.warn('No before hook found or more than one found, skipping');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user