Skip to content
Draft
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
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,80 @@ You can use the `\Psr\Container\ContainerInterface`, see [dependency injection](
# Testing
Front-end tests (Vitest for unit/component, Playwright for end-to-end) are documented in [TESTING.md](TESTING.md).

## App-owned Teams navigation and embedded views

Apps contribute their own integrations through
[`ITeamAppProvider`](lib/Contracts/ITeamAppProvider.php). This is a Circles API,
not an OCP bootstrap extension. It complements the existing
`OCP\Teams\ITeamResourceProvider`: shared resources continue to use that API.

In the owning app's bootstrap, register a listener for
`OCA\Circles\Events\RegisterTeamAppProvidersEvent`. The listener calls
`$event->registerProvider('myapp', MyTeamsProvider::class)`. The provider's
`getId()` must match its owning app ID. Its `load()` method adds its lightweight
browser registration script and any initial state. The registry instantiates
only providers whose apps are enabled for the current user. Use lazy event
listeners rather than eagerly loading the provider when Circles is absent.

The script registers a browser provider with `window.OCA.Circles.Teams`.
If that API is not present yet, listen once for `circles:teams:ready` and register
then. Check `apiVersion === 1`. The host publishes the API before announcing
readiness and waits for the page's deferred scripts before loading providers.

```typescript
const provider = {
id: 'myapp',
order: 50,
async load(context) {
return {
entries: [{ id: 'example', label: 'Example' }],
actions: [],
resourceKeys: [],
}
},
async mount(element, context, entryId) {
context.signal.throwIfAborted()
const view = await mountMyView(element, context.teamId, entryId)
return () => view.destroy()
},
}
```

The complete structural TypeScript contract is in
[`types.ts`](src/teams/integrations/types.ts). Apps may type the subset they use
locally; they do not need runtime imports from Circles or access to its Vue,
Pinia, or router instances.

- `load(context)` returns stable resource IDs, localized labels/icons, actions,
and either external `href` targets or entries rendered by `mount`.
- `mount` is optional for external-link providers. It must resolve the resource
for the current team and user, respect `context.signal`, and return cleanup.
Never treat an entry ID or pin as proof of authorization. Heavy editors should
be imported lazily here.
- `context` supplies the team, role information, folder, shared resources,
`prompt`, `confirm`, provider-scoped `getPins`/`setPins`, `refresh`, and `open`.
After mutations, await `refresh()` before opening a newly created entry.
The context is invalidated when leaving the team.
- Each action has `id`, `label`, optional MDI `icon`, and `run`. Errors must
propagate to the host, which logs them and notifies the user. Do not return
success-shaped results after a failed operation.
- `resourceKeys` lists only `{ providerId, resourceId }` pairs actually
represented by navigation entries. This prevents duplicate shared-resource
rows without hiding resources when a provider fails.
- Navigation IDs are namespaced as `provider:entry`. Individual entries can
declare `legacyIds` to retain an older saved ordering. A provider can declare
`routeAliases`, such as `{ path: 'page/:fileId', entryParam: 'fileId' }`, to
redirect old team-relative URLs to the generic embedded route. Alias paths
must be one literal segment followed by one parameter.
- Members can read pins at
`GET /ocs/v2.php/apps/circles/teams/{teamId}/apps/{providerId}/pins`.
Admins/owners can write `{ "ids": ["42"] }` with `PUT`. Responses contain
`{ "ids": [...] }` inside the OCS data envelope. IDs must be non-empty strings,
at most 64 bytes each, with at most 100 pins. Pins are shared team metadata,
scoped to a registered provider, and never grant file access.

Text and Whiteboard provide embedded views. Deck and Collectives contribute
external links. Native Teams sections remain in Circles. There is no hard-coded
fallback for older app versions: upgrade the host and integration apps together.
Provider failures are reported separately and do not hide generic shared
resources.
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
['name' => 'TeamFolder#upgradeTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'POST'],
['name' => 'TeamTabs#getTabOrder', 'url' => '/teams/{circleId}/tab-order', 'verb' => 'GET'],
['name' => 'TeamTabs#setTabOrder', 'url' => '/teams/{circleId}/tab-order', 'verb' => 'PUT'],
['name' => 'TeamAppNavigation#getNavigationEntryIds', 'url' => '/teams/{circleId}/apps/{providerId}/navigation/selection', 'verb' => 'GET'],
['name' => 'TeamAppNavigation#getNavigation', 'url' => '/teams/{circleId}/apps/{providerId}/navigation', 'verb' => 'GET'],
['name' => 'TeamAppNavigation#action', 'url' => '/teams/{circleId}/apps/{providerId}/actions/{actionId}', 'verb' => 'POST'],
['name' => 'TeamAppNavigation#setNavigationEntryIds', 'url' => '/teams/{circleId}/apps/{providerId}/navigation/selection', 'verb' => 'PUT'],
['name' => 'TeamFolder#getLinkableTeamFolders', 'url' => '/teams/{circleId}/folder/linkable', 'verb' => 'GET'],
['name' => 'TeamFolder#linkTeamFolder', 'url' => '/teams/{circleId}/folder/link', 'verb' => 'POST'],
['name' => 'TeamFolder#updateTeamFolderQuota', 'url' => '/teams/{circleId}/folder/quota', 'verb' => 'PUT'],
Expand Down
11 changes: 3 additions & 8 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,14 @@
use OCA\Circles\AppInfo\Application;
use OCA\Circles\Service\ConfigService;
use OCA\Circles\Service\TeamFolderPolicy;
use OCA\Text\Event\LoadEditor;
use OCA\Circles\Service\TeamAppProviderRegistry;
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\EventDispatcher\IEventDispatcher;
use OCP\IRequest;
use OCP\Teams\ITeamManager;
use OCP\Util;
Expand All @@ -35,7 +34,7 @@ public function __construct(
private IInitialState $initialState,
private ITeamManager $teamManager,
private TeamFolderPolicy $teamFolderPolicy,
private IEventDispatcher $eventDispatcher,
private TeamAppProviderRegistry $teamApps,
) {
parent::__construct(Application::APP_ID, $request);
}
Expand All @@ -60,11 +59,7 @@ public function index(): TemplateResponse|NotFoundResponse {
Util::addScript(Application::APP_ID, 'teams-main');
Util::addStyle(Application::APP_ID, 'teams-main');

// Load the Text editor so team pages can be edited inline on their
// tabs. The class only resolves while the Text app is enabled.
if (class_exists(LoadEditor::class)) {
$this->eventDispatcher->dispatchTyped(new LoadEditor());
}
$this->initialState->provideInitialState('teamAppProviders', $this->teamApps->load());

return new TemplateResponse(Application::APP_ID, 'main');
}
Expand Down
163 changes: 163 additions & 0 deletions lib/Controller/TeamAppNavigationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Circles\Controller;

use OCA\Circles\Db\CircleRequest;
use OCA\Circles\Exceptions\CircleNotFoundException;
use OCA\Circles\Exceptions\InsufficientPermissionException;
use OCA\Circles\Model\Member;
use OCA\Circles\Service\CircleService;
use OCA\Circles\Service\FederatedUserService;
use OCA\Circles\Service\PermissionService;
use OCA\Circles\Service\TeamAppProviderRegistry;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Teams\ITeamManager;
use OCP\Teams\TeamAppAction;
use OCP\Teams\TeamAppActionResult;
use OCP\Teams\TeamAppNavigation;

class TeamAppNavigationController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private CircleRequest $circleRequest,
private CircleService $circleService,
private FederatedUserService $federatedUserService,
private PermissionService $permissionService,
private IUserSession $userSession,
private TeamAppProviderRegistry $providers,
private ITeamManager $teamManager,
) {
parent::__construct($appName, $request);
}

#[NoAdminRequired]
public function getNavigationEntryIds(string $circleId, string $providerId): DataResponse {
$this->authorize($circleId, $providerId, false);
try {
$circle = $this->circleRequest->getCircle($circleId);
} catch (CircleNotFoundException) {
throw new OCSNotFoundException('Team not found');
}
$navigationEntryIds = json_decode($circle->getSettings()[$this->settingKey($providerId)] ?? '[]', true);
if (!is_array($navigationEntryIds) || !$this->validIds($navigationEntryIds)) {
throw new OCSException('Invalid stored Teams navigation metadata', Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new DataResponse(['navigationEntryIds' => array_values(array_unique($navigationEntryIds))]);
}

/** @param list<string> $navigationEntryIds */
#[NoAdminRequired]
public function setNavigationEntryIds(string $circleId, string $providerId, array $navigationEntryIds): DataResponse {
$user = $this->authorize($circleId, $providerId, true);
if (!$this->validIds($navigationEntryIds)) {
throw new OCSException('Navigation entry IDs must be a list of at most 100 non-empty resource IDs of at most 64 bytes', Http::STATUS_BAD_REQUEST);
}
$navigationEntryIds = array_values(array_unique($navigationEntryIds));
$this->federatedUserService->setLocalCurrentUser($user);
$this->circleService->updateSetting($circleId, $this->settingKey($providerId), json_encode($navigationEntryIds, JSON_THROW_ON_ERROR));
return new DataResponse(['navigationEntryIds' => $navigationEntryIds]);
}

/** @return DataResponse<Http::STATUS_OK, TeamAppNavigation, array{}> */

Check failure on line 77 in lib/Controller/TeamAppNavigationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Controller/TeamAppNavigationController.php:77:14: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\Teams\TeamAppNavigation does not exist (see https://psalm.dev/200)
#[NoAdminRequired]
public function getNavigation(string $circleId, string $providerId): DataResponse {
$context = $this->context($circleId, $providerId, false);
return new DataResponse($this->providers->getProviders()[$providerId]->getNavigation($context));
}

/** @return DataResponse<Http::STATUS_OK, TeamAppActionResult, array{}> */

Check failure on line 84 in lib/Controller/TeamAppNavigationController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Controller/TeamAppNavigationController.php:84:14: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\Teams\TeamAppActionResult does not exist (see https://psalm.dev/200)
#[NoAdminRequired]
public function action(string $circleId, string $providerId, string $actionId, array $parameters = []): DataResponse {
$context = $this->context($circleId, $providerId, true);
$provider = $this->providers->getProviders()[$providerId];
$navigation = $provider->getNavigation($context);
$actions = $navigation->actions ?? [];
foreach ($navigation->entries as $entry) {
array_push($actions, ...($entry->actions ?? []));
}
if (!in_array($actionId, array_map(static fn (TeamAppAction $action): string => $action->id, $actions), true)) {
throw new OCSNotFoundException('Teams app action not found');
}
try {
$result = $provider->executeAction($context, $actionId, $parameters);
} catch (\InvalidArgumentException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_BAD_REQUEST);
} catch (\OCP\Files\NotPermittedException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN);
}
if ($result->navigationEntryIds !== null) {
$this->setNavigationEntryIds($circleId, $providerId, $result->navigationEntryIds);
}
return new DataResponse($result);
}

private function context(string $circleId, string $providerId, bool $write): array {
$user = $this->authorize($circleId, $providerId, $write);
$team = $this->teamManager->getTeam($circleId, $user->getUID());
if ($team === null) {
throw new OCSNotFoundException('Team not found');
}
$member = $this->permissionService->userMustBeMember($user->getUID(), $circleId);
return [
'teamId' => $circleId,
'teamName' => $team->getDisplayName(),
'userId' => $user->getUID(),
'isAdmin' => $member->getLevel() >= Member::LEVEL_ADMIN,
'folder' => $this->teamManager->getTeamFolderProvider()?->getTeamFolder($circleId),
'resources' => $this->teamManager->getSharedWith($circleId, $user->getUID()),
'navigationEntryIds' => $this->getNavigationEntryIds($circleId, $providerId)->getData()['navigationEntryIds'],
'saveNavigationEntryIds' => fn (array $navigationEntryIds) => $this->setNavigationEntryIds($circleId, $providerId, $navigationEntryIds),
];
}

private function authorize(string $circleId, string $providerId, bool $write): IUser {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED);
}
try {
$member = $this->permissionService->userMustBeMember($user->getUID(), $circleId);
if ($write) {
$this->permissionService->memberMustBeAtLeastAdmin($member);
}
} catch (InsufficientPermissionException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN);
}
if (!isset($this->providers->getProviders()[$providerId])) {
throw new OCSNotFoundException('Teams app provider not found');
}
return $user;
}

