Merge branch 'e2ee_feature'

This commit is contained in:
Carlos Santos 2025-11-05 17:11:51 +01:00
commit 7dd368476e
44 changed files with 1367 additions and 148 deletions

View File

@ -1,4 +1,4 @@
description: Forbidden — Insufficient permissions
description: Forbidden - Insufficient Permissions
content:
application/json:
schema:

View File

@ -0,0 +1,16 @@
description: Forbidden
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
forbidden_error:
summary: Forbidden Error Example
value:
error: Authorization Error
message: 'Insufficient permissions to access this resource'
recording_not_allowed:
summary: Recording Not Allowed in Room Example
value:
error: Recording Error
message: 'Recording is disabled for room room-123'

View File

@ -21,6 +21,8 @@ content:
enabled: false
virtualBackground:
enabled: true
e2ee:
enabled: false
moderatorUrl: 'http://localhost:6080/room/room-123?secret=123456'
speakerUrl: 'http://localhost:6080/room/room-123?secret=654321'
status: open
@ -45,6 +47,8 @@ content:
enabled: false
virtualBackground:
enabled: true
e2ee:
enabled: false
fields=moderatorUrl,speakerUrl:
summary: Response containing only moderator and speaker URLs

View File

@ -30,6 +30,8 @@ content:
enabled: false
virtualBackground:
enabled: true
e2ee:
enabled: false
moderatorUrl: 'http://localhost:6080/room/room-123?secret=123456'
speakerUrl: 'http://localhost:6080/room/room-123?secret=654321'
status: open
@ -48,6 +50,8 @@ content:
enabled: true
virtualBackground:
enabled: false
e2ee:
enabled: false
moderatorUrl: 'http://localhost:6080/room/room-456?secret=789012'
speakerUrl: 'http://localhost:6080/room/room-456?secret=210987'
status: open
@ -80,6 +84,8 @@ content:
enabled: false
virtualBackground:
enabled: true
e2ee:
enabled: false
- roomId: 'room-456'
roomName: 'room'
creationDate: 1620001000000
@ -91,6 +97,8 @@ content:
enabled: true
virtualBackground:
enabled: false
e2ee:
enabled: false
pagination:
isTruncated: true
nextPageToken: 'abc123'

View File

@ -10,6 +10,9 @@ MeetRoomConfig:
virtualBackground:
$ref: '#/MeetVirtualBackgroundConfig'
description: Config for virtual background in the room.
e2ee:
$ref: '#/MeetE2EEConfig'
description: Config for End-to-End Encryption (E2EE) in the room.
MeetChatConfig:
type: object
properties:
@ -47,3 +50,14 @@ MeetVirtualBackgroundConfig:
default: true
example: true
description: If true, the room will be allowed to use virtual background.
MeetE2EEConfig:
type: object
properties:
enabled:
type: boolean
default: false
example: false
description: >
If true, the room will have End-to-End Encryption (E2EE) enabled.<br/>
This ensures that the media streams are encrypted from the sender to the receiver, providing enhanced privacy and security for the participants.<br/>
**Enabling E2EE will disable the recording feature for the room**.

View File

@ -20,7 +20,7 @@
'401':
$ref: '../../components/responses/unauthorized-error.yaml'
'403':
$ref: '../../components/responses/forbidden-error.yaml'
$ref: '../../components/responses/forbidden-not-allowed-error.yaml'
'404':
$ref: '../../components/responses/error-room-not-found.yaml'
'409':

View File

