From 25e55683d93c161ddfc1d15cf3db86783dc8226c Mon Sep 17 00:00:00 2001 From: Lennart Joswig Date: Fri, 31 Jul 2026 12:50:55 +0200 Subject: [PATCH] feat: add Teams admin settings and group-based creation limits feat: hide create actions and adapt empty-state when create is denied Signed-off-by: Lennart Joswig --- appinfo/info.xml | 2 + lib/ConfigLexicon.php | 2 + lib/Controller/PageController.php | 6 + lib/Controller/SettingsController.php | 30 +++- lib/Dashboard/TeamDashboardWidget.php | 27 +++- lib/Service/PermissionService.php | 84 +++++++++++ lib/Settings/Section.php | 43 ++++++ lib/Settings/TeamsAdmin.php | 74 ++++++++++ src/components/TeamsAdminSettings.vue | 115 +++++++++++++++ src/settings-teams-admin.ts | 12 ++ src/teams/components/GlobalNavigation.vue | 3 +- src/teams/store.ts | 4 + src/teams/views/HomeView.vue | 11 +- src/views/DashboardTeamsWidget.vue | 12 +- templates/settings-teams-admin.php | 8 ++ tests/bootstrap.php | 42 +++++- .../lib/Service/PermissionServiceTest.php | 135 ++++++++++++++++++ vite.config.ts | 1 + 18 files changed, 597 insertions(+), 14 deletions(-) create mode 100644 lib/Settings/Section.php create mode 100644 lib/Settings/TeamsAdmin.php create mode 100644 src/components/TeamsAdminSettings.vue create mode 100644 src/settings-teams-admin.ts create mode 100644 templates/settings-teams-admin.php create mode 100644 tests/unit/lib/Service/PermissionServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index ecbdf8608..7ab9081e6 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -100,5 +100,7 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Settings\Admin + OCA\Circles\Settings\TeamsAdmin + OCA\Circles\Settings\Section diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 2a83a9997..32b4c596b 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -24,6 +24,7 @@ class ConfigLexicon implements ILexicon { public const FEDERATED_TEAMS_ENABLED = 'federated_teams_enabled'; public const FEDERATED_TEAMS_FRONTAL = 'federated_teams_frontal'; public const REMOVE_SHARE_TOKENS_DONE = 'remove_share_tokens_done'; + public const TEAM_CREATION_ALLOWED_GROUPS = 'team_creation_allowed_groups'; public function getStrictness(): Strictness { return Strictness::IGNORE; @@ -34,6 +35,7 @@ public function getAppConfigs(): array { new Entry(key: self::FEDERATED_TEAMS_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable Federated Teams', lazy: true), new Entry(key: self::FEDERATED_TEAMS_FRONTAL, type: ValueType::STRING, defaultRaw: '', definition: 'domain name used to auth public request', lazy: true), new Entry(key: self::REMOVE_SHARE_TOKENS_DONE, type: ValueType::BOOL, defaultRaw: false, definition: 'whether the remove share tokens repair step has already been executed', lazy: true), + new Entry(key: self::TEAM_CREATION_ALLOWED_GROUPS, type: ValueType::STRING, defaultRaw: '[]', definition: 'JSON array of group GIDs allowed to create teams (empty = all users)', lazy: true), ]; } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 8f30d297e..128a6caa7 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -11,12 +11,14 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\Service\ConfigService; +use OCA\Circles\Service\PermissionService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\NotFoundResponse; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; use OCP\IRequest; use OCP\Util; @@ -27,6 +29,8 @@ class PageController extends Controller { public function __construct( IRequest $request, private ConfigService $configService, + private PermissionService $permissionService, + private IInitialState $initialState, ) { parent::__construct(Application::APP_ID, $request); } @@ -41,6 +45,8 @@ public function index(): TemplateResponse|NotFoundResponse { return new NotFoundResponse(); } + $this->initialState->provideInitialState('canCreateTeam', $this->permissionService->canUserCreateTeams()); + Util::addScript(Application::APP_ID, 'teams-main'); Util::addStyle(Application::APP_ID, 'teams-main'); diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index ef8be13bb..4824cdeee 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -40,6 +40,15 @@ public function setValue(string $key, string $value): DataResponse { return $this->getValues(); } + if ($key === ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS) { + if (!$this->isValidAllowedGroupsValue($value)) { + return new DataResponse(['data' => ['message' => 'allowed groups must be a JSON array of group ids']], Http::STATUS_BAD_REQUEST); + } + + $this->appConfig->setAppValueString(ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS, $value); + return $this->getValues(); + } + return new DataResponse(['data' => ['message' => 'unsupported key']], Http::STATUS_BAD_REQUEST); } @@ -47,9 +56,28 @@ public function getValues(): DataResponse { return new DataResponse([ ConfigLexicon::FEDERATED_TEAMS_FRONTAL => $this->getFrontalValue() ?? '', ConfigLexicon::FEDERATED_TEAMS_ENABLED => $this->appConfig->getAppValueBool(ConfigLexicon::FEDERATED_TEAMS_ENABLED), + ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS => $this->appConfig->getAppValueString( + ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS, + '[]', + ), ]); } + private function isValidAllowedGroupsValue(string $value): bool { + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return false; + } + + foreach ($decoded as $groupId) { + if (!is_string($groupId) || $groupId === '') { + return false; + } + } + + return true; + } + private function setFrontalValue(string $url): bool { [$scheme, $cloudId, $path] = $this->parseFrontalAddress($url); if (is_null($scheme)) { @@ -66,7 +94,7 @@ private function setFrontalValue(string $url): bool { private function getFrontalValue(): ?string { if ($this->appConfig->hasAppKey(ConfigLexicon::FEDERATED_TEAMS_FRONTAL)) { - return $this->appConfig->getAppValueString(ConfigLExicon::FEDERATED_TEAMS_FRONTAL); + return $this->appConfig->getAppValueString(ConfigLexicon::FEDERATED_TEAMS_FRONTAL); } if (!$this->appConfig->hasAppKey(ConfigService::FRONTAL_CLOUD_SCHEME) diff --git a/lib/Dashboard/TeamDashboardWidget.php b/lib/Dashboard/TeamDashboardWidget.php index e9857fbc2..ee03a82ee 100644 --- a/lib/Dashboard/TeamDashboardWidget.php +++ b/lib/Dashboard/TeamDashboardWidget.php @@ -9,12 +9,16 @@ 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\IUserManager; +use OCP\IUserSession; use OCP\Util; class TeamDashboardWidget implements IIconWidget, IButtonWidget, IConditionalWidget { @@ -22,6 +26,10 @@ public function __construct( private readonly IURLGenerator $urlGenerator, private readonly IL10N $l10n, private readonly ConfigService $configService, + private readonly PermissionService $permissionService, + private readonly IUserManager $userManager, + private readonly IUserSession $userSession, + private readonly IInitialState $initialState, ) { } @@ -64,23 +72,34 @@ public function getUrl(): ?string { * @inheritDoc */ public function load(): void { + $this->initialState->provideInitialState( + 'canCreateTeam', + $this->permissionService->canUserCreateTeams($this->userSession->getUser()), + ); + Util::addScript(Application::APP_ID, 'teams-dashboard'); Util::addStyle(Application::APP_ID, 'teams-dashboard'); } public function getWidgetButtons(string $userId): array { - return [ + $buttons = [ new WidgetButton( WidgetButton::TYPE_MORE, $this->getTeamPage(), $this->l10n->t('Show all teams') ), - new WidgetButton( + ]; + + $user = $this->userManager->get($userId); + if ($this->permissionService->canUserCreateTeams($user)) { + $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 5651bd71e..121defa11 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -9,6 +9,7 @@ namespace OCA\Circles\Service; +use OCA\Circles\ConfigLexicon; use OCA\Circles\Db\MemberRequest; use OCA\Circles\Db\MembershipRequest; use OCA\Circles\Exceptions\InitiatorNotFoundException; @@ -21,7 +22,10 @@ use OCA\Circles\Model\Circle; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Model\Member; +use OCP\IGroupManager; use OCP\IL10N; +use OCP\IUser; +use OCP\IUserSession; class PermissionService { @@ -31,15 +35,78 @@ public function __construct( private readonly ConfigService $configService, private readonly MemberRequest $memberRequest, private readonly MembershipRequest $membershipRequest, + private readonly IGroupManager $groupManager, + private readonly IUserSession $userSession, ) { } + /** + * @return string[] + */ + public function getAllowedCreationGroups(): array { + $raw = $this->configService->getAppValue(ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS); + if ($raw === '') { + return []; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + + return array_values(array_filter($decoded, static fn ($groupId): bool => is_string($groupId) && $groupId !== '')); + } + + public function canUserCreateTeams(?IUser $user = null): bool { + $user ??= $this->userSession->getUser(); + if ($user === null) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + $allowedGroups = $this->getAllowedCreationGroups(); + if ($allowedGroups === []) { + return $this->canPassLegacyCircleCreationLimit(); + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + if (array_intersect($allowedGroups, $userGroups) === []) { + return false; + } + + return $this->canPassLegacyCircleCreationLimit(); + } + /** * @throws RequestBuilderException * @throws InitiatorNotFoundException * @throws InsufficientPermissionException */ public function confirmCircleCreation(): void { + $user = $this->userSession->getUser(); + if ($user !== null && $this->groupManager->isAdmin($user->getUID())) { + return; + } + + $allowedGroups = $this->getAllowedCreationGroups(); + if ($allowedGroups !== []) { + if ($user === null) { + throw new InsufficientPermissionException( + $this->l10n->t('You have no permission to create a new team') + ); + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + if (array_intersect($allowedGroups, $userGroups) === []) { + throw new InsufficientPermissionException( + $this->l10n->t('You have no permission to create a new team') + ); + } + } + try { $this->confirm(ConfigService::LIMIT_CIRCLE_CREATION); } catch (InsufficientPermissionException) { @@ -49,6 +116,23 @@ public function confirmCircleCreation(): void { } } + private function canPassLegacyCircleCreationLimit(): bool { + $singleId = $this->configService->getAppValue(ConfigService::LIMIT_CIRCLE_CREATION); + if ($singleId === '') { + return true; + } + + try { + $this->federatedUserService->mustHaveCurrentUser(); + $federatedUser = $this->federatedUserService->getCurrentUser(); + $federatedUser->getLink($singleId); + + return true; + } catch (InitiatorNotFoundException|MembershipNotFoundException|RequestBuilderException) { + return false; + } + } + /** * @param string $config * diff --git a/lib/Settings/Section.php b/lib/Settings/Section.php new file mode 100644 index 000000000..705f017c7 --- /dev/null +++ b/lib/Settings/Section.php @@ -0,0 +1,43 @@ +l->t('Teams'); + } + + #[\Override] + public function getPriority(): int { + return 85; + } + + #[\Override] + public function getIcon(): string { + return $this->url->imagePath(Application::APP_ID, 'circles.svg'); + } +} diff --git a/lib/Settings/TeamsAdmin.php b/lib/Settings/TeamsAdmin.php new file mode 100644 index 000000000..a74a21e0a --- /dev/null +++ b/lib/Settings/TeamsAdmin.php @@ -0,0 +1,74 @@ +appConfig->getValueString( + Application::APP_ID, + ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS, + '[]', + ); + + $availableGroups = []; + foreach ($this->groupManager->search('') as $group) { + $availableGroups[] = [ + 'gid' => $group->getGID(), + 'displayName' => $group->getDisplayName(), + ]; + } + + $this->initialState->provideInitialState('teamCreationAllowedGroups', json_decode($allowedGroupsRaw, true) ?: []); + $this->initialState->provideInitialState('availableGroups', $availableGroups); + + Util::addStyle(Application::APP_ID, 'teams-settings-teams-admin'); + Util::addScript(Application::APP_ID, 'teams-settings-teams-admin'); + + return new TemplateResponse(Application::APP_ID, 'settings-teams-admin', renderAs: ''); + } + + public function getSection(): string { + return 'teams'; + } + + public function getPriority(): int { + return 10; + } + + public function getName(): ?string { + return null; + } + + public function getAuthorizedAppConfig(): array { + return [ + Application::APP_ID => [ + ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS, + ], + ]; + } +} diff --git a/src/components/TeamsAdminSettings.vue b/src/components/TeamsAdminSettings.vue new file mode 100644 index 000000000..03e29fd45 --- /dev/null +++ b/src/components/TeamsAdminSettings.vue @@ -0,0 +1,115 @@ + + + + + + + diff --git a/src/settings-teams-admin.ts b/src/settings-teams-admin.ts new file mode 100644 index 000000000..f55955058 --- /dev/null +++ b/src/settings-teams-admin.ts @@ -0,0 +1,12 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import TeamsAdminSettings from './components/TeamsAdminSettings.vue' + +import 'vite/modulepreload-polyfill' + +const app = createApp(TeamsAdminSettings) +app.mount('#vue-admin-teams') diff --git a/src/teams/components/GlobalNavigation.vue b/src/teams/components/GlobalNavigation.vue index b0848c6d8..d24712618 100644 --- a/src/teams/components/GlobalNavigation.vue +++ b/src/teams/components/GlobalNavigation.vue @@ -21,7 +21,7 @@ import TeamNavigationItem from './TeamNavigationItem.vue' import { useTeamsStore } from '../store.ts' const store = useTeamsStore() -const { loading } = storeToRefs(store) +const { loading, canCreateTeam } = storeToRefs(store) const { openCreateTeamDialog } = store const route = useRoute() @@ -38,6 +38,7 @@ const isOverviewActive = computed(() => route.name === 'home')