Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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);
}
Expand All @@ -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');
Expand Down
25 changes: 21 additions & 4 deletions lib/Dashboard/TeamDashboardWidget.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,25 @@

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 {
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,
) {
}

Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 37 additions & 1 deletion lib/Service/PermissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
*
Expand All @@ -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;
}
}

Expand Down
73 changes: 73 additions & 0 deletions src/teams/App.spec.ts
Original file line number Diff line number Diff line change
@@ -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<LoadState>())

vi.mock('@nextcloud/initial-state', () => ({ loadState }))
vi.mock('./api.ts', () => ({ fetchTeams: vi.fn(async () => []) }))
vi.mock('./components/TeamNavigation.vue', () => ({
default: { name: 'TeamNavigation', template: '<nav />' },
}))
vi.mock('./components/TeamCreationWizard.vue', () => ({
default: {
name: 'TeamCreationWizard',
template: '<div class="wizard-stub" />',
},
}))

/**
* Mount the app shell with the creation wizard flagged as open.
*
* @param canCreateTeam - value served for the `canCreateTeam` initial state
*/
function mountApp(canCreateTeam: boolean) {
loadState.mockImplementation((app, key, fallback) => {
if (app === 'circles' && key === 'canCreateTeam') {
return canCreateTeam
}
return fallback
})
const pinia = createPinia()
setActivePinia(pinia)
useTeamsStore().createWizardOpen = true

return shallowMount(App, {
global: {
plugins: [pinia],
stubs: {
NcContent: { template: '<div><slot /></div>' },
RouterView: true,
},
},
})
}

describe('App team creation wizard', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('mounts the creation wizard when allowed', () => {
const wrapper = mountApp(true)

const wizard = wrapper.findComponent({ name: 'TeamCreationWizard' })
expect(wizard.exists()).toBe(true)
})

it('never mounts the creation wizard when not allowed', () => {
const wrapper = mountApp(false)

const wizard = wrapper.findComponent({ name: 'TeamCreationWizard' })
expect(wizard.exists()).toBe(false)
})
})
6 changes: 4 additions & 2 deletions src/teams/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import TeamNavigation from './components/TeamNavigation.vue'
import { useTeamsStore } from './store.ts'

const store = useTeamsStore()
const { createWizardOpen } = storeToRefs(store)
const { createWizardOpen, canCreateTeam } = storeToRefs(store)

onMounted(() => store.loadTeams())
</script>
Expand All @@ -28,7 +28,9 @@ onMounted(() => store.loadTeams())
</div>
</NcAppContent>

<TeamCreationWizard v-if="createWizardOpen" @close="createWizardOpen = false" />
<TeamCreationWizard
v-if="canCreateTeam && createWizardOpen"
@close="createWizardOpen = false" />
</NcContent>
</template>

Expand Down
94 changes: 94 additions & 0 deletions src/teams/components/TeamHeader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type Circle from '../team-page/models/circle.ts'

import { shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TeamHeader from './TeamHeader.vue'
import { useTeamsStore } from '../store.ts'

type LoadState = (app: string, key: string, fallback?: unknown) => unknown

const loadState = vi.hoisted(() => vi.fn<LoadState>())

vi.mock('@nextcloud/initial-state', () => ({ loadState }))
vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn() }))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('vuex', () => ({
useStore: () => ({ getters: { getCircle: () => undefined } }),
}))
vi.mock('../api.ts', () => ({ fetchTeams: vi.fn(async () => []) }))

/**
* Mount the header with the switcher panel rendered inline.
*
* @param canCreateTeam - value served for the `canCreateTeam` initial state
*/
function mountHeader(canCreateTeam: boolean) {
loadState.mockImplementation((app, key, fallback) => {
if (app === 'circles' && key === 'canCreateTeam') {
return canCreateTeam
}
return fallback
})
const pinia = createPinia()
setActivePinia(pinia)

const wrapper = shallowMount(TeamHeader, {
props: {
circle: { id: 'team-1', displayName: 'Marketing' } as Circle,
},
global: {
plugins: [pinia],
stubs: {
NcPopover: {
template: '<div><slot name="trigger" /><slot /></div>',
},
NcButton: {
emits: ['click'],
template: '<button class="nc-button-stub" '
+ '@click="$emit(\'click\')"><slot /></button>',
},
},
},
})

return { wrapper, store: useTeamsStore() }
}

/**
* Find the switcher's "New team" button, if rendered.
*
* @param wrapper - The mounted header
*/
function findNewTeamButton(wrapper: ReturnType<typeof mountHeader>['wrapper']) {
return wrapper.findAll('.nc-button-stub')
.find((button) => button.text() === 'New team')
}

describe('TeamHeader team creation', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('opens the creation wizard from the switcher when allowed', async () => {
const { wrapper, store } = mountHeader(true)
const button = findNewTeamButton(wrapper)

expect(button).toBeDefined()
await button!.trigger('click')
expect(store.createWizardOpen).toBe(true)
})

it('hides the switcher "New team" button when not allowed', () => {
const { wrapper } = mountHeader(false)

expect(findNewTeamButton(wrapper)).toBeUndefined()
})
})
3 changes: 2 additions & 1 deletion src/teams/components/TeamHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ async function openHome(): Promise<void> {
/** Open the team creation wizard from the panel. */
function onNewTeam(): void {
switcherOpen.value = false
teamsStore.createWizardOpen = true
teamsStore.openCreateTeamWizard()
}

/** Open the team's members page from the panel. */
Expand Down Expand Up @@ -365,6 +365,7 @@ async function onToggleFavorite(): Promise<void> {
</NcButton>

<NcButton
v-if="teamsStore.canCreateTeam"
class="team-header__switcher-new"
variant="secondary"
@click="onNewTeam">
Expand Down
Loading
Loading