From 31c36dfa511d36dd09197e3192b799d23e718a38 Mon Sep 17 00:00:00 2001 From: Sam428-png Date: Mon, 20 Apr 2026 13:45:51 +0200 Subject: [PATCH] feat: Add simplified admin API for circle/team management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /admin/manage/ endpoints that allow admins to fully manage circles (teams) without needing to be a member. Unlike the existing /admin/{emulated}/ endpoints which require user emulation, these endpoints use super sessions to operate directly as admin. New endpoints: - GET/POST/PUT/DELETE /admin/manage/circles — CRUD for circles - GET/POST/DELETE /admin/manage/circles/{id}/members — member management - PUT /admin/manage/circles/{id}/members/{id}/level — set member level Based on the SURF circlesadmin app (sara-nl/nextcloud-circleadmin-api). Signed-off-by: Sam428-png --- appinfo/routes.php | 18 ++ lib/Controller/AdminManageController.php | 201 ++++++++++++++++++++ lib/Service/AdminManageService.php | 231 +++++++++++++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 lib/Controller/AdminManageController.php create mode 100644 lib/Service/AdminManageService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 9ad74e1f8..edec1938b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -81,6 +81,24 @@ ['name' => 'Admin#editConfig', 'url' => '/admin/{emulated}/circles/{circleId}/config', 'verb' => 'PUT'], ['name' => 'Admin#link', 'url' => '/admin/{emulated}/link/{circleId}/{singleId}', 'verb' => 'GET'], + // AdminManageController - simplified admin controller (no user emulation) + ['name' => 'AdminManage#index', 'url' => '/admin/manage/circles', 'verb' => 'GET'], + ['name' => 'AdminManage#show', 'url' => '/admin/manage/circles/{circleId}', 'verb' => 'GET'], + ['name' => 'AdminManage#create', 'url' => '/admin/manage/circles', 'verb' => 'POST'], + ['name' => 'AdminManage#update', 'url' => '/admin/manage/circles/{circleId}', 'verb' => 'PUT'], + ['name' => 'AdminManage#destroy', 'url' => '/admin/manage/circles/{circleId}', 'verb' => 'DELETE'], + ['name' => 'AdminManage#members', 'url' => '/admin/manage/circles/{circleId}/members', 'verb' => 'GET'], + ['name' => 'AdminManage#addMember', 'url' => '/admin/manage/circles/{circleId}/members', 'verb' => 'POST'], + [ + 'name' => 'AdminManage#removeMember', 'url' => '/admin/manage/circles/{circleId}/members/{memberId}', + 'verb' => 'DELETE' + ], + [ + 'name' => 'AdminManage#setMemberLevel', + 'url' => '/admin/manage/circles/{circleId}/members/{memberId}/level', + 'verb' => 'PUT' + ], + ['name' => 'Settings#getValues', 'url' => '/settings/', 'verb' => 'GET'], ['name' => 'Settings#setValue', 'url' => '/settings/{key}/', 'verb' => 'POST'], ], diff --git a/lib/Controller/AdminManageController.php b/lib/Controller/AdminManageController.php new file mode 100644 index 000000000..cc7f04157 --- /dev/null +++ b/lib/Controller/AdminManageController.php @@ -0,0 +1,201 @@ +adminManageService = $adminManageService; + $this->logger = $logger; + $this->userId = $userId ?? ''; + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function index(): DataResponse { + try { + return new DataResponse($this->adminManageService->listAll()); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: list failed: ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function show(string $circleId): DataResponse { + try { + return new DataResponse($this->adminManageService->getCircle($circleId)); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: show failed for ' . $circleId . ': ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_NOT_FOUND + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function create(string $name, string $owner = ''): DataResponse { + $ownerUserId = $owner ?: $this->userId; + // Get description from request params (not a method param to avoid Dispatcher issues) + $params = $this->request->getParams(); + $description = isset($params['description']) ? (string)$params['description'] : null; + $federated = !empty($params['federated']); + try { + return new DataResponse( + $this->adminManageService->createCircle($name, $ownerUserId, $description, $federated), + Http::STATUS_CREATED + ); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: create failed: ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function update(string $circleId, ?string $name = null, ?string $description = null): DataResponse { + if ($name === null && $description === null) { + return new DataResponse( + ['message' => 'Provide at least one of: name, description'], + Http::STATUS_BAD_REQUEST + ); + } + try { + return new DataResponse($this->adminManageService->updateCircle($circleId, $name, $description)); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: update failed for ' . $circleId . ': ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function destroy(string $circleId): DataResponse { + try { + $this->adminManageService->destroyCircle($circleId); + return new DataResponse(['message' => 'Circle deleted']); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: destroy failed for ' . $circleId . ': ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function members(string $circleId): DataResponse { + try { + return new DataResponse($this->adminManageService->getMembers($circleId)); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: members list failed for ' . $circleId . ': ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_NOT_FOUND + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function addMember(string $circleId, string $userId): DataResponse { + try { + return new DataResponse( + $this->adminManageService->addMember($circleId, $userId), + Http::STATUS_CREATED + ); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: add member failed for ' . $circleId . '/' . $userId . ': ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function removeMember(string $circleId, string $memberId): DataResponse { + try { + $this->adminManageService->removeMember($circleId, $memberId); + return new DataResponse(['message' => 'Member removed']); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: remove member failed: ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } + } + + /** + * @AdminRequired + * @NoCSRFRequired + */ + public function setMemberLevel(string $circleId, string $memberId, int $level): DataResponse { + try { + $this->adminManageService->setMemberLevel($circleId, $memberId, $level); + return new DataResponse(['message' => 'Level updated']); + } catch (\Exception $e) { + $this->logger->error('circlesadmin: set level failed: ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_BAD_REQUEST + ); + } + } +} diff --git a/lib/Service/AdminManageService.php b/lib/Service/AdminManageService.php new file mode 100644 index 000000000..367940e1e --- /dev/null +++ b/lib/Service/AdminManageService.php @@ -0,0 +1,231 @@ +circlesManager = $circlesManager; + $this->userManager = $userManager; + $this->db = $db; + $this->logger = $logger; + } + + private function getCircleService(): CircleService { + return Server::get(CircleService::class); + } + + private function stopSession(): void { + try { + $this->circlesManager->stopSession(); + } catch (\Exception $e) { + } + } + + public function listAll(): array { + $this->circlesManager->startSuperSession(); + try { + $probe = new CircleProbe(); + $probe->includeSystemCircles() + ->includeSingleCircles() + ->includeHiddenCircles() + ->includeBackendCircles(); + $circles = $this->circlesManager->getCircles($probe); + $result = []; + foreach ($circles as $circle) { + $result[] = $this->formatCircle($circle); + } + return $result; + } finally { + $this->stopSession(); + } + } + + public function getCircle(string $circleId): array { + $this->circlesManager->startSuperSession(); + try { + $circle = $this->circlesManager->getCircle($circleId); + $data = $this->formatCircle($circle); + $data['description'] = $circle->getDescription(); + $data['members'] = []; + foreach ($circle->getMembers() as $member) { + $data['members'][] = $this->formatMember($member); + } + return $data; + } finally { + $this->stopSession(); + } + } + + public function createCircle(string $name, string $ownerUserId, ?string $description = null, bool $federated = false): array { + $this->circlesManager->startSuperSession(); + $this->circlesManager->startAppSession('circles'); + try { + $owner = $this->circlesManager->getFederatedUser($ownerUserId, Member::TYPE_USER); + $circle = $this->circlesManager->createCircle($name, $owner); + $circleId = $circle->getSingleId(); + + // when enabling federation, CFG_ROOT must be enabled alongside to prevent the circle from being nested + $federatedConfigValue = Circle::CFG_ROOT + Circle::CFG_FEDERATED; + + $updates = [ + 'config' => $federated ? $federatedConfigValue : 0, + ]; + if ($description !== null && $description !== '') { + $updates['description'] = $description; + } + $qb = $this->db->getQueryBuilder(); + $qb->update('circles_circle') + ->where($qb->expr()->eq('unique_id', $qb->createNamedParameter($circleId))); + foreach ($updates as $column => $value) { + $qb->set($column, $qb->createNamedParameter($value)); + } + $qb->executeStatement(); + + $data = $this->formatCircle($circle); + $data['description'] = $description ?? ''; + $data['config'] = $federated ? $federatedConfigValue : 0; + return $data; + } finally { + $this->stopSession(); + } + } + + public function updateCircle(string $circleId, ?string $name, ?string $description): array { + $this->circlesManager->startSuperSession(true); + $this->circlesManager->startOccSession('', Member::TYPE_SINGLE, $circleId); + try { + $circleService = $this->getCircleService(); + if ($name !== null) { + $circleService->updateName($circleId, $name); + } + if ($description !== null) { + $circleService->updateDescription($circleId, $description); + } + $this->circlesManager->stopSession(); + $this->circlesManager->startSuperSession(); + $circle = $this->circlesManager->getCircle($circleId); + $data = $this->formatCircle($circle); + $data['description'] = $circle->getDescription(); + return $data; + } finally { + $this->stopSession(); + } + } + + public function destroyCircle(string $circleId): void { + $this->circlesManager->startSuperSession(true); + $this->circlesManager->startOccSession('', Member::TYPE_SINGLE, $circleId); + try { + $this->circlesManager->destroyCircle($circleId); + } finally { + $this->stopSession(); + } + } + + public function getMembers(string $circleId): array { + $this->circlesManager->startSuperSession(); + try { + $circle = $this->circlesManager->getCircle($circleId); + $result = []; + foreach ($circle->getMembers() as $member) { + $result[] = $this->formatMember($member); + } + return $result; + } finally { + $this->stopSession(); + } + } + + public function addMember(string $circleId, string $userId): array { + $this->circlesManager->startSuperSession(true); + $this->circlesManager->startOccSession('', Member::TYPE_SINGLE, $circleId); + try { + $federatedUser = $this->circlesManager->getFederatedUser($userId, Member::TYPE_USER); + $member = $this->circlesManager->addMember($circleId, $federatedUser); + return $this->formatMember($member); + } finally { + $this->stopSession(); + } + } + + public function removeMember(string $circleId, string $memberId): void { + $this->circlesManager->startSuperSession(true); + $this->circlesManager->startOccSession('', Member::TYPE_SINGLE, $circleId); + try { + $this->circlesManager->removeMember($memberId); + } finally { + $this->stopSession(); + } + } + + public function setMemberLevel(string $circleId, string $memberId, int $level): void { + $this->circlesManager->startSuperSession(true); + $this->circlesManager->startOccSession('', Member::TYPE_SINGLE, $circleId); + try { + $this->circlesManager->levelMember($memberId, $level); + } finally { + $this->stopSession(); + } + } + + private function formatCircle(Circle $circle): array { + $owner = $circle->getOwner(); + return [ + 'id' => $circle->getSingleId(), + 'name' => $circle->getDisplayName(), + 'owner' => $owner->getUserId(), + 'memberCount' => $circle->getMembers() ? count($circle->getMembers()) : 0, + 'config' => $circle->getConfig(), + 'source' => $circle->getSource(), + ]; + } + + private function formatMember(Member $member): array { + return [ + 'id' => $member->getId(), + 'singleId' => $member->getSingleId(), + 'userId' => $member->getUserId(), + 'displayName' => $member->getDisplayName(), + 'level' => $member->getLevel(), + 'levelName' => $this->getLevelName($member->getLevel()), + 'status' => $member->getStatus(), + 'userType' => $member->getUserType(), + 'userTypeName' => $this->getUserTypeName($member->getUserType()), + ]; + } + + private function getUserTypeName(int $type): string { + return ucfirst(Member::$TYPE[$type] ?? 'Unknown (' . $type . ')'); + } + + private function getLevelName(int $level): string { + return Member::$DEF_LEVEL[$level] ?? 'Unknown (' . $level . ')'; + } +}