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 @@ + + + + + + + + {{ t('circles', 'Groups allowed to create teams') }} + + + {{ t('circles', 'Leave empty to allow every user to create teams.') }} + + + + + + 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') diff --git a/src/teams/store.ts b/src/teams/store.ts index 8dc158fde..63fd23125 100644 --- a/src/teams/store.ts +++ b/src/teams/store.ts @@ -5,6 +5,7 @@ import type { Member, Team } from './types.ts' +import { loadState } from '@nextcloud/initial-state' import { defineStore } from 'pinia' import { logger } from '../logger.ts' import * as api from './api.ts' @@ -18,6 +19,8 @@ interface TeamsState { loadError: boolean /** Whether the "create a new team" dialog is open (shared across the app). */ createDialogOpen: boolean + /** Whether the current user may create top-level teams. */ + canCreateTeam: boolean } /** @@ -31,6 +34,7 @@ export const useTeamsStore = defineStore('teams', { loading: false, loadError: false, createDialogOpen: false, + canCreateTeam: loadState('circles', 'canCreateTeam', true), }), getters: { diff --git a/src/teams/views/HomeView.vue b/src/teams/views/HomeView.vue index 41163148c..6cd095b3e 100644 --- a/src/teams/views/HomeView.vue +++ b/src/teams/views/HomeView.vue @@ -7,6 +7,7 @@ import { mdiAccountGroupOutline, mdiAlertCircleOutline } from '@mdi/js' import { t } from '@nextcloud/l10n' import { storeToRefs } from 'pinia' +import { computed } from 'vue' import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' @@ -15,8 +16,12 @@ import TeamCard from '../components/TeamCard.vue' import { useTeamsStore } from '../store.ts' const store = useTeamsStore() -const { teams, loading, loadError } = storeToRefs(store) +const { teams, loading, loadError, canCreateTeam } = storeToRefs(store) const { loadTeams, openCreateTeamDialog } = store + +const emptyTeamsDescription = computed(() => canCreateTeam.value + ? t('circles', 'Create your first team to start collaborating.') + : t('circles', 'You have not been added to any teams yet.')) @@ -51,12 +56,12 @@ const { loadTeams, openCreateTeamDialog } = store + :description="emptyTeamsDescription"> - + {{ t('circles', 'Create your first team') }} diff --git a/src/views/DashboardTeamsWidget.vue b/src/views/DashboardTeamsWidget.vue index a76da26ff..99bf573da 100644 --- a/src/views/DashboardTeamsWidget.vue +++ b/src/views/DashboardTeamsWidget.vue @@ -10,9 +10,10 @@ import type { ITeam } from '../types.ts' import { mdiAccountGroupOutline, mdiAlertCircleOutline } from '@mdi/js' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { generateOcsUrl, generateUrl } from '@nextcloud/router' -import { nextTick, onMounted, ref, useTemplateRef } from 'vue' +import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue' import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' @@ -22,6 +23,11 @@ import { logger } from '../logger.ts' const LOADING_LIMIT = 3 const createTeamHref = generateUrl('/apps/circles/teams') +const canCreateTeam = loadState('circles', 'canCreateTeam', true) + +const emptyTeamsDescription = computed(() => canCreateTeam + ? t('circles', 'Join or create teams to see them here.') + : t('circles', 'Join a team to see it here.')) const teamsList = useTemplateRef('teamsListKey') @@ -124,12 +130,12 @@ async function loadMoreTeams() { + :description="emptyTeamsDescription"> - + {{ t('circles', 'Create your first team') }} diff --git a/templates/settings-teams-admin.php b/templates/settings-teams-admin.php new file mode 100644 index 000000000..2131fd2a2 --- /dev/null +++ b/templates/settings-teams-admin.php @@ -0,0 +1,8 @@ + + + diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 07f08e6ed..9038ab521 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -14,10 +14,48 @@ define('PHPUNIT_RUN', 1); } -require_once __DIR__ . '/../../../lib/base.php'; +$basePhpCandidates = []; +if (($nextcloudRoot = getenv('NEXTCLOUD_ROOT')) !== false && $nextcloudRoot !== '') { + $basePhpCandidates[] = rtrim($nextcloudRoot, '/') . '/lib/base.php'; +} +$basePhpCandidates = array_merge($basePhpCandidates, [ + // Standard layout: server/apps/circles/tests + __DIR__ . '/../../../lib/base.php', + // Sibling layout: nextcloud/circles + nextcloud/server + __DIR__ . '/../../server/lib/base.php', +]); + +$basePhp = null; +foreach ($basePhpCandidates as $candidate) { + if (is_file($candidate)) { + $basePhp = $candidate; + break; + } +} + +if ($basePhp === null) { + throw new RuntimeException( + 'Could not find Nextcloud lib/base.php. Expected server under apps/circles or as sibling nextcloud/server.' + ); +} + +require_once $basePhp; require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../../../tests/autoload.php'; +$testsAutoloadCandidates = []; +if (($nextcloudRoot = getenv('NEXTCLOUD_ROOT')) !== false && $nextcloudRoot !== '') { + $testsAutoloadCandidates[] = rtrim($nextcloudRoot, '/') . '/tests/autoload.php'; +} +$testsAutoloadCandidates = array_merge($testsAutoloadCandidates, [ + __DIR__ . '/../../../tests/autoload.php', + __DIR__ . '/../../server/tests/autoload.php', +]); +foreach ($testsAutoloadCandidates as $candidate) { + if (is_file($candidate)) { + require_once $candidate; + break; + } +} Server::get(IAppManager::class)->loadApp('circles'); diff --git a/tests/unit/lib/Service/PermissionServiceTest.php b/tests/unit/lib/Service/PermissionServiceTest.php new file mode 100644 index 000000000..65281bebe --- /dev/null +++ b/tests/unit/lib/Service/PermissionServiceTest.php @@ -0,0 +1,135 @@ +l10n = $this->createMock(IL10N::class); + $this->federatedUserService = $this->createMock(FederatedUserService::class); + $this->configService = $this->createMock(ConfigService::class); + $this->memberRequest = $this->createMock(MemberRequest::class); + $this->membershipRequest = $this->createMock(MembershipRequest::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->userSession = $this->createMock(IUserSession::class); + + $this->service = new PermissionService( + $this->l10n, + $this->federatedUserService, + $this->configService, + $this->memberRequest, + $this->membershipRequest, + $this->groupManager, + $this->userSession, + ); + } + + public function testCanUserCreateTeamsWhenNoRestrictions(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->groupManager->method('isAdmin')->willReturn(false); + $this->configService->method('getAppValue') + ->willReturnMap([ + [ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS, '[]'], + [ConfigService::LIMIT_CIRCLE_CREATION, ''], + ]); + + $this->assertTrue($this->service->canUserCreateTeams($user)); + } + + public function testCanUserCreateTeamsDeniedForUnauthorizedGroup(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->groupManager->method('isAdmin')->willReturn(false); + $this->configService->method('getAppValue') + ->with(ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS) + ->willReturn('["team-creators"]'); + $this->groupManager->method('getUserGroupIds') + ->with($user) + ->willReturn(['users']); + + $this->assertFalse($this->service->canUserCreateTeams($user)); + } + + public function testConfirmCircleCreationDeniedForUnauthorizedGroup(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->willReturn(false); + $this->configService->method('getAppValue') + ->with(ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS) + ->willReturn('["team-creators"]'); + $this->groupManager->method('getUserGroupIds') + ->with($user) + ->willReturn(['users']); + $this->l10n->method('t')->willReturnArgument(0); + + $this->expectException(InsufficientPermissionException::class); + $this->service->confirmCircleCreation(); + } + + public function testConfirmCircleCreationAllowedForMatchingGroup(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + $this->userSession->method('getUser')->willReturn($user); + $this->groupManager->method('isAdmin')->willReturn(false); + $this->configService->method('getAppValue') + ->willReturnMap([ + [ConfigLexicon::TEAM_CREATION_ALLOWED_GROUPS, '["team-creators"]'], + [ConfigService::LIMIT_CIRCLE_CREATION, ''], + ]); + $this->groupManager->method('getUserGroupIds') + ->with($user) + ->willReturn(['team-creators']); + + $this->service->confirmCircleCreation(); + $this->addToAssertionCount(1); + } + + public function testCanUserCreateTeamsAlwaysAllowedForAdmin(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin'); + + $this->groupManager->method('isAdmin') + ->with('admin') + ->willReturn(true); + + $this->assertTrue($this->service->canUserCreateTeams($user)); + } +} diff --git a/vite.config.ts b/vite.config.ts index e35c0252b..95cb01b45 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ export default (env) => createAppConfig({ main: join(import.meta.dirname, 'src/main.ts'), dashboard: join(import.meta.dirname, 'src/dashboard.ts'), 'settings-admin': join(import.meta.dirname, 'src/settings-admin.ts'), + 'settings-teams-admin': join(import.meta.dirname, 'src/settings-teams-admin.ts'), }, { appName: 'teams', emptyOutputDirectory: { additionalDirectories: ['css'] },
+ {{ t('circles', 'Leave empty to allow every user to create teams.') }} +