From 82176c9fbbf50cae39c3e7b68a84adbfdf622927 Mon Sep 17 00:00:00 2001 From: Stefan Lender Date: Thu, 3 Sep 2026 23:57:41 +0200 Subject: [PATCH 1/3] feat(teams): add managing default quota for groups Co-authored-by: Copilot GPT-5.6 Terra Signed-off-by: Stefan Lender --- appinfo/routes.php | 1 + lib/ConfigLexicon.php | 3 +- lib/Controller/AdminController.php | 41 +- lib/Listeners/TeamFolderLifecycleListener.php | 2 +- lib/Model/Circle.php | 1 + lib/Search/LocalUsers.php | 1 + lib/Service/TeamFolderPolicy.php | 92 ++- lib/Settings/AdminTeamFolders.php | 6 + src/components/AdminTeamFolders.vue | 690 +++++++++++++----- src/settings-team-folders.ts | 1 + src/teams/api.ts | 15 + .../lib/Controller/AdminControllerTest.php | 19 + .../unit/lib/Service/TeamFolderPolicyTest.php | 150 +++- .../lib/Settings/AdminTeamFoldersTest.php | 66 ++ 14 files changed, 891 insertions(+), 197 deletions(-) create mode 100644 tests/unit/lib/Settings/AdminTeamFoldersTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 7f96e2ec7..f6103268e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -61,6 +61,7 @@ ['name' => 'Admin#circles', 'url' => '/admin/{emulated}/circles', 'verb' => 'GET'], ['name' => 'Admin#circlesAll', 'url' => '/admin/circlesall', 'verb' => 'GET'], ['name' => 'Admin#teamFolders', 'url' => '/admin/teamfolders', 'verb' => 'GET'], + ['name' => 'Admin#updateTeamFolderDefaultQuota', 'url' => '/admin/teamfolders/{circleId}/default-quota', 'verb' => 'PUT'], ['name' => 'Admin#create', 'url' => '/admin/{emulated}/circles', 'verb' => 'POST'], ['name' => 'Admin#destroy', 'url' => '/admin/{emulated}/circles/{circleId}', 'verb' => 'DELETE'], [ diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 493debc90..31030194b 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -25,6 +25,7 @@ class ConfigLexicon implements ILexicon { public const FEDERATED_TEAMS_FRONTAL = 'federated_teams_frontal'; public const TEAM_FOLDER_AUTO_CREATE = 'team_folder_auto_create'; public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota'; + public const DEFAULT_QUOTA = 104857600; // 104857600 bytes ~ 100 MB // OIDC public const OIDC_ENABLED = 'oidc_enabled'; @@ -56,7 +57,7 @@ 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::TEAM_FOLDER_AUTO_CREATE, type: ValueType::BOOL, defaultRaw: true, definition: 'whether Circles auto-creates team folders and allows UI/API upgrade (not exposed in admin UI)', 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), + new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: self::DEFAULT_QUOTA, definition: 'default quota in bytes for auto-created team folders (0 means unlimited)', lazy: true), // OIDC new Entry(key: self::OIDC_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable OIDC integration', lazy: true), new Entry(key: self::OIDC_ISSUER, type: ValueType::STRING, defaultRaw: '', definition: 'provider issuer URL', lazy: true), diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index e14207145..a967b4685 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -28,10 +28,13 @@ use OCA\Circles\Service\MemberService; use OCA\Circles\Service\MembershipService; use OCA\Circles\Service\SearchService; +use OCA\Circles\Service\TeamFolderPolicy; use OCA\Circles\Tools\Traits\TDeserialize; use OCA\Circles\Tools\Traits\TNCLogger; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; use OCP\IRequest; use OCP\IUserSession; @@ -72,6 +75,7 @@ public function __construct( private MembershipService $membershipService, private SearchService $searchService, private ITeamManager $teamManager, + private TeamFolderPolicy $teamFolderPolicy, ConfigService $configService, ) { parent::__construct($appName, $request); @@ -278,9 +282,6 @@ public function teamFolders(): DataResponse { try { $this->setLocalFederatedUser($this->userSession->getUser()->getUID()); $provider = $this->teamManager->getTeamFolderProvider(); - if ($provider === null) { - return new DataResponse([]); - } $probe = new CircleProbe(); $probe->filterPersonalCircles() @@ -291,11 +292,12 @@ public function teamFolders(): DataResponse { $teamFolders = []; foreach ($this->circleService->getAllCircles($probe) as $circle) { - $folder = $provider->getTeamFolder($circle->getSingleId()); + $folder = $provider?->getTeamFolder($circle->getSingleId()); $teamFolders[] = [ 'teamId' => $circle->getSingleId(), 'teamName' => $circle->getDisplayName(), + 'defaultQuota' => $this->teamFolderPolicy->getTeamFolderQuota($circle), 'folder' => $folder?->jsonSerialize(), ]; } @@ -307,6 +309,37 @@ public function teamFolders(): DataResponse { } } + /** + * Set or remove the quota override associated with a team. + * + * @throws OCSException + */ + public function updateTeamFolderDefaultQuota(string $circleId, ?int $quota): DataResponse { + try { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED); + } + + $this->setLocalFederatedUser($user->getUID()); + $circle = $this->circleService->getCircle($circleId); + if ($quota === null) { + $this->teamFolderPolicy->removeTeamFolderQuota($circle); + } else { + $this->teamFolderPolicy->setTeamFolderQuota($circle, $quota); + } + + return new DataResponse([ + 'teamId' => $circleId, + 'defaultQuota' => $this->teamFolderPolicy->getTeamFolderQuota($circle), + ]); + } catch (\InvalidArgumentException $e) { + throw new OCSException($e->getMessage(), Http::STATUS_BAD_REQUEST); + } catch (\OCA\Circles\Exceptions\CircleNotFoundException) { + throw new OCSNotFoundException('Team not found'); + } + } + /** * @param string $emulated * @param string $circleId diff --git a/lib/Listeners/TeamFolderLifecycleListener.php b/lib/Listeners/TeamFolderLifecycleListener.php index c1263a247..5d052dbaa 100644 --- a/lib/Listeners/TeamFolderLifecycleListener.php +++ b/lib/Listeners/TeamFolderLifecycleListener.php @@ -71,7 +71,7 @@ public function handle(Event $event): void { displayName: $circle->getDisplayName(), link: null, ), - $this->policy->getDefaultQuota(), + $this->policy->getQuotaForCircle($circle), ); } catch (\Throwable $e) { $this->logger->error('Failed to auto-create team folder', [ diff --git a/lib/Model/Circle.php b/lib/Model/Circle.php index 42a90066b..1c80bfb00 100644 --- a/lib/Model/Circle.php +++ b/lib/Model/Circle.php @@ -95,6 +95,7 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow // settings array keys public const SETTING_EXTERNAL_ID = 'external_id'; + public const SETTING_TEAM_FOLDER_QUOTA = 'team_folder_quota'; /** * Note: When editing those values, update lib/Application/Capabilities.php diff --git a/lib/Search/LocalUsers.php b/lib/Search/LocalUsers.php index e58a275ca..d9d0988b5 100644 --- a/lib/Search/LocalUsers.php +++ b/lib/Search/LocalUsers.php @@ -62,6 +62,7 @@ public function search($needle): array { * @return array */ private function searchFromCollaborator($search): array { + /** @psalm-suppress DeprecatedMethod The Collaborator ISearch API has no replacement yet. */ [$temp, $hasMore] = $this->search->search($search, [IShare::TYPE_USER, IShare::TYPE_EMAIL], false, 50, 0); $result = array_merge($temp['exact']['users'], $temp['users']); diff --git a/lib/Service/TeamFolderPolicy.php b/lib/Service/TeamFolderPolicy.php index 08386200d..3c54e1924 100644 --- a/lib/Service/TeamFolderPolicy.php +++ b/lib/Service/TeamFolderPolicy.php @@ -10,6 +10,9 @@ namespace OCA\Circles\Service; use OCA\Circles\ConfigLexicon; +use OCA\Circles\Db\CircleRequest; +use OCA\Circles\Db\MembershipRequest; +use OCA\Circles\Exceptions\CircleNotFoundException; use OCA\Circles\Model\Circle; use OCP\AppFramework\Services\IAppConfig; @@ -18,13 +21,10 @@ * * This class owns the *policy* for team-folder creation: * - the `team_folder_auto_create` app config toggle (occ only, not admin UI), - * - the `team_folder_default_quota` app config value, + * - the default quota and per-team quota settings, * - the circle-type eligibility rules (personal/hidden/system/backend circles * are excluded). * - * Per-creation opt-out from the team wizard/API is applied by the listener - * before this policy runs. - * * The *orchestration* (creating, unlinking, removing folders) is owned by the * groupfolders app. The circles app keeps no reference to the groupfolders app. * @@ -36,6 +36,8 @@ class TeamFolderPolicy { public function __construct( private IAppConfig $appConfig, + private MembershipRequest $membershipRequest, + private CircleRequest $circleRequest, ) { } @@ -82,7 +84,87 @@ public function shouldCreateTeamFolder(Circle $circle): bool { return $this->isEligibleCircle($circle); } + /** + * Get the quota applied when no group-specific override matches. + */ public function getDefaultQuota(): int { - return $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0); + $quota = $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, ConfigLexicon::DEFAULT_QUOTA); + return $quota >= 0 ? $quota : ConfigLexicon::DEFAULT_QUOTA; + } + + /** + * @throws \InvalidArgumentException when the quota is negative. + */ + public function setDefaultQuota(int $quota): void { + if ($quota < 0) { + throw new \InvalidArgumentException('default quota must be a non-negative integer'); + } + + $this->appConfig->setAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, $quota); + } + + public function getTeamFolderQuota(Circle $circle): ?int { + $quota = $circle->getSettings()[Circle::SETTING_TEAM_FOLDER_QUOTA] ?? null; + + return is_int($quota) && $quota >= 0 ? $quota : null; + } + + public function setTeamFolderQuota(Circle $circle, int $quota): void { + if ($quota < 0) { + throw new \InvalidArgumentException('team folder quota must be a non-negative integer'); + } + + $settings = $circle->getSettings(); + $settings[Circle::SETTING_TEAM_FOLDER_QUOTA] = $quota; + $this->circleRequest->updateSettings($circle->setSettings($settings)); + } + + public function removeTeamFolderQuota(Circle $circle): void { + $settings = $circle->getSettings(); + if (array_key_exists(Circle::SETTING_TEAM_FOLDER_QUOTA, $settings)) { + unset($settings[Circle::SETTING_TEAM_FOLDER_QUOTA]); + $this->circleRequest->updateSettings($circle->setSettings($settings)); + } + } + + /** + * Resolve the highest configured quota for the local team owner. + * Unlimited (0) takes precedence over every finite quota. + */ + public function getQuotaForCircle(Circle $circle): int { + $override = $this->getTeamFolderQuota($circle); + if ($override !== null) { + return $override; + } + + $fallback = $this->getDefaultQuota(); + $owner = $circle->getOwner(); + if (!$owner->isLocal()) { + return $fallback; + } + + $matches = []; + foreach ($this->membershipRequest->getMemberships($owner->getSingleId()) as $membership) { + try { + $membershipCircle = $this->circleRequest->getCircle($membership->getCircleId()); + } catch (CircleNotFoundException) { + continue; + } + + $quota = $this->getTeamFolderQuota($membershipCircle); + if ($quota !== null) { + $matches[] = $quota; + } + } + + if ($matches === []) { + return $fallback; + } + + if (in_array(0, $matches, true)) { + return 0; + } + + return max($matches); } } diff --git a/lib/Settings/AdminTeamFolders.php b/lib/Settings/AdminTeamFolders.php index 231d5fa5e..61eb734a3 100644 --- a/lib/Settings/AdminTeamFolders.php +++ b/lib/Settings/AdminTeamFolders.php @@ -11,6 +11,7 @@ use OCA\Circles\AppInfo\Application; use OCA\Circles\ConfigLexicon; +use OCA\Circles\Service\TeamFolderPolicy; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IAppConfig; @@ -26,6 +27,7 @@ public function __construct( private IAppConfig $appConfig, private IL10N $l, private IInitialState $initialState, + private TeamFolderPolicy $teamFolderPolicy, ) { } @@ -36,6 +38,10 @@ public function getForm(): TemplateResponse { $teamFolderDefaultQuota = $this->appConfig->getValueInt(Application::APP_ID, ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0); $this->initialState->provideInitialState('teamFolderDefaultQuota', $teamFolderDefaultQuota); + $this->initialState->provideInitialState( + 'teamFolderProvisioningEnabled', + $this->teamFolderPolicy->isTeamFolderProvisioningEnabled(), + ); Util::addStyle(Application::APP_ID, 'teams-settings-team-folders'); Util::addScript(Application::APP_ID, 'teams-settings-team-folders'); diff --git a/src/components/AdminTeamFolders.vue b/src/components/AdminTeamFolders.vue index eb990619f..a53d31d6c 100644 --- a/src/components/AdminTeamFolders.vue +++ b/src/components/AdminTeamFolders.vue @@ -23,14 +23,24 @@ import NcDialog from '@nextcloud/vue/components/NcDialog' import NcSelect from '@nextcloud/vue/components/NcSelect' import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import NcTextField from '@nextcloud/vue/components/NcTextField' +import IconDeleteOutline from 'vue-material-design-icons/TrashCanOutline.vue' import { logger } from '../logger.ts' -import { deleteTeam, getAdminTeamFolders, getLinkableTeamFolders, linkTeamFolder, updateTeamFolderQuota, upgradeTeamFolder } from '../teams/api.ts' +import { deleteTeam, getAdminTeamFolders, getLinkableTeamFolders, linkTeamFolder, updateTeamFolderDefaultQuota, updateTeamFolderQuota, upgradeTeamFolder } from '../teams/api.ts' interface QuotaOption { id: string label: string } +interface TeamOption { + id: string + label: string +} + +interface QuotaRow extends TeamOption { + quota: QuotaOption +} + const unlimitedQuota: QuotaOption = { id: '0', label: t('circles', 'Unlimited'), @@ -43,27 +53,28 @@ const quotaPreset: QuotaOption[] = [ ] const teamFolderDefaultQuotaBytes = Number(loadState('circles', 'teamFolderDefaultQuota', 0)) +const teamFolderProvisioningEnabled = Boolean(loadState('circles', 'teamFolderProvisioningEnabled', true)) +const selectedQuota = ref(teamFolderDefaultQuotaBytes <= 0 + ? unlimitedQuota + : { id: formatFileSize(teamFolderDefaultQuotaBytes), label: formatFileSize(teamFolderDefaultQuotaBytes) }) +const rows = ref([]) +const loadedQuotas = ref>({}) +const selectedQuotaTeam = ref(null) +const savingQuotas = ref(false) +const settingsTabs = ['teamFolders', 'defaultQuotas'] as const +type SettingsTab = typeof settingsTabs[number] +const activeTab = ref('teamFolders') const quotaOptions = computed(() => { - const options = [...quotaPreset] - if (teamFolderDefaultQuotaBytes <= 0) { - options.unshift(unlimitedQuota) - return options - } - const label = formatFileSize(teamFolderDefaultQuotaBytes) - const option = { id: label, label } - if (!options.some((q) => q.id === label)) { - options.unshift(option) - } else { - options.unshift(unlimitedQuota) + const options = [unlimitedQuota, ...quotaPreset] + for (const quota of [selectedQuota.value, ...rows.value.map((row) => row.quota)]) { + if (!options.some((option) => option.id === quota.id)) { + options.push(quota) + } } return options }) -const selectedQuota = ref(teamFolderDefaultQuotaBytes <= 0 - ? unlimitedQuota - : { id: formatFileSize(teamFolderDefaultQuotaBytes), label: formatFileSize(teamFolderDefaultQuotaBytes) }) - type AdminTeamFolder = Awaited>[number] type TeamFolderSortKey = 'teamName' | 'mountPoint' @@ -82,6 +93,14 @@ const linkableFolders = ref([]) const selectedExistingFolder = ref(null) const loadingLinkableFolders = ref(false) +const availableTeams = computed(() => { + const mappedIds = new Set(rows.value.map((row) => row.id)) + return teamFolders.value + .map((team) => ({ id: team.teamId, label: team.teamName })) + .filter((team) => !mappedIds.has(team.id)) + .sort((left, right) => left.label.localeCompare(right.label)) +}) + const sortedTeamFolders = computed(() => { const direction = sortAscending.value ? 1 : -1 return [...teamFolders.value].sort((a, b) => { @@ -140,6 +159,13 @@ async function loadTeamFolders() { loadingTeamFolders.value = true try { teamFolders.value = await getAdminTeamFolders() + loadedQuotas.value = Object.fromEntries(teamFolders.value + .filter((team) => team.defaultQuota !== null) + .map((team) => [team.teamId, team.defaultQuota as number])) + rows.value = teamFolders.value + .filter((team) => team.defaultQuota !== null) + .map((team) => ({ id: team.teamId, label: team.teamName, quota: quotaOptionFromBytes(team.defaultQuota as number) })) + .sort((left, right) => left.label.localeCompare(right.label)) teamFolderQuotas.value = Object.fromEntries(teamFolders.value.map((teamFolder) => [ teamFolder.teamId, quotaOptionFromBytes(teamFolder.folder?.quota ?? teamFolderDefaultQuotaBytes), @@ -299,29 +325,6 @@ async function onUpdateTeamFolderQuota(teamFolder: AdminTeamFolder, quota: Quota } } -/** - * Apply a new default quota to every team folder the administrator has not - * manually changed in this session. - * - * @param bytes - The new default quota in bytes, zero for unlimited - */ -async function applyDefaultQuotaToUnmodifiedFolders(bytes: number) { - const targets = teamFolders.value.filter((teamFolder) => teamFolder.folder !== null && !manuallyChangedQuotaTeamIds.value.has(teamFolder.teamId)) - - await Promise.all(targets.map(async (teamFolder) => { - updatingQuotaTeamIds.value.add(teamFolder.teamId) - try { - const folder = await updateTeamFolderQuota(teamFolder.teamId, Math.round(bytes)) - teamFolderQuotas.value[teamFolder.teamId] = quotaOptionFromBytes(folder.quota ?? bytes) - } catch (error) { - showError(t('circles', 'Unable to update team folder quota for {team}', { team: teamFolder.teamName })) - logger.error('Unable to update team folder quota', { error, teamId: teamFolder.teamId }) - } finally { - updatingQuotaTeamIds.value.delete(teamFolder.teamId) - } - })) -} - /** * Update app configuration * @@ -369,170 +372,416 @@ async function onSaveQuota() { return } - if (await updateAppConfig('team_folder_default_quota', String(Math.round(bytes)))) { - showSuccess(t('circles', 'Changed default team folder quota')) - await applyDefaultQuotaToUnmodifiedFolders(bytes) + const quotas: Record = {} + for (const row of rows.value) { + const rowBytes = row.quota.id === unlimitedQuota.id ? 0 : parseFileSize(row.quota.id, true) + if (rowBytes === null || rowBytes < 0) { + showError(t('circles', 'Quota must be a non-negative number.')) + return + } + quotas[row.id] = Math.round(rowBytes) + } + + savingQuotas.value = true + try { + if (!await updateAppConfig('team_folder_default_quota', String(Math.round(bytes)))) { + return + } + + const changedTeamIds = new Set([...Object.keys(loadedQuotas.value), ...Object.keys(quotas)]) + await Promise.all([...changedTeamIds].map(async (teamId) => { + const quota = quotas[teamId] ?? null + if ((loadedQuotas.value[teamId] ?? null) !== quota) { + await updateTeamFolderDefaultQuota(teamId, quota) + } + })) + + showSuccess(t('circles', 'Changed default team folder quotas')) + await loadTeamFolders() + } catch (error) { + showError(t('circles', 'Unable to update team folder config')) + logger.error('Unable to update team quota settings', { error }) + } finally { + savingQuotas.value = false } } +/** Add the selected team with the global default quota. */ +function addQuotaTeam() { + if (selectedQuotaTeam.value === null) { + return + } + rows.value.push({ ...selectedQuotaTeam.value, quota: selectedQuota.value }) + selectedQuotaTeam.value = null +} + +/** + * Remove a team quota mapping. + * + * @param teamId - Team ID to remove + */ +function removeQuotaTeam(teamId: string) { + rows.value = rows.value.filter((row) => row.id !== teamId) +} + +/** + * Handle keyboard navigation between the settings tabs. + * + * @param event - Keyboard event from the active tab + */ +function onTabKeydown(event: KeyboardEvent) { + const currentIndex = settingsTabs.indexOf(activeTab.value) + let nextIndex: number + + switch (event.key) { + case 'ArrowLeft': + nextIndex = (currentIndex - 1 + settingsTabs.length) % settingsTabs.length + break + case 'ArrowRight': + nextIndex = (currentIndex + 1) % settingsTabs.length + break + case 'Home': + nextIndex = 0 + break + case 'End': + nextIndex = settingsTabs.length - 1 + break + default: + return + } + + event.preventDefault() + activeTab.value = settingsTabs[nextIndex] + const tabButtons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll('[role="tab"]') + tabButtons?.[nextIndex]?.focus() +} + onMounted(loadTeamFolders)