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
2 changes: 2 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,7 @@ Those groups of people can then be used by any other app for sharing purpose.

<settings>
<admin>OCA\Circles\Settings\Admin</admin>
<admin>OCA\Circles\Settings\AdminTeamFolders</admin>
<admin-section>OCA\Circles\Settings\AdminSection</admin-section>
</settings>
</info>
3 changes: 3 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
['name' => 'Local#uploadAvatar', 'url' => '/circles/{circleId}/avatar', 'verb' => 'POST'],
['name' => 'Local#removeAvatar', 'url' => '/circles/{circleId}/avatar', 'verb' => 'DELETE'],
['name' => 'Local#link', 'url' => '/link/{circleId}/{singleId}', 'verb' => 'GET'],
['name' => 'TeamFolder#getTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'GET'],
['name' => 'TeamFolder#unlinkTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'DELETE'],
['name' => 'TeamFolder#upgradeTeamFolder', 'url' => '/teams/{circleId}/folder', 'verb' => 'POST'],

// Teams Dashboard widget endpoint
['name' => 'TeamsDashboard#getCompleteTeamsData', 'url' => '/teams/dashboard/widget', 'verb' => 'GET'],
Expand Down
6 changes: 2 additions & 4 deletions css/teams-dashboard.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* extracted by css-entry-points-plugin */
@import './dashboard-BQNkvnbt.chunk.css';
@import './logger-CNf09jSX.chunk.css';
@import './NcActionRouter-vYFtIOzD-BuuqIKuh.chunk.css';
@import './NcAvatar-DX-Nk9Es-DvtEJIrI.chunk.css';
@import './dashboard-DsW2gQLu.chunk.css';
@import './index-UyurHCn6.chunk.css';
2 changes: 1 addition & 1 deletion css/teams-main.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
@import './NcAvatar-DX-Nk9Es-DvtEJIrI.chunk.css';
@import './index-bfXBK-tQ.chunk.css';
@import './index--xyrXnuf.chunk.css';
@import './NcSettingsSection-DmfxX2se-D7mIRwIy.chunk.css';
@import './NcSettingsSection-DmfxX2se-D7mIRwIy.chunk.css';
6 changes: 2 additions & 4 deletions css/teams-settings-admin.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* extracted by css-entry-points-plugin */
@import './settings-admin-D9UlOAjC.chunk.css';
@import './logger-CNf09jSX.chunk.css';
@import './NcCheckboxRadioSwitch-BVTMQSAg-rNRsAuW3.chunk.css';
@import './NcSettingsSection-DmfxX2se-D7mIRwIy.chunk.css';
@import './settings-admin-BEZBKJuE.chunk.css';
@import './index-UyurHCn6.chunk.css';
3 changes: 3 additions & 0 deletions css/teams-settings-team-folders.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* extracted by css-entry-points-plugin */
@import './settings-team-folders-Kw2lcKKl.chunk.css';
@import './index-UyurHCn6.chunk.css';
1 change: 1 addition & 0 deletions img/circles-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Circles\Events\AddingCircleMemberEvent;
use OCA\Circles\Events\CircleMemberAddedEvent;
use OCA\Circles\Events\CircleMemberRemovedEvent;
use OCA\Circles\Events\CreatingCircleEvent;
use OCA\Circles\Events\DestroyingCircleEvent;
use OCA\Circles\Events\Files\CreatingFileShareEvent;
use OCA\Circles\Events\Files\FileShareCreatedEvent;
Expand All @@ -40,6 +41,7 @@
use OCA\Circles\Listeners\GroupMemberRemoved;
use OCA\Circles\Listeners\NodeEventListener;
use OCA\Circles\Listeners\Notifications\RequestingMember as ListenerNotificationsRequestingMember;
use OCA\Circles\Listeners\TeamFolderLifecycleListener;
use OCA\Circles\Listeners\UserCreated;
use OCA\Circles\Listeners\UserDeleted;
use OCA\Circles\MountManager\CircleMountProvider;
Expand Down Expand Up @@ -113,6 +115,8 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(GroupDeletedEvent::class, GroupDeleted::class);
$context->registerEventListener(UserAddedEvent::class, GroupMemberAdded::class);
$context->registerEventListener(UserRemovedEvent::class, GroupMemberRemoved::class);
$context->registerEventListener(CreatingCircleEvent::class, TeamFolderLifecycleListener::class);
$context->registerEventListener(DestroyingCircleEvent::class, TeamFolderLifecycleListener::class);

// Local Events (for Files/Shares/Notifications management)
$context->registerEventListener(PreparingCircleMemberEvent::class, ListenerFilesPreparingMemberSendMail::class);
Expand Down
3 changes: 2 additions & 1 deletion lib/AppInfo/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ private function getCapabilitiesCircleConstants(): array {
Circle::CFG_ROOT => $this->l10n->t('Root'),
Circle::CFG_CIRCLE_INVITE => $this->l10n->t('Team invite'),
Circle::CFG_FEDERATED => $this->l10n->t('Federated'),
Circle::CFG_MOUNTPOINT => $this->l10n->t('Mount point')
Circle::CFG_MOUNTPOINT => $this->l10n->t('Mount point'),
Circle::CFG_APP => $this->l10n->t('App')
],
'source'
=> [
Expand Down
4 changes: 4 additions & 0 deletions lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ 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_FOLDER_AUTO_CREATE = 'team_folder_auto_create';
public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota';

public function getStrictness(): Strictness {
return Strictness::IGNORE;
Expand All @@ -34,6 +36,8 @@ 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_FOLDER_AUTO_CREATE, type: ValueType::BOOL, defaultRaw: true, definition: 'automatically create a team folder when a new team is created', lazy: true),
new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: 0, definition: 'default quota in bytes for auto-created team folders (0 means unlimited)', lazy: true),
];
}

