diff --git a/appinfo/routes.php b/appinfo/routes.php index 7f96e2ec7..ba43f0f51 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'], [ @@ -96,6 +97,7 @@ ['name' => 'Admin#editConfig', 'url' => '/admin/{emulated}/circles/{circleId}/config', 'verb' => 'PUT'], ['name' => 'Admin#link', 'url' => '/admin/{emulated}/link/{circleId}/{singleId}', 'verb' => 'GET'], + ['name' => 'Settings#getGroups', 'url' => '/settings/groups/', 'verb' => 'GET'], ['name' => 'Settings#getValues', 'url' => '/settings/', 'verb' => 'GET'], ['name' => 'Settings#setValue', 'url' => '/settings/{key}/', 'verb' => 'POST'], ], diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 493debc90..2dc7f8e72 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -25,6 +25,8 @@ 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 TEAM_FOLDER_GROUP_QUOTAS = 'team_folder_group_quotas'; + public const DEFAULT_QUOTA = 104857600; // 104857600 bytes ~ 100 MB // OIDC public const OIDC_ENABLED = 'oidc_enabled'; @@ -56,7 +58,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::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), + new Entry(key: self::TEAM_FOLDER_GROUP_QUOTAS, type: ValueType::ARRAY, defaultRaw: [], definition: 'quota overrides in bytes by Nextcloud group ID (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/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 7b03156b1..84a066cc4 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -15,6 +15,7 @@ use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\AppFramework\Services\IAppConfig; +use OCP\IGroupManager; use OCP\IRequest; class SettingsController extends OCSController { @@ -22,6 +23,7 @@ public function __construct( string $appName, IRequest $request, private readonly IAppConfig $appConfig, + private readonly IGroupManager $groupManager, ) { parent::__construct($appName, $request); } @@ -49,6 +51,16 @@ public function setValue(string $key, string $value): DataResponse { return $this->getValues(); } + if ($key === ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS) { + $quotas = json_decode($value, true); + if (!is_array($quotas) || !$this->areValidGroupQuotas($quotas)) { + return new DataResponse(['data' => ['message' => 'group quotas must be a mapping of group IDs to non-negative integers']], Http::STATUS_BAD_REQUEST); + } + + $this->appConfig->setAppValueArray(ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, $quotas); + return $this->getValues(); + } + return new DataResponse(['data' => ['message' => 'unsupported key']], Http::STATUS_BAD_REQUEST); } @@ -57,9 +69,31 @@ public function getValues(): DataResponse { ConfigLexicon::FEDERATED_TEAMS_FRONTAL => $this->getFrontalValue() ?? '', ConfigLexicon::FEDERATED_TEAMS_ENABLED => $this->appConfig->getAppValueBool(ConfigLexicon::FEDERATED_TEAMS_ENABLED), ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA => $this->appConfig->getAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, 0), + ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS => $this->appConfig->getAppValueArray(ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, []), ]); } + public function getGroups(string $search = ''): DataResponse { + return new DataResponse(array_map( + static fn (\OCP\IGroup $group): array => [ + 'id' => $group->getGID(), + 'label' => $group->getDisplayName(), + ], + $this->groupManager->search($search, 100), + )); + } + + /** @param array $quotas */ + private function areValidGroupQuotas(array $quotas): bool { + foreach ($quotas as $groupId => $quota) { + if (!is_string($groupId) || $groupId === '' || !is_int($quota) || $quota < 0) { + return false; + } + } + + return true; + } + private function setFrontalValue(string $url): bool { [$scheme, $cloudId, $path] = $this->parseFrontalAddress($url); if (is_null($scheme)) { diff --git a/lib/Controller/TeamFolderController.php b/lib/Controller/TeamFolderController.php index 221d30a80..0d9d63047 100644 --- a/lib/Controller/TeamFolderController.php +++ b/lib/Controller/TeamFolderController.php @@ -72,7 +72,7 @@ public function upgradeTeamFolder(string $circleId, string $name = ''): DataResp displayName: trim($name) ?: $circle->getDisplayName(), link: null, ), - $this->policy->getDefaultQuota(), + $this->policy->getQuotaForCircle($circle), ); return new DataResponse([ 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..9ef5068a7 100644 --- a/lib/Search/LocalUsers.php +++ b/lib/Search/LocalUsers.php @@ -43,7 +43,7 @@ public function search($needle): array { return $this->searchFromCollaborator($needle); } - $users = $userManager->search($needle); + $users = $userManager->searchDisplayName($needle); foreach ($users as $user) { $result[] = new SearchResult( $user->getUID(), @@ -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..3308d7aa0 100644 --- a/lib/Service/TeamFolderPolicy.php +++ b/lib/Service/TeamFolderPolicy.php @@ -10,21 +10,23 @@ 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; +use OCP\IGroupManager; +use OCP\IUserManager; /** * Policy for team folders owned by teams (circles). * * 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 +38,10 @@ class TeamFolderPolicy { public function __construct( private IAppConfig $appConfig, + private MembershipRequest $membershipRequest, + private CircleRequest $circleRequest, + private IGroupManager $groupManager, + private IUserManager $userManager, ) { } @@ -82,7 +88,104 @@ 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)); + } + } + + /** @return array */ + public function getGroupQuotas(): array { + $quotas = $this->appConfig->getAppValueArray(ConfigLexicon::TEAM_FOLDER_GROUP_QUOTAS, []); + return array_filter($quotas, static fn (mixed $quota): bool => is_int($quota) && $quota >= 0); + } + + /** + * Resolve the highest configured team or group quota for the local team owner. + * Unlimited (0) takes precedence over every finite quota. + */ + public function getQuotaForCircle(Circle $circle): int { + $override = $this->getTeamFolderQuota($circle); + $fallback = $this->getDefaultQuota(); + $matches = $override === null ? [] : [$override]; + $owner = $circle->getOwner(); + if (!$owner->isLocal()) { + return $matches === [] ? $fallback : ($matches[0] ?? $fallback); + } + + $matches = [...$matches, ...$this->getMatchingGroupQuotas($owner->getUserId())]; + 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); + } + + /** @return list */ + private function getMatchingGroupQuotas(string $userId): array { + $user = $this->userManager->get($userId); + if ($user === null) { + return []; + } + + $quotas = $this->getGroupQuotas(); + return array_values(array_filter(array_map( + static fn (string $groupId): ?int => $quotas[$groupId] ?? null, + $this->groupManager->getUserGroupIds($user), + ), static fn (?int $quota): bool => $quota !== null)); } } 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..f0590302e 100644 --- a/src/components/AdminTeamFolders.vue +++ b/src/components/AdminTeamFolders.vue @@ -23,14 +23,31 @@ 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 +} + +type QuotaTargetType = 'team' | 'group' + +interface QuotaRow extends TeamOption { + type: QuotaTargetType + quota: QuotaOption +} + +interface QuotaTarget extends QuotaRow { + selectionLabel: string +} + const unlimitedQuota: QuotaOption = { id: '0', label: t('circles', 'Unlimited'), @@ -43,27 +60,32 @@ 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 groupRows = ref([]) +const loadedGroupQuotas = ref>({}) +const availableGroups = ref([]) +const selectedQuotaTarget = ref(null) +const loadingGroups = ref(true) +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), ...groupRows.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 +104,20 @@ const linkableFolders = ref([]) const selectedExistingFolder = ref(null) const loadingLinkableFolders = ref(false) +const availableQuotaTargets = computed(() => { + const mappedTargets = new Set([...rows.value, ...groupRows.value].map((row) => `${row.type}:${row.id}`)) + const teams = teamFolders.value + .map((team) => ({ id: team.teamId, label: team.teamName, selectionLabel: `${team.teamName} (${t('circles', 'Team')})`, type: 'team' as const, quota: selectedQuota.value })) + .filter((team) => !mappedTargets.has(`${team.type}:${team.id}`)) + const groups = availableGroups.value + .map((group) => ({ ...group, selectionLabel: `${group.label} (${t('circles', 'Group')})`, type: 'group' as const, quota: selectedQuota.value })) + .filter((group) => !mappedTargets.has(`${group.type}:${group.id}`)) + return [...teams, ...groups].sort((left, right) => left.label.localeCompare(right.label)) +}) + +const quotaRows = computed(() => [...rows.value, ...groupRows.value] + .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 +176,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, type: 'team' as const, 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), @@ -153,6 +196,29 @@ async function loadTeamFolders() { } } +/** Load groups and their configured quota mappings. */ +async function loadGroupQuotas() { + loadingGroups.value = true + try { + const [groupsResponse, settingsResponse] = await Promise.all([ + axios.get(generateOcsUrl('/apps/circles/settings/groups')), + axios.get(generateOcsUrl('/apps/circles/settings')), + ]) + availableGroups.value = groupsResponse.data.ocs.data as TeamOption[] + const quotas = (settingsResponse.data.ocs.data.team_folder_group_quotas ?? {}) as Record + loadedGroupQuotas.value = quotas + const groupsById = new Map(availableGroups.value.map((group) => [group.id, group])) + groupRows.value = Object.entries(quotas) + .map(([id, quota]) => ({ id, label: groupsById.get(id)?.label ?? id, type: 'group' as const, quota: quotaOptionFromBytes(quota) })) + .sort((left, right) => left.label.localeCompare(right.label)) + } catch (error) { + showError(t('circles', 'Unable to load group quotas')) + logger.error('Unable to load group quotas', { error }) + } finally { + loadingGroups.value = false + } +} + /** * Open the team-folder assignment dialog for an unassigned team. * @@ -299,29 +365,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 +412,443 @@ 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) + } + const groupQuotas: Record = {} + for (const row of groupRows.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 + } + groupQuotas[row.id] = Math.round(rowBytes) + } + + savingQuotas.value = true + try { + if (!await updateAppConfig('team_folder_default_quota', String(Math.round(bytes)))) { + return + } + if (!await updateAppConfig('team_folder_group_quotas', JSON.stringify(groupQuotas))) { + 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 Promise.all([loadTeamFolders(), loadGroupQuotas()]) + } 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 or group with the global default quota. */ +function addQuotaTarget() { + if (selectedQuotaTarget.value === null) { + return + } + const target = { ...selectedQuotaTarget.value, quota: selectedQuota.value } + if (target.type === 'team') { + rows.value.push(target) + } else { + groupRows.value.push(target) + } + selectedQuotaTarget.value = null +} + +/** + * Remove a quota mapping. + * + * @param target - Team or group mapping to remove + */ +function removeQuotaTarget(target: QuotaRow) { + if (target.type === 'team') { + rows.value = rows.value.filter((row) => row.id !== target.id) + } else { + groupRows.value = groupRows.value.filter((row) => row.id !== target.id) } } -onMounted(loadTeamFolders) - +/** + * 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 -