feat: add admin setting to restrict rooms

fix #43
pull/67/head
sualko 2020-08-27 17:21:34 +02:00
parent cdf16960c4
commit d6abf23792
34 changed files with 1146 additions and 52 deletions

View File

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

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\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);
});

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_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;

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;
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();

View File

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

View File

@ -0,0 +1,122 @@
<?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());
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;
}
}
}

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->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);

View File

@ -3,6 +3,7 @@
/** @var $_ array */
script('bbb', 'admin');
script('bbb', 'restrictions');
?>
<div id="bbb-settings" class="section">
@ -19,7 +20,11 @@ script('bbb', 'admin');
</form>
<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>
</p>
<h3>Restrictions</h3>
<div id="bbb-restrictions">
</div>
</div>

View File

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

View File

@ -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<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) {
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<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 response = await axios.get(url, {
params: {
shareType,
itemType: 'room',
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 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,

View File

@ -0,0 +1,49 @@
import React, {useState} from 'react';
type Props = {
values: string[];
setValue: (field: string, value: string[]) => Promise<void>;
field: string;
options: {[key: string]: string};
placeholder?: string;
}
const EditableSelection: React.FC<Props> = ({ setValue, field, values: currentValues, options, placeholder }) => {
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));
}
}
return (<>
<a className="action-rename" onClick={onClick}>{currentValues?.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} value="1" onChange={(ev) => addOption(key, ev.target.checked)} />
<label htmlFor={key}>{label}</label>
</li>
);
})}
</ul>}
</>);
};
export default EditableSelection;

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%;
}
.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;
}
}
}

View File