@ -45,7 +45,8 @@
"test:integration-users": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/integration/api/users\" --ci --reporters=default --reporters=jest-junit",
"test:unit": "node --experimental-vm-modules ../../node_modules/.bin/jest --runInBand --forceExit --testPathPattern \"tests/unit\" --ci --reporters=default --reporters=jest-junit",
"lint:fix": "eslint src --fix",
"format:code": "prettier --ignore-path .gitignore --write '**/*.{ts,js,json,md}'"
"format:code": "prettier --ignore-path .gitignore --write '**/*.{ts,js,json,md}'",
"clean": "rm -rf node_modules dist public test-results"
},
"dependencies": {
"@openvidu-meet/typings": "workspace:*",

View File

@ -1,5 +1,6 @@
import {
MeetChatConfig,
MeetE2EEConfig,
MeetRecordingAccess,
MeetRecordingConfig,
MeetRoomAutoDeletionPolicy,
@ -90,6 +91,10 @@ const VirtualBackgroundConfigSchema: z.ZodType<MeetVirtualBackgroundConfig> = z.
enabled: z.boolean()
});
const E2EEConfigSchema: z.ZodType<MeetE2EEConfig> = z.object({
enabled: z.boolean()
});
const ThemeModeSchema: z.ZodType<MeetRoomThemeMode> = z.enum([MeetRoomThemeMode.LIGHT, MeetRoomThemeMode.DARK]);
const hexColorSchema = z
@ -118,12 +123,28 @@ export const AppearanceConfigSchema = z.object({
themes: z.array(RoomThemeSchema).length(1, 'There must be exactly one theme defined')
});
const RoomConfigSchema: z.ZodType<MeetRoomConfig> = z.object({
recording: RecordingConfigSchema,
chat: ChatConfigSchema,
virtualBackground: VirtualBackgroundConfigSchema
// appearance: AppearanceConfigSchema,
});
const RoomConfigSchema: z.ZodType<MeetRoomConfig> = z
.object({
recording: RecordingConfigSchema,
chat: ChatConfigSchema,
virtualBackground: VirtualBackgroundConfigSchema,
e2ee: E2EEConfigSchema.optional().default({ enabled: false }),
// appearance: AppearanceConfigSchema,
})
.transform((data) => {
// Automatically disable recording when E2EE is enabled
if (data.e2ee?.enabled && data.recording.enabled) {
return {
...data,
recording: {
...data.recording,
enabled: false
}
};
}
return data;
});
const RoomDeletionPolicyWithMeetingSchema: z.ZodType<MeetRoomDeletionPolicyWithMeeting> = z.enum([
MeetRoomDeletionPolicyWithMeeting.FORCE,
@ -183,7 +204,8 @@ const RoomRequestOptionsSchema: z.ZodType<MeetRoomOptions> = z.object({
config: RoomConfigSchema.optional().default({
recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER },
chat: { enabled: true },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: false }
})
// maxParticipants: z
// .number()

View File

@ -132,7 +132,7 @@ export class RoomService {
}
/**
* Updates the config of a specific meeting room.
* Updates the configuration of a specific meeting room.
*
* @param roomId - The unique identifier of the meeting room to update
* @param config - The new config to apply to the meeting room

View File

@ -2,6 +2,8 @@ import { expect } from '@jest/globals';
import { container } from '../../src/config/dependency-injector.config';
import { INTERNAL_CONFIG } from '../../src/config/internal-config';
import { TokenService } from '../../src/services';
import { Response } from 'supertest';
import {
MeetingEndAction,
MeetRecordingAccess,
@ -18,7 +20,7 @@ import {
} from '@openvidu-meet/typings';
export const expectErrorResponse = (
response: any,
response: Response,
status = 422,
error = 'Unprocessable Entity',
message = 'Invalid request',
@ -51,7 +53,7 @@ export const expectErrorResponse = (
);
};
export const expectValidationError = (response: any, field: string, message: string) => {
export const expectValidationError = (response: Response, field: string, message: string) => {
expectErrorResponse(response, 422, 'Unprocessable Entity', 'Invalid request', [{ field, message }]);
};
@ -68,7 +70,7 @@ export const expectValidationError = (response: any, field: string, message: str
* if false, expects nextPageToken to be undefined)
*/
export const expectSuccessRoomsResponse = (
response: any,
response: Response,
expectedRoomLength: number,
expectedMaxItems: number,
expectedTruncated: boolean,
@ -90,7 +92,7 @@ export const expectSuccessRoomsResponse = (
};
export const expectSuccessRoomResponse = (
response: any,
response: Response,
roomName: string,
autoDeletionDate?: number,
config?: MeetRoomConfig
@ -99,7 +101,7 @@ export const expectSuccessRoomResponse = (
expectValidRoom(response.body, roomName, config, autoDeletionDate);
};
export const expectSuccessRoomConfigResponse = (response: any, config: MeetRoomConfig) => {
export const expectSuccessRoomConfigResponse = (response: Response, config: MeetRoomConfig) => {
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
expect(response.body).toEqual(config);
@ -151,7 +153,8 @@ export const expectValidRoom = (
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: false }
});
}
@ -197,7 +200,7 @@ export const expectValidRecordingWithFields = (rec: MeetRecordingInfo, fields: s
expectObjectFields(rec, fields);
};
const expectObjectFields = (obj: any, present: string[] = [], absent: string[] = []) => {
const expectObjectFields = (obj: unknown, present: string[] = [], absent: string[] = []) => {
present.forEach((key) => {
expect(obj).toHaveProperty(key);
expect((obj as any)[key]).not.toBeUndefined();
@ -209,7 +212,7 @@ const expectObjectFields = (obj: any, present: string[] = [], absent: string[] =
};
// Validate recording location header in the response
export const expectValidRecordingLocationHeader = (response: any) => {
export const expectValidRecordingLocationHeader = (response: Response) => {
const locationHeader = response.headers.location;
expect(locationHeader).toBeDefined();
const locationHeaderUrl = new URL(locationHeader);
@ -230,7 +233,7 @@ export const expectValidRecordingLocationHeader = (response: any) => {
* - expectedStatus: Override the expected status code (default: auto-determined based on range)
*/
export const expectSuccessRecordingMediaResponse = (
response: any,
response: Response,
range?: string,
fullSize?: number,
options?: {
@ -348,7 +351,7 @@ export const expectSuccessRecordingMediaResponse = (
}
};
export const expectValidStartRecordingResponse = (response: any, roomId: string, roomName: string) => {
export const expectValidStartRecordingResponse = (response: Response, roomId: string, roomName: string) => {
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('recordingId');
@ -369,7 +372,7 @@ export const expectValidStartRecordingResponse = (response: any, roomId: string,
};
export const expectValidStopRecordingResponse = (
response: any,
response: Response,
recordingId: string,
roomId: string,
roomName: string
@ -388,7 +391,7 @@ export const expectValidStopRecordingResponse = (
};
export const expectValidGetRecordingResponse = (
response: any,
response: Response,
recordingId: string,
roomId: string,
roomName: string,
@ -444,7 +447,7 @@ export const expectValidGetRecordingResponse = (
};
export const expectSuccessListRecordingResponse = (
response: any,
response: Response,
recordingLength: number,
isTruncated: boolean,
nextPageToken: boolean,
@ -470,7 +473,7 @@ export const expectSuccessListRecordingResponse = (
expect(response.body.pagination.maxItems).toBe(maxItems);
};
export const expectValidGetRecordingUrlResponse = (response: any, recordingId: string) => {
export const expectValidGetRecordingUrlResponse = (response: Response, recordingId: string) => {
expect(response.status).toBe(200);
const recordingUrl = response.body.url;
expect(recordingUrl).toBeDefined();
@ -480,7 +483,7 @@ export const expectValidGetRecordingUrlResponse = (response: any, recordingId: s
expect(parsedUrl.searchParams.get('secret')).toBeDefined();
};
export const expectValidRoomRolesAndPermissionsResponse = (response: any, roomId: string) => {
export const expectValidRoomRolesAndPermissionsResponse = (response: Response, roomId: string) => {
expect(response.status).toBe(200);
expect(response.body).toEqual(
expect.arrayContaining([
@ -497,7 +500,7 @@ export const expectValidRoomRolesAndPermissionsResponse = (response: any, roomId
};
export const expectValidRoomRoleAndPermissionsResponse = (
response: any,
response: Response,
roomId: string,
participantRole: ParticipantRole
) => {
@ -552,7 +555,7 @@ export const getPermissions = (
};
export const expectValidParticipantTokenResponse = (
response: any,
response: Response,
roomId: string,
participantRole: ParticipantRole,
participantName?: string,
@ -601,7 +604,7 @@ export const expectValidParticipantTokenResponse = (
};
export const expectValidRecordingTokenResponse = (
response: any,
response: Response,
roomId: string,
participantRole: ParticipantRole,
canRetrieveRecordings: boolean,

View File

@ -22,6 +22,7 @@ import {
MeetRecordingInfo,
MeetRecordingStatus,
MeetRoom,
MeetRoomConfig,
MeetRoomDeletionPolicyWithMeeting,
MeetRoomDeletionPolicyWithRecordings,
MeetRoomOptions,
@ -284,7 +285,7 @@ export const getRoom = async (roomId: string, fields?: string, participantToken?
return await req;
};
export const getRoomConfig = async (roomId: string) => {
export const getRoomConfig = async (roomId: string): Promise<Response> => {
checkAppIsRunning();
return await request(app)
@ -293,7 +294,7 @@ export const getRoomConfig = async (roomId: string) => {
.send();
};
export const updateRoomConfig = async (roomId: string, config: any) => {
export const updateRoomConfig = async (roomId: string, config: MeetRoomConfig) => {
checkAppIsRunning();
return await request(app)

View File

@ -76,11 +76,11 @@ export const setupSingleRoom = async (
* @param withParticipants Whether to join fake participants in the rooms.
* @returns Test context with created rooms and their data.
*/
export const setupMultiRoomTestContext = async (numRooms: number, withParticipants: boolean): Promise<TestContext> => {
export const setupMultiRoomTestContext = async (numRooms: number, withParticipants: boolean, roomConfig?: MeetRoomConfig): Promise<TestContext> => {
const rooms: RoomData[] = [];
for (let i = 0; i < numRooms; i++) {
const roomData = await setupSingleRoom(withParticipants, 'TEST_ROOM');
const roomData = await setupSingleRoom(withParticipants, 'TEST_ROOM', roomConfig);
rooms.push(roomData);
}

View File

@ -63,7 +63,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: false },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: true }
}
};

View File

@ -0,0 +1,263 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
import { Express } from 'express';
import request from 'supertest';
import { INTERNAL_CONFIG } from '../../../../src/config/internal-config.js';
import { MEET_INITIAL_API_KEY } from '../../../../src/environment.js';
import { MeetRecordingAccess, MeetRoom } from '@openvidu-meet/typings';
import { expectValidRoom } from '../../../helpers/assertion-helpers.js';
import {
createRoom,
deleteAllRecordings,
deleteAllRooms,
getRoomConfig,
startRecording,
startTestServer,
updateRoomConfig
} from '../../../helpers/request-helpers.js';
import { setupMultiRoomTestContext } from '../../../helpers/test-scenarios.js';
const ROOMS_PATH = `${INTERNAL_CONFIG.API_BASE_PATH_V1}/rooms`;
describe('E2EE Room Configuration Tests', () => {
let app: Express;
beforeAll(async () => {
app = startTestServer();
});
afterAll(async () => {
await deleteAllRecordings();
await deleteAllRooms();
});
describe('E2EE Default Configuration', () => {
it('Should create a room with E2EE disabled by default', async () => {
const room = await createRoom({
roomName: 'Test E2EE Default'
});
expectValidRoom(room, 'Test E2EE Default');
expect(room.config.e2ee).toBeDefined();
expect(room.config.e2ee?.enabled).toBe(false);
});
});
describe('E2EE Enabled Configuration', () => {
it('Should create a room with E2EE enabled and recording automatically disabled', async () => {
const payload = {
roomName: 'Test E2EE Enabled',
config: {
recording: {
enabled: true, // This should be automatically disabled
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
}
};
const room = await createRoom(payload);
expect(room.roomName).toBe('Test E2EE Enabled');
expect(room.config.e2ee?.enabled).toBe(true);
expect(room.config.recording.enabled).toBe(false); // Recording should be disabled
});
});
describe('E2EE and Recording Interaction', () => {
it('Should not allow starting recording in a room with E2EE enabled', async () => {
const context = await setupMultiRoomTestContext(1, true, {
recording: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
});
const { room, moderatorToken } = context.getRoomByIndex(0)!;
// Try to start recording (should fail because recording is not enabled in room config)
const response = await startRecording(room.roomId, moderatorToken);
// The endpoint returns 404 when the recording endpoint doesn't exist for disabled recording rooms
expect(403).toBe(response.status);
expect(response.body.message).toBe(`Recording is disabled for room '${room.roomId}'`);
});
it('Should disable recording when updating room config to enable E2EE', async () => {
// Create room with recording enabled and E2EE disabled
const room = await createRoom({
roomName: 'Test E2EE Update',
config: {
recording: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: false }
}
});
expect(room.config.recording.enabled).toBe(true);
expect(room.config.e2ee?.enabled).toBe(false);
// Update room to enable E2EE (recording should be automatically disabled)
const updatedConfig = {
recording: {
enabled: true, // This should be automatically disabled
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
};
const response = await updateRoomConfig(room.roomId, updatedConfig);
expect(response.status).toBe(200);
// Fetch the updated room to verify changes
const { status, body: config } = await getRoomConfig(room.roomId);
expect(status).toBe(200);
expect(config.e2ee?.enabled).toBe(true);
expect(config.recording.enabled).toBe(false);
});
// TODO: Add test for enabling E2EE when there are active recordings in the room
});
describe('E2EE Validation Tests', () => {
it('Should fail when e2ee is not an object', async () => {
const payload = {
roomName: 'Test Invalid E2EE',
config: {
recording: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: 'invalid-e2ee' // Should be an object
}
};
const response = await request(app)
.post(ROOMS_PATH)
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_INITIAL_API_KEY)
.send(payload)
.expect(422);
expect(JSON.stringify(response.body.details)).toContain('Expected object');
});
it('Should fail when e2ee.enabled is not a boolean', async () => {
const payload = {
roomName: 'Test Invalid E2EE Enabled',
config: {
recording: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: 'yes' } // Should be a boolean
}
};
const response = await request(app)
.post(ROOMS_PATH)
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_INITIAL_API_KEY)
.send(payload)
.expect(422);
expect(JSON.stringify(response.body.details)).toContain('Expected boolean');
});
});
describe('E2EE Update Configuration Tests', () => {
it('Should successfully update room config with E2EE disabled to enabled', async () => {
const room = await createRoom({
roomName: 'Test E2EE Update Enabled'
});
expect(room.config.e2ee?.enabled).toBe(false);
const { status, body } = await updateRoomConfig(room.roomId, {
recording: {
enabled: false,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
});
expect(status).toBe(200);
expect(body.message).toBeDefined();
// Fetch the updated room to verify changes
const { body: config } = await getRoomConfig(room.roomId);
expect(config.e2ee?.enabled).toBe(true);
expect(config.recording.enabled).toBe(false);
});
});
describe('E2EE and Room Status Tests', () => {
it('Should return E2EE configuration when listing rooms', async () => {
await deleteAllRooms();
const room1 = await createRoom({
roomName: 'E2EE Enabled Room',
config: {
recording: {
enabled: false,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
}
});
const room2 = await createRoom({
roomName: 'E2EE Disabled Room',
config: {
recording: {
enabled: true,
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: false }
}
});
const response = await request(app)
.get(ROOMS_PATH)
.set(INTERNAL_CONFIG.API_KEY_HEADER, MEET_INITIAL_API_KEY)
.expect(200);
// Filter out any rooms from other test suites
const testRooms = response.body.rooms.filter(
(r: MeetRoom) => r.roomId === room1.roomId || r.roomId === room2.roomId
);
expect(testRooms).toHaveLength(2);
const e2eeEnabledRoom = testRooms.find((r: MeetRoom) => r.roomId === room1.roomId);
const e2eeDisabledRoom = testRooms.find((r: MeetRoom) => r.roomId === room2.roomId);
expect(e2eeEnabledRoom.config.e2ee?.enabled).toBe(true);
expect(e2eeEnabledRoom.config.recording.enabled).toBe(false);
expect(e2eeDisabledRoom.config.e2ee?.enabled).toBe(false);
expect(e2eeDisabledRoom.config.recording.enabled).toBe(true);
});
});
});

View File

@ -3,6 +3,7 @@ import { MeetRecordingAccess } from '@openvidu-meet/typings';
import { expectSuccessRoomConfigResponse } from '../../../helpers/assertion-helpers.js';
import { deleteAllRooms, getRoomConfig, startTestServer } from '../../../helpers/request-helpers.js';
import { setupSingleRoom } from '../../../helpers/test-scenarios.js';
import { Response } from 'supertest';
describe('Room API Tests', () => {
const DEFAULT_CONFIG = {
@ -11,7 +12,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: false }
};
beforeAll(() => {
@ -28,7 +30,7 @@ describe('Room API Tests', () => {
const roomData = await setupSingleRoom();
const roomId = roomData.room.roomId;
const response = await getRoomConfig(roomId);
const response: Response = await getRoomConfig(roomId);
expectSuccessRoomConfigResponse(response, DEFAULT_CONFIG);
});
@ -41,7 +43,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: false }
virtualBackground: { enabled: false },
e2ee: { enabled: false }
}
};

View File

@ -41,7 +41,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: false }
virtualBackground: { enabled: false },
e2ee: { enabled: false }
}
};
// Create a room with custom config

View File

@ -38,7 +38,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: false }
}
});
@ -49,7 +50,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN
},
chat: { enabled: false },
virtualBackground: { enabled: false }
virtualBackground: { enabled: false },
e2ee: { enabled: true }
};
const updateResponse = await updateRoomConfig(createdRoom.roomId, updatedConfig);
@ -86,7 +88,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: false }
}
});
@ -97,7 +100,8 @@ describe('Room API Tests', () => {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: false }
};
const updateResponse = await updateRoomConfig(createdRoom.roomId, partialConfig);

View File

@ -24,7 +24,15 @@
"polyfills": ["zone.js"],
"tsConfig": "src/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["src/favicon.ico", "src/assets"],
"assets": [
"src/favicon.ico",
"src/assets",
{
"glob": "livekit-client.e2ee.worker.mjs",
"input": "../../node_modules/livekit-client/dist/",
"output": "assets/livekit/"
}
],
"styles": ["src/styles.scss"],
"scripts": [],
"preserveSymlinks": true
@ -186,31 +194,31 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

View File

@ -2,6 +2,7 @@
"name": "@openvidu-meet/frontend",
"version": "3.4.1",
"scripts": {
"clean": "rm -rf node_modules dist test-results",
"dev": "pnpm exec ng build --configuration development --watch",
"build": "func() { pnpm exec ng build --configuration production --base-href=\"${1:-/}\"; }; func",
"lib:serve": "ng build shared-meet-components --watch",

View File

@ -5,6 +5,12 @@
<mat-icon class="ov-room-icon room-icon">video_chat</mat-icon>
<div class="room-info">
<h1 class="room-title">{{ roomName }}</h1>
@if (isE2EEEnabled) {
<span class="encryption-badge" matTooltip="End-to-end encrypted">
<mat-icon class="badge-icon">lock</mat-icon>
This meeting is end-to-end encrypted
</span>
}
</div>
</div>
@ -27,6 +33,7 @@
<mat-card-content class="card-content">
@if (!roomClosed) {
<form [formGroup]="participantForm" (ngSubmit)="onFormSubmit()" class="join-form">
<!-- Participant Name Input -->
<mat-form-field appearance="outline" class="name-field">
<mat-label>Your display name</mat-label>
<input
@ -42,13 +49,33 @@
}
</mat-form-field>
<!-- E2EE Key Input (shown when E2EE is enabled) -->
@if (isE2EEEnabled) {
<mat-form-field appearance="outline" class="e2eekey-field fade-in">
<mat-label>Encryption Key</mat-label>
<input
id="participant-e2eekey-input"
matInput
type="password"
placeholder="Enter room encryption key"
formControlName="e2eeKey"
required
/>
<mat-icon matSuffix class="ov-action-icon">vpn_key</mat-icon>
@if (participantForm.get('e2eeKey')?.hasError('required')) {
<mat-error> The encryption key is <strong>required</strong> </mat-error>
}
<mat-hint>This room requires an encryption key to join</mat-hint>
</mat-form-field>
}
<button
mat-raised-button
color="primary"
id="participant-name-submit"
type="submit"
class="join-button"
[disabled]="participantForm.invalid"
[disabled]="!participantForm.valid"
>
<span>Join Meeting</span>
</button>
@ -66,7 +93,7 @@
</mat-card>
<!-- View Recordings Card -->
@if (showRecordingsCard) {
@if (showRecordingCard) {
<mat-card class="action-card secondary-card fade-in-delayed">
<mat-card-header class="card-header">
<mat-icon class="ov-recording-icon card-icon">video_library</mat-icon>

View File

@ -14,7 +14,7 @@
@include design-tokens.ov-flex-center;
flex-direction: column;
gap: var(--ov-meet-spacing-md);
margin-bottom: var(--ov-meet-spacing-xxl);
margin-bottom: var(--ov-meet-spacing-lg);
text-align: center;
.room-icon {
@ -24,16 +24,58 @@
}
.room-info {
flex: 1;
.room-title {
margin: 0;
font-size: var(--ov-meet-font-size-hero);
font-weight: var(--ov-meet-font-weight-light);
color: var(--ov-meet-text-primary);
line-height: var(--ov-meet-line-height-tight);
display: flex;
align-items: center;
justify-content: center;
gap: var(--ov-meet-spacing-md);
flex-wrap: wrap;
@include design-tokens.ov-tablet-down {
font-size: var(--ov-meet-font-size-xxl);
flex-direction: column;
gap: var(--ov-meet-spacing-sm);
.encryption-badge {
font-size: var(--ov-meet-font-size-xs);
}
}
}
.encryption-badge {
display: inline-flex;
align-items: center;
gap: var(--ov-meet-spacing-xs);
padding: var(--ov-meet-spacing-xs) var(--ov-meet-spacing-md);
background: linear-gradient(135deg, rgba(33, 150, 243, 0.15) 0%, rgba(33, 150, 243, 0.08) 100%);
border: 1px solid rgba(33, 150, 243, 0.3);
border-radius: var(--ov-meet-radius-lg);
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-medium);
color: var(--ov-meet-color-info);
.badge-icon {
@include design-tokens.ov-icon(sm);
color: var(--ov-meet-color-info);
}
}
}
}
// E2EE Warning Container
.e2ee-warning-container {
margin-bottom: var(--ov-meet-spacing-lg);
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
// Action Cards Grid - Responsive layout
.action-cards-grid {
@include design-tokens.ov-grid-responsive(320px);
@ -172,15 +214,21 @@
.join-form {
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-lg);
gap: var(--ov-meet-spacing-sm);
flex: 1;
.name-field {
width: 100%;
transition: all 0.3s ease;
}
.mat-mdc-form-field-icon-suffix {
color: var(--ov-meet-text-hint);
}
.e2eeey-field {
width: 100%;
animation: slideDown 0.3s ease-out;
}
.mat-mdc-form-field-icon-suffix {
color: var(--ov-meet-text-hint);
}
.join-button {

View File

@ -6,7 +6,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ShareMeetingLinkComponent } from '../share-meeting-link/share-meeting-link.component';
import { ShareMeetingLinkComponent } from '../../components';
/**
* Reusable component for the meeting lobby page.
@ -45,9 +45,9 @@ export class MeetingLobbyComponent {
@Input() roomClosed = false;
/**
* Whether to show the recordings card
* Whether to show the recording card
*/
@Input() showRecordingsCard = false;
@Input() showRecordingCard = false;
/**
* Whether to show the share meeting link component
@ -64,6 +64,11 @@ export class MeetingLobbyComponent {
*/
@Input() backButtonText = 'Back';
/**
* Whether E2EE is enabled for the meeting
*/
@Input() isE2EEEnabled = false;
/**
* The participant form group
*/

View File

@ -13,6 +13,7 @@ export interface LobbyState {
showRecordingCard: boolean;
showBackButton: boolean;
backButtonText: string;
isE2EEEnabled: boolean;
participantForm: FormGroup;
participantToken: string;
}

View File

@ -46,7 +46,7 @@
@if (isBasicCreation()) {
<!-- Basic Room Creation -->
<ov-room-basic-creation
(createRoom)="createRoom($event)"
(createRoom)="createRoomBasic($event)"
(openAdvancedMode)="onOpenAdvancedMode()"
></ov-room-basic-creation>
} @else {
@ -93,7 +93,7 @@
(next)="onNext()"
(cancel)="onCancel()"
(back)="onBack()"
(finish)="onFinish()"
(finish)="createRoomAdvance()"
>
</ov-wizard-nav>
}

View File

@ -8,7 +8,7 @@ import { ActivatedRoute } from '@angular/router';
import { StepIndicatorComponent, WizardNavComponent } from '../../../../components';
import { WizardNavigationConfig, WizardStep } from '../../../../models';
import { NavigationService, NotificationService, RoomService, RoomWizardStateService } from '../../../../services';
import { MeetRoomOptions } from '@openvidu-meet/typings';
import { BaseRoomOptions, MeetRoomOptions } from '@openvidu-meet/typings';
import { RoomBasicCreationComponent } from '../room-basic-creation/room-basic-creation.component';
import { RecordingConfigComponent } from './steps/recording-config/recording-config.component';
import { RecordingLayoutComponent } from './steps/recording-layout/recording-layout.component';
@ -125,9 +125,18 @@ export class RoomWizardComponent implements OnInit {
await this.navigationService.navigateTo('rooms', undefined, true);
}
async createRoom(roomName?: string) {
async createRoomBasic(roomName?: string) {
try {
const { moderatorUrl } = await this.roomService.createRoom({ roomName });
// Create room with basic config including e2ee: false (default settings)
const { moderatorUrl } = await this.roomService.createRoom({
roomName,
config: {
chat: { enabled: true },
recording: { enabled: true },
virtualBackground: { enabled: true },
e2ee: { enabled: false }
}
});
// Extract the path and query parameters from the moderator URL and navigate to it
const url = new URL(moderatorUrl);
@ -144,8 +153,8 @@ export class RoomWizardComponent implements OnInit {
}
}
async onFinish() {
const roomOptions = this.wizardService.roomOptions();
async createRoomAdvance() {
const roomOptions: BaseRoomOptions = this.wizardService.roomOptions();
console.log('Wizard completed with data:', roomOptions);
// Activate loading state

View File

@ -41,9 +41,39 @@
margin-bottom: var(--ov-meet-spacing-md);
.recording-form {
.e2ee-warning {
display: flex;
align-items: flex-start;
gap: var(--ov-meet-spacing-sm);
padding: var(--ov-meet-spacing-md);
margin-bottom: var(--ov-meet-spacing-lg);
background: linear-gradient(135deg, var(--ov-meet-color-info-alpha-10) 0%, var(--ov-meet-color-info-alpha-5) 100%);
border: 1px solid var(--ov-meet-color-info-alpha-30);
border-radius: var(--ov-meet-border-radius-md);
.warning-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-color-info);
margin-top: 2px;
}
.warning-text {
flex: 1;
margin: 0;
font-size: var(--ov-meet-font-size-sm);
color: var(--ov-meet-text-primary);
line-height: var(--ov-meet-line-height-normal);
}
}
.options-grid {
@include design-tokens.ov-grid-responsive(260px);
gap: var(--ov-meet-spacing-md);
&.disabled-grid {
pointer-events: none;
opacity: 0.5;
}
}
.access-selection-section {

View File

@ -1,4 +1,3 @@
import { Component, OnDestroy } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@ -18,30 +17,33 @@ interface RecordingAccessOption {
}
@Component({
selector: 'ov-recording-config',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatIconModule,
MatCardModule,
MatRadioModule,
MatSelectModule,
MatFormFieldModule,
SelectableCardComponent
],
templateUrl: './recording-config.component.html',
styleUrl: './recording-config.component.scss'
selector: 'ov-recording-config',
imports: [
ReactiveFormsModule,
MatButtonModule,
MatIconModule,
MatCardModule,
MatRadioModule,
MatSelectModule,
MatFormFieldModule,
SelectableCardComponent
],
templateUrl: './recording-config.component.html',
styleUrl: './recording-config.component.scss'
})
export class RecordingConfigComponent implements OnDestroy {
recordingForm: FormGroup;
isAnimatingOut = false;
// Store the previous E2EE state before recording disables it
private e2eeStateBeforeRecording: boolean | null = null;
recordingOptions: SelectableOption[] = [
{
id: 'enabled',
title: 'Allow Recording',
description:
'Enable recording capabilities for this room. Recordings can be started manually or automatically.',
'Enable recording features for this room, allowing authorized participants to start and manage recordings.',
icon: 'video_library'
// recommended: true
},
@ -102,7 +104,43 @@ export class RecordingConfigComponent implements OnDestroy {
const previouslyEnabled = this.isRecordingEnabled;
const willBeEnabled = event.optionId === 'enabled';
// If we are disabling the recording, we want to animate out
const configStep = this.wizardState.steps().find((step) => step.id === 'config');
// Handle E2EE state when recording changes
if (configStep) {
if (!previouslyEnabled && willBeEnabled) {
// Enabling recording: save E2EE state and disable it if needed
const e2eeEnabled = configStep.formGroup.get('e2eeEnabled')?.value;
if (e2eeEnabled) {
// Save the E2EE state before disabling it
this.e2eeStateBeforeRecording = true;
// Disable E2EE when enabling recording
configStep.formGroup.patchValue(
{
e2eeEnabled: false
},
{ emitEvent: true }
);
}
} else if (previouslyEnabled && !willBeEnabled) {
// Disabling recording: restore E2EE state if it was saved
if (this.e2eeStateBeforeRecording !== null) {
configStep.formGroup.patchValue(
{
e2eeEnabled: this.e2eeStateBeforeRecording
},
{ emitEvent: true }
);
// Clear the saved state
this.e2eeStateBeforeRecording = null;
}
}
}
// Handle recording form update with animation
if (previouslyEnabled && !willBeEnabled) {
this.isAnimatingOut = true;
// Wait for the animation to finish before updating the form

View File

@ -13,6 +13,55 @@
<form [formGroup]="configForm" class="config-form">
<!-- Config Cards Grid -->
<div class="config-grid">
<!-- End-to-End Encryption Card -->
<mat-card class="config-card e2ee-card">
<mat-card-content>
<div class="card-header">
<div class="icon-title-group">
<mat-icon class="feature-icon">lock</mat-icon>
<div class="title-group">
<h4 class="card-title">End-to-End Encryption</h4>
<p class="card-description">
Add an extra layer of security to your meetings. No one outside the meeting, not
even anyone with server access, can see or hear the conversation.
</p>
</div>
</div>
<mat-slide-toggle
[checked]="e2eeEnabled"
(change)="onE2EEToggleChange($event)"
color="primary"
class="feature-toggle"
>
</mat-slide-toggle>
</div>
<!-- E2EE Info Section -->
@if (e2eeEnabled) {
<div class="e2ee-info-section fade-in">
<div class="warning-card">
<mat-icon class="warning-icon">warning</mat-icon>
<div class="warning-content">
<p class="warning-title">Restrictions</p>
<ul class="restrictions-list">
<li>
All participants must use the same encryption key to see and hear each
other.
<span class="restriction-detail">
Participants who enter an incorrect key will not receive an error
message but will be unable to see or hear others.
</span>
</li>
<li>Recording is unavailable while encryption is enabled.</li>
<li>Chat messages are not protected by end-to-end encryption.</li>
</ul>
</div>
</div>
</div>
}
</mat-card-content>
</mat-card>
<!-- Chat Settings Card -->
<mat-card class="config-card">
<mat-card-content>

View File

@ -65,6 +65,12 @@
transition: all 0.2s ease-in-out;
cursor: default;
&.e2ee-card {
.feature-icon {
color: var(--ov-meet-color-info) !important;
}
}
&:hover {
border-color: var(--ov-meet-border-primary);
box-shadow: var(--ov-meet-shadow-md);
@ -118,6 +124,78 @@
align-self: flex-start;
}
}
.e2ee-info-section {
margin-top: var(--ov-meet-spacing-md);
display: flex;
flex-direction: column;
gap: var(--ov-meet-spacing-sm);
animation: slideIn 0.3s ease-out;
.warning-card {
display: flex;
align-items: flex-start;
gap: var(--ov-meet-spacing-sm);
padding: var(--ov-meet-spacing-md);
border-radius: var(--ov-meet-radius-sm);
}
.warning-card {
background: rgba(255, 152, 0, 0.08);
border: 1px solid rgba(255, 152, 0, 0.2);
.warning-icon {
@include design-tokens.ov-icon(md);
color: var(--ov-meet-color-warning);
flex-shrink: 0;
margin-top: 2px;
}
.warning-content {
flex: 1;
.warning-title {
margin: 0 0 var(--ov-meet-spacing-xs) 0;
font-size: var(--ov-meet-font-size-sm);
font-weight: var(--ov-meet-font-weight-semibold);
color: var(--ov-meet-color-warning);
}
.restrictions-list {
margin: 0;
padding-left: var(--ov-meet-spacing-md);
font-size: var(--ov-meet-font-size-sm);
line-height: var(--ov-meet-line-height-relaxed);
color: var(--ov-meet-text-primary);
li {
margin-bottom: var(--ov-meet-spacing-xs);
&:last-child {
margin-bottom: 0;
}
}
.restriction-detail {
display: block;
color: var(--ov-meet-text-secondary);
margin-top: var(--ov-meet-spacing-xs);
}
}
}
}
}
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -16,6 +16,8 @@ export class RoomConfigComponent implements OnDestroy {
configForm: FormGroup;
private destroy$ = new Subject<void>();
// Store the previous recording state before E2EE disables it
private recordingStateBeforeE2EE: string | null = null;
constructor(private wizardService: RoomWizardStateService) {
const currentStep = this.wizardService.currentStep();
@ -32,13 +34,18 @@ export class RoomConfigComponent implements OnDestroy {
}
private saveFormData(formValue: any): void {
const isE2EEEnabled = formValue.e2eeEnabled ?? false;
const stepData: any = {
config: {
chat: {
enabled: formValue.chatEnabled
enabled: formValue.chatEnabled ?? false
},
virtualBackground: {
enabled: formValue.virtualBackgroundsEnabled
enabled: formValue.virtualBackgroundsEnabled ?? false
},
e2ee: {
enabled: isE2EEEnabled
}
}
};
@ -46,6 +53,41 @@ export class RoomConfigComponent implements OnDestroy {
this.wizardService.updateStepData('config', stepData);
}
onE2EEToggleChange(event: any): void {
const isEnabled = event.checked;
this.configForm.patchValue({
e2eeEnabled: isEnabled
});
const recordingStep = this.wizardService.steps().find(step => step.id === 'recording');
if (!recordingStep) return;
if (isEnabled) {
// Save the current recording state before disabling it
const currentRecordingValue = recordingStep.formGroup.get('recordingEnabled')?.value;
// Only save if it's not already 'disabled' (to preserve user's original choice)
if (currentRecordingValue !== 'disabled') {
this.recordingStateBeforeE2EE = currentRecordingValue;
}
// Disable recording automatically
recordingStep.formGroup.patchValue({
recordingEnabled: 'disabled'
}, { emitEvent: true });
} else {
// Restore the previous recording state when E2EE is disabled
if (this.recordingStateBeforeE2EE !== null) {
recordingStep.formGroup.patchValue({
recordingEnabled: this.recordingStateBeforeE2EE
}, { emitEvent: true });
// Clear the saved state
this.recordingStateBeforeE2EE = null;
}
}
}
onChatToggleChange(event: any): void {
const isEnabled = event.checked;
this.configForm.patchValue({ chatEnabled: isEnabled });
@ -61,6 +103,10 @@ export class RoomConfigComponent implements OnDestroy {
}
get virtualBackgroundsEnabled(): boolean {
return this.configForm.value.virtualBackgroundsEnabled || false;
return this.configForm.value.virtualBackgroundEnabled ?? false;
}
get e2eeEnabled(): boolean {
return this.configForm.value.e2eeEnabled ?? false;
}
}

View File

@ -20,6 +20,7 @@
[prejoinDisplayParticipantName]="false"
[videoEnabled]="features().videoEnabled"
[audioEnabled]="features().audioEnabled"
[e2eeKey]="e2eeKey"
[toolbarRoomName]="roomName"
[toolbarCameraButton]="features().showCamera"
[toolbarMicrophoneButton]="features().showMicrophone"

View File

@ -143,14 +143,9 @@ export class MeetingComponent implements OnInit {
protected lobbyInputs = computed(() => {
if (!this.lobbyState) return {};
return this.pluginManager.getLobbyInputs(
this.roomName,
`${this.hostname}/room/${this.roomId}`,
this.lobbyState.roomClosed,
this.lobbyState.showRecordingCard,
!this.lobbyState.roomClosed && this.features().canModerateRoom,
this.lobbyState.showBackButton,
this.lobbyState.backButtonText,
this.lobbyState.participantForm,
this.lobbyState,
this.hostname,
this.features().canModerateRoom,
() => this.submitAccessMeeting(),
() => this.lobbyService.goToRecordings(),
() => this.lobbyService.goBack(),
@ -183,6 +178,9 @@ export class MeetingComponent implements OnInit {
get participantName(): string {
return this.lobbyService.participantName;
}
get e2eeKey(): string {
return this.lobbyService.e2eeKey;
}
get participantToken(): string {
return this.lobbyState!.participantToken;

View File

@ -36,8 +36,10 @@ export class MeetingLobbyService {
showRecordingCard: false,
showBackButton: true,
backButtonText: 'Back',
isE2EEEnabled: false,
participantForm: new FormGroup({
name: new FormControl('', [Validators.required])
name: new FormControl('', [Validators.required]),
e2eeKey: new FormControl('')
}),
participantToken: ''
};
@ -70,6 +72,14 @@ export class MeetingLobbyService {
return value.name.trim();
}
get e2eeKey(): string {
const { valid, value } = this.state.participantForm;
if (!valid || !value.e2eeKey?.trim()) {
return '';
}
return value.e2eeKey.trim();
}
/**
* Initializes the lobby state by fetching room data and configuring UI
*/
@ -78,6 +88,13 @@ export class MeetingLobbyService {
this.state.roomSecret = this.roomService.getRoomSecret();
this.state.room = await this.roomService.getRoom(this.state.roomId);
this.state.roomClosed = this.state.room.status === MeetRoomStatus.CLOSED;
this.state.isE2EEEnabled = this.state.room.config.e2ee?.enabled || false;
// If E2EE is enabled, require e2eeKey
if (this.state.isE2EEEnabled) {
this.state.participantForm.get('e2eeKey')?.setValidators([Validators.required]);
this.state.participantForm.get('e2eeKey')?.updateValueAndValidity();
}
await this.setBackButtonText();
await this.checkForRecordings();
@ -86,7 +103,6 @@ export class MeetingLobbyService {
return this.state;
}
/**
* Handles the back button click event and navigates accordingly
* If in embedded mode, it closes the WebComponentManagerService
@ -134,6 +150,12 @@ export class MeetingLobbyService {
throw new Error('Participant form is invalid');
}
// For E2EE rooms, validate passkey
if (this.state.isE2EEEnabled && !this.e2eeKey) {
console.warn('E2EE key is required for encrypted rooms.');
return;
}
await this.generateParticipantToken();
await this.addParticipantNameToUrl();
await this.roomService.loadRoomConfig(this.state.roomId);

View File

@ -1,6 +1,5 @@
import { Injectable, Optional, Inject } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { CustomParticipantModel } from '../../models';
import { CustomParticipantModel, LobbyState } from '../../models';
import { MeetingActionHandler, MEETING_ACTION_HANDLER_TOKEN, ParticipantControls } from '../../customization';
import { ParticipantService } from '../participant.service';
@ -27,11 +26,7 @@ export class MeetingPluginManagerService {
/**
* Prepares inputs for the toolbar additional buttons plugin
*/
getToolbarAdditionalButtonsInputs(
canModerateRoom: boolean,
isMobile: boolean,
onCopyLink: () => void
) {
getToolbarAdditionalButtonsInputs(canModerateRoom: boolean, isMobile: boolean, onCopyLink: () => void) {
return {
showCopyLinkButton: canModerateRoom,
showLeaveMenu: false,
@ -61,11 +56,7 @@ export class MeetingPluginManagerService {
/**
* Prepares inputs for the participant panel "after local participant" plugin
*/
getParticipantPanelAfterLocalInputs(
canModerateRoom: boolean,
meetingUrl: string,
onCopyLink: () => void
) {
getParticipantPanelAfterLocalInputs(canModerateRoom: boolean, meetingUrl: string, onCopyLink: () => void) {
return {
showShareLink: canModerateRoom,
meetingUrl,
@ -76,11 +67,7 @@ export class MeetingPluginManagerService {
/**
* Prepares inputs for the layout additional elements plugin
*/
getLayoutAdditionalElementsInputs(
showOverlay: boolean,
meetingUrl: string,
onCopyLink: () => void
) {
getLayoutAdditionalElementsInputs(showOverlay: boolean, meetingUrl: string, onCopyLink: () => void) {
return {
showOverlay,
meetingUrl,
@ -118,27 +105,36 @@ export class MeetingPluginManagerService {
* Prepares inputs for the lobby plugin
*/
getLobbyInputs(
roomName: string,
meetingUrl: string,
roomClosed: boolean,
showRecordingCard: boolean,
showShareLink: boolean,
showBackButton: boolean,
backButtonText: string,
participantForm: FormGroup,
lobbyState: LobbyState,
hostname: string,
canModerateRoom: boolean,
onFormSubmit: () => void,
onViewRecordings: () => void,
onBack: () => void,
onCopyLink: () => void
) {
const {
room,
roomId,
roomClosed,
showRecordingCard,
showBackButton,
backButtonText,
isE2EEEnabled,
participantForm
} = lobbyState;
const meetingUrl = `${hostname}/room/${roomId}`;
const showShareLink = !roomClosed && canModerateRoom;
return {
roomName,
roomName: room?.roomName || 'Room',
meetingUrl,
roomClosed,
showRecordingsCard: showRecordingCard,
showRecordingCard,
showShareLink,
showBackButton,
backButtonText,
isE2EEEnabled,
participantForm,
formSubmittedFn: onFormSubmit,
viewRecordingsClickedFn: onViewRecordings,
@ -177,7 +173,8 @@ export class MeetingPluginManagerService {
// Calculate if current moderator can revoke the moderator role from the target participant
// Only allow if target is not an original moderator
const canRevokeModeratorRole = currentUserIsModerator && !isCurrentUser && participantIsModerator && !participantIsOriginalModerator;
const canRevokeModeratorRole =
currentUserIsModerator && !isCurrentUser && participantIsModerator && !participantIsOriginalModerator;
// Calculate if current moderator can kick the target participant
// Only allow if target is not an original moderator

View File

@ -16,7 +16,8 @@ const DEFAULT_CONFIG: MeetRoomConfig = {
allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER
},
chat: { enabled: true },
virtualBackground: { enabled: true }
virtualBackground: { enabled: true },
e2ee: { enabled: false }
};
/**
@ -188,7 +189,8 @@ export class RoomWizardStateService {
isVisible: true,
formGroup: this.formBuilder.group({
chatEnabled: initialRoomOptions.config!.chat.enabled,
virtualBackgroundsEnabled: initialRoomOptions.config!.virtualBackground.enabled
virtualBackgroundsEnabled: initialRoomOptions.config!.virtualBackground.enabled,
e2eeEnabled: initialRoomOptions.config!.e2ee?.enabled ?? false
})
}
];
@ -259,9 +261,16 @@ export class RoomWizardStateService {
...currentOptions.config?.virtualBackground,
...stepData.config?.virtualBackground
},
e2ee: {
...currentOptions.config?.e2ee,
...stepData.config?.e2ee
},
recording: {
...currentOptions.config?.recording,
...stepData.config?.recording
// If recording is explicitly set in stepData, use it
...(stepData.config?.recording?.enabled !== undefined && {
enabled: stepData.config.recording.enabled
})
}
} as MeetRoomConfig
};

View File

@ -24,6 +24,22 @@
}
}
// Animations
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
margin: 0;
}
to {
opacity: 1;
transform: translateY(0);
max-height: 150px;
margin: var(--ov-meet-spacing-lg) 0 0 0;
}
}
@keyframes slideInFromTop {
from {
opacity: 0;

View File

@ -13,6 +13,7 @@
"test:e2e-core-events": "playwright test tests/e2e/core/events.test.ts",
"test:e2e-core-webhooks": "playwright test tests/e2e/core/webhooks.test.ts",
"test:e2e-ui-features": "playwright test tests/e2e/ui-feature-config.test.ts",
"test:e2e-e2ee-ui": "playwright test tests/e2e/e2ee-ui.test.ts",
"test:e2e-recording-access": "playwright test tests/e2e/recording-access.test.ts",
"lint": "eslint 'src/**/*.ts'"
},

View File

@ -15,7 +15,7 @@ import {
openParticipantsPanel,
prepareForJoiningRoom,
removeParticipantModerator,
waitForElementInIframe
waitForElementInIframe,
} from '../../helpers/function-helpers.js';
let subscribedToAppErrors = false;
@ -39,6 +39,16 @@ test.describe('Moderation Functionality Tests', () => {
roomId = await createTestRoom('moderation-test-room');
});
test.afterAll(async ({ browser }) => {
const tempContext = await browser.newContext();
const tempPage = await tempContext.newPage();
await deleteAllRooms(tempPage);
await deleteAllRecordings(tempPage);
await tempContext.close();
await tempPage.close();
});
test.beforeEach(async ({ page }) => {
if (!subscribedToAppErrors) {
page.on('console', (msg) => {

View File

@ -0,0 +1,412 @@
import { expect, test } from '@playwright/test';
import { MeetRecordingAccess } from '../../../../typings/src/room-config';
import { MEET_TESTAPP_URL } from '../config';
import {
closeMoreOptionsMenu,
countElementsInIframe,
createTestRoom,
deleteAllRecordings,
deleteAllRooms,
interactWithElementInIframe,
joinRoomAs,
leaveRoom,
openMoreOptionsMenu,
prepareForJoiningRoom,
updateRoomConfig,
waitForElementInIframe
} from '../helpers/function-helpers';
let subscribedToAppErrors = false;
test.describe('E2EE UI Tests', () => {
let roomId: string;
let participantName: string;
// ==========================================
// SETUP & TEARDOWN
// ==========================================
test.beforeAll(async () => {
// Create a test room before all tests
roomId = await createTestRoom('test-room-e2ee');
});
test.beforeEach(async ({ page }) => {
if (!subscribedToAppErrors) {
page.on('console', (msg) => {
const type = msg.type();
const tag = type === 'error' ? 'ERROR' : type === 'warning' ? 'WARNING' : 'LOG';
console.log('[' + tag + ']', msg.text());
});
subscribedToAppErrors = true;
}
participantName = `P-${Math.random().toString(36).substring(2, 9)}`;
});
test.afterAll(async ({ browser }) => {
const tempContext = await browser.newContext();
const tempPage = await tempContext.newPage();
await deleteAllRooms(tempPage);
await deleteAllRecordings(tempPage);
await tempContext.close();
await tempPage.close();
});
// ==========================================
// E2EE LOBBY UI TESTS
// ==========================================
test.describe('E2EE Lobby Elements', () => {
test('should show E2EE key input and badge in lobby when E2EE is enabled', async ({ page }) => {
// Enable E2EE
await updateRoomConfig(roomId, {
chat: { enabled: true },
recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
});
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
await page.click('#join-as-speaker');
const component = page.locator('openvidu-meet');
await expect(component).toBeVisible();
// Wait for participant name input
await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' });
// Check that E2EE badge is visible
const e2eeBadge = await waitForElementInIframe(page, '.encryption-badge', { state: 'visible' });
await expect(e2eeBadge).toBeVisible();
await expect(e2eeBadge).toContainText('end-to-end encrypted');
// Check that E2EE key input is visible
const e2eeKeyInput = await waitForElementInIframe(page, '#participant-e2eekey-input', {
state: 'visible'
});
await expect(e2eeKeyInput).toBeVisible();
// Check that the input has correct attributes
await expect(e2eeKeyInput).toHaveAttribute('type', 'password');
await expect(e2eeKeyInput).toHaveAttribute('required');
});
test('should hide E2EE elements in lobby when E2EE is disabled', async ({ page }) => {
// Disable E2EE
await updateRoomConfig(roomId, {
chat: { enabled: true },
recording: { enabled: true, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER },
virtualBackground: { enabled: true },
e2ee: { enabled: false }
});
await page.goto(MEET_TESTAPP_URL);
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
await page.click('#join-as-speaker');
const component = page.locator('openvidu-meet');
await expect(component).toBeVisible();
// Wait for participant name input
await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' });
// Check that E2EE badge is hidden
const e2eeBadge = await waitForElementInIframe(page, '.encryption-badge', { state: 'hidden' });
await expect(e2eeBadge).toBeHidden();
// Check that E2EE key input is hidden
const e2eeKeyInput = await waitForElementInIframe(page, '#participant-e2eekey-input', {
state: 'hidden'
});
await expect(e2eeKeyInput).toBeHidden();
});
});
// ==========================================
// E2EE SESSION TESTS
// ==========================================
test.describe('E2EE in Session', () => {
test.afterEach(async ({ page }) => {
try {
await leaveRoom(page);
} catch (error) {
// Ignore errors if already left
}
});
test('should allow participants to see and hear each other with correct E2EE key', async ({
page,
context
}) => {
// Enable E2EE
await updateRoomConfig(roomId, {
chat: { enabled: true },
recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
});
// Create a second page for participant 2
const page2 = await context.newPage();
// Participant 1 joins with E2EE key
await page.goto(MEET_TESTAPP_URL);
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
await page.click('#join-as-speaker');
await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' });
await interactWithElementInIframe(page, '#participant-name-input', {
action: 'fill',
value: participantName
});
// Fill E2EE key
const e2eeKey = 'test-encryption-key-123';
await interactWithElementInIframe(page, '#participant-e2eekey-input', {
action: 'fill',
value: e2eeKey
});
await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' });
// Wait for prejoin page and join
await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' });
await interactWithElementInIframe(page, '#join-button', { action: 'click' });
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
// Participant 2 joins with same E2EE key
const participant2Name = `P2-${Math.random().toString(36).substring(2, 9)}`;
await page2.goto(MEET_TESTAPP_URL);
await prepareForJoiningRoom(page2, MEET_TESTAPP_URL, roomId);
await page2.click('#join-as-speaker');
await waitForElementInIframe(page2, '#participant-name-input', { state: 'visible' });
await interactWithElementInIframe(page2, '#participant-name-input', {
action: 'fill',
value: participant2Name
});
// Fill same E2EE key
await interactWithElementInIframe(page2, '#participant-e2eekey-input', {
action: 'fill',
value: e2eeKey
});
await interactWithElementInIframe(page2, '#participant-name-submit', { action: 'click' });
// Wait for prejoin page and join
await waitForElementInIframe(page2, 'ov-pre-join', { state: 'visible' });
await interactWithElementInIframe(page2, '#join-button', { action: 'click' });
await waitForElementInIframe(page2, 'ov-session', { state: 'visible' });
// Wait a bit for media to flow
await page.waitForTimeout(2000);
// Check that both participants can see each other's video elements
const videoCount1 = await countElementsInIframe(page, '.OV_video-element');
expect(videoCount1).toBeGreaterThanOrEqual(2);
const videoCount2 = await countElementsInIframe(page2, '.OV_video-element');
expect(videoCount2).toBeGreaterThanOrEqual(2);
// Check that no encryption error poster is shown
const encryptionError1 = await waitForElementInIframe(page, '.encryption-error-poster', {
state: 'hidden'
});
await expect(encryptionError1).toBeHidden();
const encryptionError2 = await waitForElementInIframe(page2, '.encryption-error-poster', {
state: 'hidden'
});
await expect(encryptionError2).toBeHidden();
// Cleanup participant 2
await leaveRoom(page2);
await page2.close();
});
test('should show encryption error poster when using wrong E2EE key', async ({ page, context }) => {
// Enable E2EE
await updateRoomConfig(roomId, {
chat: { enabled: true },
recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
});
// Create a second page for participant 2
const page2 = await context.newPage();
// Participant 1 joins with E2EE key
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
await page.click('#join-as-speaker');
await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' });
await interactWithElementInIframe(page, '#participant-name-input', {
action: 'fill',
value: participantName
});
// Fill E2EE key
const e2eeKey1 = 'correct-key-abc';
await interactWithElementInIframe(page, '#participant-e2eekey-input', {
action: 'fill',
value: e2eeKey1
});
await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' });
// Wait for prejoin page and join
await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' });
await interactWithElementInIframe(page, '#join-button', { action: 'click' });
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
// Participant 2 joins with DIFFERENT E2EE key
const participant2Name = `P2-${Math.random().toString(36).substring(2, 9)}`;
await prepareForJoiningRoom(page2, MEET_TESTAPP_URL, roomId);
await page2.click('#join-as-speaker');
await waitForElementInIframe(page2, '#participant-name-input', { state: 'visible' });
await interactWithElementInIframe(page2, '#participant-name-input', {
action: 'fill',
value: participant2Name
});
// Fill DIFFERENT E2EE key
const e2eeKey2 = 'wrong-key-xyz';
await interactWithElementInIframe(page2, '#participant-e2eekey-input', {
action: 'fill',
value: e2eeKey2
});
await interactWithElementInIframe(page2, '#participant-name-submit', { action: 'click' });
// Wait for prejoin page and join
await waitForElementInIframe(page2, 'ov-pre-join', { state: 'visible' });
await interactWithElementInIframe(page2, '#join-button', { action: 'click' });
await waitForElementInIframe(page2, 'ov-session', { state: 'visible' });
// Wait for encryption error to be detected
await page.waitForTimeout(3000);
// Check that encryption error poster is shown on both sides
// Each participant should see an encryption error for the other's video
const videoPosterCount = await countElementsInIframe(page, '.encryption-error-poster');
//! FIXME: Temporarily expecting 2 posters due to audio and video streams (needs to be fixed in ov-components)
expect(videoPosterCount).toBe(2);
const videoPosterCount2 = await countElementsInIframe(page2, '.encryption-error-poster');
//! FIXME: Temporarily expecting 2 posters due to audio and video streams (needs to be fixed in ov-components)
expect(videoPosterCount2).toBe(2);
// Add additional participant with correct key to verify they can see/hear each other
const page3 = await context.newPage();
const participant3Name = `P3-${Math.random().toString(36).substring(2, 9)}`;
await prepareForJoiningRoom(page3, MEET_TESTAPP_URL, roomId);
await page3.click('#join-as-speaker');
await waitForElementInIframe(page3, '#participant-name-input', { state: 'visible' });
await interactWithElementInIframe(page3, '#participant-name-input', {
action: 'fill',
value: participant3Name
});
// Fill CORRECT E2EE key
await interactWithElementInIframe(page3, '#participant-e2eekey-input', {
action: 'fill',
value: e2eeKey1
});
await interactWithElementInIframe(page3, '#participant-name-submit', { action: 'click' });
// Wait for prejoin page and join
await waitForElementInIframe(page3, 'ov-pre-join', { state: 'visible' });
await interactWithElementInIframe(page3, '#join-button', { action: 'click' });
await waitForElementInIframe(page3, 'ov-session', { state: 'visible' });
// Wait a bit for media to flow
await page3.waitForTimeout(2000);
// Check that participant 3 can see participant 1's video
const videoCount3 = await countElementsInIframe(page3, '.OV_video-element');
expect(videoCount3).toBeGreaterThanOrEqual(2);
const videoPosterCount3 = await countElementsInIframe(page3, '.encryption-error-poster');
//! FIXME: Temporarily expecting 2 posters due to audio and video streams (needs to be fixed in ov-components)
expect(videoPosterCount3).toBe(2);
// Cleanup participant 2
await Promise.all([leaveRoom(page2), leaveRoom(page3)]);
await Promise.all([page2.close(), page3.close()]);
});
});
// ==========================================
// E2EE AND RECORDING INTERACTION TESTS
// ==========================================
test.describe('E2EE and Recording', () => {
test.afterEach(async ({ page }) => {
try {
await leaveRoom(page, 'moderator');
} catch (error) {
// Ignore errors if already left
}
});
test('should hide recording button when E2EE is enabled', async ({ page }) => {
// Enable E2EE (which should auto-disable recording)
await updateRoomConfig(roomId, {
chat: { enabled: true },
recording: { enabled: false, allowAccessTo: MeetRecordingAccess.ADMIN_MODERATOR_SPEAKER },
virtualBackground: { enabled: true },
e2ee: { enabled: true }
});
await page.goto(MEET_TESTAPP_URL);
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
// Join as moderator to access recording controls
await page.click('#join-as-moderator');
const component = page.locator('openvidu-meet');
await expect(component).toBeVisible();
// Fill participant name
await waitForElementInIframe(page, '#participant-name-input', { state: 'visible' });
await interactWithElementInIframe(page, '#participant-name-input', {
action: 'fill',
value: participantName
});
// Fill E2EE key
const e2eeKey = 'test-key-recording';
await interactWithElementInIframe(page, '#participant-e2eekey-input', {
action: 'fill',
value: e2eeKey
});
await interactWithElementInIframe(page, '#participant-name-submit', { action: 'click' });
// Wait for prejoin page and join
await waitForElementInIframe(page, 'ov-pre-join', { state: 'visible' });
await interactWithElementInIframe(page, '#join-button', { action: 'click' });
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
// Open more options menu
await openMoreOptionsMenu(page);
// Check that recording button is not visible
const recordingButton = await waitForElementInIframe(page, '#recording-btn', { state: 'hidden' });
await expect(recordingButton).toBeHidden();
await closeMoreOptionsMenu(page);
// Also check that recording activities panel is not available
const activitiesButton = await waitForElementInIframe(page, '#activities-panel-btn', { state: 'hidden' });
await expect(activitiesButton).toBeHidden();
});
});
});

View File

@ -58,6 +58,24 @@ export async function waitForElementInIframe(
return elementLocator;
}
export async function countElementsInIframe(
page: Page,
elementSelector: string,
options: {
componentSelector?: string;
iframeSelector?: string;
timeout?: number;
state?: 'attached' | 'visible';
} = {}
): Promise<number> {
const { componentSelector = 'openvidu-meet', iframeSelector = 'iframe' } = options;
const frameLocator = await getIframeInShadowDom(page, componentSelector, iframeSelector);
const elements = frameLocator.locator(elementSelector);
return await elements.count();
}
// Interact with an element inside an iframe within Shadow DOM
export async function interactWithElementInIframe(
page: Page,

View File

@ -5,6 +5,7 @@ export interface MeetRoomConfig {
chat: MeetChatConfig;
recording: MeetRecordingConfig;
virtualBackground: MeetVirtualBackgroundConfig;
e2ee?: MeetE2EEConfig;
// appearance: MeetAppearanceConfig;
}
@ -30,6 +31,10 @@ export interface MeetVirtualBackgroundConfig {
enabled: boolean;
}
export interface MeetE2EEConfig {
enabled: boolean;
}
export interface MeetAppearanceConfig {
themes: MeetRoomTheme[];
}

View File

@ -5,6 +5,7 @@
"description": "OpenVidu Meet Monorepo",
"scripts": {
"build": "pnpm run build:typings && pnpm run build:backend && pnpm run build:shared-components && pnpm run build:frontend && pnpm run build:webcomponent",
"clean": "pnpm --filter @openvidu-meet/backend run clean && pnpm --filter @openvidu-meet/frontend run clean",
"build:frontend": "pnpm --filter @openvidu-meet/frontend run build ${BASE_HREF:-/}",
"build:backend": "pnpm --filter @openvidu-meet/backend run build",
"build:webcomponent": "pnpm --filter openvidu-meet-webcomponent run build",

View File

@ -320,6 +320,51 @@
</div>
</div>
</div>
<!-- E2EE Config -->
<div class="accordion-item">
<h2 class="accordion-header">
<button
class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#e2eeCollapse"
aria-expanded="false"
aria-controls="e2eeCollapse"
data-testid="e2ee-config-toggle"
>
End-to-End Encryption (E2EE) Settings
</button>
</h2>
<div
id="e2eeCollapse"
class="accordion-collapse collapse"
data-bs-parent="#configAccordion"
>
<div class="accordion-body">
<div class="form-check">
<input
type="checkbox"
name="config.e2ee.enabled"
id="e2ee-enabled"
class="form-check-input"
data-testid="e2ee-enabled-checkbox"
/>
<label
for="e2ee-enabled"
class="form-check-label"
>
Enable End-to-End Encryption
</label>
</div>
<div class="alert alert-info mt-2" role="alert">
<small>
<strong>Note:</strong> When E2EE is enabled, recording will be automatically disabled for security reasons.
</small>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -170,6 +170,9 @@ const processFormConfig = (body: any): any => {
},
virtualBackground: {
enabled: body['config.virtualBackground.enabled'] === 'on'
},
e2ee: {
enabled: body['config.e2ee.enabled'] === 'on'
}
};