diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -10,6 +10,68 @@ class OpenViduController extends Controller { + /** + * Accepting the room join request. + * + * @param string $id Room identifier (name) + * @param string $reqid Request identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function acceptJoinRequest($id, $reqid) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->requestAccept($reqid)) { + return $this->errorResponse(500, \trans('meet.session-request-accept-error')); + } + + return response()->json(['status' => 'success']); + } + + /** + * Denying the room join request. + * + * @param string $id Room identifier (name) + * @param string $reqid Request identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function denyJoinRequest($id, $reqid) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->requestDeny($reqid)) { + return $this->errorResponse(500, \trans('meet.session-request-deny-error')); + } + + return response()->json(['status' => 'success']); + } + /** * Close the room session. * @@ -43,6 +105,37 @@ ]); } + /** + * Accepting the room join request. + * + * @param string $id Room identifier (name) + * @param string $conn Connection identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function dismissConnection($id, $conn) + { + $room = Room::where('name', $id)->first(); + + // This isn't a room, bye bye + if (!$room) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + + // Only the room owner can do it + if (!$user || $user->id != $room->user_id) { + return $this->errorResponse(403); + } + + if (!$room->closeOVConnection($conn)) { + return $this->errorResponse(500, \trans('meet.session-dismiss-connection-error')); + } + + return response()->json(['status' => 'success']); + } + /** * Listing of rooms that belong to the current user. * @@ -136,19 +229,53 @@ if (!$isOwner && strlen($password)) { $request_password = request()->input('password'); if ($request_password !== $password) { - // Note: We send the config to the client so it knows to display the password field - $response = [ - 'config' => $config, - 'message' => \trans('meet.session-password-error'), - 'status' => 'error', - ]; - - return response()->json($response, 425); + return $this->errorResponse(425, \trans('meet.session-password-error'), ['config' => $config]); + } + } + + // Handle locked room + if (!$isOwner && $config['locked']) { + $nickname = request()->input('nickname'); + $picture = request()->input('picture'); + $requestId = request()->input('requestId'); + + $request = $requestId ? $room->requestGet($requestId) : null; + + $error = \trans('meet.session-room-locked-error'); + + // Request already has been processed (not accepted yet, but it could be denied) + if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { + if (!$request) { + if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { + return $this->errorResponse(426, $error, ['config' => $config]); + } + + if (empty($picture)) { + $svg = file_get_contents(resource_path('images/user.svg')); + $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); + } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { + return $this->errorResponse(426, $error, ['config' => $config]); + } + + // TODO: Resize when big/make safe the user picture? + + $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; + + if (!$room->requestSave($requestId, $request)) { + // FIXME: should we use error code 500? + return $this->errorResponse(426, $error, ['config' => $config]); + } + + // Send the request (signal) to the owner + $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); + } + + return $this->errorResponse(427, $error, ['config' => $config]); } } // Create session token for the current user/connection - $response = $room->getSessionToken('PUBLISHER'); + $response = $room->getSessionToken($isOwner ? Room::ROLE_MODERATOR : Room::ROLE_PUBLISHER); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); @@ -156,7 +283,7 @@ // Create session token for screen sharing connection if (!empty(request()->input('screenShare'))) { - $add_token = $room->getSessionToken('PUBLISHER'); + $add_token = $room->getSessionToken(Room::ROLE_PUBLISHER); $response['shareToken'] = $add_token['token']; } diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -19,10 +19,11 @@ * * @param int $code Error code * @param string $message Error message + * @param array $data Additional response data * * @return \Illuminate\Http\JsonResponse */ - protected function errorResponse(int $code, string $message = null) + protected function errorResponse(int $code, string $message = null, array $data = []) { $errors = [ 400 => "Bad request", @@ -39,6 +40,10 @@ 'message' => $message ?: (isset($errors[$code]) ? $errors[$code] : "Server error"), ]; + if (!empty($data)) { + $response = $response + $data; + } + return response()->json($response, $code); } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -4,6 +4,7 @@ use App\Traits\SettingsTrait; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; /** * The eloquent definition of a Room. @@ -17,6 +18,13 @@ { use SettingsTrait; + public const ROLE_MODERATOR = 'MODERATOR'; + public const ROLE_PUBLISHER = 'PUBLISHER'; + public const ROLE_SUBSCRIBER = 'SUBSCRIBER'; + + public const REQUEST_ACCEPTED = 'accepted'; + public const REQUEST_DENIED = 'denied'; + protected $fillable = [ 'user_id', 'name' @@ -52,6 +60,54 @@ return self::$client; } + /** + * Destroy a OpenVidu connection + * + * @param string $conn Connection identifier + * + * @return bool True on success, False otherwise + * @throws \Exception if session does not exist + */ + public function closeOVConnection($conn): bool + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); + + $response = $this->client()->request('DELETE', $url); + + return $response->getStatusCode() == 204; + } + + /** + * Fetch a OpenVidu connection information. + * + * @param string $conn Connection identifier + * + * @return ?array Connection data on success, Null otherwise + * @throws \Exception if session does not exist + */ + public function getOVConnection($conn): ?array + { + // Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists + // TODO: Maybe use some other name? getParticipant? + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); + + $response = $this->client()->request('GET', $url); + + if ($response->getStatusCode() == 200) { + return json_decode($response->getBody(), true); + } + + return null; + } + /** * Create a OpenVidu session * @@ -73,6 +129,7 @@ if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); + return null; } $session = json_decode($response->getBody(), true); @@ -114,7 +171,7 @@ * * @return array|null Token data on success, NULL otherwise */ - public function getSessionToken($role = 'PUBLISHER'): ?array + public function getSessionToken($role = self::ROLE_PUBLISHER): ?array { $response = $this->client()->request( 'POST', @@ -162,6 +219,75 @@ return $this->belongsTo('\App\User', 'user_id', 'id'); } + /** + * Accept the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestAccept(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_ACCEPTED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Deny the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestDeny(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_DENIED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Get the join request data. + * + * @param string $id Request identifier + * + * @return array|null Request data (e.g. nickname, status, picture?) + */ + public function requestGet(string $id): ?array + { + return Cache::get($this->session_id . '-' . $id); + } + + /** + * Save the join request. + * + * @param string $id Request identifier + * @param array $request Request data + * + * @return bool True on success, False on failure + */ + public function requestSave(string $id, array $request): bool + { + // We don't really need the picture in the cache + // As we use this cache for the request status only + unset($request['picture']); + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + /** * Any (additional) properties of this room. * @@ -171,4 +297,65 @@ { return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); } + + /** + * Send a OpenVidu signal to the session participants (connections) + * + * @param string $name Signal name (type) + * @param array $data Signal data array + * @param array|string $target List of target connections, Null for all connections. + * It can be also a participant role. + * + * @return bool True on success, False on failure + * @throws \Exception if session does not exist + */ + public function signal(string $name, array $data = [], $target = null): bool + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $post = [ + 'session' => $this->session_id, + 'type' => $name, + 'data' => $data ? json_encode($data) : '', + ]; + + // Get connection IDs by participant role + if (is_string($target)) { + // TODO: We should probably store this in our database/redis. I foresee a use-case + // for such a connections store on our side, e.g. keeping participant + // metadata, e.g. selected language, extra roles like a "language interpreter", etc. + + $response = $this->client()->request('GET', 'sessions/' . $this->session_id); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $json = json_decode($response->getBody(), true); + $connections = []; + + foreach ($json['connections']['content'] as $connection) { + if ($connection['role'] === $target) { + $connections[] = $connection['id']; + break; + } + } + + if (empty($connections)) { + return false; + } + + $target = $connections; + } + + if (!empty($target)) { + $post['to'] = $target; + } + + $response = $this->client()->request('POST', 'signal', ['json' => $post]); + + return $response->getStatusCode() == 200; + } } diff --git a/src/resources/images/user.svg b/src/resources/images/user.svg new file mode 100644 --- /dev/null +++ b/src/resources/images/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -14,6 +14,7 @@ let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata + let role // Current user role let screenOV // OpenVidu object to initialize a screen sharing session let screenSession // Session object where the user will connect for screen sharing @@ -70,7 +71,7 @@ * Join the room session * * @param data Session metadata and event handlers (session, token, shareToken, nickname, - * chatElement, menuElement, onDestroy) + * chatElement, menuElement, onDestroy, onJoinRequest) */ function joinRoom(data) { resize(); @@ -90,6 +91,7 @@ session.on('connectionCreated', event => { // Ignore the current user connection if (event.connection.role) { + role = event.connection.role return } @@ -105,6 +107,7 @@ let connectionId = event.connection.connectionId let metadata = JSON.parse(event.connection.data) + metadata.connId = connectionId let wrapper = videoWrapperCreate(container, metadata) connections[connectionId] = { @@ -414,7 +417,7 @@ */ function signalEventHandler(signal) { let conn, data - let connId = signal.from.connectionId + let connId = signal.from ? signal.from.connectionId : null switch (signal.type) { case 'signal:userChanged': @@ -431,6 +434,12 @@ data.id = connId pushChatMessage(data) break + + case 'signal:joinRequest': + if (sessionData.onJoinRequest) { + sessionData.onJoinRequest(JSON.parse(signal.data)) + } + break; } } @@ -645,26 +654,31 @@ function videoWrapperCreate(container, params) { // Create the element let wrapper = $('
').html( - `${svgIcon("user", 'fas', 'watermark')} -
- - -
-
- - - -
-
- - -
` + svgIcon('user', 'fas', 'watermark') + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' ) if (params.publisher) { // Add events for nickname change let nickname = wrapper.addClass('publisher').find('.nickname') - let editable = nickname.find('span').get(0) + let editable = nickname.find('.content')[0] let editableEnable = () => { editable.contentEditable = true editable.focus() @@ -688,14 +702,24 @@ } }) } else { - wrapper.find('.nickname > svg').addClass('hidden') - wrapper.find('.link-audio').removeClass('hidden') .on('click', e => { let video = wrapper.find('video')[0] video.muted = !video.muted wrapper.find('.link-audio')[video.muted ? 'addClass' : 'removeClass']('text-danger') }) + + if (role == 'MODERATOR') { + wrapper.addClass('moderated') + + wrapper.find('.nickname').attr({title: 'Options', 'data-toggle': 'dropdown'}).dropdown() + + wrapper.find('.action-dismiss').on('click', e => { + if (sessionData.onDismiss) { + sessionData.onDismiss(params.connId) + } + }) + } } videoWrapperUpdate(wrapper, params) @@ -741,7 +765,7 @@ } if ('nickname' in params) { - $(wrapper).find('.nickname > span').text(params.nickname) + $(wrapper).find('.nickname > .content').text(params.nickname) } } diff --git a/src/resources/lang/en/meet.php b/src/resources/lang/en/meet.php --- a/src/resources/lang/en/meet.php +++ b/src/resources/lang/en/meet.php @@ -21,6 +21,9 @@ 'session-join-error' => 'Failed to join the session.', 'session-close-error' => 'Failed to close the session.', 'session-close-success' => 'The session has been closed successfully.', + 'session-dismiss-connection-error' => 'Failed to dismiss the connection.', 'session-password-error' => 'Failed to join the session. Invalid password.', - + 'session-request-accept-error' => 'Failed to accept the join request.', + 'session-request-deny-error' => 'Failed to deny the join request.', + 'session-room-locked-error' => 'Failed to join the session. Room locked.', ]; diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss --- a/src/resources/themes/meet.scss +++ b/src/resources/themes/meet.scss @@ -6,12 +6,6 @@ align-items: center; justify-content: center; - .watermark { - color: darken($menu-bg-color, 20%); - width: 50%; - height: 50%; - } - video { // To make object-fit:cover working we have to set the height in pixels // on the wrapper element. This is what javascript method will do. @@ -33,6 +27,19 @@ } } + .watermark { + color: darken($menu-bg-color, 20%); + width: 50%; + height: 50%; + } + + .dropdown { + position: absolute; + top: 0; + left: 0; + right: 0; + } + .controls { position: absolute; bottom: 0; @@ -78,41 +85,53 @@ border-radius: 1em; max-width: calc(100% - 1em); background: rgba(#fff, 0.8); + color: $body-color; + text-decoration: none !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + display: flex; - button { + .icon { display: none; + width: 2em; + margin: 0 -1em; } - span { + .content { outline: none; } } + &.moderated .nickname { + display: flex; + + .content { + order: 1; + + &:not(:empty) + .icon { + margin-right: 0; + } + } + + .icon { + display: inline-block; + } + } + &.publisher .nickname { - cursor: pointer; background: rgba($main-color, 0.9); &:focus-within { box-shadow: $btn-focus-box-shadow; } - span:empty { + .content:empty { display: block; height: 2em; - &:not(:focus) + button { - display: block; - position: absolute; - top: 0; - left: 0; - width: 2em; - height: 2em; - border-radius: 50%; - padding: 0; - color: $menu-gray; + &:not(:focus) + .icon { + display: inline-block; } } } @@ -253,3 +272,29 @@ } } } + +.toast.join-request { + .toast-header { + color: #eee; + } + + .toast-body { + display: flex; + } + + .picture { + margin-right: 1em; + + img { + width: 64px; + height: 64px; + border: 1px solid #555; + border-radius: 50%; + object-fit: cover; + } + } + + .content { + flex: 1; + } +} diff --git a/src/resources/themes/toast.scss b/src/resources/themes/toast.scss --- a/src/resources/themes/toast.scss +++ b/src/resources/themes/toast.scss @@ -4,6 +4,10 @@ right: 0; margin: 0.5rem; width: 320px; + max-height: calc(100% - 1rem); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(52, 58, 64, 0.95) transparent; z-index: 1055; // above Bootstrap's modal backdrop and dialogs @media (max-width: 375px) { diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -70,16 +70,15 @@
- - + :disabled="roomState == 'init' || roomState == 427 || roomState == 404" + :class="'btn w-100 btn-' + (isRoomReady() ? 'success' : 'primary')" + > + JOIN NOW + I'm the owner + JOIN +
@@ -157,6 +156,8 @@ 423: 'The room is closed. Please, wait for the owner to start the session.', 424: 'The room is closed. It will be open for others after you join.', 425: 'The room is ready. Please, provide a valid password.', + 426: 'The room is locked. Please, enter your name and try again.', + 427: 'Waiting for permission to join the room.', 500: 'Failed to create a session. Server error.' }, session: {} @@ -184,8 +185,7 @@ }, methods: { authSuccess() { - // The user (owner) authentication succeeded - this.roomState = 'init' + // The user authentication succeeded, we still don't know it's really the room owner this.initSession() $('#meet-setup').removeClass('hidden') @@ -194,15 +194,20 @@ configUpdate(config) { this.session.config = Object.assign({}, this.session.config, config) }, + dismissParticipant(id) { + axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections/' + id + '/dismiss') + }, initSession(init) { this.post = { password: this.password, nickname: this.nickname, screenShare: this.canShareScreen ? 1 : 0, - init: init ? 1 : 0 + init: init ? 1 : 0, + picture: init ? this.makePicture() : '', + requestId: this.requestId() } - $('#setup-password').removeClass('is-invalid') + $('#setup-password,#setup-nickname').removeClass('is-invalid') axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true }) .then(response => { @@ -234,6 +239,19 @@ $('#setup-password').addClass('is-invalid').focus() } break; + + case '426': + // Locked room prerequisites error + if (init && !$('#setup-nickname').val()) { + $('#setup-nickname').addClass('is-invalid').focus() + } + break; + + case '427': + // Waiting for the owner's approval to join + // Update room state every 10 seconds + window.roomRequest = setTimeout(() => { this.initSession(true) }, 10000) + break; } }) @@ -241,6 +259,55 @@ $('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden') } }, + isRoomReady() { + return ['ready', '424', '425', '426', '427'].includes(this.roomState) + }, + // An event received by the room owner when a participant is asking for a permission to join the room + joinRequest(data) { + // The toast for this user request already exists, ignore + // It's not really needed as we do this on server-side already + if ($('#i' + data.requestId).length) { + return + } + + // FIXME: Should the message close button act as the Deny button? Do we need the Deny button? + + let body = $( + `
` + + `
` + + `
` + + `

` + + `
` + + `` + + `` + ) + + this.$toast.message({ + className: 'join-request', + icon: 'user', + timeout: 0, + title: 'Join request', + // titleClassName: '', + body: body.html(), + onShow: element => { + const id = data.requestId + + $(element).find('p').text((data.nickname || '') + ' requested to join.') + + // add id attribute, so we can identify it + $(element).attr('id', 'i' + id) + // add action to the buttons + .find('button.accept,button.deny').on('click', e => { + const action = $(e.target).is('.accept') ? 'accept' : 'deny' + axios.post('/api/v4/openvidu/rooms/' + this.room + '/request/' + id + '/' + action) + .then(response => { + $('#i' + id).remove() + }) + }) + } + }) + }, + // Entering the room joinSession() { if (this.roomState == 423) { $('#meet-setup').addClass('hidden') @@ -248,7 +315,7 @@ return } - if (this.roomState == 424 || this.roomState == 425) { + if (this.roomState != 'ready') { this.initSession(true) return } @@ -267,9 +334,9 @@ this.session.menuElement = $('#meet-session-menu')[0] this.session.chatElement = $('#meet-chat')[0] this.session.onDestroy = event => { - // TODO: Handle nicely other reasons: disconnect, forceDisconnectByUser, - // forceDisconnectByServer, networkDisconnect? - if (event.reason == 'sessionClosedByServer' && !this.session.owner) { + // TODO: Display different message for each reason: forceDisconnectByUser, + // forceDisconnectByServer, sessionClosedByServer? + if (event.reason != 'disconnect' && event.reason != 'networkDisconnect' && !this.session.owner) { $('#leave-dialog').on('hide.bs.modal', () => { // FIXME: Where exactly the user should land? Currently he'll land // on dashboard (if he's logged in) or login form (if he's not). @@ -279,21 +346,72 @@ } } + this.session.onDismiss = connId => { this.dismissParticipant(connId) } + + if (this.session.owner) { + this.session.onJoinRequest = data => { this.joinRequest(data) } + } + this.meet.joinRoom(this.session) }, logout() { - if (this.session.owner) { - axios.post('/api/v4/openvidu/rooms/' + this.room + '/close') - .then(response => { - this.meet.leaveRoom() - this.meet = null - window.location = window.config['app.url'] - }) - } else { + const logout = () => { this.meet.leaveRoom() this.meet = null window.location = window.config['app.url'] } + + if (this.session.owner) { + axios.post('/api/v4/openvidu/rooms/' + this.room + '/close').then(logout) + } else { + logout() + } + }, + makePicture() { + const video = $("#setup-preview video")[0]; + + // Skip if video is not "playing" + if (!video.videoWidth || !this.camera) { + return '' + } + + // we're going to crop a square from the video and resize it + const maxSize = 64 + + // Calculate sizing + let sh = Math.floor(video.videoHeight / 1.5) + let sw = sh + let sx = (video.videoWidth - sw) / 2 + let sy = (video.videoHeight - sh) / 2 + + let dh = Math.min(sh, maxSize) + let dw = sh < maxSize ? sw : Math.floor(sw * dh/sh) + + const canvas = $("")[0]; + canvas.width = dw; + canvas.height = dh; + + // draw the image on the canvas (square cropped and resized) + canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, dw, dh); + + // convert it to a usable data URL (png format) + return canvas.toDataURL(); + }, + requestId() { + if (!this.reqId) { + // FIXME: Shall we use some UUID generator? Or better something that identifies the + // user/browser so we could deny the join request for a longer time. + // I'm thinking about e.g. a bad actor knocking again and again and again, + // we don't want the room owner to be bothered every few seconds. + // Maybe a solution would be to store the identifier in the browser storage + // This would not prevent hackers from sending the new identifier on every request, + // but could make sure that it is kept after page refresh for the avg user. + + // This will create max. 24-char numeric string + this.reqId = (String(Date.now()) + String(Math.random()).substring(2)).substring(0, 24) + } + + return this.reqId }, securityOptions() { $('#security-options-dialog').modal() diff --git a/src/resources/vue/Meet/SessionSecurityOptions.vue b/src/resources/vue/Meet/SessionSecurityOptions.vue --- a/src/resources/vue/Meet/SessionSecurityOptions.vue +++ b/src/resources/vue/Meet/SessionSecurityOptions.vue @@ -27,11 +27,11 @@ the password before they are allowed to join the meeting. -
-
-
- Locked room: - +
+ +
+ +
When the room is locked participants have to be approved by you diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue --- a/src/resources/vue/Widgets/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -37,7 +37,7 @@