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/components/AdminTeamFolders.vue b/src/components/AdminTeamFolders.vue new file mode 100644 index 000000000..ad8afd6a8 --- /dev/null +++ b/src/components/AdminTeamFolders.vue @@ -0,0 +1,163 @@ + + + + + + + + {{ t('circles', 'Automatically create a team space') }} + + + + + + + {{ t('circles', 'Save') }} + + + + + {{ t('circles', 'Default storage quota applied to each auto-created team space. Use 0 for unlimited storage.') }} + + + + + + diff --git a/src/settings-team-folders.ts b/src/settings-team-folders.ts new file mode 100644 index 000000000..151c51710 --- /dev/null +++ b/src/settings-team-folders.ts @@ -0,0 +1,12 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { createApp } from 'vue' +import AdminTeamFolders from './components/AdminTeamFolders.vue' + +import 'vite/modulepreload-polyfill' + +const app = createApp(AdminTeamFolders) +app.mount('#vue-admin-team-folders') diff --git a/src/teams/App.vue b/src/teams/App.vue index a0ccdff76..97c6096ea 100644 --- a/src/teams/App.vue +++ b/src/teams/App.vue @@ -8,12 +8,12 @@ import { storeToRefs } from 'pinia' import { onMounted } from 'vue' import NcAppContent from '@nextcloud/vue/components/NcAppContent' import NcContent from '@nextcloud/vue/components/NcContent' -import CreateTeamDialog from './components/CreateTeamDialog.vue' import GlobalNavigation from './components/GlobalNavigation.vue' +import TeamCreationWizard from './components/TeamCreationWizard.vue' import { useTeamsStore } from './store.ts' const store = useTeamsStore() -const { createDialogOpen } = storeToRefs(store) +const { createWizardOpen } = storeToRefs(store) onMounted(() => store.loadTeams()) @@ -28,7 +28,7 @@ onMounted(() => store.loadTeams()) - + 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 @@ - - - - - - - - - - - - - - {{ t('circles', 'Cancel') }} - - - {{ t('circles', 'Create team') }} - - - - 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') + @click="openCreateTeamWizard()"> diff --git a/src/teams/components/TeamCreationWizard.vue b/src/teams/components/TeamCreationWizard.vue new file mode 100644 index 000000000..daf23487e --- /dev/null +++ b/src/teams/components/TeamCreationWizard.vue @@ -0,0 +1,336 @@ + + + + + + + + + {{ t('circles', 'Step {current} of {total}', { current: stepIndex + 1, total: STEPS.length }) }} + + + + + {{ t('circles', 'Name your team') }} + + + + + + {{ t('circles', 'Add initial members') }} + + + + + + + + + + + + + + + + + + + + + + {{ candidate.displayName }} + + + + + + + + + diff --git a/src/teams/components/TeamFolderWidget.vue b/src/teams/components/TeamFolderWidget.vue new file mode 100644 index 000000000..021436c80 --- /dev/null +++ b/src/teams/components/TeamFolderWidget.vue @@ -0,0 +1,595 @@ + + + + + + + + + + + + {{ crumb.name }} + + + {{ crumb.name }} + + + / + + + + + + + + + + + + + + + + {{ t('circles', 'Files') }} + + + + + + + + + + + + + + + + + + + + + + {{ node.displayname || node.basename }} + + + + {{ t('circles', 'Folder') }} + + + {{ formatFileSize(node.size) }} + + + + + + + + + + + + + diff --git a/src/teams/components/TeamHeader.vue b/src/teams/components/TeamHeader.vue new file mode 100644 index 000000000..1d3752718 --- /dev/null +++ b/src/teams/components/TeamHeader.vue @@ -0,0 +1,115 @@ + + + + + + + + + + {{ circle.displayName }} + + + {{ t('circles', 'Team owner') }} + + + + + + + + + + + + + + {{ t('circles', 'Manage team') }} + + + + + + + {{ t('circles', 'Copy link') }} + + + + + + + {{ t('circles', 'Leave team') }} + + + + + + + {{ t('circles', 'Delete team') }} + + + + + + diff --git a/src/teams/components/TeamNavigationItem.vue b/src/teams/components/TeamNavigationItem.vue index 3cf701516..af9544c4b 100644 --- a/src/teams/components/TeamNavigationItem.vue +++ b/src/teams/components/TeamNavigationItem.vue @@ -7,96 +7,21 @@ import type { Team } from '../types.ts' import { mdiCogOutline, mdiContentCopy, mdiExitToApp, mdiTrashCanOutline } from '@mdi/js' -import { showConfirmation, showError, showSuccess } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' import { t } from '@nextcloud/l10n' import { computed } from 'vue' -import { useRouter } from 'vue-router' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import TeamAvatar from './TeamAvatar.vue' -import { logger } from '../../logger.ts' -import { useTeamsStore } from '../store.ts' +import { useTeamActions } from '../composables/useTeamActions.ts' const props = defineProps<{ team: Team }>() -const router = useRouter() -const store = useTeamsStore() - const to = computed(() => ({ name: 'team', params: { teamId: props.team.id } })) -const isOwner = computed(() => props.team.myRole === 'owner') -const canManage = computed(() => ['owner', 'admin', 'moderator'].includes(props.team.myRole)) -const canLeave = computed(() => !isOwner.value) -const canDelete = computed(() => isOwner.value) - -/** Open the team and trigger its member picker (same as Contacts "Manage team"). */ -async function onManage(): Promise { - await router.push(to.value) - emit('contacts:circles:append', props.team.id) -} - -/** Copy a direct link to the team. */ -async function onCopyLink(): Promise { - const href = window.location.origin + router.resolve(to.value).href - try { - await navigator.clipboard.writeText(href) - showSuccess(t('circles', 'Link copied to the clipboard')) - } catch (error) { - logger.error('Could not copy link', { error }) - showError(t('circles', 'Could not copy link to the clipboard')) - } -} - -/** Leave the team after confirmation. */ -async function onLeave(): Promise { - const confirmed = await showConfirmation({ - name: t('circles', 'Leave team'), - text: t('circles', 'Are you sure you want to leave {team}?', { team: props.team.displayName }), - labelConfirm: t('circles', 'Leave team'), - labelReject: t('circles', 'Cancel'), - severity: 'warning', - }) - if (!confirmed) { - return - } - try { - await store.leaveTeam(props.team.id) - showSuccess(t('circles', 'You left "{name}"', { name: props.team.displayName })) - if (router.currentRoute.value.params.teamId === props.team.id) { - router.push({ name: 'home' }) - } - } catch (error) { - logger.error('Could not leave the team', { error }) - showError(t('circles', 'Could not leave the team')) - } -} -/** Delete the team after confirmation. */ -async function onDelete(): Promise { - const confirmed = await showConfirmation({ - name: t('circles', 'Delete team'), - text: t('circles', 'Are you sure you want to delete {team}? This cannot be undone.', { team: props.team.displayName }), - labelConfirm: t('circles', 'Delete team'), - labelReject: t('circles', 'Cancel'), - severity: 'error', - }) - if (!confirmed) { - return - } - try { - await store.deleteTeam(props.team.id) - showSuccess(t('circles', 'Team deleted')) - if (router.currentRoute.value.params.teamId === props.team.id) { - router.push({ name: 'home' }) - } - } catch (error) { - logger.error('Could not delete the team', { error }) - showError(t('circles', 'Could not delete the team')) - } -} +const { canManage, canLeave, canDelete, onManage, onCopyLink, onLeave, onDelete } = useTeamActions(() => props.team) diff --git a/src/teams/composables/useTeamActions.ts b/src/teams/composables/useTeamActions.ts new file mode 100644 index 000000000..c10018266 --- /dev/null +++ b/src/teams/composables/useTeamActions.ts @@ -0,0 +1,122 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Team } from '../types.ts' + +import { showConfirmation, showError, showSuccess } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { computed } from 'vue' +import { useRouter } from 'vue-router' +import { logger } from '../../logger.ts' +import { useTeamsStore } from '../store.ts' + +/** + * Team-level actions (manage/copy link/leave/delete) shared between the + * sidebar navigation item's context menu and the dashboard's admin actions + * menu. + * + * @param getTeam a getter returning the team the actions apply to (may be + * undefined while it is still loading) + */ +export function useTeamActions(getTeam: () => Team | undefined) { + const router = useRouter() + const store = useTeamsStore() + + const to = computed(() => ({ name: 'team', params: { teamId: getTeam()?.id } })) + const settingsTo = computed(() => ({ name: 'team-settings', params: { teamId: getTeam()?.id } })) + const isOwner = computed(() => getTeam()?.myRole === 'owner') + const canManage = computed(() => { + const team = getTeam() + return !!team && ['owner', 'admin', 'moderator'].includes(team.myRole) + }) + const canLeave = computed(() => !!getTeam() && !isOwner.value) + const canDelete = computed(() => isOwner.value) + + /** Open the team's Settings page. */ + async function onManage(): Promise { + await router.push(settingsTo.value) + } + + /** Copy a direct link to the team. */ + async function onCopyLink(): Promise { + const href = window.location.origin + router.resolve(to.value).href + try { + await navigator.clipboard.writeText(href) + showSuccess(t('circles', 'Link copied to the clipboard')) + } catch (error) { + logger.error('Could not copy link', { error }) + showError(t('circles', 'Could not copy link to the clipboard')) + } + } + + /** Leave the team after confirmation. */ + async function onLeave(): Promise { + const team = getTeam() + if (!team) { + return + } + const confirmed = await showConfirmation({ + name: t('circles', 'Leave team'), + text: t('circles', 'Are you sure you want to leave {team}?', { team: team.displayName }), + labelConfirm: t('circles', 'Leave team'), + labelReject: t('circles', 'Cancel'), + severity: 'warning', + }) + if (!confirmed) { + return + } + try { + await store.leaveTeam(team.id) + showSuccess(t('circles', 'You left "{name}"', { name: team.displayName })) + if (router.currentRoute.value.params.teamId === team.id) { + router.push({ name: 'home' }) + } + } catch (error) { + logger.error('Could not leave the team', { error }) + showError(t('circles', 'Could not leave the team')) + } + } + + /** Delete the team after confirmation. */ + async function onDelete(): Promise { + const team = getTeam() + if (!team) { + return + } + const confirmed = await showConfirmation({ + name: t('circles', 'Delete team'), + text: t('circles', 'Are you sure you want to delete {team}? This cannot be undone.', { team: team.displayName }), + labelConfirm: t('circles', 'Delete team'), + labelReject: t('circles', 'Cancel'), + severity: 'error', + }) + if (!confirmed) { + return + } + try { + await store.deleteTeam(team.id) + showSuccess(t('circles', 'Team deleted')) + if (router.currentRoute.value.params.teamId === team.id) { + router.push({ name: 'home' }) + } + } catch (error) { + logger.error('Could not delete the team', { error }) + showError(t('circles', 'Could not delete the team')) + } + } + + return { + to, + settingsTo, + isOwner, + canManage, + canLeave, + canDelete, + onManage, + onCopyLink, + onLeave, + onDelete, + } +} diff --git a/src/teams/router.ts b/src/teams/router.ts index fbdf99c95..3789376f0 100644 --- a/src/teams/router.ts +++ b/src/teams/router.ts @@ -8,7 +8,9 @@ import type { RouteRecordRaw } from 'vue-router' import { generateUrl } from '@nextcloud/router' import { createRouter, createWebHistory } from 'vue-router' import HomeView from './views/HomeView.vue' +import TeamDashboardView from './views/TeamDashboardView.vue' import TeamPage from './views/TeamPage.vue' +import TeamSettingsView from './views/TeamSettingsView.vue' const routes: RouteRecordRaw[] = [ { @@ -17,10 +19,23 @@ const routes: RouteRecordRaw[] = [ component: HomeView, }, { - name: 'team', path: '/team/:teamId', component: TeamPage, props: true, + children: [ + { + name: 'team', + path: '', + component: TeamDashboardView, + props: true, + }, + { + name: 'team-settings', + path: 'settings', + component: TeamSettingsView, + props: true, + }, + ], }, ] diff --git a/src/teams/store.ts b/src/teams/store.ts index 8dc158fde..6524ee272 100644 --- a/src/teams/store.ts +++ b/src/teams/store.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Member, Team } from './types.ts' +import type { Member, MemberCandidate, Team } from './types.ts' import { defineStore } from 'pinia' import { logger } from '../logger.ts' @@ -16,8 +16,8 @@ interface TeamsState { loading: boolean /** Whether the last teams load failed. */ loadError: boolean - /** Whether the "create a new team" dialog is open (shared across the app). */ - createDialogOpen: boolean + /** Whether the "create a new team" wizard is open (shared across the app). */ + createWizardOpen: boolean } /** @@ -30,7 +30,7 @@ export const useTeamsStore = defineStore('teams', { teams: [], loading: false, loadError: false, - createDialogOpen: false, + createWizardOpen: false, }), getters: { @@ -48,9 +48,9 @@ export const useTeamsStore = defineStore('teams', { }, actions: { - /** Open the "create a new team" dialog. */ - openCreateTeamDialog(): void { - this.createDialogOpen = true + /** Open the "create a new team" wizard. */ + openCreateTeamWizard(): void { + this.createWizardOpen = true }, /** Load (or reload) the list of teams from the backend. */ @@ -97,6 +97,27 @@ export const useTeamsStore = defineStore('teams', { return this.getTeam(id) }, + /** + * Search for potential new members while creating a team (users, + * groups, emails, contacts, other teams…). + * + * @param term - The search query + */ + searchMemberCandidates(term: string): Promise { + return api.searchMemberCandidates(term) + }, + + /** + * Add initial members to a freshly created team. + * + * @param teamId - The team id + * @param candidates - The candidates picked in the wizard's member step + * @return The number of candidates that were actually added. + */ + addTeamMembers(teamId: string, candidates: MemberCandidate[]): Promise { + return api.addTeamMembers(teamId, candidates) + }, + /** * Leave a team and reload the list. * diff --git a/src/teams/team-page/components/CircleDetails.vue b/src/teams/team-page/components/CircleDetails.vue index ef5bda04c..147ea4edc 100644 --- a/src/teams/team-page/components/CircleDetails.vue +++ b/src/teams/team-page/components/CircleDetails.vue @@ -6,48 +6,27 @@ - - - - + + + - - - - {{ circle.displayName }} - - - - - - {{ t('circles', 'Team owner') }} - - - - {{ circle.description }} - - + + + {{ circle.description }} - - + + + @@ -174,6 +153,7 @@ :value="resourceInputs[resourceType.id] || ''" :is-open="activePopover === resourceType.id" :helper-text="resourceType.helperText" + :loading="resourceType.id === 'teamfolder' && creatingTeamFolder" @update:value="updateResourceInput(resourceType.id, $event)" @update:is-open="setActivePopover(resourceType.id, $event)" @create="handleResourceCreation"> @@ -185,9 +165,8 @@ - - + @@ -285,7 +264,6 @@ import { FilePickerClosed, FilePickerType, getFilePickerBuilder, showError, show import { encodePath } from '@nextcloud/paths' import { generateOcsUrl, generateRemoteUrl, generateUrl } from '@nextcloud/router' import { - NcAvatar as Avatar, NcListItem as ListItem, NcActionButton, NcActions, @@ -296,7 +274,6 @@ import { NcPopover, NcTextArea, NcTextField, - NcUserBubble as UserBubble, } from '@nextcloud/vue' import { useElementSize } from '@vueuse/core' import { reactive, ref } from 'vue' @@ -311,6 +288,7 @@ import CopyIcon from 'vue-material-design-icons/ContentCopy.vue' import FileDocumentOutline from 'vue-material-design-icons/FileDocumentOutline.vue' import FolderIcon from 'vue-material-design-icons/Folder.vue' import FolderOutlineIcon from 'vue-material-design-icons/FolderOutline.vue' +import FolderPlusOutlineIcon from 'vue-material-design-icons/FolderPlusOutline.vue' import LoginIcon from 'vue-material-design-icons/Login.vue' import LogoutIcon from 'vue-material-design-icons/Logout.vue' import MessageIcon from 'vue-material-design-icons/MessageOutline.vue' @@ -324,6 +302,7 @@ import TeamResourceButton from './CircleDetails/TeamResourceButton.vue' import MemberList from './MemberList/MemberList.vue' import CircleActionsMixin from '../mixins/CircleActionsMixin.js' import { CircleEdit, editCircle } from '../services/circles.ts' +import { getTeamFolder, upgradeTeamFolder } from '../../api.ts' import 'cropperjs/dist/cropper.css' @@ -346,7 +325,6 @@ export default { components: { AccountPlusIcon, - Avatar, NcButton, NcDialog, ContentHeading, @@ -364,12 +342,12 @@ export default { NcLoadingIcon, NcPopover, PencilIcon, - UserBubble, NcTextField, NcTextArea, NcActions, NcActionButton, FolderOutlineIcon, + FolderPlusOutlineIcon, MessageIcon, CalendarIcon, ViewDashboardIcon, @@ -416,6 +394,11 @@ export default { showCalendarSuccessNotification: false, createdCalendarName: '', + // Team folder + teamFolder: null, + loadingTeamFolder: false, + creatingTeamFolder: false, + // Avatar avatarUrl: undefined, pendingAvatarBlob: null, @@ -480,6 +463,10 @@ export default { return (this.circle.isOwner || this.circle.isAdmin) && !this.circle.isPersonal }, + canCreateTeamFolder() { + return this.canManageTeam || Boolean(getCurrentUser()?.isAdmin) + }, + teamHasCollective() { return this.resourcesForProvider('collectives').length > 0 }, @@ -529,12 +516,23 @@ export default { const enabledApps = window.OC?.appswebroots || {} return [ + { + id: 'teamfolder', + label: t('circles', 'Team space'), + inputLabel: null, + placeholder: null, + helperText: t('circles', 'Create a shared team space for this team.'), + icon: 'FolderPlusOutlineIcon', + apiPath: null, + enabled: this.canCreateTeamFolder && !this.teamFolder && !this.loadingTeamFolder, + noInput: true, + }, { id: 'folder', label: t('circles', 'Folder'), inputLabel: t('circles', 'New folder'), placeholder: t('circles', 'Folder name'), - helperText: t('circles', 'This will create a regular folder shared with the team. To create a Team Folder, please contact your {productName} administrator', { productName: OC.theme.name }), + helperText: t('circles', 'This will create a regular folder shared with the team.'), icon: 'FolderOutlineIcon', apiPath: 'files', enabled: enabledApps.files !== undefined, @@ -576,6 +574,7 @@ export default { handler() { this.isEditing = false this.fetchTeamResources() + this.loadTeamFolder() if (this.avatarSupported) { this.cancelSetAvatar() this.clearPendingAvatar() @@ -609,11 +608,52 @@ export default { this.resourceInputs[resourceId] = value }, + async loadTeamFolder() { + this.loadingTeamFolder = true + this.teamFolder = null + try { + this.teamFolder = await getTeamFolder(this.circle.id) + } catch (error) { + // A 404 means the team has no linked folder yet, which is the + // expected state for teams created before auto-creation. + if (error?.response?.status === 404) { + this.teamFolder = null + return + } + console.error('Could not load team folder', { error, circleId: this.circle.id }) + showError(t('circles', 'Could not load team space')) + this.teamFolder = null + } finally { + this.loadingTeamFolder = false + } + }, + + async createTeamFolder() { + this.creatingTeamFolder = true + try { + this.teamFolder = await upgradeTeamFolder(this.circle.id) + this.$router.push({ + name: 'team', + params: { teamId: this.circle.id }, + }) + } catch (error) { + console.error('Could not create team folder', { error, circleId: this.circle.id }) + showError(t('circles', 'Could not create the team space')) + } finally { + this.creatingTeamFolder = false + } + }, + async handleResourceCreation({ resourceType, name }) { try { let resourceId switch (resourceType.id) { + case 'teamfolder': { + await this.createTeamFolder() + return + } + case 'folder': { const folderPath = `/remote.php/dav/files/${getCurrentUser().uid}/${name}` await axios.request({ @@ -1049,19 +1089,8 @@ export default { } } - .circle-details__header-wrapper { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 24px; - } - .circle-details__main-content { - margin-inline-start: 99px; - - @media (max-width: 768px) { - margin-inline-start: 0; - } + margin-inline-start: 0; } } @@ -1078,17 +1107,6 @@ export default { margin-bottom: 4px; } - .circle-name { - font-size: 1.5rem; - font-weight: bold; - margin: 0; - margin-bottom: 2px; - } - - .subtitle { - color: var(--color-text-maxcontrast); - } - .actions { display: flex; gap: 8px; diff --git a/src/teams/team-page/components/CircleDetails/TeamResourceButton.vue b/src/teams/team-page/components/CircleDetails/TeamResourceButton.vue index 1871e8fea..23cf5ad8f 100644 --- a/src/teams/team-page/components/CircleDetails/TeamResourceButton.vue +++ b/src/teams/team-page/components/CircleDetails/TeamResourceButton.vue @@ -11,10 +11,12 @@ - + + {{ resourceType.label }} @@ -58,7 +60,7 @@ @@ -56,7 +56,7 @@ const { loadTeams, openCreateTeamDialog } = store - + {{ t('circles', 'Create your first team') }} diff --git a/src/teams/views/TeamDashboardView.vue b/src/teams/views/TeamDashboardView.vue new file mode 100644 index 000000000..17ac29518 --- /dev/null +++ b/src/teams/views/TeamDashboardView.vue @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + diff --git a/src/teams/views/TeamPage.vue b/src/teams/views/TeamPage.vue index 8386951b4..969d6f201 100644 --- a/src/teams/views/TeamPage.vue +++ b/src/teams/views/TeamPage.vue @@ -12,7 +12,7 @@ import { useStore } from 'vuex' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' -import CircleDetails from '../team-page/components/CircleDetails.vue' +import TeamHeader from '../components/TeamHeader.vue' import { logger } from '../../logger.ts' const props = defineProps<{ @@ -57,6 +57,33 @@ watch(() => props.teamId, loadCircle, { immediate: true }) - + + + + + + + + diff --git a/src/teams/views/TeamSettingsView.vue b/src/teams/views/TeamSettingsView.vue new file mode 100644 index 000000000..df803f402 --- /dev/null +++ b/src/teams/views/TeamSettingsView.vue @@ -0,0 +1,21 @@ + + + + + + + diff --git a/templates/settings-team-folders.php b/templates/settings-team-folders.php new file mode 100644 index 000000000..535b5e19f --- /dev/null +++ b/templates/settings-team-folders.php @@ -0,0 +1,8 @@ + + + diff --git a/tests/unit/CirclesManagerTest.php b/tests/unit/CirclesManagerTest.php index d6085f50f..c1216c096 100644 --- a/tests/unit/CirclesManagerTest.php +++ b/tests/unit/CirclesManagerTest.php @@ -73,11 +73,14 @@ public function testCreateCircle(): void { $this->assertEquals($federatedUser->getSingleId(), $circle->getInitiator()->getSingleId()); // Created circle returned by probeCircle() + $this->startSession(); $circles = $this->circlesManager->probeCircles(); $this->assertCount(1, array_filter($circles, fn (Circle $c) => $c->getName() === $this->circleName)); // Destroyed circle not returned by probeCircle() + $this->startSession(); $this->circlesManager->destroyCircle($circle->getSingleId()); + $this->startSession(); $circles = $this->circlesManager->probeCircles(); $this->assertCount(0, array_filter($circles, fn (Circle $c) => $c->getName() === $this->circleName)); } @@ -86,6 +89,7 @@ public function testGetCirclesWithInitiator(): void { // Create circle as user 'admin' and add test user as member $this->startSession('admin'); $adminCircle = $this->circlesManager->createCircle($this->circleName); + $this->startSession('admin'); $this->circlesManager->addMember($adminCircle->getSingleId(), $this->circlesManager->getLocalFederatedUser($this->userId)); // Get circles as test user @@ -111,6 +115,7 @@ public function testProbeCirclesWithInitiator(): void { // Create circle as user 'admin' and add test user as member $this->startSession('admin'); $adminCircle = $this->circlesManager->createCircle($this->circleName); + $this->startSession('admin'); $this->circlesManager->addMember($adminCircle->getSingleId(), $this->circlesManager->getLocalFederatedUser($this->userId)); // Probe circles as test user @@ -138,6 +143,7 @@ public function testProbeCirclesWithInitiatorAsGroupMember(): void { // Create circle as user 'admin' and add test group as member $this->startSession('admin'); $adminCircle = $this->circlesManager->createCircle($this->circleName); + $this->startSession('admin'); $federatedGroup = $this->circlesManager->getFederatedUser($this->groupId, Member::TYPE_GROUP); $this->circlesManager->addMember($adminCircle->getSingleId(), $federatedGroup); diff --git a/tests/unit/lib/Controller/AdminControllerTest.php b/tests/unit/lib/Controller/AdminControllerTest.php index b50610014..435ef68e1 100644 --- a/tests/unit/lib/Controller/AdminControllerTest.php +++ b/tests/unit/lib/Controller/AdminControllerTest.php @@ -39,6 +39,20 @@ class AdminControllerTest extends TestCase { private array $circlesToCleanup = []; private static array $usersToCleanup = []; + private function setCurrentUser(string $userId): void { + $user = $this->userManager->get($userId); + $this->container->get(IUserSession::class)->setUser($user); + $this->container->get(FederatedUserService::class)->setLocalCurrentUser($user); + } + + private function createCircle(string $name, bool $personal = false): array { + $this->setCurrentUser(self::TEST_USER_1); + $circle = $this->container->get(CircleService::class)->create($name, personal: $personal); + $this->setCurrentUser(self::TEST_USER_1); + + return $circle; + } + public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); @@ -64,9 +78,7 @@ public function setUp(): void { $userSession = $this->container->get(IUserSession::class); $federatedUserService = $this->container->get(FederatedUserService::class); - $user1 = $this->userManager->get(self::TEST_USER_1); - $userSession->setUser($user1); - $federatedUserService->setLocalCurrentUser($user1); + $this->setCurrentUser(self::TEST_USER_1); $this->adminController = new AdminController( Application::APP_ID, @@ -99,6 +111,7 @@ protected function tearDown(): void { foreach ($this->circlesToCleanup as $circleId) { try { + $this->setCurrentUser(self::TEST_USER_1); $circleService->destroy($circleId); } catch (\Throwable) { // continue cleanup @@ -121,9 +134,10 @@ public function testCreate(): void { public function testDestroy(): void { $circleService = $this->container->get(CircleService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->adminController->destroy(self::TEST_USER_1, $circleData['id']); + $this->setCurrentUser(self::TEST_USER_1); $this->expectException(CircleNotFoundException::class); @@ -133,7 +147,7 @@ public function testDestroy(): void { public function testMemberAdd(): void { $circleService = $this->container->get(CircleService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $result = $this->adminController->memberAdd(self::TEST_USER_1, $circleData['id'], self::TEST_USER_2, Member::TYPE_USER)->getData(); @@ -148,7 +162,7 @@ public function testMemberLevel(): void { $federatedUserService = $this->container->get(FederatedUserService::class); $memberService = $this->container->get(MemberService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $user2 = $federatedUserService->generateFederatedUser(self::TEST_USER_2, Member::TYPE_USER); @@ -170,9 +184,9 @@ public function testCircles(): void { */ $countBefore = count($this->adminController->circles(self::TEST_USER_1)->getData()); - $circleData = $circleService->create('test-circle'); - $circleData2 = $circleService->create('test-circle-2'); - $circleData3 = $circleService->create('test-circle-3'); + $circleData = $this->createCircle('test-circle'); + $circleData2 = $this->createCircle('test-circle-2'); + $circleData3 = $this->createCircle('test-circle-3'); $this->circlesToCleanup[] = $circleData['id']; $this->circlesToCleanup[] = $circleData2['id']; $this->circlesToCleanup[] = $circleData3['id']; @@ -207,8 +221,8 @@ public function testCirclesAll(): void { $countBeforeWithPersonal = count($this->adminController->circlesAll(filterPersonal: false)->getData()); $countBeforeWithoutPersonal = count($this->adminController->circlesAll(filterPersonal: true)->getData()); - $circleData = $circleService->create('test-circle'); - $circleDataPersonal = $circleService->create('test-circle-personal', personal: true); + $circleData = $this->createCircle('test-circle'); + $circleDataPersonal = $this->createCircle('test-circle-personal', personal: true); $this->circlesToCleanup[] = $circleData['id']; $this->circlesToCleanup[] = $circleDataPersonal['id']; @@ -230,7 +244,7 @@ public function testCirclesAll(): void { public function testCircleDetails(): void { $circleService = $this->container->get(CircleService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $result = $this->adminController->circleDetails(self::TEST_USER_1, $circleData['id'])->getData(); @@ -247,7 +261,7 @@ public function testCircleJoin(): void { $circleService = $this->container->get(CircleService::class); $memberService = $this->container->get(MemberService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; // circle is visible to everyone and anyone can join @@ -271,16 +285,14 @@ public function testCircleLeave(): void { $federatedUserService = $this->container->get(FederatedUserService::class); $memberService = $this->container->get(MemberService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; // circle is visible to everyone and anyone can join $circleConfig = Circle::CFG_VISIBLE + Circle::CFG_OPEN; $circleService->updateConfig($circleData['id'], $circleConfig); - $user2 = $this->userManager->get(self::TEST_USER_2); - - $federatedUserService->setLocalCurrentUser($user2); + $this->setCurrentUser(self::TEST_USER_2); $memberData = $circleService->circleJoin($circleData['id']); $this->adminController->circleLeave(self::TEST_USER_2, $circleData['id'])->getData(); @@ -294,20 +306,17 @@ public function testMemberConfirm(): void { $circleService = $this->container->get(CircleService::class); $federatedUserService = $this->container->get(FederatedUserService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; // circle is visible to everyone but requires approval to join $circleConfig = Circle::CFG_VISIBLE + Circle::CFG_OPEN + Circle::CFG_REQUEST; $circleService->updateConfig($circleData['id'], $circleConfig); - $user1 = $this->userManager->get(self::TEST_USER_1); - $user2 = $this->userManager->get(self::TEST_USER_2); - - $federatedUserService->setLocalCurrentUser($user2); + $this->setCurrentUser(self::TEST_USER_2); $memberData = $circleService->circleJoin($circleData['id']); - $federatedUserService->setLocalCurrentUser($user1); + $this->setCurrentUser(self::TEST_USER_1); // as TEST_USER_1, allow TEST_USER_2 to be a member of the team $result = $this->adminController->memberConfirm(self::TEST_USER_1, $circleData['id'], $memberData['id'])->getData(); @@ -324,7 +333,7 @@ public function testMemberRemove(): void { $federatedUserService = $this->container->get(FederatedUserService::class); $memberService = $this->container->get(MemberService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $user2 = $federatedUserService->generateFederatedUser(self::TEST_USER_2, Member::TYPE_USER); @@ -342,7 +351,7 @@ public function testMembers(): void { $federatedUserService = $this->container->get(FederatedUserService::class); $memberService = $this->container->get(MemberService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $user2 = $federatedUserService->generateFederatedUser(self::TEST_USER_2, Member::TYPE_USER); @@ -365,7 +374,7 @@ public function testMembers(): void { public function testEditName(): void { $circleService = $this->container->get(CircleService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $result = $this->adminController->editName(self::TEST_USER_1, $circleData['id'], 'test-cricle-new-name')->getData(); @@ -377,7 +386,7 @@ public function testEditName(): void { public function testEditDescription(): void { $circleService = $this->container->get(CircleService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $result = $this->adminController->editDescription(self::TEST_USER_1, $circleData['id'], 'test-cricle-new-description')->getData(); @@ -389,7 +398,7 @@ public function testEditDescription(): void { public function testEditSetting(): void { $circleService = $this->container->get(CircleService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $result = $this->adminController->editSetting(self::TEST_USER_1, $circleData['id'], ConfigService::MEMBERS_LIMIT, '25')->getData(); @@ -401,7 +410,7 @@ public function testEditSetting(): void { public function testEditConfig(): void { $circleService = $this->container->get(CircleService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $result = $this->adminController->editConfig(self::TEST_USER_1, $circleData['id'], 128)->getData(); @@ -415,7 +424,7 @@ public function testLink(): void { $federatedUserService = $this->container->get(FederatedUserService::class); $memberService = $this->container->get(MemberService::class); - $circleData = $circleService->create('test-circle'); + $circleData = $this->createCircle('test-circle'); $this->circlesToCleanup[] = $circleData['id']; $user2 = $federatedUserService->generateFederatedUser(self::TEST_USER_2, Member::TYPE_USER); diff --git a/tests/unit/lib/Service/TeamFolderServiceTest.php b/tests/unit/lib/Service/TeamFolderServiceTest.php new file mode 100644 index 000000000..8d9fc501e --- /dev/null +++ b/tests/unit/lib/Service/TeamFolderServiceTest.php @@ -0,0 +1,105 @@ +appConfig = $this->createMock(IAppConfig::class); + + $this->service = new TeamFolderService( + $this->appConfig, + ); + } + + public function testShouldCreateTeamFolderSkipsForPersonalCircle(): void { + $circle = $this->createCircle(Circle::CFG_PERSONAL); + $this->appConfig->method('getAppValueBool') + ->with(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true) + ->willReturn(true); + + $this->assertFalse($this->service->shouldCreateTeamFolder($circle)); + } + + public function testShouldCreateTeamFolderSkipsForHiddenCircle(): void { + $circle = $this->createCircle(Circle::CFG_HIDDEN); + $this->appConfig->method('getAppValueBool') + ->with(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true) + ->willReturn(true); + + $this->assertFalse($this->service->shouldCreateTeamFolder($circle)); + } + + public function testShouldCreateTeamFolderSkipsForSystemCircle(): void { + $circle = $this->createCircle(Circle::CFG_SYSTEM); + $this->appConfig->method('getAppValueBool') + ->with(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true) + ->willReturn(true); + + $this->assertFalse($this->service->shouldCreateTeamFolder($circle)); + } + + public function testShouldCreateTeamFolderSkipsForBackendCircle(): void { + $circle = $this->createCircle(Circle::CFG_BACKEND); + $this->appConfig->method('getAppValueBool') + ->with(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true) + ->willReturn(true); + + $this->assertFalse($this->service->shouldCreateTeamFolder($circle)); + } + + public function testShouldCreateTeamFolderSkipsWhenAutoCreateDisabled(): void { + $circle = $this->createCircle(); + $this->appConfig->method('getAppValueBool') + ->with(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true) + ->willReturn(false); + + $this->assertFalse($this->service->shouldCreateTeamFolder($circle)); + } + + public function testShouldCreateTeamFolderReturnsTrueForEligibleCircle(): void { + $circle = $this->createCircle(); + $this->appConfig->method('getAppValueBool') + ->with(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true) + ->willReturn(true); + + $this->assertTrue($this->service->shouldCreateTeamFolder($circle)); + } + + public function testGetDefaultQuota(): void { + $this->appConfig->method('getAppValueInt') + ->with(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0) + ->willReturn(1073741824); + + $this->assertSame(1073741824, $this->service->getDefaultQuota()); + } + + /** + * @param int $config bitwise circle config flags + */ + private function createCircle(int $config = Circle::CFG_CIRCLE): Circle&MockObject { + $circle = $this->createMock(Circle::class); + $circle->method('isConfig') + ->willReturnCallback(function (int $flag) use ($config): bool { + return ($config & $flag) === $flag; + }); + + return $circle; + } +} diff --git a/vite.config.ts b/vite.config.ts index e35c0252b..e1f62a13d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ export default (env) => createAppConfig({ main: join(import.meta.dirname, 'src/main.ts'), dashboard: join(import.meta.dirname, 'src/dashboard.ts'), 'settings-admin': join(import.meta.dirname, 'src/settings-admin.ts'), + 'settings-team-folders': join(import.meta.dirname, 'src/settings-team-folders.ts'), }, { appName: 'teams', emptyOutputDirectory: { additionalDirectories: ['css'] },
+ {{ t('circles', 'Default storage quota applied to each auto-created team space. Use 0 for unlimited storage.') }} +
+ {{ t('circles', 'Step {current} of {total}', { current: stepIndex + 1, total: STEPS.length }) }} +