From e2bc62249fedda36c069bfe2d4b8c5369b00a5b6 Mon Sep 17 00:00:00 2001 From: Anton Josefsson Date: Fri, 2 Oct 2026 20:33:25 +0200 Subject: [PATCH] fix(teams): hide team creation when limit_circle_creation excludes the user Expose whether the current user may create teams as initial state on the Teams page and the dashboard widget, and hide every creation entry point (home banner, empty states, team switcher, dashboard widget button) when limit_circle_creation is set and the user has no permission. Fixes #1940 Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Anton Josefsson --- lib/Controller/PageController.php | 10 ++ lib/Dashboard/TeamDashboardWidget.php | 25 ++- lib/Service/PermissionService.php | 38 ++++- src/teams/App.spec.ts | 73 +++++++++ src/teams/App.vue | 6 +- src/teams/components/TeamHeader.spec.ts | 94 +++++++++++ src/teams/components/TeamHeader.vue | 3 +- src/teams/store.spec.ts | 73 ++++++++- src/teams/store.ts | 14 +- src/teams/views/HomeView.spec.ts | 49 ++++++ src/teams/views/HomeView.vue | 11 +- src/views/DashboardTeamsWidget.spec.ts | 70 ++++++++ src/views/DashboardTeamsWidget.vue | 9 +- .../lib/Controller/PageControllerTest.php | 32 +++- .../lib/Dashboard/TeamDashboardWidgetTest.php | 99 ++++++++++++ .../lib/Service/PermissionServiceTest.php | 153 ++++++++++++++++++ 16 files changed, 740 insertions(+), 19 deletions(-) create mode 100644 src/teams/App.spec.ts create mode 100644 src/teams/components/TeamHeader.spec.ts create mode 100644 src/views/DashboardTeamsWidget.spec.ts create mode 100644 tests/unit/lib/Dashboard/TeamDashboardWidgetTest.php create mode 100644 tests/unit/lib/Service/PermissionServiceTest.php diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index cc12d8b49..b4687bdd0 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -11,6 +11,7 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\Service\ConfigService; +use OCA\Circles\Service\PermissionService; use OCA\Circles\Service\TeamFolderPolicy; use OCA\Text\Event\LoadEditor; use OCP\AppFramework\Controller; @@ -22,6 +23,7 @@ use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; +use OCP\IUserSession; use OCP\Teams\ITeamManager; use OCP\Util; @@ -36,6 +38,8 @@ public function __construct( private ITeamManager $teamManager, private TeamFolderPolicy $teamFolderPolicy, private IEventDispatcher $eventDispatcher, + private IUserSession $userSession, + private PermissionService $permissionService, ) { parent::__construct(Application::APP_ID, $request); } @@ -56,6 +60,12 @@ public function index(): TemplateResponse|NotFoundResponse { 'teamFolderProvisioningEnabled', $this->teamFolderPolicy->isTeamFolderProvisioningEnabled(), ); + $this->initialState->provideInitialState( + 'canCreateTeam', + $this->permissionService->canUserCreateCircle( + $this->userSession->getUser()?->getUID() ?? '' + ), + ); Util::addScript(Application::APP_ID, 'teams-main'); Util::addStyle(Application::APP_ID, 'teams-main'); diff --git a/lib/Dashboard/TeamDashboardWidget.php b/lib/Dashboard/TeamDashboardWidget.php index e9857fbc2..c1fa16981 100644 --- a/lib/Dashboard/TeamDashboardWidget.php +++ b/lib/Dashboard/TeamDashboardWidget.php @@ -9,12 +9,15 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\Service\ConfigService; +use OCA\Circles\Service\PermissionService; +use OCP\AppFramework\Services\IInitialState; use OCP\Dashboard\IButtonWidget; use OCP\Dashboard\IConditionalWidget; use OCP\Dashboard\IIconWidget; use OCP\Dashboard\Model\WidgetButton; use OCP\IL10N; use OCP\IURLGenerator; +use OCP\IUserSession; use OCP\Util; class TeamDashboardWidget implements IIconWidget, IButtonWidget, IConditionalWidget { @@ -22,6 +25,9 @@ public function __construct( private readonly IURLGenerator $urlGenerator, private readonly IL10N $l10n, private readonly ConfigService $configService, + private readonly IInitialState $initialState, + private readonly IUserSession $userSession, + private readonly PermissionService $permissionService, ) { } @@ -66,21 +72,32 @@ public function getUrl(): ?string { public function load(): void { Util::addScript(Application::APP_ID, 'teams-dashboard'); Util::addStyle(Application::APP_ID, 'teams-dashboard'); + $this->initialState->provideInitialState( + 'canCreateTeam', + $this->permissionService->canUserCreateCircle( + $this->userSession->getUser()?->getUID() ?? '' + ), + ); } public function getWidgetButtons(string $userId): array { - return [ + $buttons = [ new WidgetButton( WidgetButton::TYPE_MORE, $this->getTeamPage(), $this->l10n->t('Show all teams') ), - new WidgetButton( + ]; + + if ($this->permissionService->canUserCreateCircle($userId)) { + $buttons[] = new WidgetButton( WidgetButton::TYPE_SETUP, $this->getTeamPage(), $this->l10n->t('Create a new team') - ), - ]; + ); + } + + return $buttons; } public function getIconUrl(): string { diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index c36b06e1e..65af4e995 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -9,6 +9,7 @@ namespace OCA\Circles\Service; +use Exception; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Db\MembershipRequest; use OCA\Circles\Exceptions\InitiatorNotFoundException; @@ -19,6 +20,7 @@ use OCA\Circles\Exceptions\MembershipNotFoundException; use OCA\Circles\Exceptions\RequestBuilderException; use OCA\Circles\Model\Circle; +use OCA\Circles\Model\FederatedUser; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Model\Member; use OCP\IGroupManager; @@ -51,6 +53,27 @@ public function confirmCircleCreation(): void { } } + /** + * Non-throwing variant of confirmCircleCreation() for a given local user, + * used to decide whether team creation is offered in the frontend. + */ + public function canUserCreateCircle(string $userId): bool { + $singleId = $this->configService->getAppValue( + ConfigService::LIMIT_CIRCLE_CREATION + ); + if ($singleId === '') { + return true; + } + + try { + $federatedUser = $this->federatedUserService + ->getLocalFederatedUser($userId); + return $this->isLinkedTo($federatedUser, $singleId); + } catch (Exception) { + return false; + } + } + /** * @param string $config * @@ -66,10 +89,23 @@ private function confirm(string $config): void { $this->federatedUserService->mustHaveCurrentUser(); $federatedUser = $this->federatedUserService->getCurrentUser(); + if (!$this->isLinkedTo($federatedUser, $singleId)) { + throw new InsufficientPermissionException(); + } + } + + /** + * @throws RequestBuilderException + */ + private function isLinkedTo( + FederatedUser $federatedUser, + string $singleId, + ): bool { try { $federatedUser->getLink($singleId); + return true; } catch (MembershipNotFoundException) { - throw new InsufficientPermissionException(); + return false; } } diff --git a/src/teams/App.spec.ts b/src/teams/App.spec.ts new file mode 100644 index 000000000..b878559df --- /dev/null +++ b/src/teams/App.spec.ts @@ -0,0 +1,73 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { shallowMount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import App from './App.vue' +import { useTeamsStore } from './store.ts' + +type LoadState = (app: string, key: string, fallback?: unknown) => unknown + +const loadState = vi.hoisted(() => vi.fn()) + +vi.mock('@nextcloud/initial-state', () => ({ loadState })) +vi.mock('./api.ts', () => ({ fetchTeams: vi.fn(async () => []) })) +vi.mock('./components/TeamNavigation.vue', () => ({ + default: { name: 'TeamNavigation', template: '