@ -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<Props> = () => {
const [areRoomsLoaded, setRoomsLoaded] = useState(false);
const [error, setError] = useState<string>('');
const [restriction, setRestriction] = useState<Restriction>();
const [rooms, setRooms] = useState<Room[]>([]);
const [orderBy, setOrderBy] = useState<SortKey>('name');
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(() => {
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<Props> = () => {
}).then(() => {
setRoomsLoaded(true);
});
}, [areRoomsLoaded]);
}, []);
function onOrderBy(key: SortKey) {
if (orderBy === key) {
@ -72,7 +75,16 @@ const App: React.FC<Props> = () => {
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<Props> = () => {
});
}
const maxRooms = restriction?.maxRooms || 0;
return (
<div id="bbb-react-root"
onClick={() => { /* @TODO hide edit inputs */ }}>
@ -131,7 +145,12 @@ const App: React.FC<Props> = () => {
{!areRoomsLoaded && <span className="icon icon-loading-small icon-visible"></span>}
</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 colSpan={4} />
</tr>
@ -141,4 +160,4 @@ const App: React.FC<Props> = () => {
);
};
export default App;
export default App;

View File

@ -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<void>;
room: Room;
restriction?: Restriction;
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);
return (
@ -16,9 +17,9 @@ const EditRoom: React.FC<Props> = ({ room, updateProperty }) => {
onClick={ev => { ev.preventDefault(), setOpen(true); }}
title={t('bbb', 'Edit')} />
<EditRoomDialog room={room} updateProperty={updateProperty} open={open} setOpen={setOpen} />
<EditRoomDialog room={room} restriction={restriction} updateProperty={updateProperty} open={open} setOpen={setOpen} />
</>
);
};
export default EditRoom;
export default EditRoom;

View File

@ -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<void>;
open: boolean;
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 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<Props> = ({ room, updateProperty, open, setOpen }
<h3>{label}</h3>
</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>}
</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 (
<Dialog open={open} onClose={() => 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<Props> = ({ room, updateProperty, open, setOpen }
type="checkbox"
className="checkbox"
checked={room.record}
disabled={!restriction?.allowRecording}
onChange={(event) => updateProperty('record', event.target.checked)} />
<label htmlFor={`bbb-record-${room.id}`}>{t('bbb', 'Recording')}</label>
</div>
@ -126,4 +133,4 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty, open, setOpen }
);
};
export default EditRoomDialog;
export default EditRoomDialog;

View File

@ -6,9 +6,14 @@ type EditableValueProps = {
setValue: (key: string, value: string | number) => Promise<void>;
field: string;
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 submit = (value: string | number) => {
@ -31,6 +36,8 @@ const EditableValue: React.FC<EditableValueProps> = ({ setValue, field, value: c
initialValue={currentValue}
type={type}
focus={true}
min={options?.min}
max={options?.max}
/>;
}
@ -40,7 +47,11 @@ const EditableValue: React.FC<EditableValueProps> = ({ setValue, field, value: c
setActive(true);
}
if (options?.disabled) {
return <span>{currentValue}</span>;
}
return <a className="action-rename" onClick={onClick}>{currentValue}</a>;
};
export default EditableValue;
export default EditableValue;

View File

@ -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<Props> = ({recording, deleteRecording, storeRecordi
);
};
export default RecordingRow;
export default RecordingRow;

View File

@ -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<void>;
deleteRoom: (id: number) => void;
}
@ -154,8 +155,8 @@ const RoomRow: React.FC<Props> = (props) => {
);
}
function edit(field: string, type: 'text' | 'number' = 'text') {
return <EditableValue field={field} value={room[field]} setValue={updateRoom} type={type} />;
function edit(field: string, type: 'text' | 'number' = 'text', options?) {
return <EditableValue field={field} value={room[field]} setValue={updateRoom} type={type} options={options} />;
}
const avatarUrl = OC.generateUrl('/avatar/' + encodeURIComponent(room.userId) + '/' + 24, {
@ -164,6 +165,9 @@ const RoomRow: React.FC<Props> = (props) => {
requesttoken: OC.requestToken,
});
const maxParticipantsLimit = props.restriction?.maxParticipants || -1;
const minParticipantsLimit = (props.restriction?.maxParticipants || -1) < 1 ? 0 : 1;
return (
<>
<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" />}
</td>
<td className="max-participants bbb-shrink">
{edit('maxParticipants', 'number')}
{edit('maxParticipants', 'number', {min: minParticipantsLimit, max: maxParticipantsLimit < 0 ? undefined : maxParticipantsLimit})}
</td>
<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>
</td>
<td className="bbb-shrink"><RecordingsNumber recordings={recordings} showRecordings={showRecordings} setShowRecordings={setShowRecordings} /></td>
<td className="edit icon-col">
<EditRoom room={props.room} updateProperty={updateRoom} />
<EditRoom room={props.room} restriction={props.restriction} updateProperty={updateRoom} />
</td>
<td className="remove icon-col">
<a className="icon icon-delete icon-visible"

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { api, ShareWith, ShareType, RoomShare, Room, Permission, ShareWithOption } from './Api';
import { api, ShareWith, ShareType, RoomShare, Room, Permission, ShareWithOption } from '../Common/Api';
import './ShareWith.scss';
type Props = {
@ -172,4 +172,4 @@ const ShareWith: React.FC<Props> = ({ room, permission, shares: allShares, setSh
);
};
export default ShareWith;
export default ShareWith;

View File

@ -40,6 +40,8 @@ export class SubmitInput extends Component<SubmitInputProps, SubmitInputState> {
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}
/>
</form>;
}

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

@ -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<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"
onClick={() => { /* @TODO hide edit inputs */ }}>
<table>
<thead>
<tr>
<th>
{t('bbb', 'Group name')}
</th>
<th>
{t('bbb', 'Max. rooms')}
</th>
<th>
{t('bbb', 'Forbidden room types')}
</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,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<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} placeholder={t('bbb', 'No restriction')} />
</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;

View File

@ -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> = (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;

View File

@ -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;
}
}

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';
declare const OCP: any;

View File

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

View File

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