Skip to content
Open
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
16 changes: 16 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
Expand Down Expand Up @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions src/teams/composables/useTeamActions.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
8 changes: 6 additions & 2 deletions src/teams/composables/useTeamActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -57,7 +57,11 @@ export function useTeamActions(getTeam: () => Team | undefined) {

/** Copy a direct link to the team. */
async function onCopyLink(): Promise<void> {
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'))
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/lib/Controller/PageControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]
Expand All @@ -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,
Expand All @@ -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
Expand Down
Loading