Expand Down
16 changes: 16 additions & 0 deletions lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,29 @@ public function setValue(string $key, string $value): DataResponse {
return $this->getValues();
}

if ($key === ConfigLexicon::TEAM_FOLDER_AUTO_CREATE) {
$this->appConfig->setAppValueBool(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, $value === 'yes');
return $this->getValues();
}

if ($key === ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA) {
if (!is_numeric($value) || (int)$value < 0) {
return new DataResponse(['data' => ['message' => 'quota must be a non-negative integer']], Http::STATUS_BAD_REQUEST);
}

$this->appConfig->setAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, (int)$value);
return $this->getValues();
}

return new DataResponse(['data' => ['message' => 'unsupported key']], Http::STATUS_BAD_REQUEST);
}

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_FOLDER_AUTO_CREATE => $this->appConfig->getAppValueBool(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true),
ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA => $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0),
]);
}

Expand Down
139 changes: 139 additions & 0 deletions lib/Controller/TeamFolderController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?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\Service\PermissionService;
use OCA\Circles\Service\TeamFolderPolicy;
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\Team;

class TeamFolderController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private readonly ITeamManager $teamManager,
private readonly TeamFolderPolicy $policy,
private readonly CircleRequest $circleRequest,
private readonly PermissionService $permissionService,
private readonly IUserSession $userSession,
) {
parent::__construct($appName, $request);
}

