Merge pull request #67 from sualko/feat-restrictions

feat: add admin setting to restrict rooms
pull/75/head
Klaus 2020-08-29 13:16:33 +02:00 committed by GitHub
commit 2c5ff0dbb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1189 additions and 159 deletions

View File

@ -4,6 +4,7 @@ return [
'resources' => [ 'resources' => [
'room' => ['url' => '/rooms'], 'room' => ['url' => '/rooms'],
'roomShare' => ['url' => '/roomShares'], 'roomShare' => ['url' => '/roomShares'],
'restriction' => ['url' => '/restrictions'],
], ],
'routes' => [ 'routes' => [
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'], ['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
@ -12,5 +13,6 @@ return [
['name' => 'server#version', 'url' => '/server/version', 'verb' => 'GET'], ['name' => 'server#version', 'url' => '/server/version', 'verb' => 'GET'],
['name' => 'server#delete_record', 'url' => '/server/record/{recordId}', 'verb' => 'DELETE'], ['name' => 'server#delete_record', 'url' => '/server/record/{recordId}', 'verb' => 'DELETE'],
['name' => 'join#index', 'url' => '/b/{token}', 'verb' => 'GET'], ['name' => 'join#index', 'url' => '/b/{token}', 'verb' => 'GET'],
['name' => 'restriction#user', 'url' => '/restrictions/user', 'verb' => 'GET'],
] ]
]; ];

View File

@ -123,7 +123,6 @@ class JoinController extends Controller {
'room' => $room->name, 'room' => $room->name,
'url' => $joinUrl, 'url' => $joinUrl,
], 'guest'); ], 'guest');
;
} }
private function getRoom(): ?Room { private function getRoom(): ?Room {

View File

@ -0,0 +1,113 @@
<?php
namespace OCA\BigBlueButton\Controller;
use OCA\BigBlueButton\Db\Restriction;
use OCP\IRequest;
use OCP\IGroupManager;
use OCP\IUserManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Controller;
use OCA\BigBlueButton\Service\RestrictionService;
class RestrictionController extends Controller {
/** @var RestrictionService */
private $service;
/** @var string */
private $userId;
/** @var IUserManager */
private $userManager;
/** @var IGroupManager */
private $groupManager;
use Errors;
public function __construct(
$appName,
IRequest $request,
RestrictionService $service,
IUserManager $userManager,
IGroupManager $groupManager,
$userId
) {
parent::__construct($appName, $request);
$this->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);
});
}
}

View File

@ -4,6 +4,7 @@ namespace OCA\BigBlueButton\Controller;
use OCA\BigBlueButton\Service\RoomService; use OCA\BigBlueButton\Service\RoomService;
use OCA\BigBlueButton\Permission; use OCA\BigBlueButton\Permission;
use OCA\BigBlueButton\Db\Room;
use OCP\IRequest; use OCP\IRequest;
use OCP\AppFramework\Http; use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\DataResponse;
@ -63,13 +64,34 @@ class RoomController extends Controller {
string $name, string $name,
string $welcome, string $welcome,
int $maxParticipants, int $maxParticipants,
bool $record bool $record,
string $access
): DataResponse { ): 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( return new DataResponse($this->service->create(
$name, $name,
$welcome, $welcome,
$maxParticipants, $maxParticipants,
$record, $record,
$access,
$this->userId $this->userId
)); ));
} }
@ -92,6 +114,21 @@ class RoomController extends Controller {
return new DataResponse(null, Http::STATUS_FORBIDDEN); 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->handleNotFound(function () use ($id, $name, $welcome, $maxParticipants, $record, $everyoneIsModerator, $access) {
return $this->service->update($id, $name, $welcome, $maxParticipants, $record, $access, $everyoneIsModerator); return $this->service->update($id, $name, $welcome, $maxParticipants, $record, $access, $everyoneIsModerator);
}); });

45
lib/Db/Restriction.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace OCA\BigBlueButton\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getRoomId()
* @method int getMaxRooms()
* @method string getRoomTypes()
* @method int getMaxParticipants()
* @method bool getAllowRecording()
* @method void setRoomId(string $id)
* @method void setMaxRooms(int $number)
* @method void setMaxParticipants(int $number)
* @method void setAllowRecording(bool $allow)
*/
class Restriction extends Entity implements JsonSerializable {
public const ALL_ID = '';
protected $groupId;
protected $maxRooms = -1;
protected $roomTypes = '[]';
protected $maxParticipants = -1;
protected $allowRecording = true;
public function __construct() {
$this->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),
];
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace OCA\BigBlueButton\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
class RestrictionMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'bbb_restrictions', Restriction::class);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(int $id): Restriction {
/* @var $qb IQueryBuilder */
$qb = $this->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<Restriction>
*/
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<Restriction> */
return $this->findEntities($qb);
}
/**
* @return array<Restriction>
*/
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->tableName);
/** @var array<Restriction> */
return $this->findEntities($qb);
}
}

View File

@ -37,6 +37,8 @@ class Room extends Entity implements JsonSerializable {
public const ACCESS_INTERNAL = 'internal'; public const ACCESS_INTERNAL = 'internal';
public const ACCESS_INTERNAL_RESTRICTED = 'internal_restricted'; 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 $uid;
public $name; public $name;
public $attendeePassword; public $attendeePassword;

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace OCA\BigBlueButton\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Auto-generated migration step: Please modify to your needs!
*/
class Version000000Date20200826100844 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->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;
}
}

View File

