feat: allow to define moderators

ref #33
pull/63/head
sualko 2020-06-15 17:23:53 +02:00
parent de834240dd
commit dc30ec0e3e
15 changed files with 868 additions and 11 deletions

View File

@ -2,6 +2,7 @@
return [
'resources' => [
'room' => ['url' => '/rooms'],
'roomShare' => ['url' => '/roomShares'],
'room_api' => ['url' => '/api/0.1/rooms'],
],
'routes' => [

View File

@ -10,8 +10,11 @@ use BigBlueButton\Core\Record;
use BigBlueButton\Parameters\DeleteRecordingsParameters;
use BigBlueButton\Parameters\IsMeetingRunningParameters;
use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Service\RoomShareService;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\IGroupManager;
class API
{
@ -21,15 +24,25 @@ class API
/** @var IURLGenerator */
private $urlGenerator;
/** @var IGroupManager */
private $groupManager;
/** @var RoomShareService */
private $roomShareService;
/** @var BigBlueButton */
private $server;
public function __construct(
IConfig $config,
IURLGenerator $urlGenerator
IURLGenerator $urlGenerator,
IGroupManager $groupManager,
RoomShareService $roomShareService
) {
$this->config = $config;
$this->urlGenerator = $urlGenerator;
$this->groupManager = $groupManager;
$this->roomShareService = $roomShareService;
}
private function getServer()
@ -51,7 +64,7 @@ class API
*/
public function createJoinUrl(Room $room, int $creationTime, string $displayname, string $uid = null)
{
$password = $uid === $room->userId ? $room->moderatorPassword : $room->attendeePassword;
$password = $this->isModerator($room, $uid) ? $room->moderatorPassword : $room->attendeePassword;
$joinMeetingParams = new JoinMeetingParameters($room->uid, $displayname, $password);
@ -68,6 +81,38 @@ class API
return $this->getServer()->getJoinMeetingURL($joinMeetingParams);
}
private function isModerator(Room $room, string $uid): bool
{
if ($uid === null) {
return false;
}
if ($uid === $room->userId) {
return true;
}
$shares = $this->roomShareService->findAll($room->id);
/** @var RoomShare $share */
foreach ($shares as $share) {
if (!$share->hasModeratorPermission()) {
continue;
}
if ($share->getShareType() === RoomShare::SHARE_TYPE_USER) {
if ($share->getShareWith() === $uid) {
return true;
}
} elseif ($share->getShareType() === RoomShare::SHARE_TYPE_GROUP) {
if ($this->groupManager->isInGroup($uid, $share->getShareWith())) {
return true;
}
}
}
return false;
}
/**
* Create meeting room.
*

View File

@ -3,21 +3,28 @@
namespace OCA\BigBlueButton\Controller;
use Closure;
use Exception;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCA\BigBlueButton\Service\RoomNotFound;
use OCA\BigBlueButton\Service\RoomShareNotFound;
trait Errors
{
protected function handleNotFound(Closure $callback): DataResponse
{
try {
return new DataResponse($callback());
} catch (RoomNotFound $e) {
$message = ['message' => $e->getMessage()];
return new DataResponse($message, Http::STATUS_NOT_FOUND);
$return = $callback();
return ($return instanceof DataResponse) ? $return : new DataResponse($return);
} catch (Exception $e) {
if ($e instanceof RoomNotFound ||
$e instanceof RoomShareNotFound) {
$message = ['message' => $e->getMessage()];
return new DataResponse($message, Http::STATUS_NOT_FOUND);
}
throw $e;
}
}
}

View File

@ -0,0 +1,153 @@
<?php
namespace OCA\BigBlueButton\Controller;
use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Service\RoomService;
use OCA\BigBlueButton\Service\RoomShareNotFound;
use OCP\IRequest;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Controller;
use OCA\BigBlueButton\Service\RoomShareService;
use OCP\IUserManager;
class RoomShareController extends Controller
{
/** @var RoomShareService */
private $service;
/** @var string */
private $userId;
/** @var IUserManager */
private $userManager;
/** @var RoomService */
private $roomService;
use Errors;
public function __construct(
$appName,
IRequest $request,
RoomShareService $service,
IUserManager $userManager,
RoomService $roomService,
$userId
) {
parent::__construct($appName, $request);
$this->service = $service;
$this->userManager = $userManager;
$this->roomService = $roomService;
$this->userId = $userId;
}
/**
* @NoAdminRequired
*/
public function index(): DataResponse
{
$roomId = $this->request->getParam('id');
if ($roomId === null) {
return new DataResponse([], Http::STATUS_BAD_REQUEST);
}
if (!$this->isUserAllowed($roomId)) {
return new DataResponse([], Http::STATUS_FORBIDDEN);
}
$roomShares = $this->service->findAll($roomId);
/** @var RoomShare $roomShare */
foreach ($roomShares as $roomShare) {
$shareWithUser = $this->userManager->get($roomShare->getShareWith());
if ($shareWithUser !== null) {
$roomShare->setShareWithDisplayName($shareWithUser->getDisplayName());
}
}
return new DataResponse($roomShares);
}
/**
* @NoAdminRequired
*/
public function create(
int $roomId,
int $shareType,
string $shareWith,
int $permission
): DataResponse {
if (!$this->isUserAllowed($roomId)) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
return new DataResponse($this->service->create(
$roomId,
$shareType,
$shareWith,
$permission
));
}
/**
* @NoAdminRequired
*/
public function update(
int $id,
int $roomId,
int $shareType,
string $shareWith,
int $permission
): DataResponse {
if (!$this->isUserAllowed($roomId)) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
return $this->handleNotFound(function () use (
$id,
$roomId,
$shareType,
$shareWith,
$permission) {
return $this->service->update(
$id,
$roomId,
$shareType,
$shareWith,
$permission
);
});
}
/**
* @NoAdminRequired
*/
public function destroy(int $id): DataResponse
{
return $this->handleNotFound(function () use ($id) {
$roomShare = $this->service->find($id);
if (!$this->isUserAllowed($roomShare->getRoomId())) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
return $this->service->delete($id);
});
}
private function isUserAllowed(int $roomId): bool
{
try {
$room = $this->roomService->find($roomId, $this->userId);
return $room !== null;
} catch (RoomShareNotFound $e) {
return false;
}
}
}

56
lib/Db/RoomShare.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace OCA\BigBlueButton\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
class RoomShare extends Entity implements JsonSerializable
{
const PERMISSION_ADMIN = 0;
const PERMISSION_MODERATOR = 1;
const PERMISSION_USER = 2;
const SHARE_TYPE_USER = 0;
const SHARE_TYPE_GROUP = 1;
protected $roomId;
protected $shareType;
protected $shareWith;
protected $shareWithDisplayName;
protected $permission;
public function __construct()
{
$this->addType('roomId', 'integer');
$this->addType('shareType', 'integer');
$this->addType('permission', 'integer');
}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'roomId' => $this->roomId,
'shareType' => $this->shareType,
'shareWith' => $this->shareWith,
'shareWithDisplayName' => $this->shareWithDisplayName,
'permission' => $this->permission,
];
}
public function hasUserPermission(): bool
{
return $this->permission === self::PERMISSION_ADMIN || $this->permission === self::PERMISSION_MODERATOR || $this->permission === self::PERMISSION_USER;
}
public function hasModeratorPermission(): bool
{
return $this->permission === self::PERMISSION_ADMIN || $this->permission === self::PERMISSION_MODERATOR;
}
public function hasAdminPermission(): bool
{
return $this->permission === self::PERMISSION_ADMIN;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace OCA\BigBlueButton\Db;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
class RoomShareMapper extends QBMapper
{
public function __construct(IDBConnection $db)
{
parent::__construct($db, 'bbb_room_shares', RoomShare::class);
}
/**
* @param int $id
* @return Entity|RoomShare
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(int $id): RoomShare
{
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('bbb_room_shares')
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
public function findAll(int $roomId): array
{
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('bbb_room_shares')
->where($qb->expr()->eq('room_id', $qb->createNamedParameter($roomId, IQueryBuilder::PARAM_INT)));
return $this->findEntities($qb);
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace OCA\BigBlueButton\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version000000Date20200613111242 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_room_shares')) {
$table = $schema->createTable('bbb_room_shares');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
]);
$table->addColumn('room_id', 'integer', [
'notnull' => true,
]);
$table->addColumn('share_with', 'string', [
'notnull' => true,
'length' => 200,
]);
$table->addColumn('share_type', 'integer', [
'notnull' => true,
]);
$table->addColumn('permission', 'integer', [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
}
return $schema;
}
}

View File

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

View File

@ -0,0 +1,86 @@
<?php
namespace OCA\BigBlueButton\Service;
use Exception;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Db\RoomShareMapper;
class RoomShareService
{
/** @var RoomShareMapper */
private $mapper;
public function __construct(RoomShareMapper $mapper)
{
$this->mapper = $mapper;
}
public function findAll(int $roomId): array
{
return $this->mapper->findAll($roomId);
}
private function handleException(Exception $e): void
{
if ($e instanceof DoesNotExistException ||
$e instanceof MultipleObjectsReturnedException) {
throw new RoomShareNotFound($e->getMessage());
} else {
throw $e;
}
}
public function find($id): RoomShare
{
try {
return $this->mapper->find($id);
} catch (Exception $e) {
$this->handleException($e);
}
}
public function create(int $roomId, int $shareType, string $shareWith, int $permission): RoomShare
{
$roomShare = new RoomShare();
$roomShare->setRoomId($roomId);
$roomShare->setShareType($shareType);
$roomShare->setShareWith($shareWith);
$roomShare->setPermission($permission);
return $this->mapper->insert($roomShare);
}
public function update(int $id, int $roomId, int $shareType, string $shareWith, int $permission): RoomShare
{
try {
$roomShare = $this->mapper->find($id);
$roomShare->setRoomId($roomId);
$roomShare->setShareType($shareType);
$roomShare->setShareWith($shareWith);
$roomShare->setPermission($permission);
return $this->mapper->update($roomShare);
} catch (Exception $e) {
$this->handleException($e);
}
}
public function delete(int $id): RoomShare
{
try {
$roomShare = $this->mapper->find($id);
$this->mapper->delete($roomShare);
return $roomShare;
} catch (Exception $e) {
$this->handleException($e);
}
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace OCA\BigBlueButton\Tests\Controller;
use PHPUnit\Framework\TestCase;
use OCP\IRequest;
use OCA\BigBlueButton\Service\RoomService;
use OCA\BigBlueButton\Controller\RoomShareController;
use OCA\BigBlueButton\Db\Room;
use OCA\BigBlueButton\Db\RoomShare;
use OCA\BigBlueButton\Service\RoomShareNotFound;
use OCA\BigBlueButton\Service\RoomShareService;
use OCP\AppFramework\Http;
use OCP\IUserManager;
class RoomShareControllerTest extends TestCase
{
private $request;
private $service;
private $roomService;
private $userManager;
private $controller;
public function setUp(): void
{
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->service = $this->createMock(RoomShareService::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->roomService = $this->createMock(RoomService::class);
$this->controller = new RoomShareController(
'bbb',
$this->request,
$this->service,
$this->userManager,
$this->roomService,
'user_foo'
);
}
public function testIndexWithoutRoomId()
{
$response = $this->controller->index();
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
}
public function testIndexWithoutPermission()
{
$this->request
->expects($this->once())
->method('getParam')
->with('id')
->willReturn(1234);
$this->roomService
->expects($this->once())
->method('find')
->will($this->throwException(new RoomShareNotFound));
$response = $this->controller->index();
$this->assertEquals(Http::STATUS_FORBIDDEN, $response->getStatus());
}
public function testIndexWithoutShares()
{
$roomId = 1234;
$this->request
->expects($this->once())
->method('getParam')
->with('id')
->willReturn($roomId);
$this->roomService
->expects($this->once())
->method('find')
->willReturn(new Room());
$this->service
->expects($this->once())
->method('findAll')
->with($roomId)
->willReturn([]);
$response = $this->controller->index();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$this->assertEquals([], $response->getData());
}
public function testIndexWithShares()
{
$roomId = 1234;
$this->request
->expects($this->once())
->method('getParam')
->with('id')
->willReturn($roomId);
$this->roomService
->expects($this->once())
->method('find')
->willReturn(new Room());
$this->service
->expects($this->once())
->method('findAll')
->with($roomId)
->willReturn([
new RoomShare()
]);
$response = $this->controller->index();
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$this->assertCount(1, $response->getData());
}
}

View File

@ -1,5 +1,9 @@
import axios from '@nextcloud/axios';
export enum ShareType { User, Group };
export enum Permission { Admin, Moderator, User };
export enum Access {
Public = 'public',
Password = 'password',
@ -19,6 +23,15 @@ export interface Room {
password?: string;
}
export interface RoomShare {
id: number;
roomId: number;
shareType: ShareType;
shareWith: string;
shareWithDisplayName?: string;
permission: Permission;
}
export type Recording = {
id: string;
name: string;
@ -32,6 +45,23 @@ export type Recording = {
meta: any;
}
export interface ShareWith {
users: {
label: string;
value: {
shareType: ShareType;
shareWith: string;
};
}[];
groups: {
label: string;
value: {
shareType: ShareType;
shareWith: string;
};
}[];
}
class Api {
public getUrl(endpoint: string): string {
return OC.generateUrl(`apps/bbb/${endpoint}`);
@ -65,7 +95,7 @@ class Api {
}
public async deleteRoom(id: number) {
const response = await axios.delete( this.getUrl(`rooms/${id}`));
const response = await axios.delete(this.getUrl(`rooms/${id}`));
return response.data;
}
@ -101,7 +131,7 @@ class Api {
return filename;
}
public async checkServer(url: string, secret: string): Promise<'success'|'invalid-url'|'invalid:secret'> {
public async checkServer(url: string, secret: string): Promise<'success' | 'invalid-url' | 'invalid:secret'> {
const response = await axios.post(this.getUrl('server/check'), {
url,
secret,
@ -109,6 +139,66 @@ class Api {
return response.data;
}
public async getRoomShares(roomId: number): Promise<RoomShare[]> {
const response = await axios.get(this.getUrl('roomShares'), {
params: {
id: roomId,
},
});
return response.data;
}
public async createRoomShare(roomId: number, shareType: ShareType, shareWith: string, permission: Permission): Promise<RoomShare> {
const response = await axios.post(this.getUrl('roomShares'), {
roomId,
shareType,
shareWith,
permission,
});
return response.data;
}
public async deleteRoomShare(id: number) {
const response = await axios.delete(this.getUrl(`roomShares/${id}`));
return response.data;
}
public async getRecommendedShareWith(): Promise<ShareWith> {
const url = OC.linkToOCS('apps/files_sharing/api/v1', 1) + 'sharees_recommended';
const response = await axios.get(url, {
params: {
itemType: 'room',
format: 'json',
},
});
return {
users: response.data.ocs.data.exact.users,
groups: response.data.ocs.data.exact.groups,
};
}
public async searchShareWith(search = ''): 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],
itemType: 'room',
format: 'json',
lookup: false,
},
});
return {
users: response.data.ocs.data.users,
groups: response.data.ocs.data.groups,
};
}
}
export const api = new Api();

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Access, Room } from './Api';
import Dialog from './Dialog';
import { Room, Access } from './Api';
import ShareWith from './ShareWith';
import { SubmitInput } from './SubmitInput';
const descriptions: { [key: string]: string } = {
@ -74,6 +75,15 @@ const EditRoomDialog: React.FC<Props> = ({ room, updateProperty }) => {
updateProperty('access', value);
})}
<div className="bbb-form-element">
<label htmlFor={'bbb-moderator'}>
<h3>Moderator</h3>
</label>
<ShareWith room={room} />
</div>
<h3>{t('bbb', 'Miscellaneous')}</h3>
<div>
<div>
<input id={`bbb-record-${room.id}`}

View File

@ -24,6 +24,8 @@ declare namespace OC {
}
namespace Share {
const SHARE_TYPE_USER = 0;
const SHARE_TYPE_GROUP = 1;
const SHARE_TYPE_LINK = 3;
}
@ -52,7 +54,7 @@ declare namespace OC {
function requirePasswordConfirmation(cb: () => void): void;
}
function generateUrl(url: string, parameters?: { [key: string]: string }, options?: EscapeOptions)
function generateUrl(url: string, parameters?: { [key: string]: string|number }, options?: EscapeOptions)
function linkToOCS(service: string, version: number): string;
@ -71,6 +73,10 @@ declare namespace OC {
const currentUser: string;
function getCurrentUser(): {uid: string; displayName: string}
const requestToken: string;
const config: {
blacklist_files_regex: string;
enable_avatars: boolean;

53
ts/Manager/ShareWith.scss Normal file
View File

@ -0,0 +1,53 @@
.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 {
height: 44px;
width: 44px;
}
&__label {
padding: 0 1em;
flex-grow: 1;
}
}
}
.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 {
padding: 0 1em;
cursor: pointer;
line-height: 44px;
&:hover {
background-color: var(--color-background-hover);
}
}
}

129
ts/Manager/ShareWith.tsx Normal file
View File

@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react';
import { api, ShareWith, ShareType, RoomShare, Room, Permission } from './Api';
import './ShareWith.scss';
type Props = {
room: Room;
}
const SearchInput: React.FC<Props> = ({ room }) => {
const [search, setSearch] = useState<string>('');
const [hasFocus, setFocus] = useState<boolean>(false);
const [recommendations, setRecommendations] = useState<ShareWith>();
const [searchResults, setSearchResults] = useState<ShareWith>();
const [shares, setShares] = useState<RoomShare[]>();
const userShares = shares ? shares.filter(share => share.shareType === ShareType.User).map(share => share.shareWith) : [];
const groupShares = shares ? shares.filter(share => share.shareType === ShareType.Group).map(share => share.shareWith) : [];
useEffect(() => {
api.getRoomShares(room.id).then(roomShares => {
setShares(roomShares);
}).catch(err => {
console.warn('Could not load room shares.', err);
setShares([]);
});
}, [room.id]);
useEffect(() => {
api.searchShareWith(search).then(result => {
setSearchResults(result);
});
}, [search]);
useEffect(() => {
api.getRecommendedShareWith().then(result => setRecommendations(result));
}, []);
async function addRoomShare(shareWith: string, shareType: number, displayName: string) {
const roomShare = await api.createRoomShare(room.id, shareType, shareWith, Permission.Moderator);
roomShare.shareWithDisplayName = displayName;
setShares([...(shares || []), roomShare]);
}
async function deleteRoomShare(id: number) {
await api.deleteRoomShare(id);
setShares(shares?.filter(share => share.id !== id));
}
function renderSearchResults(options: ShareWith) {
return (
<ul className="bbb-selection">
{[
...options.users.filter(user => !userShares.includes(user.value.shareWith)),
...options.groups.filter(group => !groupShares.includes(group.value.shareWith)),
].map(option => {
return (<li key={option.value.shareWith} onClick={() => addRoomShare(option.value.shareWith, option.value.shareType, option.label)}>
{option.label}{option.value.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''}
</li>);
})}
</ul>
);
}
function renderShares(shares: RoomShare[]) {
const currentUser = OC.getCurrentUser();
const ownShare = {
id: -1,
roomId: room.id,
shareType: ShareType.User,
shareWith: currentUser.uid,
shareWithDisplayName: currentUser.displayName,
permission: Permission.Admin,
};
return (
<ul className="bbb-shareWith">
{[ownShare, ...shares].map(share => {
const avatarUrl = share.shareType === ShareType.User ? OC.generateUrl('/avatar/' + encodeURIComponent(share.shareWith) + '/' + 32, {
user: share.shareWith,
size: 32,
requesttoken: OC.requestToken,
}) : undefined;
const displayName = share.shareWithDisplayName || share.shareWith;
return (
<li key={share.id} className="bbb-shareWith__item">
<div className="avatardiv">
{avatarUrl && <img src={avatarUrl} alt={`Avatar from ${displayName}`} />}
</div>
<div className="bbb-shareWith__item__label">
<h5>{displayName}{share.shareType === ShareType.Group ? ` (${t('bbb', 'Group')})` : ''}</h5>
</div>
{share.id > -1 && <div className="bbb-shareWith__item__action">
<a className="icon icon-delete icon-visible"
onClick={ev => {ev.preventDefault(); deleteRoomShare(share.id);}}
title={t('bbb', 'Delete')} />
</div>}
</li>
);
})}
</ul>
);
}
const loading = <><span className="icon icon-loading-small icon-visible"></span> {t('bbb', 'Loading')}</>;
return (
<>
{shares ? renderShares(shares) : loading}
<div className="bbb-selection-container">
<input
type="text"
value={search}
onChange={ev => setSearch(ev.currentTarget.value)}
onFocus={() => setFocus(true)}
onBlur={() => setTimeout(() => setFocus(false), 100)}
placeholder={t('bbb', 'Name, group, ...')} />
{hasFocus && (searchResults ? renderSearchResults(searchResults) : (recommendations ? renderSearchResults(recommendations) : loading))}
</div>
</>
);
};
export default SearchInput;