diff --git a/appinfo/routes.php b/appinfo/routes.php index eb31180..c5fb8e4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -4,6 +4,7 @@ return [ 'resources' => [ 'room' => ['url' => '/rooms'], 'roomShare' => ['url' => '/roomShares'], + 'restriction' => ['url' => '/restrictions'], ], 'routes' => [ ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], @@ -12,5 +13,6 @@ return [ ['name' => 'server#version', 'url' => '/server/version', 'verb' => 'GET'], ['name' => 'server#delete_record', 'url' => '/server/record/{recordId}', 'verb' => 'DELETE'], ['name' => 'join#index', 'url' => '/b/{token}', 'verb' => 'GET'], + ['name' => 'restriction#user', 'url' => '/restrictions/user', 'verb' => 'GET'], ] ]; diff --git a/lib/Controller/RestrictionController.php b/lib/Controller/RestrictionController.php new file mode 100644 index 0000000..a5090f2 --- /dev/null +++ b/lib/Controller/RestrictionController.php @@ -0,0 +1,113 @@ +service = $service; + $this->userManager = $userManager; + $this->groupManager = $groupManager; + $this->userId = $userId; + } + + /** + * @NoAdminRequired + */ + public function user(): DataResponse { + $user = $this->userManager->get($this->userId); + $groupIds = $this->groupManager->getUserGroupIds($user); + + return new DataResponse($this->service->findByGroupIds($groupIds)); + } + + public function index(): DataResponse { + $restrictions = $this->service->findAll(); + + if (!$this->service->existsByGroupId(Restriction::ALL_ID)) { + $defaultRestriction = new Restriction(); + $defaultRestriction->setGroupId(''); + + $restrictions[] = $defaultRestriction; + } + + return new DataResponse($restrictions); + } + + public function create( + string $groupId + ): DataResponse { + if ($this->service->existsByGroupId($groupId)) { + return new DataResponse(null, Http::STATUS_CONFLICT); + } + + return new DataResponse($this->service->create( + $groupId + )); + } + + public function update( + int $id, + string $groupId, + int $maxRooms, + array $roomTypes, + int $maxParticipants, + bool $allowRecording + ): DataResponse { + return $this->handleNotFound(function () use ( + $id, + $groupId, + $maxRooms, + $roomTypes, + $maxParticipants, + $allowRecording) { + return $this->service->update( + $id, + $groupId, + $maxRooms, + $roomTypes, + $maxParticipants, + $allowRecording + ); + }); + } + + public function destroy(int $id): DataResponse { + return $this->handleNotFound(function () use ($id) { + $roomShare = $this->service->find($id); + + return $this->service->delete($id); + }); + } +} diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 16e3770..c429421 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -4,6 +4,7 @@ namespace OCA\BigBlueButton\Controller; use OCA\BigBlueButton\Service\RoomService; use OCA\BigBlueButton\Permission; +use OCA\BigBlueButton\Db\Room; use OCP\IRequest; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; @@ -63,13 +64,34 @@ class RoomController extends Controller { string $name, string $welcome, int $maxParticipants, - bool $record + bool $record, + string $access ): DataResponse { + if (!$this->permission->isAllowedToCreateRoom($this->userId)) { + return new DataResponse(null, Http::STATUS_FORBIDDEN); + } + + $restriction = $this->permission->getRestriction($this->userId); + + if ($restriction->getMaxParticipants() > -1 && ($maxParticipants > $restriction->getMaxParticipants() || $maxParticipants <= 0)) { + return new DataResponse('Max participants limit exceeded.', Http::STATUS_BAD_REQUEST); + } + + if (!$restriction->getAllowRecording() && $record) { + return new DataResponse('Not allowed to enable recordings.', Http::STATUS_BAD_REQUEST); + } + + $disabledRoomTypes = \json_decode($restriction->getRoomTypes()); + if (in_array($access, $disabledRoomTypes) || !in_array($access, Room::ACCESS)) { + return new DataResponse('Access type not allowed.', Http::STATUS_BAD_REQUEST); + } + return new DataResponse($this->service->create( $name, $welcome, $maxParticipants, $record, + $access, $this->userId )); } @@ -92,6 +114,21 @@ class RoomController extends Controller { return new DataResponse(null, Http::STATUS_FORBIDDEN); } + $restriction = $this->permission->getRestriction($this->userId); + + if ($restriction->getMaxParticipants() > -1 && $maxParticipants !== $room->getMaxParticipants() && ($maxParticipants > $restriction->getMaxParticipants() || $maxParticipants <= 0)) { + return new DataResponse('Max participants limit exceeded.', Http::STATUS_BAD_REQUEST); + } + + if (!$restriction->getAllowRecording() && $record !== $room->getRecord()) { + return new DataResponse('Not allowed to enable recordings.', Http::STATUS_BAD_REQUEST); + } + + $disabledRoomTypes = \json_decode($restriction->getRoomTypes()); + if ((in_array($access, $disabledRoomTypes) && $access !== $room->getAccess()) || !in_array($access, Room::ACCESS)) { + return new DataResponse('Access type not allowed.', Http::STATUS_BAD_REQUEST); + } + return $this->handleNotFound(function () use ($id, $name, $welcome, $maxParticipants, $record, $everyoneIsModerator, $access) { return $this->service->update($id, $name, $welcome, $maxParticipants, $record, $access, $everyoneIsModerator); }); diff --git a/lib/Db/Restriction.php b/lib/Db/Restriction.php new file mode 100644 index 0000000..9b6cbd5 --- /dev/null +++ b/lib/Db/Restriction.php @@ -0,0 +1,45 @@ +addType('max_rooms', 'integer'); + $this->addType('max_participants', 'integer'); + $this->addType('allow_recording', 'boolean'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'groupId' => $this->groupId, + 'maxRooms' => (int) $this->maxRooms, + 'roomTypes' => \json_decode($this->roomTypes), + 'maxParticipants' => (int) $this->maxParticipants, + 'allowRecording' => boolval($this->allowRecording), + ]; + } +} diff --git a/lib/Db/RestrictionMapper.php b/lib/Db/RestrictionMapper.php new file mode 100644 index 0000000..41772a4 --- /dev/null +++ b/lib/Db/RestrictionMapper.php @@ -0,0 +1,69 @@ +db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + + /** + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws DoesNotExistException + */ + public function findByGroupId(string $groupId): Restriction { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->eq('group_id', $qb->createNamedParameter($groupId))); + + return $this->findEntity($qb); + } + + /** + * @return array + */ + public function findByGroupIds(array $groupIds): array { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName) + ->where($qb->expr()->in('group_id', $qb->createNamedParameter($groupIds, IQueryBuilder::PARAM_STR_ARRAY))); + + /** @var array */ + return $this->findEntities($qb); + } + + /** + * @return array + */ + public function findAll(): array { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->tableName); + + /** @var array */ + return $this->findEntities($qb); + } +} diff --git a/lib/Db/Room.php b/lib/Db/Room.php index f46e2dc..9e1a590 100644 --- a/lib/Db/Room.php +++ b/lib/Db/Room.php @@ -37,6 +37,8 @@ class Room extends Entity implements JsonSerializable { public const ACCESS_INTERNAL = 'internal'; public const ACCESS_INTERNAL_RESTRICTED = 'internal_restricted'; + public const ACCESS = [self::ACCESS_PUBLIC, self::ACCESS_PASSWORD, self::ACCESS_WAITING_ROOM, self::ACCESS_INTERNAL, self::ACCESS_INTERNAL_RESTRICTED]; + public $uid; public $name; public $attendeePassword; diff --git a/lib/Migration/Version000000Date20200826100844.php b/lib/Migration/Version000000Date20200826100844.php new file mode 100644 index 0000000..6c55580 --- /dev/null +++ b/lib/Migration/Version000000Date20200826100844.php @@ -0,0 +1,61 @@ +hasTable('bbb_restrictions')) { + $table = $schema->createTable('bbb_restrictions'); + $table->addColumn('id', 'integer', [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('group_id', 'string', [ + 'unique' => true, + 'notnull' => true, + 'length' => 200, + ]); + $table->addColumn('max_rooms', 'integer', [ + 'notnull' => false, + 'default' => -1, + ]); + $table->addColumn('room_types', 'string', [ + 'notnull' => true, + 'default' => '[]', + ]); + $table->addColumn('max_participants', 'integer', [ + 'notnull' => false, + 'default' => -1, + ]); + $table->addColumn('allow_recording', 'boolean', [ + 'notnull' => true, + 'default' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['group_id'], 'restrictions_group_id_index'); + } + + return $schema; + } +} diff --git a/lib/Permission.php b/lib/Permission.php index 4bb79e1..7091878 100644 --- a/lib/Permission.php +++ b/lib/Permission.php @@ -3,27 +3,60 @@ namespace OCA\BigBlueButton; use Closure; +use OCA\BigBlueButton\Service\RoomService; +use OCA\BigBlueButton\Service\RestrictionService; use OCA\BigBlueButton\Service\RoomShareService; use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Db\RoomShare; +use OCA\BigBlueButton\Db\Restriction; +use OCP\IUserManager; use OCP\IGroupManager; class Permission { + /** @var IUserManager */ + private $userManager; + /** @var IGroupManager */ private $groupManager; + /** @var RoomService */ + private $roomService; + + /** @var RestrictionService */ + private $restrictionService; + /** @var RoomShareService */ private $roomShareService; public function __construct( + IUserManager $userManager, IGroupManager $groupManager, + RoomService $roomService, + RestrictionService $restrictionService, RoomShareService $roomShareService ) { + $this->userManager = $userManager; $this->groupManager = $groupManager; + $this->roomService = $roomService; + $this->restrictionService = $restrictionService; $this->roomShareService = $roomShareService; } + public function getRestriction(string $uid): Restriction { + $user = $this->userManager->get($uid); + $groupIds = $this->groupManager->getUserGroupIds($user); + + return $this->restrictionService->findByGroupIds($groupIds); + } + + public function isAllowedToCreateRoom(string $uid) { + $numberOfCreatedRooms = count($this->roomService->findAll($uid, [])); + $restriction = $this->getRestriction($uid); + + return $restriction->getMaxRooms() < 0 || $restriction->getMaxRooms() > $numberOfCreatedRooms; + } + public function isUser(Room $room, ?string $uid) { return $this->hasPermission($room, $uid, function (RoomShare $share) { return $share->hasUserPermission(); diff --git a/lib/Service/RestrictionNotFound.php b/lib/Service/RestrictionNotFound.php new file mode 100644 index 0000000..816de06 --- /dev/null +++ b/lib/Service/RestrictionNotFound.php @@ -0,0 +1,6 @@ +mapper = $mapper; + } + + public function findAll(): array { + return $this->mapper->findAll(); + } + + public function existsByGroupId(string $groupId): bool { + try { + $this->mapper->findByGroupId($groupId); + + return true; + } catch (DoesNotExistException $e) { + return false; + } + } + + public function findByGroupIds(array $groupIds): Restriction { + $restrictions = $this->mapper->findByGroupIds($groupIds); + try { + $restriction = $this->mapper->findByGroupId(Restriction::ALL_ID); + } catch (DoesNotExistException $e) { + $restriction = new Restriction(); + } + + $roomTypes = \json_decode($restriction->getRoomTypes()); + + foreach ($restrictions as $r) { + if ($restriction->getMaxRooms() > -1 && ($r->getMaxRooms() === -1 || $restriction->getMaxRooms() < $r->getMaxRooms())) { + $restriction->setMaxRooms($r->getMaxRooms()); + } + + $rRoomTypes = \json_decode($r->getRoomTypes()); + if (count($rRoomTypes) > 0) { + $roomTypes = \array_merge($roomTypes, $rRoomTypes); + } + + if ($restriction->getMaxParticipants() > -1 && ($r->getMaxParticipants() === -1 || $restriction->getMaxParticipants() < $r->getMaxParticipants())) { + $restriction->setMaxParticipants($r->getMaxParticipants()); + } + + if (!$restriction->getAllowRecording() && $r->getAllowRecording()) { + $restriction->setAllowRecording($r->getAllowRecording()); + } + } + + $restriction->setId(0); + $restriction->setGroupId('__cumulative'); + $restriction->setRoomTypes(\json_encode(\array_values(\array_unique($roomTypes)))); + + return $restriction; + } + + public function find($id): Restriction { + try { + return $this->mapper->find($id); + } catch (Exception $e) { + $this->handleException($e); + } + } + + public function create(string $groupId): Restriction { + $restriction = new Restriction(); + + $restriction->setGroupId($groupId); + + return $this->mapper->insert($restriction); + } + + public function update(int $id, string $groupId, int $maxRooms, array $roomTypes, int $maxParticipants, bool $allowRecording): Restriction { + try { + $restriction = $this->mapper->find($id); + + $restriction->setGroupId($groupId); + $restriction->setMaxRooms(\max($maxRooms, -1)); + $restriction->setRoomTypes(\json_encode($roomTypes)); + $restriction->setMaxParticipants(\max($maxParticipants, -1)); + $restriction->setAllowRecording($allowRecording); + + return $this->mapper->update($restriction); + } catch (Exception $e) { + $this->handleException($e); + } + } + + public function delete(int $id): Restriction { + try { + $restriction = $this->mapper->find($id); + $this->mapper->delete($restriction); + + return $restriction; + } catch (Exception $e) { + $this->handleException($e); + } + } + + private function handleException(Exception $e): void { + if ($e instanceof DoesNotExistException || + $e instanceof MultipleObjectsReturnedException) { + throw new RestrictionNotFound($e->getMessage()); + } else { + throw $e; + } + } +} diff --git a/lib/Service/RoomService.php b/lib/Service/RoomService.php index f53404b..e1dbf9c 100644 --- a/lib/Service/RoomService.php +++ b/lib/Service/RoomService.php @@ -57,16 +57,17 @@ class RoomService { } } - public function create($name, $welcome, $maxParticipants, $record, $userId) { + public function create($name, $welcome, $maxParticipants, $record, $access, $userId) { $room = new Room(); $room->setUid(\OC::$server->getSecureRandom()->generate(16, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE)); $room->setName($name); $room->setWelcome($welcome); - $room->setMaxParticipants($maxParticipants); + $room->setMaxParticipants(\max($maxParticipants, 0)); $room->setAttendeePassword(\OC::$server->getSecureRandom()->generate(32, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE)); $room->setModeratorPassword(\OC::$server->getSecureRandom()->generate(32, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE)); $room->setRecord($record); + $room->setAccess($access); $room->setUserId($userId); return $this->mapper->insert($room); @@ -82,7 +83,7 @@ class RoomService { $room->setName($name); $room->setWelcome($welcome); - $room->setMaxParticipants($maxParticipants); + $room->setMaxParticipants(\max($maxParticipants, 0)); $room->setRecord($record); $room->setAccess($access); $room->setEveryoneIsModerator($everyoneIsModerator); diff --git a/templates/admin.php b/templates/admin.php index f3a28dd..1e77b70 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -3,6 +3,7 @@ /** @var $_ array */ script('bbb', 'admin'); +script('bbb', 'restrictions'); ?>
@@ -19,7 +20,11 @@ script('bbb', 'admin');

- > + />

+ +

Restrictions

+
+
diff --git a/tests/Unit/PermissionTest.php b/tests/Unit/PermissionTest.php index 922626b..1e0fb3d 100644 --- a/tests/Unit/PermissionTest.php +++ b/tests/Unit/PermissionTest.php @@ -4,9 +4,14 @@ namespace OCA\BigBlueButton\Tests; use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Db\RoomShare; +use OCA\BigBlueButton\Db\Restriction; use OCA\BigBlueButton\Permission; +use OCA\BigBlueButton\Service\RoomService; use OCA\BigBlueButton\Service\RoomShareService; +use OCA\BigBlueButton\Service\RestrictionService; +use OCP\IUserManager; use OCP\IGroupManager; +use OCP\IUser; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -14,27 +19,79 @@ class PermissionTest extends TestCase { /** @var Permission */ private $permission; + /** @var IUserManager|MockObject */ + private $userManager; + /** @var IGroupManager|MockObject */ private $groupManager; + /** @var RoomService|MockObject */ + private $roomService; + /** @var RoomShareService|MockObject */ private $roomShareService; + /** @var RestrictionService|MockObject */ + private $restrictionService; + public function setUp(): void { parent::setUp(); + /** @var IUserManager|MockObject */ + $this->userManager = $this->createMock(IUserManager::class); + /** @var IGroupManager|MockObject */ $this->groupManager = $this->createMock(IGroupManager::class); + /** @var RoomService|MockObject */ + $this->roomService = $this->createMock(RoomService::class); + + /** @var RestrictionService|MockObject */ + $this->restrictionService = $this->createMock(RestrictionService::class); + /** @var RoomShareService|MockObject */ $this->roomShareService = $this->createMock(RoomShareService::class); $this->permission = new Permission( + $this->userManager, $this->groupManager, + $this->roomService, + $this->restrictionService, $this->roomShareService ); } + public function testIsUserNotAllowed() { + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('foobar') + ->willReturn($this->createMock(IUser::class)); + + $this->groupManager + ->expects($this->once()) + ->method('getUserGroupIds') + ->willReturn([]); + + $this->roomService + ->expects($this->once()) + ->method('findAll') + ->willReturn([ + $this->createRoom(1, 'foo'), + $this->createRoom(2, 'bar'), + ]); + + $restriction = new Restriction(); + $restriction->setMaxRooms(2); + + $this->restrictionService + ->expects($this->once()) + ->method('findByGroupIds') + ->willReturn($restriction); + + $this->assertFalse($this->permission->isAllowedToCreateRoom('foobar')); + } + public function testIsUser() { $room = $this->createRoom(1, 'foo'); diff --git a/ts/Manager/Api.ts b/ts/Common/Api.ts similarity index 73% rename from ts/Manager/Api.ts rename to ts/Common/Api.ts index a1d359a..f67f8ea 100644 --- a/ts/Manager/Api.ts +++ b/ts/Common/Api.ts @@ -12,6 +12,15 @@ export enum Access { InternalRestricted = 'internal_restricted', } +export interface Restriction { + id: number; + groupId: string; + maxRooms: number; + roomTypes: string[]; + maxParticipants: number; + allowRecording: boolean; +} + export interface Room { id: number; uid: string; @@ -65,6 +74,46 @@ class Api { return OC.generateUrl(`apps/bbb/${endpoint}`); } + public async getRestriction(): Promise { + const response = await axios.get(this.getUrl('restrictions/user')); + + return response.data; + } + + public async getRestrictions(): Promise { + const response = await axios.get(this.getUrl('restrictions')); + + return response.data; + } + + public async createRestriction(groupId: string) { + const response = await axios.post(this.getUrl('restrictions'), { + groupId, + }); + + return response.data; + } + + public async updateRestriction(restriction: Restriction) { + if (!restriction.id) { + const newRestriction = await this.createRestriction( + restriction.groupId + ); + + restriction.id = newRestriction.id; + } + + const response = await axios.put(this.getUrl(`restrictions/${restriction.id}`), restriction); + + return response.data; + } + + public async deleteRestriction(id: number) { + const response = await axios.delete(this.getUrl(`restrictions/${id}`)); + + return response.data; + } + public getRoomUrl(room: Room) { return window.location.origin + api.getUrl(`b/${room.uid}`); } @@ -75,12 +124,13 @@ class Api { return response.data; } - public async createRoom(name: string) { + public async createRoom(name: string, access: Access = Access.Public, maxParticipants = 0) { const response = await axios.post(this.getUrl('rooms'), { name, welcome: '', - maxParticipants: 0, + maxParticipants, record: false, + access, }); return response.data; @@ -165,10 +215,11 @@ class Api { return response.data; } - public async getRecommendedShareWith(): Promise { + public async getRecommendedShareWith(shareType: ShareType[] = [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_GROUP]): Promise { const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees_recommended'; const response = await axios.get(url, { params: { + shareType, itemType: 'room', format: 'json', }, @@ -180,12 +231,12 @@ class Api { }; } - public async searchShareWith(search = ''): Promise { + public async searchShareWith(search = '', shareType: ShareType[] = [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_GROUP]): Promise { const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees'; const response = await axios.get(url, { params: { search, - shareType: [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_GROUP], + shareType, itemType: 'room', format: 'json', lookup: false, diff --git a/ts/Common/EditableSelection.tsx b/ts/Common/EditableSelection.tsx new file mode 100644 index 0000000..534b356 --- /dev/null +++ b/ts/Common/EditableSelection.tsx @@ -0,0 +1,49 @@ +import React, {useState} from 'react'; + +type Props = { + values: string[]; + setValue: (field: string, value: string[]) => Promise; + field: string; + options: {[key: string]: string}; + placeholder?: string; +} + +const EditableSelection: React.FC = ({ setValue, field, values: currentValues, options, placeholder }) => { + const [active, setActive] = useState(false); + + currentValues = currentValues || []; + + function onClick(ev) { + ev.stopPropagation(); + + setActive(!active); + } + + function addOption(optionKey: string, selected: boolean) { + if (selected) { + if (currentValues.indexOf(optionKey) < 0) { + setValue(field, [...currentValues, optionKey]); + } + } else { + setValue(field, currentValues.filter(value => value !== optionKey)); + } + } + + return (<> + {currentValues?.join(', ') || placeholder} + {active &&
    + {Object.keys(options).map(key => { + const label = options[key]; + + return ( +
  • + -1} value="1" onChange={(ev) => addOption(key, ev.target.checked)} /> + +
  • + ); + })} +
} + ); +}; + +export default EditableSelection; diff --git a/ts/Common/Translation.ts b/ts/Common/Translation.ts new file mode 100644 index 0000000..220c7d5 --- /dev/null +++ b/ts/Common/Translation.ts @@ -0,0 +1,17 @@ +import { Access } from './Api'; + +interface EscapeOptions { + escape?: boolean; +} + +export function bt(string: string, vars?: { [key: string]: string }, count?: number, options?: EscapeOptions): string { + return t('bbb', string, vars, count, options); +} + +export const AccessOptions = { + [Access.Public]: bt('Public'), + [Access.Password]: bt('Internal + Password protection for guests'), + [Access.WaitingRoom]: bt('Internal + Waiting room for guests'), + [Access.Internal]: bt('Internal'), + [Access.InternalRestricted]: bt('Internal restricted'), +}; diff --git a/ts/Manager/App.scss b/ts/Manager/App.scss index 3a9c12a..6fa52bf 100644 --- a/ts/Manager/App.scss +++ b/ts/Manager/App.scss @@ -19,6 +19,10 @@ border-radius: 50%; } +.text-muted { + opacity: 0.6; +} + #bbb-root { width: 100%; } @@ -54,6 +58,10 @@ width: 250px; } + h3 { + margin-top: 3em; + } + p { margin-bottom: 1em; } @@ -211,4 +219,4 @@ em { white-space: normal; } -} \ No newline at end of file +} diff --git a/ts/Manager/App.tsx b/ts/Manager/App.tsx index 4fda5af..e35fc1d 100644 --- a/ts/Manager/App.tsx +++ b/ts/Manager/App.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import './App.scss'; import RoomRow from './RoomRow'; import { SortArrow } from './SortArrow'; -import { api, Room } from './Api'; +import { api, Room, Restriction, Access } from '../Common/Api'; import NewRoomForm from './NewRoomForm'; export type SortKey = 'name' | 'welcome' | 'maxParticipants' | 'record'; @@ -37,16 +37,19 @@ type Props = { const App: React.FC = () => { const [areRoomsLoaded, setRoomsLoaded] = useState(false); const [error, setError] = useState(''); + const [restriction, setRestriction] = useState(); const [rooms, setRooms] = useState([]); const [orderBy, setOrderBy] = useState('name'); const [sortOrder, setSortOrder] = useState(SortOrder.ASC); - const rows = rooms.sort(sortRooms(orderBy, sortOrder)).map(room => ); + const rows = rooms.sort(sortRooms(orderBy, sortOrder)).map(room => ); useEffect(() => { - if (areRoomsLoaded) { - return; - } + api.getRestriction().then(restriction => { + setRestriction(restriction); + }).catch(err => { + console.warn('Could not load restriction', err); + }); api.getRooms().then(rooms => { setRooms(rooms); @@ -57,7 +60,7 @@ const App: React.FC = () => { }).then(() => { setRoomsLoaded(true); }); - }, [areRoomsLoaded]); + }, []); function onOrderBy(key: SortKey) { if (orderBy === key) { @@ -72,7 +75,16 @@ const App: React.FC = () => { return Promise.resolve(); } - return api.createRoom(name).then(room => { + let access = Access.Public; + + const disabledRoomTypes = restriction?.roomTypes || []; + if (disabledRoomTypes.length > 0 && disabledRoomTypes.indexOf(access) > -1) { + access = Object.values(Access).filter(a => disabledRoomTypes.indexOf(a) < 0)[0] as Access; + } + + const maxParticipants = restriction?.maxParticipants || 0; + + return api.createRoom(name, access, maxParticipants).then(room => { setRooms(rooms.concat([room])); }); } @@ -95,6 +107,8 @@ const App: React.FC = () => { }); } + const maxRooms = restriction?.maxRooms || 0; + return (
{ /* @TODO hide edit inputs */ }}> @@ -131,7 +145,12 @@ const App: React.FC = () => { {!areRoomsLoaded && } - + {(maxRooms > rows.length || maxRooms < 0) ? + : +

{maxRooms === 0 ? + t('bbb', 'You are not permitted to create a room.') : + t('bbb', 'You exceeded the maximum number of rooms.') + }

} @@ -141,4 +160,4 @@ const App: React.FC = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/ts/Manager/EditRoom.tsx b/ts/Manager/EditRoom.tsx index aa4548f..82e4efc 100644 --- a/ts/Manager/EditRoom.tsx +++ b/ts/Manager/EditRoom.tsx @@ -1,13 +1,14 @@ import React, { useState } from 'react'; -import { Room } from './Api'; +import { Room, Restriction } from '../Common/Api'; import EditRoomDialog from './EditRoomDialog'; type Props = { - room: Room; - updateProperty: (key: string, value: string | boolean | number) => Promise; + room: Room; + restriction?: Restriction; + updateProperty: (key: string, value: string | boolean | number) => Promise; } -const EditRoom: React.FC = ({ room, updateProperty }) => { +const EditRoom: React.FC = ({ room, restriction, updateProperty }) => { const [open, setOpen] = useState(false); return ( @@ -16,9 +17,9 @@ const EditRoom: React.FC = ({ room, updateProperty }) => { onClick={ev => { ev.preventDefault(), setOpen(true); }} title={t('bbb', 'Edit')} /> - + ); }; -export default EditRoom; \ No newline at end of file +export default EditRoom; diff --git a/ts/Manager/EditRoomDialog.tsx b/ts/Manager/EditRoomDialog.tsx index 2b74e50..8b679bb 100644 --- a/ts/Manager/EditRoomDialog.tsx +++ b/ts/Manager/EditRoomDialog.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; -import { Access, Room, Permission, RoomShare, api } from './Api'; +import { Access, Room, Permission, RoomShare, api, Restriction } from '../Common/Api'; import Dialog from './Dialog'; import ShareWith from './ShareWith'; import { SubmitInput } from './SubmitInput'; +import { AccessOptions } from '../Common/Translation'; const descriptions: { [key: string]: string } = { name: t('bbb', 'Descriptive name of this room.'), @@ -15,14 +16,18 @@ const descriptions: { [key: string]: string } = { type Props = { room: Room; + restriction?: Restriction; updateProperty: (key: string, value: string | boolean | number) => Promise; open: boolean; setOpen: (open: boolean) => void; } -const EditRoomDialog: React.FC = ({ room, updateProperty, open, setOpen }) => { +const EditRoomDialog: React.FC = ({ room, restriction, updateProperty, open, setOpen }) => { const [shares, setShares] = useState(); + const maxParticipantsLimit = (restriction?.maxParticipants || 0) < 0 ? undefined : restriction?.maxParticipants; + const minParticipantsLimit = (restriction?.maxParticipants || -1) < 1 ? 0 : 1; + useEffect(() => { if (!open) { return; @@ -45,7 +50,7 @@ const EditRoomDialog: React.FC = ({ room, updateProperty, open, setOpen }

{label}

- updateProperty(field, value)} /> + updateProperty(field, value)} min={minParticipantsLimit} max={maxParticipantsLimit} /> {descriptions[field] && {descriptions[field]}}
); @@ -71,19 +76,20 @@ const EditRoomDialog: React.FC = ({ room, updateProperty, open, setOpen } ); } + const accessOptions = {...AccessOptions}; + for(const roomType of restriction?.roomTypes || []) { + if (roomType !== room.access) { + delete accessOptions[roomType]; + } + } + return ( setOpen(false)} title={t('bbb', 'Edit "{room}"', { room: room.name })}> {inputElement(t('bbb', 'Name'), 'name')} {inputElement(t('bbb', 'Welcome'), 'welcome')} {inputElement(t('bbb', 'Participant limit'), 'maxParticipants', 'number')} - {selectElement(t('bbb', 'Access'), 'access', room.access, { - [Access.Public]: t('bbb', 'Public'), - [Access.Password]: t('bbb', 'Internal + Password protection for guests'), - [Access.WaitingRoom]: t('bbb', 'Internal + Waiting room for guests'), - [Access.Internal]: t('bbb', 'Internal'), - [Access.InternalRestricted]: t('bbb', 'Internal restricted'), - }, (value) => { + {selectElement(t('bbb', 'Access'), 'access', room.access, accessOptions, (value) => { console.log('access', value); updateProperty('access', value); })} @@ -117,6 +123,7 @@ const EditRoomDialog: React.FC = ({ room, updateProperty, open, setOpen } type="checkbox" className="checkbox" checked={room.record} + disabled={!restriction?.allowRecording} onChange={(event) => updateProperty('record', event.target.checked)} /> @@ -126,4 +133,4 @@ const EditRoomDialog: React.FC = ({ room, updateProperty, open, setOpen } ); }; -export default EditRoomDialog; \ No newline at end of file +export default EditRoomDialog; diff --git a/ts/Manager/EditableValue.tsx b/ts/Manager/EditableValue.tsx index 0bcbb5d..0032b6b 100644 --- a/ts/Manager/EditableValue.tsx +++ b/ts/Manager/EditableValue.tsx @@ -6,9 +6,14 @@ type EditableValueProps = { setValue: (key: string, value: string | number) => Promise; field: string; type: 'text' | 'number'; + options?: { + min?: number; + max?: number; + disabled?: boolean; + }; } -const EditableValue: React.FC = ({ setValue, field, value: currentValue, type }) => { +const EditableValue: React.FC = ({ setValue, field, value: currentValue, type, options }) => { const [active, setActive] = useState(false); const submit = (value: string | number) => { @@ -31,6 +36,8 @@ const EditableValue: React.FC = ({ setValue, field, value: c initialValue={currentValue} type={type} focus={true} + min={options?.min} + max={options?.max} />; } @@ -40,7 +47,11 @@ const EditableValue: React.FC = ({ setValue, field, value: c setActive(true); } + if (options?.disabled) { + return {currentValue}; + } + return {currentValue}; }; -export default EditableValue; \ No newline at end of file +export default EditableValue; diff --git a/ts/Manager/RecordingRow.tsx b/ts/Manager/RecordingRow.tsx index 6523a17..a07ced3 100644 --- a/ts/Manager/RecordingRow.tsx +++ b/ts/Manager/RecordingRow.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { CopyToClipboard } from 'react-copy-to-clipboard'; -import { Recording } from './Api'; +import { Recording } from '../Common/Api'; type Props = { recording: Recording; @@ -43,4 +43,4 @@ const RecordingRow: React.FC = ({recording, deleteRecording, storeRecordi ); }; -export default RecordingRow; \ No newline at end of file +export default RecordingRow; diff --git a/ts/Manager/RoomRow.tsx b/ts/Manager/RoomRow.tsx index bc36df7..9d77d72 100644 --- a/ts/Manager/RoomRow.tsx +++ b/ts/Manager/RoomRow.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useState } from 'react'; import { CopyToClipboard } from 'react-copy-to-clipboard'; -import { api, Recording, Room } from './Api'; +import { api, Recording, Room, Restriction } from '../Common/Api'; import EditRoom from './EditRoom'; import RecordingRow from './RecordingRow'; import EditableValue from './EditableValue'; type Props = { room: Room; + restriction?: Restriction; updateRoom: (room: Room) => Promise; deleteRoom: (id: number) => void; } @@ -154,8 +155,8 @@ const RoomRow: React.FC = (props) => { ); } - function edit(field: string, type: 'text' | 'number' = 'text') { - return ; + function edit(field: string, type: 'text' | 'number' = 'text', options?) { + return ; } const avatarUrl = OC.generateUrl('/avatar/' + encodeURIComponent(room.userId) + '/' + 24, { @@ -164,6 +165,9 @@ const RoomRow: React.FC = (props) => { requesttoken: OC.requestToken, }); + const maxParticipantsLimit = props.restriction?.maxParticipants || -1; + const minParticipantsLimit = (props.restriction?.maxParticipants || -1) < 1 ? 0 : 1; + return ( <> @@ -185,15 +189,15 @@ const RoomRow: React.FC = (props) => { {room.userId !== OC.currentUser && Avatar} - {edit('maxParticipants', 'number')} + {edit('maxParticipants', 'number', {min: minParticipantsLimit, max: maxParticipantsLimit < 0 ? undefined : maxParticipantsLimit})} - updateRoom('record', event.target.checked)} /> + updateRoom('record', event.target.checked)} /> - + = ({ room, permission, shares: allShares, setSh ); }; -export default ShareWith; \ No newline at end of file +export default ShareWith; diff --git a/ts/Manager/SubmitInput.tsx b/ts/Manager/SubmitInput.tsx index 868e5d9..16959e5 100644 --- a/ts/Manager/SubmitInput.tsx +++ b/ts/Manager/SubmitInput.tsx @@ -40,6 +40,8 @@ export class SubmitInput extends Component { onChange={event => this.setState({value: event.currentTarget.value})} onBlur={() => this.props.onSubmitValue(this.state.value)} autoFocus={this.props.focus} + min={this.props.min} + max={this.props.max} /> ; } diff --git a/ts/Manager/Nextcloud.d.ts b/ts/Nextcloud.d.ts similarity index 100% rename from ts/Manager/Nextcloud.d.ts rename to ts/Nextcloud.d.ts diff --git a/ts/Restrictions/App.tsx b/ts/Restrictions/App.tsx new file mode 100644 index 0000000..1f670d8 --- /dev/null +++ b/ts/Restrictions/App.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import '../Manager/App.scss'; +import { api, Restriction, ShareType } from '../Common/Api'; +import RestrictionRow from './RestrictionRow'; +import ShareSelection from './ShareSelection'; + +type Props = { + +} + +const App: React.FC = () => { + const [areRestrictionsLoaded, setRestrictionsLoaded] = useState(false); + const [error, setError] = useState(''); + const [restrictions, setRestrictions] = useState([]); + + const rows = restrictions.sort((a, b) => a.groupId.localeCompare(b.groupId)).map(restriction => ); + + useEffect(() => { + api.getRestrictions().then(restrictions => { + setRestrictions(restrictions); + }).catch((err) => { + console.warn('Could not load restrictions', err); + + setError(t('bbb', 'Server error')); + }).then(() => { + setRestrictionsLoaded(true); + }); + }, []); + + function addRestriction(groupId: string) { + return api.createRestriction(groupId).then(restriction => { + setRestrictions([...restrictions, restriction]); + }); + } + + function updateRestriction(restriction: Restriction) { + return api.updateRestriction(restriction).then(updatedRestriction => { + setRestrictions(restrictions.map(restriction => { + if (restriction.id === updatedRestriction.id || restriction.groupId === updatedRestriction.groupId) { + return updatedRestriction; + } + + return restriction; + })); + }); + } + + function deleteRestriction(id: number) { + api.deleteRestriction(id).then(deletedRestriction => { + setRestrictions(restrictions.filter(restriction => restriction.id !== deletedRestriction.id)); + }); + } + + return ( +
{ /* @TODO hide edit inputs */ }}> + + + + + + + + + + + + {rows} + + + + + + +
+ {t('bbb', 'Group name')} + + {t('bbb', 'Max. rooms')} + + {t('bbb', 'Forbidden room types')} + + {t('bbb', 'Max. participants')} + + {t('bbb', 'Recording')} + +
+ {!areRestrictionsLoaded + ? + : addRestriction(share.value.shareWith)} + shareType={[ShareType.Group]} + excluded={{groupIds: restrictions.map(restriction => restriction.groupId)}} /> } + {error && <> {error}} + +
+ +

{t('bbb', 'Restrictions do not affect existing rooms. Minus one means the value is unlimited. The least restrictive option is chosen for every user if multiple restrictions apply.')}

+
+ ); +}; + +export default App; diff --git a/ts/Restrictions/RestrictionRow.tsx b/ts/Restrictions/RestrictionRow.tsx new file mode 100644 index 0000000..98c300d --- /dev/null +++ b/ts/Restrictions/RestrictionRow.tsx @@ -0,0 +1,77 @@ +import React, { } from 'react'; +import { Restriction } from '../Common/Api'; +import EditableValue from '../Manager/EditableValue'; +import EditableSelection from '../Common/EditableSelection'; +import { AccessOptions } from '../Common/Translation'; + +type Props = { + restriction: Restriction; + updateRestriction: (restriction: Restriction) => Promise; + deleteRestriction: (id: number) => void; +} + + +const RestrictionRoom: React.FC = (props) => { + const restriction = props.restriction; + + function updateRestriction(key: string, value: string | boolean | number | string[]) { + return props.updateRestriction({ + ...props.restriction, + [key]: value, + }); + } + + function deleteRow(ev: MouseEvent) { + ev.preventDefault(); + + OC.dialogs.confirm( + t('bbb', 'Are you sure you want to delete the restrictions for group "{name}"? This operation can not be undone.', { name: restriction.groupId }), + t('bbb', 'Delete restrictions for "{name}"?', { name: restriction.groupId }), + confirmed => { + if (confirmed) { + props.deleteRestriction(restriction.id); + } + }, + true + ); + } + + function edit(field: string, type: 'text' | 'number' = 'text') { + return ; + } + + return ( + + {restriction.groupId || t('bbb', 'All users')} + + {edit('maxRooms', 'number')} + + + + + + + + {edit('maxParticipants', 'number')} + + + + updateRestriction('allowRecording', event.target.checked)} /> + + + + + {restriction.groupId &&
} + + + ); +}; + +export default RestrictionRoom; diff --git a/ts/Restrictions/ShareSelection.tsx b/ts/Restrictions/ShareSelection.tsx new file mode 100644 index 0000000..fec770b --- /dev/null +++ b/ts/Restrictions/ShareSelection.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect } from 'react'; +import { api, ShareWith, ShareType, ShareWithOption } from '../Common/Api'; +import './ShareWith.scss'; + +type Props = { + selectShare: (selection: ShareWithOption) => void; + shareType?: ShareType[]; + excluded?: { + groupIds?: string[]; + userIds?: string[]; + }; + placeholder?: string; +} + +const ShareSelection: React.FC = (props) => { + const [search, setSearch] = useState(''); + const [hasFocus, setFocus] = useState(false); + const [showSearchResults, setShowSearchResults] = useState(false); + const [recommendations, setRecommendations] = useState(); + const [searchResults, setSearchResults] = useState(); + + const shareType = props.shareType || [ShareType.User, ShareType.Group]; + const excluded = { + userIds: props.excluded?.userIds || [], + groupIds: props.excluded?.groupIds || [], + }; + const placeholder = props.placeholder || t('bbb', 'Name, group, ...'); + + useEffect(() => { + setSearchResults(undefined); + const searchQuery = search; + + if (!searchQuery) { + return; + } + + api.searchShareWith(searchQuery, shareType).then(result => { + if (searchQuery === search) { + setSearchResults(result); + } + }); + }, [search]); + + useEffect(() => { + api.getRecommendedShareWith(shareType).then(result => setRecommendations(result)); + }, []); + + useEffect(() => { + setTimeout(() => setShowSearchResults(hasFocus), 100); + }, [hasFocus]); + + async function selectShare(share: ShareWithOption) { + props.selectShare(share); + + setSearch(''); + } + + function renderSearchResults(options: ShareWith|undefined) { + const results = options ? [ + ...options.users.filter(user => !excluded.userIds.includes(user.value.shareWith)), + ...options.groups.filter(group => !excluded.groupIds.includes(group.value.shareWith)), + ] : []; + + const renderOption = (option: ShareWithOption) => { + return (
  • selectShare(option)}> + {option.label}{option.value.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''} +
  • ); + }; + + return ( +
      + {!options ? +
    • {t('bbb', 'Searching')}
    • : + ( + (results.length === 0 && search) ?
    • {t('bbb', 'No matches')}
    • : results.map(renderOption) + )} +
    + ); + } + + return ( +
    + setSearch(ev.currentTarget.value)} + onFocus={() => setFocus(true)} + onBlur={() => setFocus(false)} + placeholder={placeholder} /> + {showSearchResults && renderSearchResults((search && searchResults) ? searchResults : ((recommendations && !search) ? recommendations : undefined))} +
    + ); +}; + +export default ShareSelection; diff --git a/ts/Restrictions/ShareWith.scss b/ts/Restrictions/ShareWith.scss new file mode 100644 index 0000000..13eda7e --- /dev/null +++ b/ts/Restrictions/ShareWith.scss @@ -0,0 +1,82 @@ +.bbb-shareWith { + margin: 1em 0; + + &__item { + height: 44px; + display: flex; + align-items: center; + + &:hover { + background-color: var(--color-background-dark); + } + + .avatardiv { + height: 32px; + width: 32px; + overflow: hidden; + + .icon-group-white { + display: block; + height: 100%; + width: 100%; + background-color: #a9a9a9; + } + } + + .icon { + height: 44px; + width: 44px; + } + + &__label { + padding: 0 1em; + flex-grow: 1; + } + } +} + +.bbb-selection-container { + position: relative; + + input { + width: 100%; + } +} + +.bbb-selection { + position: absolute; + width: 100%; + background-color: #ffffff; + border: 1px solid var(--color-border-dark); + max-height: 88px; + overflow: auto; + box-shadow: 0 5px 10px -5px var(--color-box-shadow); + z-index: 100; + + li { + color: var(--color-text-lighter); + padding: 0 1em; + line-height: 44px; + + &.suggestion { + color: var(--color-main-text); + cursor: pointer; + + &:hover { + background-color: var(--color-background-hover); + } + } + } +} + +.bbb-form-shareWith { + margin-top: -1.5em; +} + +.bbb-icon-unselected { + opacity: 0.2 !important; + + &:hover { + opacity: 0.5 !important; + } +} diff --git a/ts/Restrictions/index.tsx b/ts/Restrictions/index.tsx new file mode 100644 index 0000000..add7f30 --- /dev/null +++ b/ts/Restrictions/index.tsx @@ -0,0 +1,12 @@ +'use strict'; + +import App from './App'; +import React from 'react'; +import ReactDom from 'react-dom'; + +// Enable React devtools +window['React'] = React; + +$(document).ready(() => { + ReactDom.render( , document.getElementById('bbb-restrictions')); +}); diff --git a/ts/admin.ts b/ts/admin.ts index 17826a1..43180ac 100644 --- a/ts/admin.ts +++ b/ts/admin.ts @@ -1,4 +1,4 @@ -import {api} from './Manager/Api'; +import {api} from './Common/Api'; import './Manager/App.scss'; declare const OCP: any; diff --git a/ts/filelist.ts b/ts/filelist.ts index 21b9d91..706ec40 100644 --- a/ts/filelist.ts +++ b/ts/filelist.ts @@ -1,6 +1,6 @@ import axios from '@nextcloud/axios'; import { generateOcsUrl, generateUrl } from '@nextcloud/router'; -import { Room } from './Manager/Api'; +import { Room } from './Common/Api'; declare const OCA: any; @@ -75,4 +75,4 @@ $(() => { return createResponse.data?.ocs?.data?.url; } -}); \ No newline at end of file +}); diff --git a/webpack.common.js b/webpack.common.js index d5a9a8d..b907c19 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -11,6 +11,9 @@ module.exports = { manager: [ path.join(__dirname, 'ts', 'Manager', 'index.tsx'), ], + restrictions: [ + path.join(__dirname, 'ts', 'Restrictions', 'index.tsx'), + ], join: [ path.join(__dirname, 'ts', 'join.ts'), ]