diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index cc12d8b49..2aeba19a4 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -18,10 +18,12 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; +use OCP\IURLGenerator; use OCP\Teams\ITeamManager; use OCP\Util; @@ -36,6 +38,7 @@ public function __construct( private ITeamManager $teamManager, private TeamFolderPolicy $teamFolderPolicy, private IEventDispatcher $eventDispatcher, + private IURLGenerator $urlGenerator, ) { parent::__construct(Application::APP_ID, $request); } @@ -69,6 +72,19 @@ public function index(): TemplateResponse|NotFoundResponse { return new TemplateResponse(Application::APP_ID, 'main'); } + #[NoAdminRequired] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/t/{teamId}', root: '')] + public function teamLink(string $teamId): RedirectResponse|NotFoundResponse { + if (!$this->configService->getAppValueBool(ConfigService::FRONTEND_ENABLED)) { + return new NotFoundResponse(); + } + + return new RedirectResponse($this->urlGenerator->linkToRoute('circles.page.indexPath', [ + 'path' => 'team/' . $teamId, + ])); + } + /** * Catch-all for the SPA's client-side (HTML5 history) routes so deep-link * reloads still serve the app shell. $path is handled by the client router. diff --git a/src/teams/composables/useTeamActions.spec.ts b/src/teams/composables/useTeamActions.spec.ts new file mode 100644 index 000000000..d5f4ee90f --- /dev/null +++ b/src/teams/composables/useTeamActions.spec.ts @@ -0,0 +1,90 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Team } from '../types.ts' + +import { showError, showSuccess } from '@nextcloud/dialogs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useTeamActions } from './useTeamActions.ts' + +vi.mock('@nextcloud/auth', () => ({ getCurrentUser: vi.fn() })) +vi.mock('@nextcloud/dialogs', () => ({ + showConfirmation: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), +})) +vi.mock('@nextcloud/l10n', () => ({ t: (app: string, text: string) => text })) +vi.mock('vue-router', () => ({ useRouter: () => ({}) })) +vi.mock('../store.ts', () => ({ useTeamsStore: () => ({}) })) +vi.mock('../../logger.ts', () => ({ logger: { error: vi.fn() } })) + +describe('useTeamActions copy link', () => { + const writeText = vi.fn() + let team: Team | undefined + + beforeEach(() => { + vi.clearAllMocks() + writeText.mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + vi.stubGlobal('_oc_webroot', '') + vi.stubGlobal('OC', { config: { modRewriteWorking: true } }) + team = { + id: 'MhoB14OeDtLNDtgvfPJgNadWVQ4TU4n', + displayName: 'Team', + description: '', + isFavorite: false, + favoritePosition: null, + memberCount: 1, + myRole: 'member', + members: [], + resources: [], + } + }) + + afterEach(() => vi.unstubAllGlobals()) + + it.each([ + ['', true, '/t/'], + ['', false, '/index.php/t/'], + ['/nextcloud', true, '/nextcloud/t/'], + ['/nextcloud', false, '/nextcloud/index.php/t/'], + ])('copies a short link with webroot %s and rewriting %s', async (webroot, rewrite, path) => { + vi.stubGlobal('_oc_webroot', webroot) + vi.stubGlobal('OC', { config: { modRewriteWorking: rewrite } }) + + await useTeamActions(() => team).onCopyLink() + + expect(writeText).toHaveBeenCalledWith(window.location.origin + path + team!.id) + expect(showSuccess).toHaveBeenCalledWith('Link copied to the clipboard') + expect(showError).not.toHaveBeenCalled() + }) + + it('does not copy a link before the team has loaded', async () => { + team = undefined + + await useTeamActions(() => team).onCopyLink() + + expect(writeText).not.toHaveBeenCalled() + expect(showSuccess).not.toHaveBeenCalled() + }) + + it('uses the currently selected team', async () => { + const actions = useTeamActions(() => team) + team = { ...team!, id: 'another-team' } + + await actions.onCopyLink() + + expect(writeText).toHaveBeenCalledWith(window.location.origin + '/t/another-team') + }) + + it('reports a clipboard failure', async () => { + writeText.mockRejectedValue(new Error('Clipboard unavailable')) + + await useTeamActions(() => team).onCopyLink() + + expect(showError).toHaveBeenCalledWith('Could not copy link to the clipboard') + expect(showSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/src/teams/composables/useTeamActions.ts b/src/teams/composables/useTeamActions.ts index 1e16ff339..aaadb8f46 100644 --- a/src/teams/composables/useTeamActions.ts +++ b/src/teams/composables/useTeamActions.ts @@ -9,6 +9,7 @@ import type { Team } from '../types.ts' import { getCurrentUser } from '@nextcloud/auth' import { showConfirmation, showError, showSuccess } from '@nextcloud/dialogs' import { t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' import { computed } from 'vue' import { useRouter } from 'vue-router' import { logger } from '../../logger.ts' @@ -39,7 +40,6 @@ export function useTeamActions(getTeam: () => Team | undefined) { const router = useRouter() const store = useTeamsStore() - const to = computed(() => ({ name: 'team', params: { teamId: getTeam()?.id } })) const isOwner = computed(() => getTeam()?.myRole === 'owner') // Management gates with deliberately different thresholds: the settings // entry includes moderators, while creating team resources, managing @@ -57,7 +57,11 @@ export function useTeamActions(getTeam: () => Team | undefined) { /** Copy a direct link to the team. */ async function onCopyLink(): Promise { - const href = window.location.origin + router.resolve(to.value).href + const team = getTeam() + if (!team) { + return + } + const href = window.location.origin + generateUrl('/t/{teamId}', { teamId: team.id }) try { await navigator.clipboard.writeText(href) showSuccess(t('circles', 'Link copied to the clipboard')) diff --git a/tests/unit/lib/Controller/PageControllerTest.php b/tests/unit/lib/Controller/PageControllerTest.php index 0309d89a9..82fdb5707 100644 --- a/tests/unit/lib/Controller/PageControllerTest.php +++ b/tests/unit/lib/Controller/PageControllerTest.php @@ -13,10 +13,12 @@ use OCA\Circles\Service\ConfigService; use OCA\Circles\Service\TeamFolderPolicy; use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\RedirectResponse; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; +use OCP\IURLGenerator; use OCP\Teams\ITeamFolderProvider; use OCP\Teams\ITeamManager; use PHPUnit\Framework\MockObject\MockObject; @@ -29,6 +31,7 @@ final class PageControllerTest extends TestCase { private ITeamManager&MockObject $teamManager; private TeamFolderPolicy&MockObject $teamFolderPolicy; private IEventDispatcher&MockObject $eventDispatcher; + private IURLGenerator&MockObject $urlGenerator; private PageController $pageController; #[\Override] @@ -41,6 +44,7 @@ protected function setUp(): void { $this->teamManager = $this->createMock(ITeamManager::class); $this->teamFolderPolicy = $this->createMock(TeamFolderPolicy::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->pageController = new PageController( $this->request, @@ -49,9 +53,38 @@ protected function setUp(): void { $this->teamManager, $this->teamFolderPolicy, $this->eventDispatcher, + $this->urlGenerator, ); } + public function testTeamLinkRedirectsToTeamLandingPage(): void { + $teamId = 'MhoB14OeDtLNDtgvfPJgNadWVQ4TU4n'; + $url = '/nextcloud/index.php/apps/circles/teams/team/' . $teamId; + $this->configService->expects($this->once()) + ->method('getAppValueBool') + ->with(ConfigService::FRONTEND_ENABLED) + ->willReturn(true); + $this->urlGenerator->expects($this->once()) + ->method('linkToRoute') + ->with('circles.page.indexPath', ['path' => 'team/' . $teamId]) + ->willReturn($url); + + $result = $this->pageController->teamLink($teamId); + + $this->assertInstanceOf(RedirectResponse::class, $result); + $this->assertSame($url, $result->getRedirectURL()); + } + + public function testTeamLinkReturnsNotFoundWhenFrontendDisabled(): void { + $this->configService->expects($this->once()) + ->method('getAppValueBool') + ->with(ConfigService::FRONTEND_ENABLED) + ->willReturn(false); + $this->urlGenerator->expects($this->never())->method('linkToRoute'); + + $this->assertInstanceOf(NotFoundResponse::class, $this->pageController->teamLink('team-id')); + } + /** * Assert that index() provides the provider initial state and returns * the SPA template when the frontend is enabled and a folder provider is