#[NoAdminRequired]
public function getTeamFolder(string $circleId): DataResponse {
$this->assertAuthenticatedUserIsMember($circleId);
$folder = $this->getProvider()->getTeamFolder($circleId);

Check failure on line 45 in lib/Controller/TeamFolderController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Controller/TeamFolderController.php:45:13: UndefinedClass: Class, interface or enum named OCP\Teams\ITeamFolderProvider does not exist (see https://psalm.dev/019)
if ($folder === null) {
throw new OCSNotFoundException('No team folder linked to this team');
}

return new DataResponse($folder->jsonSerialize());
}

#[NoAdminRequired]
public function upgradeTeamFolder(string $circleId): DataResponse {
$circle = $this->getCircle($circleId);
$this->assertAuthenticatedUserIsTeamAdmin($circleId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a specific reason upgradeTeamFolder only requires admin level while unlinkTeamFolder requires owner level? not sure if this was by design, just raising the question

$folder = $this->getProvider()->createTeamFolder(

Check failure on line 57 in lib/Controller/TeamFolderController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Controller/TeamFolderController.php:57:13: UndefinedClass: Class, interface or enum named OCP\Teams\ITeamFolderProvider does not exist (see https://psalm.dev/019)
new Team(
teamId: $circle->getSingleId(),
displayName: $circle->getDisplayName(),
link: null,
),
$this->policy->getDefaultQuota(),
);

return new DataResponse([
'success' => true,
'folderId' => $folder->getId(),
'folder' => $folder->jsonSerialize(),
]);
}

#[NoAdminRequired]
public function unlinkTeamFolder(string $circleId, bool $deleteFolder = false): DataResponse {
$this->assertAuthenticatedUserIsTeamOwner($circleId);
$provider = $this->getProvider();
if ($deleteFolder) {
$changed = $provider->removeTeamFolder($circleId);

Check failure on line 78 in lib/Controller/TeamFolderController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Controller/TeamFolderController.php:78:15: UndefinedClass: Class, interface or enum named OCP\Teams\ITeamFolderProvider does not exist (see https://psalm.dev/019)
} else {
$changed = $provider->unlinkTeamFolder($circleId) !== null;

Check failure on line 80 in lib/Controller/TeamFolderController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Controller/TeamFolderController.php:80:15: UndefinedClass: Class, interface or enum named OCP\Teams\ITeamFolderProvider does not exist (see https://psalm.dev/019)
}
if (!$changed) {
throw new OCSNotFoundException('No team folder linked to this team');
}

return new DataResponse(['success' => true]);
}

private function getProvider(): \OCP\Teams\ITeamFolderProvider {

Check failure on line 89 in lib/Controller/TeamFolderController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedClass

lib/Controller/TeamFolderController.php:89:34: UndefinedClass: Class, interface or enum named OCP\Teams\ITeamFolderProvider does not exist (see https://psalm.dev/019)
$provider = $this->teamManager->getTeamFolderProvider();

Check failure on line 90 in lib/Controller/TeamFolderController.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Controller/TeamFolderController.php:90:35: UndefinedInterfaceMethod: Method OCP\Teams\ITeamManager::getTeamFolderProvider does not exist (see https://psalm.dev/181)
if ($provider === null) {
throw new OCSNotFoundException('No team folder provider is enabled');
}

return $provider;
}

private function getCircle(string $circleId): \OCA\Circles\Model\Circle {
try {
return $this->circleRequest->getCircle($circleId);
} catch (CircleNotFoundException) {
throw new OCSNotFoundException('Team not found');
}
}

private function assertAuthenticatedUserIsMember(string $circleId): void {
try {
$this->permissionService->userMustBeMember($this->getAuthenticatedUser()->getUID(), $circleId);
} catch (InsufficientPermissionException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN);
}
}

private function assertAuthenticatedUserIsTeamAdmin(string $circleId): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private function assertAuthenticatedUserIsTeamAdmin(string $circleId): void {
private function assertAuthenticatedUserIsTeamAdminOrServerAdmin(string $circleId): void {

try {
$this->permissionService->userMustBeAtLeastTeamAdminOrServerAdmin($this->getAuthenticatedUser()->getUID(), $circleId);
} catch (InsufficientPermissionException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN);
}
}

private function assertAuthenticatedUserIsTeamOwner(string $circleId): void {
try {
$member = $this->permissionService->userMustBeMember($this->getAuthenticatedUser()->getUID(), $circleId);
$this->permissionService->memberMustBeOwner($member);
} catch (InsufficientPermissionException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN);
}
}

private function getAuthenticatedUser(): IUser {
$user = $this->userSession->getUser();
if ($user === null) {
throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED);
}

return $user;
}
}
79 changes: 79 additions & 0 deletions lib/Listeners/TeamFolderLifecycleListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

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

namespace OCA\Circles\Listeners;

use OCA\Circles\Events\CreatingCircleEvent;
use OCA\Circles\Events\DestroyingCircleEvent;
use OCA\Circles\Service\TeamFolderPolicy;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Teams\ITeamManager;
use OCP\Teams\Team;
use Psr\Log\LoggerInterface;

/**
* @template-implements IEventListener<CreatingCircleEvent|DestroyingCircleEvent>
*/
class TeamFolderLifecycleListener implements IEventListener {
public function __construct(
private readonly ITeamManager $teamManager,
private readonly TeamFolderPolicy $policy,
private readonly LoggerInterface $logger,
) {
}

#[\Override]
public function handle(Event $event): void {
if ($event instanceof DestroyingCircleEvent) {
$provider = $this->teamManager->getTeamFolderProvider();

Check failure on line 35 in lib/Listeners/TeamFolderLifecycleListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Listeners/TeamFolderLifecycleListener.php:35:36: UndefinedInterfaceMethod: Method OCP\Teams\ITeamManager::getTeamFolderProvider does not exist (see https://psalm.dev/181)
if ($provider === null) {
return;
}

// The circle (team) is being destroyed entirely, so remove the
// team folder and its contents rather than unlinking it. Unlinking
// would leave an orphaned folder that no team owns anymore and
// that is hard for an admin to discover and clean up.
$circle = $event->getCircle();
$provider->removeTeamFolder($circle->getSingleId());
return;
}

if (!$event instanceof CreatingCircleEvent) {
return;
}

$circle = $event->getCircle();
if (!$this->policy->shouldCreateTeamFolder($circle)) {
return;
}

$provider = $this->teamManager->getTeamFolderProvider();

Check failure on line 58 in lib/Listeners/TeamFolderLifecycleListener.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UndefinedInterfaceMethod

lib/Listeners/TeamFolderLifecycleListener.php:58:35: UndefinedInterfaceMethod: Method OCP\Teams\ITeamManager::getTeamFolderProvider does not exist (see https://psalm.dev/181)
if ($provider === null) {
return;
}

try {
$provider->createTeamFolder(
new Team(
teamId: $circle->getSingleId(),
displayName: $circle->getDisplayName(),
link: null,
),
$this->policy->getDefaultQuota(),
);
} catch (\Throwable $e) {
$this->logger->error('Failed to auto-create team folder', [
'teamId' => $circle->getSingleId(),
'exception' => $e,
]);
}
}
}
6 changes: 3 additions & 3 deletions lib/Model/Circle.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow
16384 => 'CI|Circle Invite',
32768 => 'F|Federated',
65536 => 'M|Nountpoint',
131072 => 'A|App'
131072 => 'A|App',
];

/**
Expand All @@ -141,7 +141,7 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow
public static $DEF_CFG_CORE_FILTER = [
1,
2,
4
4,
];

public static $DEF_CFG_SYSTEM_FILTER = [
Expand Down Expand Up @@ -801,7 +801,7 @@ public function jsonSerialize(): array {
'description' => $this->getDescription(),
'url' => $this->getUrl(),
'creation' => $this->getCreation(),
'initiator' => ($this->hasInitiator()) ? $this->getInitiator() : null
'initiator' => ($this->hasInitiator()) ? $this->getInitiator() : null,
];

if ($this->hasOwner()) {
Expand Down
Loading
Loading