diff options
Diffstat (limited to 'apps/settings/src/components')
65 files changed, 1752 insertions, 523 deletions
diff --git a/apps/settings/src/components/AdminAI.vue b/apps/settings/src/components/AdminAI.vue index b31e6fd9e7b..0d3e9154bb9 100644 --- a/apps/settings/src/components/AdminAI.vue +++ b/apps/settings/src/components/AdminAI.vue @@ -6,16 +6,22 @@ <div class="ai-settings"> <NcSettingsSection :name="t('settings', 'Unified task processing')" :description="t('settings', 'AI tasks can be implemented by different apps. Here you can set which app should be used for which task.')"> + <NcCheckboxRadioSwitch v-model="settings['ai.taskprocessing_guests']" + type="switch" + @update:modelValue="saveChanges"> + {{ t('settings', 'Allow AI usage for guest users') }} + </NcCheckboxRadioSwitch> + <h3>{{ t('settings', 'Provider for Task types') }}</h3> <template v-for="type in taskProcessingTaskTypes"> - <div :key="type"> - <h3>{{ t('settings', 'Task:') }} {{ type.name }}</h3> - <p>{{ type.description }}</p> + <div :key="type" class="tasktype-item"> + <p class="tasktype-name"> + {{ type.name }} + </p> <NcCheckboxRadioSwitch v-model="settings['ai.taskprocessing_type_preferences'][type.id]" type="switch" @update:modelValue="saveChanges"> {{ t('settings', 'Enable') }} - </NcCheckboxRadioSwitch> - <NcSelect v-model="settings['ai.taskprocessing_provider_preferences'][type.id]" + </NcCheckboxRadioSwitch><NcSelect v-model="settings['ai.taskprocessing_provider_preferences'][type.id]" class="provider-select" :clearable="false" :disabled="!settings['ai.taskprocessing_type_preferences'][type.id]" @@ -28,7 +34,6 @@ {{ taskProcessingProviders.find(p => p.id === label)?.name }} </template> </NcSelect> - <p> </p> </div> </template> <template v-if="!hasTaskProcessing"> @@ -107,11 +112,11 @@ <script> import axios from '@nextcloud/axios' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import draggable from 'vuedraggable' import DragVerticalIcon from 'vue-material-design-icons/DragVertical.vue' import ArrowDownIcon from 'vue-material-design-icons/ArrowDown.vue' @@ -239,4 +244,14 @@ export default { .provider-select { min-width: 350px !important; } + +.tasktype-item { + display: flex; + align-items: center; + gap: 8px; + .tasktype-name { + flex: 1; + margin: 0; + } +} </style> diff --git a/apps/settings/src/components/AdminDelegating.vue b/apps/settings/src/components/AdminDelegating.vue index 0775316ed2b..521ff8f0155 100644 --- a/apps/settings/src/components/AdminDelegating.vue +++ b/apps/settings/src/components/AdminDelegating.vue @@ -17,7 +17,7 @@ <script> import GroupSelect from './AdminDelegation/GroupSelect.vue' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import { loadState } from '@nextcloud/initial-state' export default { diff --git a/apps/settings/src/components/AdminDelegation/GroupSelect.vue b/apps/settings/src/components/AdminDelegation/GroupSelect.vue index 203c17aa7f8..28d3deb0afa 100644 --- a/apps/settings/src/components/AdminDelegation/GroupSelect.vue +++ b/apps/settings/src/components/AdminDelegation/GroupSelect.vue @@ -14,7 +14,7 @@ </template> <script> -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' import { generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' import { showError } from '@nextcloud/dialogs' diff --git a/apps/settings/src/components/AdminSettingsSharingForm.vue b/apps/settings/src/components/AdminSettingsSharingForm.vue index 2d3269563bb..b0e142d8480 100644 --- a/apps/settings/src/components/AdminSettingsSharingForm.vue +++ b/apps/settings/src/components/AdminSettingsSharingForm.vue @@ -27,6 +27,15 @@ :label="t('settings', 'Ignore the following groups when checking group membership')" style="width: 100%" /> </div> + <NcCheckboxRadioSwitch :checked.sync="settings.allowViewWithoutDownload"> + {{ t('settings', 'Allow users to preview files even if download is disabled') }} + </NcCheckboxRadioSwitch> + <NcNoteCard v-show="settings.allowViewWithoutDownload" + id="settings-sharing-api-view-without-download-hint" + class="sharing__note" + type="warning"> + {{ t('settings', 'Users will still be able to screenshot or record the screen. This does not provide any definitive protection.') }} + </NcNoteCard> </div> <div v-show="settings.enabled" id="settings-sharing-api" class="sharing__section"> @@ -39,6 +48,10 @@ <NcCheckboxRadioSwitch :checked.sync="settings.allowPublicUpload"> {{ t('settings', 'Allow public uploads') }} </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-model="settings.allowFederationOnPublicShares"> + {{ t('settings', 'Allow public shares to be added to other clouds by federation.') }} + {{ t('settings', 'This will add share permissions to all newly created link shares.') }} + </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch :checked.sync="settings.enableLinkPasswordByDefault"> {{ t('settings', 'Always ask for a password') }} </NcCheckboxRadioSwitch> @@ -59,6 +72,24 @@ </label> </fieldset> + <NcCheckboxRadioSwitch type="switch" + aria-describedby="settings-sharing-custom-token-disable-hint settings-sharing-custom-token-access-hint" + :checked.sync="settings.allowCustomTokens"> + {{ t('settings', 'Allow users to set custom share link tokens') }} + </NcCheckboxRadioSwitch> + <div class="sharing__sub-section"> + <NcNoteCard id="settings-sharing-custom-token-disable-hint" + class="sharing__note" + type="info"> + {{ t('settings', 'Shares with custom tokens will continue to be accessible after this setting has been disabled') }} + </NcNoteCard> + <NcNoteCard id="settings-sharing-custom-token-access-hint" + class="sharing__note" + type="warning"> + {{ t('settings', 'Shares with guessable tokens may be accessed easily') }} + </NcNoteCard> + </div> + <label>{{ t('settings', 'Limit sharing based on groups') }}</label> <div class="sharing__sub-section"> <NcCheckboxRadioSwitch :checked.sync="settings.excludeGroups" @@ -133,7 +164,7 @@ </NcCheckboxRadioSwitch> <fieldset v-show="settings.allowLinks && settings.defaultExpireDate" id="settings-sharing-api-api-expiration" class="sharing__sub-section"> <NcCheckboxRadioSwitch :checked.sync="settings.enforceExpireDate"> - {{ t('settings', 'Enforce expiration date for remote shares') }} + {{ t('settings', 'Enforce expiration date for link or mail shares') }} </NcCheckboxRadioSwitch> <NcTextField type="number" class="sharing__input" @@ -192,19 +223,18 @@ </template> <script lang="ts"> -import { - NcCheckboxRadioSwitch, - NcSettingsSelectGroup, - NcTextArea, - NcTextField, -} from '@nextcloud/vue' import { showError, showSuccess } from '@nextcloud/dialogs' -import { translate as t } from '@nextcloud/l10n' import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' import { snakeCase } from 'lodash' import { defineComponent } from 'vue' import debounce from 'debounce' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcSettingsSelectGroup from '@nextcloud/vue/components/NcSettingsSelectGroup' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' +import NcTextField from '@nextcloud/vue/components/NcTextField' import SelectSharingPermissions from './SelectSharingPermissions.vue' interface IShareSettings { @@ -215,6 +245,7 @@ interface IShareSettings { allowPublicUpload: boolean allowResharing: boolean allowShareDialogUserEnumeration: boolean + allowFederationOnPublicShares: boolean restrictUserEnumerationToGroup: boolean restrictUserEnumerationToPhone: boolean restrictUserEnumerationFullMatch: boolean @@ -240,6 +271,8 @@ interface IShareSettings { defaultRemoteExpireDate: boolean remoteExpireAfterNDays: string enforceRemoteExpireDate: boolean + allowCustomTokens: boolean + allowViewWithoutDownload: boolean } export default defineComponent({ @@ -247,6 +280,7 @@ export default defineComponent({ components: { NcCheckboxRadioSwitch, NcSettingsSelectGroup, + NcNoteCard, NcTextArea, NcTextField, SelectSharingPermissions, @@ -354,6 +388,10 @@ export default defineComponent({ width: 100%; } } + + & &__note { + margin: 2px 0; + } } @media only screen and (max-width: 350px) { diff --git a/apps/settings/src/components/AdminTwoFactor.vue b/apps/settings/src/components/AdminTwoFactor.vue index 56b9d609b8b..e24bee02593 100644 --- a/apps/settings/src/components/AdminTwoFactor.vue +++ b/apps/settings/src/components/AdminTwoFactor.vue @@ -71,10 +71,10 @@ <script> import axios from '@nextcloud/axios' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import { loadState } from '@nextcloud/initial-state' import sortedUniq from 'lodash/sortedUniq.js' diff --git a/apps/settings/src/components/AppAPI/DaemonSelectionDialog.vue b/apps/settings/src/components/AppAPI/DaemonSelectionDialog.vue new file mode 100644 index 00000000000..696c77d19ce --- /dev/null +++ b/apps/settings/src/components/AppAPI/DaemonSelectionDialog.vue @@ -0,0 +1,41 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcDialog :open="show" + :name="t('settings', 'Choose Deploy Daemon for {appName}', {appName: app.name })" + size="normal" + @update:open="closeModal"> + <DaemonSelectionList :app="app" + :deploy-options="deployOptions" + @close="closeModal" /> + </NcDialog> +</template> + +<script setup> +import { defineProps, defineEmits } from 'vue' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import DaemonSelectionList from './DaemonSelectionList.vue' + +defineProps({ + show: { + type: Boolean, + required: true, + }, + app: { + type: Object, + required: true, + }, + deployOptions: { + type: Object, + required: false, + default: () => ({}), + }, +}) + +const emit = defineEmits(['update:show']) +const closeModal = () => { + emit('update:show', false) +} +</script> diff --git a/apps/settings/src/components/AppAPI/DaemonSelectionEntry.vue b/apps/settings/src/components/AppAPI/DaemonSelectionEntry.vue new file mode 100644 index 00000000000..6b1cefde032 --- /dev/null +++ b/apps/settings/src/components/AppAPI/DaemonSelectionEntry.vue @@ -0,0 +1,77 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcListItem :name="itemTitle" + :details="isDefault ? t('settings', 'Default') : ''" + :force-display-actions="true" + :counter-number="daemon.exAppsCount" + :active="isDefault" + counter-type="highlighted" + @click.stop="selectDaemonAndInstall"> + <template #subname> + {{ daemon.accepts_deploy_id }} + </template> + </NcListItem> +</template> + +<script> +import NcListItem from '@nextcloud/vue/components/NcListItem' +import AppManagement from '../../mixins/AppManagement.js' +import { useAppsStore } from '../../store/apps-store' +import { useAppApiStore } from '../../store/app-api-store' + +export default { + name: 'DaemonSelectionEntry', + components: { + NcListItem, + }, + mixins: [AppManagement], // TODO: Convert to Composition API when AppManagement is refactored + props: { + daemon: { + type: Object, + required: true, + }, + isDefault: { + type: Boolean, + required: true, + }, + app: { + type: Object, + required: true, + }, + deployOptions: { + type: Object, + required: false, + default: () => ({}), + }, + }, + setup() { + const store = useAppsStore() + const appApiStore = useAppApiStore() + + return { + store, + appApiStore, + } + }, + computed: { + itemTitle() { + return this.daemon.name + ' - ' + this.daemon.display_name + }, + daemons() { + return this.appApiStore.dockerDaemons + }, + }, + methods: { + closeModal() { + this.$emit('close') + }, + selectDaemonAndInstall() { + this.closeModal() + this.enable(this.app.id, this.daemon, this.deployOptions) + }, + }, +} +</script> diff --git a/apps/settings/src/components/AppAPI/DaemonSelectionList.vue b/apps/settings/src/components/AppAPI/DaemonSelectionList.vue new file mode 100644 index 00000000000..701a17dbe24 --- /dev/null +++ b/apps/settings/src/components/AppAPI/DaemonSelectionList.vue @@ -0,0 +1,77 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="daemon-selection-list"> + <ul v-if="dockerDaemons.length > 0" + :aria-label="t('settings', 'Registered Deploy daemons list')"> + <DaemonSelectionEntry v-for="daemon in dockerDaemons" + :key="daemon.id" + :daemon="daemon" + :is-default="defaultDaemon.name === daemon.name" + :app="app" + :deploy-options="deployOptions" + @close="closeModal" /> + </ul> + <NcEmptyContent v-else + class="daemon-selection-list__empty-content" + :name="t('settings', 'No Deploy daemons configured')" + :description="t('settings', 'Register a custom one or setup from available templates')"> + <template #icon> + <FormatListBullet :size="20" /> + </template> + <template #action> + <NcButton :href="appApiAdminPage"> + {{ t('settings', 'Manage Deploy daemons') }} + </NcButton> + </template> + </NcEmptyContent> + </div> +</template> + +<script setup> +import { computed, defineProps } from 'vue' +import { generateUrl } from '@nextcloud/router' + +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcButton from '@nextcloud/vue/components/NcButton' +import FormatListBullet from 'vue-material-design-icons/FormatListBulleted.vue' +import DaemonSelectionEntry from './DaemonSelectionEntry.vue' +import { useAppApiStore } from '../../store/app-api-store.ts' + +defineProps({ + app: { + type: Object, + required: true, + }, + deployOptions: { + type: Object, + required: false, + default: () => ({}), + }, +}) + +const appApiStore = useAppApiStore() + +const dockerDaemons = computed(() => appApiStore.dockerDaemons) +const defaultDaemon = computed(() => appApiStore.defaultDaemon) +const appApiAdminPage = computed(() => generateUrl('/settings/admin/app_api')) +const emit = defineEmits(['close']) +const closeModal = () => { + emit('close') +} +</script> + +<style scoped lang="scss"> +.daemon-selection-list { + max-height: 350px; + overflow-y: scroll; + padding: 2rem; + + &__empty-content { + margin-top: 0; + text-align: center; + } +} +</style> diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue index 8d874982e0d..3e40e08b257 100644 --- a/apps/settings/src/components/AppList.vue +++ b/apps/settings/src/components/AppList.vue @@ -141,7 +141,7 @@ <script> import { subscribe, unsubscribe } from '@nextcloud/event-bus' import pLimit from 'p-limit' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcButton from '@nextcloud/vue/components/NcButton' import AppItem from './AppList/AppItem.vue' import AppManagement from '../mixins/AppManagement' import { useAppApiStore } from '../store/app-api-store' @@ -200,9 +200,13 @@ export default { const apps = [...this.$store.getters.getAllApps, ...exApps] .filter(app => app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) .sort(function(a, b) { - const sortStringA = '' + (a.active ? 0 : 1) + (a.update ? 0 : 1) + a.name - const sortStringB = '' + (b.active ? 0 : 1) + (b.update ? 0 : 1) + b.name - return OC.Util.naturalSortCompare(sortStringA, sortStringB) + const natSortDiff = OC.Util.naturalSortCompare(a, b) + if (natSortDiff === 0) { + const sortStringA = '' + (a.active ? 0 : 1) + (a.update ? 0 : 1) + const sortStringB = '' + (b.active ? 0 : 1) + (b.update ? 0 : 1) + return Number(sortStringA) - Number(sortStringB) + } + return natSortDiff }) if (this.category === 'installed') { diff --git a/apps/settings/src/components/AppList/AppDaemonBadge.vue b/apps/settings/src/components/AppList/AppDaemonBadge.vue index b323272e837..ca81e7fab0b 100644 --- a/apps/settings/src/components/AppList/AppDaemonBadge.vue +++ b/apps/settings/src/components/AppList/AppDaemonBadge.vue @@ -14,7 +14,7 @@ <script setup lang="ts"> import type { IDeployDaemon } from '../../app-types.ts' import { mdiFileChart } from '@mdi/js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' defineProps<{ daemon?: IDeployDaemon diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue index 5420e7af37c..95a98a93cde 100644 --- a/apps/settings/src/components/AppList/AppItem.vue +++ b/apps/settings/src/components/AppList/AppItem.vue @@ -100,7 +100,7 @@ :aria-label="enableButtonTooltip" type="primary" :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" - @click.stop="enable(app.id)"> + @click.stop="enableButtonAction"> {{ enableButtonText }} </NcButton> <NcButton v-else-if="!app.active" @@ -111,6 +111,10 @@ @click.stop="forceEnable(app.id)"> {{ forceEnableButtonText }} </NcButton> + + <DaemonSelectionDialog v-if="app?.app_api && showSelectDaemonModal" + :show.sync="showSelectDaemonModal" + :app="app" /> </component> </component> </template> @@ -122,10 +126,11 @@ import AppScore from './AppScore.vue' import AppLevelBadge from './AppLevelBadge.vue' import AppManagement from '../../mixins/AppManagement.js' import SvgFilterMixin from '../SvgFilterMixin.vue' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { mdiCogOutline } from '@mdi/js' import { useAppApiStore } from '../../store/app-api-store.ts' +import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue' export default { name: 'AppItem', @@ -134,6 +139,7 @@ export default { AppScore, NcButton, NcIconSvgWrapper, + DaemonSelectionDialog, }, mixins: [AppManagement, SvgFilterMixin], props: { @@ -177,6 +183,7 @@ export default { isSelected: false, scrolled: false, screenshotLoaded: false, + showSelectDaemonModal: false, } }, computed: { @@ -219,6 +226,23 @@ export default { getDataItemHeaders(columnName) { return this.useBundleView ? [this.headers, columnName].join(' ') : null }, + showSelectionModal() { + this.showSelectDaemonModal = true + }, + async enableButtonAction() { + if (!this.app?.app_api) { + this.enable(this.app.id) + return + } + await this.appApiStore.fetchDockerDaemons() + if (this.appApiStore.dockerDaemons.length === 1 && this.app.needsDownload) { + this.enable(this.app.id, this.appApiStore.dockerDaemons[0]) + } else if (this.app.needsDownload) { + this.showSelectionModal() + } else { + this.enable(this.app.id, this.app.daemon) + } + }, }, } </script> diff --git a/apps/settings/src/components/AppList/AppLevelBadge.vue b/apps/settings/src/components/AppList/AppLevelBadge.vue index cceb5b0ecf9..8461f5eb6b9 100644 --- a/apps/settings/src/components/AppList/AppLevelBadge.vue +++ b/apps/settings/src/components/AppList/AppLevelBadge.vue @@ -13,9 +13,9 @@ </template> <script setup lang="ts"> -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' -import { mdiCheck, mdiStarShooting } from '@mdi/js' +import { mdiCheck, mdiStarShootingOutline } from '@mdi/js' import { translate as t } from '@nextcloud/l10n' import { computed } from 'vue' @@ -28,7 +28,7 @@ const props = defineProps<{ const isSupported = computed(() => props.level === 300) const isFeatured = computed(() => props.level === 200) -const badgeIcon = computed(() => isSupported.value ? mdiStarShooting : mdiCheck) +const badgeIcon = computed(() => isSupported.value ? mdiStarShootingOutline : mdiCheck) const badgeText = computed(() => isSupported.value ? t('settings', 'Supported') : t('settings', 'Featured')) const badgeTitle = computed(() => isSupported.value ? t('settings', 'This app is supported via your current Nextcloud subscription.') diff --git a/apps/settings/src/components/AppList/AppScore.vue b/apps/settings/src/components/AppList/AppScore.vue index 7eebc620a0b..a1dd4c03842 100644 --- a/apps/settings/src/components/AppList/AppScore.vue +++ b/apps/settings/src/components/AppList/AppScore.vue @@ -20,7 +20,7 @@ </span> </template> <script lang="ts"> -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { mdiStar, mdiStarHalfFull, mdiStarOutline } from '@mdi/js' import { translate as t } from '@nextcloud/l10n' import { defineComponent } from 'vue' diff --git a/apps/settings/src/components/AppNavigationGroupList.vue b/apps/settings/src/components/AppNavigationGroupList.vue new file mode 100644 index 00000000000..8f21d18d695 --- /dev/null +++ b/apps/settings/src/components/AppNavigationGroupList.vue @@ -0,0 +1,220 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Fragment> + <NcAppNavigationCaption :name="t('settings', 'Groups')" + :disabled="loadingAddGroup" + :aria-label="loadingAddGroup ? t('settings', 'Creating group…') : t('settings', 'Create group')" + force-menu + is-heading + :open.sync="isAddGroupOpen"> + <template v-if="isAdminOrDelegatedAdmin" #actionsTriggerIcon> + <NcLoadingIcon v-if="loadingAddGroup" /> + <NcIconSvgWrapper v-else :path="mdiPlus" /> + </template> + <template v-if="isAdminOrDelegatedAdmin" #actions> + <NcActionText> + <template #icon> + <NcIconSvgWrapper :path="mdiAccountGroupOutline" /> + </template> + {{ t('settings', 'Create group') }} + </NcActionText> + <NcActionInput :label="t('settings', 'Group name')" + data-cy-users-settings-new-group-name + :label-outside="false" + :disabled="loadingAddGroup" + :value.sync="newGroupName" + :error="hasAddGroupError" + :helper-text="hasAddGroupError ? t('settings', 'Please enter a valid group name') : ''" + @submit="createGroup" /> + </template> + </NcAppNavigationCaption> + + <NcAppNavigationSearch v-model="groupsSearchQuery" + :label="t('settings', 'Search groups…')" /> + + <p id="group-list-desc" class="hidden-visually"> + {{ t('settings', 'List of groups. This list is not fully populated for performance reasons. The groups will be loaded as you navigate or search through the list.') }} + </p> + <NcAppNavigationList class="account-management__group-list" + aria-describedby="group-list-desc" + data-cy-users-settings-navigation-groups="custom"> + <GroupListItem v-for="group in filteredGroups" + :id="group.id" + ref="groupListItems" + :key="group.id" + :active="selectedGroupDecoded === group.id" + :name="group.title" + :count="group.count" /> + <div v-if="loadingGroups" role="note"> + <NcLoadingIcon :name="t('settings', 'Loading groups…')" /> + </div> + </NcAppNavigationList> + </Fragment> +</template> + +<script setup lang="ts"> +import type CancelablePromise from 'cancelable-promise' +import type { IGroup } from '../views/user-types.d.ts' + +import { mdiAccountGroupOutline, mdiPlus } from '@mdi/js' +import { showError } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' +import { useElementVisibility } from '@vueuse/core' +import { computed, ref, watch, onBeforeMount } from 'vue' +import { Fragment } from 'vue-frag' +import { useRoute, useRouter } from 'vue-router/composables' + +import NcActionInput from '@nextcloud/vue/components/NcActionInput' +import NcActionText from '@nextcloud/vue/components/NcActionText' +import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption' +import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList' +import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' + +import GroupListItem from './GroupListItem.vue' + +import { useFormatGroups } from '../composables/useGroupsNavigation.ts' +import { useStore } from '../store' +import { searchGroups } from '../service/groups.ts' +import logger from '../logger.ts' + +const store = useStore() +const route = useRoute() +const router = useRouter() + +onBeforeMount(async () => { + await loadGroups() +}) + +/** Current active group in the view - this is URL encoded */ +const selectedGroup = computed(() => route.params?.selectedGroup) +/** Current active group - URL decoded */ +const selectedGroupDecoded = computed(() => selectedGroup.value ? decodeURIComponent(selectedGroup.value) : null) +/** All available groups */ +const groups = computed(() => { + return isAdminOrDelegatedAdmin.value + ? store.getters.getSortedGroups + : store.getters.getSubAdminGroups +}) +/** User groups */ +const { userGroups } = useFormatGroups(groups) +/** Server settings for current user */ +const settings = computed(() => store.getters.getServerData) +/** True if the current user is a (delegated) admin */ +const isAdminOrDelegatedAdmin = computed(() => settings.value.isAdmin || settings.value.isDelegatedAdmin) + +/** True if the 'add-group' dialog is open - needed to be able to close it when the group is created */ +const isAddGroupOpen = ref(false) +/** True if the group creation is in progress to show loading spinner and disable adding another one */ +const loadingAddGroup = ref(false) +/** Error state for creating a new group */ +const hasAddGroupError = ref(false) +/** Name of the group to create (used in the group creation dialog) */ +const newGroupName = ref('') + +/** True if groups are loading */ +const loadingGroups = ref(false) +/** Search offset */ +const offset = ref(0) +/** Search query for groups */ +const groupsSearchQuery = ref('') +const filteredGroups = computed(() => { + if (isAdminOrDelegatedAdmin.value) { + return userGroups.value + } + + const substring = groupsSearchQuery.value.toLowerCase() + return userGroups.value.filter(group => group.id.toLowerCase().search(substring) !== -1 || group.title.toLowerCase().search(substring) !== -1) +}) + +const groupListItems = ref([]) +const lastGroupListItem = computed(() => { + return groupListItems.value + .findLast(component => component?.$vnode?.key === userGroups.value?.at(-1)?.id) // Order of refs is not guaranteed to match source array order + ?.$refs?.listItem?.$el +}) +const isLastGroupVisible = useElementVisibility(lastGroupListItem) +watch(isLastGroupVisible, async () => { + if (!isLastGroupVisible.value) { + return + } + await loadGroups() +}) + +watch(groupsSearchQuery, async () => { + store.commit('resetGroups') + offset.value = 0 + await loadGroups() +}) + +/** Cancelable promise for search groups request */ +const promise = ref<CancelablePromise<IGroup[]>>() + +/** + * Load groups + */ +async function loadGroups() { + if (!isAdminOrDelegatedAdmin.value) { + return + } + + if (promise.value) { + promise.value.cancel() + } + loadingGroups.value = true + try { + promise.value = searchGroups({ + search: groupsSearchQuery.value, + offset: offset.value, + limit: 25, + }) + const groups = await promise.value + if (groups.length > 0) { + offset.value += 25 + } + for (const group of groups) { + store.commit('addGroup', group) + } + } catch (error) { + logger.error(t('settings', 'Failed to load groups'), { error }) + } + promise.value = undefined + loadingGroups.value = false +} + +/** + * Create a new group + */ +async function createGroup() { + hasAddGroupError.value = false + const groupId = newGroupName.value.trim() + if (groupId === '') { + hasAddGroupError.value = true + return + } + + isAddGroupOpen.value = false + loadingAddGroup.value = true + + try { + await store.dispatch('addGroup', groupId) + await router.push({ + name: 'group', + params: { + selectedGroup: encodeURIComponent(groupId), + }, + }) + const newGroupListItem = groupListItems.value.findLast(component => component?.$vnode?.key === groupId) + newGroupListItem?.$refs?.listItem?.$el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + newGroupName.value = '' + } catch { + showError(t('settings', 'Failed to create group')) + } + loadingAddGroup.value = false +} +</script> diff --git a/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue index d0a781159b9..bb91940c763 100644 --- a/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue +++ b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue @@ -8,7 +8,7 @@ :name="t('settings', 'Nothing to show')" :description="t('settings', 'Could not load section content from app store.')"> <template #icon> - <NcIconSvgWrapper :path="mdiEyeOff" :size="64" /> + <NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" /> </template> </NcEmptyContent> <NcEmptyContent v-else-if="elements.length === 0" @@ -30,16 +30,16 @@ <script setup lang="ts"> import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts' -import { mdiEyeOff } from '@mdi/js' +import { mdiEyeOffOutline } from '@mdi/js' import { showError } from '@nextcloud/dialogs' import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue' import axios from '@nextcloud/axios' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import logger from '../../logger' import { parseApiResponse, filterElements } from '../../utils/appDiscoverParser.ts' diff --git a/apps/settings/src/components/AppStoreDiscover/CarouselType.vue b/apps/settings/src/components/AppStoreDiscover/CarouselType.vue index e657c7ae800..69393176835 100644 --- a/apps/settings/src/components/AppStoreDiscover/CarouselType.vue +++ b/apps/settings/src/components/AppStoreDiscover/CarouselType.vue @@ -69,8 +69,8 @@ import { computed, defineComponent, nextTick, ref, watch } from 'vue' import { commonAppDiscoverProps } from './common.ts' import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import PostType from './PostType.vue' export default defineComponent({ diff --git a/apps/settings/src/components/AppStoreDiscover/PostType.vue b/apps/settings/src/components/AppStoreDiscover/PostType.vue index fbd70796274..090e9dee577 100644 --- a/apps/settings/src/components/AppStoreDiscover/PostType.vue +++ b/apps/settings/src/components/AppStoreDiscover/PostType.vue @@ -65,7 +65,7 @@ import { computed, defineComponent, ref, watchEffect } from 'vue' import { commonAppDiscoverProps } from './common' import { useLocalizedValue } from '../../composables/useGetLocalizedValue' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import AppLink from './AppLink.vue' export default defineComponent({ diff --git a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue index a082ab326cc..7c0b8ea4421 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue @@ -25,8 +25,8 @@ <script setup lang="ts"> import type { IAppstoreExApp } from '../../app-types' -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { mdiFileChart } from '@mdi/js' import { ref } from 'vue' diff --git a/apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue b/apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue new file mode 100644 index 00000000000..0544c3848be --- /dev/null +++ b/apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue @@ -0,0 +1,320 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcDialog :open="show" + size="normal" + :name="t('settings', 'Advanced deploy options')" + @update:open="$emit('update:show', $event)"> + <div class="modal__content"> + <p class="deploy-option__hint"> + {{ configuredDeployOptions === null ? t('settings', 'Edit ExApp deploy options before installation') : t('settings', 'Configured ExApp deploy options. Can be set only during installation') }}. + <a v-if="deployOptionsDocsUrl" :href="deployOptionsDocsUrl"> + {{ t('settings', 'Learn more') }} + </a> + </p> + <h3 v-if="environmentVariables.length > 0 || (configuredDeployOptions !== null && configuredDeployOptions.environment_variables.length > 0)"> + {{ t('settings', 'Environment variables') }} + </h3> + <template v-if="configuredDeployOptions === null"> + <div v-for="envVar in environmentVariables" + :key="envVar.envName" + class="deploy-option"> + <NcTextField :label="envVar.displayName" :value.sync="deployOptions.environment_variables[envVar.envName]" /> + <p class="deploy-option__hint"> + {{ envVar.description }} + </p> + </div> + </template> + <fieldset v-else-if="Object.keys(configuredDeployOptions).length > 0" + class="envs"> + <legend class="deploy-option__hint"> + {{ t('settings', 'ExApp container environment variables') }} + </legend> + <NcTextField v-for="(value, key) in configuredDeployOptions.environment_variables" + :key="key" + :label="value.displayName ?? key" + :helper-text="value.description" + :value="value.value" + readonly /> + </fieldset> + <template v-else> + <p class="deploy-option__hint"> + {{ t('settings', 'No environment variables defined') }} + </p> + </template> + + <h3>{{ t('settings', 'Mounts') }}</h3> + <template v-if="configuredDeployOptions === null"> + <p class="deploy-option__hint"> + {{ t('settings', 'Define host folder mounts to bind to the ExApp container') }} + </p> + <NcNoteCard type="info" :text="t('settings', 'Must exist on the Deploy daemon host prior to installing the ExApp')" /> + <div v-for="mount in deployOptions.mounts" + :key="mount.hostPath" + class="deploy-option" + style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;"> + <NcTextField :label="t('settings', 'Host path')" :value.sync="mount.hostPath" /> + <NcTextField :label="t('settings', 'Container path')" :value.sync="mount.containerPath" /> + <NcCheckboxRadioSwitch :checked.sync="mount.readonly"> + {{ t('settings', 'Read-only') }} + </NcCheckboxRadioSwitch> + <NcButton :aria-label="t('settings', 'Remove mount')" + style="margin-top: 6px;" + @click="removeMount(mount)"> + <template #icon> + <NcIconSvgWrapper :path="mdiDeleteOutline" /> + </template> + </NcButton> + </div> + <div v-if="addingMount" class="deploy-option"> + <h4> + {{ t('settings', 'New mount') }} + </h4> + <div style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;"> + <NcTextField ref="newMountHostPath" + :label="t('settings', 'Host path')" + :aria-label="t('settings', 'Enter path to host folder')" + :value.sync="newMountPoint.hostPath" /> + <NcTextField :label="t('settings', 'Container path')" + :aria-label="t('settings', 'Enter path to container folder')" + :value.sync="newMountPoint.containerPath" /> + <NcCheckboxRadioSwitch :checked.sync="newMountPoint.readonly" + :aria-label="t('settings', 'Toggle read-only mode')"> + {{ t('settings', 'Read-only') }} + </NcCheckboxRadioSwitch> + </div> + <div style="display: flex; align-items: center; margin-top: 4px;"> + <NcButton :aria-label="t('settings', 'Confirm adding new mount')" + @click="addMountPoint"> + <template #icon> + <NcIconSvgWrapper :path="mdiCheck" /> + </template> + {{ t('settings', 'Confirm') }} + </NcButton> + <NcButton :aria-label="t('settings', 'Cancel adding mount')" + style="margin-left: 4px;" + @click="cancelAddMountPoint"> + <template #icon> + <NcIconSvgWrapper :path="mdiClose" /> + </template> + {{ t('settings', 'Cancel') }} + </NcButton> + </div> + </div> + <NcButton v-if="!addingMount" + :aria-label="t('settings', 'Add mount')" + style="margin-top: 5px;" + @click="startAddingMount"> + <template #icon> + <NcIconSvgWrapper :path="mdiPlus" /> + </template> + {{ t('settings', 'Add mount') }} + </NcButton> + </template> + <template v-else-if="configuredDeployOptions.mounts.length > 0"> + <p class="deploy-option__hint"> + {{ t('settings', 'ExApp container mounts') }} + </p> + <div v-for="mount in configuredDeployOptions.mounts" + :key="mount.hostPath" + class="deploy-option" + style="display: flex; align-items: center; justify-content: space-between; flex-direction: row;"> + <NcTextField :label="t('settings', 'Host path')" :value.sync="mount.hostPath" readonly /> + <NcTextField :label="t('settings', 'Container path')" :value.sync="mount.containerPath" readonly /> + <NcCheckboxRadioSwitch :checked.sync="mount.readonly" disabled> + {{ t('settings', 'Read-only') }} + </NcCheckboxRadioSwitch> + </div> + </template> + <p v-else class="deploy-option__hint"> + {{ t('settings', 'No mounts defined') }} + </p> + </div> + + <template v-if="!app.active && (app.canInstall || app.isCompatible) && configuredDeployOptions === null" #actions> + <NcButton :title="enableButtonTooltip" + :aria-label="enableButtonTooltip" + type="primary" + :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" + @click.stop="submitDeployOptions"> + {{ enableButtonText }} + </NcButton> + </template> + </NcDialog> +</template> + +<script> +import { computed, ref } from 'vue' + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' +import { emit } from '@nextcloud/event-bus' + +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' + +import { mdiPlus, mdiCheck, mdiClose, mdiDeleteOutline } from '@mdi/js' + +import { useAppApiStore } from '../../store/app-api-store.ts' +import { useAppsStore } from '../../store/apps-store.ts' + +import AppManagement from '../../mixins/AppManagement.js' + +export default { + name: 'AppDeployOptionsModal', + components: { + NcDialog, + NcTextField, + NcButton, + NcNoteCard, + NcCheckboxRadioSwitch, + NcIconSvgWrapper, + }, + mixins: [AppManagement], + props: { + app: { + type: Object, + required: true, + }, + show: { + type: Boolean, + required: true, + }, + }, + setup(props) { + // for AppManagement mixin + const store = useAppsStore() + const appApiStore = useAppApiStore() + + const environmentVariables = computed(() => { + if (props.app?.releases?.length === 1) { + return props.app?.releases[0]?.environmentVariables || [] + } + return [] + }) + + const deployOptions = ref({ + environment_variables: environmentVariables.value.reduce((acc, envVar) => { + acc[envVar.envName] = envVar.default || '' + return acc + }, {}), + mounts: [], + }) + + return { + environmentVariables, + deployOptions, + store, + appApiStore, + mdiPlus, + mdiCheck, + mdiClose, + mdiDeleteOutline, + } + }, + data() { + return { + addingMount: false, + newMountPoint: { + hostPath: '', + containerPath: '', + readonly: false, + }, + addingPortBinding: false, + configuredDeployOptions: null, + deployOptionsDocsUrl: loadState('settings', 'deployOptionsDocsUrl', null), + } + }, + watch: { + show(newShow) { + if (newShow) { + this.fetchExAppDeployOptions() + } else { + this.configuredDeployOptions = null + } + }, + }, + methods: { + startAddingMount() { + this.addingMount = true + this.$nextTick(() => { + this.$refs.newMountHostPath.focus() + }) + }, + addMountPoint() { + this.deployOptions.mounts.push(this.newMountPoint) + this.newMountPoint = { + hostPath: '', + containerPath: '', + readonly: false, + } + this.addingMount = false + }, + cancelAddMountPoint() { + this.newMountPoint = { + hostPath: '', + containerPath: '', + readonly: false, + } + this.addingMount = false + }, + removeMount(mountToRemove) { + this.deployOptions.mounts = this.deployOptions.mounts.filter(mount => mount !== mountToRemove) + }, + async fetchExAppDeployOptions() { + return axios.get(generateUrl(`/apps/app_api/apps/deploy-options/${this.app.id}`)) + .then(response => { + this.configuredDeployOptions = response.data + }) + .catch(() => { + this.configuredDeployOptions = null + }) + }, + async submitDeployOptions() { + await this.appApiStore.fetchDockerDaemons() + if (this.appApiStore.dockerDaemons.length === 1 && this.app.needsDownload) { + this.enable(this.app.id, this.appApiStore.dockerDaemons[0], this.deployOptions) + } else if (this.app.needsDownload) { + emit('showDaemonSelectionModal', this.deployOptions) + } else { + this.enable(this.app.id, this.app.daemon, this.deployOptions) + } + this.$emit('update:show', false) + }, + }, +} +</script> + +<style scoped> +.deploy-option { + margin: calc(var(--default-grid-baseline) * 4) 0; + display: flex; + flex-direction: column; + align-items: flex-start; + + &__hint { + margin-top: 4px; + font-size: 0.8em; + color: var(--color-text-maxcontrast); + } +} + +.envs { + width: 100%; + overflow: auto; + height: 100%; + max-height: 300px; + + li { + margin: 10px 0; + } +} +</style> diff --git a/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue index 36c551eb0e8..299d084ef9e 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue @@ -22,8 +22,8 @@ import type { IAppstoreApp } from '../../app-types' import { mdiTextShort } from '@mdi/js' import { translate as t } from '@nextcloud/l10n' -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import Markdown from '../Markdown.vue' defineProps<{ diff --git a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue index f3adbfd2a1c..eb66d8f3e3a 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue @@ -8,7 +8,7 @@ :name="t('settings', 'Details')" :order="1"> <template #icon> - <NcIconSvgWrapper :path="mdiTextBox" /> + <NcIconSvgWrapper :path="mdiTextBoxOutline" /> </template> <div class="app-details"> <div class="app-details__actions"> @@ -68,7 +68,7 @@ type="button" :value="enableButtonText" :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying" - @click="enable(app.id)"> + @click="enableButtonAction"> <input v-else-if="!app.active && !app.canInstall" :title="forceEnableButtonTooltip" :aria-label="forceEnableButtonTooltip" @@ -77,6 +77,15 @@ :value="forceEnableButtonText" :disabled="installing || isLoading" @click="forceEnable(app.id)"> + <NcButton v-if="app?.app_api && (app.canInstall || app.isCompatible)" + :aria-label="t('settings', 'Advanced deploy options')" + type="secondary" + @click="() => showDeployOptionsModal = true"> + <template #icon> + <NcIconSvgWrapper :path="mdiToyBrickPlusOutline" /> + </template> + {{ t('settings', 'Deploy options') }} + </NcButton> </div> <p v-if="!defaultDeployDaemonAccessible" class="warning"> {{ t('settings', 'Default Deploy daemon is not accessible') }} @@ -153,7 +162,7 @@ :aria-label="t('settings', 'Report a bug')" :title="t('settings', 'Report a bug')"> <template #icon> - <NcIconSvgWrapper :path="mdiBug" /> + <NcIconSvgWrapper :path="mdiBugOutline" /> </template> </NcButton> <NcButton :disabled="!app.bugs" @@ -161,7 +170,7 @@ :aria-label="t('settings', 'Request feature')" :title="t('settings', 'Request feature')"> <template #icon> - <NcIconSvgWrapper :path="mdiFeatureSearch" /> + <NcIconSvgWrapper :path="mdiFeatureSearchOutline" /> </template> </NcButton> <NcButton v-if="app.appstoreData?.discussion" @@ -169,7 +178,7 @@ :aria-label="t('settings', 'Ask questions or discuss')" :title="t('settings', 'Ask questions or discuss')"> <template #icon> - <NcIconSvgWrapper :path="mdiTooltipQuestion" /> + <NcIconSvgWrapper :path="mdiTooltipQuestionOutline" /> </template> </NcButton> <NcButton v-if="!app.internal" @@ -182,20 +191,31 @@ </NcButton> </div> </div> + + <AppDeployOptionsModal v-if="app?.app_api" + :show.sync="showDeployOptionsModal" + :app="app" /> + <DaemonSelectionDialog v-if="app?.app_api" + :show.sync="showSelectDaemonModal" + :app="app" + :deploy-options="deployOptions" /> </div> </NcAppSidebarTab> </template> <script> -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import AppDeployOptionsModal from './AppDeployOptionsModal.vue' +import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue' import AppManagement from '../../mixins/AppManagement.js' -import { mdiBug, mdiFeatureSearch, mdiStar, mdiTextBox, mdiTooltipQuestion } from '@mdi/js' +import { mdiBugOutline, mdiFeatureSearchOutline, mdiStar, mdiTextBoxOutline, mdiTooltipQuestionOutline, mdiToyBrickPlusOutline } from '@mdi/js' import { useAppsStore } from '../../store/apps-store' import { useAppApiStore } from '../../store/app-api-store' @@ -209,6 +229,8 @@ export default { NcIconSvgWrapper, NcSelect, NcCheckboxRadioSwitch, + AppDeployOptionsModal, + DaemonSelectionDialog, }, mixins: [AppManagement], @@ -227,11 +249,12 @@ export default { store, appApiStore, - mdiBug, - mdiFeatureSearch, + mdiBugOutline, + mdiFeatureSearchOutline, mdiStar, - mdiTextBox, - mdiTooltipQuestion, + mdiTextBoxOutline, + mdiTooltipQuestionOutline, + mdiToyBrickPlusOutline, } }, @@ -239,6 +262,9 @@ export default { return { groupCheckedAppsData: false, removeData: false, + showDeployOptionsModal: false, + showSelectDaemonModal: false, + deployOptions: null, } }, @@ -348,15 +374,40 @@ export default { this.removeData = false }, }, + beforeUnmount() { + this.deployOptions = null + unsubscribe('showDaemonSelectionModal') + }, mounted() { if (this.app.groups.length > 0) { this.groupCheckedAppsData = true } + subscribe('showDaemonSelectionModal', (deployOptions) => { + this.showSelectionModal(deployOptions) + }) }, methods: { toggleRemoveData() { this.removeData = !this.removeData }, + showSelectionModal(deployOptions = null) { + this.deployOptions = deployOptions + this.showSelectDaemonModal = true + }, + async enableButtonAction() { + if (!this.app?.app_api) { + this.enable(this.app.id) + return + } + await this.appApiStore.fetchDockerDaemons() + if (this.appApiStore.dockerDaemons.length === 1 && this.app.needsDownload) { + this.enable(this.app.id, this.appApiStore.dockerDaemons[0]) + } else if (this.app.needsDownload) { + this.showSelectionModal() + } else { + this.enable(this.app.id, this.app.daemon) + } + }, }, } </script> @@ -370,6 +421,7 @@ export default { &-manage { // if too many, shrink them and ellipsis display: flex; + align-items: center; input { flex: 0 1 auto; min-width: 0; diff --git a/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue b/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue index 68c5cb8b5ff..e65df0341db 100644 --- a/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue +++ b/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue @@ -25,8 +25,8 @@ import { mdiClockFast } from '@mdi/js' import { getLanguage, translate as t } from '@nextcloud/l10n' import { computed } from 'vue' -import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import Markdown from '../Markdown.vue' // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/components/AuthToken.vue index 9e77d8b3749..15286adb135 100644 --- a/apps/settings/src/components/AuthToken.vue +++ b/apps/settings/src/components/AuthToken.vue @@ -80,18 +80,18 @@ import type { PropType } from 'vue' import type { IToken } from '../store/authtoken' -import { mdiCheck, mdiCellphone, mdiTablet, mdiMonitor, mdiWeb, mdiKey, mdiMicrosoftEdge, mdiFirefox, mdiGoogleChrome, mdiAppleSafari, mdiAndroid, mdiAppleIos } from '@mdi/js' +import { mdiCheck, mdiCellphone, mdiTablet, mdiMonitor, mdiWeb, mdiKeyOutline, mdiMicrosoftEdge, mdiFirefox, mdiGoogleChrome, mdiAppleSafari, mdiAndroid, mdiAppleIos } from '@mdi/js' import { translate as t } from '@nextcloud/l10n' import { defineComponent } from 'vue' import { TokenType, useAuthTokenStore } from '../store/authtoken.ts' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcTextField from '@nextcloud/vue/components/NcTextField' // When using capture groups the following parts are extracted the first is used as the version number, the second as the OS const userAgentMap = { @@ -215,7 +215,7 @@ export default defineComponent({ tokenIcon() { // For custom created app tokens / app passwords if (this.token.type === TokenType.PERMANENT_TOKEN) { - return mdiKey + return mdiKeyOutline } switch (this.client?.id) { diff --git a/apps/settings/src/components/AuthTokenSetup.vue b/apps/settings/src/components/AuthTokenSetup.vue index 74dd3c2bbed..b93086c9e88 100644 --- a/apps/settings/src/components/AuthTokenSetup.vue +++ b/apps/settings/src/components/AuthTokenSetup.vue @@ -31,8 +31,8 @@ import { translate as t } from '@nextcloud/l10n' import { defineComponent } from 'vue' import { useAuthTokenStore, type ITokenResponse } from '../store/authtoken' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcTextField from '@nextcloud/vue/components/NcTextField' import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue' import logger from '../logger' diff --git a/apps/settings/src/components/AuthTokenSetupDialog.vue b/apps/settings/src/components/AuthTokenSetupDialog.vue index 439ab030f4a..3b8fac8dc1d 100644 --- a/apps/settings/src/components/AuthTokenSetupDialog.vue +++ b/apps/settings/src/components/AuthTokenSetupDialog.vue @@ -53,10 +53,10 @@ import { getRootUrl } from '@nextcloud/router' import { defineComponent, type PropType } from 'vue' import QR from '@chenfengyuan/vue-qrcode' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcTextField from '@nextcloud/vue/components/NcTextField' import logger from '../logger' diff --git a/apps/settings/src/components/BasicSettings/BackgroundJob.vue b/apps/settings/src/components/BasicSettings/BackgroundJob.vue index 291be810944..a9a3cbb9cef 100644 --- a/apps/settings/src/components/BasicSettings/BackgroundJob.vue +++ b/apps/settings/src/components/BasicSettings/BackgroundJob.vue @@ -69,9 +69,9 @@ import { confirmPassword } from '@nextcloud/password-confirmation' import axios from '@nextcloud/axios' import moment from '@nextcloud/moment' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import '@nextcloud/password-confirmation/dist/style.css' diff --git a/apps/settings/src/components/BasicSettings/ProfileSettings.vue b/apps/settings/src/components/BasicSettings/ProfileSettings.vue index 910a02ab454..276448cd97b 100644 --- a/apps/settings/src/components/BasicSettings/ProfileSettings.vue +++ b/apps/settings/src/components/BasicSettings/ProfileSettings.vue @@ -30,7 +30,7 @@ import { saveProfileDefault } from '../../service/ProfileService.js' import { validateBoolean } from '../../utils/validate.js' import logger from '../../logger.ts' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' const profileEnabledByDefault = loadState('settings', 'profileEnabledByDefault', true) diff --git a/apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue b/apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue index 1c739a54412..9ee1680516e 100644 --- a/apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue +++ b/apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue @@ -115,10 +115,11 @@ import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' import { showError } from '@nextcloud/dialogs' import debounce from 'debounce' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import { confirmPassword } from '@nextcloud/password-confirmation' export default { name: 'DeclarativeSection', @@ -202,9 +203,19 @@ export default { } }, - updateDeclarativeSettingsValue(formField, value = null) { + async updateDeclarativeSettingsValue(formField, value = null) { try { - return axios.post(generateOcsUrl('settings/api/declarative/value'), { + let url = generateOcsUrl('settings/api/declarative/value') + if (formField?.sensitive === true) { + url = generateOcsUrl('settings/api/declarative/value-sensitive') + try { + await confirmPassword() + } catch (err) { + showError(t('settings', 'Password confirmation is required')) + return + } + } + return axios.post(url, { app: this.formApp, formId: this.form.id.replace(this.formApp + '_', ''), // Remove app prefix to send clean form id fieldId: formField.id, diff --git a/apps/settings/src/components/Encryption.vue b/apps/settings/src/components/Encryption.vue deleted file mode 100644 index 4d80f9b9833..00000000000 --- a/apps/settings/src/components/Encryption.vue +++ /dev/null @@ -1,189 +0,0 @@ -<!-- - - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - - SPDX-License-Identifier: AGPL-3.0-or-later ---> - -<template> - <NcSettingsSection :name="t('settings', 'Server-side encryption')" - :description="t('settings', 'Server-side encryption makes it possible to encrypt files which are uploaded to this server. This comes with limitations like a performance penalty, so enable this only if needed.')" - :doc-url="encryptionAdminDoc"> - <NcCheckboxRadioSwitch :checked="encryptionEnabled || shouldDisplayWarning" - :disabled="encryptionEnabled" - type="switch" - @update:checked="displayWarning"> - {{ t('settings', 'Enable server-side encryption') }} - </NcCheckboxRadioSwitch> - - <div v-if="shouldDisplayWarning && !encryptionEnabled" class="notecard warning" role="alert"> - <p>{{ t('settings', 'Please read carefully before activating server-side encryption:') }}</p> - <ul> - <li>{{ t('settings', 'Once encryption is enabled, all files uploaded to the server from that point forward will be encrypted at rest on the server. It will only be possible to disable encryption at a later date if the active encryption module supports that function, and all pre-conditions (e.g. setting a recover key) are met.') }}</li> - <li>{{ t('settings', 'Encryption alone does not guarantee security of the system. Please see documentation for more information about how the encryption app works, and the supported use cases.') }}</li> - <li>{{ t('settings', 'Be aware that encryption always increases the file size.') }}</li> - <li>{{ t('settings', 'It is always good to create regular backups of your data, in case of encryption make sure to backup the encryption keys along with your data.') }}</li> - </ul> - - <p class="margin-bottom"> - {{ t('settings', 'This is the final warning: Do you really want to enable encryption?') }} - </p> - <NcButton type="primary" - @click="enableEncryption()"> - {{ t('settings', "Enable encryption") }} - </NcButton> - </div> - - <div v-if="encryptionEnabled"> - <div v-if="encryptionReady"> - <p v-if="encryptionModules.length === 0"> - {{ t('settings', 'No encryption module loaded, please enable an encryption module in the app menu.') }} - </p> - <template v-else> - <h3>{{ t('settings', 'Select default encryption module:') }}</h3> - <fieldset> - <NcCheckboxRadioSwitch v-for="(module, id) in encryptionModules" - :key="id" - :checked.sync="defaultCheckedModule" - :value="id" - type="radio" - name="default_encryption_module" - @update:checked="checkDefaultModule"> - {{ module.displayName }} - </NcCheckboxRadioSwitch> - </fieldset> - </template> - </div> - - <div v-else-if="externalBackendsEnabled"> - {{ - t( - 'settings', - 'You need to migrate your encryption keys from the old encryption (ownCloud <= 8.0) to the new one. Please enable the "Default encryption module" and run {command}', - { command: '"occ encryption:migrate"' }, - ) - }} - </div> - </div> - </NcSettingsSection> -</template> - -<script> -import { showError } from '@nextcloud/dialogs' -import { loadState } from '@nextcloud/initial-state' -import { generateOcsUrl } from '@nextcloud/router' -import { confirmPassword } from '@nextcloud/password-confirmation' -import axios from '@nextcloud/axios' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' - -import logger from '../logger' - -import '@nextcloud/password-confirmation/dist/style.css' - -export default { - name: 'Encryption', - components: { - NcCheckboxRadioSwitch, - NcSettingsSection, - NcButton, - }, - data() { - const encryptionModules = loadState('settings', 'encryption-modules') - return { - encryptionReady: loadState('settings', 'encryption-ready'), - encryptionEnabled: loadState('settings', 'encryption-enabled'), - externalBackendsEnabled: loadState('settings', 'external-backends-enabled'), - encryptionAdminDoc: loadState('settings', 'encryption-admin-doc'), - encryptionModules, - shouldDisplayWarning: false, - migrating: false, - defaultCheckedModule: Object.entries(encryptionModules).find((module) => module[1].default)[0], - } - }, - methods: { - displayWarning() { - if (!this.encryptionEnabled) { - this.shouldDisplayWarning = !this.shouldDisplayWarning - } else { - this.encryptionEnabled = false - this.shouldDisplayWarning = false - } - }, - async update(key, value) { - await confirmPassword() - - const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', { - appId: 'core', - key, - }) - - try { - const { data } = await axios.post(url, { - value, - }) - this.handleResponse({ - status: data.ocs?.meta?.status, - }) - } catch (e) { - this.handleResponse({ - errorMessage: t('settings', 'Unable to update server side encryption config'), - error: e, - }) - } - }, - async checkDefaultModule() { - await this.update('default_encryption_module', this.defaultCheckedModule) - }, - async enableEncryption() { - this.encryptionEnabled = true - await this.update('encryption_enabled', 'yes') - }, - async handleResponse({ status, errorMessage, error }) { - if (status !== 'ok') { - showError(errorMessage) - logger.error(errorMessage, { error }) - } - }, - }, -} -</script> - -<style lang="scss" scoped> - -.notecard.success { - --note-background: rgba(var(--color-success-rgb), 0.2); - --note-theme: var(--color-success); -} - -.notecard.error { - --note-background: rgba(var(--color-error-rgb), 0.2); - --note-theme: var(--color-error); -} - -.notecard.warning { - --note-background: rgba(var(--color-warning-rgb), 0.2); - --note-theme: var(--color-warning); -} - -#body-settings .notecard { - color: var(--color-text-light); - background-color: var(--note-background); - border: 1px solid var(--color-border); - border-inline-start: 4px solid var(--note-theme); - border-radius: var(--border-radius); - box-shadow: rgba(43, 42, 51, 0.05) 0px 1px 2px 0px; - margin: 1rem 0; - margin-top: 1rem; - padding: 1rem; -} - -li { - list-style-type: initial; - margin-inline-start: 1rem; - padding: 0.25rem 0; -} - -.margin-bottom { - margin-bottom: 0.75rem; -} -</style> diff --git a/apps/settings/src/components/Encryption/EncryptionSettings.vue b/apps/settings/src/components/Encryption/EncryptionSettings.vue new file mode 100644 index 00000000000..f4db63ce53c --- /dev/null +++ b/apps/settings/src/components/Encryption/EncryptionSettings.vue @@ -0,0 +1,197 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { OCSResponse } from '@nextcloud/typings/ocs' +import { showError, spawnDialog } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { confirmPassword } from '@nextcloud/password-confirmation' +import { generateOcsUrl } from '@nextcloud/router' +import { ref } from 'vue' +import { textExistingFilesNotEncrypted } from './sharedTexts.ts' + +import axios from '@nextcloud/axios' +import logger from '../../logger.ts' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import EncryptionWarningDialog from './EncryptionWarningDialog.vue' + +interface EncryptionModule { + default?: boolean + displayName: string +} + +const allEncryptionModules = loadState<never[]|Record<string, EncryptionModule>>('settings', 'encryption-modules') +/** Available encryption modules on the backend */ +const encryptionModules = Array.isArray(allEncryptionModules) ? [] : Object.entries(allEncryptionModules).map(([id, module]) => ({ ...module, id })) +/** ID of the default encryption module */ +const defaultCheckedModule = encryptionModules.find((module) => module.default)?.id + +/** Is the server side encryptio ready to be enabled */ +const encryptionReady = loadState<boolean>('settings', 'encryption-ready') +/** Are external backends enabled (legacy ownCloud stuff) */ +const externalBackendsEnabled = loadState<boolean>('settings', 'external-backends-enabled') +/** URL to the admin docs */ +const encryptionAdminDoc = loadState<string>('settings', 'encryption-admin-doc') + +/** Is the encryption enabled */ +const encryptionEnabled = ref(loadState<boolean>('settings', 'encryption-enabled')) + +/** Loading state while enabling encryption (e.g. because the confirmation dialog is open) */ +const loadingEncryptionState = ref(false) + +/** + * Open the encryption-enabling warning (spawns a dialog) + * @param enabled The enabled state of encryption + */ +function displayWarning(enabled: boolean) { + if (loadingEncryptionState.value || enabled === false) { + return + } + + loadingEncryptionState.value = true + spawnDialog(EncryptionWarningDialog, {}, async (confirmed) => { + try { + if (confirmed) { + await enableEncryption() + } + } finally { + loadingEncryptionState.value = false + } + }) +} + +/** + * Update an encryption setting on the backend + * @param key The setting to update + * @param value The new value + */ +async function update(key: string, value: string) { + await confirmPassword() + + const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', { + appId: 'core', + key, + }) + + try { + const { data } = await axios.post<OCSResponse>(url, { + value, + }) + if (data.ocs.meta.status !== 'ok') { + throw new Error('Unsuccessful OCS response', { cause: data.ocs }) + } + } catch (error) { + showError(t('settings', 'Unable to update server side encryption config')) + logger.error('Unable to update server side encryption config', { error }) + return false + } + return true +} + +/** + * Choose the default encryption module + */ +async function checkDefaultModule(): Promise<void> { + if (defaultCheckedModule) { + await update('default_encryption_module', defaultCheckedModule) + } +} + +/** + * Enable encryption - sends an async POST request + */ +async function enableEncryption(): Promise<void> { + encryptionEnabled.value = await update('encryption_enabled', 'yes') +} +</script> + +<template> + <NcSettingsSection :name="t('settings', 'Server-side encryption')" + :description="t('settings', 'Server-side encryption makes it possible to encrypt files which are uploaded to this server. This comes with limitations like a performance penalty, so enable this only if needed.')" + :doc-url="encryptionAdminDoc"> + <NcNoteCard v-if="encryptionEnabled" type="info"> + <p> + {{ textExistingFilesNotEncrypted }} + {{ t('settings', 'To encrypt all existing files run this OCC command:') }} + </p> + <code> + <pre>occ encryption:encrypt-all</pre> + </code> + </NcNoteCard> + + <NcCheckboxRadioSwitch :class="{ disabled: encryptionEnabled }" + :checked="encryptionEnabled" + :aria-disabled="encryptionEnabled ? 'true' : undefined" + :aria-describedby="encryptionEnabled ? 'server-side-encryption-disable-hint' : undefined" + :loading="loadingEncryptionState" + type="switch" + @update:checked="displayWarning"> + {{ t('settings', 'Enable server-side encryption') }} + </NcCheckboxRadioSwitch> + <p v-if="encryptionEnabled" id="server-side-encryption-disable-hint" class="disable-hint"> + {{ t('settings', 'Disabling server side encryption is only possible using OCC, please refer to the documentation.') }} + </p> + + <NcNoteCard v-if="encryptionModules.length === 0" + type="warning" + :text="t('settings', 'No encryption module loaded, please enable an encryption module in the app menu.')" /> + + <template v-else-if="encryptionEnabled"> + <div v-if="encryptionReady && encryptionModules.length > 0"> + <h3>{{ t('settings', 'Select default encryption module:') }}</h3> + <fieldset> + <NcCheckboxRadioSwitch v-for="module in encryptionModules" + :key="module.id" + :checked.sync="defaultCheckedModule" + :value="module.id" + type="radio" + name="default_encryption_module" + @update:checked="checkDefaultModule"> + {{ module.displayName }} + </NcCheckboxRadioSwitch> + </fieldset> + </div> + + <div v-else-if="externalBackendsEnabled"> + {{ + t( + 'settings', + 'You need to migrate your encryption keys from the old encryption (ownCloud <= 8.0) to the new one. Please enable the "Default encryption module" and run {command}', + { command: '"occ encryption:migrate"' }, + ) + }} + </div> + </template> + </NcSettingsSection> +</template> + +<style scoped> +code { + background-color: var(--color-background-dark); + color: var(--color-main-text); + + display: block; + margin-block-start: 0.5rem; + padding: .25lh .5lh; + width: fit-content; +} + +.disabled { + opacity: .75; +} + +.disabled :deep(*) { + cursor: not-allowed !important; +} + +.disable-hint { + color: var(--color-text-maxcontrast); + padding-inline-start: 10px; +} +</style> diff --git a/apps/settings/src/components/Encryption/EncryptionWarningDialog.vue b/apps/settings/src/components/Encryption/EncryptionWarningDialog.vue new file mode 100644 index 00000000000..f229544a7d9 --- /dev/null +++ b/apps/settings/src/components/Encryption/EncryptionWarningDialog.vue @@ -0,0 +1,91 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<script setup lang="ts"> +import type { IDialogButton } from '@nextcloud/dialogs' + +import { t } from '@nextcloud/l10n' +import { textExistingFilesNotEncrypted } from './sharedTexts.ts' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +const emit = defineEmits<{ + (e: 'close', encrypt: boolean): void +}>() + +const buttons: IDialogButton[] = [ + { + label: t('settings', 'Cancel encryption'), + // @ts-expect-error Needs to be fixed in the dialogs library - value is allowed but missing from the types + type: 'tertiary', + callback: () => emit('close', false), + }, + { + label: t('settings', 'Enable encryption'), + type: 'error', + callback: () => emit('close', true), + }, +] + +/** + * When closed we need to emit the close event + * @param isOpen open state of the dialog + */ +function onUpdateOpen(isOpen: boolean) { + if (!isOpen) { + emit('close', false) + } +} +</script> + +<template> + <NcDialog :buttons="buttons" + :name="t('settings', 'Confirm enabling encryption')" + size="normal" + @update:open="onUpdateOpen"> + <NcNoteCard type="warning"> + <p> + {{ t('settings', 'Please read carefully before activating server-side encryption:') }} + <ul> + <li> + {{ t('settings', 'Once encryption is enabled, all files uploaded to the server from that point forward will be encrypted at rest on the server. It will only be possible to disable encryption at a later date if the active encryption module supports that function, and all pre-conditions (e.g. setting a recover key) are met.') }} + </li> + <li> + {{ t('settings', 'By default a master key for the whole instance will be generated. Please check if that level of access is compliant with your needs.') }} + </li> + <li> + {{ t('settings', 'Encryption alone does not guarantee security of the system. Please see documentation for more information about how the encryption app works, and the supported use cases.') }} + </li> + <li> + {{ t('settings', 'Be aware that encryption always increases the file size.') }} + </li> + <li> + {{ t('settings', 'It is always good to create regular backups of your data, in case of encryption make sure to backup the encryption keys along with your data.') }} + </li> + <li> + {{ textExistingFilesNotEncrypted }} + {{ t('settings', 'Refer to the admin documentation on how to manually also encrypt existing files.') }} + </li> + </ul> + </p> + </NcNoteCard> + <p> + {{ t('settings', 'This is the final warning: Do you really want to enable encryption?') }} + </p> + </NcDialog> +</template> + +<style scoped> +li { + list-style-type: initial; + margin-inline-start: 1rem; + padding: 0.25rem 0; +} + +p + p, +div + p { + margin-block: 0.75rem; +} +</style> diff --git a/apps/settings/src/components/Encryption/sharedTexts.ts b/apps/settings/src/components/Encryption/sharedTexts.ts new file mode 100644 index 00000000000..94d23be07f2 --- /dev/null +++ b/apps/settings/src/components/Encryption/sharedTexts.ts @@ -0,0 +1,7 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { t } from '@nextcloud/l10n' + +export const textExistingFilesNotEncrypted = t('settings', 'For performance reasons, when you enable encryption on a Nextcloud server only new and changed files are encrypted.') diff --git a/apps/settings/src/components/GroupListItem.vue b/apps/settings/src/components/GroupListItem.vue index 44b0605c9de..69bb8a3f575 100644 --- a/apps/settings/src/components/GroupListItem.vue +++ b/apps/settings/src/components/GroupListItem.vue @@ -13,7 +13,7 @@ </h2> <NcNoteCard type="warning" show-alert> - {{ t('settings', 'You are about to remove the group "{group}". The accounts will NOT be deleted.', { group: name }) }} + {{ t('settings', 'You are about to delete the group "{group}". The accounts will NOT be deleted.', { group: name }) }} </NcNoteCard> <div class="modal__button-row"> <NcButton type="secondary" @@ -29,6 +29,7 @@ </NcModal> <NcAppNavigationItem :key="id" + ref="listItem" :exact="true" :name="name" :to="{ name: 'group', params: { selectedGroup: encodeURIComponent(id) } }" @@ -61,7 +62,7 @@ <template #icon> <Delete :size="20" /> </template> - {{ t('settings', 'Remove group') }} + {{ t('settings', 'Delete group') }} </NcActionButton> </template> </NcAppNavigationItem> @@ -71,17 +72,17 @@ <script> import { Fragment } from 'vue-frag' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js' -import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js' -import NcModal from '@nextcloud/vue/dist/Components/NcModal.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionInput from '@nextcloud/vue/components/NcActionInput' +import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble' +import NcModal from '@nextcloud/vue/components/NcModal' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' -import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' -import Delete from 'vue-material-design-icons/Delete.vue' -import Pencil from 'vue-material-design-icons/Pencil.vue' +import AccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue' +import Delete from 'vue-material-design-icons/DeleteOutline.vue' +import Pencil from 'vue-material-design-icons/PencilOutline.vue' import { showError } from '@nextcloud/dialogs' @@ -178,7 +179,7 @@ export default { await this.$store.dispatch('removeGroup', this.id) this.showRemoveGroupModal = false } catch (error) { - showError(t('settings', 'Failed to remove group "{group}"', { group: this.name })) + showError(t('settings', 'Failed to delete group "{group}"', { group: this.name })) } }, }, diff --git a/apps/settings/src/components/PasswordSection.vue b/apps/settings/src/components/PasswordSection.vue index 717be494e36..44845c51ff4 100644 --- a/apps/settings/src/components/PasswordSection.vue +++ b/apps/settings/src/components/PasswordSection.vue @@ -32,9 +32,9 @@ </template> <script> -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { showSuccess, showError } from '@nextcloud/dialogs' diff --git a/apps/settings/src/components/PersonalInfo/AvatarSection.vue b/apps/settings/src/components/PersonalInfo/AvatarSection.vue index b75efaf6fb3..a99f228668c 100644 --- a/apps/settings/src/components/PersonalInfo/AvatarSection.vue +++ b/apps/settings/src/components/PersonalInfo/AvatarSection.vue @@ -80,15 +80,15 @@ import { getCurrentUser } from '@nextcloud/auth' import { getFilePickerBuilder, showError } from '@nextcloud/dialogs' import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' import VueCropper from 'vue-cropperjs' // eslint-disable-next-line n/no-extraneous-import import 'cropperjs/dist/cropper.css' import Upload from 'vue-material-design-icons/Upload.vue' import Folder from 'vue-material-design-icons/Folder.vue' -import Delete from 'vue-material-design-icons/Delete.vue' +import Delete from 'vue-material-design-icons/DeleteOutline.vue' import HeaderBar from './shared/HeaderBar.vue' import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' diff --git a/apps/settings/src/components/PersonalInfo/BiographySection.vue b/apps/settings/src/components/PersonalInfo/BiographySection.vue index 32af1a03e2d..bbfb25e25cc 100644 --- a/apps/settings/src/components/PersonalInfo/BiographySection.vue +++ b/apps/settings/src/components/PersonalInfo/BiographySection.vue @@ -5,7 +5,7 @@ <template> <AccountPropertySection v-bind.sync="biography" - :placeholder="t('settings', 'Your biography')" + :placeholder="t('settings', 'Your biography. Markdown is supported.')" :multi-line="true" /> </template> diff --git a/apps/settings/src/components/PersonalInfo/BirthdaySection.vue b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue index f5171d388b7..f55f09c95e5 100644 --- a/apps/settings/src/components/PersonalInfo/BirthdaySection.vue +++ b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue @@ -28,7 +28,7 @@ import { handleError } from '../../utils/handlers' import debounce from 'debounce' -import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js' +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' import HeaderBar from './shared/HeaderBar.vue' const { birthdate } = loadState('settings', 'personalInfoParameters', {}) diff --git a/apps/settings/src/components/PersonalInfo/BlueskySection.vue b/apps/settings/src/components/PersonalInfo/BlueskySection.vue new file mode 100644 index 00000000000..65223d1ab53 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/BlueskySection.vue @@ -0,0 +1,64 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="value" + :readable="readable" + :on-validate="onValidate" + :placeholder="t('settings', 'Bluesky handle')" /> +</template> + +<script setup lang="ts"> +import type { AccountProperties } from '../../constants/AccountPropertyConstants.js' + +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts' +import AccountPropertySection from './shared/AccountPropertySection.vue' + +const { bluesky } = loadState<AccountProperties>('settings', 'personalInfoParameters') + +const value = ref({ ...bluesky }) +const readable = NAME_READABLE_ENUM[bluesky.name] + +/** + * Validate that the text might be a bluesky handle + * @param text The potential bluesky handle + */ +function onValidate(text: string): boolean { + if (text === '') return true + + const lowerText = text.toLowerCase() + + if (lowerText === 'bsky.social') { + // Standalone bsky.social is invalid + return false + } + + if (lowerText.endsWith('.bsky.social')) { + // Enforce format: exactly one label + '.bsky.social' + const parts = lowerText.split('.') + + // Must be in form: [username, 'bsky', 'social'] + if (parts.length !== 3 || parts[1] !== 'bsky' || parts[2] !== 'social') { + return false + } + + const username = parts[0] + const validateRegex = /^[a-z0-9][a-z0-9-]{2,17}$/ + return validateRegex.test(username) + } + + // Else, treat as a custom domain + try { + const url = new URL(`https://${text}`) + // Ensure the parsed host matches exactly (case-insensitive already) + return url.host === lowerText + } catch { + return false + } +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/DetailsSection.vue b/apps/settings/src/components/PersonalInfo/DetailsSection.vue index a5de95f7ee5..d4bb0ce16ec 100644 --- a/apps/settings/src/components/PersonalInfo/DetailsSection.vue +++ b/apps/settings/src/components/PersonalInfo/DetailsSection.vue @@ -35,8 +35,8 @@ import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' -import Account from 'vue-material-design-icons/Account.vue' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' +import Account from 'vue-material-design-icons/AccountOutline.vue' import CircleSlice from 'vue-material-design-icons/CircleSlice3.vue' import HeaderBar from './shared/HeaderBar.vue' diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue index ad9835baed1..6a6baef8817 100644 --- a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue +++ b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue @@ -48,7 +48,7 @@ :disabled="deleteDisabled" @click="deleteEmail"> <template #icon> - <NcIconSvgWrapper :path="mdiTrashCan" /> + <NcIconSvgWrapper :path="mdiTrashCanOutline" /> </template> {{ deleteEmailLabel }} </NcActionButton> @@ -64,14 +64,14 @@ </template> <script> -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcTextField from '@nextcloud/vue/components/NcTextField' import debounce from 'debounce' -import { mdiArrowLeft, mdiLock, mdiStar, mdiStarOutline, mdiTrashCan } from '@mdi/js' +import { mdiArrowLeft, mdiLockOutline, mdiStar, mdiStarOutline, mdiTrashCanOutline } from '@mdi/js' import FederationControl from '../shared/FederationControl.vue' import { handleError } from '../../../utils/handlers.ts' @@ -133,10 +133,10 @@ export default { setup() { return { mdiArrowLeft, - mdiLock, + mdiLockOutline, mdiStar, mdiStarOutline, - mdiTrashCan, + mdiTrashCanOutline, saveAdditionalEmailScope, } }, diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue index 8fd17922724..f9674a3163b 100644 --- a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue +++ b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue @@ -13,7 +13,7 @@ :scope.sync="primaryEmail.scope" @add-additional="onAddAdditionalEmail" /> - <template v-if="displayNameChangeSupported"> + <template v-if="emailChangeSupported"> <Email :input-id="inputId" :primary="true" :scope.sync="primaryEmail.scope" @@ -56,7 +56,7 @@ import { validateEmail } from '../../../utils/validate.js' import { handleError } from '../../../utils/handlers.ts' const { emailMap: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {}) -const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {}) +const { emailChangeSupported } = loadState('settings', 'accountParameters', {}) export default { name: 'EmailSection', @@ -70,7 +70,7 @@ export default { return { accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, additionalEmails: additionalEmails.map(properties => ({ ...properties, key: this.generateUniqueKey() })), - displayNameChangeSupported, + emailChangeSupported, primaryEmail: { ...primaryEmail, readable: NAME_READABLE_ENUM[primaryEmail.name] }, notificationEmail, } diff --git a/apps/settings/src/components/PersonalInfo/FediverseSection.vue b/apps/settings/src/components/PersonalInfo/FediverseSection.vue index 9ba9c37ab80..043fa6e64b9 100644 --- a/apps/settings/src/components/PersonalInfo/FediverseSection.vue +++ b/apps/settings/src/components/PersonalInfo/FediverseSection.vue @@ -4,30 +4,47 @@ --> <template> - <AccountPropertySection v-bind.sync="fediverse" + <AccountPropertySection v-bind.sync="value" + :readable="readable" + :on-validate="onValidate" :placeholder="t('settings', 'Your handle')" /> </template> -<script> +<script setup lang="ts"> +import type { AccountProperties } from '../../constants/AccountPropertyConstants.js' import { loadState } from '@nextcloud/initial-state' - -import AccountPropertySection from './shared/AccountPropertySection.vue' - +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' -const { fediverse } = loadState('settings', 'personalInfoParameters', {}) - -export default { - name: 'FediverseSection', - - components: { - AccountPropertySection, - }, +import AccountPropertySection from './shared/AccountPropertySection.vue' - data() { - return { - fediverse: { ...fediverse, readable: NAME_READABLE_ENUM[fediverse.name] }, - } - }, +const { fediverse } = loadState<AccountProperties>('settings', 'personalInfoParameters') + +const value = ref({ ...fediverse }) +const readable = NAME_READABLE_ENUM[fediverse.name] + +/** + * Validate a fediverse handle + * @param text The potential fediverse handle + */ +function onValidate(text: string): boolean { + // allow to clear the value + if (text === '') { + return true + } + + // check its in valid format + const result = text.match(/^@?([^@/]+)@([^@/]+)$/) + if (result === null) { + return false + } + + // check its a valid URL + try { + return URL.parse(`https://${result[2]}/`) !== null + } catch { + return false + } } </script> diff --git a/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue b/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue index 6a261772dc1..98501db7ccc 100644 --- a/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue +++ b/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue @@ -22,7 +22,7 @@ <script lang="ts"> import HeaderBar from './shared/HeaderBar.vue' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' import { ACCOUNT_SETTING_PROPERTY_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue index 3e5b9b67bf5..8f42b2771c0 100644 --- a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue +++ b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue @@ -29,7 +29,7 @@ import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/Person import { validateLanguage } from '../../../utils/validate.js' import { handleError } from '../../../utils/handlers.ts' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' export default { name: 'Language', diff --git a/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue b/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue index 311aa697adb..73300756472 100644 --- a/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue +++ b/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue @@ -32,7 +32,7 @@ <script> import moment from '@nextcloud/moment' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' import MapClock from 'vue-material-design-icons/MapClock.vue' import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js' diff --git a/apps/settings/src/components/PersonalInfo/PhoneSection.vue b/apps/settings/src/components/PersonalInfo/PhoneSection.vue index 13ac7fdca0f..8ddeada960e 100644 --- a/apps/settings/src/components/PersonalInfo/PhoneSection.vue +++ b/apps/settings/src/components/PersonalInfo/PhoneSection.vue @@ -39,6 +39,10 @@ export default { methods: { onValidate(value) { + if (value === '') { + return true + } + if (defaultPhoneRegion) { return isValidPhoneNumber(value, defaultPhoneRegion) } diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue index b9d8b1276eb..6eb7cf8c34c 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue @@ -19,7 +19,7 @@ import { emit } from '@nextcloud/event-bus' import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js' import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import { handleError } from '../../../utils/handlers.ts' export default { diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue index 58a3d609866..47894f64f34 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue @@ -27,7 +27,7 @@ import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' export default { name: 'ProfilePreviewCard', diff --git a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue index 15a98ca528f..aaa13e63e92 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue @@ -23,7 +23,7 @@ import { loadState } from '@nextcloud/initial-state' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' import { saveProfileParameterVisibility } from '../../../service/ProfileService.js' import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants.js' diff --git a/apps/settings/src/components/PersonalInfo/PronounsSection.vue b/apps/settings/src/components/PersonalInfo/PronounsSection.vue index fb35b1800c5..e345cb8e225 100644 --- a/apps/settings/src/components/PersonalInfo/PronounsSection.vue +++ b/apps/settings/src/components/PersonalInfo/PronounsSection.vue @@ -8,16 +8,18 @@ :placeholder="randomPronounsPlaceholder" /> </template> -<script> -import { loadState } from '@nextcloud/initial-state' +<script lang="ts"> +import type { IAccountProperty } from '../../constants/AccountPropertyConstants.ts' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' import AccountPropertySection from './shared/AccountPropertySection.vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts' -import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' - -const { pronouns } = loadState('settings', 'personalInfoParameters', {}) +const { pronouns } = loadState<{ pronouns: IAccountProperty }>('settings', 'personalInfoParameters') -export default { +export default defineComponent({ name: 'PronounsSection', components: { @@ -33,13 +35,13 @@ export default { computed: { randomPronounsPlaceholder() { const pronouns = [ - this.t('settings', 'she/her'), - this.t('settings', 'he/him'), - this.t('settings', 'they/them'), + t('settings', 'she/her'), + t('settings', 'he/him'), + t('settings', 'they/them'), ] const pronounsExample = pronouns[Math.floor(Math.random() * pronouns.length)] - return this.t('settings', `Your pronouns. E.g. ${pronounsExample}`, { pronounsExample }) + return t('settings', 'Your pronouns. E.g. {pronounsExample}', { pronounsExample }) }, }, -} +}) </script> diff --git a/apps/settings/src/components/PersonalInfo/TwitterSection.vue b/apps/settings/src/components/PersonalInfo/TwitterSection.vue index bb809c8d2b7..43d08f81e3f 100644 --- a/apps/settings/src/components/PersonalInfo/TwitterSection.vue +++ b/apps/settings/src/components/PersonalInfo/TwitterSection.vue @@ -4,30 +4,31 @@ --> <template> - <AccountPropertySection v-bind.sync="twitter" + <AccountPropertySection v-bind.sync="value" + :readable="readable" + :on-validate="onValidate" :placeholder="t('settings', 'Your X (formerly Twitter) handle')" /> </template> -<script> -import { loadState } from '@nextcloud/initial-state' +<script setup lang="ts"> +import type { AccountProperties } from '../../constants/AccountPropertyConstants.js' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts' import AccountPropertySection from './shared/AccountPropertySection.vue' -import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' - -const { twitter } = loadState('settings', 'personalInfoParameters', {}) - -export default { - name: 'TwitterSection', +const { twitter } = loadState<AccountProperties>('settings', 'personalInfoParameters') - components: { - AccountPropertySection, - }, +const value = ref({ ...twitter }) +const readable = NAME_READABLE_ENUM[twitter.name] - data() { - return { - twitter: { ...twitter, readable: NAME_READABLE_ENUM[twitter.name] }, - } - }, +/** + * Validate that the text might be a twitter handle + * @param text The potential twitter handle + */ +function onValidate(text: string): boolean { + return text === '' || text.match(/^@?([a-zA-Z0-9_]{2,15})$/) !== null } </script> diff --git a/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue index bd1d18bdd00..d039641ec72 100644 --- a/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue +++ b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue @@ -46,8 +46,8 @@ <script> import debounce from 'debounce' -import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' -import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' import HeaderBar from './HeaderBar.vue' @@ -155,6 +155,7 @@ export default { methods: { async updateProperty(value) { try { + this.hasError = false const responseData = await savePrimaryAccountProperty( this.name, value, @@ -180,10 +181,8 @@ export default { this.isSuccess = true setTimeout(() => { this.isSuccess = false }, 2000) } else { - this.$emit('update:value', this.initialValue) handleError(error, errorMessage) this.hasError = true - setTimeout(() => { this.hasError = false }, 2000) } }, }, diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue index 29498b6e14b..e55a50056d3 100644 --- a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue +++ b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue @@ -30,9 +30,9 @@ </template> <script> -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { loadState } from '@nextcloud/initial-state' import { diff --git a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue index db26bc945fc..7c95c2b8f4c 100644 --- a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue +++ b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue @@ -36,7 +36,7 @@ </template> <script> -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcButton from '@nextcloud/vue/components/NcButton' import Plus from 'vue-material-design-icons/Plus.vue' import FederationControl from './FederationControl.vue' diff --git a/apps/settings/src/components/SelectSharingPermissions.vue b/apps/settings/src/components/SelectSharingPermissions.vue index 212884abcde..ef24bcda026 100644 --- a/apps/settings/src/components/SelectSharingPermissions.vue +++ b/apps/settings/src/components/SelectSharingPermissions.vue @@ -21,8 +21,8 @@ <script lang="ts"> import { translate } from '@nextcloud/l10n' -import { NcCheckboxRadioSwitch } from '@nextcloud/vue' import { defineComponent } from 'vue' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' export default defineComponent({ name: 'SelectSharingPermissions', diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue index f6f694f1a0e..459548fad26 100644 --- a/apps/settings/src/components/UserList.vue +++ b/apps/settings/src/components/UserList.vue @@ -19,7 +19,7 @@ <NcLoadingIcon v-if="isInitialLoad && loading.users" :name="t('settings', 'Loading accounts …')" :size="64" /> - <NcIconSvgWrapper v-else :path="mdiAccountGroup" :size="64" /> + <NcIconSvgWrapper v-else :path="mdiAccountGroupOutline" :size="64" /> </template> </NcEmptyContent> @@ -34,8 +34,6 @@ users, settings, hasObfuscated, - groups, - subAdminsGroups, quotaOptions, languages, externalActions, @@ -60,15 +58,15 @@ </template> <script> -import { mdiAccountGroup } from '@mdi/js' +import { mdiAccountGroupOutline } from '@mdi/js' import { showError } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' import { Fragment } from 'vue-frag' import Vue from 'vue' -import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import VirtualList from './Users/VirtualList.vue' import NewUserDialog from './Users/NewUserDialog.vue' @@ -122,7 +120,7 @@ export default { setup() { // non reactive properties return { - mdiAccountGroup, + mdiAccountGroupOutline, rowHeight: 55, UserRow, @@ -173,15 +171,8 @@ export default { }, groups() { - // data provided php side + remove the recent and disabled groups - return this.$store.getters.getGroups + return this.$store.getters.getSortedGroups .filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') - .sort((a, b) => a.name.localeCompare(b.name)) - }, - - subAdminsGroups() { - // data provided php side - return this.$store.getters.getSubadminGroups }, quotaOptions() { @@ -359,11 +350,13 @@ export default { setNewUserDefaultGroup(value) { // Is no value set, but user is a line manager we set their group as this is a requirement for line manager if (!value && !this.settings.isAdmin && !this.settings.isDelegatedAdmin) { + const groups = this.$store.getters.getSubAdminGroups // if there are multiple groups we do not know which to add, // so we cannot make the managers life easier by preselecting it. - if (this.groups.length === 1) { - value = this.groups[0].id + if (groups.length === 1) { + this.newUser.groups = [...groups] } + return } if (value) { diff --git a/apps/settings/src/components/Users/NewUserDialog.vue b/apps/settings/src/components/Users/NewUserDialog.vue index 26e4afbd924..ef401b565fa 100644 --- a/apps/settings/src/components/Users/NewUserDialog.vue +++ b/apps/settings/src/components/Users/NewUserDialog.vue @@ -61,32 +61,36 @@ :required="newUser.password === '' || settings.newUserRequireEmail" /> <div class="dialog__item"> <NcSelect class="dialog__select" + data-test="groups" :input-label="!settings.isAdmin && !settings.isDelegatedAdmin ? t('settings', 'Member of the following groups (required)') : t('settings', 'Member of the following groups')" :placeholder="t('settings', 'Set account groups')" :disabled="loading.groups || loading.all" - :options="canAddGroups" + :options="availableGroups" :value="newUser.groups" label="name" :close-on-select="false" :multiple="true" - :taggable="true" + :taggable="settings.isAdmin || settings.isDelegatedAdmin" :required="!settings.isAdmin && !settings.isDelegatedAdmin" - @input="handleGroupInput" - @option:created="createGroup" /> + :create-option="(value) => ({ id: value, name: value, isCreating: true })" + @search="searchGroups" + @option:created="createGroup" + @option:selected="options => addGroup(options.at(-1))" /> <!-- If user is not admin, they are a subadmin. Subadmins can't create users outside their groups Therefore, empty select is forbidden --> </div> - <div v-if="subAdminsGroups.length > 0" - class="dialog__item"> + <div class="dialog__item"> <NcSelect v-model="newUser.subAdminsGroups" class="dialog__select" :input-label="t('settings', 'Admin of the following groups')" :placeholder="t('settings', 'Set account as admin for …')" - :options="subAdminsGroups" + :disabled="loading.groups || loading.all" + :options="availableGroups" :close-on-select="false" :multiple="true" - label="name" /> + label="name" + @search="searchGroups" /> </div> <div class="dialog__item"> <NcSelect v-model="newUser.quota" @@ -136,11 +140,14 @@ <script> import { formatFileSize, parseFileSize } from '@nextcloud/files' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' -import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import { searchGroups } from '../../service/groups.ts' +import logger from '../../logger.ts' export default { name: 'NewUserDialog', @@ -177,6 +184,8 @@ export default { managerInputLabel: t('settings', 'Manager'), // TRANSLATORS This string describes a manager in the context of an organization managerLabel: t('settings', 'Set line manager'), + // Cancelable promise for search groups request + promise: null, } }, @@ -200,27 +209,12 @@ export default { return this.$store.getters.getPasswordPolicyMinLength }, - groups() { - // data provided php side + remove the recent and disabled groups - return this.$store.getters.getGroups - .filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') - .sort((a, b) => a.name.localeCompare(b.name)) - }, - - subAdminsGroups() { - // data provided php side - return this.$store.getters.getSubadminGroups - }, + availableGroups() { + const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin) + ? this.$store.getters.getSortedGroups + : this.$store.getters.getSubAdminGroups - canAddGroups() { - // disabled if no permission to add new users to group - return this.groups.map(group => { - // clone object because we don't want - // to edit the original groups - group = Object.assign({}, group) - group.$isDisabled = group.canAdd === false - return group - }) + return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') }, languages() { @@ -281,13 +275,32 @@ export default { } }, - handleGroupInput(groups) { - /** - * Filter out groups with no id to prevent duplicate selected options - * - * Created groups are added programmatically by `createGroup()` - */ - this.newUser.groups = groups.filter(group => Boolean(group.id)) + async searchGroups(query, toggleLoading) { + if (!this.settings.isAdmin && !this.settings.isDelegatedAdmin) { + // managers cannot search for groups + return + } + + if (this.promise) { + this.promise.cancel() + } + toggleLoading(true) + try { + this.promise = searchGroups({ + search: query, + offset: 0, + limit: 25, + }) + const groups = await this.promise + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + } catch (error) { + logger.error(t('settings', 'Failed to search groups'), { error }) + } + this.promise = null + toggleLoading(false) }, /** @@ -300,11 +313,26 @@ export default { this.loading.groups = true try { await this.$store.dispatch('addGroup', gid) - this.newUser.groups.push(this.groups.find(group => group.id === gid)) - this.loading.groups = false + this.newUser.groups.push({ id: gid, name: gid }) } catch (error) { - this.loading.groups = false + logger.error(t('settings', 'Failed to create group'), { error }) + } + this.loading.groups = false + }, + + /** + * Add user to group + * + * @param {object} group Group object + */ + async addGroup(group) { + if (group.isCreating) { + return + } + if (group.canAdd === false) { + return } + this.newUser.groups.push(group) }, /** @@ -318,7 +346,7 @@ export default { const validQuota = OC.Util.computerFileSize(quota) if (validQuota !== null && validQuota >= 0) { // unify format output - quota = formatFileSize(parseFileSize(quota)) + quota = formatFileSize(parseFileSize(quota, true)) this.newUser.quota = { id: quota, label: quota } return this.newUser.quota } diff --git a/apps/settings/src/components/Users/UserListFooter.vue b/apps/settings/src/components/Users/UserListFooter.vue index d3667a38580..bf9aa43b6d3 100644 --- a/apps/settings/src/components/Users/UserListFooter.vue +++ b/apps/settings/src/components/Users/UserListFooter.vue @@ -26,7 +26,7 @@ <script lang="ts"> import Vue from 'vue' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import { translate as t, diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue index 99a4126924f..a85306d84d3 100644 --- a/apps/settings/src/components/Users/UserListHeader.vue +++ b/apps/settings/src/components/Users/UserListHeader.vue @@ -42,7 +42,7 @@ scope="col"> <span>{{ t('settings', 'Groups') }}</span> </th> - <th v-if="subAdminsGroups.length > 0 && (settings.isAdmin || settings.isDelegatedAdmin)" + <th v-if="settings.isAdmin || settings.isDelegatedAdmin" class="header__cell header__cell--large" data-cy-user-list-header-subadmins scope="col"> @@ -71,6 +71,12 @@ {{ t('settings', 'Storage location') }} </span> </th> + <th v-if="showConfig.showFirstLogin" + class="header__cell" + data-cy-user-list-header-first-login + scope="col"> + <span>{{ t('settings', 'First login') }}</span> + </th> <th v-if="showConfig.showLastLogin" class="header__cell" data-cy-user-list-header-last-login @@ -119,11 +125,6 @@ export default Vue.extend({ return this.$store.getters.getServerData }, - subAdminsGroups() { - // @ts-expect-error: allow untyped $store - return this.$store.getters.getSubadminGroups - }, - passwordLabel(): string { if (this.hasObfuscated) { // TRANSLATORS This string is for a column header labelling either a password or a message that the current user has insufficient permissions diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue index 7dea5091f3b..43668725972 100644 --- a/apps/settings/src/components/Users/UserRow.vue +++ b/apps/settings/src/components/Users/UserRow.vue @@ -106,7 +106,7 @@ :data-loading="loading.groups || undefined" :input-id="'groups' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.groupsDetails" :loading="loading.groups" :multiple="true" :append-to-body="false" @@ -116,7 +116,8 @@ :value="userGroups" label="name" :no-wrap="true" - :create-option="(value) => ({ name: value, isCreating: true })" + :create-option="(value) => ({ id: value, name: value, isCreating: true })" + @search="searchGroups" @option:created="createGroup" @option:selected="options => addUserGroup(options.at(-1))" @option:deselected="removeUserGroup" /> @@ -127,10 +128,10 @@ </span> </td> - <td v-if="subAdminsGroups.length > 0 && (settings.isAdmin || settings.isDelegatedAdmin)" + <td v-if="settings.isAdmin || settings.isDelegatedAdmin" data-cy-user-list-cell-subadmins class="row__cell row__cell--large row__cell--multiline"> - <template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin) && subAdminsGroups.length > 0"> + <template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin)"> <label class="hidden-visually" :for="'subadmins' + uniqueId"> {{ t('settings', 'Set account as admin for') }} @@ -139,21 +140,22 @@ :data-loading="loading.subadmins || undefined" :input-id="'subadmins' + uniqueId" :close-on-select="false" - :disabled="isLoadingField" + :disabled="isLoadingField || loading.subAdminGroupsDetails" :loading="loading.subadmins" label="name" :append-to-body="false" :multiple="true" :no-wrap="true" - :options="subAdminsGroups" + :options="availableSubAdminGroups" :placeholder="t('settings', 'Set account as admin for')" - :value="userSubAdminsGroups" + :value="userSubAdminGroups" + @search="searchGroups" @option:deselected="removeUserSubAdmin" @option:selected="options => addUserSubAdmin(options.at(-1))" /> </template> <span v-else-if="!isObfuscated" - :title="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null"> - {{ userSubAdminsGroupsLabels }} + :title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null"> + {{ userSubAdminGroupsLabels }} </span> </td> @@ -229,6 +231,12 @@ </template> </td> + <td v-if="showConfig.showFirstLogin" + class="row__cell" + data-cy-user-list-cell-first-login> + <span v-if="!isObfuscated">{{ userFirstLogin }}</span> + </td> + <td v-if="showConfig.showLastLogin" :title="userLastLoginTooltip" class="row__cell" @@ -247,16 +255,17 @@ data-cy-user-list-input-manager :data-loading="loading.manager || undefined" :input-id="'manager' + uniqueId" - :close-on-select="true" :disabled="isLoadingField" - :append-to-body="false" :loading="loadingPossibleManagers || loading.manager" - label="displayname" :options="possibleManagers" :placeholder="managerLabel" + label="displayname" + :filterable="false" + :internal-search="false" + :clearable="true" @open="searchInitialUserManager" @search="searchUserManager" - @option:selected="updateUserManager" /> + @update:model-value="updateUserManager" /> </template> <span v-else-if="!isObfuscated"> {{ user.manager }} @@ -280,16 +289,18 @@ import { getCurrentUser } from '@nextcloud/auth' import { showSuccess, showError } from '@nextcloud/dialogs' import { confirmPassword } from '@nextcloud/password-confirmation' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' -import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' -import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcTextField from '@nextcloud/vue/components/NcTextField' import UserRowActions from './UserRowActions.vue' import UserRowMixin from '../../mixins/UserRowMixin.js' import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts' +import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts' +import logger from '../../logger.ts' export default { name: 'UserRow', @@ -324,14 +335,6 @@ export default { type: Boolean, required: true, }, - groups: { - type: Array, - default: () => [], - }, - subAdminsGroups: { - type: Array, - required: true, - }, quotaOptions: { type: Array, required: true, @@ -364,6 +367,8 @@ export default { password: false, mailAddress: false, groups: false, + groupsDetails: false, + subAdminGroupsDetails: false, subadmins: false, quota: false, delete: false, @@ -375,6 +380,8 @@ export default { editedDisplayName: this.user.displayname, editedPassword: '', editedMail: this.user.email ?? '', + // Cancelable promise for search groups request + promise: null, } }, @@ -404,15 +411,35 @@ export default { return encodeURIComponent(this.user.id + this.rand) }, + availableGroups() { + const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin) + ? this.$store.getters.getSortedGroups + : this.$store.getters.getSubAdminGroups + + return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled') + }, + + availableSubAdminGroups() { + return this.availableGroups.filter(group => group.id !== 'admin') + }, + userGroupsLabels() { return this.userGroups - .map(group => group.name) + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, - userSubAdminsGroupsLabels() { - return this.userSubAdminsGroups - .map(group => group.name) + userSubAdminGroupsLabels() { + return this.userSubAdminGroups + .map(group => { + // Try to match with more extensive group data + const availableGroup = this.availableSubAdminGroups.find(g => g.id === group.id) + return availableGroup?.name ?? group.name ?? group.id + }) .join(', ') }, @@ -496,7 +523,6 @@ export default { return this.languages[0].languages.concat(this.languages[1].languages) }, }, - async beforeMount() { if (this.user.manager) { await this.initManager(this.user.manager) @@ -548,6 +574,66 @@ export default { this.loadingPossibleManagers = false }, + async loadGroupsDetails() { + this.loading.groups = true + this.loading.groupsDetails = true + try { + const groups = await loadUserGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedGroups = this.selectedGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load groups with details'), { error }) + } + this.loading.groups = false + this.loading.groupsDetails = false + }, + + async loadSubAdminGroupsDetails() { + this.loading.subadmins = true + this.loading.subAdminGroupsDetails = true + try { + const groups = await loadUserSubAdminGroups({ userId: this.user.id }) + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + this.selectedSubAdminGroups = this.selectedSubAdminGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup) + } catch (error) { + logger.error(t('settings', 'Failed to load sub admin groups with details'), { error }) + } + this.loading.subadmins = false + this.loading.subAdminGroupsDetails = false + }, + + async searchGroups(query, toggleLoading) { + if (query === '') { + return // Prevent unexpected search behaviour e.g. on option:created + } + if (this.promise) { + this.promise.cancel() + } + toggleLoading(true) + try { + this.promise = await searchGroups({ + search: query, + offset: 0, + limit: 25, + }) + const groups = await this.promise + // Populate store from server request + for (const group of groups) { + this.$store.commit('addGroup', group) + } + } catch (error) { + logger.error(t('settings', 'Failed to search groups'), { error }) + } + this.promise = null + toggleLoading(false) + }, + async searchUserManager(query) { await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => { const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : [] @@ -557,11 +643,12 @@ export default { }) }, - async updateUserManager(manager) { - if (manager === null) { - this.currentManager = '' - } + async updateUserManager() { this.loading.manager = true + + // Store the current manager before making changes + const previousManager = this.user.manager + try { await this.$store.dispatch('setUserData', { userid: this.user.id, @@ -570,8 +657,11 @@ export default { }) } catch (error) { // TRANSLATORS This string describes a line manager in the context of an organization - showError(t('setting', 'Failed to update line manager')) - console.error(error) + showError(t('settings', 'Failed to update line manager')) + logger.error('Failed to update manager:', { error }) + + // Revert to the previous manager in the UI on error + this.currentManager = previousManager } finally { this.loading.manager = false } @@ -632,7 +722,7 @@ export default { }) if (this.editedDisplayName === this.user.displayname) { - showSuccess(t('setting', 'Display name was successfully changed')) + showSuccess(t('settings', 'Display name was successfully changed')) } } finally { this.loading.displayName = false @@ -645,7 +735,7 @@ export default { async updatePassword() { this.loading.password = true if (this.editedPassword.length === 0) { - showError(t('setting', "Password can't be empty")) + showError(t('settings', "Password can't be empty")) this.loading.password = false } else { try { @@ -655,7 +745,7 @@ export default { value: this.editedPassword, }) this.editedPassword = '' - showSuccess(t('setting', 'Password was successfully changed')) + showSuccess(t('settings', 'Password was successfully changed')) } finally { this.loading.password = false } @@ -668,7 +758,7 @@ export default { async updateEmail() { this.loading.mailAddress = true if (this.editedMail === '') { - showError(t('setting', "Email can't be empty")) + showError(t('settings', "Email can't be empty")) this.loading.mailAddress = false this.editedMail = this.user.email } else { @@ -680,7 +770,7 @@ export default { }) if (this.editedMail === this.user.email) { - showSuccess(t('setting', 'Email was successfully changed')) + showSuccess(t('settings', 'Email was successfully changed')) } } finally { this.loading.mailAddress = false @@ -694,17 +784,16 @@ export default { * @param {string} gid Group id */ async createGroup({ name: gid }) { - this.loading = { groups: true, subadmins: true } + this.loading.groups = true try { await this.$store.dispatch('addGroup', gid) const userid = this.user.id await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push({ id: gid, name: gid }) } catch (error) { - console.error(error) - } finally { - this.loading = { groups: false, subadmins: false } + logger.error(t('settings', 'Failed to create group'), { error }) } - return this.$store.getters.getGroups[this.groups.length] + this.loading.groups = false }, /** @@ -718,19 +807,19 @@ export default { // Ignore return } - this.loading.groups = true const userid = this.user.id const gid = group.id if (group.canAdd === false) { - return false + return } + this.loading.groups = true try { await this.$store.dispatch('addUserGroup', { userid, gid }) + this.userGroups.push(group) } catch (error) { console.error(error) - } finally { - this.loading.groups = false } + this.loading.groups = false }, /** @@ -750,6 +839,7 @@ export default { userid, gid, }) + this.userGroups = this.userGroups.filter(group => group.id !== gid) this.loading.groups = false // remove user from current list if current list is the removed group if (this.$route.params.selectedGroup === gid) { @@ -774,10 +864,11 @@ export default { userid, gid, }) - this.loading.subadmins = false + this.userSubAdminGroups.push(group) } catch (error) { console.error(error) } + this.loading.subadmins = false }, /** @@ -795,6 +886,7 @@ export default { userid, gid, }) + this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid) } catch (error) { console.error(error) } finally { @@ -884,7 +976,7 @@ export default { sendWelcomeMail() { this.loading.all = true this.$store.dispatch('sendWelcomeMail', this.user.id) - .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 })) + .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 })) .finally(() => { this.loading.all = false }) @@ -895,6 +987,8 @@ export default { if (this.editing) { await this.$nextTick() this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus() + this.loadGroupsDetails() + this.loadSubAdminGroupsDetails() } if (this.editedDisplayName !== this.user.displayname) { this.editedDisplayName = this.user.displayname diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue index 1bacd79e8a1..efd70d879a7 100644 --- a/apps/settings/src/components/Users/UserRowActions.vue +++ b/apps/settings/src/components/Users/UserRowActions.vue @@ -35,11 +35,11 @@ import type { PropType } from 'vue' import { defineComponent } from 'vue' import isSvg from 'is-svg' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import SvgCheck from '@mdi/svg/svg/check.svg?raw' -import SvgPencil from '@mdi/svg/svg/pencil.svg?raw' +import SvgPencil from '@mdi/svg/svg/pencil-outline.svg?raw' interface UserAction { action: (event: MouseEvent, user: Record<string, unknown>) => void, diff --git a/apps/settings/src/components/Users/UserSettingsDialog.vue b/apps/settings/src/components/Users/UserSettingsDialog.vue index 4d412146f9a..94c77d320dd 100644 --- a/apps/settings/src/components/Users/UserSettingsDialog.vue +++ b/apps/settings/src/components/Users/UserSettingsDialog.vue @@ -25,6 +25,11 @@ {{ t('settings', 'Show storage path') }} </NcCheckboxRadioSwitch> <NcCheckboxRadioSwitch type="switch" + data-test="showFirstLogin" + :checked.sync="showFirstLogin"> + {{ t('settings', 'Show first login') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch type="switch" data-test="showLastLogin" :checked.sync="showLastLogin"> {{ t('settings', 'Show last login') }} @@ -38,6 +43,9 @@ </NcNoteCard> <fieldset> <legend>{{ t('settings', 'Group list sorting') }}</legend> + <NcNoteCard class="dialog__note" + type="info" + :text="t('settings', 'Sorting only applies to the currently loaded groups for performance reasons. Groups will be loaded as you navigate or search through the list.')" /> <NcCheckboxRadioSwitch type="radio" :checked.sync="groupSorting" data-test="sortGroupsByMemberCount" @@ -70,13 +78,14 @@ <NcAppSettingsSection id="default-settings" :name="t('settings', 'Defaults')"> <NcSelect v-model="defaultQuota" + :clearable="false" + :create-option="validateQuota" + :filter-by="filterQuotas" :input-label="t('settings', 'Default quota')" - placement="top" - :taggable="true" :options="quotaOptions" - :create-option="validateQuota" + placement="top" :placeholder="t('settings', 'Select default quota')" - :clearable="false" + taggable @option:selected="setDefaultQuota" /> </NcAppSettingsSection> </NcAppSettingsDialog> @@ -87,14 +96,15 @@ import { formatFileSize, parseFileSize } from '@nextcloud/files' import { generateUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' -import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js' -import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' +import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcSelect from '@nextcloud/vue/components/NcSelect' import { GroupSorting } from '../../constants/GroupManagement.ts' import { unlimitedQuota } from '../../utils/userUtils.ts' +import logger from '../../logger.ts' export default { name: 'UserSettingsDialog', @@ -164,6 +174,15 @@ export default { }, }, + showFirstLogin: { + get() { + return this.showConfig.showFirstLogin + }, + set(status) { + this.setShowConfig('showFirstLogin', status) + }, + }, + showLastLogin: { get() { return this.showConfig.showLastLogin @@ -229,8 +248,8 @@ export default { newUserSendEmail: value, }) await axios.post(generateUrl('/settings/users/preferences/newUser.sendEmail'), { value: value ? 'yes' : 'no' }) - } catch (e) { - console.error('could not update newUser.sendEmail preference: ' + e.message, e) + } catch (error) { + logger.error('Could not update newUser.sendEmail preference', { error }) } finally { this.loadingSendMail = false } @@ -239,6 +258,22 @@ export default { }, methods: { + /** + * Check if a quota matches the current search. + * This is a custom filter function to allow to map "1GB" to the label "1 GB" (ignoring whitespaces). + * + * @param option The quota to check + * @param label The label of the quota + * @param search The search string + */ + filterQuotas(option, label, search) { + const searchValue = search.toLocaleLowerCase().replaceAll(/\s/g, '') + return (label || '') + .toLocaleLowerCase() + .replaceAll(/\s/g, '') + .indexOf(searchValue) > -1 + }, + setShowConfig(key, status) { this.$store.commit('setShowConfig', { key, value: status }) }, @@ -254,14 +289,13 @@ export default { quota = quota?.id || quota.label } // only used for new presets sent through @Tag - const validQuota = parseFileSize(quota) + const validQuota = parseFileSize(quota, true) if (validQuota === null) { return unlimitedQuota - } else { - // unify format output - quota = formatFileSize(parseFileSize(quota)) - return { id: quota, label: quota } } + // unify format output + quota = formatFileSize(validQuota) + return { id: quota, label: quota } }, /** @@ -291,6 +325,12 @@ export default { </script> <style scoped lang="scss"> +.dialog { + &__note { + font-weight: normal; + } +} + fieldset { font-weight: bold; } diff --git a/apps/settings/src/components/WebAuthn/AddDevice.vue b/apps/settings/src/components/WebAuthn/AddDevice.vue index 6c0b1eacde8..db00bae451a 100644 --- a/apps/settings/src/components/WebAuthn/AddDevice.vue +++ b/apps/settings/src/components/WebAuthn/AddDevice.vue @@ -50,8 +50,8 @@ <script> import { showError } from '@nextcloud/dialogs' import { confirmPassword } from '@nextcloud/password-confirmation' -import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcTextField from '@nextcloud/vue/components/NcTextField' import logger from '../../logger.ts' import { diff --git a/apps/settings/src/components/WebAuthn/Device.vue b/apps/settings/src/components/WebAuthn/Device.vue index d755d0a76ba..4e10c1f234d 100644 --- a/apps/settings/src/components/WebAuthn/Device.vue +++ b/apps/settings/src/components/WebAuthn/Device.vue @@ -16,8 +16,8 @@ </template> <script> -import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' export default { name: 'Device', diff --git a/apps/settings/src/components/WebAuthn/Section.vue b/apps/settings/src/components/WebAuthn/Section.vue index 71ec616534c..fa818c24355 100644 --- a/apps/settings/src/components/WebAuthn/Section.vue +++ b/apps/settings/src/components/WebAuthn/Section.vue @@ -37,7 +37,7 @@ <script> import { browserSupportsWebAuthn } from '@simplewebauthn/browser' import { confirmPassword } from '@nextcloud/password-confirmation' -import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import sortBy from 'lodash/fp/sortBy.js' import AddDevice from './AddDevice.vue' |