diff --git a/appinfo/info.xml b/appinfo/info.xml index ecbdf8608..4f7f9c526 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -12,7 +12,7 @@ Those groups of people can then be used by any other app for sharing purpose. ]]> - 35.0.0-dev.0 + 35.0.0-dev.1 agpl Maxence Lange @@ -100,5 +100,7 @@ Those groups of people can then be used by any other app for sharing purpose. OCA\Circles\Settings\Admin + OCA\Circles\Settings\AdminTeamFolders + OCA\Circles\Settings\AdminSection diff --git a/appinfo/routes.php b/appinfo/routes.php index ed5137e22..9066dc52e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -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'], diff --git a/css/teams-dashboard.css b/css/teams-dashboard.css index cdc980871..f9f0ef1c6 100644 --- a/css/teams-dashboard.css +++ b/css/teams-dashboard.css @@ -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'; \ No newline at end of file +@import './dashboard-DsW2gQLu.chunk.css'; +@import './index-UyurHCn6.chunk.css'; \ No newline at end of file diff --git a/css/teams-main.css b/css/teams-main.css index 205266856..e359f3383 100644 --- a/css/teams-main.css +++ b/css/teams-main.css @@ -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'; \ No newline at end of file +@import './main-CFgzRPpb.chunk.css'; +@import './index-UyurHCn6.chunk.css'; \ No newline at end of file diff --git a/css/teams-settings-admin.css b/css/teams-settings-admin.css index 6227d23fd..f90d85f75 100644 --- a/css/teams-settings-admin.css +++ b/css/teams-settings-admin.css @@ -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'; \ No newline at end of file +@import './settings-admin-BEZBKJuE.chunk.css'; +@import './index-UyurHCn6.chunk.css'; \ No newline at end of file diff --git a/css/teams-settings-team-folders.css b/css/teams-settings-team-folders.css new file mode 100644 index 000000000..2ccd34910 --- /dev/null +++ b/css/teams-settings-team-folders.css @@ -0,0 +1,3 @@ +/* extracted by css-entry-points-plugin */ +@import './settings-team-folders-Kw2lcKKl.chunk.css'; +@import './index-UyurHCn6.chunk.css'; \ No newline at end of file diff --git a/img/circles-dark.svg b/img/circles-dark.svg index 73ecb1e9c..c916698e6 100644 --- a/img/circles-dark.svg +++ b/img/circles-dark.svg @@ -1,4 +1,5 @@ diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 7aee8dad7..3e3970014 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -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; @@ -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; @@ -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); @@ -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); } /** diff --git a/lib/AppInfo/Capabilities.php b/lib/AppInfo/Capabilities.php index 6210557d2..48e151f78 100644 --- a/lib/AppInfo/Capabilities.php +++ b/lib/AppInfo/Capabilities.php @@ -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' => [ diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 2a83a9997..4337fd4d1 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -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; @@ -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), ]; } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index ef8be13bb..6b6c3eb20 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -40,6 +40,20 @@ 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); } @@ -47,6 +61,8 @@ 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), ]); } diff --git a/lib/Controller/TeamFolderController.php b/lib/Controller/TeamFolderController.php new file mode 100644 index 000000000..d8beae7a1 --- /dev/null +++ b/lib/Controller/TeamFolderController.php @@ -0,0 +1,139 @@ +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; + } +} diff --git a/lib/Db/CoreQueryBuilder.php b/lib/Db/CoreQueryBuilder.php index f6c76c9cf..9e84eed55 100644 --- a/lib/Db/CoreQueryBuilder.php +++ b/lib/Db/CoreQueryBuilder.php @@ -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 @@ -1567,6 +1567,44 @@ private function generateCircleSelectAlias(string $alias, array $default = []): return $this; } + /** + * @param list $fields + * @return list + */ + 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 diff --git a/lib/Listeners/TeamFolderLifecycleListener.php b/lib/Listeners/TeamFolderLifecycleListener.php new file mode 100644 index 000000000..72bddef8f --- /dev/null +++ b/lib/Listeners/TeamFolderLifecycleListener.php @@ -0,0 +1,75 @@ + + */ +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(); + if ($provider === null) { + return; + } + + $circle = $event->getCircle(); + $provider->unlinkTeamFolder($circle->getSingleId()); + return; + } + + if (!$event instanceof CreatingCircleEvent) { + return; + } + + $circle = $event->getCircle(); + if (!$this->policy->shouldCreateTeamFolder($circle)) { + return; + } + + $provider = $this->teamManager->getTeamFolderProvider(); + 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, + ]); + } + } +} diff --git a/lib/Migration/Version3500Date20260721000001.php b/lib/Migration/Version3500Date20260721000001.php new file mode 100644 index 000000000..3fa4f1a6f --- /dev/null +++ b/lib/Migration/Version3500Date20260721000001.php @@ -0,0 +1,88 @@ +connection->getQueryBuilder(); + $query->select('unique_id', 'settings', 'config') + ->from('circles_circle'); + $result = $query->executeQuery(); + + $migrated = 0; + while (($row = $result->fetch()) !== false) { + $uniqueId = (string)$row['unique_id']; + $rawSettings = $row['settings'] ?? null; + $config = (int)($row['config'] ?? 0); + + $settings = []; + if ($rawSettings !== null && $rawSettings !== '') { + try { + $decoded = json_decode($rawSettings, true, 512, JSON_THROW_ON_ERROR); + if (is_array($decoded)) { + $settings = $decoded; + } + } catch (\JsonException $e) { + $this->logger->warning( + 'Could not decode circles_circle settings during team_folder_id backfill', + ['unique_id' => $uniqueId, 'exception' => $e], + ); + continue; + } + } + + $hadEssentialKey = array_key_exists('team_folder_essential', $settings) + || array_key_exists('team_folder_id', $settings); + + if (!$hadEssentialKey && ($config & self::CFG_TEAM_FOLDER_LEGACY) === 0) { + continue; + } + + unset($settings['team_folder_id'], $settings['team_folder_essential']); + $newConfig = $config & ~self::CFG_TEAM_FOLDER_LEGACY; + + $update = $this->connection->getQueryBuilder(); + $update->update('circles_circle') + ->set('settings', $update->createNamedParameter(json_encode($settings, JSON_THROW_ON_ERROR))) + ->set('config', $update->createNamedParameter($newConfig, IQueryBuilder::PARAM_INT)) + ->where($update->expr()->eq('unique_id', $update->createNamedParameter($uniqueId))); + $update->executeStatement(); + $migrated++; + } + $result->closeCursor(); + + if ($migrated > 0) { + $this->logger->info('Removed legacy Circles team folder metadata', [ + 'migrated' => $migrated, + ]); + } + } +} diff --git a/lib/Migration/Version3500Date20260722000001.php b/lib/Migration/Version3500Date20260722000001.php new file mode 100644 index 000000000..22d5abf9a --- /dev/null +++ b/lib/Migration/Version3500Date20260722000001.php @@ -0,0 +1,40 @@ +getTable('circles_circle'); + + if ($table->hasIndex('circles_team_folder')) { + $table->dropIndex('circles_team_folder'); + } + if ($table->hasColumn('team_folder_id')) { + $table->dropColumn('team_folder_id'); + } + + return $schema; + } +} diff --git a/lib/Model/Circle.php b/lib/Model/Circle.php index 5b236097f..a29fa2e0b 100644 --- a/lib/Model/Circle.php +++ b/lib/Model/Circle.php @@ -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', ]; /** @@ -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 = [ @@ -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()) { diff --git a/lib/Service/PermissionService.php b/lib/Service/PermissionService.php index 5651bd71e..9936eea6b 100644 --- a/lib/Service/PermissionService.php +++ b/lib/Service/PermissionService.php @@ -21,6 +21,7 @@ use OCA\Circles\Model\Circle; use OCA\Circles\Model\Helpers\MemberHelper; use OCA\Circles\Model\Member; +use OCP\IGroupManager; use OCP\IL10N; class PermissionService { @@ -31,6 +32,7 @@ public function __construct( private readonly ConfigService $configService, private readonly MemberRequest $memberRequest, private readonly MembershipRequest $membershipRequest, + private readonly IGroupManager $groupManager, ) { } @@ -206,4 +208,34 @@ public function memberMustBeHigherLevelThan(Member $memberUser, string $targetMe ); } } + + /** + * Returns true if the given user id is a Nextcloud server admin (member + * of the `admin` group). Server admins are allowed to bypass per-team + * permission checks for administrative operations such as creating a + * team folder. + * + * @param string $userId + * @return bool + */ + public function isServerAdmin(string $userId): bool { + return $this->groupManager->isAdmin($userId); + } + + /** + * Require the current user to be at least a team admin (level ADMIN) of + * the given circle, or a Nextcloud server admin. + * + * @param string $userId + * @param string $circleId + * @throws InsufficientPermissionException + */ + public function userMustBeAtLeastTeamAdminOrServerAdmin(string $userId, string $circleId): void { + if ($this->isServerAdmin($userId)) { + return; + } + + $member = $this->userMustBeMember($userId, $circleId); + $this->memberMustBeAtLeastAdmin($member); + } } diff --git a/lib/Service/TeamFolderPolicy.php b/lib/Service/TeamFolderPolicy.php new file mode 100644 index 000000000..0d6da32a7 --- /dev/null +++ b/lib/Service/TeamFolderPolicy.php @@ -0,0 +1,41 @@ +appConfig->getAppValueBool(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true); + if (!$autoCreate) { + return false; + } + + if ($circle->isConfig(Circle::CFG_PERSONAL)) { + return false; + } + + if ($circle->isConfig(Circle::CFG_HIDDEN)) { + return false; + } + + if ($circle->isConfig(Circle::CFG_SYSTEM)) { + return false; + } + + if ($circle->isConfig(Circle::CFG_BACKEND)) { + return false; + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function getDefaultQuota(): int { + return $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0); + } +} diff --git a/lib/Settings/AdminSection.php b/lib/Settings/AdminSection.php new file mode 100644 index 000000000..20e297131 --- /dev/null +++ b/lib/Settings/AdminSection.php @@ -0,0 +1,37 @@ +l->t('Teams'); + } + + public function getPriority(): int { + return 80; + } + + public function getIcon(): string { + return $this->url->imagePath(Application::APP_ID, 'circles-dark.svg'); + } +} diff --git a/lib/Settings/AdminTeamFolders.php b/lib/Settings/AdminTeamFolders.php new file mode 100644 index 000000000..7674afed1 --- /dev/null +++ b/lib/Settings/AdminTeamFolders.php @@ -0,0 +1,77 @@ +appConfig->getValueBool(Application::APP_ID, ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true); + $teamFolderDefaultQuota = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0); + + $this->initialState->provideInitialState('teamFolderAutoCreate', $teamFolderAutoCreate); + $this->initialState->provideInitialState('teamFolderDefaultQuota', $teamFolderDefaultQuota); + + \OCP\Util::addStyle(Application::APP_ID, 'teams-settings-team-folders'); + \OCP\Util::addScript(Application::APP_ID, 'teams-settings-team-folders'); + + return new TemplateResponse(Application::APP_ID, 'settings-team-folders', renderAs: ''); + } + + /** + * @return string the section ID, e.g. 'sharing' + */ + public function getSection(): string { + return 'teams'; + } + + /** + * @return int whether the form should be rather on the top or bottom of + * the admin section. The forms are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * + * E.g.: 70 + */ + public function getPriority(): int { + return 50; + } + + public function getName(): ?string { + return $this->l->t('Team spaces'); + } + + public function getAuthorizedAppConfig(): array { + return [ + Application::APP_ID => [ + ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, + ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, + ], + ]; + } +} diff --git a/lib/StatusCode.php b/lib/StatusCode.php index eeba47faa..30597a3ea 100644 --- a/lib/StatusCode.php +++ b/lib/StatusCode.php @@ -53,7 +53,7 @@ class StatusCode { ]; public static $CIRCLE_DESTROY = [ - 120 => 'Team is managed from an other app' + 120 => 'Team is managed from an other app', ]; public static $MEMBER_LEVEL = [ diff --git a/package-lock.json b/package-lock.json index 609ee896a..70eb4a8d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nextcloud/axios": "^2.5.1", "@nextcloud/cdav-library": "^2.6.2", "@nextcloud/dialogs": "^7.4.0", + "@nextcloud/files": "^3.12.2", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.2", "@nextcloud/password-confirmation": "^6.1.0", @@ -354,6 +355,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -402,6 +404,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1599,6 +1602,66 @@ "node": "^20 || ^22 || ^24" } }, + "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", + "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.2", + "webdav": "^5.9.0" + }, + "engines": { + "node": "^24.0.0" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/files": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", + "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", + "license": "AGPL-3.0-or-later", + "optional": true, + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "cancelable-promise": "^4.3.1", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.1", + "webdav": "^5.8.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + } + }, + "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/sharing": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", + "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", + "license": "GPL-3.0-or-later", + "dependencies": { + "@nextcloud/initial-state": "^3.0.0", + "is-svg": "^6.1.0" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || ^24.0.0" + }, + "optionalDependencies": { + "@nextcloud/files": "^3.12.0" + } + }, "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/paths": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz", @@ -1713,6 +1776,7 @@ "version": "3.5.39", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", @@ -1786,30 +1850,10 @@ } }, "node_modules/@nextcloud/files": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", - "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", - "dependencies": { - "@nextcloud/auth": "^2.5.3", - "@nextcloud/capabilities": "^1.2.1", - "@nextcloud/l10n": "^3.4.1", - "@nextcloud/logger": "^3.0.3", - "@nextcloud/paths": "^3.0.0", - "@nextcloud/router": "^3.1.0", - "@nextcloud/sharing": "^0.3.0", - "is-svg": "^6.1.0", - "typescript-event-target": "^1.1.2", - "webdav": "^5.9.0" - }, - "engines": { - "node": "^24.0.0" - } - }, - "node_modules/@nextcloud/files/node_modules/@nextcloud/files": { "version": "3.12.2", "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", - "optional": true, + "license": "AGPL-3.0-or-later", "dependencies": { "@nextcloud/auth": "^2.5.3", "@nextcloud/capabilities": "^1.2.1", @@ -1831,6 +1875,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-3.1.0.tgz", "integrity": "sha512-vtFYA/kthaUDzu6KejTOL1OwnOy7/yynq5zdB/UBpYacAWjUX5Ddh4OMWx3rEavkBJ9/QGhrFryNJLjNfe8OQA==", + "license": "GPL-3.0-or-later", "engines": { "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } @@ -1839,6 +1884,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", + "license": "GPL-3.0-or-later", "dependencies": { "@nextcloud/initial-state": "^3.0.0", "is-svg": "^6.1.0" @@ -2019,6 +2065,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", @@ -2308,6 +2355,7 @@ "version": "3.5.39", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", @@ -2398,7 +2446,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", @@ -2441,7 +2488,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2463,7 +2509,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2485,7 +2530,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2507,7 +2551,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2529,7 +2572,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2551,7 +2593,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2573,7 +2614,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2595,7 +2635,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2617,7 +2656,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2639,7 +2677,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2661,7 +2698,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2683,7 +2719,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -2705,7 +2740,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -3238,6 +3272,7 @@ "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -3382,6 +3417,7 @@ "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", @@ -3596,6 +3632,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -3814,6 +3851,7 @@ "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.9", @@ -4030,6 +4068,7 @@ "version": "3.5.22", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz", "integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==", + "peer": true, "dependencies": { "@vue/compiler-core": "3.5.22", "@vue/shared": "3.5.22" @@ -4150,7 +4189,6 @@ "version": "3.5.22", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", - "peer": true, "dependencies": { "@vue/shared": "3.5.22" } @@ -4159,7 +4197,6 @@ "version": "3.5.22", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" @@ -4169,7 +4206,6 @@ "version": "3.5.22", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", - "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", @@ -4310,6 +4346,7 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4726,7 +4763,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "fill-range": "^7.1.1" }, @@ -4904,6 +4940,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -5045,7 +5082,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/cancelable-promise/-/cancelable-promise-4.3.1.tgz", "integrity": "sha512-A/8PwLk/T7IJDfUdQ68NR24QHa8rIlnN/stiJEBo6dmVUkD4K14LswG0w3VwdeK/o7qOwRUR1k2MhK5Rpy2m7A==", - "optional": true + "license": "MIT" }, "node_modules/caniuse-lite": { "version": "1.0.30001751", @@ -5142,7 +5179,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -5752,7 +5788,6 @@ "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -6052,6 +6087,7 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -6120,6 +6156,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, + "peer": true, "workspaces": [ "packages/*" ], @@ -6640,7 +6677,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -7271,8 +7307,7 @@ "version": "5.1.9", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/import-lazy": { "version": "4.0.0", @@ -7498,7 +7533,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=0.12.0" } @@ -8760,7 +8794,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -8775,7 +8808,6 @@ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=8.6" }, @@ -8988,8 +9020,7 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-domexception": { "version": "1.0.0", @@ -9560,6 +9591,7 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -9941,7 +9973,6 @@ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -10141,6 +10172,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "devOptional": true, + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -11169,7 +11201,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "is-number": "^7.0.0" }, @@ -11327,6 +11358,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11361,7 +11393,8 @@ "node_modules/typescript-event-target": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/typescript-event-target/-/typescript-event-target-1.1.2.tgz", - "integrity": "sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==" + "integrity": "sha512-TvkrTUpv7gCPlcnSoEwUVUBwsdheKm+HF5u2tPAKubkIGMfovdSizCTaZRY/NhR8+Ijy8iZZUapbVQAsNrkFrw==", + "license": "MIT" }, "node_modules/ufo": { "version": "1.6.4", @@ -11754,6 +11787,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "devOptional": true, + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12340,6 +12374,7 @@ "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", diff --git a/package.json b/package.json index 380afb7ce..2074b3f32 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@nextcloud/axios": "^2.5.1", "@nextcloud/cdav-library": "^2.6.2", "@nextcloud/dialogs": "^7.4.0", + "@nextcloud/files": "^3.12.2", "@nextcloud/l10n": "^3.4.1", "@nextcloud/logger": "^3.0.2", "@nextcloud/password-confirmation": "^6.1.0", diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 478eae16f..8f6a3e1da 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -104,6 +104,7 @@ watchDebounced(federatedTeamsFrontal, async (value) => { await updateAppConfig('federated_teams_frontal', value) showSuccess(t('circles', 'Changed frontal cloud URL')) }, { debounce: 500 }) + diff --git a/src/teams/api.ts b/src/teams/api.ts index 5537c6f89..e874692ce 100644 --- a/src/teams/api.ts +++ b/src/teams/api.ts @@ -3,11 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Member, Resource, Team, TeamRole } from './types.ts' +import type { Member, MemberCandidate, Resource, Team, TeamRole } from './types.ts' import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' import { logger } from '../logger.ts' +import { SHARES_TYPES_MEMBER_MAP } from './team-page/models/constants.ts' +import { getRecommendations, getSuggestions } from './team-page/services/collaborationAutocompletion.js' + +/** `SHARES_TYPES_MEMBER_MAP` is built dynamically, so type its shape explicitly. */ +const shareTypeToMemberType = SHARES_TYPES_MEMBER_MAP as Record /** OCS endpoints require this header. */ const HEADERS = { 'OCS-APIRequest': 'true' } @@ -54,6 +59,15 @@ interface RawDashboardTeam { resources: RawResource[] } +/** Raw sharee suggestion, as returned by the files_sharing autocompletion helper. */ +interface RawSuggestion { + id: string + label: string + shareWith: string + shareType: number + user?: string | null +} + /** * Map a circles member level to a role. * @@ -226,3 +240,98 @@ export async function deleteTeam(teamId: string): Promise { { headers: HEADERS }, ) } + +/** + * The team folder linked to a team. + */ +export interface TeamFolder { + id: number + mountPoint: string +} + +/** + * Fetch the team folder linked to a team. + * + * The Circles app exposes the team-folder lifecycle through the core Teams + * contract. The active provider is discovered server-side. + * + * @param teamId - The team single id + * @return The linked team folder, or null if none exists. + */ +export async function getTeamFolder(teamId: string): Promise { + try { + const { data } = await axios.get>( + generateOcsUrl('apps/circles/teams/{circleId}/folder', { circleId: teamId }), + { headers: HEADERS }, + ) + return data.ocs.data + } catch (error) { + if (error && typeof error === 'object' + && 'response' in error + && (error.response as { status?: number })?.status === 404) { + return null + } + throw error + } +} + +/** + * Create a team folder for a team that predates the auto-create feature. + * + * Idempotent: if the team already owns a folder, the existing folder is + * returned. Requires team owner privileges. + * + * @param teamId - The team single id + * @return The created (or existing) team folder. + */ +export async function upgradeTeamFolder(teamId: string): Promise { + const { data } = await axios.post>( + generateOcsUrl('apps/circles/teams/{circleId}/folder', { circleId: teamId }), + {}, + { headers: HEADERS }, + ) + return data.ocs.data.folder +} + +/** + * Search for potential new members (users, groups, emails, contacts, other + * teams…) using the same sharee autocompletion endpoint as file sharing. + * This restores the legacy "add members while creating a team" feature for + * the team creation wizard. + * + * @param term - The search query. An empty term returns curated recommendations. + */ +export async function searchMemberCandidates(term: string): Promise { + const suggestions: RawSuggestion[] = term.trim() + ? await getSuggestions(term) + : await getRecommendations() + + return suggestions.map((suggestion) => ({ + key: suggestion.id, + shareWith: suggestion.shareWith, + shareType: suggestion.shareType, + displayName: suggestion.label, + isUser: suggestion.user !== null && suggestion.user !== undefined, + })) +} + +/** + * Add a batch of picked candidates as members of a team, typically right + * after creating it from the wizard. + * + * @param teamId - The team single id + * @param candidates - The candidates picked in the wizard's member step + * @return The number of candidates that were actually added. + */ +export async function addTeamMembers(teamId: string, candidates: MemberCandidate[]): Promise { + const members = candidates.map((candidate) => ({ + id: candidate.shareWith, + type: shareTypeToMemberType[candidate.shareType], + })) + const res = await axios.post>>( + generateOcsUrl('apps/circles/circles/{circleId}/members/multi', { circleId: teamId }), + { members }, + { headers: HEADERS }, + ) + return Object.keys(res.data.ocs.data ?? {}).length +} diff --git a/src/teams/components/CreateTeamDialog.vue b/src/teams/components/CreateTeamDialog.vue deleted file mode 100644 index f2c0e56c8..000000000 --- a/src/teams/components/CreateTeamDialog.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - - - diff --git a/src/teams/components/GlobalNavigation.vue b/src/teams/components/GlobalNavigation.vue index b0848c6d8..cb5c54ef7 100644 --- a/src/teams/components/GlobalNavigation.vue +++ b/src/teams/components/GlobalNavigation.vue @@ -22,7 +22,7 @@ import { useTeamsStore } from '../store.ts' const store = useTeamsStore() const { loading } = storeToRefs(store) -const { openCreateTeamDialog } = store +const { openCreateTeamWizard } = store const route = useRoute() const query = ref('') @@ -39,7 +39,7 @@ const isOverviewActive = computed(() => route.name === 'home')