private function settingKey(string $providerId): string {
return 'teamsAppPins_' . $providerId;
}

private function validIds(array $ids): bool {
if (!array_is_list($ids) || count($ids) > 100) {
return false;
}
foreach ($ids as $id) {
if (!is_string($id) || $id === '' || strlen($id) > 64 || preg_match('/[\x00-\x1f\x7f]/', $id)) {
return false;
}
}
return true;
}
}
98 changes: 98 additions & 0 deletions lib/Service/TeamAppProviderRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Circles\Service;

use OC\AppFramework\Bootstrap\Coordinator;
use OCP\App\IAppManager;
use OCP\L10N\IFactory;
use OCP\Teams\ITeamAppProvider;
use OCP\Teams\TeamAppRouteAlias;
use OCP\Util;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use UnexpectedValueException;

class TeamAppProviderRegistry {
/** @var array<string, ITeamAppProvider>|null */
private ?array $providers = null;

Check failure on line 25 in lib/Service/TeamAppProviderRegistry.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Service/TeamAppProviderRegistry.php:25:10: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\Teams\ITeamAppProvider does not exist (see https://psalm.dev/200)

Check failure on line 25 in lib/Service/TeamAppProviderRegistry.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Service/TeamAppProviderRegistry.php:25:2: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\Teams\ITeamAppProvider does not exist (see https://psalm.dev/200)
/** @var list<string> */
private array $errors = [];

public function __construct(
private Coordinator $bootstrap,
private IAppManager $appManager,
private ContainerInterface $container,
private LoggerInterface $logger,
private IFactory $l10nFactory,
) {
}

/** @return array<string, ITeamAppProvider> */

Check failure on line 38 in lib/Service/TeamAppProviderRegistry.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Service/TeamAppProviderRegistry.php:38:14: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\Teams\ITeamAppProvider does not exist (see https://psalm.dev/200)
public function getProviders(): array {
if ($this->providers !== null) {
return $this->providers;
}
$this->providers = [];
$registrations = [];
try {
$registrations = $this->bootstrap->getRegistrationContext()?->getTeamAppProviders() ?? [];
} catch (Throwable $e) {
$this->errors[] = 'registration';
$this->logger->error('Could not discover Teams app providers', ['exception' => $e]);
}
$seen = [];
foreach ($registrations as $registration) {
$appId = $registration->getAppId();
if (!$this->appManager->isEnabledForUser($appId)) {
continue;
}
try {
if (!preg_match('/^[a-z][a-z0-9_]*$/D', $appId) || isset($seen[$appId])) {
throw new UnexpectedValueException('Invalid or duplicate Teams app provider: ' . $appId);
}
$seen[$appId] = true;
$provider = $this->container->get($registration->getService());
if (!$provider instanceof ITeamAppProvider || $provider->getId() !== $appId) {
throw new UnexpectedValueException('Teams provider identity must match its owning app');
}
$this->providers[$appId] = $provider;
} catch (Throwable $e) {
unset($this->providers[$appId]);
$this->errors[] = $appId;
$this->logger->error('Could not register Teams app provider', ['app' => $appId, 'exception' => $e]);
}
}
return $this->providers;
}

/** @return array{providers: list<array{id: string, order: int, routeAliases: list<TeamAppRouteAlias>, module: ?string}>, errors: list<string>} */

Check failure on line 76 in lib/Service/TeamAppProviderRegistry.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedDocblockClass

lib/Service/TeamAppProviderRegistry.php:76:14: UndefinedDocblockClass: Docblock-defined class, interface or enum named OCP\Teams\TeamAppRouteAlias does not exist (see https://psalm.dev/200)
public function load(): array {
$loaded = [];
foreach ($this->getProviders() as $appId => $provider) {
try {
$module = $provider->load();
if ($module === '') {
throw new UnexpectedValueException('Teams provider must declare a browser module URL');
}
Util::addTranslations($appId, $this->l10nFactory->findLanguage($appId));
$version = $this->appManager->getAppVersion($appId);
if ($module !== null && $version !== '') {
$module .= (str_contains($module, '?') ? '&' : '?') . 'v=' . rawurlencode($version);
}
$loaded[] = ['id' => $appId, 'order' => $provider->getOrder(), 'routeAliases' => $provider->getRouteAliases(), 'module' => $module];
} catch (Throwable $e) {
$this->errors[] = $appId;
$this->logger->error('Could not load Teams app provider', ['app' => $appId, 'exception' => $e]);
}
}
return ['providers' => $loaded, 'errors' => array_values(array_unique($this->errors))];
}
}
Loading
Loading