Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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>

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.

I don't think we should handle this on on this PR, but rather upon release on a separate PR

<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 {
Comment thread
stediefan marked this conversation as resolved.
Outdated
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 {
Comment thread
stediefan marked this conversation as resolved.
Outdated
static $columns = null;
Comment thread
stediefan marked this conversation as resolved.
Outdated

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