diff --git a/app/assets/locales/de.json b/app/assets/locales/de.json index db75245b42..9a2478c1c1 100644 --- a/app/assets/locales/de.json +++ b/app/assets/locales/de.json @@ -168,7 +168,8 @@ "default_tag_name": "Standard", "server_tag_desired": "Erwünscht", "server_tag_required": "Notwendig", - "are_you_sure_delete_room": "Diesen Raum wirklich löschen?" + "are_you_sure_delete_room": "Diesen Raum wirklich löschen?", + "generate_recordings_access_code": "Zugangscode für Aufzeichnungen generieren" } }, "recording": { @@ -194,7 +195,12 @@ "public_recordings_list_empty_description": "Hier werden Aufzeichnungen angezeigt,sobald sie verfügbar sind.", "delete_recording": "Aufzeichnung löschen", "are_you_sure_delete_recording": "Diese Aufzeichnung wirklich löschen?", - "search_not_found": "Keine Aufzeichnungen gefunden" + "search_not_found": "Keine Aufzeichnungen gefunden", + "access_code_required": "Zugangscode erforderlich", + "enter_access_code_description": "Bitte gebe den Zugangscode ein, um die öffentlichen Aufzeichnungen für diesen Raum anzusehen.", + "access_code_placeholder": "Zugangscode eingeben", + "submit_access_code": "Zugangscode senden", + "invalid_access_code": "Falscher Zugangscode" }, "admin": { "admin_panel": "Administration", diff --git a/app/assets/locales/en.json b/app/assets/locales/en.json index 8fc8bcd0e1..cbc335f13a 100644 --- a/app/assets/locales/en.json +++ b/app/assets/locales/en.json @@ -168,7 +168,8 @@ "default_tag_name": "Default", "server_tag_desired": "Desired", "server_tag_required": "Required", - "are_you_sure_delete_room": "Are you sure you want to delete this room?" + "are_you_sure_delete_room": "Are you sure you want to delete this room?", + "generate_recordings_access_code": "Generate access code for recordings" } }, "recording": { @@ -203,7 +204,12 @@ "public_recordings_list_empty_description": "Recordings will appear here when available.", "delete_recording": "Delete Recording", "are_you_sure_delete_recording": "Are you sure you want to delete this recording?", - "search_not_found": "No Recordings Found" + "search_not_found": "No Recordings Found", + "access_code_required": "Access Code Required", + "enter_access_code_description": "Please enter the access code to view the public recordings for this room.", + "access_code_placeholder": "Enter access code", + "submit_access_code": "Submit Access Code", + "invalid_access_code": "Invalid access code" }, "admin": { "admin_panel": "Administrator Panel", diff --git a/app/controllers/api/v1/room_settings_controller.rb b/app/controllers/api/v1/room_settings_controller.rb index c696de52b6..27a1e54fd9 100644 --- a/app/controllers/api/v1/room_settings_controller.rb +++ b/app/controllers/api/v1/room_settings_controller.rb @@ -45,16 +45,27 @@ def update return render_error status: :bad_request unless config_value - is_access_code = %w[glViewerAccessCode glModeratorAccessCode].include? name + is_access_code = %w[glViewerAccessCode glModeratorAccessCode glRecordingsAccessCode].include? name # Only allow the settings to update if the room config is default or optional / if it is an access_code regeneration unless %w[optional default_enabled].include?(config_value) || (config_value == 'true' && is_access_code && value != 'false') return render_error status: :forbidden end - value = infer_access_code(value:) if is_access_code # Handling access code update. - + # Handling access code update + value = infer_access_code(value:) if is_access_code + option = @room.get_setting(name:) + + # If the option doesn't exist, we create it for access codes + if option.nil? && is_access_code + option = RoomMeetingOption.create!( + room_id: @room.id, + meeting_option_id: MeetingOption.find_by(name: name).id, + value: value + ) + return render_data status: :ok + end return render_error status: :bad_request unless option&.update(value:) diff --git a/app/controllers/api/v1/rooms_controller.rb b/app/controllers/api/v1/rooms_controller.rb index d777f1334f..59d1b87ab7 100644 --- a/app/controllers/api/v1/rooms_controller.rb +++ b/app/controllers/api/v1/rooms_controller.rb @@ -140,11 +140,20 @@ def recordings # GET /api/v1/rooms/:friendly_id/public_recordings.json # Returns all of a specific room's PUBLIC recordings def public_recordings - sort_config = config_sorting(allowed_columns: %w[name length]) + # Get the recordings access code from the database if it exists + recordings_access_code = RoomMeetingOption.joins(:meeting_option) + .where(room_id: @room.id, meeting_options: { name: 'glRecordingsAccessCode' }) + .first&.value + + # If a recordings access code exists and the provided code doesn't match, require access code + if recordings_access_code.present? && params[:access_code] != recordings_access_code + return render_data data: [], meta: { requires_access_code: true }, status: :ok + end + sort_config = config_sorting(allowed_columns: %w[name length]) pagy, recordings = pagy(@room.public_recordings.order(sort_config, recorded_at: :desc).public_search(params[:search])) - render_data data: recordings, meta: pagy_metadata(pagy), serializer: PublicRecordingSerializer, status: :ok + render_data data: recordings, meta: pagy_metadata(pagy).merge(requires_access_code: false), serializer: PublicRecordingSerializer, status: :ok end # GET /api/v1/rooms/:friendly_id/recordings_processing.json diff --git a/app/javascript/components/rooms/room/join/JoinCard.jsx b/app/javascript/components/rooms/room/join/JoinCard.jsx index d54ae975d8..047ea0a12d 100644 --- a/app/javascript/components/rooms/room/join/JoinCard.jsx +++ b/app/javascript/components/rooms/room/join/JoinCard.jsx @@ -227,7 +227,7 @@ export default function JoinCard() {

{publicRoom?.data.name}

- { (recordValue !== 'false') && recordings?.data?.length > 0 && ( + { (recordValue !== 'false') && (recordings?.data?.length > 0 || recordings?.meta?.requires_access_code === true) && ( . + +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import { Form, Button, Stack } from 'react-bootstrap'; +import ButtonLink from '../../../shared_components/utilities/ButtonLink'; +import UserBoardIcon from '../../UserBoardIcon'; + +export default function AccessCodeForm({ onAccessCodeSubmit, error, friendlyId }) { + const { t } = useTranslation(); + const [accessCode, setAccessCode] = useState(''); + const [validated] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [emptyCodeError, setEmptyCodeError] = useState(false); + + const handleSubmit = (e) => { + e.preventDefault(); + + // Reset error states + setEmptyCodeError(false); + + // Check for empty access code first + if (!accessCode?.trim()) { + setEmptyCodeError(true); + return; + } + + setIsSubmitting(true); + + // Call the submit handler and reset submission state when complete + Promise.resolve(onAccessCodeSubmit(accessCode)) + .finally(() => { + setIsSubmitting(false); + }); + }; + + return ( +
+

{t('recording.access_code_required')}

+

{t('recording.enter_access_code_description')}

+ +
+ +
+ { + setAccessCode(e.target.value); + if (e.target.value.trim()) { + setEmptyCodeError(false); + } + }} + required + autoFocus + isInvalid={error || emptyCodeError} + className={`${(error || emptyCodeError) ? 'border-danger' : ''} text-center`} + /> + + {emptyCodeError && t('room.settings.access_code_required')} + {error && !emptyCodeError && t('recording.invalid_access_code')} + +
+
+ + + + {t('join_session')} + + + +
+
+ ); +} + +AccessCodeForm.propTypes = { + onAccessCodeSubmit: PropTypes.func.isRequired, + error: PropTypes.bool, + friendlyId: PropTypes.string.isRequired, +}; + +AccessCodeForm.defaultProps = { + error: false, +}; diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx index 922a142647..dfb3d37c37 100644 --- a/app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordings.jsx @@ -14,20 +14,56 @@ // You should have received a copy of the GNU Lesser General Public License along // with Greenlight; if not, see . -/* eslint-disable consistent-return */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Row } from 'react-bootstrap'; +import { useSearchParams } from 'react-router-dom'; import Logo from '../../../shared_components/Logo'; import PublicRecordingsCard from './PublicRecordingsCard'; -export default function RoomJoin() { +export default function PublicRecordings() { + const [searchParams, setSearchParams] = useSearchParams(); + const [accessCode, setAccessCode] = useState(''); + const [accessCodeError, setAccessCodeError] = useState(false); + + useEffect(() => { + // Check if there's an access code in the URL + const code = searchParams.get('access_code'); + if (code) { + setAccessCode(code); + } + }, [searchParams]); + + const handleAccessCodeSubmit = (code) => { + // Update state and reset any previous errors + setAccessCode(code); + setAccessCodeError(false); + + // Persist access code in URL for bookmarking and page refreshes + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set('access_code', code); + setSearchParams(newSearchParams); + + // Explicitly return a Promise for await in PublicRecordingsCard + return Promise.resolve(); + }; + + const handleAccessCodeError = () => { + setAccessCodeError(true); + // Error message remains until the user enters a correct code + }; + return (
- +
); diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx index ea570c0ee0..4efe82f2ed 100644 --- a/app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsCard.jsx @@ -15,19 +15,87 @@ // with Greenlight; if not, see . /* eslint-disable consistent-return */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; import Card from 'react-bootstrap/Card'; import { useParams } from 'react-router-dom'; import PublicRecordingsList from './PublicRecordingsList'; +import AccessCodeForm from './AccessCodeForm'; -export default function PublicRecordingsCard() { +export default function PublicRecordingsCard({ + accessCode, + onAccessCodeSubmit, + onAccessCodeError, + accessCodeError +}) { const { friendlyId } = useParams(); + const [requiresAccessCode, setRequiresAccessCode] = useState(false); + const [isValidating, setIsValidating] = useState(!!accessCode); + + const handleAccessCodeSubmit = async (code) => { + setIsValidating(true); + + try { + // Wait until the access code is submitted + await onAccessCodeSubmit(code); + + // Check the access code validity immediately after the API response + // This is more reliable than using a timeout + const checkAccessCodeValidity = () => { + if (requiresAccessCode) { + // If we still need the access code, the entered code was incorrect + onAccessCodeError(); + } + }; + + // Use requestAnimationFrame to ensure we check after the state has been updated + requestAnimationFrame(checkAccessCodeValidity); + } catch (error) { + // Keep only essential error logging + console.error('Error submitting access code:', error); + onAccessCodeError(); + } + }; + + // Reset requiresAccessCode when accessCode changes + useEffect(() => { + if (accessCode) { + setRequiresAccessCode(false); + setIsValidating(false); // Reset validation state when access code is provided + } + }, [accessCode]); return ( - - + + + + {requiresAccessCode || (isValidating && !accessCode) ? ( + + ) : ( + setRequiresAccessCode(true)} + /> + )} ); } + +PublicRecordingsCard.propTypes = { + accessCode: PropTypes.string, + onAccessCodeSubmit: PropTypes.func.isRequired, + onAccessCodeError: PropTypes.func.isRequired, + accessCodeError: PropTypes.bool, +}; + +PublicRecordingsCard.defaultProps = { + accessCode: '', + accessCodeError: false, +}; diff --git a/app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx index eac1767155..340f369316 100644 --- a/app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx +++ b/app/javascript/components/rooms/room/public_recordings/PublicRecordingsList.jsx @@ -14,10 +14,10 @@ // You should have received a copy of the GNU Lesser General Public License along // with Greenlight; if not, see . -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { VideoCameraIcon } from '@heroicons/react/24/outline'; -import { Card, Stack, Table } from 'react-bootstrap'; +import { Card, Stack, Table, Alert } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import SortBy from '../../../shared_components/search/SortBy'; import NoSearchResults from '../../../shared_components/search/NoSearchResults'; @@ -29,11 +29,32 @@ import PublicRecordingsRowPlaceHolder from './PublicRecordingsRowPlaceHolder'; import ButtonLink from '../../../shared_components/utilities/ButtonLink'; import UserBoardIcon from '../../UserBoardIcon'; -export default function PublicRecordingsList({ friendlyId }) { +export default function PublicRecordingsList({ friendlyId, accessCode, onRequiresAccessCode }) { const { t } = useTranslation(); const [page, setPage] = useState(1); const [searchInput, setSearchInput] = useState(''); - const { data: recordings, ...publicRecordingsAPI } = usePublicRecordings({ friendlyId, page, search: searchInput }); + const [showAccessCodeError, setShowAccessCodeError] = useState(false); + + const { data: recordings, ...publicRecordingsAPI } = usePublicRecordings({ + friendlyId, + page, + search: searchInput, + accessCode: accessCode || undefined + }); + + // Check if access code is required + useEffect(() => { + if (publicRecordingsAPI.isFetched && recordings?.meta?.requires_access_code) { + onRequiresAccessCode(); + // Only show error if we've attempted to submit an access code + if (accessCode !== '') { + setShowAccessCodeError(true); + // Access code is invalid + } + } else { + setShowAccessCodeError(false); + } + }, [recordings, publicRecordingsAPI.isFetched, onRequiresAccessCode, accessCode]); if (!publicRecordingsAPI.isLoading && recordings?.data?.length === 0 && !searchInput) { return ( @@ -70,6 +91,13 @@ export default function PublicRecordingsList({ friendlyId }) { {t('join_session')}
+ + {showAccessCodeError && ( + + {t('recording.invalid_access_code')} +

{t('recording.invalid_access_code_try_again')}

+
+ )} { (searchInput && recordings?.data.length === 0) ? ( @@ -123,4 +151,10 @@ export default function PublicRecordingsList({ friendlyId }) { PublicRecordingsList.propTypes = { friendlyId: PropTypes.string.isRequired, + accessCode: PropTypes.string, + onRequiresAccessCode: PropTypes.func.isRequired, +}; + +PublicRecordingsList.defaultProps = { + accessCode: '', }; diff --git a/app/javascript/components/rooms/room/room_settings/RoomSettings.jsx b/app/javascript/components/rooms/room/room_settings/RoomSettings.jsx index 9d8d9285f0..a736b1015f 100644 --- a/app/javascript/components/rooms/room/room_settings/RoomSettings.jsx +++ b/app/javascript/components/rooms/room/room_settings/RoomSettings.jsx @@ -71,6 +71,13 @@ export default function RoomSettings() { config={roomConfigs?.glModeratorAccessCode} description={t('room.settings.generate_mods_access_code')} /> + {serverTags && Object.keys(serverTags).length !== 0 && ( axios.get(`/rooms/${friendlyId}/public_recordings.json`, { params }).then((resp) => resp.data), + ['getPublicRecordings', friendlyId, { ...params }], + () => { + return axios.get(`/rooms/${friendlyId}/public_recordings.json`, { params }) + .then((resp) => { + return resp.data; + }) + .catch((error) => { + // Only keep console.error for important errors + console.error('usePublicRecordings: API error:', error); + throw error; + }); + }, { keepPreviousData: true, + retry: false, // Don't retry on 401/403 errors + staleTime: 60000, // Data remains fresh for 1 minute + cacheTime: 300000, // Cache data for 5 minutes + refetchOnWindowFocus: false, // Don't refetch when window regains focus + // Only refetch if access code changes to avoid unnecessary requests + refetchOnMount: accessCode ? true : 'stale', }, ); } diff --git a/db/migrate/20250514211900_add_recordings_access_code.rb b/db/migrate/20250514211900_add_recordings_access_code.rb new file mode 100644 index 0000000000..ac108b1126 --- /dev/null +++ b/db/migrate/20250514211900_add_recordings_access_code.rb @@ -0,0 +1,56 @@ +# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/. +# +# Copyright (c) 2022 BigBlueButton Inc. and by respective authors (see below). +# +# This program is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free Software +# Foundation; either version 3.0 of the License, or (at your option) any later +# version. +# +# Greenlight is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +# PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with Greenlight; if not, see . + +# frozen_string_literal: true + +class AddRecordingsAccessCode < ActiveRecord::Migration[7.0] + def up + # 1. Create the MeetingOption for glRecordingsAccessCode + meeting_option = MeetingOption.create!(name: 'glRecordingsAccessCode', default_value: '') + + # 2. Add the option to the RoomsConfigurations + provider_name = 'greenlight' + + # Check if the entry already exists + unless RoomsConfiguration.exists?(provider: provider_name, meeting_option_id: meeting_option.id) + RoomsConfiguration.create!( + provider: provider_name, + meeting_option_id: meeting_option.id, + value: 'optional' # optional means that the setting can be changed by the user + ) + end + end + + def down + # Find the MeetingOption for glRecordingsAccessCode + meeting_option = MeetingOption.find_by(name: 'glRecordingsAccessCode') + + if meeting_option + provider_name = 'greenlight' + + # Delete all room settings for this option + RoomMeetingOption.joins(:meeting_option) + .where(meeting_options: { name: 'glRecordingsAccessCode' }) + .destroy_all + + # Remove from RoomsConfigurations + RoomsConfiguration.where(provider: provider_name, meeting_option_id: meeting_option.id).destroy_all + + # Delete the MeetingOption + meeting_option.destroy + end + end +end