@ -3,27 +3,60 @@
namespace OCA\BigBlueButton; namespace OCA\BigBlueButton;
use Closure; use Closure;
use OCA\BigBlueButton\Service\RoomService;
use OCA\BigBlueButton\Service\RestrictionService;
use OCA\BigBlueButton\Service\RoomShareService; use OCA\BigBlueButton\Service\RoomShareService;
use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Db\RoomShare; use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Db\Restriction;
use OCP\IUserManager;
use OCP\IGroupManager; use OCP\IGroupManager;
class Permission { class Permission {
/** @var IUserManager */
private $userManager;
/** @var IGroupManager */ /** @var IGroupManager */
private $groupManager; private $groupManager;
/** @var RoomService */
private $roomService;
/** @var RestrictionService */
private $restrictionService;
/** @var RoomShareService */ /** @var RoomShareService */
private $roomShareService; private $roomShareService;
public function __construct( public function __construct(
IUserManager $userManager,
IGroupManager $groupManager, IGroupManager $groupManager,
RoomService $roomService,
RestrictionService $restrictionService,
RoomShareService $roomShareService RoomShareService $roomShareService
) { ) {
$this->userManager = $userManager;
$this->groupManager = $groupManager; $this->groupManager = $groupManager;
$this->roomService = $roomService;
$this->restrictionService = $restrictionService;
$this->roomShareService = $roomShareService; $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) { public function isUser(Room $room, ?string $uid) {
return $this->hasPermission($room, $uid, function (RoomShare $share) { return $this->hasPermission($room, $uid, function (RoomShare $share) {
return $share->hasUserPermission(); return $share->hasUserPermission();

View File

@ -0,0 +1,6 @@
<?php
namespace OCA\BigBlueButton\Service;
class RestrictionNotFound extends \Exception {
}

View File

@ -0,0 +1,120 @@
<?php
namespace OCA\BigBlueButton\Service;
use Exception;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCA\BigBlueButton\Db\Restriction;
use OCA\BigBlueButton\Db\RestrictionMapper;
class RestrictionService {
/** @var RestrictionMapper */
private $mapper;
public function __construct(RestrictionMapper $mapper) {
$this->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());
$roomTypes = array_intersect($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($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;
}
}
}

View File

@ -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 = new Room();
$room->setUid(\OC::$server->getSecureRandom()->generate(16, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE)); $room->setUid(\OC::$server->getSecureRandom()->generate(16, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE));
$room->setName($name); $room->setName($name);
$room->setWelcome($welcome); $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->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->setModeratorPassword(\OC::$server->getSecureRandom()->generate(32, \OCP\Security\ISecureRandom::CHAR_HUMAN_READABLE));
$room->setRecord($record); $room->setRecord($record);
$room->setAccess($access);
$room->setUserId($userId); $room->setUserId($userId);
return $this->mapper->insert($room); return $this->mapper->insert($room);
@ -82,7 +83,7 @@ class RoomService {
$room->setName($name); $room->setName($name);
$room->setWelcome($welcome); $room->setWelcome($welcome);
$room->setMaxParticipants($maxParticipants); $room->setMaxParticipants(\max($maxParticipants, 0));
$room->setRecord($record); $room->setRecord($record);
$room->setAccess($access); $room->setAccess($access);
$room->setEveryoneIsModerator($everyoneIsModerator); $room->setEveryoneIsModerator($everyoneIsModerator);

View File

@ -3,6 +3,7 @@
/** @var $_ array */ /** @var $_ array */
script('bbb', 'admin'); script('bbb', 'admin');
script('bbb', 'restrictions');
?> ?>
<div id="bbb-settings" class="section"> <div id="bbb-settings" class="section">
@ -19,7 +20,11 @@ script('bbb', 'admin');
</form> </form>
<p> <p>
<input type="checkbox" name="app.navigation" id="bbb-app-navigation" class="checkbox" value="1" <?php p($_['app.navigation']); ?>> <input type="checkbox" name="app.navigation" id="bbb-app-navigation" class="checkbox" value="1" <?php p($_['app.navigation']); ?> />
<label for="bbb-app-navigation"><?php p($l->t('Show room manager in app navigation instead of settings page.')); ?></label> <label for="bbb-app-navigation"><?php p($l->t('Show room manager in app navigation instead of settings page.')); ?></label>
</p> </p>
<h3>Restrictions</h3>
<div id="bbb-restrictions">
</div>
</div> </div>

View File

@ -4,9 +4,14 @@ namespace OCA\BigBlueButton\Tests;
use OCA\BigBlueButton\Db\Room; use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Db\RoomShare; use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Db\Restriction;
use OCA\BigBlueButton\Permission; use OCA\BigBlueButton\Permission;
use OCA\BigBlueButton\Service\RoomService;
use OCA\BigBlueButton\Service\RoomShareService; use OCA\BigBlueButton\Service\RoomShareService;
use OCA\BigBlueButton\Service\RestrictionService;
use OCP\IUserManager;
use OCP\IGroupManager; use OCP\IGroupManager;
use OCP\IUser;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -14,27 +19,79 @@ class PermissionTest extends TestCase {
/** @var Permission */ /** @var Permission */
private $permission; private $permission;
/** @var IUserManager|MockObject */
private $userManager;
/** @var IGroupManager|MockObject */ /** @var IGroupManager|MockObject */
private $groupManager; private $groupManager;
/** @var RoomService|MockObject */
private $roomService;
/** @var RoomShareService|MockObject */ /** @var RoomShareService|MockObject */
private $roomShareService; private $roomShareService;
/** @var RestrictionService|MockObject */
private $restrictionService;
public function setUp(): void { public function setUp(): void {
parent::setUp(); parent::setUp();
/** @var IUserManager|MockObject */
$this->userManager = $this->createMock(IUserManager::class);
/** @var IGroupManager|MockObject */ /** @var IGroupManager|MockObject */
$this->groupManager = $this->createMock(IGroupManager::class); $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 */ /** @var RoomShareService|MockObject */
$this->roomShareService = $this->createMock(RoomShareService::class); $this->roomShareService = $this->createMock(RoomShareService::class);
$this->permission = new Permission( $this->permission = new Permission(
$this->userManager,
$this->groupManager, $this->groupManager,
$this->roomService,
$this->restrictionService,
$this->roomShareService $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() { public function testIsUser() {
$room = $this->createRoom(1, 'foo'); $room = $this->createRoom(1, 'foo');

View File

@ -0,0 +1,52 @@
<?php
namespace OCA\BigBlueButton\Tests\Unit\Service;
use PHPUnit\Framework\TestCase;
use OCA\BigBlueButton\Service\RestrictionService;
use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Db\Restriction;
use OCA\BigBlueButton\Db\RestrictionMapper;
class RestrictionServiceTest extends TestCase {
protected $mapper;
protected $service;
public function setUp(): void {
$this->mapper = $this->createMock(RestrictionMapper::class);
$this->service = new RestrictionService($this->mapper);
}
public function testFindByGroupIds() {
$restriction0 = new Restriction();
$restriction0->setRoomTypes(\json_encode([Room::ACCESS_INTERNAL]));
$restriction0->setMaxParticipants(50);
$restriction0->setAllowRecording(false);
$restriction1 = new Restriction();
$restriction1->setRoomTypes(\json_encode([Room::ACCESS_INTERNAL, Room::ACCESS_INTERNAL_RESTRICTED]));
$restriction1->setMaxRooms(10);
$restriction1->setMaxParticipants(100);
$restriction1->setAllowRecording(true);
$this->mapper
->expects($this->once())
->method('findByGroupIds')
->willReturn([$restriction1]);
$this->mapper
->expects($this->once())
->method('findByGroupId')
->willReturn($restriction0);
$result = $this->service->findByGroupIds([]);
$this->assertEquals([Room::ACCESS_INTERNAL], \json_decode($result->getRoomTypes()));
$this->assertEquals(-1, $result->getMaxRooms());
$this->assertEquals(100, $result->getMaxParticipants());
$this->assertTrue($result->getAllowRecording());
}
}

View File

@ -12,6 +12,15 @@ export enum Access {
InternalRestricted = 'internal_restricted', InternalRestricted = 'internal_restricted',
} }
export interface Restriction {
id: number;
groupId: string;
maxRooms: number;
roomTypes: string[];
maxParticipants: number;
allowRecording: boolean;
}
export interface Room { export interface Room {
id: number; id: number;
uid: string; uid: string;
@ -65,6 +74,46 @@ class Api {
return OC.generateUrl(`apps/bbb/${endpoint}`); return OC.generateUrl(`apps/bbb/${endpoint}`);
} }
public async getRestriction(): Promise<Restriction> {
const response = await axios.get(this.getUrl('restrictions/user'));
return response.data;
}
public async getRestrictions(): Promise<Restriction[]> {
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) { public getRoomUrl(room: Room) {
return window.location.origin + api.getUrl(`b/${room.uid}`); return window.location.origin + api.getUrl(`b/${room.uid}`);
} }
@ -75,12 +124,13 @@ class Api {
return response.data; 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'), { const response = await axios.post(this.getUrl('rooms'), {
name, name,
welcome: '', welcome: '',
maxParticipants: 0, maxParticipants,
record: false, record: false,
access,
}); });
return response.data; return response.data;
@ -165,10 +215,11 @@ class Api {
return response.data; return response.data;
} }
public async getRecommendedShareWith(): Promise<ShareWith> { public async getRecommendedShareWith(shareType: ShareType[] = [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_GROUP]): Promise<ShareWith> {
const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees_recommended'; const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees_recommended';
const response = await axios.get(url, { const response = await axios.get(url, {
params: { params: {
shareType,
itemType: 'room', itemType: 'room',
format: 'json', format: 'json',
}, },
@ -180,12 +231,12 @@ class Api {
}; };
} }
public async searchShareWith(search = ''): Promise<ShareWith> { public async searchShareWith(search = '', shareType: ShareType[] = [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_GROUP]): Promise<ShareWith> {
const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees'; const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees';
const response = await axios.get(url, { const response = await axios.get(url, {
params: { params: {
search, search,
shareType: [OC.Share.SHARE_TYPE_USER, OC.Share.SHARE_TYPE_GROUP], shareType,
itemType: 'room', itemType: 'room',
format: 'json', format: 'json',
lookup: false, lookup: false,

View File

@ -0,0 +1,58 @@
import React, {useState} from 'react';
type Props = {
values: string[];
setValue: (field: string, value: string[]) => Promise<void>;
field: string;
options: {[key: string]: string};
placeholder?: string;
invert?: boolean;
}
const EditableSelection: React.FC<Props> = ({ setValue, field, values: currentValues, options, placeholder, invert = false }) => {
const [active, setActive] = useState<boolean>(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));
}
}
const selection = !invert ? currentValues : (currentValues.length ? Object.keys(options).filter(option => currentValues?.indexOf(option) < 0) : []);
return (<>
<a className="action-rename" onClick={onClick}>{selection.join(', ') || placeholder}</a>
{active && <ul className="bbb-selection">
{Object.keys(options).map(key => {
const label = options[key];
return (
<li key={key}>
<input
type="checkbox"
id={key}
className="checkbox"
checked={(currentValues.indexOf(key) > -1) !== invert}
value="1"
onChange={(ev) => addOption(key, ev.target.checked !== invert)} />
<label htmlFor={key}>{label}</label>
</li>
);
})}
</ul>}
</>);
};
export default EditableSelection;

View File

@ -0,0 +1,33 @@
.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);
}
}
}
}

View File

@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { api, ShareWith, ShareType, ShareWithOption } from '../Common/Api';
import './ShareSelection.scss';
type Props = {
selectShare: (selection: ShareWithOption) => void;
shareType?: ShareType[];
excluded?: {
groupIds?: string[];
userIds?: string[];
};
placeholder?: string;
}
const ShareSelection: React.FC<Props> = (props) => {
const [search, setSearch] = useState<string>('');
const [hasFocus, setFocus] = useState<boolean>(false);
const [showSearchResults, setShowSearchResults] = useState<boolean>(false);
const [recommendations, setRecommendations] = useState<ShareWith>();
const [searchResults, setSearchResults] = useState<ShareWith>();
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 (<li key={option.value.shareWith} className="suggestion" onClick={() => selectShare(option)}>
{option.label}{option.value.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''}
</li>);
};
return (
<ul className="bbb-selection">
{!options ?
<li><span className="icon icon-loading-small icon-visible"></span> {t('bbb', 'Searching')}</li> :
(
(results.length === 0 && search) ? <li>{t('bbb', 'No matches')}</li> : results.map(renderOption)
)}
</ul>
);
}
return (
<div className="bbb-selection-container">
<input
type="text"
value={search}
onChange={ev => setSearch(ev.currentTarget.value)}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
placeholder={placeholder} />
{showSearchResults && renderSearchResults((search && searchResults) ? searchResults : ((recommendations && !search) ? recommendations : undefined))}
</div>
);
};
export default ShareSelection;

17
ts/Common/Translation.ts Normal file
View File

@ -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'),
};

View File

@ -19,6 +19,10 @@
border-radius: 50%; border-radius: 50%;
} }
.text-muted {
opacity: 0.6;
}
#bbb-root { #bbb-root {
width: 100%; width: 100%;
} }
@ -54,6 +58,10 @@
width: 250px; width: 250px;
} }
h3 {
margin-top: 3em;
}
p { p {
margin-bottom: 1em; margin-bottom: 1em;
} }

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import './App.scss'; import './App.scss';
import RoomRow from './RoomRow'; import RoomRow from './RoomRow';
import { SortArrow } from './SortArrow'; import { SortArrow } from './SortArrow';
import { api, Room } from './Api'; import { api, Room, Restriction, Access } from '../Common/Api';
import NewRoomForm from './NewRoomForm'; import NewRoomForm from './NewRoomForm';
export type SortKey = 'name' | 'welcome' | 'maxParticipants' | 'record'; export type SortKey = 'name' | 'welcome' | 'maxParticipants' | 'record';
@ -37,16 +37,19 @@ type Props = {
const App: React.FC<Props> = () => { const App: React.FC<Props> = () => {
const [areRoomsLoaded, setRoomsLoaded] = useState(false); const [areRoomsLoaded, setRoomsLoaded] = useState(false);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [restriction, setRestriction] = useState<Restriction>();
const [rooms, setRooms] = useState<Room[]>([]); const [rooms, setRooms] = useState<Room[]>([]);
const [orderBy, setOrderBy] = useState<SortKey>('name'); const [orderBy, setOrderBy] = useState<SortKey>('name');
const [sortOrder, setSortOrder] = useState(SortOrder.ASC); const [sortOrder, setSortOrder] = useState(SortOrder.ASC);
const rows = rooms.sort(sortRooms(orderBy, sortOrder)).map(room => <RoomRow room={room} key={room.id} updateRoom={updateRoom} deleteRoom={deleteRoom} />); const rows = rooms.sort(sortRooms(orderBy, sortOrder)).map(room => <RoomRow room={room} restriction={restriction} key={room.id} updateRoom={updateRoom} deleteRoom={deleteRoom} />);
useEffect(() => { useEffect(() => {
if (areRoomsLoaded) { api.getRestriction().then(restriction => {
return; setRestriction(restriction);
} }).catch(err => {
console.warn('Could not load restriction', err);
});
api.getRooms().then(rooms => { api.getRooms().then(rooms => {
setRooms(rooms); setRooms(rooms);
@ -57,7 +60,7 @@ const App: React.FC<Props> = () => {
}).then(() => { }).then(() => {
setRoomsLoaded(true); setRoomsLoaded(true);
}); });
}, [areRoomsLoaded]); }, []);
function onOrderBy(key: SortKey) { function onOrderBy(key: SortKey) {
if (orderBy === key) { if (orderBy === key) {
@ -72,7 +75,16 @@ const App: React.FC<Props> = () => {
return Promise.resolve(); 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])); setRooms(rooms.concat([room]));
}); });
} }
@ -95,6 +107,8 @@ const App: React.FC<Props> = () => {
}); });
} }
const maxRooms = restriction?.maxRooms || 0;
return ( return (
<div id="bbb-react-root" <div id="bbb-react-root"
onClick={() => { /* @TODO hide edit inputs */ }}> onClick={() => { /* @TODO hide edit inputs */ }}>
@ -131,9 +145,17 @@ const App: React.FC<Props> = () => {
{!areRoomsLoaded && <span className="icon icon-loading-small icon-visible"></span>} {!areRoomsLoaded && <span className="icon icon-loading-small icon-visible"></span>}
</td> </td>
<td> <td>
<NewRoomForm addRoom={addRoom} /> {(maxRooms > rows.length || maxRooms < 0) ?
<NewRoomForm addRoom={addRoom} /> :
<p className="text-muted">{maxRooms === 0 ?
t('bbb', 'You are not permitted to create a room.') :
t('bbb', 'You exceeded the maximum number of rooms.')
}</p>}
</td>
<td />
<td colSpan={3}>
<p className="text-muted">{t('bbb', 'Room quota:')} {rooms.filter(room => room.userId === OC.currentUser).length} / {maxRooms}</p>
</td> </td>
<td colSpan={4} />
</tr> </tr>
</tfoot> </tfoot>
</table> </table>

View File

@ -1,13 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Room } from './Api'; import { Room, Restriction } from '../Common/Api';
import EditRoomDialog from './EditRoomDialog'; import EditRoomDialog from './EditRoomDialog';
type Props = { type Props = {
room: Room; room: Room;
restriction?: Restriction;
updateProperty: (key: string, value: string | boolean | number) => Promise<void>; updateProperty: (key: string, value: string | boolean | number) => Promise<void>;
} }
const EditRoom: React.FC<Props> = ({ room, updateProperty }) => { const EditRoom: React.FC<Props> = ({ room, restriction, updateProperty }) => {
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
return ( return (
@ -16,7 +17,7 @@ const EditRoom: React.FC<Props> = ({ room, updateProperty }) => {
onClick={ev => { ev.preventDefault(), setOpen(true); }} onClick={ev => { ev.preventDefault(), setOpen(true); }}
title={t('bbb', 'Edit')} /> title={t('bbb', 'Edit')} />
<EditRoomDialog room={room} updateProperty={updateProperty} open={open} setOpen={setOpen} /> <EditRoomDialog room={room} restriction={restriction} updateProperty={updateProperty} open={open} setOpen={setOpen} />
</> </>
); );
}; };

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'; 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 Dialog from './Dialog';
import ShareWith from './ShareWith'; import ShareWith from './ShareWith';
import { SubmitInput } from './SubmitInput'; import { SubmitInput } from './SubmitInput';
import { AccessOptions } from '../Common/Translation';
const descriptions: { [key: string]: string } = { const descriptions: { [key: string]: string } = {
name: t('bbb', 'Descriptive name of this room.'), name: t('bbb', 'Descriptive name of this room.'),
@ -15,14 +16,18 @@ const descriptions: { [key: string]: string } = {
type Props = { type Props = {
room: Room; room: Room;
restriction?: Restriction;
updateProperty: (key: string, value: string | boolean | number) => Promise<void>; updateProperty: (key: string, value: string | boolean | number) => Promise<void>;
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
} }
const EditRoomDialog: React.FC<Props> = ({ room, updateProperty, open, setOpen }) => { const EditRoomDialog: React.FC<Props> = ({ room, restriction, updateProperty, open, setOpen }) => {
const [shares, setShares] = useState<RoomShare[]>(); const [shares, setShares] = useState<RoomShare[]>();
const maxParticipantsLimit = (restriction?.maxParticipants || 0) < 0 ? undefined : restriction?.maxParticipants;
const minParticipantsLimit = (restriction?.maxParticipants || -1) < 1 ? 0 : 1;
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
return; return;
@ -45,7 +50,7 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty, open, setOpen }
<h3>{label}</h3> <h3>{label}</h3>
</label> </label>
<SubmitInput initialValue={room[field]} type={type} name={field} onSubmitValue={value => updateProperty(field, value)} /> <SubmitInput initialValue={room[field]} type={type} name={field} onSubmitValue={value => updateProperty(field, value)} min={minParticipantsLimit} max={maxParticipantsLimit} />
{descriptions[field] && <em>{descriptions[field]}</em>} {descriptions[field] && <em>{descriptions[field]}</em>}
</div> </div>
); );
@ -71,19 +76,20 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty, open, setOpen }
); );
} }
const accessOptions = {...AccessOptions};
for(const roomType of restriction?.roomTypes || []) {
if (roomType !== room.access) {
delete accessOptions[roomType];
}
}
return ( return (
<Dialog open={open} onClose={() => setOpen(false)} title={t('bbb', 'Edit "{room}"', { room: room.name })}> <Dialog open={open} onClose={() => setOpen(false)} title={t('bbb', 'Edit "{room}"', { room: room.name })}>
{inputElement(t('bbb', 'Name'), 'name')} {inputElement(t('bbb', 'Name'), 'name')}
{inputElement(t('bbb', 'Welcome'), 'welcome')} {inputElement(t('bbb', 'Welcome'), 'welcome')}
{inputElement(t('bbb', 'Participant limit'), 'maxParticipants', 'number')} {inputElement(t('bbb', 'Participant limit'), 'maxParticipants', 'number')}
{selectElement(t('bbb', 'Access'), 'access', room.access, { {selectElement(t('bbb', 'Access'), 'access', room.access, accessOptions, (value) => {
[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) => {
console.log('access', value); console.log('access', value);
updateProperty('access', value); updateProperty('access', value);
})} })}
@ -117,6 +123,7 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty, open, setOpen }
type="checkbox" type="checkbox"
className="checkbox" className="checkbox"
checked={room.record} checked={room.record}
disabled={!restriction?.allowRecording}
onChange={(event) => updateProperty('record', event.target.checked)} /> onChange={(event) => updateProperty('record', event.target.checked)} />
<label htmlFor={`bbb-record-${room.id}`}>{t('bbb', 'Recording')}</label> <label htmlFor={`bbb-record-${room.id}`}>{t('bbb', 'Recording')}</label>
</div> </div>

View File

@ -6,9 +6,14 @@ type EditableValueProps = {
setValue: (key: string, value: string | number) => Promise<void>; setValue: (key: string, value: string | number) => Promise<void>;
field: string; field: string;
type: 'text' | 'number'; type: 'text' | 'number';
options?: {
min?: number;
max?: number;
disabled?: boolean;
};
} }
const EditableValue: React.FC<EditableValueProps> = ({ setValue, field, value: currentValue, type }) => { const EditableValue: React.FC<EditableValueProps> = ({ setValue, field, value: currentValue, type, options }) => {
const [active, setActive] = useState<boolean>(false); const [active, setActive] = useState<boolean>(false);
const submit = (value: string | number) => { const submit = (value: string | number) => {
@ -31,6 +36,8 @@ const EditableValue: React.FC<EditableValueProps> = ({ setValue, field, value: c
initialValue={currentValue} initialValue={currentValue}
type={type} type={type}
focus={true} focus={true}
min={options?.min}
max={options?.max}
/>; />;
} }
@ -40,6 +47,10 @@ const EditableValue: React.FC<EditableValueProps> = ({ setValue, field, value: c
setActive(true); setActive(true);
} }
if (options?.disabled) {
return <span>{currentValue}</span>;
}
return <a className="action-rename" onClick={onClick}>{currentValue}</a>; return <a className="action-rename" onClick={onClick}>{currentValue}</a>;
}; };

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
import { Recording } from './Api'; import { Recording } from '../Common/Api';
type Props = { type Props = {
recording: Recording; recording: Recording;

View File

@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { CopyToClipboard } from 'react-copy-to-clipboard'; 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 EditRoom from './EditRoom';
import RecordingRow from './RecordingRow'; import RecordingRow from './RecordingRow';
import EditableValue from './EditableValue'; import EditableValue from './EditableValue';
type Props = { type Props = {
room: Room; room: Room;
restriction?: Restriction;
updateRoom: (room: Room) => Promise<void>; updateRoom: (room: Room) => Promise<void>;
deleteRoom: (id: number) => void; deleteRoom: (id: number) => void;
} }
@ -154,8 +155,8 @@ const RoomRow: React.FC<Props> = (props) => {
); );
} }
function edit(field: string, type: 'text' | 'number' = 'text') { function edit(field: string, type: 'text' | 'number' = 'text', options?) {
return <EditableValue field={field} value={room[field]} setValue={updateRoom} type={type} />; return <EditableValue field={field} value={room[field]} setValue={updateRoom} type={type} options={options} />;
} }
const avatarUrl = OC.generateUrl('/avatar/' + encodeURIComponent(room.userId) + '/' + 24, { const avatarUrl = OC.generateUrl('/avatar/' + encodeURIComponent(room.userId) + '/' + 24, {
@ -164,6 +165,9 @@ const RoomRow: React.FC<Props> = (props) => {
requesttoken: OC.requestToken, requesttoken: OC.requestToken,
}); });
const maxParticipantsLimit = props.restriction?.maxParticipants || -1;
const minParticipantsLimit = (props.restriction?.maxParticipants || -1) < 1 ? 0 : 1;
return ( return (
<> <>
<tr className={showRecordings ? 'selected-row' : ''}> <tr className={showRecordings ? 'selected-row' : ''}>
@ -185,15 +189,15 @@ const RoomRow: React.FC<Props> = (props) => {
{room.userId !== OC.currentUser && <img src={avatarUrl} alt="Avatar" className="bbb-avatar" />} {room.userId !== OC.currentUser && <img src={avatarUrl} alt="Avatar" className="bbb-avatar" />}
</td> </td>
<td className="max-participants bbb-shrink"> <td className="max-participants bbb-shrink">
{edit('maxParticipants', 'number')} {edit('maxParticipants', 'number', {min: minParticipantsLimit, max: maxParticipantsLimit < 0 ? undefined : maxParticipantsLimit})}
</td> </td>
<td className="record bbb-shrink"> <td className="record bbb-shrink">
<input id={`bbb-record-${room.id}`} type="checkbox" className="checkbox" checked={room.record} onChange={(event) => updateRoom('record', event.target.checked)} /> <input id={`bbb-record-${room.id}`} type="checkbox" className="checkbox" disabled={!props.restriction?.allowRecording} checked={room.record} onChange={(event) => updateRoom('record', event.target.checked)} />
<label htmlFor={`bbb-record-${room.id}`}></label> <label htmlFor={`bbb-record-${room.id}`}></label>
</td> </td>
<td className="bbb-shrink"><RecordingsNumber recordings={recordings} showRecordings={showRecordings} setShowRecordings={setShowRecordings} /></td> <td className="bbb-shrink"><RecordingsNumber recordings={recordings} showRecordings={showRecordings} setShowRecordings={setShowRecordings} /></td>
<td className="edit icon-col"> <td className="edit icon-col">
<EditRoom room={props.room} updateProperty={updateRoom} /> <EditRoom room={props.room} restriction={props.restriction} updateProperty={updateRoom} />
</td> </td>
<td className="remove icon-col"> <td className="remove icon-col">
<a className="icon icon-delete icon-visible" <a className="icon icon-delete icon-visible"

View File

@ -35,39 +35,6 @@
} }
} }
.bbb-selection-container {
position: relative;
}
.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);
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 { .bbb-icon-unselected {
opacity: 0.2 !important; opacity: 0.2 !important;

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import { api, ShareWith, ShareType, RoomShare, Room, Permission, ShareWithOption } from './Api'; import { api, ShareWith, ShareType, RoomShare, Room, Permission } from '../Common/Api';
import './ShareWith.scss'; import './ShareWith.scss';
import ShareSelection from '../Common/ShareSelection';
type Props = { type Props = {
room: Room; room: Room;
@ -10,12 +11,6 @@ type Props = {
} }
const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setShares }) => { const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setShares }) => {
const [search, setSearch] = useState<string>('');
const [hasFocus, setFocus] = useState<boolean>(false);
const [showSearchResults, setShowSearchResults] = useState<boolean>(false);
const [recommendations, setRecommendations] = useState<ShareWith>();
const [searchResults, setSearchResults] = useState<ShareWith>();
const isOwner = room.userId === OC.currentUser; const isOwner = room.userId === OC.currentUser;
const shares = (allShares && permission === Permission.Moderator) ? const shares = (allShares && permission === Permission.Moderator) ?
@ -24,32 +19,11 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
const sharedUserIds = shares ? shares.filter(share => share.shareType === ShareType.User).map(share => share.shareWith) : []; const sharedUserIds = shares ? shares.filter(share => share.shareType === ShareType.User).map(share => share.shareWith) : [];
const sharedGroupIds = shares ? shares.filter(share => share.shareType === ShareType.Group).map(share => share.shareWith) : []; const sharedGroupIds = shares ? shares.filter(share => share.shareType === ShareType.Group).map(share => share.shareWith) : [];
useEffect(() => {
setSearchResults(undefined);
const searchQuery = search;
api.searchShareWith(searchQuery).then(result => {
if (searchQuery === search) {
setSearchResults(result);
}
});
}, [search]);
useEffect(() => {
api.getRecommendedShareWith().then(result => setRecommendations(result));
}, []);
useEffect(() => {
setTimeout(() => setShowSearchResults(hasFocus), 100);
}, [hasFocus]);
async function addRoomShare(shareWith: string, shareType: number, displayName: string, permission: Permission) { async function addRoomShare(shareWith: string, shareType: number, displayName: string, permission: Permission) {
const roomShare = await api.createRoomShare(room.id, shareType, shareWith, permission); const roomShare = await api.createRoomShare(room.id, shareType, shareWith, permission);
roomShare.shareWithDisplayName = displayName; roomShare.shareWithDisplayName = displayName;
console.log('addRoomShare', allShares, roomShare);
const newShares = allShares ? [...allShares] : []; const newShares = allShares ? [...allShares] : [];
const index = newShares.findIndex(share => share.id === roomShare.id); const index = newShares.findIndex(share => share.id === roomShare.id);
@ -59,10 +33,7 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
newShares.push(roomShare); newShares.push(roomShare);
} }
console.log('newroomshares', newShares);
setShares(newShares); setShares(newShares);
setSearch('');
} }
async function deleteRoomShare(id: number) { async function deleteRoomShare(id: number) {
@ -79,27 +50,12 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
return addRoomShare(share.shareWith, share.shareType, share.shareWithDisplayName || share.shareWith, newPermission); return addRoomShare(share.shareWith, share.shareType, share.shareWithDisplayName || share.shareWith, newPermission);
} }
function renderSearchResults(options: ShareWith|undefined) { function getAvatarUrl(userId: string) {
const results = options ? [ return OC.generateUrl('/avatar/' + encodeURIComponent(userId) + '/' + 32, {
...options.users.filter(user => !sharedUserIds.includes(user.value.shareWith)), user: userId,
...options.groups.filter(group => !sharedGroupIds.includes(group.value.shareWith)), size: 32,
] : []; requesttoken: OC.requestToken,
});
const renderOption = (option: ShareWithOption) => {
return (<li key={option.value.shareWith} className="suggestion" onClick={() => addRoomShare(option.value.shareWith, option.value.shareType, option.label, permission)}>
{option.label}{option.value.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''}
</li>);
};
return (
<ul className="bbb-selection">
{!options ?
<li><span className="icon icon-loading-small icon-visible"></span> {t('bbb', 'Searching')}</li> :
(
(results.length === 0 && search) ? <li>{t('bbb', 'No matches')}</li> : results.map(renderOption)
)}
</ul>
);
} }
function renderShares(shares: RoomShare[]) { function renderShares(shares: RoomShare[]) {
@ -116,11 +72,7 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
return ( return (
<ul className="bbb-shareWith"> <ul className="bbb-shareWith">
{[ownShare, ...shares].map(share => { {[ownShare, ...shares].map(share => {
const avatarUrl = share.shareType === ShareType.User ? OC.generateUrl('/avatar/' + encodeURIComponent(share.shareWith) + '/' + 32, { const avatarUrl = share.shareType === ShareType.User ? getAvatarUrl(share.shareWith) : undefined;
user: share.shareWith,
size: 32,
requesttoken: OC.requestToken,
}) : undefined;
const displayName = share.shareWithDisplayName || share.shareWith; const displayName = share.shareWithDisplayName || share.shareWith;
return ( return (
@ -136,7 +88,10 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
</div> </div>
{(share.id > -1 && permission === Permission.Moderator && isOwner) && <div className="bbb-shareWith__item__action"> {(share.id > -1 && permission === Permission.Moderator && isOwner) && <div className="bbb-shareWith__item__action">
<a className={`icon icon-shared icon-visible ${share.permission === Permission.Admin ? 'bbb-icon-selected' : 'bbb-icon-unselected'}`} <a className={`icon icon-shared icon-visible ${share.permission === Permission.Admin ? 'bbb-icon-selected' : 'bbb-icon-unselected'}`}
onClick={ev => {ev.preventDefault(); toggleAdminShare(share);}} onClick={ev => {
ev.preventDefault();
toggleAdminShare(share);
}}
title={t('bbb', 'Share')} /> title={t('bbb', 'Share')} />
</div>} </div>}
{(share.id > -1 && isOwner) && <div className="bbb-shareWith__item__action"> {(share.id > -1 && isOwner) && <div className="bbb-shareWith__item__action">
@ -157,17 +112,14 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
<> <>
{shares ? renderShares(shares) : loading} {shares ? renderShares(shares) : loading}
<div className="bbb-selection-container"> {isOwner ?
{isOwner ? <input <ShareSelection
type="text" selectShare={(shareOption) => addRoomShare(shareOption.value.shareWith, shareOption.value.shareType, shareOption.label, permission)}
value={search} excluded={{userIds: sharedUserIds, groupIds: sharedGroupIds}}/> :
onChange={ev => setSearch(ev.currentTarget.value)} <em>
onFocus={() => setFocus(true)} <span className="icon icon-details icon-visible"></span> {t('bbb', 'You are not allowed to change this option, because this room is shared with you.')}
onBlur={() => setFocus(false)} </em>
placeholder={t('bbb', 'Name, group, ...')} /> : }
<em><span className="icon icon-details icon-visible"></span> {t('bbb', 'You are not allowed to change this option, because this room is shared with you.')}</em>}
{showSearchResults && renderSearchResults((search && searchResults) ? searchResults : ((recommendations && !search) ? recommendations : undefined))}
</div>
</> </>
); );
}; };

View File

@ -40,6 +40,8 @@ export class SubmitInput extends Component<SubmitInputProps, SubmitInputState> {
onChange={event => this.setState({value: event.currentTarget.value})} onChange={event => this.setState({value: event.currentTarget.value})}
onBlur={() => this.props.onSubmitValue(this.state.value)} onBlur={() => this.props.onSubmitValue(this.state.value)}
autoFocus={this.props.focus} autoFocus={this.props.focus}
min={this.props.min}
max={this.props.max}
/> />
</form>; </form>;
} }

102
ts/Restrictions/App.tsx Normal file
View File

@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react';
import '../Manager/App.scss';
import { api, Restriction, ShareType } from '../Common/Api';
import RestrictionRow from './RestrictionRow';
import ShareSelection from '../Common/ShareSelection';
type Props = {
}
const App: React.FC<Props> = () => {
const [areRestrictionsLoaded, setRestrictionsLoaded] = useState(false);
const [error, setError] = useState<string>('');
const [restrictions, setRestrictions] = useState<Restriction[]>([]);
const rows = restrictions.sort((a, b) => a.groupId.localeCompare(b.groupId)).map(restriction => <RestrictionRow key={restriction.id} restriction={restriction} updateRestriction={updateRestriction} deleteRestriction={deleteRestriction} />);
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 (
<div id="bbb-react-root">
<table>
<thead>
<tr>
<th>
{t('bbb', 'Group name')}
</th>
<th>
{t('bbb', 'Max. rooms')}
</th>
<th>
{t('bbb', 'Access options')}
</th>
<th>
{t('bbb', 'Max. participants')}
</th>
<th>
{t('bbb', 'Recording')}
</th>
<th/>
</tr>
</thead>
<tbody>
{rows}
</tbody>
<tfoot>
<tr>
<td>
{!areRestrictionsLoaded
? <span className="icon icon-loading-small icon-visible"></span>
: <ShareSelection
placeholder={t('bbb', 'Group, ...')}
selectShare={(share) => addRestriction(share.value.shareWith)}
shareType={[ShareType.Group]}
excluded={{groupIds: restrictions.map(restriction => restriction.groupId)}} /> }
{error && <><span className="icon icon-error icon-visible"></span> {error}</>}
</td>
<td colSpan={4} />
</tr>
</tfoot>
</table>
<p className="text-muted">{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.')}</p>
</div>
);
};
export default App;

View File

@ -0,0 +1,83 @@
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<void>;
deleteRestriction: (id: number) => void;
}
const RestrictionRoom: React.FC<Props> = (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 <EditableValue field={field} value={restriction[field]} setValue={updateRestriction} type={type} options={{min: -1}} />;
}
return (
<tr>
<td className="name">{restriction.groupId || t('bbb', 'All users')}</td>
<td className="max-rooms bbb-shrink">
{edit('maxRooms', 'number')}
</td>
<td>
<EditableSelection
field="roomTypes"
values={restriction.roomTypes}
options={AccessOptions}
setValue={updateRestriction}
invert={true}
placeholder={t('bbb', 'All')} />
</td>
<td className="max-participants bbb-shrink">
{edit('maxParticipants', 'number')}
</td>
<td className="record bbb-shrink">
<input
id={`bbb-record-${restriction.id}`}
type="checkbox"
className="checkbox"
checked={restriction.allowRecording}
onChange={(event) => updateRestriction('allowRecording', event.target.checked)} />
<label htmlFor={`bbb-record-${restriction.id}`}></label>
</td>
<td className="remove icon-col">
{restriction.groupId && <a className="icon icon-delete icon-visible"
onClick={deleteRow as any}
title={t('bbb', 'Delete')} />}
</td>
</tr>
);
};
export default RestrictionRoom;

12
ts/Restrictions/index.tsx Normal file
View File

@ -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( <App/>, document.getElementById('bbb-restrictions'));
});

View File

@ -1,4 +1,4 @@
import {api} from './Manager/Api'; import {api} from './Common/Api';
import './Manager/App.scss'; import './Manager/App.scss';
declare const OCP: any; declare const OCP: any;

View File

@ -1,6 +1,6 @@
import axios from '@nextcloud/axios'; import axios from '@nextcloud/axios';
import { generateOcsUrl, generateUrl } from '@nextcloud/router'; import { generateOcsUrl, generateUrl } from '@nextcloud/router';
import { Room } from './Manager/Api'; import { Room } from './Common/Api';
declare const OCA: any; declare const OCA: any;

View File

@ -11,6 +11,9 @@ module.exports = {
manager: [ manager: [
path.join(__dirname, 'ts', 'Manager', 'index.tsx'), path.join(__dirname, 'ts', 'Manager', 'index.tsx'),
], ],
restrictions: [
path.join(__dirname, 'ts', 'Restrictions', 'index.tsx'),
],
join: [ join: [
path.join(__dirname, 'ts', 'join.ts'), path.join(__dirname, 'ts', 'join.ts'),
] ]