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
4 changes: 3 additions & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
Those groups of people can then be used by any other app for sharing purpose.
]]>
</description>
<version>35.0.0-dev.0</version>
<version>35.0.0-dev.1</version>
<licence>agpl</licence>
<author>Maxence Lange</author>
<types>
Expand Down 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';
11 changes: 2 additions & 9 deletions css/teams-main.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
/* extracted by css-entry-points-plugin */
@import './main-D5fDg73W.chunk.css';
@import './logger-CNf09jSX.chunk.css';
@import './NcCheckboxRadioSwitch-BVTMQSAg-rNRsAuW3.chunk.css';
@import './NcActionRouter-vYFtIOzD-BuuqIKuh.chunk.css';
@import './index-B8JF8h-K.chunk.css';
@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 './main-CFgzRPpb.chunk.css';
@import './index-UyurHCn6.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.
10 changes: 10 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,12 +41,15 @@
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;
use OCA\Circles\Notification\Notifier;
use OCA\Circles\Search\UnifiedSearchProvider;
use OCA\Circles\Service\ConfigService;
use OCA\Circles\Service\TeamFolderPolicy;
use OCA\Circles\Service\TeamFolderService;
use OCA\Circles\ShareByCircleProvider;
use OCP\Accounts\UserUpdatedEvent;
use OCP\AppFramework\App;
Expand Down Expand Up @@ -113,6 +117,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 All @@ -139,6 +145,10 @@ public function register(IRegistrationContext $context): void {
$context->registerTeamResourceProvider(FileSharingTeamResourceProvider::class);

$context->registerConfigLexicon(ConfigLexicon::class);

// The lifecycle listener resolves this policy locally before invoking an
// optional core Teams folder provider.
$context->registerServiceAlias(TeamFolderPolicy::class, TeamFolderService::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\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
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->requireMember($circleId);
$folder = $this->getProvider()->getTeamFolder($circleId);
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->requireTeamAdmin($circleId);
$folder = $this->getProvider()->createTeamFolder(
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->requireTeamOwner($circleId);
$provider = $this->getProvider();
if ($deleteFolder) {
$changed = $provider->removeTeamFolder($circleId);
} else {
$changed = $provider->unlinkTeamFolder($circleId) !== null;
}
if (!$changed) {
throw new OCSNotFoundException('No team folder linked to this team');
}

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

private function getProvider(): \OCP\Teams\ITeamFolderProvider {
$provider = $this->teamManager->getTeamFolderProvider();
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 requireMember(string $circleId): void {
try {
$this->permissionService->userMustBeMember($this->getAuthenticatedUser()->getUID(), $circleId);
} catch (InsufficientPermissionException $e) {
throw new OCSException($e->getMessage(), Http::STATUS_FORBIDDEN);
}
}

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

private function requireTeamOwner(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;
}
}
40 changes: 39 additions & 1 deletion lib/Db/CoreQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -1558,7 +1558,7 @@ public function limitToMountpoints(string $aliasMount, array $paths, bool $forCh
*/
private function generateCircleSelectAlias(string $alias, array $default = []): self {
$this->generateSelectAlias(
CoreRequestBuilder::$tables[CoreRequestBuilder::TABLE_CIRCLE],
$this->filterExistingCircleColumns(CoreRequestBuilder::$tables[CoreRequestBuilder::TABLE_CIRCLE]),
$alias,
$alias,
$default
Expand All @@ -1567,6 +1567,44 @@ private function generateCircleSelectAlias(string $alias, array $default = []):
return $this;
}

/**
* @param list<string> $fields
* @return list<string>
*/
private function filterExistingCircleColumns(array $fields): array {
static $columns = null;

if ($columns === null) {
$columns = false;
try {
$connection = $this->getConnection();
$tableName = CoreRequestBuilder::TABLE_CIRCLE;
if ($connection instanceof \OC\DB\Connection) {
$tableName = $connection->getPrefix() . $tableName;
}
$schema = $connection->createSchema();
if ($schema->hasTable($tableName)) {
$columns = array_map(
static fn ($column): string => strtolower($column->getName()),
$schema->getTable($tableName)->getColumns()
);
}
} catch (\Throwable) {
// Preserve the original query behavior if introspection is unavailable.
$columns = false;
}
}

if ($columns === false) {
return $fields;
}

return array_values(array_filter(
$fields,
static fn (string $field): bool => in_array(strtolower($field), $columns, true)
));
}

/**
* @param string $alias
* @param array $default
Expand Down
Loading