aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src/components')
-rw-r--r--apps/settings/src/components/AdminAI.vue257
-rw-r--r--apps/settings/src/components/AdminDelegating.vue40
-rw-r--r--apps/settings/src/components/AdminDelegation/GroupSelect.vue22
-rw-r--r--apps/settings/src/components/AdminSettingsSharingForm.vue405
-rw-r--r--apps/settings/src/components/AdminTwoFactor.vue95
-rw-r--r--apps/settings/src/components/AppAPI/DaemonSelectionDialog.vue41
-rw-r--r--apps/settings/src/components/AppAPI/DaemonSelectionEntry.vue77
-rw-r--r--apps/settings/src/components/AppAPI/DaemonSelectionList.vue77
-rw-r--r--apps/settings/src/components/AppDetails.vue256
-rw-r--r--apps/settings/src/components/AppList.vue328
-rw-r--r--apps/settings/src/components/AppList/AppDaemonBadge.vue37
-rw-r--r--apps/settings/src/components/AppList/AppItem.vue456
-rw-r--r--apps/settings/src/components/AppList/AppLevelBadge.vue56
-rw-r--r--apps/settings/src/components/AppList/AppScore.vue92
-rw-r--r--apps/settings/src/components/AppNavigationGroupList.vue220
-rw-r--r--apps/settings/src/components/AppStoreDiscover/AppLink.vue98
-rw-r--r--apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue119
-rw-r--r--apps/settings/src/components/AppStoreDiscover/AppType.vue100
-rw-r--r--apps/settings/src/components/AppStoreDiscover/CarouselType.vue206
-rw-r--r--apps/settings/src/components/AppStoreDiscover/PostType.vue299
-rw-r--r--apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue122
-rw-r--r--apps/settings/src/components/AppStoreDiscover/common.ts48
-rw-r--r--apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue50
-rw-r--r--apps/settings/src/components/AppStoreSidebar/AppDeployOptionsModal.vue320
-rw-r--r--apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue38
-rw-r--r--apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue495
-rw-r--r--apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue57
-rw-r--r--apps/settings/src/components/AuthToken.vue389
-rw-r--r--apps/settings/src/components/AuthTokenList.vue146
-rw-r--r--apps/settings/src/components/AuthTokenSection.vue179
-rw-r--r--apps/settings/src/components/AuthTokenSetup.vue97
-rw-r--r--apps/settings/src/components/AuthTokenSetupDialog.vue203
-rw-r--r--apps/settings/src/components/AuthTokenSetupDialogue.vue213
-rw-r--r--apps/settings/src/components/BasicSettings/BackgroundJob.vue201
-rw-r--r--apps/settings/src/components/BasicSettings/ProfileSettings.vue41
-rw-r--r--apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue275
-rw-r--r--apps/settings/src/components/Encryption/EncryptionSettings.vue197
-rw-r--r--apps/settings/src/components/Encryption/EncryptionWarningDialog.vue91
-rw-r--r--apps/settings/src/components/Encryption/sharedTexts.ts7
-rw-r--r--apps/settings/src/components/GroupListItem.vue217
-rw-r--r--apps/settings/src/components/Markdown.cy.ts58
-rw-r--r--apps/settings/src/components/Markdown.vue102
-rw-r--r--apps/settings/src/components/PasswordSection.vue82
-rw-r--r--apps/settings/src/components/PersonalInfo/AvatarSection.vue309
-rw-r--r--apps/settings/src/components/PersonalInfo/BiographySection.vue34
-rw-r--r--apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue183
-rw-r--r--apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue69
-rw-r--r--apps/settings/src/components/PersonalInfo/BirthdaySection.vue132
-rw-r--r--apps/settings/src/components/PersonalInfo/BlueskySection.vue64
-rw-r--r--apps/settings/src/components/PersonalInfo/DetailsSection.vue114
-rw-r--r--apps/settings/src/components/PersonalInfo/DisplayNameSection.vue54
-rw-r--r--apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue179
-rw-r--r--apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue86
-rw-r--r--apps/settings/src/components/PersonalInfo/EmailSection/Email.vue328
-rw-r--r--apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue89
-rw-r--r--apps/settings/src/components/PersonalInfo/FediverseSection.vue50
-rw-r--r--apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue126
-rw-r--r--apps/settings/src/components/PersonalInfo/HeadlineSection.vue33
-rw-r--r--apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue174
-rw-r--r--apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue69
-rw-r--r--apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue110
-rw-r--r--apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue59
-rw-r--r--apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue157
-rw-r--r--apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue66
-rw-r--r--apps/settings/src/components/PersonalInfo/LocationSection.vue34
-rw-r--r--apps/settings/src/components/PersonalInfo/OrganisationSection.vue34
-rw-r--r--apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue174
-rw-r--r--apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue69
-rw-r--r--apps/settings/src/components/PersonalInfo/PhoneSection.vue53
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue37
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue79
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue48
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue41
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue55
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue57
-rw-r--r--apps/settings/src/components/PersonalInfo/PronounsSection.vue47
-rw-r--r--apps/settings/src/components/PersonalInfo/RoleSection.vue34
-rw-r--r--apps/settings/src/components/PersonalInfo/RoleSection/Role.vue174
-rw-r--r--apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue69
-rw-r--r--apps/settings/src/components/PersonalInfo/TwitterSection.vue34
-rw-r--r--apps/settings/src/components/PersonalInfo/WebsiteSection.vue43
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue243
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/FederationControl.vue146
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue104
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue97
-rw-r--r--apps/settings/src/components/PrefixMixin.vue32
-rw-r--r--apps/settings/src/components/SelectSharingPermissions.vue83
-rw-r--r--apps/settings/src/components/SvgFilterMixin.vue25
-rw-r--r--apps/settings/src/components/UserList.vue657
-rw-r--r--apps/settings/src/components/UserList/UserRow.vue688
-rw-r--r--apps/settings/src/components/UserList/UserRowSimple.vue199
-rw-r--r--apps/settings/src/components/Users/NewUserDialog.vue436
-rw-r--r--apps/settings/src/components/Users/UserListFooter.vue112
-rw-r--r--apps/settings/src/components/Users/UserListHeader.vue152
-rw-r--r--apps/settings/src/components/Users/UserRow.vue1049
-rw-r--r--apps/settings/src/components/Users/UserRowActions.vue119
-rw-r--r--apps/settings/src/components/Users/UserSettingsDialog.vue337
-rw-r--r--apps/settings/src/components/Users/VirtualList.vue184
-rw-r--r--apps/settings/src/components/Users/shared/styles.scss110
-rw-r--r--apps/settings/src/components/WebAuthn/AddDevice.vue191
-rw-r--r--apps/settings/src/components/WebAuthn/Device.vue42
-rw-r--r--apps/settings/src/components/WebAuthn/Section.vue80
102 files changed, 10617 insertions, 4945 deletions
diff --git a/apps/settings/src/components/AdminAI.vue b/apps/settings/src/components/AdminAI.vue
new file mode 100644
index 00000000000..0d3e9154bb9
--- /dev/null
+++ b/apps/settings/src/components/AdminAI.vue
@@ -0,0 +1,257 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <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" 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]"
+ class="provider-select"
+ :clearable="false"
+ :disabled="!settings['ai.taskprocessing_type_preferences'][type.id]"
+ :options="taskProcessingProviders.filter(p => p.taskType === type.id).map(p => p.id)"
+ @input="saveChanges">
+ <template #option="{label}">
+ {{ taskProcessingProviders.find(p => p.id === label)?.name }}
+ </template>
+ <template #selected-option="{label}">
+ {{ taskProcessingProviders.find(p => p.id === label)?.name }}
+ </template>
+ </NcSelect>
+ </div>
+ </template>
+ <template v-if="!hasTaskProcessing">
+ <NcNoteCard type="info">
+ {{ t('settings', 'None of your currently installed apps provide Task processing functionality') }}
+ </NcNoteCard>
+ </template>
+ </NcSettingsSection>
+ <NcSettingsSection :name="t('settings', 'Machine translation')"
+ :description="t('settings', 'Machine translation can be implemented by different apps. Here you can define the precedence of the machine translation apps you have installed at the moment.')">
+ <draggable v-model="settings['ai.translation_provider_preferences']" @change="saveChanges">
+ <div v-for="(providerClass, i) in settings['ai.translation_provider_preferences']" :key="providerClass" class="draggable__item">
+ <DragVerticalIcon /> <span class="draggable__number">{{ i + 1 }}</span> {{ translationProviders.find(p => p.class === providerClass)?.name }}
+ <NcButton aria-label="Move up" type="tertiary" @click="moveUp(i)">
+ <template #icon>
+ <ArrowUpIcon />
+ </template>
+ </NcButton>
+ <NcButton aria-label="Move down" type="tertiary" @click="moveDown(i)">
+ <template #icon>
+ <ArrowDownIcon />
+ </template>
+ </NcButton>
+ </div>
+ </draggable>
+ </NcSettingsSection>
+ <NcSettingsSection :name="t('settings', 'Image generation')"
+ :description="t('settings', 'Image generation can be implemented by different apps. Here you can set which app should be used.')">
+ <template v-for="provider in text2imageProviders">
+ <NcCheckboxRadioSwitch :key="provider.id"
+ :checked.sync="settings['ai.text2image_provider']"
+ :value="provider.id"
+ name="text2image_provider"
+ type="radio"
+ @update:checked="saveChanges">
+ {{ provider.name }}
+ </NcCheckboxRadioSwitch>
+ </template>
+ <template v-if="!hasText2ImageProviders">
+ <NcNoteCard type="info">
+ {{ t('settings', 'None of your currently installed apps provide image generation functionality') }}
+ </NcNoteCard>
+ </template>
+ </NcSettingsSection>
+ <NcSettingsSection :name="t('settings', 'Text processing')"
+ :description="t('settings', 'Text processing tasks can be implemented by different apps. Here you can set which app should be used for which task.')">
+ <template v-for="type in tpTaskTypes">
+ <div :key="type">
+ <h3>{{ t('settings', 'Task:') }} {{ getTextProcessingTaskType(type).name }}</h3>
+ <p>{{ getTextProcessingTaskType(type).description }}</p>
+ <p>&nbsp;</p>
+ <NcSelect v-model="settings['ai.textprocessing_provider_preferences'][type]"
+ class="provider-select"
+ :clearable="false"
+ :options="textProcessingProviders.filter(p => p.taskType === type).map(p => p.class)"
+ @input="saveChanges">
+ <template #option="{label}">
+ {{ textProcessingProviders.find(p => p.class === label)?.name }}
+ </template>
+ <template #selected-option="{label}">
+ {{ textProcessingProviders.find(p => p.class === label)?.name }}
+ </template>
+ </NcSelect>
+ <p>&nbsp;</p>
+ </div>
+ </template>
+ <template v-if="tpTaskTypes.length === 0">
+ <NcNoteCard type="info">
+ <!-- TRANSLATORS Text processing is the name of a Nextcloud-internal API -->
+ {{ t('settings', 'None of your currently installed apps provide text processing functionality using the Text Processing API.') }}
+ </NcNoteCard>
+ </template>
+ </NcSettingsSection>
+ </div>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+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'
+import ArrowUpIcon from 'vue-material-design-icons/ArrowUp.vue'
+import { loadState } from '@nextcloud/initial-state'
+import { nextTick } from 'vue'
+import { generateUrl } from '@nextcloud/router'
+
+export default {
+ name: 'AdminAI',
+ components: {
+ NcCheckboxRadioSwitch,
+ NcSettingsSection,
+ NcSelect,
+ draggable,
+ DragVerticalIcon,
+ ArrowDownIcon,
+ ArrowUpIcon,
+ NcButton,
+ NcNoteCard,
+ },
+ data() {
+ return {
+ loading: false,
+ dirty: false,
+ groups: [],
+ loadingGroups: false,
+ sttProviders: loadState('settings', 'ai-stt-providers'),
+ translationProviders: loadState('settings', 'ai-translation-providers'),
+ textProcessingProviders: loadState('settings', 'ai-text-processing-providers'),
+ textProcessingTaskTypes: loadState('settings', 'ai-text-processing-task-types'),
+ text2imageProviders: loadState('settings', 'ai-text2image-providers'),
+ taskProcessingProviders: loadState('settings', 'ai-task-processing-providers'),
+ taskProcessingTaskTypes: loadState('settings', 'ai-task-processing-task-types'),
+ settings: loadState('settings', 'ai-settings'),
+ }
+ },
+ computed: {
+ hasTextProcessing() {
+ return Object.keys(this.settings['ai.textprocessing_provider_preferences']).length > 0 && Array.isArray(this.textProcessingTaskTypes)
+ },
+ tpTaskTypes() {
+ const builtinTextProcessingTypes = [
+ 'OCP\\TextProcessing\\FreePromptTaskType',
+ 'OCP\\TextProcessing\\HeadlineTaskType',
+ 'OCP\\TextProcessing\\SummaryTaskType',
+ 'OCP\\TextProcessing\\TopicsTaskType',
+ ]
+ return Object.keys(this.settings['ai.textprocessing_provider_preferences'])
+ .filter(type => !!this.getTextProcessingTaskType(type))
+ .filter(type => !builtinTextProcessingTypes.includes(type))
+ },
+ hasText2ImageProviders() {
+ return this.text2imageProviders.length > 0
+ },
+ hasTaskProcessing() {
+ return Object.keys(this.settings['ai.taskprocessing_provider_preferences']).length > 0 && Array.isArray(this.taskProcessingTaskTypes)
+ },
+ },
+ methods: {
+ moveUp(i) {
+ this.settings['ai.translation_provider_preferences'].splice(
+ Math.min(i - 1, 0),
+ 0,
+ ...this.settings['ai.translation_provider_preferences'].splice(i, 1),
+ )
+ this.saveChanges()
+ },
+ moveDown(i) {
+ this.settings['ai.translation_provider_preferences'].splice(
+ i + 1,
+ 0,
+ ...this.settings['ai.translation_provider_preferences'].splice(i, 1),
+ )
+ this.saveChanges()
+ },
+ async saveChanges() {
+ this.loading = true
+ await nextTick()
+ const data = { settings: this.settings }
+ try {
+ await axios.put(generateUrl('/settings/api/admin/ai'), data)
+ } catch (err) {
+ console.error('could not save changes', err)
+ }
+ this.loading = false
+ },
+ getTextProcessingTaskType(type) {
+ if (!Array.isArray(this.textProcessingTaskTypes)) {
+ return null
+ }
+ return this.textProcessingTaskTypes.find(taskType => taskType.class === type)
+ },
+ },
+}
+</script>
+<style scoped>
+.draggable__item {
+ margin-bottom: 5px;
+ display: flex;
+ align-items: center;
+}
+
+.draggable__item,
+.draggable__item * {
+ cursor: grab;
+}
+
+.draggable__number {
+ border-radius: 20px;
+ border: 2px solid var(--color-primary-element);
+ color: var(--color-primary-element);
+ padding: 0px 7px;
+ margin-inline-end: 3px;
+}
+
+.drag-vertical-icon {
+ float: left;
+}
+
+.ai-settings h3 {
+ font-size: 16px; /* to offset against the 20px section heading */
+}
+
+.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 d669e26efd0..521ff8f0155 100644
--- a/apps/settings/src/components/AdminDelegating.vue
+++ b/apps/settings/src/components/AdminDelegating.vue
@@ -1,37 +1,47 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div id="admin-right-sub-granting" class="section">
- <h2>{{ t('settings', 'Administration privileges') }}</h2>
- <p class="settings-hint">
- {{ t('settings', 'Here you can decide which group can access certain sections of the administration settings.') }}
- </p>
-
+ <NcSettingsSection :name="t('settings', 'Administration privileges')"
+ :description="t('settings', 'Here you can decide which group can access certain sections of the administration settings.')"
+ :doc-url="authorizedSettingsDocLink">
<div class="setting-list">
<div v-for="setting in availableSettings" :key="setting.class">
- <h3>{{ setting.sectionName }}</h3>
+ <label :for="setting.id">{{ setting.sectionName }}</label>
<GroupSelect :available-groups="availableGroups" :authorized-groups="authorizedGroups" :setting="setting" />
</div>
</div>
- </div>
+ </NcSettingsSection>
</template>
<script>
-import GroupSelect from './AdminDelegation/GroupSelect'
+import GroupSelect from './AdminDelegation/GroupSelect.vue'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { loadState } from '@nextcloud/initial-state'
export default {
name: 'AdminDelegating',
components: {
GroupSelect,
+ NcSettingsSection,
},
data() {
- const availableSettings = loadState('settings', 'available-settings')
- const availableGroups = loadState('settings', 'available-groups')
- const authorizedGroups = loadState('settings', 'authorized-groups')
return {
- availableSettings,
- availableGroups,
- authorizedGroups,
+ availableSettings: loadState('settings', 'available-settings'),
+ availableGroups: loadState('settings', 'available-groups'),
+ authorizedGroups: loadState('settings', 'authorized-groups'),
+ authorizedSettingsDocLink: loadState('settings', 'authorized-settings-doc-link'),
}
},
}
</script>
+
+<style lang="scss" scoped>
+label {
+ display: block;
+ font-size: 16px;
+ margin: 12px 0;
+ color: var(--color-text-light);
+}
+</style>
diff --git a/apps/settings/src/components/AdminDelegation/GroupSelect.vue b/apps/settings/src/components/AdminDelegation/GroupSelect.vue
index f9db50b3cc6..28d3deb0afa 100644
--- a/apps/settings/src/components/AdminDelegation/GroupSelect.vue
+++ b/apps/settings/src/components/AdminDelegation/GroupSelect.vue
@@ -1,26 +1,29 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <Multiselect v-model="selected"
- class="group-multiselect"
+ <NcSelect v-model="selected"
+ :input-id="setting.id"
+ class="group-select"
:placeholder="t('settings', 'None')"
- track-by="gid"
label="displayName"
:options="availableGroups"
- open-direction="bottom"
:multiple="true"
- :allow-empty="true" />
+ :close-on-select="false" />
</template>
<script>
-import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
-import logger from '../../logger'
+import logger from '../../logger.ts'
export default {
name: 'GroupSelect',
components: {
- Multiselect,
+ NcSelect,
},
props: {
availableGroups: {
@@ -67,8 +70,7 @@ export default {
</script>
<style lang="scss">
-.group-multiselect {
+.group-select {
width: 100%;
- margin-right: 0;
}
</style>
diff --git a/apps/settings/src/components/AdminSettingsSharingForm.vue b/apps/settings/src/components/AdminSettingsSharingForm.vue
new file mode 100644
index 00000000000..b0e142d8480
--- /dev/null
+++ b/apps/settings/src/components/AdminSettingsSharingForm.vue
@@ -0,0 +1,405 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <form class="sharing">
+ <NcCheckboxRadioSwitch aria-controls="settings-sharing-api settings-sharing-api-settings settings-sharing-default-permissions settings-sharing-privary-related"
+ type="switch"
+ :checked.sync="settings.enabled">
+ {{ t('settings', 'Allow apps to use the Share API') }}
+ </NcCheckboxRadioSwitch>
+
+ <div v-show="settings.enabled" id="settings-sharing-api-settings" class="sharing__sub-section">
+ <NcCheckboxRadioSwitch :checked.sync="settings.allowResharing">
+ {{ t('settings', 'Allow resharing') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked.sync="settings.allowGroupSharing">
+ {{ t('settings', 'Allow sharing with groups') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked.sync="settings.onlyShareWithGroupMembers">
+ {{ t('settings', 'Restrict users to only share with users in their groups') }}
+ </NcCheckboxRadioSwitch>
+ <div v-show="settings.onlyShareWithGroupMembers" id="settings-sharing-api-excluded-groups" class="sharing__labeled-entry sharing__input">
+ <label for="settings-sharing-only-group-members-excluded-groups">{{ t('settings', 'Ignore the following groups when checking group membership') }}</label>
+ <NcSettingsSelectGroup id="settings-sharing-only-group-members-excluded-groups"
+ v-model="settings.onlyShareWithGroupMembersExcludeGroupList"
+ :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">
+ <NcCheckboxRadioSwitch type="switch"
+ aria-controls="settings-sharing-api-public-link"
+ :checked.sync="settings.allowLinks">
+ {{ t('settings', 'Allow users to share via link and emails') }}
+ </NcCheckboxRadioSwitch>
+ <fieldset v-show="settings.allowLinks" id="settings-sharing-api-public-link" class="sharing__sub-section">
+ <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>
+ <NcCheckboxRadioSwitch :checked.sync="settings.enforceLinksPassword" :disabled="!settings.enableLinkPasswordByDefault">
+ {{ t('settings', 'Enforce password protection') }}
+ </NcCheckboxRadioSwitch>
+ <label v-if="settings.enforceLinksPasswordExcludedGroupsEnabled" class="sharing__labeled-entry sharing__input">
+ <span>{{ t('settings', 'Exclude groups from password requirements') }}</span>
+ <NcSettingsSelectGroup v-model="settings.enforceLinksPasswordExcludedGroups"
+ style="width: 100%"
+ :disabled="!settings.enforceLinksPassword || !settings.enableLinkPasswordByDefault" />
+ </label>
+ <label class="sharing__labeled-entry sharing__input">
+ <span>{{ t('settings', 'Exclude groups from creating link shares') }}</span>
+ <NcSettingsSelectGroup v-model="settings.allowLinksExcludeGroups"
+ :label="t('settings', 'Exclude groups from creating link shares')"
+ style="width: 100%" />
+ </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"
+ name="excludeGroups"
+ value="no"
+ type="radio"
+ @update:checked="onUpdateExcludeGroups">
+ {{ t('settings', 'Allow sharing for everyone (default)') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked.sync="settings.excludeGroups"
+ name="excludeGroups"
+ value="yes"
+ type="radio"
+ @update:checked="onUpdateExcludeGroups">
+ {{ t('settings', 'Exclude some groups from sharing') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked.sync="settings.excludeGroups"
+ name="excludeGroups"
+ value="allow"
+ type="radio"
+ @update:checked="onUpdateExcludeGroups">
+ {{ t('settings', 'Limit sharing to some groups') }}
+ </NcCheckboxRadioSwitch>
+ <div v-show="settings.excludeGroups !== 'no'" class="sharing__labeled-entry sharing__input">
+ <NcSettingsSelectGroup id="settings-sharing-excluded-groups"
+ v-model="settings.excludeGroupsList"
+ aria-describedby="settings-sharing-excluded-groups-desc"
+ :label="settings.excludeGroups === 'allow' ? t('settings', 'Groups allowed to share') : t('settings', 'Groups excluded from sharing')"
+ :disabled="settings.excludeGroups === 'no'"
+ style="width: 100%" />
+ <em id="settings-sharing-excluded-groups-desc">{{ t('settings', 'Not allowed groups will still be able to receive shares, but not to initiate them.') }}</em>
+ </div>
+ </div>
+
+ <NcCheckboxRadioSwitch type="switch"
+ aria-controls="settings-sharing-api-expiration"
+ :checked.sync="settings.defaultInternalExpireDate">
+ {{ t('settings', 'Set default expiration date for internal shares') }}
+ </NcCheckboxRadioSwitch>
+ <fieldset v-show="settings.defaultInternalExpireDate" id="settings-sharing-api-expiration" class="sharing__sub-section">
+ <NcCheckboxRadioSwitch :checked.sync="settings.enforceInternalExpireDate">
+ {{ t('settings', 'Enforce expiration date') }}
+ </NcCheckboxRadioSwitch>
+ <NcTextField type="number"
+ class="sharing__input"
+ :label="t('settings', 'Default expiration time of new shares in days')"
+ :placeholder="t('settings', 'Expire shares after x days')"
+ :value.sync="settings.internalExpireAfterNDays" />
+ </fieldset>
+
+ <NcCheckboxRadioSwitch type="switch"
+ aria-controls="settings-sharing-remote-api-expiration"
+ :checked.sync="settings.defaultRemoteExpireDate">
+ {{ t('settings', 'Set default expiration date for shares to other servers') }}
+ </NcCheckboxRadioSwitch>
+ <fieldset v-show="settings.defaultRemoteExpireDate" id="settings-sharing-remote-api-expiration" class="sharing__sub-section">
+ <NcCheckboxRadioSwitch :checked.sync="settings.enforceRemoteExpireDate">
+ {{ t('settings', 'Enforce expiration date for remote shares') }}
+ </NcCheckboxRadioSwitch>
+ <NcTextField type="number"
+ class="sharing__input"
+ :label="t('settings', 'Default expiration time of remote shares in days')"
+ :placeholder="t('settings', 'Expire remote shares after x days')"
+ :value.sync="settings.remoteExpireAfterNDays" />
+ </fieldset>
+
+ <NcCheckboxRadioSwitch type="switch"
+ aria-controls="settings-sharing-api-api-expiration"
+ :checked.sync="settings.defaultExpireDate"
+ :disabled="!settings.allowLinks">
+ {{ t('settings', 'Set default expiration date for shares via link or mail') }}
+ </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 link or mail shares') }}
+ </NcCheckboxRadioSwitch>
+ <NcTextField type="number"
+ class="sharing__input"
+ :label="t('settings', 'Default expiration time of shares in days')"
+ :placeholder="t('settings', 'Expire shares after x days')"
+ :value.sync="settings.expireAfterNDays" />
+ </fieldset>
+ </div>
+
+ <div v-show="settings.enabled" id="settings-sharing-privary-related" class="sharing__section">
+ <h3>{{ t('settings', 'Privacy settings for sharing') }}</h3>
+
+ <NcCheckboxRadioSwitch type="switch"
+ aria-controls="settings-sharing-privacy-user-enumeration"
+ :checked.sync="settings.allowShareDialogUserEnumeration">
+ {{ t('settings', 'Allow account name autocompletion in share dialog and allow access to the system address book') }}
+ </NcCheckboxRadioSwitch>
+ <fieldset v-show="settings.allowShareDialogUserEnumeration" id="settings-sharing-privacy-user-enumeration" class="sharing__sub-section">
+ <em>
+ {{ t('settings', 'If autocompletion "same group" and "phone number integration" are enabled a match in either is enough to show the user.') }}
+ </em>
+ <NcCheckboxRadioSwitch :checked.sync="settings.restrictUserEnumerationToGroup">
+ {{ t('settings', 'Restrict account name autocompletion and system address book access to users within the same groups') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked.sync="settings.restrictUserEnumerationToPhone">
+ {{ t('settings', 'Restrict account name autocompletion to users based on phone number integration') }}
+ </NcCheckboxRadioSwitch>
+ </fieldset>
+
+ <NcCheckboxRadioSwitch type="switch" :checked.sync="settings.restrictUserEnumerationFullMatch">
+ {{ t('settings', 'Allow autocompletion when entering the full name or email address (ignoring missing phonebook match and being in the same group)') }}
+ </NcCheckboxRadioSwitch>
+
+ <NcCheckboxRadioSwitch type="switch" :checked.sync="publicShareDisclaimerEnabled">
+ {{ t('settings', 'Show disclaimer text on the public link upload page (only shown when the file list is hidden)') }}
+ </NcCheckboxRadioSwitch>
+ <div v-if="publicShareDisclaimerEnabled"
+ aria-describedby="settings-sharing-privary-related-disclaimer-hint"
+ class="sharing__sub-section">
+ <NcTextArea class="sharing__input"
+ :label="t('settings', 'Disclaimer text')"
+ aria-describedby="settings-sharing-privary-related-disclaimer-hint"
+ :value="settings.publicShareDisclaimerText"
+ @update:value="onUpdateDisclaimer" />
+ <em id="settings-sharing-privary-related-disclaimer-hint" class="sharing__input">
+ {{ t('settings', 'This text will be shown on the public link upload page when the file list is hidden.') }}
+ </em>
+ </div>
+ </div>
+
+ <div id="settings-sharing-default-permissions" class="sharing__section">
+ <h3>{{ t('settings', 'Default share permissions') }}</h3>
+ <SelectSharingPermissions :value.sync="settings.defaultPermissions" />
+ </div>
+ </form>
+</template>
+
+<script lang="ts">
+import { showError, showSuccess } from '@nextcloud/dialogs'
+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 {
+ enabled: boolean
+ allowGroupSharing: boolean
+ allowLinks: boolean
+ allowLinksExcludeGroups: unknown
+ allowPublicUpload: boolean
+ allowResharing: boolean
+ allowShareDialogUserEnumeration: boolean
+ allowFederationOnPublicShares: boolean
+ restrictUserEnumerationToGroup: boolean
+ restrictUserEnumerationToPhone: boolean
+ restrictUserEnumerationFullMatch: boolean
+ restrictUserEnumerationFullMatchUserId: boolean
+ restrictUserEnumerationFullMatchEmail: boolean
+ restrictUserEnumerationFullMatchIgnoreSecondDN: boolean
+ enforceLinksPassword: boolean
+ enforceLinksPasswordExcludedGroups: string[]
+ enforceLinksPasswordExcludedGroupsEnabled: boolean
+ onlyShareWithGroupMembers: boolean
+ onlyShareWithGroupMembersExcludeGroupList: string[]
+ defaultExpireDate: boolean
+ expireAfterNDays: string
+ enforceExpireDate: boolean
+ excludeGroups: string
+ excludeGroupsList: string[]
+ publicShareDisclaimerText: string
+ enableLinkPasswordByDefault: boolean
+ defaultPermissions: number
+ defaultInternalExpireDate: boolean
+ internalExpireAfterNDays: string
+ enforceInternalExpireDate: boolean
+ defaultRemoteExpireDate: boolean
+ remoteExpireAfterNDays: string
+ enforceRemoteExpireDate: boolean
+ allowCustomTokens: boolean
+ allowViewWithoutDownload: boolean
+}
+
+export default defineComponent({
+ name: 'AdminSettingsSharingForm',
+ components: {
+ NcCheckboxRadioSwitch,
+ NcSettingsSelectGroup,
+ NcNoteCard,
+ NcTextArea,
+ NcTextField,
+ SelectSharingPermissions,
+ },
+ data() {
+ const settingsData = loadState<IShareSettings>('settings', 'sharingSettings')
+ return {
+ settingsData,
+ publicShareDisclaimerEnabled: settingsData.publicShareDisclaimerText !== '',
+ }
+ },
+ computed: {
+ settings() {
+ console.warn('new proxy')
+ return new Proxy(this.settingsData, {
+ get(target, property) {
+ return target[property]
+ },
+ set(target, property: string, newValue) {
+ const configName = `shareapi_${snakeCase(property)}`
+ const value = typeof newValue === 'boolean' ? (newValue ? 'yes' : 'no') : (typeof newValue === 'string' ? newValue : JSON.stringify(newValue))
+ window.OCP.AppConfig.setValue('core', configName, value)
+ target[property] = newValue
+ return true
+ },
+ })
+ },
+ },
+
+ watch: {
+ publicShareDisclaimerEnabled() {
+ // When disabled we just remove the disclaimer content
+ if (this.publicShareDisclaimerEnabled === false) {
+ this.onUpdateDisclaimer('')
+ }
+ },
+ },
+
+ methods: {
+ t,
+
+ onUpdateDisclaimer: debounce(function(value: string) {
+ const options = {
+ success() {
+ if (value !== '') {
+ showSuccess(t('settings', 'Changed disclaimer text'))
+ } else {
+ showSuccess(t('settings', 'Deleted disclaimer text'))
+ }
+ },
+ error() {
+ showError(t('settings', 'Could not set disclaimer text'))
+ },
+ }
+ if (value === '') {
+ window.OCP.AppConfig.deleteKey('core', 'shareapi_public_link_disclaimertext', options)
+ } else {
+ window.OCP.AppConfig.setValue('core', 'shareapi_public_link_disclaimertext', value, options)
+ }
+ this.settingsData.publicShareDisclaimerText = value
+ }, 500) as (v?: string) => void,
+ onUpdateExcludeGroups: debounce(function(value: string) {
+ window.OCP.AppConfig.setValue('core', 'excludeGroups', value)
+ this.settings.excludeGroups = value
+ }, 500) as (v?: string) => void,
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.sharing {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ &__labeled-entry {
+ display: flex;
+ flex: 1 0;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__section {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-block-end: 12px
+ }
+
+ &__sub-section {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ margin-inline-start: 44px;
+ margin-block-end: 12px
+ }
+
+ &__input {
+ max-width: 500px;
+ // align with checkboxes
+ margin-inline-start: 14px;
+
+ :deep(.v-select.select) {
+ width: 100%;
+ }
+ }
+
+ & &__note {
+ margin: 2px 0;
+ }
+}
+
+@media only screen and (max-width: 350px) {
+ // ensure no overflow happens on small devices (required for WCAG)
+ .sharing {
+ &__sub-section {
+ margin-inline-start: 14px;
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AdminTwoFactor.vue b/apps/settings/src/components/AdminTwoFactor.vue
index bfec05e331b..e24bee02593 100644
--- a/apps/settings/src/components/AdminTwoFactor.vue
+++ b/apps/settings/src/components/AdminTwoFactor.vue
@@ -1,83 +1,94 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div>
- <p class="settings-hint">
- {{ t('settings', 'Two-factor authentication can be enforced for all users and specific groups. If they do not have a two-factor provider configured, they will be unable to log into the system.') }}
- </p>
+ <NcSettingsSection :name="t('settings', 'Two-Factor Authentication')"
+ :description="t('settings', 'Two-factor authentication can be enforced for all accounts and specific groups. If they do not have a two-factor provider configured, they will be unable to log into the system.')"
+ :doc-url="twoFactorAdminDoc">
<p v-if="loading">
<span class="icon-loading-small two-factor-loading" />
<span>{{ t('settings', 'Enforce two-factor authentication') }}</span>
</p>
- <p v-else>
- <input id="two-factor-enforced"
- v-model="enforced"
- type="checkbox"
- class="checkbox">
- <label for="two-factor-enforced">{{ t('settings', 'Enforce two-factor authentication') }}</label>
- </p>
+ <NcCheckboxRadioSwitch v-else
+ id="two-factor-enforced"
+ :checked.sync="enforced"
+ type="switch">
+ {{ t('settings', 'Enforce two-factor authentication') }}
+ </NcCheckboxRadioSwitch>
<template v-if="enforced">
<h3>{{ t('settings', 'Limit to groups') }}</h3>
{{ t('settings', 'Enforcement of two-factor authentication can be set for certain groups only.') }}
- <p>
+ <p class="top-margin">
{{ t('settings', 'Two-factor authentication is enforced for all members of the following groups.') }}
</p>
<p>
- <Multiselect v-model="enforcedGroups"
+ <label for="enforcedGroups">
+ <span>{{ t('settings', 'Enforced groups') }}</span>
+ </label>
+ <NcSelect v-model="enforcedGroups"
+ input-id="enforcedGroups"
:options="groups"
- :placeholder="t('settings', 'Enforced groups')"
:disabled="loading"
:multiple="true"
- :searchable="true"
:loading="loadingGroups"
- :show-no-options="false"
:close-on-select="false"
- @search-change="searchGroup" />
+ @search="searchGroup" />
</p>
- <p>
+ <p class="top-margin">
{{ t('settings', 'Two-factor authentication is not enforced for members of the following groups.') }}
</p>
<p>
- <Multiselect v-model="excludedGroups"
+ <label for="excludedGroups">
+ <span>{{ t('settings', 'Excluded groups') }}</span>
+ </label>
+ <NcSelect v-model="excludedGroups"
+ input-id="excludedGroups"
:options="groups"
- :placeholder="t('settings', 'Excluded groups')"
:disabled="loading"
:multiple="true"
- :searchable="true"
:loading="loadingGroups"
- :show-no-options="false"
:close-on-select="false"
- @search-change="searchGroup" />
+ @search="searchGroup" />
</p>
- <p>
+ <p class="top-margin">
<em>
<!-- this text is also found in the documentation. update it there as well if it ever changes -->
- {{ t('settings', 'When groups are selected/excluded, they use the following logic to determine if a user has 2FA enforced: If no groups are selected, 2FA is enabled for everyone except members of the excluded groups. If groups are selected, 2FA is enabled for all members of these. If a user is both in a selected and excluded group, the selected takes precedence and 2FA is enforced.') }}
+ {{ t('settings', 'When groups are selected/excluded, they use the following logic to determine if an account has 2FA enforced: If no groups are selected, 2FA is enabled for everyone except members of the excluded groups. If groups are selected, 2FA is enabled for all members of these. If an account is both in a selected and excluded group, the selected takes precedence and 2FA is enforced.') }}
</em>
</p>
</template>
- <p>
- <Button v-if="dirty"
+ <p class="top-margin">
+ <NcButton v-if="dirty"
type="primary"
:disabled="loading"
@click="saveChanges">
{{ t('settings', 'Save changes') }}
- </Button>
+ </NcButton>
</p>
- </div>
+ </NcSettingsSection>
</template>
<script>
import axios from '@nextcloud/axios'
-import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
-import Button from '@nextcloud/vue/dist/Components/Button'
+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 _ from 'lodash'
+import sortedUniq from 'lodash/sortedUniq.js'
+import uniq from 'lodash/uniq.js'
+import debounce from 'lodash/debounce.js'
import { generateUrl, generateOcsUrl } from '@nextcloud/router'
export default {
name: 'AdminTwoFactor',
components: {
- Multiselect,
- Button,
+ NcSelect,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcSettingsSection,
},
data() {
return {
@@ -85,6 +96,7 @@ export default {
dirty: false,
groups: [],
loadingGroups: false,
+ twoFactorAdminDoc: loadState('settings', 'two-factor-admin-doc'),
}
},
computed: {
@@ -119,19 +131,19 @@ export default {
mounted() {
// Groups are loaded dynamically, but the assigned ones *should*
// be valid groups, so let's add them as initial state
- this.groups = _.sortedUniq(_.uniq(this.enforcedGroups.concat(this.excludedGroups)))
+ this.groups = sortedUniq(uniq(this.enforcedGroups.concat(this.excludedGroups)))
// Populate the groups with a first set so the dropdown is not empty
// when opening the page the first time
this.searchGroup('')
},
methods: {
- searchGroup: _.debounce(function(query) {
+ searchGroup: debounce(function(query) {
this.loadingGroups = true
axios.get(generateOcsUrl('cloud/groups?offset=0&search={query}&limit=20', { query }))
.then(res => res.data.ocs)
.then(ocs => ocs.data.groups)
- .then(groups => { this.groups = _.sortedUniq(_.uniq(this.groups.concat(groups))) })
+ .then(groups => { this.groups = sortedUniq(uniq(this.groups.concat(groups))) })
.catch(err => console.error('could not search groups', err))
.then(() => { this.loadingGroups = false })
}, 500),
@@ -159,11 +171,14 @@ export default {
}
</script>
-<style>
+<style scoped>
.two-factor-loading {
display: inline-block;
vertical-align: sub;
- margin-left: -2px;
- margin-right: 1px;
+ margin-inline: -2px 1px;
+ }
+
+ .top-margin {
+ margin-top: 0.5rem;
}
</style>
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/AppDetails.vue b/apps/settings/src/components/AppDetails.vue
deleted file mode 100644
index f4faa36eed4..00000000000
--- a/apps/settings/src/components/AppDetails.vue
+++ /dev/null
@@ -1,256 +0,0 @@
-<!--
- - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
-<template>
- <div class="app-details">
- <div class="app-details__actions">
- <div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups">
- <input :id="prefix('groups_enable', app.id)"
- v-model="groupCheckedAppsData"
- type="checkbox"
- :value="app.id"
- class="groups-enable__checkbox checkbox"
- @change="setGroupLimit">
- <label :for="prefix('groups_enable', app.id)">{{ t('settings', 'Limit to groups') }}</label>
- <input type="hidden"
- class="group_select"
- :title="t('settings', 'All')"
- value="">
- <Multiselect v-if="isLimitedToGroups(app)"
- :options="groups"
- :value="appGroups"
- :options-limit="5"
- :placeholder="t('settings', 'Limit app usage to groups')"
- label="name"
- track-by="id"
- class="multiselect-vue"
- :multiple="true"
- :close-on-select="false"
- :tag-width="60"
- @select="addGroupLimitation"
- @remove="removeGroupLimitation"
- @search-change="asyncFindGroup">
- <span slot="noResult">{{ t('settings', 'No results') }}</span>
- </Multiselect>
- </div>
- <div class="app-details__actions-manage">
- <input v-if="app.update"
- class="update primary"
- type="button"
- :value="t('settings', 'Update to {version}', { version: app.update })"
- :disabled="installing || isLoading"
- @click="update(app.id)">
- <input v-if="app.canUnInstall"
- class="uninstall"
- type="button"
- :value="t('settings', 'Remove')"
- :disabled="installing || isLoading"
- @click="remove(app.id)">
- <input v-if="app.active"
- class="enable"
- type="button"
- :value="t('settings','Disable')"
- :disabled="installing || isLoading"
- @click="disable(app.id)">
- <input v-if="!app.active && (app.canInstall || app.isCompatible)"
- v-tooltip.auto="enableButtonTooltip"
- class="enable primary"
- type="button"
- :value="enableButtonText"
- :disabled="!app.canInstall || installing || isLoading"
- @click="enable(app.id)">
- <input v-else-if="!app.active && !app.canInstall"
- v-tooltip.auto="forceEnableButtonTooltip"
- class="enable force"
- type="button"
- :value="forceEnableButtonText"
- :disabled="installing || isLoading"
- @click="forceEnable(app.id)">
- </div>
- </div>
-
- <ul class="app-details__dependencies">
- <li v-if="app.missingMinOwnCloudVersion">
- {{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }}
- </li>
- <li v-if="app.missingMaxOwnCloudVersion">
- {{ t('settings', 'This app has no maximum Nextcloud version assigned. This will be an error in the future.') }}
- </li>
- <li v-if="!app.canInstall">
- {{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
- <ul class="missing-dependencies">
- <li v-for="(dep, index) in app.missingDependencies" :key="index">
- {{ dep }}
- </li>
- </ul>
- </li>
- </ul>
-
- <p class="app-details__documentation">
- <a v-if="!app.internal"
- class="appslink"
- :href="appstoreUrl"
- target="_blank"
- rel="noreferrer noopener">{{ t('settings', 'View in store') }} ↗</a>
-
- <a v-if="app.website"
- class="appslink"
- :href="app.website"
- target="_blank"
- rel="noreferrer noopener">{{ t('settings', 'Visit website') }} ↗</a>
- <a v-if="app.bugs"
- class="appslink"
- :href="app.bugs"
- target="_blank"
- rel="noreferrer noopener">{{ t('settings', 'Report a bug') }} ↗</a>
-
- <a v-if="app.documentation && app.documentation.user"
- class="appslink"
- :href="app.documentation.user"
- target="_blank"
- rel="noreferrer noopener">{{ t('settings', 'User documentation') }} ↗</a>
- <a v-if="app.documentation && app.documentation.admin"
- class="appslink"
- :href="app.documentation.admin"
- target="_blank"
- rel="noreferrer noopener">{{ t('settings', 'Admin documentation') }} ↗</a>
- <a v-if="app.documentation && app.documentation.developer"
- class="appslink"
- :href="app.documentation.developer"
- target="_blank"
- rel="noreferrer noopener">{{ t('settings', 'Developer documentation') }} ↗</a>
- </p>
- <Markdown class="app-details__description" :text="app.description" />
- </div>
-</template>
-
-<script>
-import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
-
-import AppManagement from '../mixins/AppManagement'
-import PrefixMixin from './PrefixMixin'
-import Markdown from './Markdown'
-
-export default {
- name: 'AppDetails',
-
- components: {
- Multiselect,
- Markdown,
- },
- mixins: [AppManagement, PrefixMixin],
-
- props: {
- app: {
- type: Object,
- required: true,
- },
- },
-
- data() {
- return {
- groupCheckedAppsData: false,
- }
- },
-
- computed: {
- appstoreUrl() {
- return `https://apps.nextcloud.com/apps/${this.app.id}`
- },
- licence() {
- if (this.app.licence) {
- return t('settings', '{license}-licensed', { license: ('' + this.app.licence).toUpperCase() })
- }
- return null
- },
- author() {
- if (typeof this.app.author === 'string') {
- return [
- {
- '@value': this.app.author,
- },
- ]
- }
- if (this.app.author['@value']) {
- return [this.app.author]
- }
- return this.app.author
- },
- appGroups() {
- return this.app.groups.map(group => { return { id: group, name: group } })
- },
- groups() {
- return this.$store.getters.getGroups
- .filter(group => group.id !== 'disabled')
- .sort((a, b) => a.name.localeCompare(b.name))
- },
- },
- mounted() {
- if (this.app.groups.length > 0) {
- this.groupCheckedAppsData = true
- }
- },
-}
-</script>
-
-<style scoped lang="scss">
-.app-details {
- padding: 20px;
-
- &__actions {
- // app management
- &-manage {
- // if too many, shrink them and ellipsis
- display: flex;
- input {
- flex: 0 1 auto;
- min-width: 0;
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
- }
- }
- }
- &__dependencies {
- opacity: .7;
- }
- &__documentation {
- padding-top: 20px;
- }
- &__description {
- padding-top: 20px;
- }
-}
-
-.force {
- color: var(--color-error);
- border-color: var(--color-error);
- background: var(--color-main-background);
-}
-.force:hover,
-.force:active {
- color: var(--color-main-background);
- border-color: var(--color-error) !important;
- background: var(--color-error);
-}
-
-</style>
diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue
index 3a0c1fe51d0..3e40e08b257 100644
--- a/apps/settings/src/components/AppList.vue
+++ b/apps/settings/src/components/AppList.vue
@@ -1,96 +1,133 @@
<!--
- - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div id="app-content-inner">
- <div id="apps-list" class="apps-list" :class="{installed: (useBundleView || useListView), store: useAppStoreView}">
+ <div id="apps-list"
+ class="apps-list"
+ :class="{
+ 'apps-list--list-view': (useBundleView || useListView),
+ 'apps-list--store-view': useAppStoreView,
+ }">
<template v-if="useListView">
- <div v-if="showUpdateAll" class="toolbar">
+ <div v-if="showUpdateAll" class="apps-list__toolbar">
{{ n('settings', '%n app has an update available', '%n apps have an update available', counter) }}
- <Button v-if="showUpdateAll"
+ <NcButton v-if="showUpdateAll"
id="app-list-update-all"
type="primary"
@click="updateAll">
{{ n('settings', 'Update', 'Update all', counter) }}
- </Button>
+ </NcButton>
</div>
- <div v-if="!showUpdateAll" class="toolbar">
+ <div v-if="!showUpdateAll" class="apps-list__toolbar">
{{ t('settings', 'All apps are up-to-date.') }}
</div>
- <transition-group name="app-list" tag="div" class="apps-list-container">
+ <TransitionGroup name="apps-list" tag="table" class="apps-list__list-container">
+ <tr key="app-list-view-header">
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Name') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Version') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Level') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
+ </th>
+ </tr>
<AppItem v-for="app in apps"
:key="app.id"
:app="app"
:category="category" />
- </transition-group>
+ </TransitionGroup>
</template>
- <transition-group v-if="useBundleView"
- name="app-list"
- tag="div"
- class="apps-list-container">
+ <table v-if="useBundleView"
+ class="apps-list__list-container">
+ <tr key="app-list-view-header">
+ <th id="app-table-col-icon">
+ <span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
+ </th>
+ <th id="app-table-col-name">
+ <span class="hidden-visually">{{ t('settings', 'Name') }}</span>
+ </th>
+ <th id="app-table-col-version">
+ <span class="hidden-visually">{{ t('settings', 'Version') }}</span>
+ </th>
+ <th id="app-table-col-level">
+ <span class="hidden-visually">{{ t('settings', 'Level') }}</span>
+ </th>
+ <th id="app-table-col-actions">
+ <span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
+ </th>
+ </tr>
<template v-for="bundle in bundles">
- <div :key="bundle.id" class="apps-header">
- <div class="app-image" />
- <h2>{{ bundle.name }} <input type="button" :value="bundleToggleText(bundle.id)" @click="toggleBundle(bundle.id)"></h2>
- <div class="app-version" />
- <div class="app-level" />
- <div class="app-groups" />
- <div class="actions">
- &nbsp;
- </div>
- </div>
+ <tr :key="bundle.id">
+ <th :id="`app-table-rowgroup-${bundle.id}`" colspan="5" scope="rowgroup">
+ <div class="apps-list__bundle-heading">
+ <span class="apps-list__bundle-header">
+ {{ bundle.name }}
+ </span>
+ <NcButton type="secondary" @click="toggleBundle(bundle.id)">
+ {{ t('settings', bundleToggleText(bundle.id)) }}
+ </NcButton>
+ </div>
+ </th>
+ </tr>
<AppItem v-for="app in bundleApps(bundle.id)"
:key="bundle.id + app.id"
+ :use-bundle-view="true"
+ :headers="`app-table-rowgroup-${bundle.id}`"
:app="app"
:category="category" />
</template>
- </transition-group>
- <template v-if="useAppStoreView">
+ </table>
+ <ul v-if="useAppStoreView" class="apps-list__store-container">
<AppItem v-for="app in apps"
:key="app.id"
:app="app"
:category="category"
:list-view="false" />
- </template>
+ </ul>
</div>
- <div id="apps-list-search" class="apps-list installed">
- <div class="apps-list-container">
- <template v-if="search !== '' && searchApps.length > 0">
- <div class="section">
- <div />
- <td colspan="5">
- <h2>{{ t('settings', 'Results from other categories') }}</h2>
- </td>
- </div>
+ <div id="apps-list-search" class="apps-list apps-list--list-view">
+ <div class="apps-list__list-container">
+ <table v-if="search !== '' && searchApps.length > 0" class="apps-list__list-container">
+ <caption class="apps-list__bundle-header">
+ {{ t('settings', 'Results from other categories') }}
+ </caption>
+ <tr key="app-list-view-header">
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Name') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Version') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Level') }}</span>
+ </th>
+ <th>
+ <span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
+ </th>
+ </tr>
<AppItem v-for="app in searchApps"
:key="app.id"
:app="app"
- :category="category"
- :list-view="true" />
- </template>
+ :category="category" />
+ </table>
</div>
</div>
@@ -98,31 +135,58 @@
<div id="app-list-empty-icon" class="icon-settings-dark" />
<h2>{{ t('settings', 'No apps found for your version') }}</h2>
</div>
-
- <div id="searchresults" />
</div>
</template>
<script>
-import AppItem from './AppList/AppItem'
-import PrefixMixin from './PrefixMixin'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import pLimit from 'p-limit'
-import Button from '@nextcloud/vue/dist/Components/Button'
+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'
+import { useAppsStore } from '../store/apps-store'
export default {
name: 'AppList',
components: {
AppItem,
- Button,
+ NcButton,
+ },
+
+ mixins: [AppManagement],
+
+ props: {
+ category: {
+ type: String,
+ required: true,
+ },
+ },
+
+ setup() {
+ const appApiStore = useAppApiStore()
+ const store = useAppsStore()
+
+ return {
+ appApiStore,
+ store,
+ }
+ },
+
+ data() {
+ return {
+ search: '',
+ }
},
- mixins: [PrefixMixin],
- props: ['category', 'app', 'search'],
computed: {
counter() {
return this.apps.filter(app => app.update).length
},
loading() {
- return this.$store.getters.loading('list')
+ if (!this.$store.getters['appApiApps/isAppApiEnabled']) {
+ return this.$store.getters.loading('list')
+ }
+ return this.$store.getters.loading('list') || this.appApiStore.getLoading('list')
},
hasPendingUpdate() {
return this.apps.filter(app => app.update).length > 0
@@ -131,12 +195,18 @@ export default {
return this.hasPendingUpdate && this.useListView
},
apps() {
- const apps = this.$store.getters.getAllApps
+ // Exclude ExApps from the list if AppAPI is disabled
+ const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : []
+ 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') {
@@ -154,9 +224,15 @@ export default {
if (this.category === 'updates') {
return apps.filter(app => app.update)
}
+ if (this.category === 'supported') {
+ // For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
+ return apps.filter(app => app.level === 300)
+ }
if (this.category === 'featured') {
+ // An app level of `200` will be set for apps featured on the app store
return apps.filter(app => app.level === 200)
}
+
// filter app store categories
return apps.filter(app => {
return app.appstore && app.category !== undefined
@@ -164,7 +240,7 @@ export default {
})
},
bundles() {
- return this.$store.getters.getServerData.bundles.filter(bundle => this.bundleApps(bundle.id).length > 0)
+ return this.$store.getters.getAppBundles.filter(bundle => this.bundleApps(bundle.id).length > 0)
},
bundleApps() {
return function(bundle) {
@@ -178,7 +254,8 @@ export default {
if (this.search === '') {
return []
}
- return this.$store.getters.getAllApps
+ const exApps = this.$store.getters.isAppApiEnabled ? this.appApiStore.getAllApps : []
+ return [...this.$store.getters.getAllApps, ...exApps]
.filter(app => {
if (app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) {
return (!this.apps.find(_app => _app.id === app.id))
@@ -190,28 +267,43 @@ export default {
return !this.useListView && !this.useBundleView
},
useListView() {
- return (this.category === 'installed' || this.category === 'enabled' || this.category === 'disabled' || this.category === 'updates' || this.category === 'featured')
+ return (this.category === 'installed' || this.category === 'enabled' || this.category === 'disabled' || this.category === 'updates' || this.category === 'featured' || this.category === 'supported')
},
useBundleView() {
return (this.category === 'app-bundles')
},
allBundlesEnabled() {
- const self = this
- return function(id) {
- return self.bundleApps(id).filter(app => !app.active).length === 0
+ return (id) => {
+ return this.bundleApps(id).filter(app => !app.active).length === 0
}
},
bundleToggleText() {
- const self = this
- return function(id) {
- if (self.allBundlesEnabled(id)) {
+ return (id) => {
+ if (this.allBundlesEnabled(id)) {
return t('settings', 'Disable all')
}
- return t('settings', 'Enable all')
+ return t('settings', 'Download and enable all')
}
},
},
+
+ beforeDestroy() {
+ unsubscribe('nextcloud:unified-search.search', this.setSearch)
+ unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
+ },
+
+ mounted() {
+ subscribe('nextcloud:unified-search.search', this.setSearch)
+ subscribe('nextcloud:unified-search.reset', this.resetSearch)
+ },
+
methods: {
+ setSearch({ query }) {
+ this.search = query
+ },
+ resetSearch() {
+ this.search = ''
+ },
toggleBundle(id) {
if (this.allBundlesEnabled(id)) {
return this.disableBundle(id)
@@ -237,9 +329,83 @@ export default {
const limit = pLimit(1)
this.apps
.filter(app => app.update)
- .map(app => limit(() => this.$store.dispatch('updateApp', { appId: app.id }))
- )
+ .map((app) => limit(() => {
+ this.update(app.id)
+ }))
},
},
}
</script>
+
+<style lang="scss" scoped>
+$toolbar-padding: 8px;
+$toolbar-height: 44px + $toolbar-padding * 2;
+
+.apps-list {
+ display: flex;
+ flex-wrap: wrap;
+ align-content: flex-start;
+
+ // For transition group
+ &--move {
+ transition: transform 1s;
+ }
+
+ #app-list-update-all {
+ margin-inline-start: 10px;
+ }
+
+ &__toolbar {
+ height: $toolbar-height;
+ padding: $toolbar-padding;
+ // Leave room for app-navigation-toggle
+ padding-inline-start: $toolbar-height;
+ width: 100%;
+ background-color: var(--color-main-background);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ }
+
+ &--list-view {
+ margin-bottom: 100px;
+ // For positioning link overlay on rows
+ position: relative;
+ }
+
+ &__list-container {
+ width: 100%;
+ }
+
+ &__store-container {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ &__bundle-heading {
+ display: flex;
+ align-items: center;
+ margin-block: 20px;
+ margin-inline: 0 10px;
+ }
+
+ &__bundle-header {
+ margin-block: 0;
+ margin-inline: 50px 10px;
+ font-weight: bold;
+ font-size: 20px;
+ line-height: 30px;
+ color: var(--color-text-light);
+ }
+}
+
+#apps-list-search {
+ .app-item {
+ h2 {
+ margin-bottom: 0;
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AppList/AppDaemonBadge.vue b/apps/settings/src/components/AppList/AppDaemonBadge.vue
new file mode 100644
index 00000000000..ca81e7fab0b
--- /dev/null
+++ b/apps/settings/src/components/AppList/AppDaemonBadge.vue
@@ -0,0 +1,37 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <span v-if="daemon"
+ class="app-daemon-badge"
+ :title="daemon.name">
+ <NcIconSvgWrapper :path="mdiFileChart" :size="20" inline />
+ {{ daemon.display_name }}
+ </span>
+</template>
+
+<script setup lang="ts">
+import type { IDeployDaemon } from '../../app-types.ts'
+import { mdiFileChart } from '@mdi/js'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+defineProps<{
+ daemon?: IDeployDaemon
+}>()
+</script>
+
+<style scoped lang="scss">
+.app-daemon-badge {
+ color: var(--color-text-maxcontrast);
+ background-color: transparent;
+ border: 1px solid var(--color-text-maxcontrast);
+ border-radius: var(--border-radius);
+
+ display: flex;
+ flex-direction: row;
+ gap: 6px;
+ padding: 3px 6px;
+ width: fit-content;
+}
+</style>
diff --git a/apps/settings/src/components/AppList/AppItem.vue b/apps/settings/src/components/AppList/AppItem.vue
index f6431d64e18..95a98a93cde 100644
--- a/apps/settings/src/components/AppList/AppItem.vue
+++ b/apps/settings/src/components/AppList/AppItem.vue
@@ -1,141 +1,204 @@
<!--
- - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div class="section" :class="{ selected: isSelected }" @click="showAppDetails">
- <div class="app-image app-image-icon" @click="showAppDetails">
- <div v-if="(listView && !app.preview) || (!listView && !screenshotLoaded)" class="icon-settings-dark" />
+ <component :is="listView ? 'tr' : (inline ? 'article' : 'li')"
+ class="app-item"
+ :class="{
+ 'app-item--list-view': listView,
+ 'app-item--store-view': !listView,
+ 'app-item--selected': isSelected,
+ 'app-item--with-sidebar': withSidebar,
+ }">
+ <component :is="dataItemTag"
+ class="app-image app-image-icon"
+ :headers="getDataItemHeaders(`app-table-col-icon`)">
+ <div v-if="!app?.app_api && shouldDisplayDefaultIcon" class="icon-settings-dark" />
+ <NcIconSvgWrapper v-else-if="app.app_api && shouldDisplayDefaultIcon"
+ :path="mdiCogOutline"
+ :size="listView ? 24 : 48"
+ style="min-width: auto; min-height: auto; height: 100%;" />
- <svg v-else-if="listView && app.preview"
+ <svg v-else-if="listView && app.preview && !app.app_api"
width="32"
height="32"
viewBox="0 0 32 32">
- <defs><filter :id="filterId"><feColorMatrix in="SourceGraphic" type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0" /></filter></defs>
<image x="0"
y="0"
width="32"
height="32"
preserveAspectRatio="xMinYMin meet"
- :filter="filterUrl"
:xlink:href="app.preview"
class="app-icon" />
</svg>
- <img v-if="!listView && app.screenshot && screenshotLoaded" :src="app.screenshot" width="100%">
- </div>
- <div class="app-name" @click="showAppDetails">
- {{ app.name }}
- </div>
- <div v-if="!listView" class="app-summary">
+ <img v-if="!listView && app.screenshot && screenshotLoaded" :src="app.screenshot" alt="">
+ </component>
+ <component :is="dataItemTag"
+ class="app-name"
+ :headers="getDataItemHeaders(`app-table-col-name`)">
+ <router-link class="app-name--link"
+ :to="{
+ name: 'apps-details',
+ params: {
+ category: category,
+ id: app.id
+ },
+ }"
+ :aria-label="t('settings', 'Show details for {appName} app', { appName:app.name })">
+ {{ app.name }}
+ </router-link>
+ </component>
+ <component :is="dataItemTag"
+ v-if="!listView"
+ class="app-summary"
+ :headers="getDataItemHeaders(`app-version`)">
{{ app.summary }}
- </div>
- <div v-if="listView" class="app-version">
+ </component>
+ <component :is="dataItemTag"
+ v-if="listView"
+ class="app-version"
+ :headers="getDataItemHeaders(`app-table-col-version`)">
<span v-if="app.version">{{ app.version }}</span>
<span v-else-if="app.appstoreData.releases[0].version">{{ app.appstoreData.releases[0].version }}</span>
- </div>
-
- <div class="app-level">
- <span v-if="app.level === 300"
- v-tooltip.auto="t('settings', 'This app is supported via your current Nextcloud subscription.')"
- class="supported icon-checkmark-color">
- {{ t('settings', 'Supported') }}</span>
- <span v-if="app.level === 200"
- v-tooltip.auto="t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.')"
- class="official icon-checkmark">
- {{ t('settings', 'Featured') }}</span>
- <AppScore v-if="hasRating && !listView" :score="app.score" />
- </div>
+ </component>
- <div class="actions">
+ <component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-level`)" class="app-level">
+ <AppLevelBadge :level="app.level" />
+ <AppScore v-if="hasRating && !listView" :score="app.score" />
+ </component>
+ <component :is="dataItemTag"
+ v-if="!inline"
+ :headers="getDataItemHeaders(`app-table-col-actions`)"
+ class="app-actions">
<div v-if="app.error" class="warning">
{{ app.error }}
</div>
- <div v-if="isLoading" class="icon icon-loading-small" />
- <input v-if="app.update"
- class="update primary"
- type="button"
- :value="t('settings', 'Update to {update}', {update:app.update})"
- :disabled="installing || isLoading"
+ <div v-if="isLoading || isInitializing" class="icon icon-loading-small" />
+ <NcButton v-if="app.update"
+ type="primary"
+ :disabled="installing || isLoading || !defaultDeployDaemonAccessible || isManualInstall"
+ :title="updateButtonText"
@click.stop="update(app.id)">
- <input v-if="app.canUnInstall"
+ {{ t('settings', 'Update to {update}', {update:app.update}) }}
+ </NcButton>
+ <NcButton v-if="app.canUnInstall"
class="uninstall"
- type="button"
- :value="t('settings', 'Remove')"
+ type="tertiary"
:disabled="installing || isLoading"
@click.stop="remove(app.id)">
- <input v-if="app.active"
- class="enable"
- type="button"
- :value="t('settings','Disable')"
- :disabled="installing || isLoading"
+ {{ t('settings', 'Remove') }}
+ </NcButton>
+ <NcButton v-if="app.active"
+ :disabled="installing || isLoading || isInitializing || isDeploying"
@click.stop="disable(app.id)">
- <input v-if="!app.active && (app.canInstall || app.isCompatible)"
- v-tooltip.auto="enableButtonTooltip"
- class="enable"
- type="button"
- :value="enableButtonText"
- :disabled="!app.canInstall || installing || isLoading"
- @click.stop="enable(app.id)">
- <input v-else-if="!app.active"
- v-tooltip.auto="forceEnableButtonTooltip"
- class="enable force"
- type="button"
- :value="forceEnableButtonText"
- :disabled="installing || isLoading"
+ {{ disableButtonText }}
+ </NcButton>
+ <NcButton v-if="!app.active && (app.canInstall || app.isCompatible)"
+ :title="enableButtonTooltip"
+ :aria-label="enableButtonTooltip"
+ type="primary"
+ :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying"
+ @click.stop="enableButtonAction">
+ {{ enableButtonText }}
+ </NcButton>
+ <NcButton v-else-if="!app.active"
+ :title="forceEnableButtonTooltip"
+ :aria-label="forceEnableButtonTooltip"
+ type="secondary"
+ :disabled="installing || isLoading || !defaultDeployDaemonAccessible"
@click.stop="forceEnable(app.id)">
- </div>
- </div>
+ {{ forceEnableButtonText }}
+ </NcButton>
+
+ <DaemonSelectionDialog v-if="app?.app_api && showSelectDaemonModal"
+ :show.sync="showSelectDaemonModal"
+ :app="app" />
+ </component>
+ </component>
</template>
<script>
-import AppScore from './AppScore'
-import AppManagement from '../../mixins/AppManagement'
-import SvgFilterMixin from '../SvgFilterMixin'
+import { useAppsStore } from '../../store/apps-store.js'
+
+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/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',
components: {
+ AppLevelBadge,
AppScore,
+ NcButton,
+ NcIconSvgWrapper,
+ DaemonSelectionDialog,
},
mixins: [AppManagement, SvgFilterMixin],
props: {
- app: {},
- category: {},
+ app: {
+ type: Object,
+ required: true,
+ },
+ category: {
+ type: String,
+ required: true,
+ },
listView: {
type: Boolean,
default: true,
},
+ useBundleView: {
+ type: Boolean,
+ default: false,
+ },
+ headers: {
+ type: String,
+ default: null,
+ },
+ inline: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ setup() {
+ const store = useAppsStore()
+ const appApiStore = useAppApiStore()
+
+ return {
+ store,
+ appApiStore,
+ mdiCogOutline,
+ }
},
data() {
return {
isSelected: false,
scrolled: false,
screenshotLoaded: false,
+ showSelectDaemonModal: false,
}
},
computed: {
hasRating() {
return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5
},
+ dataItemTag() {
+ return this.listView ? 'td' : 'div'
+ },
+ withSidebar() {
+ return !!this.$route.params.id
+ },
+ shouldDisplayDefaultIcon() {
+ return (this.listView && !this.app.preview) || (!this.listView && !this.screenshotLoaded)
+ },
},
watch: {
'$route.params.id'(id) {
@@ -146,7 +209,7 @@ export default {
this.isSelected = (this.app.id === this.$route.params.id)
if (this.app.releases && this.app.screenshot) {
const image = new Image()
- image.onload = (e) => {
+ image.onload = () => {
this.screenshotLoaded = true
}
image.src = this.app.screenshot
@@ -156,36 +219,217 @@ export default {
},
methods: {
- async showAppDetails(event) {
- if (event.currentTarget.tagName === 'INPUT' || event.currentTarget.tagName === 'A') {
+ prefix(prefix, content) {
+ return prefix + '_' + content
+ },
+
+ 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
}
- try {
- await this.$router.push({
- name: 'apps-details',
- params: { category: this.category, id: this.app.id },
- })
- } catch (e) {
- // we already view this app
+ 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)
}
},
- prefix(prefix, content) {
- return prefix + '_' + content
- },
},
}
</script>
-<style scoped>
- .force {
- background: var(--color-main-background);
- border-color: var(--color-error);
- color: var(--color-error);
+<style scoped lang="scss">
+@use '../../../../../core/css/variables.scss' as variables;
+@use 'sass:math';
+
+.app-item {
+ position: relative;
+
+ &:hover {
+ background-color: var(--color-background-dark);
}
- .force:hover,
- .force:active {
- background: var(--color-error);
- border-color: var(--color-error) !important;
- color: var(--color-main-background);
+
+ &--list-view {
+ --app-item-padding: calc(var(--default-grid-baseline) * 2);
+ --app-item-height: calc(var(--default-clickable-area) + var(--app-item-padding) * 2);
+
+ &.app-item--selected {
+ background-color: var(--color-background-dark);
+ }
+
+ > * {
+ vertical-align: middle;
+ border-bottom: 1px solid var(--color-border);
+ padding: var(--app-item-padding);
+ height: var(--app-item-height);
+ }
+
+ .app-image {
+ width: var(--default-clickable-area);
+ height: auto;
+ text-align: end;
+ }
+
+ .app-image-icon svg,
+ .app-image-icon .icon-settings-dark {
+ margin-top: 5px;
+ width: 20px;
+ height: 20px;
+ opacity: .5;
+ background-size: cover;
+ display: inline-block;
+ }
+
+ .app-name {
+ padding: 0 var(--app-item-padding);
+ }
+
+ .app-name--link {
+ height: var(--app-item-height);
+ display: flex;
+ align-items: center;
+ }
+
+ // Note: because of Safari bug, we cannot position link overlay relative to the table row
+ // So we need to manually position it relative to the table container and cell
+ // See: https://bugs.webkit.org/show_bug.cgi?id=240961
+ .app-name--link::after {
+ content: '';
+ position: absolute;
+ inset-inline: 0;
+ height: var(--app-item-height);
+ }
+
+ .app-actions {
+ display: flex;
+ gap: var(--app-item-padding);
+ flex-wrap: wrap;
+ justify-content: end;
+
+ .icon-loading-small {
+ display: inline-block;
+ top: 4px;
+ margin-inline-end: 10px;
+ }
+ }
+
+ /* hide app version and level on narrower screens */
+ @media only screen and (max-width: 900px) {
+ .app-version,
+ .app-level {
+ display: none;
+ }
+ }
+
+ /* Hide actions on a small screen. Click on app opens fill-screen sidebar with the buttons */
+ @media only screen and (max-width: math.div(variables.$breakpoint-mobile, 2)) {
+ .app-actions {
+ display: none;
+ }
+ }
+ }
+
+ &--store-view {
+ padding: 30px;
+
+ .app-image-icon .icon-settings-dark {
+ width: 100%;
+ height: 150px;
+ background-size: 45px;
+ opacity: 0.5;
+ }
+
+ .app-image-icon svg {
+ position: absolute;
+ bottom: 43px;
+ /* position halfway vertically */
+ width: 64px;
+ height: 64px;
+ opacity: .1;
+ }
+
+ .app-name {
+ margin: 5px 0;
+ }
+
+ .app-name--link::after {
+ content: '';
+ position: absolute;
+ inset-block: 0;
+ inset-inline: 0;
+ }
+
+ .app-actions {
+ margin: 10px 0;
+ }
+
+ @media only screen and (min-width: 1601px) {
+ width: 25%;
+
+ &.app-item--with-sidebar {
+ width: 33%;
+ }
+ }
+
+ @media only screen and (max-width: 1600px) {
+ width: 25%;
+
+ &.app-item--with-sidebar {
+ width: 33%;
+ }
+ }
+
+ @media only screen and (max-width: 1400px) {
+ width: 33%;
+
+ &.app-item--with-sidebar {
+ width: 50%;
+ }
+ }
+
+ @media only screen and (max-width: 900px) {
+ width: 50%;
+
+ &.app-item--with-sidebar {
+ width: 100%;
+ }
+ }
+
+ @media only screen and (max-width: variables.$breakpoint-mobile) {
+ width: 50%;
+ }
+
+ @media only screen and (max-width: 480px) {
+ width: 100%;
+ }
+ }
+}
+
+.app-icon {
+ filter: var(--background-invert-if-bright);
+}
+
+.app-image {
+ position: relative;
+ height: 150px;
+ opacity: 1;
+ overflow: hidden;
+
+ img {
+ width: 100%;
}
+}
+
+.app-version {
+ color: var(--color-text-maxcontrast);
+}
</style>
diff --git a/apps/settings/src/components/AppList/AppLevelBadge.vue b/apps/settings/src/components/AppList/AppLevelBadge.vue
new file mode 100644
index 00000000000..8461f5eb6b9
--- /dev/null
+++ b/apps/settings/src/components/AppList/AppLevelBadge.vue
@@ -0,0 +1,56 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <span v-if="isSupported || isFeatured"
+ class="app-level-badge"
+ :class="{ 'app-level-badge--supported': isSupported }"
+ :title="badgeTitle">
+ <NcIconSvgWrapper :path="badgeIcon" :size="20" inline />
+ {{ badgeText }}
+ </span>
+</template>
+
+<script setup lang="ts">
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+import { mdiCheck, mdiStarShootingOutline } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+
+const props = defineProps<{
+ /**
+ * The app level
+ */
+ level?: number
+}>()
+
+const isSupported = computed(() => props.level === 300)
+const isFeatured = computed(() => props.level === 200)
+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.')
+ : t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.'))
+</script>
+
+<style scoped lang="scss">
+.app-level-badge {
+ color: var(--color-text-maxcontrast);
+ background-color: transparent;
+ border: 1px solid var(--color-text-maxcontrast);
+ border-radius: var(--border-radius);
+
+ display: flex;
+ flex-direction: row;
+ gap: 6px;
+ padding: 3px 6px;
+ width: fit-content;
+
+ &--supported {
+ border-color: var(--color-success);
+ color: var(--color-success);
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AppList/AppScore.vue b/apps/settings/src/components/AppList/AppScore.vue
index 0569d687e88..a1dd4c03842 100644
--- a/apps/settings/src/components/AppList/AppScore.vue
+++ b/apps/settings/src/components/AppList/AppScore.vue
@@ -1,40 +1,72 @@
<!--
- - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <img :src="scoreImage" class="app-score-image">
+ <span role="img"
+ :aria-label="title"
+ :title="title"
+ class="app-score__wrapper">
+ <NcIconSvgWrapper v-for="index in fullStars"
+ :key="`full-star-${index}`"
+ :path="mdiStar"
+ inline />
+ <NcIconSvgWrapper v-if="hasHalfStar" :path="mdiStarHalfFull" inline />
+ <NcIconSvgWrapper v-for="index in emptyStars"
+ :key="`empty-star-${index}`"
+ :path="mdiStarOutline"
+ inline />
+ </span>
</template>
-<script>
-import { imagePath } from '@nextcloud/router'
+<script lang="ts">
+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'
-export default {
+export default defineComponent({
name: 'AppScore',
- props: ['score'],
+ components: {
+ NcIconSvgWrapper,
+ },
+ props: {
+ score: {
+ type: Number,
+ required: true,
+ },
+ },
+ setup() {
+ return {
+ mdiStar,
+ mdiStarHalfFull,
+ mdiStarOutline,
+ }
+ },
computed: {
- scoreImage() {
- const score = Math.round(this.score * 10)
- const imageName = 'rating/s' + score + '.svg'
- return imagePath('core', imageName)
+ title() {
+ const appScore = (this.score * 5).toFixed(1)
+ return t('settings', 'Community rating: {score}/5', { score: appScore })
+ },
+ fullStars() {
+ return Math.floor(this.score * 5 + 0.25)
+ },
+ emptyStars() {
+ return Math.min(Math.floor((1 - this.score) * 5 + 0.25), 5 - this.fullStars)
+ },
+ hasHalfStar() {
+ return (this.fullStars + this.emptyStars) < 5
},
},
-}
+})
</script>
+<style scoped>
+.app-score__wrapper {
+ display: inline-flex;
+ color: var(--color-favorite, #a08b00);
+
+ > * {
+ vertical-align: text-bottom;
+ }
+}
+</style>
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/AppLink.vue b/apps/settings/src/components/AppStoreDiscover/AppLink.vue
new file mode 100644
index 00000000000..703adb9f041
--- /dev/null
+++ b/apps/settings/src/components/AppStoreDiscover/AppLink.vue
@@ -0,0 +1,98 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <a v-if="linkProps" v-bind="linkProps">
+ <slot />
+ </a>
+ <RouterLink v-else-if="routerProps" v-bind="routerProps">
+ <slot />
+ </RouterLink>
+</template>
+
+<script lang="ts">
+import type { RouterLinkProps } from 'vue-router/types/router.js'
+
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+import { RouterLink } from 'vue-router'
+import type { INavigationEntry } from '../../../../../core/src/types/navigation'
+
+const apps = loadState<INavigationEntry[]>('core', 'apps')
+const knownRoutes = Object.fromEntries(apps.map((app) => [app.app ?? app.id, app.href]))
+
+/**
+ * This component either shows a native link to the installed app or external size - or a router link to the appstore page of the app if not installed
+ */
+export default defineComponent({
+ name: 'AppLink',
+
+ components: { RouterLink },
+
+ props: {
+ href: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ routerProps: undefined as RouterLinkProps|undefined,
+ linkProps: undefined as Record<string, string>|undefined,
+ }
+ },
+
+ watch: {
+ href: {
+ immediate: true,
+ handler() {
+ const match = this.href.match(/^app:\/\/([^/]+)(\/.+)?$/)
+ this.routerProps = undefined
+ this.linkProps = undefined
+
+ // not an app url
+ if (match === null) {
+ this.linkProps = {
+ href: this.href,
+ target: '_blank',
+ rel: 'noreferrer noopener',
+ }
+ return
+ }
+
+ const appId = match[1]
+ // Check if specific route was requested
+ if (match[2]) {
+ // we do no know anything about app internal path so we only allow generic app paths
+ this.linkProps = {
+ href: generateUrl(`/apps/${appId}${match[2]}`),
+ }
+ return
+ }
+
+ // If we know any route for that app we open it
+ if (appId in knownRoutes) {
+ this.linkProps = {
+ href: knownRoutes[appId],
+ }
+ return
+ }
+
+ // Fallback to show the app store entry
+ this.routerProps = {
+ to: {
+ name: 'apps-details',
+ params: {
+ category: this.$route.params?.category ?? 'discover',
+ id: appId,
+ },
+ },
+ }
+ },
+ },
+ },
+})
+</script>
diff --git a/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue
new file mode 100644
index 00000000000..bb91940c763
--- /dev/null
+++ b/apps/settings/src/components/AppStoreDiscover/AppStoreDiscoverSection.vue
@@ -0,0 +1,119 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="app-discover">
+ <NcEmptyContent v-if="hasError"
+ :name="t('settings', 'Nothing to show')"
+ :description="t('settings', 'Could not load section content from app store.')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiEyeOffOutline" :size="64" />
+ </template>
+ </NcEmptyContent>
+ <NcEmptyContent v-else-if="elements.length === 0"
+ :name="t('settings', 'Loading')"
+ :description="t('settings', 'Fetching the latest news…')">
+ <template #icon>
+ <NcLoadingIcon :size="64" />
+ </template>
+ </NcEmptyContent>
+ <template v-else>
+ <component :is="getComponent(entry.type)"
+ v-for="entry, index in elements"
+ :key="entry.id ?? index"
+ v-bind="entry" />
+ </template>
+ </div>
+</template>
+
+<script setup lang="ts">
+import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts'
+
+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/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'
+
+const PostType = defineAsyncComponent(() => import('./PostType.vue'))
+const CarouselType = defineAsyncComponent(() => import('./CarouselType.vue'))
+const ShowcaseType = defineAsyncComponent(() => import('./ShowcaseType.vue'))
+
+const hasError = ref(false)
+const elements = ref<IAppDiscoverElements[]>([])
+
+/**
+ * Shuffle using the Fisher-Yates algorithm
+ * @param array The array to shuffle (in place)
+ */
+const shuffleArray = <T, >(array: T[]): T[] => {
+ for (let i = array.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [array[i], array[j]] = [array[j], array[i]]
+ }
+ return array
+}
+
+/**
+ * Load the app discover section information
+ */
+onBeforeMount(async () => {
+ try {
+ const { data } = await axios.get<Record<string, unknown>[]>(generateUrl('/settings/api/apps/discover'))
+ if (data.length === 0) {
+ logger.info('No app discover elements available (empty response)')
+ hasError.value = true
+ return
+ }
+ // Parse data to ensure dates are useable and then filter out expired or future elements
+ const parsedElements = data.map(parseApiResponse).filter(filterElements)
+ // Shuffle elements to make it looks more interesting
+ const shuffledElements = shuffleArray(parsedElements)
+ // Sort pinned elements first
+ shuffledElements.sort((a, b) => (a.order ?? Infinity) < (b.order ?? Infinity) ? -1 : 1)
+ // Set the elements to the UI
+ elements.value = shuffledElements
+ } catch (error) {
+ hasError.value = true
+ logger.error(error as Error)
+ showError(t('settings', 'Could not load app discover section'))
+ }
+})
+
+const getComponent = (type) => {
+ if (type === 'post') {
+ return PostType
+ } else if (type === 'carousel') {
+ return CarouselType
+ } else if (type === 'showcase') {
+ return ShowcaseType
+ }
+ return defineComponent({
+ mounted: () => logger.error('Unknown component requested ', type),
+ render: (h) => h('div', t('settings', 'Could not render element')),
+ })
+}
+</script>
+
+<style scoped lang="scss">
+.app-discover {
+ max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
+ margin-inline: auto;
+ padding-inline: 54px;
+ /* Padding required to make last element not bound to the bottom */
+ padding-block-end: var(--default-clickable-area, 44px);
+
+ display: flex;
+ flex-direction: column;
+ gap: var(--default-clickable-area, 44px);
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/AppType.vue b/apps/settings/src/components/AppStoreDiscover/AppType.vue
new file mode 100644
index 00000000000..7263dc71041
--- /dev/null
+++ b/apps/settings/src/components/AppStoreDiscover/AppType.vue
@@ -0,0 +1,100 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <AppItem v-if="app"
+ :app="app"
+ category="discover"
+ class="app-discover-app"
+ inline
+ :list-view="false" />
+ <a v-else
+ class="app-discover-app app-discover-app__skeleton"
+ :href="appStoreLink"
+ target="_blank"
+ :title="modelValue.appId"
+ rel="noopener noreferrer">
+ <!-- This is a fallback skeleton -->
+ <span class="skeleton-element" />
+ <span class="skeleton-element" />
+ <span class="skeleton-element" />
+ <span class="skeleton-element" />
+ <span class="skeleton-element" />
+ </a>
+</template>
+
+<script setup lang="ts">
+import type { IAppDiscoverApp } from '../../constants/AppDiscoverTypes'
+
+import { computed } from 'vue'
+import { useAppsStore } from '../../store/apps-store.ts'
+
+import AppItem from '../AppList/AppItem.vue'
+
+const props = defineProps<{
+ modelValue: IAppDiscoverApp
+}>()
+
+const store = useAppsStore()
+const app = computed(() => store.getAppById(props.modelValue.appId))
+
+const appStoreLink = computed(() => props.modelValue.appId ? `https://apps.nextcloud.com/apps/${props.modelValue.appId}` : '#')
+</script>
+
+<style scoped lang="scss">
+.app-discover-app {
+ width: 100% !important; // full with of the showcase item
+
+ &:hover {
+ background: var(--color-background-hover);
+ border-radius: var(--border-radius-rounded);
+ }
+
+ &__skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ padding: 30px; // Same as AppItem
+
+ > :first-child {
+ height: 50%;
+ min-height: 130px;
+ }
+
+ > :nth-child(2) {
+ width: 50px;
+ }
+
+ > :nth-child(5) {
+ height: 20px;
+ width: 100px;
+ }
+
+ > :not(:first-child) {
+ border-radius: 4px;
+ }
+ }
+}
+
+.skeleton-element {
+ min-height: var(--default-font-size, 15px);
+
+ background: linear-gradient(90deg, var(--color-background-dark), var(--color-background-darker), var(--color-background-dark));
+ background-size: 400% 400%;
+ animation: gradient 6s ease infinite;
+}
+
+@keyframes gradient {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/CarouselType.vue b/apps/settings/src/components/AppStoreDiscover/CarouselType.vue
new file mode 100644
index 00000000000..69393176835
--- /dev/null
+++ b/apps/settings/src/components/AppStoreDiscover/CarouselType.vue
@@ -0,0 +1,206 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <section :aria-roledescription="t('settings', 'Carousel')" :aria-labelledby="headingId ? `${headingId}` : undefined">
+ <h3 v-if="headline" :id="headingId">
+ {{ translatedHeadline }}
+ </h3>
+ <div class="app-discover-carousel__wrapper">
+ <div class="app-discover-carousel__button-wrapper">
+ <NcButton class="app-discover-carousel__button app-discover-carousel__button--previous"
+ type="tertiary-no-background"
+ :aria-label="t('settings', 'Previous slide')"
+ :disabled="!hasPrevious"
+ @click="currentIndex -= 1">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiChevronLeft" />
+ </template>
+ </NcButton>
+ </div>
+
+ <Transition :name="transitionName" mode="out-in">
+ <PostType v-bind="shownElement"
+ :key="shownElement.id ?? currentIndex"
+ :aria-labelledby="`${internalId}-tab-${currentIndex}`"
+ :dom-id="`${internalId}-tabpanel-${currentIndex}`"
+ inline
+ role="tabpanel" />
+ </Transition>
+
+ <div class="app-discover-carousel__button-wrapper">
+ <NcButton class="app-discover-carousel__button app-discover-carousel__button--next"
+ type="tertiary-no-background"
+ :aria-label="t('settings', 'Next slide')"
+ :disabled="!hasNext"
+ @click="currentIndex += 1">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiChevronRight" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ <div class="app-discover-carousel__tabs" role="tablist" :aria-label="t('settings', 'Choose slide to display')">
+ <NcButton v-for="index of content.length"
+ :id="`${internalId}-tab-${index}`"
+ :key="index"
+ :aria-label="t('settings', '{index} of {total}', { index, total: content.length })"
+ :aria-controls="`${internalId}-tabpanel-${index}`"
+ :aria-selected="`${currentIndex === (index - 1)}`"
+ role="tab"
+ type="tertiary-no-background"
+ @click="currentIndex = index - 1">
+ <template #icon>
+ <NcIconSvgWrapper :path="currentIndex === (index - 1) ? mdiCircleSlice8 : mdiCircleOutline" />
+ </template>
+ </NcButton>
+ </div>
+ </section>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { IAppDiscoverCarousel } from '../../constants/AppDiscoverTypes.ts'
+
+import { mdiChevronLeft, mdiChevronRight, mdiCircleOutline, mdiCircleSlice8 } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { computed, defineComponent, nextTick, ref, watch } from 'vue'
+import { commonAppDiscoverProps } from './common.ts'
+import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import PostType from './PostType.vue'
+
+export default defineComponent({
+ name: 'CarouselType',
+
+ components: {
+ NcButton,
+ NcIconSvgWrapper,
+ PostType,
+ },
+
+ props: {
+ ...commonAppDiscoverProps,
+
+ /**
+ * The content of the carousel
+ */
+ content: {
+ type: Array as PropType<IAppDiscoverCarousel['content']>,
+ required: true,
+ },
+ },
+
+ setup(props) {
+ const translatedHeadline = useLocalizedValue(computed(() => props.headline))
+
+ const currentIndex = ref(Math.min(1, props.content.length - 1))
+ const shownElement = ref(props.content[currentIndex.value])
+ const hasNext = computed(() => currentIndex.value < (props.content.length - 1))
+ const hasPrevious = computed(() => currentIndex.value > 0)
+
+ const internalId = computed(() => props.id ?? (Math.random() + 1).toString(36).substring(7))
+ const headingId = computed(() => `${internalId.value}-h`)
+
+ const transitionName = ref('slide-in')
+ watch(() => currentIndex.value, (o, n) => {
+ if (o < n) {
+ transitionName.value = 'slide-in'
+ } else {
+ transitionName.value = 'slide-out'
+ }
+
+ // Wait next tick
+ nextTick(() => {
+ shownElement.value = props.content[currentIndex.value]
+ })
+ })
+
+ return {
+ t,
+ internalId,
+ headingId,
+
+ hasNext,
+ hasPrevious,
+ currentIndex,
+ shownElement,
+
+ transitionName,
+
+ translatedHeadline,
+
+ mdiChevronLeft,
+ mdiChevronRight,
+ mdiCircleOutline,
+ mdiCircleSlice8,
+ }
+ },
+})
+</script>
+
+<style scoped lang="scss">
+h3 {
+ font-size: 24px;
+ font-weight: 600;
+ margin-block: 0 1em;
+}
+
+.app-discover-carousel {
+ &__wrapper {
+ display: flex;
+ }
+
+ &__button {
+ color: var(--color-text-maxcontrast);
+ position: absolute;
+ top: calc(50% - 22px); // 50% minus half of button height
+
+ &-wrapper {
+ position: relative;
+ }
+
+ // See padding of discover section
+ &--next {
+ inset-inline-end: -54px;
+ }
+ &--previous {
+ inset-inline-start: -54px;
+ }
+ }
+
+ &__tabs {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+
+ > * {
+ color: var(--color-text-maxcontrast);
+ }
+ }
+}
+</style>
+
+<style>
+.slide-in-enter-active,
+.slide-in-leave-active,
+.slide-out-enter-active,
+.slide-out-leave-active {
+ transition: all .4s ease-out;
+}
+
+.slide-in-leave-to,
+.slide-out-enter {
+ opacity: 0;
+ transform: translateX(50%);
+}
+
+.slide-in-enter,
+.slide-out-leave-to {
+ opacity: 0;
+ transform: translateX(-50%);
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/PostType.vue b/apps/settings/src/components/AppStoreDiscover/PostType.vue
new file mode 100644
index 00000000000..090e9dee577
--- /dev/null
+++ b/apps/settings/src/components/AppStoreDiscover/PostType.vue
@@ -0,0 +1,299 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <article :id="domId"
+ ref="container"
+ class="app-discover-post"
+ :class="{
+ 'app-discover-post--reverse': media && media.alignment === 'start',
+ 'app-discover-post--small': isSmallWidth
+ }">
+ <component :is="link ? 'AppLink' : 'div'"
+ v-if="headline || text"
+ :href="link"
+ class="app-discover-post__text">
+ <component :is="inline ? 'h4' : 'h3'">
+ {{ translatedHeadline }}
+ </component>
+ <p>{{ translatedText }}</p>
+ </component>
+ <component :is="mediaLink ? 'AppLink' : 'div'"
+ v-if="mediaSources"
+ :href="mediaLink"
+ class="app-discover-post__media"
+ :class="{
+ 'app-discover-post__media--fullwidth': isFullWidth,
+ 'app-discover-post__media--start': media?.alignment === 'start',
+ 'app-discover-post__media--end': media?.alignment === 'end',
+ }">
+ <component :is="isImage ? 'picture' : 'video'"
+ ref="mediaElement"
+ class="app-discover-post__media-element"
+ :muted="!isImage"
+ :playsinline="!isImage"
+ :preload="!isImage && 'auto'"
+ @ended="hasPlaybackEnded = true">
+ <source v-for="source of mediaSources"
+ :key="source.src"
+ :src="isImage ? undefined : generatePrivacyUrl(source.src)"
+ :srcset="isImage ? generatePrivacyUrl(source.src) : undefined"
+ :type="source.mime">
+ <img v-if="isImage"
+ :src="generatePrivacyUrl(mediaSources[0].src)"
+ :alt="mediaAlt">
+ </component>
+ <div class="app-discover-post__play-icon-wrapper">
+ <NcIconSvgWrapper v-if="!isImage && showPlayVideo"
+ class="app-discover-post__play-icon"
+ :path="mdiPlayCircleOutline"
+ :size="92" />
+ </div>
+ </component>
+ </article>
+</template>
+
+<script lang="ts">
+import type { IAppDiscoverPost } from '../../constants/AppDiscoverTypes.ts'
+import type { PropType } from 'vue'
+
+import { mdiPlayCircleOutline } from '@mdi/js'
+import { generateUrl } from '@nextcloud/router'
+import { useElementSize, useElementVisibility } from '@vueuse/core'
+import { computed, defineComponent, ref, watchEffect } from 'vue'
+import { commonAppDiscoverProps } from './common'
+import { useLocalizedValue } from '../../composables/useGetLocalizedValue'
+
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import AppLink from './AppLink.vue'
+
+export default defineComponent({
+ components: {
+ AppLink,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ ...commonAppDiscoverProps,
+
+ text: {
+ type: Object as PropType<IAppDiscoverPost['text']>,
+ required: false,
+ default: () => null,
+ },
+
+ media: {
+ type: Object as PropType<IAppDiscoverPost['media']>,
+ required: false,
+ default: () => null,
+ },
+
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+
+ domId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+
+ setup(props) {
+ const translatedHeadline = useLocalizedValue(computed(() => props.headline))
+ const translatedText = useLocalizedValue(computed(() => props.text))
+ const localizedMedia = useLocalizedValue(computed(() => props.media?.content))
+
+ const mediaSources = computed(() => localizedMedia.value !== null ? [localizedMedia.value.src].flat() : undefined)
+ const mediaAlt = computed(() => localizedMedia.value?.alt ?? '')
+
+ const isImage = computed(() => mediaSources?.value?.[0].mime.startsWith('image/') === true)
+ /**
+ * Is the media is shown full width
+ */
+ const isFullWidth = computed(() => !translatedHeadline.value && !translatedText.value)
+
+ /**
+ * Link on the media
+ * Fallback to post link to prevent link inside link (which is invalid HTML)
+ */
+ const mediaLink = computed(() => localizedMedia.value?.link ?? props.link)
+
+ const hasPlaybackEnded = ref(false)
+ const showPlayVideo = computed(() => localizedMedia.value?.link && hasPlaybackEnded.value)
+
+ /**
+ * The content is sized / styles are applied based on the container width
+ * To make it responsive even for inline usage and when opening / closing the sidebar / navigation
+ */
+ const container = ref<HTMLElement>()
+ const { width: containerWidth } = useElementSize(container)
+ const isSmallWidth = computed(() => containerWidth.value < 600)
+
+ /**
+ * Generate URL for cached media to prevent user can be tracked
+ * @param url The URL to resolve
+ */
+ const generatePrivacyUrl = (url: string) => url.startsWith('/') ? url : generateUrl('/settings/api/apps/media?fileName={fileName}', { fileName: url })
+
+ const mediaElement = ref<HTMLVideoElement|HTMLPictureElement>()
+ const mediaIsVisible = useElementVisibility(mediaElement, { threshold: 0.3 })
+ watchEffect(() => {
+ // Only if media is video
+ if (!isImage.value && mediaElement.value) {
+ const video = mediaElement.value as HTMLVideoElement
+
+ if (mediaIsVisible.value) {
+ // Ensure video is muted - otherwise .play() will be blocked by browsers
+ video.muted = true
+ // If visible start playback
+ video.play()
+ } else {
+ // If not visible pause the playback
+ video.pause()
+ // If the animation has ended reset
+ if (video.ended) {
+ video.currentTime = 0
+ hasPlaybackEnded.value = false
+ }
+ }
+ }
+ })
+
+ return {
+ mdiPlayCircleOutline,
+
+ container,
+
+ translatedText,
+ translatedHeadline,
+ mediaElement,
+ mediaSources,
+ mediaAlt,
+ mediaLink,
+
+ hasPlaybackEnded,
+ showPlayVideo,
+
+ isFullWidth,
+ isSmallWidth,
+ isImage,
+
+ generatePrivacyUrl,
+ }
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.app-discover-post {
+ max-height: 300px;
+ width: 100%;
+ background-color: var(--color-primary-element-light);
+ border-radius: var(--border-radius-rounded);
+
+ display: flex;
+ flex-direction: row;
+ justify-content: start;
+
+ &--reverse {
+ flex-direction: row-reverse;
+ }
+
+ h3, h4 {
+ font-size: 24px;
+ font-weight: 600;
+ margin-block: 0 1em;
+ }
+
+ &__text {
+ display: block;
+ width: 100%;
+ padding: var(--border-radius-rounded);
+ overflow-y: scroll;
+ }
+
+ // If there is media next to the text we do not want a padding on the bottom as this looks weird when scrolling
+ &:has(&__media) &__text {
+ padding-block-end: 0;
+ }
+
+ &__media {
+ display: block;
+ overflow: hidden;
+
+ max-width: 450px;
+ border-radius: var(--border-radius-rounded);
+
+ &--fullwidth {
+ max-width: unset;
+ max-height: unset;
+ }
+
+ &--end {
+ border-end-start-radius: 0;
+ border-start-start-radius: 0;
+ }
+
+ &--start {
+ border-end-end-radius: 0;
+ border-start-end-radius: 0;
+ }
+
+ img, &-element {
+ height: 100%;
+ width: 100%;
+ object-fit: cover;
+ object-position: center;
+ }
+ }
+
+ &__play-icon {
+ position: absolute;
+ top: -46px; // half of the icon height
+ inset-inline-end: -46px; // half of the icon width
+
+ &-wrapper {
+ position: relative;
+ top: -50%;
+ inset-inline-start: -50%;
+ }
+ }
+}
+
+.app-discover-post--small {
+ &.app-discover-post {
+ flex-direction: column;
+ max-height: 500px;
+
+ &--reverse {
+ flex-direction: column-reverse;
+ }
+ }
+
+ .app-discover-post {
+ &__text {
+ flex: 1 1 50%;
+ }
+
+ &__media {
+ min-width: 100%;
+
+ &--end {
+ border-radius: var(--border-radius-rounded);
+ border-start-end-radius: 0;
+ border-start-start-radius: 0;
+ }
+
+ &--start {
+ border-radius: var(--border-radius-rounded);
+ border-end-end-radius: 0;
+ border-end-start-radius: 0;
+ }
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue b/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue
new file mode 100644
index 00000000000..ac057b9ab7d
--- /dev/null
+++ b/apps/settings/src/components/AppStoreDiscover/ShowcaseType.vue
@@ -0,0 +1,122 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <section ref="container"
+ class="app-discover-showcase"
+ :class="{
+ 'app-discover-showcase--small': isSmallWidth,
+ 'app-discover-showcase--extra-small': isExtraSmallWidth,
+ }">
+ <h3 v-if="translatedHeadline">
+ {{ translatedHeadline }}
+ </h3>
+ <ul class="app-discover-showcase__list">
+ <li v-for="(item, index) of content"
+ :key="item.id ?? index"
+ class="app-discover-showcase__item">
+ <PostType v-if="item.type === 'post'"
+ v-bind="item"
+ inline />
+ <AppType v-else-if="item.type === 'app'" :model-value="item" />
+ </li>
+ </ul>
+ </section>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { IAppDiscoverShowcase } from '../../constants/AppDiscoverTypes.ts'
+
+import { translate as t } from '@nextcloud/l10n'
+import { useElementSize } from '@vueuse/core'
+import { computed, defineComponent, ref } from 'vue'
+import { commonAppDiscoverProps } from './common.ts'
+import { useLocalizedValue } from '../../composables/useGetLocalizedValue.ts'
+
+import AppType from './AppType.vue'
+import PostType from './PostType.vue'
+
+export default defineComponent({
+ name: 'ShowcaseType',
+
+ components: {
+ AppType,
+ PostType,
+ },
+
+ props: {
+ ...commonAppDiscoverProps,
+
+ /**
+ * The content of the carousel
+ */
+ content: {
+ type: Array as PropType<IAppDiscoverShowcase['content']>,
+ required: true,
+ },
+ },
+
+ setup(props) {
+ const translatedHeadline = useLocalizedValue(computed(() => props.headline))
+
+ /**
+ * Make the element responsive based on the container width to also handle open navigation or sidebar
+ */
+ const container = ref<HTMLElement>()
+ const { width: containerWidth } = useElementSize(container)
+ const isSmallWidth = computed(() => containerWidth.value < 768)
+ const isExtraSmallWidth = computed(() => containerWidth.value < 512)
+
+ return {
+ t,
+
+ container,
+ isSmallWidth,
+ isExtraSmallWidth,
+ translatedHeadline,
+ }
+ },
+})
+</script>
+
+<style scoped lang="scss">
+$item-gap: calc(var(--default-clickable-area, 44px) / 2);
+
+h3 {
+ font-size: 24px;
+ font-weight: 600;
+ margin-block: 0 1em;
+}
+
+.app-discover-showcase {
+ &__list {
+ list-style: none;
+
+ display: flex;
+ flex-wrap: wrap;
+ gap: $item-gap;
+ }
+
+ &__item {
+ display: flex;
+ align-items: stretch;
+
+ position: relative;
+ width: calc(33% - $item-gap);
+ }
+}
+
+.app-discover-showcase--small {
+ .app-discover-showcase__item {
+ width: calc(50% - $item-gap);
+ }
+}
+
+.app-discover-showcase--extra-small {
+ .app-discover-showcase__item {
+ width: 100%;
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreDiscover/common.ts b/apps/settings/src/components/AppStoreDiscover/common.ts
new file mode 100644
index 00000000000..277d4910e49
--- /dev/null
+++ b/apps/settings/src/components/AppStoreDiscover/common.ts
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { PropType } from 'vue'
+import type { IAppDiscoverElement } from '../../constants/AppDiscoverTypes.ts'
+
+import { APP_DISCOVER_KNOWN_TYPES } from '../../constants/AppDiscoverTypes.ts'
+
+/**
+ * Common Props for all app discover types
+ */
+export const commonAppDiscoverProps = {
+ type: {
+ type: String as PropType<IAppDiscoverElement['type']>,
+ required: true,
+ validator: (v: unknown) => typeof v === 'string' && APP_DISCOVER_KNOWN_TYPES.includes(v as never),
+ },
+
+ id: {
+ type: String as PropType<IAppDiscoverElement['id']>,
+ required: true,
+ },
+
+ date: {
+ type: Number as PropType<IAppDiscoverElement['date']>,
+ required: false,
+ default: undefined,
+ },
+
+ expiryDate: {
+ type: Number as PropType<IAppDiscoverElement['expiryDate']>,
+ required: false,
+ default: undefined,
+ },
+
+ headline: {
+ type: Object as PropType<IAppDiscoverElement['headline']>,
+ required: false,
+ default: () => null,
+ },
+
+ link: {
+ type: String as PropType<IAppDiscoverElement['link']>,
+ required: false,
+ default: () => null,
+ },
+} as const
diff --git a/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue
new file mode 100644
index 00000000000..7c0b8ea4421
--- /dev/null
+++ b/apps/settings/src/components/AppStoreSidebar/AppDeployDaemonTab.vue
@@ -0,0 +1,50 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcAppSidebarTab v-if="app?.daemon"
+ id="daemon"
+ :name="t('settings', 'Daemon')"
+ :order="3">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiFileChart" :size="24" />
+ </template>
+ <div class="daemon">
+ <h4>{{ t('settings', 'Deploy Daemon') }}</h4>
+ <p><b>{{ t('settings', 'Type') }}</b>: {{ app?.daemon.accepts_deploy_id }}</p>
+ <p><b>{{ t('settings', 'Name') }}</b>: {{ app?.daemon.name }}</p>
+ <p><b>{{ t('settings', 'Display Name') }}</b>: {{ app?.daemon.display_name }}</p>
+ <p><b>{{ t('settings', 'GPUs support') }}</b>: {{ gpuSupport }}</p>
+ <p><b>{{ t('settings', 'Compute device') }}</b>: {{ app?.daemon?.deploy_config?.computeDevice?.label }}</p>
+ </div>
+ </NcAppSidebarTab>
+</template>
+
+<script setup lang="ts">
+import type { IAppstoreExApp } from '../../app-types'
+
+import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+import { mdiFileChart } from '@mdi/js'
+import { ref } from 'vue'
+
+const props = defineProps<{
+ app: IAppstoreExApp,
+}>()
+
+const gpuSupport = ref(props.app?.daemon?.deploy_config?.computeDevice?.id !== 'cpu' || false)
+</script>
+
+<style scoped lang="scss">
+.daemon {
+ padding: 20px;
+
+ h4 {
+ font-weight: bold;
+ margin: 10px auto;
+ }
+}
+</style>
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
new file mode 100644
index 00000000000..299d084ef9e
--- /dev/null
+++ b/apps/settings/src/components/AppStoreSidebar/AppDescriptionTab.vue
@@ -0,0 +1,38 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcAppSidebarTab id="desc"
+ :name="t('settings', 'Description')"
+ :order="0">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiTextShort" />
+ </template>
+ <div class="app-description">
+ <Markdown :text="app.description" :min-heading="4" />
+ </div>
+ </NcAppSidebarTab>
+</template>
+
+<script setup lang="ts">
+import type { IAppstoreApp } from '../../app-types'
+
+import { mdiTextShort } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+
+import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import Markdown from '../Markdown.vue'
+
+defineProps<{
+ app: IAppstoreApp,
+}>()
+</script>
+
+<style scoped lang="scss">
+.app-description {
+ padding: 12px;
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue
new file mode 100644
index 00000000000..eb66d8f3e3a
--- /dev/null
+++ b/apps/settings/src/components/AppStoreSidebar/AppDetailsTab.vue
@@ -0,0 +1,495 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcAppSidebarTab id="details"
+ :name="t('settings', 'Details')"
+ :order="1">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiTextBoxOutline" />
+ </template>
+ <div class="app-details">
+ <div class="app-details__actions">
+ <div v-if="app.active && canLimitToGroups(app)" class="app-details__actions-groups">
+ <input :id="`groups_enable_${app.id}`"
+ v-model="groupCheckedAppsData"
+ type="checkbox"
+ :value="app.id"
+ class="groups-enable__checkbox checkbox"
+ @change="setGroupLimit">
+ <label :for="`groups_enable_${app.id}`">{{ t('settings', 'Limit to groups') }}</label>
+ <input type="hidden"
+ class="group_select"
+ :title="t('settings', 'All')"
+ value="">
+ <br>
+ <label for="limitToGroups">
+ <span>{{ t('settings', 'Limit app usage to groups') }}</span>
+ </label>
+ <NcSelect v-if="isLimitedToGroups(app)"
+ input-id="limitToGroups"
+ :options="groups"
+ :value="appGroups"
+ :limit="5"
+ label="name"
+ :multiple="true"
+ :close-on-select="false"
+ @option:selected="addGroupLimitation"
+ @option:deselected="removeGroupLimitation"
+ @search="asyncFindGroup">
+ <span slot="noResult">{{ t('settings', 'No results') }}</span>
+ </NcSelect>
+ </div>
+ <div class="app-details__actions-manage">
+ <input v-if="app.update"
+ class="update primary"
+ type="button"
+ :value="t('settings', 'Update to {version}', { version: app.update })"
+ :disabled="installing || isLoading || isManualInstall"
+ @click="update(app.id)">
+ <input v-if="app.canUnInstall"
+ class="uninstall"
+ type="button"
+ :value="t('settings', 'Remove')"
+ :disabled="installing || isLoading"
+ @click="remove(app.id, removeData)">
+ <input v-if="app.active"
+ class="enable"
+ type="button"
+ :value="disableButtonText"
+ :disabled="installing || isLoading || isInitializing || isDeploying"
+ @click="disable(app.id)">
+ <input v-if="!app.active && (app.canInstall || app.isCompatible)"
+ :title="enableButtonTooltip"
+ :aria-label="enableButtonTooltip"
+ class="enable primary"
+ type="button"
+ :value="enableButtonText"
+ :disabled="!app.canInstall || installing || isLoading || !defaultDeployDaemonAccessible || isInitializing || isDeploying"
+ @click="enableButtonAction">
+ <input v-else-if="!app.active && !app.canInstall"
+ :title="forceEnableButtonTooltip"
+ :aria-label="forceEnableButtonTooltip"
+ class="enable force"
+ type="button"
+ :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') }}
+ </p>
+ <NcCheckboxRadioSwitch v-if="app.canUnInstall"
+ :checked="removeData"
+ :disabled="installing || isLoading || !defaultDeployDaemonAccessible"
+ @update:checked="toggleRemoveData">
+ {{ t('settings', 'Delete data on remove') }}
+ </NcCheckboxRadioSwitch>
+ </div>
+
+ <ul class="app-details__dependencies">
+ <li v-if="app.missingMinOwnCloudVersion">
+ {{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }}
+ </li>
+ <li v-if="app.missingMaxOwnCloudVersion">
+ {{ t('settings', 'This app has no maximum Nextcloud version assigned. This will be an error in the future.') }}
+ </li>
+ <li v-if="!app.canInstall">
+ {{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }}
+ <ul class="missing-dependencies">
+ <li v-for="(dep, index) in app.missingDependencies" :key="index">
+ {{ dep }}
+ </li>
+ </ul>
+ </li>
+ </ul>
+
+ <div v-if="lastModified && !app.shipped" class="app-details__section">
+ <h4>
+ {{ t('settings', 'Latest updated') }}
+ </h4>
+ <NcDateTime :timestamp="lastModified" />
+ </div>
+
+ <div class="app-details__section">
+ <h4>
+ {{ t('settings', 'Author') }}
+ </h4>
+ <p class="app-details__authors">
+ {{ appAuthors }}
+ </p>
+ </div>
+
+ <div class="app-details__section">
+ <h4>
+ {{ t('settings', 'Categories') }}
+ </h4>
+ <p>
+ {{ appCategories }}
+ </p>
+ </div>
+
+ <div v-if="externalResources.length > 0" class="app-details__section">
+ <h4>{{ t('settings', 'Resources') }}</h4>
+ <ul class="app-details__documentation" :aria-label="t('settings', 'Documentation')">
+ <li v-for="resource of externalResources" :key="resource.id">
+ <a class="appslink"
+ :href="resource.href"
+ target="_blank"
+ rel="noreferrer noopener">
+ {{ resource.label }} ↗
+ </a>
+ </li>
+ </ul>
+ </div>
+
+ <div class="app-details__section">
+ <h4>{{ t('settings', 'Interact') }}</h4>
+ <div class="app-details__interact">
+ <NcButton :disabled="!app.bugs"
+ :href="app.bugs ?? '#'"
+ :aria-label="t('settings', 'Report a bug')"
+ :title="t('settings', 'Report a bug')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiBugOutline" />
+ </template>
+ </NcButton>
+ <NcButton :disabled="!app.bugs"
+ :href="app.bugs ?? '#'"
+ :aria-label="t('settings', 'Request feature')"
+ :title="t('settings', 'Request feature')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiFeatureSearchOutline" />
+ </template>
+ </NcButton>
+ <NcButton v-if="app.appstoreData?.discussion"
+ :href="app.appstoreData.discussion"
+ :aria-label="t('settings', 'Ask questions or discuss')"
+ :title="t('settings', 'Ask questions or discuss')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiTooltipQuestionOutline" />
+ </template>
+ </NcButton>
+ <NcButton v-if="!app.internal"
+ :href="rateAppUrl"
+ :aria-label="t('settings', 'Rate the app')"
+ :title="t('settings', 'Rate')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiStar" />
+ </template>
+ </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 { 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 { mdiBugOutline, mdiFeatureSearchOutline, mdiStar, mdiTextBoxOutline, mdiTooltipQuestionOutline, mdiToyBrickPlusOutline } from '@mdi/js'
+import { useAppsStore } from '../../store/apps-store'
+import { useAppApiStore } from '../../store/app-api-store'
+
+export default {
+ name: 'AppDetailsTab',
+
+ components: {
+ NcAppSidebarTab,
+ NcButton,
+ NcDateTime,
+ NcIconSvgWrapper,
+ NcSelect,
+ NcCheckboxRadioSwitch,
+ AppDeployOptionsModal,
+ DaemonSelectionDialog,
+ },
+ mixins: [AppManagement],
+
+ props: {
+ app: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ setup() {
+ const store = useAppsStore()
+ const appApiStore = useAppApiStore()
+
+ return {
+ store,
+ appApiStore,
+
+ mdiBugOutline,
+ mdiFeatureSearchOutline,
+ mdiStar,
+ mdiTextBoxOutline,
+ mdiTooltipQuestionOutline,
+ mdiToyBrickPlusOutline,
+ }
+ },
+
+ data() {
+ return {
+ groupCheckedAppsData: false,
+ removeData: false,
+ showDeployOptionsModal: false,
+ showSelectDaemonModal: false,
+ deployOptions: null,
+ }
+ },
+
+ computed: {
+ lastModified() {
+ return (this.app.appstoreData?.releases ?? [])
+ .map(({ lastModified }) => Date.parse(lastModified))
+ .sort()
+ .at(0) ?? null
+ },
+ /**
+ * App authors as comma separated string
+ */
+ appAuthors() {
+ console.warn(this.app)
+ if (!this.app) {
+ return ''
+ }
+
+ const authorName = (xmlNode) => {
+ if (xmlNode['@value']) {
+ // Complex node (with email or homepage attribute)
+ return xmlNode['@value']
+ }
+ // Simple text node
+ return xmlNode
+ }
+
+ const authors = Array.isArray(this.app.author)
+ ? this.app.author.map(authorName)
+ : [authorName(this.app.author)]
+
+ return authors
+ .sort((a, b) => a.split(' ').at(-1).localeCompare(b.split(' ').at(-1)))
+ .join(', ')
+ },
+
+ appstoreUrl() {
+ return `https://apps.nextcloud.com/apps/${this.app.id}`
+ },
+
+ /**
+ * Further external resources (e.g. website)
+ */
+ externalResources() {
+ const resources = []
+ if (!this.app.internal) {
+ resources.push({
+ id: 'appstore',
+ href: this.appstoreUrl,
+ label: t('settings', 'View in store'),
+ })
+ }
+ if (this.app.website) {
+ resources.push({
+ id: 'website',
+ href: this.app.website,
+ label: t('settings', 'Visit website'),
+ })
+ }
+ if (this.app.documentation) {
+ if (this.app.documentation.user) {
+ resources.push({
+ id: 'doc-user',
+ href: this.app.documentation.user,
+ label: t('settings', 'Usage documentation'),
+ })
+ }
+ if (this.app.documentation.admin) {
+ resources.push({
+ id: 'doc-admin',
+ href: this.app.documentation.admin,
+ label: t('settings', 'Admin documentation'),
+ })
+ }
+ if (this.app.documentation.developer) {
+ resources.push({
+ id: 'doc-developer',
+ href: this.app.documentation.developer,
+ label: t('settings', 'Developer documentation'),
+ })
+ }
+ }
+ return resources
+ },
+
+ appCategories() {
+ return [this.app.category].flat()
+ .map((id) => this.store.getCategoryById(id)?.displayName ?? id)
+ .join(', ')
+ },
+
+ rateAppUrl() {
+ return `${this.appstoreUrl}#comments`
+ },
+ appGroups() {
+ return this.app.groups.map(group => { return { id: group, name: group } })
+ },
+ groups() {
+ return this.$store.getters.getGroups
+ .filter(group => group.id !== 'disabled')
+ .sort((a, b) => a.name.localeCompare(b.name))
+ },
+ },
+ watch: {
+ 'app.id'() {
+ 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>
+
+<style scoped lang="scss">
+.app-details {
+ padding: 20px;
+
+ &__actions {
+ // app management
+ &-manage {
+ // if too many, shrink them and ellipsis
+ display: flex;
+ align-items: center;
+ input {
+ flex: 0 1 auto;
+ min-width: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+ }
+ }
+ &__authors {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &__section {
+ margin-top: 15px;
+
+ h4 {
+ font-size: 16px;
+ font-weight: bold;
+ margin-block-end: 5px;
+ }
+ }
+
+ &__interact {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+
+ &__documentation {
+ a {
+ text-decoration: underline;
+ }
+ li {
+ padding-inline-start: 20px;
+
+ &::before {
+ width: 5px;
+ height: 5px;
+ border-radius: 100%;
+ background-color: var(--color-main-text);
+ content: "";
+ float: inline-start;
+ margin-inline-start: -13px;
+ position: relative;
+ top: 10px;
+ }
+ }
+ }
+}
+
+.force {
+ color: var(--color-error);
+ border-color: var(--color-error);
+ background: var(--color-main-background);
+}
+
+.force:hover,
+.force:active {
+ color: var(--color-main-background);
+ border-color: var(--color-error) !important;
+ background: var(--color-error);
+}
+
+.missing-dependencies {
+ list-style: initial;
+ list-style-type: initial;
+ list-style-position: inside;
+}
+</style>
diff --git a/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue b/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue
new file mode 100644
index 00000000000..e65df0341db
--- /dev/null
+++ b/apps/settings/src/components/AppStoreSidebar/AppReleasesTab.vue
@@ -0,0 +1,57 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcAppSidebarTab v-if="hasChangelog"
+ id="changelog"
+ :name="t('settings', 'Changelog')"
+ :order="2">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiClockFast" :size="24" />
+ </template>
+ <div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release">
+ <h2>{{ release.version }}</h2>
+ <Markdown class="app-sidebar-tabs__release-text"
+ :text="createChangelogFromRelease(release)" />
+ </div>
+ </NcAppSidebarTab>
+</template>
+
+<script setup lang="ts">
+import type { IAppstoreApp, IAppstoreAppRelease } from '../../app-types.ts'
+
+import { mdiClockFast } from '@mdi/js'
+import { getLanguage, translate as t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+
+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
+const props = defineProps<{ app: IAppstoreApp }>()
+
+const hasChangelog = computed(() => Object.values(props.app.releases?.[0]?.translations ?? {}).some(({ changelog }) => !!changelog))
+
+const createChangelogFromRelease = (release: IAppstoreAppRelease) => release.translations?.[getLanguage()]?.changelog ?? release.translations?.en?.changelog ?? ''
+</script>
+
+<style scoped lang="scss">
+.app-sidebar-tabs__release {
+ h2 {
+ border-bottom: 1px solid var(--color-border);
+ font-size: 24px;
+ }
+
+ &-text {
+ // Overwrite changelog heading styles
+ :deep(h3) {
+ font-size: 20px;
+ }
+ :deep(h4) {
+ font-size: 17px;
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/components/AuthToken.vue
index e599be9c671..15286adb135 100644
--- a/apps/settings/src/components/AuthToken.vue
+++ b/apps/settings/src/components/AuthToken.vue
@@ -1,95 +1,97 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <tr :data-id="token.id"
- :class="wiping">
- <td class="client">
- <div :class="iconName.icon" />
- </td>
- <td class="token-name">
- <input v-if="token.canRename && renaming"
- ref="input"
- v-model="newName"
- type="text"
- @keyup.enter="rename"
- @blur="cancelRename"
- @keyup.esc="cancelRename">
- <span v-else>{{ iconName.name }}</span>
- <span v-if="wiping" class="wiping-warning">({{ t('settings', 'Marked for remote wipe') }})</span>
+ <tr :class="['auth-token', { 'auth-token--wiping': wiping }]" :data-id="token.id">
+ <td class="auth-token__name">
+ <NcIconSvgWrapper :path="tokenIcon" />
+ <div class="auth-token__name-wrapper">
+ <form v-if="token.canRename && renaming"
+ class="auth-token__name-form"
+ @submit.prevent.stop="rename">
+ <NcTextField ref="input"
+ :value.sync="newName"
+ :label="t('settings', 'Device name')"
+ :show-trailing-button="true"
+ :trailing-button-label="t('settings', 'Cancel renaming')"
+ @trailing-button-click="cancelRename"
+ @keyup.esc="cancelRename" />
+ <NcButton :aria-label="t('settings', 'Save new name')" type="tertiary" native-type="submit">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCheck" />
+ </template>
+ </NcButton>
+ </form>
+ <span v-else>{{ tokenLabel }}</span>
+ <span v-if="wiping" class="wiping-warning">({{ t('settings', 'Marked for remote wipe') }})</span>
+ </div>
</td>
<td>
- <span v-tooltip="lastActivity" class="last-activity">{{ lastActivityRelative }}</span>
+ <NcDateTime class="auth-token__last-activity"
+ :ignore-seconds="true"
+ :timestamp="tokenLastActivity" />
</td>
- <td class="more">
- <Actions v-if="!token.current"
- v-tooltip.auto="{
- content: t('settings', 'Device settings'),
- container: 'body'
- }"
+ <td class="auth-token__actions">
+ <NcActions v-if="!token.current"
+ :title="t('settings', 'Device settings')"
+ :aria-label="t('settings', 'Device settings')"
:open.sync="actionOpen">
- <ActionCheckbox v-if="token.type === 1"
+ <NcActionCheckbox v-if="canChangeScope"
:checked="token.scope.filesystem"
- @change.stop.prevent="$emit('toggle-scope', token, 'filesystem', !token.scope.filesystem)">
+ @update:checked="updateFileSystemScope">
<!-- TODO: add text/longtext with some description -->
{{ t('settings', 'Allow filesystem access') }}
- </ActionCheckbox>
- <ActionButton v-if="token.canRename"
+ </NcActionCheckbox>
+ <NcActionButton v-if="token.canRename"
icon="icon-rename"
@click.stop.prevent="startRename">
<!-- TODO: add text/longtext with some description -->
{{ t('settings', 'Rename') }}
- </ActionButton>
+ </NcActionButton>
<!-- revoke & wipe -->
<template v-if="token.canDelete">
<template v-if="token.type !== 2">
- <ActionButton icon="icon-delete"
+ <NcActionButton icon="icon-delete"
@click.stop.prevent="revoke">
<!-- TODO: add text/longtext with some description -->
{{ t('settings', 'Revoke') }}
- </ActionButton>
- <ActionButton icon="icon-delete"
+ </NcActionButton>
+ <NcActionButton icon="icon-delete"
@click.stop.prevent="wipe">
{{ t('settings', 'Wipe device') }}
- </ActionButton>
+ </NcActionButton>
</template>
- <ActionButton v-else-if="token.type === 2"
+ <NcActionButton v-else-if="token.type === 2"
icon="icon-delete"
- :title="t('settings', 'Revoke')"
+ :name="t('settings', 'Revoke')"
@click.stop.prevent="revoke">
{{ t('settings', 'Revoking this token might prevent the wiping of your device if it has not started the wipe yet.') }}
- </ActionButton>
+ </NcActionButton>
</template>
- </Actions>
+ </NcActions>
</td>
</tr>
</template>
-<script>
-import {
- Actions,
- ActionButton,
- ActionCheckbox,
-} from '@nextcloud/vue'
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { IToken } from '../store/authtoken'
+
+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/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 = {
@@ -107,7 +109,7 @@ const userAgentMap = {
iphone: / *CPU +iPhone +OS +([0-9]+)_(?:[0-9_])+ +like +Mac +OS +X */,
ipad: /\(iPad; *CPU +OS +([0-9]+)_(?:[0-9_])+ +like +Mac +OS +X */,
iosClient: /^Mozilla\/5\.0 \(iOS\) (?:ownCloud|Nextcloud)-iOS.*$/,
- androidClient: /^Mozilla\/5\.0 \(Android\) ownCloud-android.*$/,
+ androidClient: /^Mozilla\/5\.0 \(Android\) (?:ownCloud|Nextcloud)-android.*$/,
iosTalkClient: /^Mozilla\/5\.0 \(iOS\) Nextcloud-Talk.*$/,
androidTalkClient: /^Mozilla\/5\.0 \(Android\) Nextcloud-Talk.*$/,
// DAVx5/3.3.8-beta2-gplay (2021/01/02; dav4jvm; okhttp/4.9.0) Android/10
@@ -116,123 +118,175 @@ const userAgentMap = {
webPirate: /(Sailfish).*WebPirate\/(\d+)/,
// Mozilla/5.0 (Maemo; Linux; U; Jolla; Sailfish; Mobile; rv:31.0) Gecko/31.0 Firefox/31.0 SailfishBrowser/1.0
sailfishBrowser: /(Sailfish).*SailfishBrowser\/(\d+)/,
+ // Neon 1.0.0+1
+ neon: /Neon \d+\.\d+\.\d+\+\d+/,
}
const nameMap = {
- ie: t('setting', 'Internet Explorer'),
- edge: t('setting', 'Edge'),
- firefox: t('setting', 'Firefox'),
- chrome: t('setting', 'Google Chrome'),
- safari: t('setting', 'Safari'),
- androidChrome: t('setting', 'Google Chrome for Android'),
- iphone: t('setting', 'iPhone'),
- ipad: t('setting', 'iPad'),
- iosClient: t('setting', '{productName} iOS app', { productName: window.oc_defaults.productName }),
- androidClient: t('setting', '{productName} Android app', { productName: window.oc_defaults.productName }),
- iosTalkClient: t('setting', '{productName} Talk for iOS', { productName: window.oc_defaults.productName }),
- androidTalkClient: t('setting', '{productName} Talk for Android', { productName: window.oc_defaults.productName }),
+ edge: 'Microsoft Edge',
+ firefox: 'Firefox',
+ chrome: 'Google Chrome',
+ safari: 'Safari',
+ androidChrome: t('settings', 'Google Chrome for Android'),
+ iphone: 'iPhone',
+ ipad: 'iPad',
+ iosClient: t('settings', '{productName} iOS app', { productName: window.oc_defaults.productName }),
+ androidClient: t('settings', '{productName} Android app', { productName: window.oc_defaults.productName }),
+ iosTalkClient: t('settings', '{productName} Talk for iOS', { productName: window.oc_defaults.productName }),
+ androidTalkClient: t('settings', '{productName} Talk for Android', { productName: window.oc_defaults.productName }),
+ syncClient: t('settings', 'Sync client'),
davx5: 'DAVx5',
webPirate: 'WebPirate',
sailfishBrowser: 'SailfishBrowser',
-}
-const iconMap = {
- ie: 'icon-desktop',
- edge: 'icon-desktop',
- firefox: 'icon-desktop',
- chrome: 'icon-desktop',
- safari: 'icon-desktop',
- androidChrome: 'icon-phone',
- iphone: 'icon-phone',
- ipad: 'icon-tablet',
- iosClient: 'icon-phone',
- androidClient: 'icon-phone',
- iosTalkClient: 'icon-phone',
- androidTalkClient: 'icon-phone',
- davx5: 'icon-phone',
- webPirate: 'icon-link',
- sailfishBrowser: 'icon-link',
+ neon: 'Neon',
}
-export default {
+export default defineComponent({
name: 'AuthToken',
components: {
- Actions,
- ActionButton,
- ActionCheckbox,
+ NcActions,
+ NcActionButton,
+ NcActionCheckbox,
+ NcButton,
+ NcDateTime,
+ NcIconSvgWrapper,
+ NcTextField,
},
props: {
token: {
- type: Object,
+ type: Object as PropType<IToken>,
required: true,
},
},
+ setup() {
+ const authTokenStore = useAuthTokenStore()
+ return { authTokenStore }
+ },
data() {
return {
- showMore: this.token.canScope || this.token.canDelete,
+ actionOpen: false,
renaming: false,
newName: '',
- actionOpen: false,
+ oldName: '',
+ mdiCheck,
}
},
computed: {
- lastActivityRelative() {
- return OC.Util.relativeModifiedDate(this.token.lastActivity * 1000)
- },
- lastActivity() {
- return OC.Util.formatDate(this.token.lastActivity * 1000, 'LLL')
+ canChangeScope() {
+ return this.token.type === TokenType.PERMANENT_TOKEN
},
- iconName() {
+ /**
+ * Object ob the current user agent used by the token
+ * This either returns an object containing user agent information or `null` if unknown
+ */
+ client() {
// pretty format sync client user agent
const matches = this.token.name.match(/Mozilla\/5\.0 \((\w+)\) (?:mirall|csyncoC)\/(\d+\.\d+\.\d+)/)
- let icon = ''
if (matches) {
- /* eslint-disable-next-line */
- this.token.name = t('settings', 'Sync client - {os}', {
+ return {
+ id: 'syncClient',
os: matches[1],
version: matches[2],
- })
- icon = 'icon-desktop'
+ }
}
- // preserve title for cases where we format it further
- const title = this.token.name
- let name = this.token.name
for (const client in userAgentMap) {
- const matches = title.match(userAgentMap[client])
+ const matches = this.token.name.match(userAgentMap[client])
if (matches) {
- if (matches[2] && matches[1]) { // version number and os
- name = nameMap[client] + ' ' + matches[2] + ' - ' + matches[1]
- } else if (matches[1]) { // only version number
- name = nameMap[client] + ' ' + matches[1]
- } else {
- name = nameMap[client]
+ return {
+ id: client,
+ os: matches[2] && matches[1],
+ version: matches[2] ?? matches[1],
}
-
- icon = iconMap[client]
}
}
+
+ return null
+ },
+ /**
+ * Last activity of the token as ECMA timestamp (in ms)
+ */
+ tokenLastActivity() {
+ return this.token.lastActivity * 1000
+ },
+ /**
+ * Icon to use for the current token
+ */
+ tokenIcon() {
+ // For custom created app tokens / app passwords
+ if (this.token.type === TokenType.PERMANENT_TOKEN) {
+ return mdiKeyOutline
+ }
+
+ switch (this.client?.id) {
+ case 'edge':
+ return mdiMicrosoftEdge
+ case 'firefox':
+ return mdiFirefox
+ case 'chrome':
+ return mdiGoogleChrome
+ case 'safari':
+ return mdiAppleSafari
+ case 'androidChrome':
+ case 'androidClient':
+ case 'androidTalkClient':
+ return mdiAndroid
+ case 'iphone':
+ case 'iosClient':
+ case 'iosTalkClient':
+ return mdiAppleIos
+ case 'ipad':
+ return mdiTablet
+ case 'davx5':
+ return mdiCellphone
+ case 'syncClient':
+ return mdiMonitor
+ case 'webPirate':
+ case 'sailfishBrowser':
+ default:
+ return mdiWeb
+ }
+ },
+ /**
+ * Label to be shown for current token
+ */
+ tokenLabel() {
if (this.token.current) {
- name = t('settings', 'This session')
+ return t('settings', 'This session')
+ }
+ if (this.client === null) {
+ return this.token.name
}
- return {
- icon,
- name,
+ const name = nameMap[this.client.id]
+ if (this.client.os) {
+ return t('settings', '{client} - {version} ({system})', { client: name, system: this.client.os, version: this.client.version })
+ } else if (this.client.version) {
+ return t('settings', '{client} - {version}', { client: name, version: this.client.version })
}
+ return name
},
+ /**
+ * If the current token is considered for remote wiping
+ */
wiping() {
- return this.token.type === 2
+ return this.token.type === TokenType.WIPING_TOKEN
},
},
methods: {
+ t,
+ updateFileSystemScope(state: boolean) {
+ this.authTokenStore.setTokenScope(this.token, 'filesystem', state)
+ },
startRename() {
// Close action (popover menu)
this.actionOpen = false
+ this.oldName = this.token.name
this.newName = this.token.name
this.renaming = true
this.$nextTick(() => {
- this.$refs.input.select()
+ this.$refs.input!.select()
})
},
cancelRename() {
@@ -240,68 +294,61 @@ export default {
},
revoke() {
this.actionOpen = false
- this.$emit('delete', this.token)
+ this.authTokenStore.deleteToken(this.token)
},
rename() {
this.renaming = false
- this.$emit('rename', this.token, this.newName)
+ this.authTokenStore.renameToken(this.token, this.newName)
},
wipe() {
this.actionOpen = false
- this.$emit('wipe', this.token)
+ this.authTokenStore.wipeToken(this.token)
},
},
-}
+})
</script>
<style lang="scss" scoped>
- .wiping {
- background-color: var(--color-background-darker);
- }
-
- td {
- border-top: 1px solid var(--color-border);
- max-width: 200px;
- white-space: normal;
- vertical-align: middle;
- position: relative;
+.auth-token {
+ border-top: 2px solid var(--color-border);
+ max-width: 200px;
+ white-space: normal;
+ vertical-align: middle;
+ position: relative;
- &%icon {
- overflow: visible;
- position: relative;
- width: 44px;
- height: 44px;
- }
+ &--wiping {
+ background-color: var(--color-background-dark);
+ }
- &.token-name {
- padding: 10px 6px;
+ &__name {
+ padding-block: 10px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 355px; // ensure no jumping when renaming
+ }
- &.token-rename {
- padding: 0;
- }
+ &__name-wrapper {
+ display: flex;
+ flex-direction: column;
+ }
- input {
- width: 100%;
- margin: 0;
- }
- }
- &.token-name .wiping-warning {
- color: var(--color-text-lighter);
- }
+ &__name-form {
+ align-items: end;
+ display: flex;
+ gap: 4px;
+ }
- &.more {
- @extend %icon;
- padding: 0 10px;
- }
+ &__actions {
+ padding: 0 10px;
+ }
- &.client {
- @extend %icon;
+ &__last-activity {
+ padding-inline-start: 10px;
+ }
- div {
- opacity: 0.57;
- width: 44px;
- height: 44px;
- }
- }
+ .wiping-warning {
+ color: var(--color-text-maxcontrast);
}
+}
</style>
diff --git a/apps/settings/src/components/AuthTokenList.vue b/apps/settings/src/components/AuthTokenList.vue
index 491b672d59a..dbe3b9596d8 100644
--- a/apps/settings/src/components/AuthTokenList.vue
+++ b/apps/settings/src/components/AuthTokenList.vue
@@ -1,135 +1,77 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <table id="app-tokens-table">
- <thead v-if="tokens.length">
+ <table id="app-tokens-table" class="token-list">
+ <thead>
<tr>
- <th />
- <th>{{ t('settings', 'Device') }}</th>
- <th>{{ t('settings', 'Last activity') }}</th>
- <th />
+ <th class="token-list__header-device">
+ {{ t('settings', 'Device') }}
+ </th>
+ <th class="toke-list__header-activity">
+ {{ t('settings', 'Last activity') }}
+ </th>
+ <th>
+ <span class="hidden-visually">
+ {{ t('settings', 'Actions') }}
+ </span>
+ </th>
</tr>
</thead>
- <tbody class="token-list">
+ <tbody class="token-list__body">
<AuthToken v-for="token in sortedTokens"
:key="token.id"
- :token="token"
- @toggle-scope="toggleScope"
- @rename="rename"
- @delete="onDelete"
- @wipe="onWipe" />
+ :token="token" />
</tbody>
</table>
</template>
-<script>
-import AuthToken from './AuthToken'
+<script lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+import { useAuthTokenStore } from '../store/authtoken'
-export default {
+import AuthToken from './AuthToken.vue'
+
+export default defineComponent({
name: 'AuthTokenList',
components: {
AuthToken,
},
- props: {
- tokens: {
- type: Array,
- required: true,
- },
+ setup() {
+ const authTokenStore = useAuthTokenStore()
+ return { authTokenStore }
},
computed: {
sortedTokens() {
- return this.tokens.slice().sort((t1, t2) => {
- const ts1 = parseInt(t1.lastActivity, 10)
- const ts2 = parseInt(t2.lastActivity, 10)
- return ts2 - ts1
- })
+ return [...this.authTokenStore.tokens].sort((t1, t2) => t2.lastActivity - t1.lastActivity)
},
},
methods: {
- toggleScope(token, scope, value) {
- // Just pass it on
- this.$emit('toggle-scope', token, scope, value)
- },
- rename(token, newName) {
- // Just pass it on
- this.$emit('rename', token, newName)
- },
- onDelete(token) {
- // Just pass it on
- this.$emit('delete', token)
- },
- onWipe(token) {
- // Just pass it on
- this.$emit('wipe', token)
- },
+ t,
},
-}
+})
</script>
<style lang="scss" scoped>
- table {
- width: 100%;
- min-height: 50px;
- padding-top: 5px;
- max-width: 580px;
+.token-list {
+ width: 100%;
+ min-height: 50px;
+ padding-top: 5px;
+ max-width: fit-content;
- th {
- opacity: .5;
- padding: 10px 0;
- }
+ th {
+ padding-block: 10px;
+ padding-inline-start: 10px;
}
- .token-list {
- td > a.icon-more {
- transition: opacity var(--animation-quick);
- }
-
- a.icon-more {
- padding: 14px;
- display: block;
- width: 44px;
- height: 44px;
- opacity: .5;
- }
-
- tr {
- &:hover td > a.icon,
- td > a.icon:focus,
- &.active td > a.icon {
- opacity: 1;
- }
- }
+ #{&}__header-device {
+ padding-inline-start: 50px; // 44px icon + 6px padding
}
-</style>
-
-<!-- some styles are not scoped to make them work on subcomponents -->
-<style lang="scss">
- #app-tokens-table {
- tr > *:nth-child(2) {
- padding-left: 6px;
- }
-
- tr > *:nth-child(3) {
- text-align: right;
- }
+ &__header-activity {
+ text-align: end;
}
+}
</style>
diff --git a/apps/settings/src/components/AuthTokenSection.vue b/apps/settings/src/components/AuthTokenSection.vue
index 9d060c1cc64..3a216f6407f 100644
--- a/apps/settings/src/components/AuthTokenSection.vue
+++ b/apps/settings/src/components/AuthTokenSection.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div id="security" class="section">
@@ -25,163 +9,32 @@
<p class="settings-hint hidden-when-empty">
{{ t('settings', 'Web, desktop and mobile clients currently logged in to your account.') }}
</p>
- <AuthTokenList :tokens="tokens"
- @toggle-scope="toggleTokenScope"
- @rename="rename"
- @delete="deleteToken"
- @wipe="wipeToken" />
- <AuthTokenSetupDialogue v-if="canCreateToken" :add="addNewToken" />
+ <AuthTokenList />
+ <AuthTokenSetup v-if="canCreateToken" />
</div>
</template>
-<script>
-import axios from '@nextcloud/axios'
-import confirmPassword from '@nextcloud/password-confirmation'
-import { generateUrl } from '@nextcloud/router'
+<script lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
-import AuthTokenList from './AuthTokenList'
-import AuthTokenSetupDialogue from './AuthTokenSetupDialogue'
+import AuthTokenList from './AuthTokenList.vue'
+import AuthTokenSetup from './AuthTokenSetup.vue'
-const confirm = () => {
- return new Promise(resolve => {
- OC.dialogs.confirm(
- t('settings', 'Do you really want to wipe your data from this device?'),
- t('settings', 'Confirm wipe'),
- resolve,
- true
- )
- })
-}
-
-/**
- * Tap into a promise without losing the value
- *
- * @param {Function} cb the callback
- * @return {any} val the value
- */
-const tap = cb => val => {
- cb(val)
- return val
-}
-
-export default {
+export default defineComponent({
name: 'AuthTokenSection',
components: {
- AuthTokenSetupDialogue,
AuthTokenList,
- },
- props: {
- tokens: {
- type: Array,
- required: true,
- },
- canCreateToken: {
- type: Boolean,
- required: true,
- },
+ AuthTokenSetup,
},
data() {
return {
- baseUrl: generateUrl('/settings/personal/authtokens'),
+ canCreateToken: loadState('settings', 'can_create_app_token'),
}
},
methods: {
- addNewToken(name) {
- console.debug('creating a new app token', name)
-
- const data = {
- name,
- }
- return axios.post(this.baseUrl, data)
- .then(resp => resp.data)
- .then(tap(() => console.debug('app token created')))
- // eslint-disable-next-line vue/no-mutating-props
- .then(tap(data => this.tokens.push(data.deviceToken)))
- .catch(err => {
- console.error.bind('could not create app password', err)
- OC.Notification.showTemporary(t('settings', 'Error while creating device token'))
- throw err
- })
- },
- toggleTokenScope(token, scope, value) {
- console.debug('updating app token scope', token.id, scope, value)
-
- const oldVal = token.scope[scope]
- token.scope[scope] = value
-
- return this.updateToken(token)
- .then(tap(() => console.debug('app token scope updated')))
- .catch(err => {
- console.error.bind('could not update app token scope', err)
- OC.Notification.showTemporary(t('settings', 'Error while updating device token scope'))
-
- // Restore
- token.scope[scope] = oldVal
-
- throw err
- })
- },
- rename(token, newName) {
- console.debug('renaming app token', token.id, token.name, newName)
-
- const oldName = token.name
- token.name = newName
-
- return this.updateToken(token)
- .then(tap(() => console.debug('app token name updated')))
- .catch(err => {
- console.error.bind('could not update app token name', err)
- OC.Notification.showTemporary(t('settings', 'Error while updating device token name'))
-
- // Restore
- token.name = oldName
- })
- },
- updateToken(token) {
- return axios.put(this.baseUrl + '/' + token.id, token)
- .then(resp => resp.data)
- },
- deleteToken(token) {
- console.debug('deleting app token', token)
-
- // eslint-disable-next-line vue/no-mutating-props
- this.tokens = this.tokens.filter(t => t !== token)
-
- return axios.delete(this.baseUrl + '/' + token.id)
- .then(resp => resp.data)
- .then(tap(() => console.debug('app token deleted')))
- .catch(err => {
- console.error.bind('could not delete app token', err)
- OC.Notification.showTemporary(t('settings', 'Error while deleting the token'))
-
- // Restore
- // eslint-disable-next-line vue/no-mutating-props
- this.tokens.push(token)
- })
- },
- async wipeToken(token) {
- console.debug('wiping app token', token)
-
- try {
- await confirmPassword()
-
- if (!(await confirm())) {
- console.debug('wipe aborted by user')
- return
- }
- await axios.post(this.baseUrl + '/wipe/' + token.id)
- console.debug('app token marked for wipe')
-
- token.type = 2
- } catch (err) {
- console.error('could not wipe app token', err)
- OC.Notification.showTemporary(t('settings', 'Error while wiping the device with the token'))
- }
- },
+ t,
},
-}
+})
</script>
-
-<style scoped>
-
-</style>
diff --git a/apps/settings/src/components/AuthTokenSetup.vue b/apps/settings/src/components/AuthTokenSetup.vue
new file mode 100644
index 00000000000..b93086c9e88
--- /dev/null
+++ b/apps/settings/src/components/AuthTokenSetup.vue
@@ -0,0 +1,97 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <form id="generate-app-token-section"
+ class="row spacing"
+ @submit.prevent="submit">
+ <!-- Port to TextField component when available -->
+ <NcTextField :value.sync="deviceName"
+ type="text"
+ :maxlength="120"
+ :disabled="loading"
+ class="app-name-text-field"
+ :label="t('settings', 'App name')"
+ :placeholder="t('settings', 'App name')" />
+ <NcButton type="primary"
+ :disabled="loading || deviceName.length === 0"
+ native-type="submit">
+ {{ t('settings', 'Create new app password') }}
+ </NcButton>
+
+ <AuthTokenSetupDialog :token="newToken" @close="newToken = null" />
+ </form>
+</template>
+
+<script lang="ts">
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+import { useAuthTokenStore, type ITokenResponse } from '../store/authtoken'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue'
+import logger from '../logger'
+
+export default defineComponent({
+ name: 'AuthTokenSetup',
+ components: {
+ NcButton,
+ NcTextField,
+ AuthTokenSetupDialog,
+ },
+ setup() {
+ const authTokenStore = useAuthTokenStore()
+ return { authTokenStore }
+ },
+ data() {
+ return {
+ deviceName: '',
+ loading: false,
+ newToken: null as ITokenResponse|null,
+ }
+ },
+ methods: {
+ t,
+ reset() {
+ this.loading = false
+ this.deviceName = ''
+ this.newToken = null
+ },
+ async submit() {
+ try {
+ this.loading = true
+ this.newToken = await this.authTokenStore.addToken(this.deviceName)
+ } catch (error) {
+ logger.error(error as Error)
+ showError(t('settings', 'Error while creating device token'))
+ this.reset()
+ } finally {
+ this.loading = false
+ }
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+ .app-name-text-field {
+ height: 44px !important;
+ padding-inline-start: 12px;
+ margin-inline-end: 12px;
+ width: 200px;
+ }
+
+ .row {
+ display: flex;
+ align-items: center;
+ }
+
+ .spacing {
+ padding-top: 16px;
+ }
+</style>
diff --git a/apps/settings/src/components/AuthTokenSetupDialog.vue b/apps/settings/src/components/AuthTokenSetupDialog.vue
new file mode 100644
index 00000000000..3b8fac8dc1d
--- /dev/null
+++ b/apps/settings/src/components/AuthTokenSetupDialog.vue
@@ -0,0 +1,203 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog :open.sync="open"
+ :name="t('settings', 'New app password')"
+ content-classes="token-dialog">
+ <p>
+ {{ t('settings', 'Use the credentials below to configure your app or device. For security reasons this password will only be shown once.') }}
+ </p>
+ <div class="token-dialog__name">
+ <NcTextField :label="t('settings', 'Login')" :value="loginName" readonly />
+ <NcButton type="tertiary"
+ :title="copyLoginNameLabel"
+ :aria-label="copyLoginNameLabel"
+ @click="copyLoginName">
+ <template #icon>
+ <NcIconSvgWrapper :path="copyNameIcon" />
+ </template>
+ </NcButton>
+ </div>
+ <div class="token-dialog__password">
+ <NcTextField ref="appPassword"
+ :label="t('settings', 'Password')"
+ :value="appPassword"
+ readonly />
+ <NcButton type="tertiary"
+ :title="copyPasswordLabel"
+ :aria-label="copyPasswordLabel"
+ @click="copyPassword">
+ <template #icon>
+ <NcIconSvgWrapper :path="copyPasswordIcon" />
+ </template>
+ </NcButton>
+ </div>
+ <div class="token-dialog__qrcode">
+ <NcButton v-if="!showQRCode" @click="showQRCode = true">
+ {{ t('settings', 'Show QR code for mobile apps') }}
+ </NcButton>
+ <QR v-else :value="qrUrl" />
+ </div>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import type { ITokenResponse } from '../store/authtoken'
+
+import { mdiCheck, mdiContentCopy } from '@mdi/js'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { getRootUrl } from '@nextcloud/router'
+import { defineComponent, type PropType } from 'vue'
+
+import QR from '@chenfengyuan/vue-qrcode'
+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'
+
+export default defineComponent({
+ name: 'AuthTokenSetupDialog',
+ components: {
+ NcButton,
+ NcDialog,
+ NcIconSvgWrapper,
+ NcTextField,
+ QR,
+ },
+ props: {
+ token: {
+ type: Object as PropType<ITokenResponse|null>,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ isNameCopied: false,
+ isPasswordCopied: false,
+ showQRCode: false,
+ }
+ },
+ computed: {
+ open: {
+ get() {
+ return this.token !== null
+ },
+ set(value: boolean) {
+ if (!value) {
+ this.$emit('close')
+ }
+ },
+ },
+ copyPasswordIcon() {
+ return this.isPasswordCopied ? mdiCheck : mdiContentCopy
+ },
+ copyNameIcon() {
+ return this.isNameCopied ? mdiCheck : mdiContentCopy
+ },
+ appPassword() {
+ return this.token?.token ?? ''
+ },
+ loginName() {
+ return this.token?.loginName ?? ''
+ },
+ qrUrl() {
+ const server = window.location.protocol + '//' + window.location.host + getRootUrl()
+ return `nc://login/user:${this.loginName}&password:${this.appPassword}&server:${server}`
+ },
+ copyPasswordLabel() {
+ if (this.isPasswordCopied) {
+ return t('settings', 'App password copied!')
+ }
+ return t('settings', 'Copy app password')
+ },
+ copyLoginNameLabel() {
+ if (this.isNameCopied) {
+ return t('settings', 'Login name copied!')
+ }
+ return t('settings', 'Copy login name')
+ },
+ },
+ watch: {
+ token() {
+ // reset showing the QR code on token change
+ this.showQRCode = false
+ },
+ open() {
+ if (this.open) {
+ this.$nextTick(() => {
+ this.$refs.appPassword!.select()
+ })
+ }
+ },
+ },
+ methods: {
+ t,
+ async copyPassword() {
+ try {
+ await navigator.clipboard.writeText(this.appPassword)
+ this.isPasswordCopied = true
+ } catch (e) {
+ this.isPasswordCopied = false
+ logger.error(e as Error)
+ showError(t('settings', 'Could not copy app password. Please copy it manually.'))
+ } finally {
+ setTimeout(() => {
+ this.isPasswordCopied = false
+ }, 4000)
+ }
+ },
+ async copyLoginName() {
+ try {
+ await navigator.clipboard.writeText(this.loginName)
+ this.isNameCopied = true
+ } catch (e) {
+ this.isNameCopied = false
+ logger.error(e as Error)
+ showError(t('settings', 'Could not copy login name. Please copy it manually.'))
+ } finally {
+ setTimeout(() => {
+ this.isNameCopied = false
+ }, 4000)
+ }
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+:deep(.token-dialog) {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ padding-inline: 22px;
+ padding-block-end: 20px;
+
+ > * {
+ box-sizing: border-box;
+ }
+}
+
+.token-dialog {
+ &__name, &__password {
+ align-items: end;
+ display: flex;
+ gap: 10px;
+
+ :deep(input) {
+ font-family: monospace;
+ }
+ }
+
+ &__qrcode {
+ display: flex;
+ justify-content: center;
+ }
+}
+</style>
diff --git a/apps/settings/src/components/AuthTokenSetupDialogue.vue b/apps/settings/src/components/AuthTokenSetupDialogue.vue
deleted file mode 100644
index 6500f533881..00000000000
--- a/apps/settings/src/components/AuthTokenSetupDialogue.vue
+++ /dev/null
@@ -1,213 +0,0 @@
-<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
-
-<template>
- <div v-if="!adding">
- <input v-model="deviceName"
- type="text"
- :maxlength="120"
- :disabled="loading"
- :placeholder="t('settings', 'App name')"
- @keydown.enter="submit">
- <Button :disabled="loading"
- @click="submit">
- {{ t('settings', 'Create new app password') }}
- </Button>
- </div>
- <div v-else>
- {{ t('settings', 'Use the credentials below to configure your app or device.') }}
- {{ t('settings', 'For security reasons this password will only be shown once.') }}
- <div class="app-password-row">
- <span class="app-password-label">{{ t('settings', 'Username') }}</span>
- <input :value="loginName"
- type="text"
- class="monospaced"
- readonly="readonly"
- @focus="selectInput">
- </div>
- <div class="app-password-row">
- <span class="app-password-label">{{ t('settings', 'Password') }}</span>
- <input ref="appPassword"
- :value="appPassword"
- type="text"
- class="monospaced"
- readonly="readonly"
- @focus="selectInput">
- <a ref="clipboardButton"
- v-tooltip="copyTooltipOptions"
- v-clipboard:copy="appPassword"
- v-clipboard:success="onCopyPassword"
- v-clipboard:error="onCopyPasswordFailed"
- class="icon icon-clippy"
- @mouseover="hoveringCopyButton = true"
- @mouseleave="hoveringCopyButton = false" />
- <Button @click="reset">
- {{ t('settings', 'Done') }}
- </Button>
- </div>
- <div class="app-password-row">
- <span class="app-password-label" />
- <a v-if="!showQR"
- @click="showQR = true">
- {{ t('settings', 'Show QR code for mobile apps') }}
- </a>
- <QR v-else
- :value="qrUrl" />
- </div>
- </div>
-</template>
-
-<script>
-import QR from '@chenfengyuan/vue-qrcode'
-import confirmPassword from '@nextcloud/password-confirmation'
-import { getRootUrl } from '@nextcloud/router'
-import Button from '@nextcloud/vue/dist/Components/Button'
-
-export default {
- name: 'AuthTokenSetupDialogue',
- components: {
- QR,
- Button,
- },
- props: {
- add: {
- type: Function,
- required: true,
- },
- },
- data() {
- return {
- adding: false,
- loading: false,
- deviceName: '',
- appPassword: '',
- loginName: '',
- passwordCopied: false,
- showQR: false,
- qrUrl: '',
- hoveringCopyButton: false,
- }
- },
- computed: {
- copyTooltipOptions() {
- const base = {
- hideOnTargetClick: false,
- trigger: 'manual',
- }
-
- if (this.passwordCopied) {
- return {
- ...base,
- content: t('settings', 'Copied!'),
- show: true,
- }
- } else {
- return {
- ...base,
- content: t('settings', 'Copy'),
- show: this.hoveringCopyButton,
- }
- }
- },
- },
- methods: {
- selectInput(e) {
- e.currentTarget.select()
- },
- submit() {
- confirmPassword()
- .then(() => {
- this.loading = true
- return this.add(this.deviceName)
- })
- .then(token => {
- this.adding = true
- this.loginName = token.loginName
- this.appPassword = token.token
-
- const server = window.location.protocol + '//' + window.location.host + getRootUrl()
- this.qrUrl = `nc://login/user:${token.loginName}&password:${token.token}&server:${server}`
-
- this.$nextTick(() => {
- this.$refs.appPassword.select()
- })
- })
- .catch(err => {
- console.error('could not create a new app password', err)
- OC.Notification.showTemporary(t('settings', 'Error while creating device token'))
-
- this.reset()
- })
- },
- onCopyPassword() {
- this.passwordCopied = true
- this.$refs.clipboardButton.blur()
- setTimeout(() => { this.passwordCopied = false }, 3000)
- },
- onCopyPasswordFailed() {
- OC.Notification.showTemporary(t('settings', 'Could not copy app password. Please copy it manually.'))
- },
- reset() {
- this.adding = false
- this.loading = false
- this.showQR = false
- this.qrUrl = ''
- this.deviceName = ''
- this.appPassword = ''
- this.loginName = ''
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
- .app-password-row {
- display: table-row;
-
- .icon {
- background-size: 16px 16px;
- display: inline-block;
- position: relative;
- top: 3px;
- margin-left: 5px;
- margin-right: 8px;
- }
-
- }
-
- .app-password-label {
- display: table-cell;
- padding-right: 1em;
- text-align: right;
- vertical-align: middle;
- }
-
- .monospaced {
- width: 245px;
- font-family: monospace;
- }
-
- .button-vue{
- display:inline-block;
- margin: 3px 3px 3px 3px;
- }
-
-</style>
diff --git a/apps/settings/src/components/BasicSettings/BackgroundJob.vue b/apps/settings/src/components/BasicSettings/BackgroundJob.vue
new file mode 100644
index 00000000000..a9a3cbb9cef
--- /dev/null
+++ b/apps/settings/src/components/BasicSettings/BackgroundJob.vue
@@ -0,0 +1,201 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcSettingsSection :name="t('settings', 'Background jobs')"
+ :description="t('settings', 'For the server to work properly, it\'s important to configure background jobs correctly. Cron is the recommended setting. Please see the documentation for more information.')"
+ :doc-url="backgroundJobsDocUrl">
+ <template v-if="lastCron !== 0">
+ <NcNoteCard v-if="oldExecution" type="error">
+ {{ t('settings', 'Last job execution ran {time}. Something seems wrong.', {time: relativeTime}) }}
+ </NcNoteCard>
+
+ <NcNoteCard v-else-if="longExecutionCron" type="warning">
+ {{ t('settings', "Some jobs have not been executed since {maxAgeRelativeTime}. Please consider increasing the execution frequency.", {maxAgeRelativeTime}) }}
+ </NcNoteCard>
+
+ <NcNoteCard v-else-if="longExecutionNotCron" type="warning">
+ {{ t('settings', "Some jobs have not been executed since {maxAgeRelativeTime}. Please consider switching to system cron.", {maxAgeRelativeTime}) }}
+ </NcNoteCard>
+
+ <NcNoteCard v-else type="success">
+ {{ t('settings', 'Last job ran {relativeTime}.', {relativeTime}) }}
+ </NcNoteCard>
+ </template>
+
+ <NcNoteCard v-else type="error">
+ {{ t('settings', 'Background job did not run yet!') }}
+ </NcNoteCard>
+
+ <NcCheckboxRadioSwitch type="radio"
+ :checked.sync="backgroundJobsMode"
+ name="backgroundJobsMode"
+ value="ajax"
+ class="ajaxSwitch"
+ @update:checked="onBackgroundJobModeChanged">
+ {{ t('settings', 'AJAX') }}
+ </NcCheckboxRadioSwitch>
+ <em>{{ t('settings', 'Execute one task with each page loaded. Use case: Single account instance.') }}</em>
+
+ <NcCheckboxRadioSwitch type="radio"
+ :checked.sync="backgroundJobsMode"
+ name="backgroundJobsMode"
+ value="webcron"
+ @update:checked="onBackgroundJobModeChanged">
+ {{ t('settings', 'Webcron') }}
+ </NcCheckboxRadioSwitch>
+ <em>{{ t('settings', 'cron.php is registered at a webcron service to call cron.php every 5 minutes over HTTP. Use case: Very small instance (1–5 accounts depending on the usage).') }}</em>
+
+ <NcCheckboxRadioSwitch type="radio"
+ :disabled="!cliBasedCronPossible"
+ :checked.sync="backgroundJobsMode"
+ value="cron"
+ name="backgroundJobsMode"
+ @update:checked="onBackgroundJobModeChanged">
+ {{ t('settings', 'Cron (Recommended)') }}
+ </NcCheckboxRadioSwitch>
+ <!-- eslint-disable-next-line vue/no-v-html The translation is sanitized-->
+ <em v-html="cronLabel" />
+ </NcSettingsSection>
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+import { showError } from '@nextcloud/dialogs'
+import { generateOcsUrl } from '@nextcloud/router'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import axios from '@nextcloud/axios'
+import moment from '@nextcloud/moment'
+
+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'
+
+const lastCron = loadState('settings', 'lastCron')
+const cronMaxAge = loadState('settings', 'cronMaxAge', '')
+const backgroundJobsMode = loadState('settings', 'backgroundJobsMode', 'cron')
+const cliBasedCronPossible = loadState('settings', 'cliBasedCronPossible', true)
+const cliBasedCronUser = loadState('settings', 'cliBasedCronUser', 'www-data')
+const backgroundJobsDocUrl = loadState('settings', 'backgroundJobsDocUrl')
+
+export default {
+ name: 'BackgroundJob',
+
+ components: {
+ NcCheckboxRadioSwitch,
+ NcSettingsSection,
+ NcNoteCard,
+ },
+
+ data() {
+ return {
+ lastCron,
+ cronMaxAge,
+ backgroundJobsMode,
+ cliBasedCronPossible,
+ cliBasedCronUser,
+ backgroundJobsDocUrl,
+ relativeTime: moment(lastCron * 1000).fromNow(),
+ maxAgeRelativeTime: moment(cronMaxAge * 1000).fromNow(),
+ }
+ },
+ computed: {
+ cronLabel() {
+ let desc = t('settings', 'Use system cron service to call the cron.php file every 5 minutes.')
+ if (this.cliBasedCronPossible) {
+ desc += '<br>' + t('settings', 'The cron.php needs to be executed by the system account "{user}".', { user: this.cliBasedCronUser })
+ } else {
+ desc += '<br>' + t('settings', 'The PHP POSIX extension is required. See {linkstart}PHP documentation{linkend} for more details.', {
+ linkstart: '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.php.net/manual/en/book.posix.php">',
+ linkend: '</a>',
+ }, undefined, { escape: false })
+ }
+ return desc
+ },
+ oldExecution() {
+ return Date.now() / 1000 - this.lastCron > 600
+ },
+ longExecutionNotCron() {
+ return Date.now() / 1000 - this.cronMaxAge > 12 * 3600 && this.backgroundJobsMode !== 'cron'
+ },
+ longExecutionCron() {
+ return Date.now() / 1000 - this.cronMaxAge > 24 * 3600 && this.backgroundJobsMode === 'cron'
+ },
+ },
+ methods: {
+ async onBackgroundJobModeChanged(backgroundJobsMode) {
+ const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
+ appId: 'core',
+ key: 'backgroundjobs_mode',
+ })
+
+ await confirmPassword()
+
+ try {
+ const { data } = await axios.post(url, {
+ value: backgroundJobsMode,
+ })
+ this.handleResponse({
+ status: data.ocs?.meta?.status,
+ })
+ } catch (e) {
+ this.handleResponse({
+ errorMessage: t('settings', 'Unable to update background job mode'),
+ error: e,
+ })
+ }
+ },
+ async handleResponse({ status, errorMessage, error }) {
+ if (status === 'ok') {
+ await this.deleteError()
+ } else {
+ showError(errorMessage)
+ console.error(errorMessage, error)
+ }
+ },
+ async deleteError() {
+ // clear cron errors on background job mode change
+ const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
+ appId: 'core',
+ key: 'cronErrors',
+ })
+
+ await confirmPassword()
+
+ try {
+ await axios.delete(url)
+ } catch (error) {
+ console.error(error)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.error {
+ margin-top: 8px;
+ padding: 5px;
+ border-radius: var(--border-radius);
+ color: var(--color-primary-element-text);
+ background-color: var(--color-error);
+ width: initial;
+}
+
+.warning {
+ margin-top: 8px;
+ padding: 5px;
+ border-radius: var(--border-radius);
+ color: var(--color-primary-element-text);
+ background-color: var(--color-warning);
+ width: initial;
+}
+
+.ajaxSwitch {
+ margin-top: 1rem;
+}
+</style>
diff --git a/apps/settings/src/components/BasicSettings/ProfileSettings.vue b/apps/settings/src/components/BasicSettings/ProfileSettings.vue
index 9abce3d787c..276448cd97b 100644
--- a/apps/settings/src/components/BasicSettings/ProfileSettings.vue
+++ b/apps/settings/src/components/BasicSettings/ProfileSettings.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -28,14 +11,14 @@
</h2>
<p class="settings-hint">
- {{ t('settings', 'Enable or disable profile by default for new users.') }}
+ {{ t('settings', 'Enable or disable profile by default for new accounts.') }}
</p>
- <CheckboxRadioSwitch type="switch"
+ <NcCheckboxRadioSwitch type="switch"
:checked.sync="initialProfileEnabledByDefault"
@update:checked="onProfileDefaultChange">
{{ t('settings', 'Enable') }}
- </CheckboxRadioSwitch>
+ </NcCheckboxRadioSwitch>
</div>
</template>
@@ -43,10 +26,11 @@
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
-import { saveProfileDefault } from '../../service/ProfileService'
-import { validateBoolean } from '../../utils/validate'
+import { saveProfileDefault } from '../../service/ProfileService.js'
+import { validateBoolean } from '../../utils/validate.js'
+import logger from '../../logger.ts'
-import CheckboxRadioSwitch from '@nextcloud/vue/dist/Components/CheckboxRadioSwitch'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const profileEnabledByDefault = loadState('settings', 'profileEnabledByDefault', true)
@@ -54,7 +38,7 @@ export default {
name: 'ProfileSettings',
components: {
- CheckboxRadioSwitch,
+ NcCheckboxRadioSwitch,
},
data() {
@@ -90,12 +74,9 @@ export default {
this.initialProfileEnabledByDefault = isEnabled
} else {
showError(errorMessage)
- this.logger.error(errorMessage, error)
+ logger.error(errorMessage, error)
}
},
},
}
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue b/apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue
new file mode 100644
index 00000000000..9ee1680516e
--- /dev/null
+++ b/apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue
@@ -0,0 +1,275 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcSettingsSection class="declarative-settings-section"
+ :name="t(formApp, form.title)"
+ :description="t(formApp, form.description)"
+ :doc-url="form.doc_url || ''">
+ <div v-for="formField in formFields"
+ :key="formField.id"
+ class="declarative-form-field"
+ :aria-label="t('settings', '{app}\'s declarative setting field: {name}', { app: formApp, name: t(formApp, formField.title) })"
+ :class="{
+ 'declarative-form-field-text': isTextFormField(formField),
+ 'declarative-form-field-select': formField.type === 'select',
+ 'declarative-form-field-multi-select': formField.type === 'multi-select',
+ 'declarative-form-field-checkbox': formField.type === 'checkbox',
+ 'declarative-form-field-multi_checkbox': formField.type === 'multi-checkbox',
+ 'declarative-form-field-radio': formField.type === 'radio'
+ }">
+ <template v-if="isTextFormField(formField)">
+ <div class="input-wrapper">
+ <NcInputField :type="formField.type"
+ :label="t(formApp, formField.title)"
+ :value.sync="formFieldsData[formField.id].value"
+ :placeholder="t(formApp, formField.placeholder)"
+ @update:value="onChangeDebounced(formField)"
+ @submit="updateDeclarativeSettingsValue(formField)" />
+ </div>
+ <span v-if="formField.description" class="hint">{{ t(formApp, formField.description) }}</span>
+ </template>
+
+ <template v-if="formField.type === 'select'">
+ <label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+ <div class="input-wrapper">
+ <NcSelect :id="formField.id + '_field'"
+ :options="formField.options"
+ :placeholder="t(formApp, formField.placeholder)"
+ :label-outside="true"
+ :value="formFieldsData[formField.id].value"
+ @input="(value) => updateFormFieldDataValue(value, formField, true)" />
+ </div>
+ <span v-if="formField.description" class="hint">{{ t(formApp, formField.description) }}</span>
+ </template>
+
+ <template v-if="formField.type === 'multi-select'">
+ <label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+ <div class="input-wrapper">
+ <NcSelect :id="formField.id + '_field'"
+ :options="formField.options"
+ :placeholder="t(formApp, formField.placeholder)"
+ :multiple="true"
+ :label-outside="true"
+ :value="formFieldsData[formField.id].value"
+ @input="(value) => {
+ formFieldsData[formField.id].value = value
+ updateDeclarativeSettingsValue(formField, JSON.stringify(formFieldsData[formField.id].value))
+ }
+ " />
+ </div>
+ <span v-if="formField.description" class="hint">{{ t(formApp, formField.description) }}</span>
+ </template>
+
+ <template v-if="formField.type === 'checkbox'">
+ <label v-if="formField.label" :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+ <NcCheckboxRadioSwitch :id="formField.id + '_field'"
+ :checked="Boolean(formFieldsData[formField.id].value)"
+ type="switch"
+ @update:checked="(value) => {
+ formField.value = value
+ updateFormFieldDataValue(+value, formField, true)
+ }
+ ">
+ {{ t(formApp, formField.label ?? formField.title) }}
+ </NcCheckboxRadioSwitch>
+ <span v-if="formField.description" class="hint">{{ t(formApp, formField.description) }}</span>
+ </template>
+
+ <template v-if="formField.type === 'multi-checkbox'">
+ <label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+ <NcCheckboxRadioSwitch v-for="option in formField.options"
+ :id="formField.id + '_field_' + option.value"
+ :key="option.value"
+ :checked="formFieldsData[formField.id].value[option.value]"
+ @update:checked="(value) => {
+ formFieldsData[formField.id].value[option.value] = value
+ // Update without re-generating initial formFieldsData.value object as the link to components are lost
+ updateDeclarativeSettingsValue(formField, JSON.stringify(formFieldsData[formField.id].value))
+ }
+ ">
+ {{ t(formApp, option.name) }}
+ </NcCheckboxRadioSwitch>
+ <span v-if="formField.description" class="hint">{{ t(formApp, formField.description) }}</span>
+ </template>
+
+ <template v-if="formField.type === 'radio'">
+ <label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+ <NcCheckboxRadioSwitch v-for="option in formField.options"
+ :key="option.value"
+ :value="option.value"
+ type="radio"
+ :checked="formFieldsData[formField.id].value"
+ @update:checked="(value) => updateFormFieldDataValue(value, formField, true)">
+ {{ t(formApp, option.name) }}
+ </NcCheckboxRadioSwitch>
+ <span v-if="formField.description" class="hint">{{ t(formApp, formField.description) }}</span>
+ </template>
+ </div>
+ </NcSettingsSection>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+import { showError } from '@nextcloud/dialogs'
+import debounce from 'debounce'
+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',
+ components: {
+ NcSettingsSection,
+ NcInputField,
+ NcSelect,
+ NcCheckboxRadioSwitch,
+ },
+ props: {
+ form: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ formFieldsData: {},
+ }
+ },
+ computed: {
+ formApp() {
+ return this.form.app || ''
+ },
+ formFields() {
+ return this.form.fields || []
+ },
+ },
+ beforeMount() {
+ this.initFormFieldsData()
+ },
+ methods: {
+ initFormFieldsData() {
+ this.form.fields.forEach((formField) => {
+ if (formField.type === 'checkbox') {
+ // convert bool to number using unary plus (+) operator
+ this.$set(formField, 'value', +formField.value)
+ }
+ if (formField.type === 'multi-checkbox') {
+ if (formField.value === '') {
+ // Init formFieldsData from options
+ this.$set(formField, 'value', {})
+ formField.options.forEach(option => {
+ this.$set(formField.value, option.value, false)
+ })
+ } else {
+ this.$set(formField, 'value', JSON.parse(formField.value))
+ // Merge possible new options
+ formField.options.forEach(option => {
+ if (!Object.prototype.hasOwnProperty.call(formField.value, option.value)) {
+ this.$set(formField.value, option.value, false)
+ }
+ })
+ // Remove options that are not in the form anymore
+ Object.keys(formField.value).forEach(key => {
+ if (!formField.options.find(option => option.value === key)) {
+ delete formField.value[key]
+ }
+ })
+ }
+ }
+ if (formField.type === 'multi-select') {
+ if (formField.value === '') {
+ // Init empty array for multi-select
+ this.$set(formField, 'value', [])
+ } else {
+ // JSON decode an array of multiple values set
+ this.$set(formField, 'value', JSON.parse(formField.value))
+ }
+ }
+ this.$set(this.formFieldsData, formField.id, {
+ value: formField.value,
+ })
+ })
+ },
+
+ updateFormFieldDataValue(value, formField, update = false) {
+ this.formFieldsData[formField.id].value = value
+ if (update) {
+ this.updateDeclarativeSettingsValue(formField)
+ }
+ },
+
+ async updateDeclarativeSettingsValue(formField, value = null) {
+ try {
+ 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,
+ value: value === null ? this.formFieldsData[formField.id].value : value,
+ })
+ } catch (err) {
+ console.debug(err)
+ showError(t('settings', 'Failed to save setting'))
+ }
+ },
+
+ onChangeDebounced: debounce(function(formField) {
+ this.updateDeclarativeSettingsValue(formField)
+ }, 1000),
+
+ isTextFormField(formField) {
+ return ['text', 'password', 'email', 'tel', 'url', 'number'].includes(formField.type)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.declarative-form-field {
+ padding: 10px 0;
+
+ .input-wrapper {
+ width: 100%;
+ max-width: 400px;
+ }
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .hint {
+ display: inline-block;
+ color: var(--color-text-maxcontrast);
+ margin-inline-start: 8px;
+ padding-block-start: 5px;
+ }
+
+ &-radio, &-multi_checkbox {
+ max-height: 250px;
+ overflow-y: auto;
+ }
+
+ &-multi-select, &-select {
+ display: flex;
+ flex-direction: column;
+
+ label {
+ margin-bottom: 5px;
+ }
+ }
+}
+</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 173d3a45f5b..69bb8a3f575 100644
--- a/apps/settings/src/components/GroupListItem.vue
+++ b/apps/settings/src/components/GroupListItem.vue
@@ -1,89 +1,141 @@
<!--
- - @copyright Copyright (c) 2021 Martin Jänel <spammemore@posteo.de>
- -
- - @author Martin Jänel <spammemore@posteo.de>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <AppNavigationItem :key="id"
- :exact="true"
- :title="title"
- :to="{ name: 'group', params: { selectedGroup: encodeURIComponent(id) } }"
- icon="icon-group"
- :loading="loadingRenameGroup"
- :menu-open="openGroupMenu"
- @update:menuOpen="handleGroupMenuOpen">
- <template #counter>
- <CounterBubble v-if="count">
- {{ count }}
- </CounterBubble>
- </template>
- <template #actions>
- <ActionInput v-if="id !== 'admin' && id !== 'disabled' && settings.isAdmin"
- ref="displayNameInput"
- icon="icon-edit"
- type="text"
- :value="title"
- @submit="renameGroup(id)">
- {{ t('settings', 'Rename group') }}
- </ActionInput>
- <ActionButton v-if="id !== 'admin' && id !== 'disabled' && settings.isAdmin"
- icon="icon-delete"
- @click="removeGroup(id)">
- {{ t('settings', 'Remove group') }}
- </ActionButton>
- </template>
- </AppNavigationItem>
+ <Fragment>
+ <NcModal v-if="showRemoveGroupModal"
+ @close="showRemoveGroupModal = false">
+ <div class="modal__content">
+ <h2 class="modal__header">
+ {{ t('settings', 'Please confirm the group removal') }}
+ </h2>
+ <NcNoteCard type="warning"
+ show-alert>
+ {{ 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"
+ @click="showRemoveGroupModal = false">
+ {{ t('settings', 'Cancel') }}
+ </NcButton>
+ <NcButton type="primary"
+ @click="removeGroup">
+ {{ t('settings', 'Confirm') }}
+ </NcButton>
+ </div>
+ </div>
+ </NcModal>
+
+ <NcAppNavigationItem :key="id"
+ ref="listItem"
+ :exact="true"
+ :name="name"
+ :to="{ name: 'group', params: { selectedGroup: encodeURIComponent(id) } }"
+ :loading="loadingRenameGroup"
+ :menu-open="openGroupMenu"
+ @update:menuOpen="handleGroupMenuOpen">
+ <template #icon>
+ <AccountGroup :size="20" />
+ </template>
+ <template #counter>
+ <NcCounterBubble v-if="count"
+ :type="active ? 'highlighted' : undefined">
+ {{ count }}
+ </NcCounterBubble>
+ </template>
+ <template #actions>
+ <NcActionInput v-if="id !== 'admin' && id !== 'disabled' && (settings.isAdmin || settings.isDelegatedAdmin)"
+ ref="displayNameInput"
+ :trailing-button-label="t('settings', 'Submit')"
+ type="text"
+ :value="name"
+ :label=" t('settings', 'Rename group')"
+ @submit="renameGroup(id)">
+ <template #icon>
+ <Pencil :size="20" />
+ </template>
+ </NcActionInput>
+ <NcActionButton v-if="id !== 'admin' && id !== 'disabled' && (settings.isAdmin || settings.isDelegatedAdmin)"
+ @click="showRemoveGroupModal = true">
+ <template #icon>
+ <Delete :size="20" />
+ </template>
+ {{ t('settings', 'Delete group') }}
+ </NcActionButton>
+ </template>
+ </NcAppNavigationItem>
+ </Fragment>
</template>
<script>
-import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
-import CounterBubble from '@nextcloud/vue/dist/Components/CounterBubble'
-import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
+import { Fragment } from 'vue-frag'
+
+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/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'
export default {
name: 'GroupListItem',
components: {
- ActionInput,
- ActionButton,
- CounterBubble,
- AppNavigationItem,
+ AccountGroup,
+ Delete,
+ Fragment,
+ NcActionButton,
+ NcActionInput,
+ NcAppNavigationItem,
+ NcButton,
+ NcCounterBubble,
+ NcModal,
+ NcNoteCard,
+ Pencil,
},
props: {
+ /**
+ * If this group is currently selected
+ */
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ /**
+ * Number of members within this group
+ */
+ count: {
+ type: Number,
+ default: null,
+ },
+ /**
+ * Identifier of this group
+ */
id: {
type: String,
required: true,
},
- title: {
+ /**
+ * Name of this group
+ */
+ name: {
type: String,
required: true,
},
- count: {
- type: Number,
- required: false,
- },
},
data() {
return {
loadingRenameGroup: false,
openGroupMenu: false,
+ showRemoveGroupModal: false,
}
},
computed: {
@@ -122,19 +174,36 @@ export default {
this.loadingRenameGroup = false
}
},
- removeGroup(groupid) {
- const self = this
- // TODO migrate to a vue js confirm dialog component
- OC.dialogs.confirm(
- t('settings', 'You are about to remove the group {group}. The users will NOT be deleted.', { group: groupid }),
- t('settings', 'Please confirm the group removal '),
- function(success) {
- if (success) {
- self.$store.dispatch('removeGroup', groupid)
- }
- }
- )
+ async removeGroup() {
+ try {
+ await this.$store.dispatch('removeGroup', this.id)
+ this.showRemoveGroupModal = false
+ } catch (error) {
+ showError(t('settings', 'Failed to delete group "{group}"', { group: this.name }))
+ }
},
},
}
</script>
+
+<style lang="scss" scoped>
+.modal {
+ &__header {
+ margin: 0;
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 20px;
+ gap: 4px 0;
+ }
+
+ &__button-row {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ }
+}
+</style>
diff --git a/apps/settings/src/components/Markdown.cy.ts b/apps/settings/src/components/Markdown.cy.ts
new file mode 100644
index 00000000000..ccdf43c26df
--- /dev/null
+++ b/apps/settings/src/components/Markdown.cy.ts
@@ -0,0 +1,58 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Markdown from './Markdown.vue'
+
+describe('Markdown component', () => {
+ it('renders links', () => {
+ cy.mount(Markdown, {
+ propsData: {
+ text: 'This is [a link](http://example.com)!',
+ },
+ })
+
+ cy.contains('This is')
+ .find('a')
+ .should('exist')
+ .and('have.attr', 'href', 'http://example.com')
+ .and('contain.text', 'a link')
+ })
+
+ it('renders headings', () => {
+ cy.mount(Markdown, {
+ propsData: {
+ text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
+ },
+ })
+
+ for (let level = 1; level <= 6; level++) {
+ cy.contains(`h${level}`, `level ${level}`)
+ .should('be.visible')
+ }
+ })
+
+ it('can limit headings', () => {
+ cy.mount(Markdown, {
+ propsData: {
+ text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
+ minHeading: 4,
+ },
+ })
+
+ cy.get('h1').should('not.exist')
+ cy.get('h2').should('not.exist')
+ cy.get('h3').should('not.exist')
+ cy.get('h4')
+ .should('exist')
+ .and('contain.text', 'level 1')
+ cy.get('h5')
+ .should('exist')
+ .and('contain.text', 'level 2')
+ cy.contains('h6', 'level 3').should('exist')
+ cy.contains('h6', 'level 4').should('exist')
+ cy.contains('h6', 'level 5').should('exist')
+ cy.contains('h6', 'level 6').should('exist')
+ })
+})
diff --git a/apps/settings/src/components/Markdown.vue b/apps/settings/src/components/Markdown.vue
index fbbbf7456a1..36535e46763 100644
--- a/apps/settings/src/components/Markdown.vue
+++ b/apps/settings/src/components/Markdown.vue
@@ -1,26 +1,10 @@
<!--
- - @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
+ <!-- eslint-disable-next-line vue/no-v-html This is rendered markdown so should be "safe" -->
<div class="settings-markdown" v-html="renderMarkdown" />
</template>
@@ -35,11 +19,15 @@ export default {
type: String,
default: '',
},
+ minHeading: {
+ type: Number,
+ default: 1,
+ },
},
computed: {
renderMarkdown() {
const renderer = new marked.Renderer()
- renderer.link = function(href, title, text) {
+ renderer.link = function({ href, title, text }) {
let prot
try {
prot = decodeURIComponent(unescape(href))
@@ -60,14 +48,18 @@ export default {
out += '>' + text + '</a>'
return out
}
- renderer.image = function(href, title, text) {
+ renderer.heading = ({ text, depth }) => {
+ depth = Math.min(6, depth + (this.minHeading - 1))
+ return `<h${depth}>${text}</h${depth}>`
+ }
+ renderer.image = ({ title, text }) => {
if (text) {
return text
}
return title
}
- renderer.blockquote = function(quote) {
- return quote
+ renderer.blockquote = ({ text }) => {
+ return `<blockquote>${text}</blockquote>`
}
return dompurify.sanitize(
marked(this.text.trim(), {
@@ -100,7 +92,7 @@ export default {
'del',
'blockquote',
],
- }
+ },
)
},
},
@@ -108,45 +100,13 @@ export default {
</script>
<style scoped lang="scss">
- .settings-markdown::v-deep {
-
- h1,
- h2,
- h3,
- h4,
- h5,
- h6 {
- font-weight: 600;
- line-height: 120%;
- margin-top: 24px;
- margin-bottom: 12px;
- color: var(--color-main-text);
- }
-
- h1 {
- font-size: 36px;
- margin-top: 48px;
- }
-
- h2 {
- font-size: 28px;
- margin-top: 48px;
- }
-
- h3 {
- font-size: 24px;
- }
-
- h4 {
- font-size: 21px;
- }
-
- h5 {
- font-size: 17px;
- }
-
- h6 {
- font-size: var(--default-font-size);
+.settings-markdown :deep {
+ a {
+ text-decoration: underline;
+ &::after {
+ content: '↗';
+ padding-inline: calc(var(--default-grid-baseline) / 2);
+ }
}
pre {
@@ -169,8 +129,8 @@ export default {
}
ul, ol {
- padding-left: 10px;
- margin-left: 10px;
+ padding-inline-start: 10px;
+ margin-inline-start: 10px;
}
ul li {
@@ -186,12 +146,10 @@ export default {
}
blockquote {
- padding-left: 1em;
- border-left: 4px solid var(--color-primary-element);
+ padding-inline-start: 1em;
+ border-inline-start: 4px solid var(--color-primary-element);
color: var(--color-text-maxcontrast);
- margin-left: 0;
- margin-right: 0;
- }
-
+ margin-inline: 0;
}
+}
</style>
diff --git a/apps/settings/src/components/PasswordSection.vue b/apps/settings/src/components/PasswordSection.vue
new file mode 100644
index 00000000000..44845c51ff4
--- /dev/null
+++ b/apps/settings/src/components/PasswordSection.vue
@@ -0,0 +1,82 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcSettingsSection :name="t('settings', 'Password')">
+ <form id="passwordform" method="POST" @submit.prevent="changePassword">
+ <NcPasswordField id="old-pass"
+ :label="t('settings', 'Current password')"
+ name="oldpassword"
+ :value.sync="oldPass"
+ autocomplete="current-password"
+ autocapitalize="none"
+ spellcheck="false" />
+
+ <NcPasswordField id="new-pass"
+ :label="t('settings', 'New password')"
+ :value.sync="newPass"
+ :maxlength="469"
+ autocomplete="new-password"
+ autocapitalize="none"
+ spellcheck="false"
+ :check-password-strength="true" />
+
+ <NcButton type="primary"
+ native-type="submit"
+ :disabled="newPass.length === 0 || oldPass.length === 0">
+ {{ t('settings', 'Change password') }}
+ </NcButton>
+ </form>
+ </NcSettingsSection>
+</template>
+
+<script>
+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'
+
+export default {
+ name: 'PasswordSection',
+ components: {
+ NcSettingsSection,
+ NcButton,
+ NcPasswordField,
+ },
+ data() {
+ return {
+ oldPass: '',
+ newPass: '',
+ }
+ },
+ methods: {
+ changePassword() {
+ axios.post(generateUrl('/settings/personal/changepassword'), {
+ oldpassword: this.oldPass,
+ newpassword: this.newPass,
+ })
+ .then(res => res.data)
+ .then(data => {
+ if (data.status === 'error') {
+ this.errorMessage = data.data.message
+ showError(data.data.message)
+ } else {
+ showSuccess(data.data.message)
+ }
+ })
+ },
+ },
+}
+</script>
+
+<style>
+ #passwordform {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-width: 400px;
+ }
+</style>
diff --git a/apps/settings/src/components/PersonalInfo/AvatarSection.vue b/apps/settings/src/components/PersonalInfo/AvatarSection.vue
new file mode 100644
index 00000000000..a99f228668c
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/AvatarSection.vue
@@ -0,0 +1,309 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <section id="vue-avatar-section">
+ <HeaderBar :is-heading="true"
+ :readable="avatar.readable"
+ :scope.sync="avatar.scope" />
+
+ <div v-if="!showCropper" class="avatar__container">
+ <div class="avatar__preview">
+ <NcAvatar v-if="!loading"
+ :key="version"
+ :user="userId"
+ :aria-label="t('settings', 'Your profile picture')"
+ :disable-tooltip="true"
+ :show-user-status="false"
+ :size="180" />
+ <div v-else class="icon-loading" />
+ </div>
+ <template v-if="avatarChangeSupported">
+ <div class="avatar__buttons">
+ <NcButton :aria-label="t('settings', 'Upload profile picture')"
+ @click="activateLocalFilePicker">
+ <template #icon>
+ <Upload :size="20" />
+ </template>
+ </NcButton>
+ <NcButton :aria-label="t('settings', 'Choose profile picture from Files')"
+ @click="openFilePicker">
+ <template #icon>
+ <Folder :size="20" />
+ </template>
+ </NcButton>
+ <NcButton v-if="!isGenerated"
+ :aria-label="t('settings', 'Remove profile picture')"
+ @click="removeAvatar">
+ <template #icon>
+ <Delete :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ <span>{{ t('settings', 'The file must be a PNG or JPG') }}</span>
+ <input ref="input"
+ type="file"
+ :accept="validMimeTypes.join(',')"
+ @change="onChange">
+ </template>
+ <span v-else>
+ {{ t('settings', 'Picture provided by original account') }}
+ </span>
+ </div>
+
+ <!-- Use v-show to ensure early cropper ref availability -->
+ <div v-show="showCropper" class="avatar__container">
+ <VueCropper ref="cropper"
+ class="avatar__cropper"
+ v-bind="cropperOptions" />
+ <div class="avatar__cropper-buttons">
+ <NcButton @click="cancel">
+ {{ t('settings', 'Cancel') }}
+ </NcButton>
+ <NcButton type="primary"
+ @click="saveAvatar">
+ {{ t('settings', 'Set as profile picture') }}
+ </NcButton>
+ </div>
+ <span>{{ t('settings', 'Please note that it can take up to 24 hours for your profile picture to be updated everywhere.') }}</span>
+ </div>
+ </section>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+
+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/DeleteOutline.vue'
+
+import HeaderBar from './shared/HeaderBar.vue'
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const { avatar } = loadState('settings', 'personalInfoParameters', {})
+const { avatarChangeSupported } = loadState('settings', 'accountParameters', {})
+
+const VALID_MIME_TYPES = ['image/png', 'image/jpeg']
+
+const picker = getFilePickerBuilder(t('settings', 'Choose your profile picture'))
+ .setMultiSelect(false)
+ .setMimeTypeFilter(VALID_MIME_TYPES)
+ .setType(1)
+ .allowDirectories(false)
+ .build()
+
+export default {
+ name: 'AvatarSection',
+
+ components: {
+ Delete,
+ Folder,
+ HeaderBar,
+ NcAvatar,
+ NcButton,
+ Upload,
+ VueCropper,
+ },
+
+ data() {
+ return {
+ avatar: { ...avatar, readable: NAME_READABLE_ENUM[avatar.name] },
+ avatarChangeSupported,
+ showCropper: false,
+ loading: false,
+ userId: getCurrentUser().uid,
+ displayName: getCurrentUser().displayName,
+ version: oc_userconfig.avatar.version,
+ isGenerated: oc_userconfig.avatar.generated,
+ validMimeTypes: VALID_MIME_TYPES,
+ cropperOptions: {
+ aspectRatio: 1 / 1,
+ viewMode: 1,
+ guides: false,
+ center: false,
+ highlight: false,
+ autoCropArea: 1,
+ minContainerWidth: 300,
+ minContainerHeight: 300,
+ },
+ }
+ },
+
+ created() {
+ subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ beforeDestroy() {
+ unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ methods: {
+ activateLocalFilePicker() {
+ // Set to null so that selecting the same file will trigger the change event
+ this.$refs.input.value = null
+ this.$refs.input.click()
+ },
+
+ onChange(e) {
+ this.loading = true
+ const file = e.target.files[0]
+ if (!this.validMimeTypes.includes(file.type)) {
+ showError(t('settings', 'Please select a valid png or jpg file'))
+ this.cancel()
+ return
+ }
+
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ this.$refs.cropper.replace(e.target.result)
+ this.showCropper = true
+ }
+ reader.readAsDataURL(file)
+ },
+
+ async openFilePicker() {
+ const path = await picker.pick()
+ this.loading = true
+ try {
+ const { data } = await axios.post(generateUrl('/avatar'), { path })
+ if (data.status === 'success') {
+ this.handleAvatarUpdate(false)
+ } else if (data.data === 'notsquare') {
+ const tempAvatar = generateUrl('/avatar/tmp') + '?requesttoken=' + encodeURIComponent(OC.requestToken) + '#' + Math.floor(Math.random() * 1000)
+ this.$refs.cropper.replace(tempAvatar)
+ this.showCropper = true
+ } else {
+ showError(data.data.message)
+ this.cancel()
+ }
+ } catch (e) {
+ showError(t('settings', 'Error setting profile picture'))
+ this.cancel()
+ }
+ },
+
+ saveAvatar() {
+ this.showCropper = false
+ this.loading = true
+
+ const canvasData = this.$refs.cropper.getCroppedCanvas()
+ const scaleFactor = canvasData.width > 512 ? 512 / canvasData.width : 1
+
+ this.$refs.cropper.scale(scaleFactor, scaleFactor).getCroppedCanvas().toBlob(async (blob) => {
+ if (blob === null) {
+ showError(t('settings', 'Error cropping profile picture'))
+ this.cancel()
+ return
+ }
+
+ const formData = new FormData()
+ formData.append('files[]', blob)
+ try {
+ await axios.post(generateUrl('/avatar'), formData)
+ this.handleAvatarUpdate(false)
+ } catch (e) {
+ showError(t('settings', 'Error saving profile picture'))
+ this.handleAvatarUpdate(this.isGenerated)
+ }
+ })
+ },
+
+ async removeAvatar() {
+ this.loading = true
+ try {
+ await axios.delete(generateUrl('/avatar'))
+ this.handleAvatarUpdate(true)
+ } catch (e) {
+ showError(t('settings', 'Error removing profile picture'))
+ this.handleAvatarUpdate(this.isGenerated)
+ }
+ },
+
+ cancel() {
+ this.showCropper = false
+ this.loading = false
+ },
+
+ handleAvatarUpdate(isGenerated) {
+ // Update the avatar version so that avatar update handlers refresh correctly
+ this.version = oc_userconfig.avatar.version = Date.now()
+ this.isGenerated = oc_userconfig.avatar.generated = isGenerated
+ this.loading = false
+ emit('settings:avatar:updated', oc_userconfig.avatar.version)
+ },
+
+ handleDisplayNameUpdate() {
+ this.version = oc_userconfig.avatar.version
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+section {
+ grid-row: 1/3;
+ padding: 10px 10px;
+}
+
+.avatar {
+ &__container {
+ margin: calc(var(--default-grid-baseline) * 2) auto 0 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 16px 0;
+ width: min(100%, 300px);
+
+ span {
+ color: var(--color-text-lighter);
+ }
+ }
+
+ &__preview {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 180px;
+ height: 180px;
+ }
+
+ &__buttons {
+ display: flex;
+ gap: 0 10px;
+ }
+
+ &__cropper {
+ width: 300px;
+ height: 300px;
+ overflow: hidden;
+
+ &-buttons {
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ }
+
+ :deep(.cropper-view-box) {
+ border-radius: 50%;
+ }
+ }
+}
+
+input[type="file"] {
+ display: none;
+}
+</style>
diff --git a/apps/settings/src/components/PersonalInfo/BiographySection.vue b/apps/settings/src/components/PersonalInfo/BiographySection.vue
new file mode 100644
index 00000000000..bbfb25e25cc
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/BiographySection.vue
@@ -0,0 +1,34 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="biography"
+ :placeholder="t('settings', 'Your biography. Markdown is supported.')"
+ :multi-line="true" />
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const { biography } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'BiographySection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ biography: { ...biography, readable: NAME_READABLE_ENUM[biography.name] },
+ }
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue b/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue
deleted file mode 100644
index ffe0029db8d..00000000000
--- a/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue
+++ /dev/null
@@ -1,183 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <div class="biography">
- <textarea id="biography"
- :placeholder="t('settings', 'Your biography')"
- :value="biography"
- rows="8"
- autocapitalize="none"
- autocomplete="off"
- autocorrect="off"
- @input="onBiographyChange" />
-
- <div class="biography__actions-container">
- <transition name="fade">
- <span v-if="showCheckmarkIcon" class="icon-checkmark" />
- <span v-else-if="showErrorIcon" class="icon-error" />
- </transition>
- </div>
- </div>
-</template>
-
-<script>
-import { showError } from '@nextcloud/dialogs'
-import { emit } from '@nextcloud/event-bus'
-import debounce from 'debounce'
-
-import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
-import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
-
-export default {
- name: 'Biography',
-
- props: {
- biography: {
- type: String,
- required: true,
- },
- scope: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- initialBiography: this.biography,
- localScope: this.scope,
- showCheckmarkIcon: false,
- showErrorIcon: false,
- }
- },
-
- methods: {
- onBiographyChange(e) {
- this.$emit('update:biography', e.target.value)
- this.debounceBiographyChange(e.target.value.trim())
- },
-
- debounceBiographyChange: debounce(async function(biography) {
- await this.updatePrimaryBiography(biography)
- }, 500),
-
- async updatePrimaryBiography(biography) {
- try {
- const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.BIOGRAPHY, biography)
- this.handleResponse({
- biography,
- status: responseData.ocs?.meta?.status,
- })
- } catch (e) {
- this.handleResponse({
- errorMessage: t('settings', 'Unable to update biography'),
- error: e,
- })
- }
- },
-
- handleResponse({ biography, status, errorMessage, error }) {
- if (status === 'ok') {
- // Ensure that local state reflects server state
- this.initialBiography = biography
- emit('settings:biography:updated', biography)
- this.showCheckmarkIcon = true
- setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
- } else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
- }
- },
-
- onScopeChange(scope) {
- this.$emit('update:scope', scope)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.biography {
- display: grid;
- align-items: center;
-
- textarea {
- resize: vertical;
- grid-area: 1 / 1;
- width: 100%;
- margin: 3px 3px 3px 0;
- padding: 7px 6px;
- color: var(--color-main-text);
- border: 1px solid var(--color-border-dark);
- border-radius: var(--border-radius);
- background-color: var(--color-main-background);
- font-family: var(--font-face);
- cursor: text;
-
- &:hover,
- &:focus,
- &:active {
- border-color: var(--color-primary-element) !important;
- outline: none !important;
- }
- }
-
- .biography__actions-container {
- grid-area: 1 / 1;
- justify-self: flex-end;
- align-self: flex-end;
- height: 30px;
-
- display: flex;
- gap: 0 2px;
- margin-right: 5px;
- margin-bottom: 5px;
-
- .icon-checkmark,
- .icon-error {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- top: 0;
- right: 0;
- float: none;
- }
- }
-}
-
-.fade-enter,
-.fade-leave-to {
- opacity: 0;
-}
-
-.fade-enter-active {
- transition: opacity 200ms ease-out;
-}
-
-.fade-leave-active {
- transition: opacity 300ms ease-out;
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue b/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue
deleted file mode 100644
index c8aacb03e9c..00000000000
--- a/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <section>
- <HeaderBar :account-property="accountProperty"
- label-for="biography"
- :scope.sync="primaryBiography.scope" />
-
- <Biography :biography.sync="primaryBiography.value"
- :scope.sync="primaryBiography.scope" />
- </section>
-</template>
-
-<script>
-import { loadState } from '@nextcloud/initial-state'
-
-import Biography from './Biography'
-import HeaderBar from '../shared/HeaderBar'
-
-import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
-
-const { biographyMap: { primaryBiography } } = loadState('settings', 'personalInfoParameters', {})
-
-export default {
- name: 'BiographySection',
-
- components: {
- Biography,
- HeaderBar,
- },
-
- data() {
- return {
- accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY,
- primaryBiography,
- }
- },
-}
-</script>
-
-<style lang="scss" scoped>
-section {
- padding: 10px 10px;
-
- &::v-deep button:disabled {
- cursor: default;
- }
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/BirthdaySection.vue b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue
new file mode 100644
index 00000000000..f55f09c95e5
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue
@@ -0,0 +1,132 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <section>
+ <HeaderBar :scope="birthdate.scope"
+ :input-id="inputId"
+ :readable="birthdate.readable" />
+
+ <NcDateTimePickerNative :id="inputId"
+ type="date"
+ label=""
+ :value="value"
+ @input="onInput" />
+
+ <p class="property__helper-text-message">
+ {{ t('settings', 'Enter your date of birth') }}
+ </p>
+ </section>
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+import { savePrimaryAccountProperty } from '../../service/PersonalInfo/PersonalInfoService'
+import { handleError } from '../../utils/handlers'
+
+import debounce from 'debounce'
+
+import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
+import HeaderBar from './shared/HeaderBar.vue'
+
+const { birthdate } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'BirthdaySection',
+
+ components: {
+ NcDateTimePickerNative,
+ HeaderBar,
+ },
+
+ data() {
+ let initialValue = null
+ if (birthdate.value) {
+ initialValue = new Date(birthdate.value)
+ }
+
+ return {
+ birthdate: {
+ ...birthdate,
+ readable: NAME_READABLE_ENUM[birthdate.name],
+ },
+ initialValue,
+ }
+ },
+
+ computed: {
+ inputId() {
+ return `account-property-${birthdate.name}`
+ },
+ value: {
+ get() {
+ return new Date(this.birthdate.value)
+ },
+ /** @param {Date} value The date to set */
+ set(value) {
+ const day = value.getDate().toString().padStart(2, '0')
+ const month = (value.getMonth() + 1).toString().padStart(2, '0')
+ const year = value.getFullYear()
+ this.birthdate.value = `${year}-${month}-${day}`
+ },
+ },
+ },
+
+ methods: {
+ onInput(e) {
+ this.value = e
+ this.debouncePropertyChange(this.value)
+ },
+
+ debouncePropertyChange: debounce(async function(value) {
+ await this.updateProperty(value)
+ }, 500),
+
+ async updateProperty(value) {
+ try {
+ const responseData = await savePrimaryAccountProperty(
+ this.birthdate.name,
+ value,
+ )
+ this.handleResponse({
+ value,
+ status: responseData.ocs?.meta?.status,
+ })
+ } catch (error) {
+ this.handleResponse({
+ errorMessage: t('settings', 'Unable to update date of birth'),
+ error,
+ })
+ }
+ },
+
+ handleResponse({ value, status, errorMessage, error }) {
+ if (status === 'ok') {
+ this.initialValue = value
+ } else {
+ this.$emit('update:value', this.initialValue)
+ handleError(error, errorMessage)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+section {
+ padding: 10px 10px;
+
+ :deep(button:disabled) {
+ cursor: default;
+ }
+
+ .property__helper-text-message {
+ color: var(--color-text-maxcontrast);
+ padding: 4px 0;
+ display: flex;
+ align-items: center;
+ }
+}
+</style>
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
new file mode 100644
index 00000000000..d4bb0ce16ec
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/DetailsSection.vue
@@ -0,0 +1,114 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <section>
+ <HeaderBar :is-heading="true" :readable="t('settings', 'Details')" />
+
+ <div class="details">
+ <div class="details__groups">
+ <Account :size="20" />
+ <div class="details__groups-info">
+ <p>{{ t('settings', 'You are a member of the following groups:') }}</p>
+ <p class="details__groups-list">
+ {{ groups.join(', ') }}
+ </p>
+ </div>
+ </div>
+ <div class="details__quota">
+ <CircleSlice :size="20" />
+ <div class="details__quota-info">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p class="details__quota-text" v-html="quotaText" />
+ <NcProgressBar size="medium"
+ :value="usageRelative"
+ :error="usageRelative > 80" />
+ </div>
+ </div>
+ </div>
+ </section>
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+
+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'
+
+/** SYNC to be kept in sync with `lib/public/Files/FileInfo.php` */
+const SPACE_UNLIMITED = -3
+
+const { groups, quota, totalSpace, usage, usageRelative } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'DetailsSection',
+
+ components: {
+ Account,
+ CircleSlice,
+ HeaderBar,
+ NcProgressBar,
+ },
+
+ data() {
+ return {
+ groups,
+ usageRelative,
+ }
+ },
+
+ computed: {
+ quotaText() {
+ if (quota === SPACE_UNLIMITED) {
+ return t('settings', 'You are using {s}{usage}{/s}', { usage, s: '<strong>', '/s': '</strong>' }, undefined, { escape: false })
+ }
+ return t(
+ 'settings',
+ 'You are using {s}{usage}{/s} of {s}{totalSpace}{/s} ({s}{usageRelative}%{/s})',
+ { usage, totalSpace, usageRelative, s: '<strong>', '/s': '</strong>' },
+ undefined,
+ { escape: false },
+ )
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.details {
+ display: flex;
+ flex-direction: column;
+ margin-block: 10px;
+ margin-inline: 0 32px;
+ gap: 16px 0;
+ color: var(--color-text-maxcontrast);
+
+ &__groups,
+ &__quota {
+ display: flex;
+ gap: 0 10px;
+
+ &-info {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ gap: 4px 0;
+ }
+
+ &-list {
+ font-weight: bold;
+ }
+
+ &:deep(.material-design-icon) {
+ align-self: flex-start;
+ margin-top: 2px;
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue
new file mode 100644
index 00000000000..431dfbecc9a
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue
@@ -0,0 +1,54 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="displayName"
+ :placeholder="t('settings', 'Your full name')"
+ autocomplete="username"
+ :is-editable="displayNameChangeSupported"
+ :on-validate="onValidate"
+ :on-save="onSave" />
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+import { emit } from '@nextcloud/event-bus'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const { displayName } = loadState('settings', 'personalInfoParameters', {})
+const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
+
+export default {
+ name: 'DisplayNameSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ displayName: { ...displayName, readable: NAME_READABLE_ENUM[displayName.name] },
+ displayNameChangeSupported,
+ }
+ },
+
+ methods: {
+ onValidate(value) {
+ return value !== ''
+ },
+
+ onSave(value) {
+ if (oc_userconfig.avatar.generated) {
+ // Update the avatar version so that avatar update handlers refresh correctly
+ oc_userconfig.avatar.version = Date.now()
+ }
+ emit('settings:display-name:updated', value)
+ },
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue
deleted file mode 100644
index 0cfa630123e..00000000000
--- a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue
+++ /dev/null
@@ -1,179 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <div class="displayname">
- <input id="displayname"
- type="text"
- :placeholder="t('settings', 'Your full name')"
- :value="displayName"
- autocapitalize="none"
- autocomplete="on"
- autocorrect="off"
- @input="onDisplayNameChange">
-
- <div class="displayname__actions-container">
- <transition name="fade">
- <span v-if="showCheckmarkIcon" class="icon-checkmark" />
- <span v-else-if="showErrorIcon" class="icon-error" />
- </transition>
- </div>
- </div>
-</template>
-
-<script>
-import { showError } from '@nextcloud/dialogs'
-import { emit } from '@nextcloud/event-bus'
-import debounce from 'debounce'
-
-import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
-import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
-import { validateStringInput } from '../../../utils/validate'
-
-// TODO Global avatar updating on events (e.g. updating the displayname) is currently being handled by global js, investigate using https://github.com/nextcloud/nextcloud-event-bus for global avatar updating
-
-export default {
- name: 'DisplayName',
-
- props: {
- displayName: {
- type: String,
- required: true,
- },
- scope: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- initialDisplayName: this.displayName,
- localScope: this.scope,
- showCheckmarkIcon: false,
- showErrorIcon: false,
- }
- },
-
- methods: {
- onDisplayNameChange(e) {
- this.$emit('update:display-name', e.target.value)
- this.debounceDisplayNameChange(e.target.value.trim())
- },
-
- debounceDisplayNameChange: debounce(async function(displayName) {
- if (validateStringInput(displayName)) {
- await this.updatePrimaryDisplayName(displayName)
- }
- }, 500),
-
- async updatePrimaryDisplayName(displayName) {
- try {
- const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, displayName)
- this.handleResponse({
- displayName,
- status: responseData.ocs?.meta?.status,
- })
- } catch (e) {
- this.handleResponse({
- errorMessage: t('settings', 'Unable to update full name'),
- error: e,
- })
- }
- },
-
- handleResponse({ displayName, status, errorMessage, error }) {
- if (status === 'ok') {
- // Ensure that local state reflects server state
- this.initialDisplayName = displayName
- emit('settings:display-name:updated', displayName)
- this.showCheckmarkIcon = true
- setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
- } else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
- }
- },
-
- onScopeChange(scope) {
- this.$emit('update:scope', scope)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.displayname {
- display: grid;
- align-items: center;
-
- input {
- grid-area: 1 / 1;
- width: 100%;
- height: 34px;
- margin: 3px 3px 3px 0;
- padding: 7px 6px;
- color: var(--color-main-text);
- border: 1px solid var(--color-border-dark);
- border-radius: var(--border-radius);
- background-color: var(--color-main-background);
- font-family: var(--font-face);
- cursor: text;
- }
-
- .displayname__actions-container {
- grid-area: 1 / 1;
- justify-self: flex-end;
- height: 30px;
-
- display: flex;
- gap: 0 2px;
- margin-right: 5px;
-
- .icon-checkmark,
- .icon-error {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- top: 0;
- right: 0;
- float: none;
- }
- }
-}
-
-.fade-enter,
-.fade-leave-to {
- opacity: 0;
-}
-
-.fade-enter-active {
- transition: opacity 200ms ease-out;
-}
-
-.fade-leave-active {
- transition: opacity 300ms ease-out;
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue
deleted file mode 100644
index caee7e7c68e..00000000000
--- a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <section>
- <HeaderBar :account-property="accountProperty"
- label-for="displayname"
- :is-editable="displayNameChangeSupported"
- :is-valid-section="isValidSection"
- :scope.sync="primaryDisplayName.scope" />
-
- <template v-if="displayNameChangeSupported">
- <DisplayName :display-name.sync="primaryDisplayName.value"
- :scope.sync="primaryDisplayName.scope" />
- </template>
-
- <span v-else>
- {{ primaryDisplayName.value || t('settings', 'No full name set') }}
- </span>
- </section>
-</template>
-
-<script>
-import { loadState } from '@nextcloud/initial-state'
-
-import DisplayName from './DisplayName'
-import HeaderBar from '../shared/HeaderBar'
-
-import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
-import { validateStringInput } from '../../../utils/validate'
-
-const { displayNameMap: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {})
-const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
-
-export default {
- name: 'DisplayNameSection',
-
- components: {
- DisplayName,
- HeaderBar,
- },
-
- data() {
- return {
- accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME,
- displayNameChangeSupported,
- primaryDisplayName,
- }
- },
-
- computed: {
- isValidSection() {
- return validateStringInput(this.primaryDisplayName.value)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-section {
- padding: 10px 10px;
-
- &::v-deep button:disabled {
- cursor: default;
- }
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
index ef03ae0677d..6a6baef8817 100644
--- a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
+++ b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
@@ -1,74 +1,59 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
- <div class="email">
- <input :id="inputId"
- ref="email"
- type="email"
- :placeholder="inputPlaceholder"
- :value="email"
- autocapitalize="none"
- autocomplete="on"
- autocorrect="off"
- @input="onEmailChange">
-
- <div class="email__actions-container">
- <transition name="fade">
- <span v-if="showCheckmarkIcon" class="icon-checkmark" />
- <span v-else-if="showErrorIcon" class="icon-error" />
- </transition>
-
- <template v-if="!primary">
- <FederationControl :account-property="accountProperty"
- :additional="true"
- :additional-value="email"
- :disabled="federationDisabled"
- :handle-additional-scope-change="saveAdditionalEmailScope"
- :scope.sync="localScope"
- @update:scope="onScopeChange" />
- </template>
-
- <Actions class="email__actions"
- :aria-label="t('settings', 'Email options')"
- :disabled="deleteDisabled"
- :force-menu="true">
- <ActionButton :aria-label="deleteEmailLabel"
- :close-after-click="true"
- :disabled="deleteDisabled"
- icon="icon-delete"
- @click.stop.prevent="deleteEmail">
- {{ deleteEmailLabel }}
- </ActionButton>
- <ActionButton v-if="!primary || !isNotificationEmail"
- :aria-label="setNotificationMailLabel"
- :close-after-click="true"
- :disabled="setNotificationMailDisabled"
- icon="icon-favorite"
- @click.stop.prevent="setNotificationMail">
- {{ setNotificationMailLabel }}
- </ActionButton>
- </Actions>
+ <div class="email" :class="{ 'email--additional': !primary }">
+ <div v-if="!primary" class="email__label-container">
+ <label :for="inputIdWithDefault">{{ inputPlaceholder }}</label>
+ <FederationControl v-if="!federationDisabled && !primary"
+ :readable="propertyReadable"
+ :additional="true"
+ :additional-value="email"
+ :disabled="federationDisabled"
+ :handle-additional-scope-change="saveAdditionalEmailScope"
+ :scope.sync="localScope"
+ @update:scope="onScopeChange" />
+ </div>
+ <div class="email__input-container">
+ <NcTextField :id="inputIdWithDefault"
+ ref="email"
+ class="email__input"
+ autocapitalize="none"
+ autocomplete="email"
+ :error="hasError || !!helperText"
+ :helper-text="helperTextWithNonConfirmed"
+ label-outside
+ :placeholder="inputPlaceholder"
+ spellcheck="false"
+ :success="isSuccess"
+ type="email"
+ :value.sync="emailAddress" />
+
+ <div class="email__actions">
+ <NcActions :aria-label="actionsLabel">
+ <NcActionButton v-if="!primary || !isNotificationEmail"
+ close-after-click
+ :disabled="!isConfirmedAddress"
+ @click="setNotificationMail">
+ <template #icon>
+ <NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" />
+ <NcIconSvgWrapper v-else :path="mdiStarOutline" />
+ </template>
+ {{ setNotificationMailLabel }}
+ </NcActionButton>
+ <NcActionButton close-after-click
+ :disabled="deleteDisabled"
+ @click="deleteEmail">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiTrashCanOutline" />
+ </template>
+ {{ deleteEmailLabel }}
+ </NcActionButton>
+ </NcActions>
+ </div>
</div>
</div>
@@ -79,14 +64,19 @@
</template>
<script>
-import Actions from '@nextcloud/vue/dist/Components/Actions'
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
-import { showError } from '@nextcloud/dialogs'
+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 FederationControl from '../shared/FederationControl'
+import { mdiArrowLeft, mdiLockOutline, mdiStar, mdiStarOutline, mdiTrashCanOutline } from '@mdi/js'
-import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants'
+import FederationControl from '../shared/FederationControl.vue'
+import { handleError } from '../../../utils/handlers.ts'
+
+import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js'
import {
removeAdditionalEmail,
saveAdditionalEmail,
@@ -94,15 +84,17 @@ import {
saveNotificationEmail,
savePrimaryEmail,
updateAdditionalEmail,
-} from '../../../service/PersonalInfo/EmailService'
-import { validateEmail } from '../../../utils/validate'
+} from '../../../service/PersonalInfo/EmailService.js'
+import { validateEmail } from '../../../utils/validate.js'
export default {
name: 'Email',
components: {
- Actions,
- ActionButton,
+ NcActions,
+ NcActionButton,
+ NcIconSvgWrapper,
+ NcTextField,
FederationControl,
},
@@ -131,20 +123,45 @@ export default {
type: Number,
default: VERIFICATION_ENUM.NOT_VERIFIED,
},
+ inputId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ setup() {
+ return {
+ mdiArrowLeft,
+ mdiLockOutline,
+ mdiStar,
+ mdiStarOutline,
+ mdiTrashCanOutline,
+ saveAdditionalEmailScope,
+ }
},
data() {
return {
- accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
+ hasError: false,
+ helperText: null,
initialEmail: this.email,
+ isSuccess: false,
localScope: this.scope,
- saveAdditionalEmailScope,
- showCheckmarkIcon: false,
- showErrorIcon: false,
+ propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
+ showFederationSettings: false,
}
},
computed: {
+ actionsLabel() {
+ if (this.primary) {
+ return t('settings', 'Email options')
+ } else {
+ return t('settings', 'Options for additional email address {index}', { index: this.index + 1 })
+ }
+ },
+
deleteDisabled() {
if (this.primary) {
// Disable for empty primary email as there is nothing to delete
@@ -163,15 +180,27 @@ export default {
return t('settings', 'Delete email')
},
- setNotificationMailDisabled() {
- return !this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED
+ isConfirmedAddress() {
+ return this.primary || this.localVerificationState === VERIFICATION_ENUM.VERIFIED
},
- setNotificationMailLabel() {
+ isNotConfirmedHelperText() {
+ if (!this.isConfirmedAddress) {
+ return t('settings', 'This address is not confirmed')
+ }
+ return ''
+ },
+
+ helperTextWithNonConfirmed() {
+ if (this.helperText || this.hasError || this.isSuccess) {
+ return this.helperText || ''
+ }
+ return this.isNotConfirmedHelperText
+ },
+
+ setNotificationMailLabel() {
if (this.isNotificationEmail) {
return t('settings', 'Unset as primary email')
- } else if (!this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED) {
- return t('settings', 'This address is not confirmed')
}
return t('settings', 'Set as primary email')
},
@@ -180,40 +209,46 @@ export default {
return !this.initialEmail
},
- inputId() {
- if (this.primary) {
- return 'email'
- }
- return `email-${this.index}`
+ inputIdWithDefault() {
+ return this.inputId || `account-property-email--${this.index}`
},
inputPlaceholder() {
- if (this.primary) {
- return t('settings', 'Your email address')
- }
- return t('settings', 'Additional email address {index}', { index: this.index + 1 })
+ // Primary email has implicit linked <label>
+ return !this.primary ? t('settings', 'Additional email address {index}', { index: this.index + 1 }) : undefined
},
isNotificationEmail() {
return (this.email && this.email === this.activeNotificationEmail)
|| (this.primary && this.activeNotificationEmail === '')
},
+
+ emailAddress: {
+ get() {
+ return this.email
+ },
+ set(value) {
+ this.$emit('update:email', value)
+ this.debounceEmailChange(value.trim())
+ },
+ },
},
mounted() {
if (!this.primary && this.initialEmail === '') {
- // $nextTick is needed here, otherwise it may not always work https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725
+ // $nextTick is needed here, otherwise it may not always work
+ // https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725
this.$nextTick(() => this.$refs.email?.focus())
}
},
methods: {
- onEmailChange(e) {
- this.$emit('update:email', e.target.value)
- this.debounceEmailChange(e.target.value.trim())
- },
-
debounceEmailChange: debounce(async function(email) {
+ // TODO: provide method to get native input in NcTextField
+ this.helperText = this.$refs.email.$refs.inputField.$refs.input.validationMessage || null
+ if (this.helperText !== null) {
+ return
+ }
if (validateEmail(email) || email === '') {
if (this.primary) {
await this.updatePrimaryEmail(email)
@@ -227,7 +262,7 @@ export default {
}
}
}
- }, 500),
+ }, 1000),
async deleteEmail() {
if (this.primary) {
@@ -321,6 +356,9 @@ export default {
handleDeleteAdditionalEmail(status) {
if (status === 'ok') {
this.$emit('delete-additional-email')
+ if (this.isNotificationEmail) {
+ this.$emit('update:notification-email', '')
+ }
} else {
this.handleResponse({
errorMessage: t('settings', 'Unable to delete additional email address'),
@@ -336,13 +374,12 @@ export default {
} else if (notificationEmail !== undefined) {
this.$emit('update:notification-email', notificationEmail)
}
- this.showCheckmarkIcon = true
- setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
+ this.isSuccess = true
+ setTimeout(() => { this.isSuccess = false }, 2000)
} else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
+ handleError(error, errorMessage)
+ this.hasError = true
+ setTimeout(() => { this.hasError = false }, 2000)
}
},
@@ -355,72 +392,29 @@ export default {
<style lang="scss" scoped>
.email {
- display: grid;
- align-items: center;
-
- input {
- grid-area: 1 / 1;
- width: 100%;
- height: 34px;
- margin: 3px 3px 3px 0;
- padding: 7px 6px;
- color: var(--color-main-text);
- border: 1px solid var(--color-border-dark);
- border-radius: var(--border-radius);
- background-color: var(--color-main-background);
- font-family: var(--font-face);
- cursor: text;
- }
-
- .email__actions-container {
- grid-area: 1 / 1;
- justify-self: flex-end;
- height: 30px;
-
+ &__label-container {
+ height: var(--default-clickable-area);
display: flex;
- gap: 0 2px;
- margin-right: 5px;
-
- .email__actions {
- opacity: 0.4 !important;
-
- &:hover,
- &:focus,
- &:active {
- opacity: 0.8 !important;
- }
+ flex-direction: row;
+ align-items: center;
+ gap: calc(var(--default-grid-baseline) * 2);
+ }
- &::v-deep button {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- }
- }
+ &__input-container {
+ position: relative;
+ }
- .icon-checkmark,
- .icon-error {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- top: 0;
- right: 0;
- float: none;
+ &__input {
+ // TODO: provide a way to hide status icon or combine it with trailing button in NcInputField
+ :deep(.input-field__icon--trailing) {
+ display: none;
}
}
-}
-
-.fade-enter,
-.fade-leave-to {
- opacity: 0;
-}
-.fade-enter-active {
- transition: opacity 200ms ease-out;
-}
-
-.fade-leave-active {
- transition: opacity 300ms ease-out;
+ &__actions {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-end: 0;
+ }
}
</style>
diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue
index 07ec35861a9..f9674a3163b 100644
--- a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue
+++ b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue
@@ -1,38 +1,21 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <section>
- <HeaderBar :account-property="accountProperty"
- label-for="email"
- :handle-scope-change="savePrimaryEmailScope"
+ <section class="section-emails">
+ <HeaderBar :input-id="inputId"
+ :readable="primaryEmail.readable"
:is-editable="true"
:is-multi-value-supported="true"
:is-valid-section="isValidSection"
:scope.sync="primaryEmail.scope"
@add-additional="onAddAdditionalEmail" />
- <template v-if="displayNameChangeSupported">
- <Email :primary="true"
+ <template v-if="emailChangeSupported">
+ <Email :input-id="inputId"
+ :primary="true"
:scope.sync="primaryEmail.scope"
:email.sync="primaryEmail.value"
:active-notification-email.sync="notificationEmail"
@@ -45,9 +28,10 @@
</span>
<template v-if="additionalEmails.length">
- <em class="additional-emails-label">{{ t('settings', 'Additional emails') }}</em>
+ <!-- TODO use unique key for additional email when uniqueness can be guaranteed, see https://github.com/nextcloud/server/issues/26866 -->
<Email v-for="(additionalEmail, index) in additionalEmails"
- :key="index"
+ :key="additionalEmail.key"
+ class="section-emails__additional-email"
:index="index"
:scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value"
@@ -62,17 +46,17 @@
<script>
import { loadState } from '@nextcloud/initial-state'
-import { showError } from '@nextcloud/dialogs'
-import Email from './Email'
-import HeaderBar from '../shared/HeaderBar'
+import Email from './Email.vue'
+import HeaderBar from '../shared/HeaderBar.vue'
-import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants'
-import { savePrimaryEmail, savePrimaryEmailScope, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService'
-import { validateEmail } from '../../../utils/validate'
+import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE, NAME_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js'
+import { savePrimaryEmail, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService.js'
+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',
@@ -85,10 +69,9 @@ export default {
data() {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
- additionalEmails,
- displayNameChangeSupported,
- primaryEmail,
- savePrimaryEmailScope,
+ additionalEmails: additionalEmails.map(properties => ({ ...properties, key: this.generateUniqueKey() })),
+ emailChangeSupported,
+ primaryEmail: { ...primaryEmail, readable: NAME_READABLE_ENUM[primaryEmail.name] },
notificationEmail,
}
},
@@ -101,6 +84,10 @@ export default {
return null
},
+ inputId() {
+ return `account-property-${this.primaryEmail.name}`
+ },
+
isValidSection() {
return validateEmail(this.primaryEmail.value)
&& this.additionalEmails.map(({ value }) => value).every(validateEmail)
@@ -119,7 +106,7 @@ export default {
methods: {
onAddAdditionalEmail() {
if (this.isValidSection) {
- this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE })
+ this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE, key: this.generateUniqueKey() })
}
},
@@ -148,7 +135,7 @@ export default {
this.handleResponse(
'error',
t('settings', 'Unable to update primary email address'),
- e
+ e,
)
}
},
@@ -161,7 +148,7 @@ export default {
this.handleResponse(
'error',
t('settings', 'Unable to delete additional email address'),
- e
+ e,
)
}
},
@@ -173,32 +160,30 @@ export default {
this.handleResponse(
'error',
t('settings', 'Unable to delete additional email address'),
- {}
+ {},
)
}
},
handleResponse(status, errorMessage, error) {
if (status !== 'ok') {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
+ handleError(error, errorMessage)
}
},
+
+ generateUniqueKey() {
+ return Math.random().toString(36).substring(2)
+ },
},
}
</script>
<style lang="scss" scoped>
-section {
+.section-emails {
padding: 10px 10px;
- &::v-deep button:disabled {
- cursor: default;
- }
-
- .additional-emails-label {
- display: block;
- margin-top: 16px;
+ &__additional-email {
+ margin-top: calc(var(--default-grid-baseline) * 3);
}
}
</style>
diff --git a/apps/settings/src/components/PersonalInfo/FediverseSection.vue b/apps/settings/src/components/PersonalInfo/FediverseSection.vue
new file mode 100644
index 00000000000..043fa6e64b9
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/FediverseSection.vue
@@ -0,0 +1,50 @@
+<!--
+ - 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', 'Your 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.js'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+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
new file mode 100644
index 00000000000..98501db7ccc
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue
@@ -0,0 +1,126 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <section class="fdow-section">
+ <HeaderBar :input-id="inputId"
+ :readable="propertyReadable" />
+
+ <NcSelect :aria-label-listbox="t('settings', 'Day to use as the first day of week')"
+ class="fdow-section__day-select"
+ :clearable="false"
+ :input-id="inputId"
+ label="label"
+ label-outside
+ :options="dayOptions"
+ :value="valueOption"
+ @option:selected="updateFirstDayOfWeek" />
+ </section>
+</template>
+
+<script lang="ts">
+import HeaderBar from './shared/HeaderBar.vue'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import {
+ ACCOUNT_SETTING_PROPERTY_ENUM,
+ ACCOUNT_SETTING_PROPERTY_READABLE_ENUM,
+} from '../../constants/AccountPropertyConstants'
+import { getDayNames, getFirstDay } from '@nextcloud/l10n'
+import { savePrimaryAccountProperty } from '../../service/PersonalInfo/PersonalInfoService'
+import { handleError } from '../../utils/handlers.ts'
+import { loadState } from '@nextcloud/initial-state'
+
+interface DayOption {
+ value: number,
+ label: string,
+}
+
+const { firstDayOfWeek } = loadState<{firstDayOfWeek?: string}>(
+ 'settings',
+ 'personalInfoParameters',
+ {},
+)
+
+export default {
+ name: 'FirstDayOfWeekSection',
+ components: {
+ HeaderBar,
+ NcSelect,
+ },
+ data() {
+ let firstDay = -1
+ if (firstDayOfWeek) {
+ firstDay = parseInt(firstDayOfWeek)
+ }
+
+ return {
+ firstDay,
+ }
+ },
+ computed: {
+ inputId(): string {
+ return 'account-property-fdow'
+ },
+ propertyReadable(): string {
+ return ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.FIRST_DAY_OF_WEEK
+ },
+ dayOptions(): DayOption[] {
+ const options = [{
+ value: -1,
+ label: t('settings', 'Derived from your locale ({weekDayName})', {
+ weekDayName: getDayNames()[getFirstDay()],
+ }),
+ }]
+ for (const [index, dayName] of getDayNames().entries()) {
+ options.push({ value: index, label: dayName })
+ }
+ return options
+ },
+ valueOption(): DayOption | undefined {
+ return this.dayOptions.find((option) => option.value === this.firstDay)
+ },
+ },
+ methods: {
+ async updateFirstDayOfWeek(option: DayOption): Promise<void> {
+ try {
+ const responseData = await savePrimaryAccountProperty(
+ ACCOUNT_SETTING_PROPERTY_ENUM.FIRST_DAY_OF_WEEK,
+ option.value.toString(),
+ )
+ this.handleResponse({
+ value: option.value,
+ status: responseData.ocs?.meta?.status,
+ })
+ window.location.reload()
+ } catch (e) {
+ this.handleResponse({
+ errorMessage: t('settings', 'Unable to update first day of week'),
+ error: e,
+ })
+ }
+ },
+
+ handleResponse({ value, status, errorMessage, error }): void {
+ if (status === 'ok') {
+ this.firstDay = value
+ } else {
+ this.$emit('update:value', this.firstDay)
+ handleError(error, errorMessage)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.fdow-section {
+ padding: 10px;
+
+ &__day-select {
+ width: 100%;
+ margin-top: 6px; // align with other inputs
+ }
+}
+</style>
diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection.vue
new file mode 100644
index 00000000000..25fbde5b2f5
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/HeadlineSection.vue
@@ -0,0 +1,33 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="headline"
+ :placeholder="t('settings', 'Your headline')" />
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const { headline } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'HeadlineSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ headline: { ...headline, readable: NAME_READABLE_ENUM[headline.name] },
+ }
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue
deleted file mode 100644
index 2b81169bb4b..00000000000
--- a/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue
+++ /dev/null
@@ -1,174 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <div class="headline">
- <input id="headline"
- type="text"
- :placeholder="t('settings', 'Your headline')"
- :value="headline"
- autocapitalize="none"
- autocomplete="on"
- autocorrect="off"
- @input="onHeadlineChange">
-
- <div class="headline__actions-container">
- <transition name="fade">
- <span v-if="showCheckmarkIcon" class="icon-checkmark" />
- <span v-else-if="showErrorIcon" class="icon-error" />
- </transition>
- </div>
- </div>
-</template>
-
-<script>
-import { showError } from '@nextcloud/dialogs'
-import { emit } from '@nextcloud/event-bus'
-import debounce from 'debounce'
-
-import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
-import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
-
-export default {
- name: 'Headline',
-
- props: {
- headline: {
- type: String,
- required: true,
- },
- scope: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- initialHeadline: this.headline,
- localScope: this.scope,
- showCheckmarkIcon: false,
- showErrorIcon: false,
- }
- },
-
- methods: {
- onHeadlineChange(e) {
- this.$emit('update:headline', e.target.value)
- this.debounceHeadlineChange(e.target.value.trim())
- },
-
- debounceHeadlineChange: debounce(async function(headline) {
- await this.updatePrimaryHeadline(headline)
- }, 500),
-
- async updatePrimaryHeadline(headline) {
- try {
- const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.HEADLINE, headline)
- this.handleResponse({
- headline,
- status: responseData.ocs?.meta?.status,
- })
- } catch (e) {
- this.handleResponse({
- errorMessage: t('settings', 'Unable to update headline'),
- error: e,
- })
- }
- },
-
- handleResponse({ headline, status, errorMessage, error }) {
- if (status === 'ok') {
- // Ensure that local state reflects server state
- this.initialHeadline = headline
- emit('settings:headline:updated', headline)
- this.showCheckmarkIcon = true
- setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
- } else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
- }
- },
-
- onScopeChange(scope) {
- this.$emit('update:scope', scope)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.headline {
- display: grid;
- align-items: center;
-
- input {
- grid-area: 1 / 1;
- width: 100%;
- height: 34px;
- margin: 3px 3px 3px 0;
- padding: 7px 6px;
- color: var(--color-main-text);
- border: 1px solid var(--color-border-dark);
- border-radius: var(--border-radius);
- background-color: var(--color-main-background);
- font-family: var(--font-face);
- cursor: text;
- }
-
- .headline__actions-container {
- grid-area: 1 / 1;
- justify-self: flex-end;
- height: 30px;
-
- display: flex;
- gap: 0 2px;
- margin-right: 5px;
-
- .icon-checkmark,
- .icon-error {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- top: 0;
- right: 0;
- float: none;
- }
- }
-}
-
-.fade-enter,
-.fade-leave-to {
- opacity: 0;
-}
-
-.fade-enter-active {
- transition: opacity 200ms ease-out;
-}
-
-.fade-leave-active {
- transition: opacity 300ms ease-out;
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue
deleted file mode 100644
index 4f3714aa0ee..00000000000
--- a/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <section>
- <HeaderBar :account-property="accountProperty"
- label-for="headline"
- :scope.sync="primaryHeadline.scope" />
-
- <Headline :headline.sync="primaryHeadline.value"
- :scope.sync="primaryHeadline.scope" />
- </section>
-</template>
-
-<script>
-import { loadState } from '@nextcloud/initial-state'
-
-import Headline from './Headline'
-import HeaderBar from '../shared/HeaderBar'
-
-import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
-
-const { headlineMap: { primaryHeadline } } = loadState('settings', 'personalInfoParameters', {})
-
-export default {
- name: 'HeadlineSection',
-
- components: {
- Headline,
- HeaderBar,
- },
-
- data() {
- return {
- accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE,
- primaryHeadline,
- }
- },
-}
-</script>
-
-<style lang="scss" scoped>
-section {
- padding: 10px 10px;
-
- &::v-deep button:disabled {
- cursor: default;
- }
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue
index 2f11f493207..8f42b2771c0 100644
--- a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue
+++ b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue
@@ -1,46 +1,19 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="language">
- <select id="language"
- :placeholder="t('settings', 'Language')"
- @change="onLanguageChange">
- <option v-for="commonLanguage in commonLanguages"
- :key="commonLanguage.code"
- :selected="language.code === commonLanguage.code"
- :value="commonLanguage.code">
- {{ commonLanguage.name }}
- </option>
- <option disabled>
- ──────────
- </option>
- <option v-for="otherLanguage in otherLanguages"
- :key="otherLanguage.code"
- :selected="language.code === otherLanguage.code"
- :value="otherLanguage.code">
- {{ otherLanguage.name }}
- </option>
- </select>
+ <NcSelect :aria-label-listbox="t('settings', 'Languages')"
+ class="language__select"
+ :clearable="false"
+ :input-id="inputId"
+ label="name"
+ label-outside
+ :options="allLanguages"
+ :value="language"
+ @option:selected="onLanguageChange" />
<a href="https://www.transifex.com/nextcloud/nextcloud/"
target="_blank"
@@ -51,16 +24,25 @@
</template>
<script>
-import { showError } from '@nextcloud/dialogs'
+import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js'
+import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
+import { validateLanguage } from '../../../utils/validate.js'
+import { handleError } from '../../../utils/handlers.ts'
-import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
-import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
-import { validateLanguage } from '../../../utils/validate'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
export default {
name: 'Language',
+ components: {
+ NcSelect,
+ },
+
props: {
+ inputId: {
+ type: String,
+ default: null,
+ },
commonLanguages: {
type: Array,
required: true,
@@ -82,17 +64,18 @@ export default {
},
computed: {
+ /**
+ * All available languages, sorted like: current, common, other
+ */
allLanguages() {
- return Object.freeze(
- [...this.commonLanguages, ...this.otherLanguages]
- .reduce((acc, { code, name }) => ({ ...acc, [code]: name }), {})
- )
+ const common = this.commonLanguages.filter(l => l.code !== this.language.code)
+ const other = this.otherLanguages.filter(l => l.code !== this.language.code)
+ return [this.language, ...common, ...other]
},
},
methods: {
- async onLanguageChange(e) {
- const language = this.constructLanguage(e.target.value)
+ async onLanguageChange(language) {
this.$emit('update:language', language)
if (validateLanguage(language)) {
@@ -107,7 +90,7 @@ export default {
language,
status: responseData.ocs?.meta?.status,
})
- this.reloadPage()
+ window.location.reload()
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update language'),
@@ -116,26 +99,14 @@ export default {
}
},
- constructLanguage(languageCode) {
- return {
- code: languageCode,
- name: this.allLanguages[languageCode],
- }
- },
-
handleResponse({ language, status, errorMessage, error }) {
if (status === 'ok') {
// Ensure that local state reflects server state
this.initialLanguage = language
} else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
+ handleError(error, errorMessage)
}
},
-
- reloadPage() {
- location.reload()
- },
},
}
</script>
@@ -144,22 +115,11 @@ export default {
.language {
display: grid;
- select {
- width: 100%;
- height: 34px;
- margin: 3px 3px 3px 0;
- padding: 6px 16px;
- color: var(--color-main-text);
- border: 1px solid var(--color-border-dark);
- border-radius: var(--border-radius);
- background: var(--icon-triangle-s-000) no-repeat right 4px center;
- font-family: var(--font-face);
- appearance: none;
- cursor: pointer;
+ #{&}__select {
+ margin-top: 6px; // align with other inputs
}
a {
- color: var(--color-main-text);
text-decoration: none;
width: max-content;
}
diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue
index 90882b23869..4e92436fd63 100644
--- a/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue
+++ b/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue
@@ -1,35 +1,18 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section>
- <HeaderBar :account-property="accountProperty"
- label-for="language" />
+ <HeaderBar :input-id="inputId"
+ :readable="propertyReadable" />
- <template v-if="isEditable">
- <Language :common-languages="commonLanguages"
- :other-languages="otherLanguages"
- :language.sync="language" />
- </template>
+ <Language v-if="isEditable"
+ :input-id="inputId"
+ :common-languages="commonLanguages"
+ :other-languages="otherLanguages"
+ :language.sync="language" />
<span v-else>
{{ t('settings', 'No language set') }}
@@ -40,10 +23,10 @@
<script>
import { loadState } from '@nextcloud/initial-state'
-import Language from './Language'
-import HeaderBar from '../shared/HeaderBar'
+import Language from './Language.vue'
+import HeaderBar from '../shared/HeaderBar.vue'
-import { ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
+import { ACCOUNT_SETTING_PROPERTY_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js'
const { languageMap: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {})
@@ -55,16 +38,26 @@ export default {
HeaderBar,
},
- data() {
+ setup() {
+ // Non reactive instance properties
return {
- accountProperty: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
commonLanguages,
otherLanguages,
+ propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
+ }
+ },
+
+ data() {
+ return {
language: activeLanguage,
}
},
computed: {
+ inputId() {
+ return `account-setting-${ACCOUNT_SETTING_PROPERTY_ENUM.LANGUAGE}`
+ },
+
isEditable() {
return Boolean(this.language)
},
@@ -75,9 +68,5 @@ export default {
<style lang="scss" scoped>
section {
padding: 10px 10px;
-
- &::v-deep button:disabled {
- cursor: default;
- }
}
</style>
diff --git a/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue b/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue
new file mode 100644
index 00000000000..73300756472
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue
@@ -0,0 +1,157 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="locale">
+ <NcSelect :aria-label-listbox="t('settings', 'Locales')"
+ class="locale__select"
+ :clearable="false"
+ :input-id="inputId"
+ label="name"
+ label-outside
+ :options="allLocales"
+ :value="locale"
+ @option:selected="updateLocale" />
+
+ <div class="example">
+ <MapClock :size="20" />
+ <div class="example__text">
+ <p>
+ <span>{{ example.date }}</span>
+ <span>{{ example.time }}</span>
+ </p>
+ <p>
+ {{ t('settings', 'Week starts on {firstDayOfWeek}', { firstDayOfWeek: example.firstDayOfWeek }) }}
+ </p>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import moment from '@nextcloud/moment'
+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'
+import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
+import { handleError } from '../../../utils/handlers.ts'
+
+export default {
+ name: 'Locale',
+
+ components: {
+ MapClock,
+ NcSelect,
+ },
+
+ props: {
+ inputId: {
+ type: String,
+ default: null,
+ },
+ locale: {
+ type: Object,
+ required: true,
+ },
+ localesForLanguage: {
+ type: Array,
+ required: true,
+ },
+ otherLocales: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ initialLocale: this.locale,
+ intervalId: 0,
+ example: {
+ date: moment().format('L'),
+ time: moment().format('LTS'),
+ firstDayOfWeek: window.dayNames[window.firstDay],
+ },
+ }
+ },
+
+ computed: {
+ /**
+ * All available locale, sorted like: current, common, other
+ */
+ allLocales() {
+ const common = this.localesForLanguage.filter(l => l.code !== this.locale.code)
+ const other = this.otherLocales.filter(l => l.code !== this.locale.code)
+ return [this.locale, ...common, ...other]
+ },
+ },
+
+ mounted() {
+ this.intervalId = window.setInterval(this.refreshExample, 1000)
+ },
+
+ beforeDestroy() {
+ window.clearInterval(this.intervalId)
+ },
+
+ methods: {
+ async updateLocale(locale) {
+ try {
+ const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LOCALE, locale.code)
+ this.handleResponse({
+ locale,
+ status: responseData.ocs?.meta?.status,
+ })
+ window.location.reload()
+ } catch (e) {
+ this.handleResponse({
+ errorMessage: t('settings', 'Unable to update locale'),
+ error: e,
+ })
+ }
+ },
+
+ handleResponse({ locale, status, errorMessage, error }) {
+ if (status === 'ok') {
+ this.initialLocale = locale
+ } else {
+ this.$emit('update:locale', this.initialLocale)
+ handleError(error, errorMessage)
+ }
+ },
+
+ refreshExample() {
+ this.example = {
+ date: moment().format('L'),
+ time: moment().format('LTS'),
+ firstDayOfWeek: window.dayNames[window.firstDay],
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.locale {
+ display: grid;
+
+ #{&}__select {
+ margin-top: 6px; // align with other inputs
+ }
+}
+
+.example {
+ margin: 10px 0;
+ display: flex;
+ gap: 0 10px;
+ color: var(--color-text-maxcontrast);
+
+ &:deep(.material-design-icon) {
+ align-self: flex-start;
+ margin-top: 2px;
+ }
+}
+</style>
diff --git a/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue b/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue
new file mode 100644
index 00000000000..d4488e77efd
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue
@@ -0,0 +1,66 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <section>
+ <HeaderBar :input-id="inputId"
+ :readable="propertyReadable" />
+
+ <Locale v-if="isEditable"
+ :input-id="inputId"
+ :locales-for-language="localesForLanguage"
+ :other-locales="otherLocales"
+ :locale.sync="locale" />
+
+ <span v-else>
+ {{ t('settings', 'No locale set') }}
+ </span>
+ </section>
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+
+import Locale from './Locale.vue'
+import HeaderBar from '../shared/HeaderBar.vue'
+
+import { ACCOUNT_SETTING_PROPERTY_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js'
+
+const { localeMap: { activeLocale, localesForLanguage, otherLocales } } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'LocaleSection',
+
+ components: {
+ Locale,
+ HeaderBar,
+ },
+
+ data() {
+ return {
+ propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LOCALE,
+ localesForLanguage,
+ otherLocales,
+ locale: activeLocale,
+ }
+ },
+
+ computed: {
+ inputId() {
+ return `account-setting-${ACCOUNT_SETTING_PROPERTY_ENUM.LOCALE}`
+ },
+
+ isEditable() {
+ return Boolean(this.locale)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+section {
+ padding: 10px 10px;
+}
+</style>
diff --git a/apps/settings/src/components/PersonalInfo/LocationSection.vue b/apps/settings/src/components/PersonalInfo/LocationSection.vue
new file mode 100644
index 00000000000..a32f86b3442
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/LocationSection.vue
@@ -0,0 +1,34 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="location"
+ autocomplete="address-level1"
+ :placeholder="t('settings', 'Your city')" />
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const { location } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'LocationSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ location: { ...location, readable: NAME_READABLE_ENUM[location.name] },
+ }
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection.vue
new file mode 100644
index 00000000000..b951b938919
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/OrganisationSection.vue
@@ -0,0 +1,34 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="organisation"
+ autocomplete="organization"
+ :placeholder="t('settings', 'Your organisation')" />
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const { organisation } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'OrganisationSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ organisation: { ...organisation, readable: NAME_READABLE_ENUM[organisation.name] },
+ }
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue
deleted file mode 100644
index 106c5b6f6ff..00000000000
--- a/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue
+++ /dev/null
@@ -1,174 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <div class="organisation">
- <input id="organisation"
- type="text"
- :placeholder="t('settings', 'Your organisation')"
- :value="organisation"
- autocapitalize="none"
- autocomplete="on"
- autocorrect="off"
- @input="onOrganisationChange">
-
- <div class="organisation__actions-container">
- <transition name="fade">
- <span v-if="showCheckmarkIcon" class="icon-checkmark" />
- <span v-else-if="showErrorIcon" class="icon-error" />
- </transition>
- </div>
- </div>
-</template>
-
-<script>
-import { showError } from '@nextcloud/dialogs'
-import { emit } from '@nextcloud/event-bus'
-import debounce from 'debounce'
-
-import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
-import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
-
-export default {
- name: 'Organisation',
-
- props: {
- organisation: {
- type: String,
- required: true,
- },
- scope: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- initialOrganisation: this.organisation,
- localScope: this.scope,
- showCheckmarkIcon: false,
- showErrorIcon: false,
- }
- },
-
- methods: {
- onOrganisationChange(e) {
- this.$emit('update:organisation', e.target.value)
- this.debounceOrganisationChange(e.target.value.trim())
- },
-
- debounceOrganisationChange: debounce(async function(organisation) {
- await this.updatePrimaryOrganisation(organisation)
- }, 500),
-
- async updatePrimaryOrganisation(organisation) {
- try {
- const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ORGANISATION, organisation)
- this.handleResponse({
- organisation,
- status: responseData.ocs?.meta?.status,
- })
- } catch (e) {
- this.handleResponse({
- errorMessage: t('settings', 'Unable to update organisation'),
- error: e,
- })
- }
- },
-
- handleResponse({ organisation, status, errorMessage, error }) {
- if (status === 'ok') {
- // Ensure that local state reflects server state
- this.initialOrganisation = organisation
- emit('settings:organisation:updated', organisation)
- this.showCheckmarkIcon = true
- setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
- } else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
- }
- },
-
- onScopeChange(scope) {
- this.$emit('update:scope', scope)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.organisation {
- display: grid;
- align-items: center;
-
- input {
- grid-area: 1 / 1;
- width: 100%;
- height: 34px;
- margin: 3px 3px 3px 0;
- padding: 7px 6px;
- color: var(--color-main-text);
- border: 1px solid var(--color-border-dark);
- border-radius: var(--border-radius);
- background-color: var(--color-main-background);
- font-family: var(--font-face);
- cursor: text;
- }
-
- .organisation__actions-container {
- grid-area: 1 / 1;
- justify-self: flex-end;
- height: 30px;
-
- display: flex;
- gap: 0 2px;
- margin-right: 5px;
-
- .icon-checkmark,
- .icon-error {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- top: 0;
- right: 0;
- float: none;
- }
- }
-}
-
-.fade-enter,
-.fade-leave-to {
- opacity: 0;
-}
-
-.fade-enter-active {
- transition: opacity 200ms ease-out;
-}
-
-.fade-leave-active {
- transition: opacity 300ms ease-out;
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue
deleted file mode 100644
index 2a0b93d552f..00000000000
--- a/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <section>
- <HeaderBar :account-property="accountProperty"
- label-for="organisation"
- :scope.sync="primaryOrganisation.scope" />
-
- <Organisation :organisation.sync="primaryOrganisation.value"
- :scope.sync="primaryOrganisation.scope" />
- </section>
-</template>
-
-<script>
-import { loadState } from '@nextcloud/initial-state'
-
-import Organisation from './Organisation'
-import HeaderBar from '../shared/HeaderBar'
-
-import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
-
-const { organisationMap: { primaryOrganisation } } = loadState('settings', 'personalInfoParameters', {})
-
-export default {
- name: 'OrganisationSection',
-
- components: {
- Organisation,
- HeaderBar,
- },
-
- data() {
- return {
- accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION,
- primaryOrganisation,
- }
- },
-}
-</script>
-
-<style lang="scss" scoped>
-section {
- padding: 10px 10px;
-
- &::v-deep button:disabled {
- cursor: default;
- }
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/PhoneSection.vue b/apps/settings/src/components/PersonalInfo/PhoneSection.vue
new file mode 100644
index 00000000000..8ddeada960e
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/PhoneSection.vue
@@ -0,0 +1,53 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="phone"
+ :placeholder="t('settings', 'Your phone number')"
+ autocomplete="tel"
+ type="tel"
+ :on-validate="onValidate" />
+</template>
+
+<script>
+import { isValidPhoneNumber } from 'libphonenumber-js'
+import { loadState } from '@nextcloud/initial-state'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const {
+ defaultPhoneRegion,
+ phone,
+} = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'PhoneSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ phone: { ...phone, readable: NAME_READABLE_ENUM[phone.name] },
+ }
+ },
+
+ methods: {
+ onValidate(value) {
+ if (value === '') {
+ return true
+ }
+
+ if (defaultPhoneRegion) {
+ return isValidPhoneNumber(value, defaultPhoneRegion)
+ }
+ return isValidPhoneNumber(value)
+ },
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue
index 1ee3bc0e149..3deb5340751 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2021 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -25,15 +8,13 @@
href="#profile-visibility"
v-on="$listeners">
<ChevronDownIcon class="anchor-icon"
- decorative
- title=""
:size="22" />
{{ t('settings', 'Edit your Profile visibility') }}
</a>
</template>
<script>
-import ChevronDownIcon from 'vue-material-design-icons/ChevronDown'
+import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue'
export default {
name: 'EditProfileAnchorLink',
@@ -71,26 +52,28 @@ html {
a {
display: block;
height: 44px;
- width: 290px;
+ width: min(100%, 290px);
+ overflow: hidden;
+ text-overflow: ellipsis;
line-height: 44px;
padding: 0 16px;
margin: 14px auto;
border-radius: var(--border-radius-pill);
- opacity: 0.4;
+ color: var(--color-text-maxcontrast);
background-color: transparent;
.anchor-icon {
display: inline-block;
vertical-align: middle;
margin-top: 6px;
- margin-right: 8px;
+ margin-inline-end: 8px;
}
&:hover,
&:focus,
&:active {
- opacity: 0.8;
- background-color: rgba(127, 127, 127, .25);
+ color: var(--color-main-text);
+ background-color: var(--color-background-dark);
}
&.disabled {
diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue
index d7e78915c5d..6eb7cf8c34c 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue
@@ -1,49 +1,34 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="checkbox-container">
- <input id="enable-profile"
- class="checkbox"
- type="checkbox"
- :checked="profileEnabled"
- @change="onEnableProfileChange">
- <label for="enable-profile">
- {{ t('settings', 'Enable Profile') }}
- </label>
+ <NcCheckboxRadioSwitch type="switch"
+ :checked.sync="isProfileEnabled"
+ :loading="loading"
+ @update:checked="saveEnableProfile">
+ {{ t('settings', 'Enable profile') }}
+ </NcCheckboxRadioSwitch>
</div>
</template>
<script>
-import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
-import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
-import { validateBoolean } from '../../../utils/validate'
-import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
+import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
+import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import { handleError } from '../../../utils/handlers.ts'
export default {
name: 'ProfileCheckbox',
+ components: {
+ NcCheckboxRadioSwitch,
+ },
+
props: {
profileEnabled: {
type: Boolean,
@@ -53,25 +38,18 @@ export default {
data() {
return {
- initialProfileEnabled: this.profileEnabled,
+ isProfileEnabled: this.profileEnabled,
+ loading: false,
}
},
methods: {
- async onEnableProfileChange(e) {
- const isEnabled = e.target.checked
- this.$emit('update:profile-enabled', isEnabled)
-
- if (validateBoolean(isEnabled)) {
- await this.updateEnableProfile(isEnabled)
- }
- },
-
- async updateEnableProfile(isEnabled) {
+ async saveEnableProfile() {
+ this.loading = true
try {
- const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, isEnabled)
+ const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, this.isProfileEnabled)
this.handleResponse({
- isEnabled,
+ isProfileEnabled: this.isProfileEnabled,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
@@ -82,19 +60,14 @@ export default {
}
},
- handleResponse({ isEnabled, status, errorMessage, error }) {
+ handleResponse({ isProfileEnabled, status, errorMessage, error }) {
if (status === 'ok') {
- // Ensure that local state reflects server state
- this.initialProfileEnabled = isEnabled
- emit('settings:profile-enabled:updated', isEnabled)
+ emit('settings:profile-enabled:updated', isProfileEnabled)
} else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
+ handleError(error, errorMessage)
}
+ this.loading = false
},
},
}
</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue
index ef12d511fb9..47894f64f34 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue
@@ -1,30 +1,13 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<a class="preview-card"
:class="{ disabled }"
:href="profilePageLink">
- <Avatar class="preview-card__avatar"
+ <NcAvatar class="preview-card__avatar"
:user="userId"
:size="48"
:show-user-status="true"
@@ -44,13 +27,13 @@
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'
-import Avatar from '@nextcloud/vue/dist/Components/Avatar'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
export default {
name: 'ProfilePreviewCard',
components: {
- Avatar,
+ NcAvatar,
},
props: {
@@ -95,7 +78,7 @@ export default {
display: flex;
flex-direction: column;
position: relative;
- width: 290px;
+ width: min(100%, 290px);
height: 116px;
margin: 14px auto;
border-radius: var(--border-radius-large);
@@ -121,7 +104,7 @@ export default {
box-shadow: 0 0 3px var(--color-box-shadow);
& *,
- &::v-deep * {
+ &:deep(*) {
cursor: default;
}
}
@@ -130,7 +113,7 @@ export default {
// Override Avatar component position to fix positioning on rerender
position: absolute !important;
top: 40px;
- left: 18px;
+ inset-inline-start: 18px;
z-index: 1;
&:not(.avatardiv--unknown) {
@@ -145,10 +128,10 @@ export default {
span {
position: absolute;
- left: 78px;
+ inset-inline-start: 78px;
overflow: hidden;
text-overflow: ellipsis;
- word-break: break-all;
+ overflow-wrap: anywhere;
@supports (-webkit-line-clamp: 2) {
display: -webkit-box;
@@ -161,15 +144,15 @@ export default {
&__header {
height: 70px;
border-radius: var(--border-radius-large) var(--border-radius-large) 0 0;
- background-color: var(--color-primary);
- background-image: linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-element-light) 100%);
+ background-color: var(--color-primary-element);
span {
bottom: 0;
- color: var(--color-primary-text);
+ color: var(--color-primary-element-text);
font-size: 18px;
font-weight: bold;
- margin: 0 4px 8px 0;
+ margin-block: 0 8px;
+ margin-inline: 0 4px;
}
}
@@ -181,7 +164,8 @@ export default {
color: var(--color-text-maxcontrast);
font-size: 14px;
font-weight: normal;
- margin: 4px 4px 0 0;
+ margin-block: 4px 0;
+ margin-inline: 0 4px;
line-height: 1.3;
}
}
diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue
index 46048e96c0e..22c03f72697 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue
@@ -1,28 +1,11 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section>
- <HeaderBar :account-property="accountProperty" />
+ <HeaderBar :is-heading="true" :readable="propertyReadable" />
<ProfileCheckbox :profile-enabled.sync="profileEnabled" />
@@ -39,16 +22,16 @@
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import EditProfileAnchorLink from './EditProfileAnchorLink'
-import HeaderBar from '../shared/HeaderBar'
-import ProfileCheckbox from './ProfileCheckbox'
-import ProfilePreviewCard from './ProfilePreviewCard'
+import EditProfileAnchorLink from './EditProfileAnchorLink.vue'
+import HeaderBar from '../shared/HeaderBar.vue'
+import ProfileCheckbox from './ProfileCheckbox.vue'
+import ProfilePreviewCard from './ProfilePreviewCard.vue'
-import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
+import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js'
const {
- organisationMap: { primaryOrganisation: { value: organisation } },
- displayNameMap: { primaryDisplayName: { value: displayName } },
+ organisation: { value: organisation },
+ displayName: { value: displayName },
profileEnabled,
userId,
} = loadState('settings', 'personalInfoParameters', {})
@@ -65,7 +48,7 @@ export default {
data() {
return {
- accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED,
+ propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED,
organisation,
displayName,
profileEnabled,
@@ -99,7 +82,7 @@ export default {
section {
padding: 10px 10px;
- &::v-deep button:disabled {
+ &:deep(button:disabled) {
cursor: default;
}
}
diff --git a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue
index 16a46fee969..8acec883842 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue
@@ -1,30 +1,13 @@
<!--
- - @copyright 2021 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<!-- TODO remove this inline margin placeholder once the settings layout is updated -->
<section id="profile-visibility"
:style="{ marginLeft }">
- <HeaderBar :account-property="heading" />
+ <HeaderBar :is-heading="true" :readable="heading" />
<em :class="{ disabled }">
{{ t('settings', 'The more restrictive setting of either visibility or scope is respected on your Profile. For example, if visibility is set to "Show to everyone" and scope is set to "Private", "Private" is respected.') }}
@@ -47,9 +30,9 @@
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import HeaderBar from '../shared/HeaderBar'
-import VisibilityDropdown from './VisibilityDropdown'
-import { PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
+import HeaderBar from '../shared/HeaderBar.vue'
+import VisibilityDropdown from './VisibilityDropdown.vue'
+import { PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js'
const { profileConfig } = loadState('settings', 'profileParameters', {})
const { profileEnabled } = loadState('settings', 'personalInfoParameters', false)
@@ -81,7 +64,7 @@ export default {
.sort(compareParams),
// TODO remove this when not used once the settings layout is updated
marginLeft: window.matchMedia('(min-width: 1600px)').matches
- ? window.getComputedStyle(document.getElementById('personal-settings-avatar-container')).getPropertyValue('width').trim()
+ ? window.getComputedStyle(document.getElementById('vue-avatar-section')).getPropertyValue('width').trim()
: '0px',
}
},
@@ -101,7 +84,7 @@ export default {
// TODO remove this when not used once the settings layout is updated
window.onresize = () => {
this.marginLeft = window.matchMedia('(min-width: 1600px)').matches
- ? window.getComputedStyle(document.getElementById('personal-settings-avatar-container')).getPropertyValue('width').trim()
+ ? window.getComputedStyle(document.getElementById('vue-avatar-section')).getPropertyValue('width').trim()
: '0px'
}
},
@@ -121,7 +104,8 @@ export default {
<style lang="scss" scoped>
section {
padding: 30px;
- max-width: 100vw;
+ max-width: 900px;
+ width: 100%;
em {
display: block;
@@ -134,28 +118,11 @@ section {
pointer-events: none;
& *,
- &::v-deep * {
+ &:deep(*) {
cursor: default;
pointer-events: none;
}
}
}
-
- .visibility-dropdowns {
- display: grid;
- gap: 10px 40px;
- }
-
- @media (min-width: 1200px) {
- width: 940px;
-
- .visibility-dropdowns {
- grid-auto-flow: column;
- }
- }
-
- @media (max-width: 1200px) {
- width: 470px;
- }
}
</style>
diff --git a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue
index e057d5f0a08..aaa13e63e92 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue
@@ -1,51 +1,33 @@
<!--
- - @copyright 2021 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="visibility-container"
:class="{ disabled }">
<label :for="inputId">
- {{ t('settings', '{displayId}', { displayId }) }}
+ {{ displayId }}
</label>
- <Multiselect :id="inputId"
- class="visibility-container__multiselect"
+ <NcSelect :input-id="inputId"
+ class="visibility-container__select"
+ :clearable="false"
:options="visibilityOptions"
- track-by="name"
- label="label"
:value="visibilityObject"
- @change="onVisibilityChange" />
+ label-outside
+ @option:selected="onVisibilityChange" />
</div>
</template>
<script>
-import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
-import { saveProfileParameterVisibility } from '../../../service/ProfileService'
-import { validateStringInput } from '../../../utils/validate'
-import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants'
+import { saveProfileParameterVisibility } from '../../../service/ProfileService.js'
+import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants.js'
+import { handleError } from '../../../utils/handlers.ts'
const { profileEnabled } = loadState('settings', 'personalInfoParameters', false)
@@ -53,7 +35,7 @@ export default {
name: 'VisibilityDropdown',
components: {
- Multiselect,
+ NcSelect,
},
props: {
@@ -111,7 +93,7 @@ export default {
const { name: visibility } = visibilityObject
this.$emit('update:visibility', visibility)
- if (validateStringInput(visibility)) {
+ if (visibility !== '') {
await this.updateVisibility(visibility)
}
}
@@ -137,8 +119,7 @@ export default {
// Ensure that local state reflects server state
this.initialVisibility = visibility
} else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
+ handleError(error, errorMessage)
}
},
@@ -152,7 +133,7 @@ export default {
<style lang="scss" scoped>
.visibility-container {
display: flex;
- width: max-content;
+ flex-wrap: wrap;
&.disabled {
filter: grayscale(1);
@@ -161,7 +142,7 @@ export default {
pointer-events: none;
& *,
- &::v-deep * {
+ &:deep(*) {
cursor: default;
pointer-events: none;
}
@@ -173,8 +154,8 @@ export default {
line-height: 50px;
}
- &__multiselect {
- width: 260px;
+ &__select {
+ width: 270px;
max-width: 40vw;
}
}
diff --git a/apps/settings/src/components/PersonalInfo/PronounsSection.vue b/apps/settings/src/components/PersonalInfo/PronounsSection.vue
new file mode 100644
index 00000000000..e345cb8e225
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/PronounsSection.vue
@@ -0,0 +1,47 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="pronouns"
+ :placeholder="randomPronounsPlaceholder" />
+</template>
+
+<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'
+
+const { pronouns } = loadState<{ pronouns: IAccountProperty }>('settings', 'personalInfoParameters')
+
+export default defineComponent({
+ name: 'PronounsSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ pronouns: { ...pronouns, readable: NAME_READABLE_ENUM[pronouns.name] },
+ }
+ },
+
+ computed: {
+ randomPronounsPlaceholder() {
+ const pronouns = [
+ t('settings', 'she/her'),
+ t('settings', 'he/him'),
+ t('settings', 'they/them'),
+ ]
+ const pronounsExample = pronouns[Math.floor(Math.random() * pronouns.length)]
+ return t('settings', 'Your pronouns. E.g. {pronounsExample}', { pronounsExample })
+ },
+ },
+})
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/RoleSection.vue b/apps/settings/src/components/PersonalInfo/RoleSection.vue
new file mode 100644
index 00000000000..3581112fe1b
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/RoleSection.vue
@@ -0,0 +1,34 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="role"
+ autocomplete="organization-title"
+ :placeholder="t('settings', 'Your role')" />
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+
+const { role } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'RoleSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ role: { ...role, readable: NAME_READABLE_ENUM[role.name] },
+ }
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue b/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue
deleted file mode 100644
index efbc06e61be..00000000000
--- a/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue
+++ /dev/null
@@ -1,174 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <div class="role">
- <input id="role"
- type="text"
- :placeholder="t('settings', 'Your role')"
- :value="role"
- autocapitalize="none"
- autocomplete="on"
- autocorrect="off"
- @input="onRoleChange">
-
- <div class="role__actions-container">
- <transition name="fade">
- <span v-if="showCheckmarkIcon" class="icon-checkmark" />
- <span v-else-if="showErrorIcon" class="icon-error" />
- </transition>
- </div>
- </div>
-</template>
-
-<script>
-import { showError } from '@nextcloud/dialogs'
-import { emit } from '@nextcloud/event-bus'
-import debounce from 'debounce'
-
-import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
-import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService'
-
-export default {
- name: 'Role',
-
- props: {
- role: {
- type: String,
- required: true,
- },
- scope: {
- type: String,
- required: true,
- },
- },
-
- data() {
- return {
- initialRole: this.role,
- localScope: this.scope,
- showCheckmarkIcon: false,
- showErrorIcon: false,
- }
- },
-
- methods: {
- onRoleChange(e) {
- this.$emit('update:role', e.target.value)
- this.debounceRoleChange(e.target.value.trim())
- },
-
- debounceRoleChange: debounce(async function(role) {
- await this.updatePrimaryRole(role)
- }, 500),
-
- async updatePrimaryRole(role) {
- try {
- const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ROLE, role)
- this.handleResponse({
- role,
- status: responseData.ocs?.meta?.status,
- })
- } catch (e) {
- this.handleResponse({
- errorMessage: t('settings', 'Unable to update role'),
- error: e,
- })
- }
- },
-
- handleResponse({ role, status, errorMessage, error }) {
- if (status === 'ok') {
- // Ensure that local state reflects server state
- this.initialRole = role
- emit('settings:role:updated', role)
- this.showCheckmarkIcon = true
- setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
- } else {
- showError(errorMessage)
- this.logger.error(errorMessage, error)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
- }
- },
-
- onScopeChange(scope) {
- this.$emit('update:scope', scope)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.role {
- display: grid;
- align-items: center;
-
- input {
- grid-area: 1 / 1;
- width: 100%;
- height: 34px;
- margin: 3px 3px 3px 0;
- padding: 7px 6px;
- color: var(--color-main-text);
- border: 1px solid var(--color-border-dark);
- border-radius: var(--border-radius);
- background-color: var(--color-main-background);
- font-family: var(--font-face);
- cursor: text;
- }
-
- .role__actions-container {
- grid-area: 1 / 1;
- justify-self: flex-end;
- height: 30px;
-
- display: flex;
- gap: 0 2px;
- margin-right: 5px;
-
- .icon-checkmark,
- .icon-error {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- top: 0;
- right: 0;
- float: none;
- }
- }
-}
-
-.fade-enter,
-.fade-leave-to {
- opacity: 0;
-}
-
-.fade-enter-active {
- transition: opacity 200ms ease-out;
-}
-
-.fade-leave-active {
- transition: opacity 300ms ease-out;
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue b/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue
deleted file mode 100644
index 51026f4860c..00000000000
--- a/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <section>
- <HeaderBar :account-property="accountProperty"
- label-for="role"
- :scope.sync="primaryRole.scope" />
-
- <Role :role.sync="primaryRole.value"
- :scope.sync="primaryRole.scope" />
- </section>
-</template>
-
-<script>
-import { loadState } from '@nextcloud/initial-state'
-
-import Role from './Role'
-import HeaderBar from '../shared/HeaderBar'
-
-import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
-
-const { roleMap: { primaryRole } } = loadState('settings', 'personalInfoParameters', {})
-
-export default {
- name: 'RoleSection',
-
- components: {
- Role,
- HeaderBar,
- },
-
- data() {
- return {
- accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ROLE,
- primaryRole,
- }
- },
-}
-</script>
-
-<style lang="scss" scoped>
-section {
- padding: 10px 10px;
-
- &::v-deep button:disabled {
- cursor: default;
- }
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/TwitterSection.vue b/apps/settings/src/components/PersonalInfo/TwitterSection.vue
new file mode 100644
index 00000000000..43d08f81e3f
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/TwitterSection.vue
@@ -0,0 +1,34 @@
+<!--
+ - 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', 'Your X (formerly Twitter) 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 { twitter } = loadState<AccountProperties>('settings', 'personalInfoParameters')
+
+const value = ref({ ...twitter })
+const 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/WebsiteSection.vue b/apps/settings/src/components/PersonalInfo/WebsiteSection.vue
new file mode 100644
index 00000000000..762909139dd
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/WebsiteSection.vue
@@ -0,0 +1,43 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <AccountPropertySection v-bind.sync="website"
+ :placeholder="t('settings', 'Your website')"
+ autocomplete="url"
+ type="url"
+ :on-validate="onValidate" />
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+
+import AccountPropertySection from './shared/AccountPropertySection.vue'
+
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
+import { validateUrl } from '../../utils/validate.js'
+
+const { website } = loadState('settings', 'personalInfoParameters', {})
+
+export default {
+ name: 'WebsiteSection',
+
+ components: {
+ AccountPropertySection,
+ },
+
+ data() {
+ return {
+ website: { ...website, readable: NAME_READABLE_ENUM[website.name] },
+ }
+ },
+
+ methods: {
+ onValidate(value) {
+ return validateUrl(value)
+ },
+ },
+}
+</script>
diff --git a/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue
new file mode 100644
index 00000000000..d039641ec72
--- /dev/null
+++ b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue
@@ -0,0 +1,243 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <section>
+ <HeaderBar :scope="scope"
+ :readable="readable"
+ :input-id="inputId"
+ :is-editable="isEditable"
+ @update:scope="(scope) => $emit('update:scope', scope)" />
+
+ <div v-if="isEditable" class="property">
+ <NcTextArea v-if="multiLine"
+ :id="inputId"
+ autocapitalize="none"
+ autocomplete="off"
+ :error="hasError || !!helperText"
+ :helper-text="helperText"
+ label-outside
+ :placeholder="placeholder"
+ rows="8"
+ spellcheck="false"
+ :success="isSuccess"
+ :value.sync="inputValue" />
+ <NcInputField v-else
+ :id="inputId"
+ ref="input"
+ autocapitalize="none"
+ :autocomplete="autocomplete"
+ :error="hasError || !!helperText"
+ :helper-text="helperText"
+ label-outside
+ :placeholder="placeholder"
+ spellcheck="false"
+ :success="isSuccess"
+ :type="type"
+ :value.sync="inputValue" />
+ </div>
+ <span v-else>
+ {{ value || t('settings', 'No {property} set', { property: readable.toLocaleLowerCase() }) }}
+ </span>
+ </section>
+</template>
+
+<script>
+import debounce from 'debounce'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcTextArea from '@nextcloud/vue/components/NcTextArea'
+
+import HeaderBar from './HeaderBar.vue'
+
+import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
+import { handleError } from '../../../utils/handlers.ts'
+
+export default {
+ name: 'AccountPropertySection',
+
+ components: {
+ HeaderBar,
+ NcInputField,
+ NcTextArea,
+ },
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ scope: {
+ type: String,
+ required: true,
+ },
+ readable: {
+ type: String,
+ required: true,
+ },
+ placeholder: {
+ type: String,
+ required: true,
+ },
+ type: {
+ type: String,
+ default: 'text',
+ },
+ isEditable: {
+ type: Boolean,
+ default: true,
+ },
+ multiLine: {
+ type: Boolean,
+ default: false,
+ },
+ onValidate: {
+ type: Function,
+ default: null,
+ },
+ onSave: {
+ type: Function,
+ default: null,
+ },
+ autocomplete: {
+ type: String,
+ default: null,
+ },
+ },
+
+ emits: ['update:scope', 'update:value'],
+
+ data() {
+ return {
+ initialValue: this.value,
+ helperText: '',
+ isSuccess: false,
+ hasError: false,
+ }
+ },
+
+ computed: {
+ inputId() {
+ return `account-property-${this.name}`
+ },
+
+ inputValue: {
+ get() {
+ return this.value
+ },
+ set(value) {
+ this.$emit('update:value', value)
+ this.debouncePropertyChange(value.trim())
+ },
+ },
+
+ debouncePropertyChange() {
+ return debounce(async function(value) {
+ this.helperText = this.$refs.input?.$refs.input?.validationMessage || ''
+ if (this.helperText !== '') {
+ return
+ }
+ this.hasError = this.onValidate && !this.onValidate(value)
+ if (this.hasError) {
+ this.helperText = t('settings', 'Invalid value')
+ return
+ }
+ await this.updateProperty(value)
+ }, 1000)
+ },
+ },
+
+ methods: {
+ async updateProperty(value) {
+ try {
+ this.hasError = false
+ const responseData = await savePrimaryAccountProperty(
+ this.name,
+ value,
+ )
+ this.handleResponse({
+ value,
+ status: responseData.ocs?.meta?.status,
+ })
+ } catch (e) {
+ this.handleResponse({
+ errorMessage: t('settings', 'Unable to update {property}', { property: this.readable.toLocaleLowerCase() }),
+ error: e,
+ })
+ }
+ },
+
+ handleResponse({ value, status, errorMessage, error }) {
+ if (status === 'ok') {
+ this.initialValue = value
+ if (this.onSave) {
+ this.onSave(value)
+ }
+ this.isSuccess = true
+ setTimeout(() => { this.isSuccess = false }, 2000)
+ } else {
+ handleError(error, errorMessage)
+ this.hasError = true
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+section {
+ padding: 10px 10px;
+
+ .property {
+ display: flex;
+ flex-direction: row;
+ align-items: start;
+ gap: 4px;
+
+ .property__actions-container {
+ margin-top: 6px;
+ justify-self: flex-end;
+ align-self: flex-end;
+
+ display: flex;
+ gap: 0 2px;
+ margin-inline-end: 5px;
+ margin-bottom: 5px;
+ }
+ }
+
+ .property__helper-text-message {
+ padding: 4px 0;
+ display: flex;
+ align-items: center;
+
+ &__icon {
+ margin-inline-end: 8px;
+ align-self: start;
+ margin-top: 4px;
+ }
+
+ &--error {
+ color: var(--color-error);
+ }
+ }
+
+ .fade-enter,
+ .fade-leave-to {
+ opacity: 0;
+ }
+
+ .fade-enter-active {
+ transition: opacity 200ms ease-out;
+ }
+
+ .fade-leave-active {
+ transition: opacity 300ms ease-out;
+ }
+}
+</style>
diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue
index b14bc5165b5..e55a50056d3 100644
--- a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue
+++ b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue
@@ -1,74 +1,72 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <Actions :class="{ 'federation-actions': !additional, 'federation-actions--additional': additional }"
+ <NcActions ref="federationActions"
+ class="federation-actions"
:aria-label="ariaLabel"
- :default-icon="scopeIcon"
:disabled="disabled">
- <FederationControlAction v-for="federationScope in federationScopes"
+ <template #icon>
+ <NcIconSvgWrapper :path="scopeIcon" />
+ </template>
+
+ <NcActionButton v-for="federationScope in federationScopes"
:key="federationScope.name"
- :active-scope="scope"
- :display-name="federationScope.displayName"
- :handle-scope-change="changeScope"
- :icon-class="federationScope.iconClass"
- :is-supported-scope="supportedScopes.includes(federationScope.name)"
- :name="federationScope.name"
- :tooltip-disabled="federationScope.tooltipDisabled"
- :tooltip="federationScope.tooltip" />
- </Actions>
+ :close-after-click="true"
+ :disabled="!supportedScopes.includes(federationScope.name)"
+ :name="federationScope.displayName"
+ type="radio"
+ :value="federationScope.name"
+ :model-value="scope"
+ @update:modelValue="changeScope">
+ <template #icon>
+ <NcIconSvgWrapper :path="federationScope.icon" />
+ </template>
+ {{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }}
+ </NcActionButton>
+ </NcActions>
</template>
<script>
-import Actions from '@nextcloud/vue/dist/Components/Actions'
+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 { showError } from '@nextcloud/dialogs'
-
-import FederationControlAction from './FederationControlAction'
import {
ACCOUNT_PROPERTY_READABLE_ENUM,
+ ACCOUNT_SETTING_PROPERTY_READABLE_ENUM,
+ PROFILE_READABLE_ENUM,
PROPERTY_READABLE_KEYS_ENUM,
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM,
- SCOPE_ENUM, SCOPE_PROPERTY_ENUM,
+ SCOPE_PROPERTY_ENUM,
+ SCOPE_ENUM,
UNPUBLISHED_READABLE_PROPERTIES,
-} from '../../../constants/AccountPropertyConstants'
-import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService'
+} from '../../../constants/AccountPropertyConstants.js'
+import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js'
+import { handleError } from '../../../utils/handlers.ts'
-const { lookupServerUploadEnabled } = loadState('settings', 'accountParameters', {})
+const {
+ federationEnabled,
+ lookupServerUploadEnabled,
+} = loadState('settings', 'accountParameters', {})
export default {
name: 'FederationControl',
components: {
- Actions,
- FederationControlAction,
+ NcActions,
+ NcActionButton,
+ NcIconSvgWrapper,
},
props: {
- accountProperty: {
+ readable: {
type: String,
required: true,
- validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value),
+ validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
},
additional: {
type: Boolean,
@@ -92,20 +90,26 @@ export default {
},
},
+ emits: ['update:scope'],
+
data() {
return {
- accountPropertyLowerCase: this.accountProperty.toLocaleLowerCase(),
+ readableLowerCase: this.readable.toLocaleLowerCase(),
initialScope: this.scope,
}
},
computed: {
ariaLabel() {
- return t('settings', 'Change scope level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase })
+ return t('settings', 'Change scope level of {property}, current scope is {scope}', { property: this.readableLowerCase, scope: this.scopeDisplayNameLowerCase })
+ },
+
+ scopeDisplayNameLowerCase() {
+ return SCOPE_PROPERTY_ENUM[this.scope].displayName.toLocaleLowerCase()
},
scopeIcon() {
- return SCOPE_PROPERTY_ENUM[this.scope].iconClass
+ return SCOPE_PROPERTY_ENUM[this.scope].icon
},
federationScopes() {
@@ -113,15 +117,21 @@ export default {
},
supportedScopes() {
- if (lookupServerUploadEnabled && !UNPUBLISHED_READABLE_PROPERTIES.includes(this.accountProperty)) {
- return [
- ...PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.accountProperty],
- SCOPE_ENUM.FEDERATED,
- SCOPE_ENUM.PUBLISHED,
- ]
+ const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable]
+
+ if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) {
+ return scopes
+ }
+
+ if (federationEnabled) {
+ scopes.push(SCOPE_ENUM.FEDERATED)
}
- return PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.accountProperty]
+ if (lookupServerUploadEnabled) {
+ scopes.push(SCOPE_ENUM.PUBLISHED)
+ }
+
+ return scopes
},
},
@@ -134,18 +144,21 @@ export default {
} else {
await this.updateAdditionalScope(scope)
}
+
+ // TODO: provide focus method from NcActions
+ this.$refs.federationActions.$refs?.triggerButton?.$el?.focus?.()
},
async updatePrimaryScope(scope) {
try {
- const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.accountProperty], scope)
+ const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
- errorMessage: t('settings', 'Unable to update federation scope of the primary {accountProperty}', { accountProperty: this.accountPropertyLowerCase }),
+ errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }),
error: e,
})
}
@@ -160,7 +173,7 @@ export default {
})
} catch (e) {
this.handleResponse({
- errorMessage: t('settings', 'Unable to update federation scope of additional {accountProperty}', { accountProperty: this.accountPropertyLowerCase }),
+ errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }),
error: e,
})
}
@@ -171,8 +184,7 @@ export default {
this.initialScope = scope
} else {
this.$emit('update:scope', this.initialScope)
- showError(errorMessage)
- this.logger.error(errorMessage, error)
+ handleError(error, errorMessage)
}
},
},
@@ -180,25 +192,15 @@ export default {
</script>
<style lang="scss" scoped>
- .federation-actions,
- .federation-actions--additional {
- opacity: 0.4 !important;
-
- &:hover,
- &:focus,
- &:active {
- opacity: 0.8 !important;
- }
- }
-
- .federation-actions--additional {
- &::v-deep button {
+.federation-actions {
+ &--additional {
+ &:deep(button) {
// TODO remove this hack
- padding-bottom: 7px;
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
}
}
+}
</style>
diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue
deleted file mode 100644
index f98d9bc7535..00000000000
--- a/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue
+++ /dev/null
@@ -1,104 +0,0 @@
-<!--
- - @copyright 2021 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
--->
-
-<template>
- <ActionButton :aria-label="isSupportedScope ? tooltip : tooltipDisabled"
- class="federation-actions__btn"
- :class="{ 'federation-actions__btn--active': activeScope === name }"
- :close-after-click="true"
- :disabled="!isSupportedScope"
- :icon="iconClass"
- :title="displayName"
- @click.stop.prevent="updateScope">
- {{ isSupportedScope ? tooltip : tooltipDisabled }}
- </ActionButton>
-</template>
-
-<script>
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
-
-export default {
- name: 'FederationControlAction',
-
- components: {
- ActionButton,
- },
-
- props: {
- activeScope: {
- type: String,
- required: true,
- },
- displayName: {
- type: String,
- required: true,
- },
- handleScopeChange: {
- type: Function,
- default: () => {},
- },
- iconClass: {
- type: String,
- required: true,
- },
- isSupportedScope: {
- type: Boolean,
- required: true,
- },
- name: {
- type: String,
- required: true,
- },
- tooltipDisabled: {
- type: String,
- default: '',
- },
- tooltip: {
- type: String,
- required: true,
- },
- },
-
- methods: {
- updateScope() {
- this.handleScopeChange(this.name)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
- .federation-actions__btn {
- &::v-deep p {
- width: 150px !important;
- padding: 8px 0 !important;
- color: var(--color-main-text) !important;
- font-size: 12.8px !important;
- line-height: 1.5em !important;
- }
- }
-
- .federation-actions__btn--active {
- background-color: var(--color-primary-light) !important;
- box-shadow: inset 2px 0 var(--color-primary) !important;
- }
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue
index 65eb5a110a3..7c95c2b8f4c 100644
--- a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue
+++ b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue
@@ -1,41 +1,28 @@
<!--
- - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <h3 :class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }">
- <label :for="labelFor">
+ <div class="headerbar-label" :class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }">
+ <h3 v-if="isHeading" class="headerbar__heading">
<!-- Already translated as required by prop validator -->
- {{ accountProperty }}
+ {{ readable }}
+ </h3>
+ <label v-else :for="inputId">
+ <!-- Already translated as required by prop validator -->
+ {{ readable }}
</label>
<template v-if="scope">
<FederationControl class="federation-control"
- :account-property="accountProperty"
+ :readable="readable"
:scope.sync="localScope"
@update:scope="onScopeChange" />
</template>
<template v-if="isEditable && isMultiValueSupported">
- <Button type="tertiary"
+ <NcButton type="tertiary"
:disabled="!isValidSection"
:aria-label="t('settings', 'Add additional email')"
@click.stop.prevent="onAddAdditional">
@@ -43,31 +30,43 @@
<Plus :size="20" />
</template>
{{ t('settings', 'Add') }}
- </Button>
+ </NcButton>
</template>
- </h3>
+ </div>
</template>
<script>
-import FederationControl from './FederationControl'
-import Button from '@nextcloud/vue/dist/Components/Button'
-import Plus from 'vue-material-design-icons/Plus'
-import { ACCOUNT_PROPERTY_READABLE_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import Plus from 'vue-material-design-icons/Plus.vue'
+
+import FederationControl from './FederationControl.vue'
+
+import {
+ ACCOUNT_PROPERTY_READABLE_ENUM,
+ PROFILE_READABLE_ENUM,
+} from '../../../constants/AccountPropertyConstants.js'
export default {
name: 'HeaderBar',
components: {
FederationControl,
- Button,
+ NcButton,
Plus,
},
props: {
- accountProperty: {
+ scope: {
+ type: String,
+ default: null,
+ },
+ readable: {
type: String,
required: true,
- validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
+ },
+ inputId: {
+ type: String,
+ default: null,
},
isEditable: {
type: Boolean,
@@ -79,15 +78,11 @@ export default {
},
isValidSection: {
type: Boolean,
- default: false,
- },
- labelFor: {
- type: String,
- default: '',
+ default: true,
},
- scope: {
- type: String,
- default: null,
+ isHeading: {
+ type: Boolean,
+ default: false,
},
},
@@ -99,11 +94,11 @@ export default {
computed: {
isProfileProperty() {
- return this.accountProperty === ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED
+ return this.readable === ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED
},
isSettingProperty() {
- return Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty)
+ return !Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(this.readable) && !Object.values(PROFILE_READABLE_ENUM).includes(this.readable)
},
},
@@ -120,10 +115,13 @@ export default {
</script>
<style lang="scss" scoped>
- h3 {
+ .headerbar-label {
+ font-weight: normal;
display: inline-flex;
width: 100%;
margin: 12px 0 0 0;
+ gap: 8px;
+ align-items: center;
font-size: 16px;
color: var(--color-text-light);
@@ -132,7 +130,7 @@ export default {
}
&.setting-property {
- height: 32px;
+ height: 34px;
}
label {
@@ -140,11 +138,16 @@ export default {
}
}
+ .headerbar__heading {
+ margin: 0;
+ }
+
.federation-control {
- margin: -12px 0 0 8px;
+ margin: 0;
}
.button-vue {
- margin: -6px 0 0 auto !important;
+ margin: 0 !important;
+ margin-inline-start: auto !important;
}
</style>
diff --git a/apps/settings/src/components/PrefixMixin.vue b/apps/settings/src/components/PrefixMixin.vue
deleted file mode 100644
index cd37416b27f..00000000000
--- a/apps/settings/src/components/PrefixMixin.vue
+++ /dev/null
@@ -1,32 +0,0 @@
-<!--
- - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
-<script>
-export default {
- name: 'PrefixMixin',
- methods: {
- prefix(prefix, content) {
- return prefix + '_' + content
- },
- },
-}
-</script>
diff --git a/apps/settings/src/components/SelectSharingPermissions.vue b/apps/settings/src/components/SelectSharingPermissions.vue
new file mode 100644
index 00000000000..ef24bcda026
--- /dev/null
+++ b/apps/settings/src/components/SelectSharingPermissions.vue
@@ -0,0 +1,83 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <fieldset class="permissions-select">
+ <NcCheckboxRadioSwitch :checked="canCreate" @update:checked="toggle(PERMISSION_CREATE)">
+ {{ t('settings', 'Create') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked="canUpdate" @update:checked="toggle(PERMISSION_UPDATE)">
+ {{ t('settings', 'Change') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked="canDelete" @update:checked="toggle(PERMISSION_DELETE)">
+ {{ t('settings', 'Delete') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch :checked="canShare" @update:checked="toggle(PERMISSION_SHARE)">
+ {{ t('settings', 'Reshare') }}
+ </NcCheckboxRadioSwitch>
+ </fieldset>
+</template>
+
+<script lang="ts">
+import { translate } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
+export default defineComponent({
+ name: 'SelectSharingPermissions',
+ components: {
+ NcCheckboxRadioSwitch,
+ },
+ props: {
+ value: {
+ type: Number,
+ required: true,
+ },
+ },
+ emits: {
+ 'update:value': (value: number) => typeof value === 'number',
+ },
+ data() {
+ return {
+ PERMISSION_UPDATE: 2,
+ PERMISSION_CREATE: 4,
+ PERMISSION_DELETE: 8,
+ PERMISSION_SHARE: 16,
+ }
+ },
+ computed: {
+ canCreate() {
+ return (this.value & this.PERMISSION_CREATE) !== 0
+ },
+ canUpdate() {
+ return (this.value & this.PERMISSION_UPDATE) !== 0
+ },
+ canDelete() {
+ return (this.value & this.PERMISSION_DELETE) !== 0
+ },
+ canShare() {
+ return (this.value & this.PERMISSION_SHARE) !== 0
+ },
+ },
+ methods: {
+ t: translate,
+ /**
+ * Toggle a permission
+ * @param permission The permission (bit) to toggle
+ */
+ toggle(permission: number) {
+ // xor to toggle the bit
+ this.$emit('update:value', this.value ^ permission)
+ },
+ },
+})
+</script>
+
+<style scoped>
+.permissions-select {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+</style>
diff --git a/apps/settings/src/components/SvgFilterMixin.vue b/apps/settings/src/components/SvgFilterMixin.vue
index 228b574f3c4..004ab7b1857 100644
--- a/apps/settings/src/components/SvgFilterMixin.vue
+++ b/apps/settings/src/components/SvgFilterMixin.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<script>
export default {
@@ -34,7 +17,7 @@ export default {
},
},
mounted() {
- this.filterId = 'invertIconApps' + Math.floor((Math.random() * 100)) + new Date().getSeconds() + new Date().getMilliseconds()
+ this.filterId = 'invertIconApps-' + Math.random().toString(36).substring(2)
},
}
</script>
diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue
index 4d50da62596..459548fad26 100644
--- a/apps/settings/src/components/UserList.vue
+++ b/apps/settings/src/components/UserList.vue
@@ -1,285 +1,112 @@
<!--
- - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div id="app-content" class="user-list-grid" @scroll.passive="onScroll">
- <Modal v-if="showConfig.showNewUserForm" size="small" @close="closeModal">
- <form id="new-user"
- :disabled="loading.all"
- class="modal__content"
- @submit.prevent="createUser">
- <h2>{{ t('settings','New user') }}</h2>
- <input id="newusername"
- ref="newusername"
- v-model="newUser.id"
- :disabled="settings.newUserGenerateUserID"
- :placeholder="settings.newUserGenerateUserID
- ? t('settings', 'Will be autogenerated')
- : t('settings', 'Username')"
- autocapitalize="none"
- autocomplete="off"
- autocorrect="off"
- class="modal__item"
- name="username"
- pattern="[a-zA-Z0-9 _\.@\-']+"
- required
- type="text">
- <input id="newdisplayname"
- v-model="newUser.displayName"
- :placeholder="t('settings', 'Display name')"
- autocapitalize="none"
- autocomplete="off"
- autocorrect="off"
- class="modal__item"
- name="displayname"
- type="text">
- <input id="newuserpassword"
- ref="newuserpassword"
- v-model="newUser.password"
- :minlength="minPasswordLength"
- :placeholder="t('settings', 'Password')"
- :required="newUser.mailAddress===''"
- autocapitalize="none"
- autocomplete="new-password"
- autocorrect="off"
- class="modal__item"
- name="password"
- type="password">
- <input id="newemail"
- v-model="newUser.mailAddress"
- :placeholder="t('settings', 'Email')"
- :required="newUser.password==='' || settings.newUserRequireEmail"
- autocapitalize="none"
- autocomplete="off"
- autocorrect="off"
- class="modal__item"
- name="email"
- type="email">
- <div class="groups modal__item">
- <!-- hidden input trick for vanilla html5 form validation -->
- <input v-if="!settings.isAdmin"
- id="newgroups"
- :class="{'icon-loading-small': loading.groups}"
- :required="!settings.isAdmin"
- :value="newUser.groups"
- tabindex="-1"
- type="text">
- <Multiselect v-model="newUser.groups"
- :close-on-select="false"
- :disabled="loading.groups||loading.all"
- :multiple="true"
- :options="canAddGroups"
- :placeholder="t('settings', 'Add user to group')"
- :tag-width="60"
- :taggable="true"
- class="multiselect-vue"
- label="name"
- tag-placeholder="create"
- track-by="id"
- @tag="createGroup">
- <!-- If user is not admin, he is a subadmin.
- Subadmins can't create users outside their groups
- Therefore, empty select is forbidden -->
- <span slot="noResult">{{ t('settings', 'No results') }}</span>
- </Multiselect>
- </div>
- <div v-if="subAdminsGroups.length>0 && settings.isAdmin"
- class="subadmins modal__item">
- <Multiselect v-model="newUser.subAdminsGroups"
- :close-on-select="false"
- :multiple="true"
- :options="subAdminsGroups"
- :placeholder="t('settings', 'Set user as admin for')"
- :tag-width="60"
- class="multiselect-vue"
- label="name"
- track-by="id">
- <span slot="noResult">{{ t('settings', 'No results') }}</span>
- </Multiselect>
- </div>
- <div class="quota modal__item">
- <Multiselect v-model="newUser.quota"
- :allow-empty="false"
- :options="quotaOptions"
- :placeholder="t('settings', 'Select user quota')"
- :taggable="true"
- class="multiselect-vue"
- label="label"
- track-by="id"
- @tag="validateQuota" />
- </div>
- <div v-if="showConfig.showLanguages" class="languages modal__item">
- <Multiselect v-model="newUser.language"
- :allow-empty="false"
- :options="languages"
- :placeholder="t('settings', 'Default language')"
- class="multiselect-vue"
- group-label="label"
- group-values="languages"
- label="name"
- track-by="code" />
- </div>
- <div v-if="showConfig.showStoragePath" class="storageLocation" />
- <div v-if="showConfig.showUserBackend" class="userBackend" />
- <div v-if="showConfig.showLastLogin" class="lastLogin" />
- <div class="user-actions">
- <Button id="newsubmit"
- type="primary"
- native-type="submit"
- value="">
- {{ t('settings', 'Add a new user') }}
- </Button>
- </div>
- </form>
- </Modal>
- <div id="grid-header"
- :class="{'sticky': scrolled && !showConfig.showNewUserForm}"
- class="row">
- <div id="headerAvatar" class="avatar" />
- <div id="headerName" class="name">
- {{ t('settings', 'Username') }}
-
- <div class="subtitle">
- {{ t('settings', 'Display name') }}
- </div>
- </div>
- <div id="headerPassword" class="password">
- {{ t('settings', 'Password') }}
- </div>
- <div id="headerAddress" class="mailAddress">
- {{ t('settings', 'Email') }}
- </div>
- <div id="headerGroups" class="groups">
- {{ t('settings', 'Groups') }}
- </div>
- <div v-if="subAdminsGroups.length>0 && settings.isAdmin"
- id="headerSubAdmins"
- class="subadmins">
- {{ t('settings', 'Group admin for') }}
- </div>
- <div id="headerQuota" class="quota">
- {{ t('settings', 'Quota') }}
- </div>
- <div v-if="showConfig.showLanguages"
- id="headerLanguages"
- class="languages">
- {{ t('settings', 'Language') }}
- </div>
-
- <div v-if="showConfig.showUserBackend || showConfig.showStoragePath"
- class="headerUserBackend userBackend">
- <div v-if="showConfig.showUserBackend" class="userBackend">
- {{ t('settings', 'User backend') }}
- </div>
- <div v-if="showConfig.showStoragePath"
- class="subtitle storageLocation">
- {{ t('settings', 'Storage location') }}
- </div>
- </div>
- <div v-if="showConfig.showLastLogin"
- class="headerLastLogin lastLogin">
- {{ t('settings', 'Last login') }}
- </div>
-
- <div class="userActions" />
- </div>
-
- <user-row v-for="user in filteredUsers"
- :key="user.id"
- :external-actions="externalActions"
- :groups="groups"
- :languages="languages"
+ <Fragment>
+ <NewUserDialog v-if="showConfig.showNewUserForm"
+ :loading="loading"
+ :new-user="newUser"
:quota-options="quotaOptions"
- :settings="settings"
- :show-config="showConfig"
- :sub-admins-groups="subAdminsGroups"
- :user="user" />
- <InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler">
- <div slot="spinner">
- <div class="users-icon-loading icon-loading" />
- </div>
- <div slot="no-more">
- <div class="users-list-end" />
- </div>
- <div slot="no-results">
- <div id="emptycontent">
- <div class="icon-contacts-dark" />
- <h2>{{ t('settings', 'No users in here') }}</h2>
- </div>
- </div>
- </InfiniteLoading>
- </div>
+ @reset="resetForm"
+ @closing="closeDialog" />
+
+ <NcEmptyContent v-if="filteredUsers.length === 0"
+ class="empty"
+ :name="isInitialLoad && loading.users ? null : t('settings', 'No accounts')">
+ <template #icon>
+ <NcLoadingIcon v-if="isInitialLoad && loading.users"
+ :name="t('settings', 'Loading accounts …')"
+ :size="64" />
+ <NcIconSvgWrapper v-else :path="mdiAccountGroupOutline" :size="64" />
+ </template>
+ </NcEmptyContent>
+
+ <VirtualList v-else
+ :data-component="UserRow"
+ :data-sources="filteredUsers"
+ data-key="id"
+ data-cy-user-list
+ :item-height="rowHeight"
+ :style="style"
+ :extra-props="{
+ users,
+ settings,
+ hasObfuscated,
+ quotaOptions,
+ languages,
+ externalActions,
+ }"
+ @scroll-end="handleScrollEnd">
+ <template #before>
+ <caption class="hidden-visually">
+ {{ t('settings', 'List of accounts. This list is not fully rendered for performance reasons. The accounts will be rendered as you navigate through the list.') }}
+ </caption>
+ </template>
+
+ <template #header>
+ <UserListHeader :has-obfuscated="hasObfuscated" />
+ </template>
+
+ <template #footer>
+ <UserListFooter :loading="loading.users"
+ :filtered-users="filteredUsers" />
+ </template>
+ </VirtualList>
+ </Fragment>
</template>
<script>
+import { mdiAccountGroupOutline } from '@mdi/js'
+import { showError } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import InfiniteLoading from 'vue-infinite-loading'
+import { Fragment } from 'vue-frag'
+
import Vue from 'vue'
-import Modal from '@nextcloud/vue/dist/Components/Modal'
-import Button from '@nextcloud/vue/dist/Components/Button'
-import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
-import userRow from './UserList/UserRow'
+import VirtualList from './Users/VirtualList.vue'
+import NewUserDialog from './Users/NewUserDialog.vue'
+import UserListFooter from './Users/UserListFooter.vue'
+import UserListHeader from './Users/UserListHeader.vue'
+import UserRow from './Users/UserRow.vue'
-const unlimitedQuota = {
- id: 'none',
- label: t('settings', 'Unlimited'),
-}
-const defaultQuota = {
- id: 'default',
- label: t('settings', 'Default quota'),
-}
-const newUser = {
+import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts'
+import logger from '../logger.ts'
+
+const newUser = Object.freeze({
id: '',
displayName: '',
password: '',
mailAddress: '',
groups: [],
+ manager: '',
subAdminsGroups: [],
quota: defaultQuota,
language: {
code: 'en',
name: t('settings', 'Default language'),
},
-}
+})
export default {
name: 'UserList',
+
components: {
- Modal,
- userRow,
- Multiselect,
- InfiniteLoading,
- Button,
+ Fragment,
+ NcEmptyContent,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ NewUserDialog,
+ UserListFooter,
+ UserListHeader,
+ VirtualList,
},
+
props: {
- users: {
- type: Array,
- default: () => [],
- },
- showConfig: {
- type: Object,
- required: true,
- },
selectedGroup: {
type: String,
default: null,
@@ -289,56 +116,65 @@ export default {
default: () => [],
},
},
+
+ setup() {
+ // non reactive properties
+ return {
+ mdiAccountGroupOutline,
+ rowHeight: 55,
+
+ UserRow,
+ }
+ },
+
data() {
return {
- unlimitedQuota,
- defaultQuota,
loading: {
all: false,
groups: false,
+ users: false,
},
- scrolled: false,
+ newUser: { ...newUser },
+ isInitialLoad: true,
searchQuery: '',
- newUser: Object.assign({}, newUser),
}
},
+
computed: {
+ showConfig() {
+ return this.$store.getters.getShowConfig
+ },
+
settings() {
return this.$store.getters.getServerData
},
- selectedGroupDecoded() {
- return decodeURIComponent(this.selectedGroup)
+
+ style() {
+ return {
+ '--row-height': `${this.rowHeight}px`,
+ }
+ },
+
+ hasObfuscated() {
+ return this.filteredUsers.some(user => isObfuscated(user))
+ },
+
+ users() {
+ return this.$store.getters.getUsers
},
+
filteredUsers() {
if (this.selectedGroup === 'disabled') {
return this.users.filter(user => user.enabled === false)
}
- if (!this.settings.isAdmin) {
- // we don't want subadmins to edit themselves
- return this.users.filter(user => user.enabled !== false)
- }
return this.users.filter(user => user.enabled !== false)
},
+
groups() {
- // data provided php side + remove the disabled group
- return this.$store.getters.getGroups
- .filter(group => group.id !== 'disabled')
- .sort((a, b) => a.name.localeCompare(b.name))
- },
- 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
- })
- },
- subAdminsGroups() {
- // data provided php side
- return this.$store.getters.getSubadminGroups
+ return this.$store.getters.getSortedGroups
+ .filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
},
+
quotaOptions() {
// convert the preset array into objects
const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({
@@ -347,20 +183,28 @@ export default {
}), [])
// add default presets
if (this.settings.allowUnlimitedQuota) {
- quotaPreset.unshift(this.unlimitedQuota)
+ quotaPreset.unshift(unlimitedQuota)
}
- quotaPreset.unshift(this.defaultQuota)
+ quotaPreset.unshift(defaultQuota)
return quotaPreset
},
- minPasswordLength() {
- return this.$store.getters.getPasswordPolicyMinLength
- },
+
usersOffset() {
return this.$store.getters.getUsersOffset
},
+
usersLimit() {
return this.$store.getters.getUsersLimit
},
+
+ disabledUsersOffset() {
+ return this.$store.getters.getDisabledUsersOffset
+ },
+
+ disabledUsersLimit() {
+ return this.$store.getters.getDisabledUsersLimit
+ },
+
usersCount() {
return this.users.length
},
@@ -379,32 +223,28 @@ export default {
]
},
},
+
watch: {
// watch url change and group select
- selectedGroup(val, old) {
+ async selectedGroup(val) {
+ this.isInitialLoad = true
// if selected is the disabled group but it's empty
- this.redirectIfDisabled()
+ await this.redirectIfDisabled()
this.$store.commit('resetUsers')
- this.$refs.infiniteLoading.stateChanger.reset()
+ await this.loadUsers()
this.setNewUserDefaultGroup(val)
},
- // make sure the infiniteLoading state is changed if we manually
- // add/remove data from the store
- usersCount(val, old) {
- // deleting the last user, reset the list
- if (val === 0 && old === 1) {
- this.$refs.infiniteLoading.stateChanger.reset()
- // adding the first user, warn the infiniteLoader that
- // the list is not empty anymore (we don't fetch the newly
- // added user as we already have all the info we need)
- } else if (val === 1 && old === 0) {
- this.$refs.infiniteLoading.stateChanger.loaded()
- }
+ filteredUsers(filteredUsers) {
+ logger.debug(`${filteredUsers.length} filtered user(s)`)
},
},
- mounted() {
+ async created() {
+ await this.loadUsers()
+ },
+
+ async mounted() {
if (!this.settings.canChangePassword) {
OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled'))
}
@@ -423,61 +263,64 @@ export default {
/**
* If disabled group but empty, redirect
*/
- this.redirectIfDisabled()
+ await this.redirectIfDisabled()
},
+
beforeDestroy() {
unsubscribe('nextcloud:unified-search.search', this.search)
unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
},
methods: {
- onScroll(event) {
- this.scrolled = event.target.scrollTo > 0
+ async handleScrollEnd() {
+ await this.loadUsers()
},
- /**
- * Validate quota string to make sure it's a valid human file size
- *
- * @param {string} quota Quota in readable format '5 GB'
- * @return {object}
- */
- validateQuota(quota) {
- // only used for new presets sent through @Tag
- const validQuota = OC.Util.computerFileSize(quota)
- if (validQuota !== null && validQuota >= 0) {
- // unify format output
- quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota))
- this.newUser.quota = { id: quota, label: quota }
- return this.newUser.quota
+ async loadUsers() {
+ this.loading.users = true
+ try {
+ if (this.selectedGroup === 'disabled') {
+ await this.$store.dispatch('getDisabledUsers', {
+ offset: this.disabledUsersOffset,
+ limit: this.disabledUsersLimit,
+ search: this.searchQuery,
+ })
+ } else if (this.selectedGroup === '__nc_internal_recent') {
+ await this.$store.dispatch('getRecentUsers', {
+ offset: this.usersOffset,
+ limit: this.usersLimit,
+ search: this.searchQuery,
+ })
+ } else {
+ await this.$store.dispatch('getUsers', {
+ offset: this.usersOffset,
+ limit: this.usersLimit,
+ group: this.selectedGroup,
+ search: this.searchQuery,
+ })
+ }
+ logger.debug(`${this.users.length} total user(s) loaded`)
+ } catch (error) {
+ logger.error('Failed to load accounts', { error })
+ showError('Failed to load accounts')
}
- // Default is unlimited
- this.newUser.quota = this.quotaOptions[0]
- return this.quotaOptions[0]
+ this.loading.users = false
+ this.isInitialLoad = false
},
- infiniteHandler($state) {
- this.$store.dispatch('getUsers', {
- offset: this.usersOffset,
- limit: this.usersLimit,
- group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '',
- search: this.searchQuery,
+ closeDialog() {
+ this.$store.commit('setShowConfig', {
+ key: 'showNewUserForm',
+ value: false,
})
- .then((usersCount) => {
- if (usersCount > 0) {
- $state.loaded()
- }
- if (usersCount < this.usersLimit) {
- $state.complete()
- }
- })
},
- /* SEARCH */
- search({ query }) {
+ async search({ query }) {
this.searchQuery = query
this.$store.commit('resetUsers')
- this.$refs.infiniteLoading.stateChanger.reset()
+ await this.loadUsers()
},
+
resetSearch() {
this.search({ query: '' })
},
@@ -503,40 +346,21 @@ export default {
this.loading.all = false
},
- createUser() {
- this.loading.all = true
- this.$store.dispatch('addUser', {
- userid: this.newUser.id,
- password: this.newUser.password,
- displayName: this.newUser.displayName,
- email: this.newUser.mailAddress,
- groups: this.newUser.groups.map(group => group.id),
- subadmin: this.newUser.subAdminsGroups.map(group => group.id),
- quota: this.newUser.quota.id,
- language: this.newUser.language.code,
- })
- .then(() => {
- this.resetForm()
- this.$refs.newusername.focus()
- this.closeModal()
- })
- .catch((error) => {
- this.loading.all = false
- if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) {
- const statuscode = error.response.data.ocs.meta.statuscode
- if (statuscode === 102) {
- // wrong username
- this.$refs.newusername.focus()
- } else if (statuscode === 107) {
- // wrong password
- this.$refs.newuserpassword.focus()
- }
- }
- })
- },
+
setNewUserDefaultGroup(value) {
- if (value && value.length > 0) {
- // setting new user default group to the current selected one
+ // 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 (groups.length === 1) {
+ this.newUser.groups = [...groups]
+ }
+ return
+ }
+
+ if (value) {
+ // setting new account default group to the current selected one
const currentGroup = this.groups.find(group => group.id === value)
if (currentGroup) {
this.newUser.groups = [currentGroup]
@@ -548,91 +372,38 @@ export default {
},
/**
- * Create a new group
- *
- * @param {string} gid Group id
- * @return {Promise}
- */
- createGroup(gid) {
- this.loading.groups = true
- this.$store.dispatch('addGroup', gid)
- .then((group) => {
- this.newUser.groups.push(this.groups.find(group => group.id === gid))
- this.loading.groups = false
- })
- .catch(() => {
- this.loading.groups = false
- })
- return this.$store.getters.getGroups[this.groups.length]
- },
-
- /**
* If the selected group is the disabled group but the count is 0
* redirect to the all users page.
* we only check for 0 because we don't have the count on ldap
* and we therefore set the usercount to -1 in this specific case
*/
- redirectIfDisabled() {
+ async redirectIfDisabled() {
const allGroups = this.$store.getters.getGroups
if (this.selectedGroup === 'disabled'
&& allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) {
// disabled group is empty, redirection to all users
this.$router.push({ name: 'users' })
- this.$refs.infiniteLoading.stateChanger.reset()
+ await this.loadUsers()
}
},
- closeModal() {
- // eslint-disable-next-line vue/no-mutating-props
- this.showConfig.showNewUserForm = false
- },
},
}
</script>
-<style scoped>
- .modal-wrapper {
- margin: 2vh 0;
- align-items: flex-start;
- }
- .modal__content {
- display: flex;
- padding: 20px;
- flex-direction: column;
- align-items: center;
- text-align: center;
- }
- .modal__item {
- margin-bottom: 16px;
- width: 100%;
- }
- .modal__item:not(:focus):not(:active) {
- border-color: var(--color-border-dark);
- }
- .modal__item::v-deep .multiselect {
- width: 100%;
- }
- .user-actions {
- margin-top: 20px;
- }
- .modal__content::v-deep .multiselect__single {
- text-align: left;
- box-sizing: border-box;
- }
- .modal__content::v-deep .multiselect__content-wrapper {
- box-sizing: border-box;
- }
- .row::v-deep .multiselect__single {
- z-index: auto !important;
- }
- /* fake input for groups validation */
- input#newgroups {
- position: absolute;
- opacity: 0;
- /* The "hidden" input is behind the Multiselect, so in general it does
- * not receives clicks. However, with Firefox, after the validation
- * fails, it will receive the first click done on it, so its width needs
- * to be set to 0 to prevent that ("pointer-events: none" does not
- * prevent it). */
- width: 0;
+<style lang="scss" scoped>
+@use './Users/shared/styles' as *;
+
+.empty {
+ :deep {
+ .icon-vue {
+ width: 64px;
+ height: 64px;
+
+ svg {
+ max-width: 64px;
+ max-height: 64px;
+ }
+ }
}
+}
</style>
diff --git a/apps/settings/src/components/UserList/UserRow.vue b/apps/settings/src/components/UserList/UserRow.vue
deleted file mode 100644
index de0a09f2221..00000000000
--- a/apps/settings/src/components/UserList/UserRow.vue
+++ /dev/null
@@ -1,688 +0,0 @@
-<!--
- - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- - @author Gary Kim <gary@garykim.dev>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
-<template>
- <!-- Obfuscated user: Logged in user does not have permissions to see all of the data -->
- <div v-if="Object.keys(user).length ===1" :data-id="user.id" class="row">
- <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"
- class="avatar">
- <img v-if="!loading.delete && !loading.disable && !loading.wipe"
- :src="generateAvatar(user.id, 32)"
- :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"
- alt=""
- height="32"
- width="32">
- </div>
- <div class="name">
- {{ user.id }}
- </div>
- <div class="obfuscated">
- {{ t('settings','You do not have permissions to see the details of this user') }}
- </div>
- </div>
-
- <!-- User full data -->
- <UserRowSimple v-else-if="!editing"
- :editing.sync="editing"
- :feedback-message="feedbackMessage"
- :groups="groups"
- :languages="languages"
- :loading="loading"
- :opened-menu.sync="openedMenu"
- :settings="settings"
- :show-config="showConfig"
- :sub-admins-groups="subAdminsGroups"
- :user-actions="userActions"
- :user="user"
- :class="{'row--menu-opened': openedMenu}" />
- <div v-else
- :class="{
- 'disabled': loading.delete || loading.disable,
- 'row--menu-opened': openedMenu
- }"
- :data-id="user.id"
- class="row row--editable">
- <div :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"
- class="avatar">
- <img v-if="!loading.delete && !loading.disable && !loading.wipe"
- :src="generateAvatar(user.id, 32)"
- :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"
- alt=""
- height="32"
- width="32">
- </div>
- <!-- dirty hack to ellipsis on two lines -->
- <div v-if="user.backendCapabilities.setDisplayName" class="displayName">
- <form :class="{'icon-loading-small': loading.displayName}"
- class="displayName"
- @submit.prevent="updateDisplayName">
- <input :id="'displayName'+user.id+rand"
- ref="displayName"
- :disabled="loading.displayName||loading.all"
- :value="user.displayname"
- autocapitalize="off"
- autocomplete="off"
- autocorrect="off"
- spellcheck="false"
- type="text">
- <input class="icon-confirm"
- type="submit"
- value="">
- </form>
- </div>
- <div v-else class="name">
- {{ user.id }}
- <div class="displayName subtitle">
- <div v-tooltip="user.displayname.length > 20 ? user.displayname : ''" class="cellText">
- {{ user.displayname }}
- </div>
- </div>
- </div>
- <form v-if="settings.canChangePassword && user.backendCapabilities.setPassword"
- :class="{'icon-loading-small': loading.password}"
- class="password"
- @submit.prevent="updatePassword">
- <input :id="'password'+user.id+rand"
- ref="password"
- :disabled="loading.password || loading.all"
- :minlength="minPasswordLength"
- :placeholder="t('settings', 'Add new password')"
- autocapitalize="off"
- autocomplete="new-password"
- autocorrect="off"
- required
- spellcheck="false"
- type="password"
- value="">
- <input class="icon-confirm" type="submit" value="">
- </form>
- <div v-else />
- <form :class="{'icon-loading-small': loading.mailAddress}"
- class="mailAddress"
- @submit.prevent="updateEmail">
- <input :id="'mailAddress'+user.id+rand"
- ref="mailAddress"
- :disabled="loading.mailAddress||loading.all"
- :placeholder="t('settings', 'Add new email address')"
- :value="user.email"
- autocapitalize="off"
- autocomplete="new-password"
- autocorrect="off"
- spellcheck="false"
- type="email">
- <input class="icon-confirm" type="submit" value="">
- </form>
- <div :class="{'icon-loading-small': loading.groups}" class="groups">
- <Multiselect :close-on-select="false"
- :disabled="loading.groups||loading.all"
- :limit="2"
- :multiple="true"
- :options="availableGroups"
- :placeholder="t('settings', 'Add user to group')"
- :tag-width="60"
- :taggable="settings.isAdmin"
- :value="userGroups"
- class="multiselect-vue"
- label="name"
- tag-placeholder="create"
- track-by="id"
- @remove="removeUserGroup"
- @select="addUserGroup"
- @tag="createGroup">
- <span slot="noResult">{{ t('settings', 'No results') }}</span>
- </Multiselect>
- </div>
- <div v-if="subAdminsGroups.length>0 && settings.isAdmin"
- :class="{'icon-loading-small': loading.subadmins}"
- class="subadmins">
- <Multiselect :close-on-select="false"
- :disabled="loading.subadmins||loading.all"
- :limit="2"
- :multiple="true"
- :options="subAdminsGroups"
- :placeholder="t('settings', 'Set user as admin for')"
- :tag-width="60"
- :value="userSubAdminsGroups"
- class="multiselect-vue"
- label="name"
- track-by="id"
- @remove="removeUserSubAdmin"
- @select="addUserSubAdmin">
- <span slot="noResult">{{ t('settings', 'No results') }}</span>
- </Multiselect>
- </div>
- <div v-tooltip.auto="usedSpace"
- :class="{'icon-loading-small': loading.quota}"
- class="quota">
- <Multiselect :allow-empty="false"
- :disabled="loading.quota||loading.all"
- :options="quotaOptions"
- :placeholder="t('settings', 'Select user quota')"
- :taggable="true"
- :value="userQuota"
- class="multiselect-vue"
- label="label"
- tag-placeholder="create"
- track-by="id"
- @input="setUserQuota"
- @tag="validateQuota" />
- </div>
- <div v-if="showConfig.showLanguages"
- :class="{'icon-loading-small': loading.languages}"
- class="languages">
- <Multiselect :allow-empty="false"
- :disabled="loading.languages||loading.all"
- :options="languages"
- :placeholder="t('settings', 'No language set')"
- :value="userLanguage"
- class="multiselect-vue"
- group-label="label"
- group-values="languages"
- label="name"
- track-by="code"
- @input="setUserLanguage" />
- </div>
-
- <!-- don't show this on edit mode -->
- <div v-if="showConfig.showStoragePath || showConfig.showUserBackend"
- class="storageLocation" />
- <div v-if="showConfig.showLastLogin" />
-
- <div class="userActions">
- <div v-if="!loading.all"
- class="toggleUserActions">
- <Actions>
- <ActionButton icon="icon-checkmark"
- @click="editing = false">
- {{ t('settings', 'Done') }}
- </ActionButton>
- </Actions>
- <div v-click-outside="hideMenu" class="userPopoverMenuWrapper">
- <button class="icon-more"
- @click.prevent="toggleMenu" />
- <div :class="{ 'open': openedMenu }" class="popovermenu">
- <PopoverMenu :menu="userActions" />
- </div>
- </div>
- </div>
- <div :style="{opacity: feedbackMessage !== '' ? 1 : 0}"
- class="feedback">
- <div class="icon-checkmark" />
- {{ feedbackMessage }}
- </div>
- </div>
- </div>
-</template>
-
-<script>
-import ClickOutside from 'vue-click-outside'
-import Vue from 'vue'
-import VTooltip from 'v-tooltip'
-import {
- PopoverMenu,
- Multiselect,
- Actions,
- ActionButton,
-} from '@nextcloud/vue'
-import UserRowSimple from './UserRowSimple'
-import UserRowMixin from '../../mixins/UserRowMixin'
-
-Vue.use(VTooltip)
-
-export default {
- name: 'UserRow',
- components: {
- UserRowSimple,
- PopoverMenu,
- Actions,
- ActionButton,
- Multiselect,
- },
- directives: {
- ClickOutside,
- },
- mixins: [UserRowMixin],
- props: {
- user: {
- type: Object,
- required: true,
- },
- settings: {
- type: Object,
- default: () => ({}),
- },
- groups: {
- type: Array,
- default: () => [],
- },
- subAdminsGroups: {
- type: Array,
- default: () => [],
- },
- quotaOptions: {
- type: Array,
- default: () => [],
- },
- showConfig: {
- type: Object,
- default: () => ({}),
- },
- languages: {
- type: Array,
- required: true,
- },
- externalActions: {
- type: Array,
- default: () => [],
- },
- },
- data() {
- return {
- rand: parseInt(Math.random() * 1000),
- openedMenu: false,
- feedbackMessage: '',
- editing: false,
- loading: {
- all: false,
- displayName: false,
- password: false,
- mailAddress: false,
- groups: false,
- subadmins: false,
- quota: false,
- delete: false,
- disable: false,
- languages: false,
- wipe: false,
- },
- }
- },
- computed: {
- /* USER POPOVERMENU ACTIONS */
- userActions() {
- const actions = [
- {
- icon: 'icon-delete',
- text: t('settings', 'Delete user'),
- action: this.deleteUser,
- },
- {
- icon: 'icon-delete',
- text: t('settings', 'Wipe all devices'),
- action: this.wipeUserDevices,
- },
- {
- icon: this.user.enabled ? 'icon-close' : 'icon-add',
- text: this.user.enabled ? t('settings', 'Disable user') : t('settings', 'Enable user'),
- action: this.enableDisableUser,
- },
- ]
- if (this.user.email !== null && this.user.email !== '') {
- actions.push({
- icon: 'icon-mail',
- text: t('settings', 'Resend welcome email'),
- action: this.sendWelcomeMail,
- })
- }
- return actions.concat(this.externalActions)
- },
- },
-
- methods: {
- /* MENU HANDLING */
- toggleMenu() {
- this.openedMenu = !this.openedMenu
- },
- hideMenu() {
- this.openedMenu = false
- },
-
- wipeUserDevices() {
- const userid = this.user.id
- OC.dialogs.confirmDestructive(
- t('settings', 'In case of lost device or exiting the organization, this can remotely wipe the Nextcloud data from all devices associated with {userid}. Only works if the devices are connected to the internet.', { userid }),
- t('settings', 'Remote wipe of devices'),
- {
- type: OC.dialogs.YES_NO_BUTTONS,
- confirm: t('settings', 'Wipe {userid}\'s devices', { userid }),
- confirmClasses: 'error',
- cancel: t('settings', 'Cancel'),
- },
- (result) => {
- if (result) {
- this.loading.wipe = true
- this.loading.all = true
- this.$store.dispatch('wipeUserDevices', userid)
- .then(() => {
- this.loading.wipe = false
- this.loading.all = false
- })
- }
- },
- true
- )
- },
-
- deleteUser() {
- const userid = this.user.id
- OC.dialogs.confirmDestructive(
- t('settings', 'Fully delete {userid}\'s account including all their personal files, app data, etc.', { userid }),
- t('settings', 'Account deletion'),
- {
- type: OC.dialogs.YES_NO_BUTTONS,
- confirm: t('settings', 'Delete {userid}\'s account', { userid }),
- confirmClasses: 'error',
- cancel: t('settings', 'Cancel'),
- },
- (result) => {
- if (result) {
- this.loading.delete = true
- this.loading.all = true
- return this.$store.dispatch('deleteUser', userid)
- .then(() => {
- this.loading.delete = false
- this.loading.all = false
- })
- }
- },
- true
- )
- },
-
- enableDisableUser() {
- this.loading.delete = true
- this.loading.all = true
- const userid = this.user.id
- const enabled = !this.user.enabled
- return this.$store.dispatch('enableDisableUser', {
- userid,
- enabled,
- })
- .then(() => {
- this.loading.delete = false
- this.loading.all = false
- })
- },
-
- /**
- * Set user displayName
- *
- * @param {string} displayName The display name
- */
- updateDisplayName() {
- const displayName = this.$refs.displayName.value
- this.loading.displayName = true
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'displayname',
- value: displayName,
- }).then(() => {
- this.loading.displayName = false
- this.$refs.displayName.value = displayName
- })
- },
-
- /**
- * Set user password
- *
- * @param {string} password The email adress
- */
- updatePassword() {
- const password = this.$refs.password.value
- this.loading.password = true
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'password',
- value: password,
- }).then(() => {
- this.loading.password = false
- this.$refs.password.value = '' // empty & show placeholder
- })
- },
-
- /**
- * Set user mailAddress
- *
- * @param {string} mailAddress The email adress
- */
- updateEmail() {
- const mailAddress = this.$refs.mailAddress.value
- this.loading.mailAddress = true
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'email',
- value: mailAddress,
- }).then(() => {
- this.loading.mailAddress = false
- this.$refs.mailAddress.value = mailAddress
- })
- },
-
- /**
- * Create a new group and add user to it
- *
- * @param {string} gid Group id
- */
- async createGroup(gid) {
- this.loading = { groups: true, subadmins: true }
- try {
- await this.$store.dispatch('addGroup', gid)
- const userid = this.user.id
- await this.$store.dispatch('addUserGroup', { userid, gid })
- } catch (error) {
- console.error(error)
- } finally {
- this.loading = { groups: false, subadmins: false }
- }
- return this.$store.getters.getGroups[this.groups.length]
- },
-
- /**
- * Add user to group
- *
- * @param {object} group Group object
- */
- async addUserGroup(group) {
- if (group.canAdd === false) {
- return false
- }
- this.loading.groups = true
- const userid = this.user.id
- const gid = group.id
- try {
- await this.$store.dispatch('addUserGroup', { userid, gid })
- } catch (error) {
- console.error(error)
- } finally {
- this.loading.groups = false
- }
- },
-
- /**
- * Remove user from group
- *
- * @param {object} group Group object
- */
- async removeUserGroup(group) {
- if (group.canRemove === false) {
- return false
- }
-
- this.loading.groups = true
- const userid = this.user.id
- const gid = group.id
-
- try {
- await this.$store.dispatch('removeUserGroup', {
- userid,
- gid,
- })
- this.loading.groups = false
- // remove user from current list if current list is the removed group
- if (this.$route.params.selectedGroup === gid) {
- this.$store.commit('deleteUser', userid)
- }
- } catch {
- this.loading.groups = false
- }
- },
-
- /**
- * Add user to group
- *
- * @param {object} group Group object
- */
- async addUserSubAdmin(group) {
- this.loading.subadmins = true
- const userid = this.user.id
- const gid = group.id
-
- try {
- await this.$store.dispatch('addUserSubAdmin', {
- userid,
- gid,
- })
- this.loading.subadmins = false
- } catch (error) {
- console.error(error)
- }
- },
-
- /**
- * Remove user from group
- *
- * @param {object} group Group object
- */
- async removeUserSubAdmin(group) {
- this.loading.subadmins = true
- const userid = this.user.id
- const gid = group.id
-
- try {
- await this.$store.dispatch('removeUserSubAdmin', {
- userid,
- gid,
- })
- } catch (error) {
- console.error(error)
- } finally {
- this.loading.subadmins = false
- }
- },
-
- /**
- * Dispatch quota set request
- *
- * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
- * @return {string}
- */
- async setUserQuota(quota = 'none') {
- this.loading.quota = true
- // ensure we only send the preset id
- quota = quota.id ? quota.id : quota
-
- try {
- await this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'quota',
- value: quota,
- })
- } catch (error) {
- console.error(error)
- } finally {
- this.loading.quota = false
- }
- return quota
- },
-
- /**
- * Validate quota string to make sure it's a valid human file size
- *
- * @param {string} quota Quota in readable format '5 GB'
- * @return {Promise|boolean}
- */
- validateQuota(quota) {
- // only used for new presets sent through @Tag
- const validQuota = OC.Util.computerFileSize(quota)
- if (validQuota !== null && validQuota >= 0) {
- // unify format output
- return this.setUserQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota)))
- }
- // if no valid do not change
- return false
- },
-
- /**
- * Dispatch language set request
- *
- * @param {object} lang language object {code:'en', name:'English'}
- * @return {object}
- */
- async setUserLanguage(lang) {
- this.loading.languages = true
- // ensure we only send the preset id
- try {
- await this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'language',
- value: lang.code,
- })
- } catch (error) {
- console.error(error)
- } finally {
- this.loading.languages = false
- }
- return lang
- },
-
- /**
- * Dispatch new welcome mail request
- */
- sendWelcomeMail() {
- this.loading.all = true
- this.$store.dispatch('sendWelcomeMail', this.user.id)
- .then(success => {
- if (success) {
- // Show feedback to indicate the success
- this.feedbackMessage = t('setting', 'Welcome mail sent!')
- setTimeout(() => {
- this.feedbackMessage = ''
- }, 2000)
- }
- this.loading.all = false
- })
- },
-
- },
-}
-</script>
-<style scoped lang="scss">
- // Force menu to be above other rows
- .row--menu-opened {
- z-index: 1 !important;
- }
- .row::v-deep .multiselect__single {
- z-index: auto !important;
- }
-</style>
diff --git a/apps/settings/src/components/UserList/UserRowSimple.vue b/apps/settings/src/components/UserList/UserRowSimple.vue
deleted file mode 100644
index 50ed78a0bc7..00000000000
--- a/apps/settings/src/components/UserList/UserRowSimple.vue
+++ /dev/null
@@ -1,199 +0,0 @@
-<template>
- <div class="row"
- :class="{'disabled': loading.delete || loading.disable}"
- :data-id="user.id">
- <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}">
- <img v-if="!loading.delete && !loading.disable && !loading.wipe"
- alt=""
- width="32"
- height="32"
- :src="generateAvatar(user.id, 32)"
- :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'">
- </div>
- <!-- dirty hack to ellipsis on two lines -->
- <div class="name">
- {{ user.id }}
- <div class="displayName subtitle">
- <div v-tooltip="user.displayname.length > 20 ? user.displayname : ''" class="cellText">
- {{ user.displayname }}
- </div>
- </div>
- </div>
- <div />
- <div class="mailAddress">
- <div v-tooltip="user.email !== null && user.email.length > 20 ? user.email : ''" class="cellText">
- {{ user.email }}
- </div>
- </div>
- <div class="groups">
- {{ userGroupsLabels }}
- </div>
- <div v-if="subAdminsGroups.length > 0 && settings.isAdmin" class="subAdminsGroups">
- {{ userSubAdminsGroupsLabels }}
- </div>
- <div class="userQuota">
- <div class="quota">
- {{ userQuota }} ({{ usedSpace }})
- <progress class="quota-user-progress"
- :class="{'warn': usedQuota > 80}"
- :value="usedQuota"
- max="100" />
- </div>
- </div>
- <div v-if="showConfig.showLanguages" class="languages">
- {{ userLanguage.name }}
- </div>
- <div v-if="showConfig.showUserBackend || showConfig.showStoragePath" class="userBackend">
- <div v-if="showConfig.showUserBackend" class="userBackend">
- {{ user.backend }}
- </div>
- <div v-if="showConfig.showStoragePath" v-tooltip="user.storageLocation" class="storageLocation subtitle">
- {{ user.storageLocation }}
- </div>
- </div>
- <div v-if="showConfig.showLastLogin" v-tooltip.auto="userLastLoginTooltip" class="lastLogin">
- {{ userLastLogin }}
- </div>
-
- <div class="userActions">
- <div v-if="canEdit && !loading.all" class="toggleUserActions">
- <Actions>
- <ActionButton icon="icon-rename" @click="toggleEdit">
- {{ t('settings', 'Edit User') }}
- </ActionButton>
- </Actions>
- <div class="userPopoverMenuWrapper">
- <button v-click-outside="hideMenu"
- class="icon-more"
- :aria-label="t('settings', 'Toggle user actions menu')"
- @click.prevent="toggleMenu" />
- <div class="popovermenu" :class="{ 'open': openedMenu }" :aria-expanded="openedMenu">
- <PopoverMenu :menu="userActions" />
- </div>
- </div>
- </div>
- <div class="feedback" :style="{opacity: feedbackMessage !== '' ? 1 : 0}">
- <div class="icon-checkmark" />
- {{ feedbackMessage }}
- </div>
- </div>
- </div>
-</template>
-
-<script>
-import PopoverMenu from '@nextcloud/vue/dist/Components/PopoverMenu'
-import Actions from '@nextcloud/vue/dist/Components/Actions'
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
-import ClickOutside from 'vue-click-outside'
-import { getCurrentUser } from '@nextcloud/auth'
-import UserRowMixin from '../../mixins/UserRowMixin'
-export default {
- name: 'UserRowSimple',
- components: {
- PopoverMenu,
- ActionButton,
- Actions,
- },
- directives: {
- ClickOutside,
- },
- mixins: [UserRowMixin],
- props: {
- user: {
- type: Object,
- required: true,
- },
- loading: {
- type: Object,
- required: true,
- },
- showConfig: {
- type: Object,
- required: true,
- },
- userActions: {
- type: Array,
- required: true,
- },
- openedMenu: {
- type: Boolean,
- required: true,
- },
- feedbackMessage: {
- type: String,
- required: true,
- },
- subAdminsGroups: {
- type: Array,
- required: true,
- },
- settings: {
- type: Object,
- required: true,
- },
- },
- computed: {
- userGroupsLabels() {
- return this.userGroups
- .map(group => group.name)
- .join(', ')
- },
- userSubAdminsGroupsLabels() {
- return this.userSubAdminsGroups
- .map(group => group.name)
- .join(', ')
- },
- usedSpace() {
- if (this.user.quota.used) {
- return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota.used) })
- }
- return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) })
- },
- canEdit() {
- return getCurrentUser().uid !== this.user.id || this.settings.isAdmin
- },
- userQuota() {
- let quota = this.user.quota.quota
-
- if (quota === 'default') {
- quota = this.settings.defaultQuota
- if (quota !== 'none') {
- // convert to numeric value to match what the server would usually return
- quota = OC.Util.computerFileSize(quota)
- }
- }
-
- // when the default quota is unlimited, the server returns -3 here, map it to "none"
- if (quota === 'none' || quota === -3) {
- return t('settings', 'Unlimited')
- } else if (quota >= 0) {
- return OC.Util.humanFileSize(quota)
- }
- return OC.Util.humanFileSize(0)
- },
- },
- methods: {
- toggleMenu() {
- this.$emit('update:openedMenu', !this.openedMenu)
- },
- hideMenu() {
- this.$emit('update:openedMenu', false)
- },
- toggleEdit() {
- this.$emit('update:editing', true)
- },
- },
-}
-</script>
-
-<style lang="scss">
- .cellText {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
- .icon-more {
- background-color: var(--color-main-background);
- border: 0;
- }
-</style>
diff --git a/apps/settings/src/components/Users/NewUserDialog.vue b/apps/settings/src/components/Users/NewUserDialog.vue
new file mode 100644
index 00000000000..ef401b565fa
--- /dev/null
+++ b/apps/settings/src/components/Users/NewUserDialog.vue
@@ -0,0 +1,436 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcDialog class="dialog"
+ size="small"
+ :name="t('settings', 'New account')"
+ out-transition
+ v-on="$listeners">
+ <form id="new-user-form"
+ class="dialog__form"
+ data-test="form"
+ :disabled="loading.all"
+ @submit.prevent="createUser">
+ <NcTextField ref="username"
+ class="dialog__item"
+ data-test="username"
+ :value.sync="newUser.id"
+ :disabled="settings.newUserGenerateUserID"
+ :label="usernameLabel"
+ autocapitalize="none"
+ autocomplete="off"
+ spellcheck="false"
+ pattern="[a-zA-Z0-9 _\.@\-']+"
+ required />
+ <NcTextField class="dialog__item"
+ data-test="displayName"
+ :value.sync="newUser.displayName"
+ :label="t('settings', 'Display name')"
+ autocapitalize="none"
+ autocomplete="off"
+ spellcheck="false" />
+ <span v-if="!settings.newUserRequireEmail"
+ id="password-email-hint"
+ class="dialog__hint">
+ {{ t('settings', 'Either password or email is required') }}
+ </span>
+ <NcPasswordField ref="password"
+ class="dialog__item"
+ data-test="password"
+ :value.sync="newUser.password"
+ :minlength="minPasswordLength"
+ :maxlength="469"
+ aria-describedby="password-email-hint"
+ :label="newUser.mailAddress === '' ? t('settings', 'Password (required)') : t('settings', 'Password')"
+ autocapitalize="none"
+ autocomplete="new-password"
+ spellcheck="false"
+ :required="newUser.mailAddress === ''" />
+ <NcTextField class="dialog__item"
+ data-test="email"
+ type="email"
+ :value.sync="newUser.mailAddress"
+ aria-describedby="password-email-hint"
+ :label="newUser.password === '' || settings.newUserRequireEmail ? t('settings', 'Email (required)') : t('settings', 'Email')"
+ autocapitalize="none"
+ autocomplete="off"
+ spellcheck="false"
+ :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="availableGroups"
+ :value="newUser.groups"
+ label="name"
+ :close-on-select="false"
+ :multiple="true"
+ :taggable="settings.isAdmin || settings.isDelegatedAdmin"
+ :required="!settings.isAdmin && !settings.isDelegatedAdmin"
+ :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 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 …')"
+ :disabled="loading.groups || loading.all"
+ :options="availableGroups"
+ :close-on-select="false"
+ :multiple="true"
+ label="name"
+ @search="searchGroups" />
+ </div>
+ <div class="dialog__item">
+ <NcSelect v-model="newUser.quota"
+ class="dialog__select"
+ :input-label="t('settings', 'Quota')"
+ :placeholder="t('settings', 'Set account quota')"
+ :options="quotaOptions"
+ :clearable="false"
+ :taggable="true"
+ :create-option="validateQuota" />
+ </div>
+ <div v-if="showConfig.showLanguages"
+ class="dialog__item">
+ <NcSelect v-model="newUser.language"
+ class="dialog__select"
+ :input-label="t('settings', 'Language')"
+ :placeholder="t('settings', 'Set default language')"
+ :clearable="false"
+ :selectable="option => !option.languages"
+ :filter-by="languageFilterBy"
+ :options="languages"
+ label="name" />
+ </div>
+ <div :class="['dialog__item dialog__managers', { 'icon-loading-small': loading.manager }]">
+ <NcSelect v-model="newUser.manager"
+ class="dialog__select"
+ :input-label="managerInputLabel"
+ :placeholder="managerLabel"
+ :options="possibleManagers"
+ :user-select="true"
+ label="displayname"
+ @search="searchUserManager" />
+ </div>
+ </form>
+
+ <template #actions>
+ <NcButton class="dialog__submit"
+ data-test="submit"
+ form="new-user-form"
+ type="primary"
+ native-type="submit">
+ {{ t('settings', 'Add new account') }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script>
+import { formatFileSize, parseFileSize } from '@nextcloud/files'
+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',
+
+ components: {
+ NcButton,
+ NcDialog,
+ NcPasswordField,
+ NcSelect,
+ NcTextField,
+ },
+
+ props: {
+ loading: {
+ type: Object,
+ required: true,
+ },
+
+ newUser: {
+ type: Object,
+ required: true,
+ },
+
+ quotaOptions: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ possibleManagers: [],
+ // TRANSLATORS This string describes a manager in the context of an organization
+ 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,
+ }
+ },
+
+ computed: {
+ showConfig() {
+ return this.$store.getters.getShowConfig
+ },
+
+ settings() {
+ return this.$store.getters.getServerData
+ },
+
+ usernameLabel() {
+ if (this.settings.newUserGenerateUserID) {
+ return t('settings', 'Account name will be autogenerated')
+ }
+ return t('settings', 'Account name (required)')
+ },
+
+ minPasswordLength() {
+ return this.$store.getters.getPasswordPolicyMinLength
+ },
+
+ 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')
+ },
+
+ languages() {
+ return [
+ {
+ name: t('settings', 'Common languages'),
+ languages: this.settings.languages.commonLanguages,
+ },
+ ...this.settings.languages.commonLanguages,
+ {
+ name: t('settings', 'Other languages'),
+ languages: this.settings.languages.otherLanguages,
+ },
+ ...this.settings.languages.otherLanguages,
+ ]
+ },
+ },
+
+ async beforeMount() {
+ await this.searchUserManager()
+ },
+
+ mounted() {
+ this.$refs.username?.focus?.()
+ },
+
+ methods: {
+ async createUser() {
+ this.loading.all = true
+ try {
+ await this.$store.dispatch('addUser', {
+ userid: this.newUser.id,
+ password: this.newUser.password,
+ displayName: this.newUser.displayName,
+ email: this.newUser.mailAddress,
+ groups: this.newUser.groups.map(group => group.id),
+ subadmin: this.newUser.subAdminsGroups.map(group => group.id),
+ quota: this.newUser.quota.id,
+ language: this.newUser.language.code,
+ manager: this.newUser.manager.id,
+ })
+
+ this.$emit('reset')
+ this.$refs.username?.focus?.()
+ this.$emit('closing')
+ } catch (error) {
+ this.loading.all = false
+ if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) {
+ const statuscode = error.response.data.ocs.meta.statuscode
+ if (statuscode === 102) {
+ // wrong username
+ this.$refs.username?.focus?.()
+ } else if (statuscode === 107) {
+ // wrong password
+ this.$refs.password?.focus?.()
+ }
+ }
+ }
+ },
+
+ 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)
+ },
+
+ /**
+ * Create a new group
+ *
+ * @param {any} group Group
+ * @param {string} group.name Group id
+ */
+ async createGroup({ name: gid }) {
+ this.loading.groups = true
+ try {
+ await this.$store.dispatch('addGroup', gid)
+ this.newUser.groups.push({ id: gid, name: gid })
+ } catch (error) {
+ 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)
+ },
+
+ /**
+ * Validate quota string to make sure it's a valid human file size
+ *
+ * @param {string} quota Quota in readable format '5 GB'
+ * @return {object}
+ */
+ validateQuota(quota) {
+ // only used for new presets sent through @Tag
+ const validQuota = OC.Util.computerFileSize(quota)
+ if (validQuota !== null && validQuota >= 0) {
+ // unify format output
+ quota = formatFileSize(parseFileSize(quota, true))
+ this.newUser.quota = { id: quota, label: quota }
+ return this.newUser.quota
+ }
+ // Default is unlimited
+ this.newUser.quota = this.quotaOptions[0]
+ return this.quotaOptions[0]
+ },
+
+ languageFilterBy(option, label, search) {
+ // Show group header of the language
+ if (option.languages) {
+ return option.languages.some(
+ ({ name }) => name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
+ )
+ }
+
+ return (label || '').toLocaleLowerCase().includes(search.toLocaleLowerCase())
+ },
+
+ async searchUserManager(query) {
+ await this.$store.dispatch(
+ 'searchUsers',
+ {
+ offset: 0,
+ limit: 10,
+ search: query,
+ },
+ ).then(response => {
+ const users = response?.data ? Object.values(response?.data.ocs.data.users) : []
+ if (users.length > 0) {
+ this.possibleManagers = users
+ }
+ })
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.dialog {
+ &__form {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0 8px;
+ gap: 4px 0;
+ }
+
+ &__item {
+ width: 100%;
+
+ &:not(:focus):not(:active) {
+ border-color: var(--color-border-dark);
+ }
+ }
+
+ &__hint {
+ color: var(--color-text-maxcontrast);
+ margin-top: 8px;
+ align-self: flex-start;
+ }
+
+ &__label {
+ display: block;
+ padding: 4px 0;
+ }
+
+ &__select {
+ width: 100%;
+ }
+
+ &__managers {
+ margin-bottom: 12px;
+ }
+
+ &__submit {
+ margin-top: 4px;
+ margin-bottom: 8px;
+ }
+
+ :deep {
+ .dialog__actions {
+ margin: auto;
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/Users/UserListFooter.vue b/apps/settings/src/components/Users/UserListFooter.vue
new file mode 100644
index 00000000000..bf9aa43b6d3
--- /dev/null
+++ b/apps/settings/src/components/Users/UserListFooter.vue
@@ -0,0 +1,112 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <tr class="footer">
+ <th scope="row">
+ <!-- TRANSLATORS Label for a table footer which summarizes the columns of the table -->
+ <span class="hidden-visually">{{ t('settings', 'Total rows summary') }}</span>
+ </th>
+ <td class="footer__cell footer__cell--loading">
+ <NcLoadingIcon v-if="loading"
+ :title="t('settings', 'Loading accounts …')"
+ :size="32" />
+ </td>
+ <td class="footer__cell footer__cell--count footer__cell--multiline">
+ <span aria-describedby="user-count-desc">{{ userCount }}</span>
+ <span id="user-count-desc"
+ class="hidden-visually">
+ {{ t('settings', 'Scroll to load more rows') }}
+ </span>
+ </td>
+ </tr>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import {
+ translate as t,
+ translatePlural as n,
+} from '@nextcloud/l10n'
+
+export default Vue.extend({
+ name: 'UserListFooter',
+
+ components: {
+ NcLoadingIcon,
+ },
+
+ props: {
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ filteredUsers: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ computed: {
+ userCount(): string {
+ if (this.loading) {
+ return this.n(
+ 'settings',
+ '{userCount} account …',
+ '{userCount} accounts …',
+ this.filteredUsers.length,
+ {
+ userCount: this.filteredUsers.length,
+ },
+ )
+ }
+ return this.n(
+ 'settings',
+ '{userCount} account',
+ '{userCount} accounts',
+ this.filteredUsers.length,
+ {
+ userCount: this.filteredUsers.length,
+ },
+ )
+ },
+ },
+
+ methods: {
+ t,
+ n,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+@use './shared/styles';
+
+.footer {
+ @include styles.row;
+ @include styles.cell;
+
+ &__cell {
+ position: sticky;
+ color: var(--color-text-maxcontrast);
+
+ &--loading {
+ inset-inline-start: 0;
+ min-width: var(--avatar-cell-width);
+ width: var(--avatar-cell-width);
+ align-items: center;
+ padding: 0;
+ }
+
+ &--count {
+ inset-inline-start: var(--avatar-cell-width);
+ min-width: var(--cell-width);
+ width: var(--cell-width);
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue
new file mode 100644
index 00000000000..a85306d84d3
--- /dev/null
+++ b/apps/settings/src/components/Users/UserListHeader.vue
@@ -0,0 +1,152 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <tr class="header">
+ <th class="header__cell header__cell--avatar"
+ data-cy-user-list-header-avatar
+ scope="col">
+ <span class="hidden-visually">
+ {{ t('settings', 'Avatar') }}
+ </span>
+ </th>
+ <th class="header__cell header__cell--displayname"
+ data-cy-user-list-header-displayname
+ scope="col">
+ <strong>
+ {{ t('settings', 'Display name') }}
+ </strong>
+ </th>
+ <th class="header__cell header__cell--username"
+ data-cy-user-list-header-username
+ scope="col">
+ <span>
+ {{ t('settings', 'Account name') }}
+ </span>
+ </th>
+ <th class="header__cell"
+ :class="{ 'header__cell--obfuscated': hasObfuscated }"
+ data-cy-user-list-header-password
+ scope="col">
+ <span>{{ passwordLabel }}</span>
+ </th>
+ <th class="header__cell"
+ data-cy-user-list-header-email
+ scope="col">
+ <span>{{ t('settings', 'Email') }}</span>
+ </th>
+ <th class="header__cell header__cell--large"
+ data-cy-user-list-header-groups
+ scope="col">
+ <span>{{ t('settings', 'Groups') }}</span>
+ </th>
+ <th v-if="settings.isAdmin || settings.isDelegatedAdmin"
+ class="header__cell header__cell--large"
+ data-cy-user-list-header-subadmins
+ scope="col">
+ <span>{{ t('settings', 'Group admin for') }}</span>
+ </th>
+ <th class="header__cell"
+ data-cy-user-list-header-quota
+ scope="col">
+ <span>{{ t('settings', 'Quota') }}</span>
+ </th>
+ <th v-if="showConfig.showLanguages"
+ class="header__cell header__cell--large"
+ data-cy-user-list-header-languages
+ scope="col">
+ <span>{{ t('settings', 'Language') }}</span>
+ </th>
+ <th v-if="showConfig.showUserBackend || showConfig.showStoragePath"
+ class="header__cell header__cell--large"
+ data-cy-user-list-header-storage-location
+ scope="col">
+ <span v-if="showConfig.showUserBackend">
+ {{ t('settings', 'Account backend') }}
+ </span>
+ <span v-if="showConfig.showStoragePath"
+ class="header__subtitle">
+ {{ 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
+ scope="col">
+ <span>{{ t('settings', 'Last login') }}</span>
+ </th>
+ <th class="header__cell header__cell--large header__cell--fill"
+ data-cy-user-list-header-manager
+ scope="col">
+ <!-- TRANSLATORS This string describes a manager in the context of an organization -->
+ <span>{{ t('settings', 'Manager') }}</span>
+ </th>
+ <th class="header__cell header__cell--actions"
+ data-cy-user-list-header-actions
+ scope="col">
+ <span class="hidden-visually">
+ {{ t('settings', 'Account actions') }}
+ </span>
+ </th>
+ </tr>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+
+import { translate as t } from '@nextcloud/l10n'
+
+export default Vue.extend({
+ name: 'UserListHeader',
+
+ props: {
+ hasObfuscated: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ computed: {
+ showConfig() {
+ // @ts-expect-error: allow untyped $store
+ return this.$store.getters.getShowConfig
+ },
+
+ settings() {
+ // @ts-expect-error: allow untyped $store
+ return this.$store.getters.getServerData
+ },
+
+ 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
+ return t('settings', 'Password or insufficient permissions message')
+ }
+ return t('settings', 'Password')
+ },
+ },
+
+ methods: {
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+@use './shared/styles';
+
+.header {
+ border-bottom: 1px solid var(--color-border);
+
+ @include styles.row;
+ @include styles.cell;
+}
+</style>
diff --git a/apps/settings/src/components/Users/UserRow.vue b/apps/settings/src/components/Users/UserRow.vue
new file mode 100644
index 00000000000..43668725972
--- /dev/null
+++ b/apps/settings/src/components/Users/UserRow.vue
@@ -0,0 +1,1049 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <tr class="user-list__row"
+ :data-cy-user-row="user.id">
+ <td class="row__cell row__cell--avatar" data-cy-user-list-cell-avatar>
+ <NcLoadingIcon v-if="isLoadingUser"
+ :name="t('settings', 'Loading account …')"
+ :size="32" />
+ <NcAvatar v-else-if="visible"
+ disable-menu
+ :show-user-status="false"
+ :user="user.id" />
+ </td>
+
+ <td class="row__cell row__cell--displayname" data-cy-user-list-cell-displayname>
+ <template v-if="editing && user.backendCapabilities.setDisplayName">
+ <NcTextField ref="displayNameField"
+ class="user-row-text-field"
+ data-cy-user-list-input-displayname
+ :data-loading="loading.displayName || undefined"
+ :trailing-button-label="t('settings', 'Submit')"
+ :class="{ 'icon-loading-small': loading.displayName }"
+ :show-trailing-button="true"
+ :disabled="loading.displayName || isLoadingField"
+ :label="t('settings', 'Change display name')"
+ trailing-button-icon="arrowRight"
+ :value.sync="editedDisplayName"
+ autocapitalize="off"
+ autocomplete="off"
+ spellcheck="false"
+ @trailing-button-click="updateDisplayName" />
+ </template>
+ <strong v-else-if="!isObfuscated"
+ :title="user.displayname?.length > 20 ? user.displayname : null">
+ {{ user.displayname }}
+ </strong>
+ </td>
+
+ <td class="row__cell row__cell--username" data-cy-user-list-cell-username>
+ <span class="row__subtitle">{{ user.id }}</span>
+ </td>
+
+ <td data-cy-user-list-cell-password
+ class="row__cell"
+ :class="{ 'row__cell--obfuscated': hasObfuscated }">
+ <template v-if="editing && settings.canChangePassword && user.backendCapabilities.setPassword">
+ <NcTextField class="user-row-text-field"
+ data-cy-user-list-input-password
+ :data-loading="loading.password || undefined"
+ :trailing-button-label="t('settings', 'Submit')"
+ :class="{'icon-loading-small': loading.password}"
+ :show-trailing-button="true"
+ :disabled="loading.password || isLoadingField"
+ :minlength="minPasswordLength"
+ maxlength="469"
+ :label="t('settings', 'Set new password')"
+ trailing-button-icon="arrowRight"
+ :value.sync="editedPassword"
+ autocapitalize="off"
+ autocomplete="new-password"
+ required
+ spellcheck="false"
+ type="password"
+ @trailing-button-click="updatePassword" />
+ </template>
+ <span v-else-if="isObfuscated">
+ {{ t('settings', 'You do not have permissions to see the details of this account') }}
+ </span>
+ </td>
+
+ <td class="row__cell" data-cy-user-list-cell-email>
+ <template v-if="editing">
+ <NcTextField class="user-row-text-field"
+ :class="{'icon-loading-small': loading.mailAddress}"
+ data-cy-user-list-input-email
+ :data-loading="loading.mailAddress || undefined"
+ :show-trailing-button="true"
+ :trailing-button-label="t('settings', 'Submit')"
+ :label="t('settings', 'Set new email address')"
+ :disabled="loading.mailAddress || isLoadingField"
+ trailing-button-icon="arrowRight"
+ :value.sync="editedMail"
+ autocapitalize="off"
+ autocomplete="email"
+ spellcheck="false"
+ type="email"
+ @trailing-button-click="updateEmail" />
+ </template>
+ <span v-else-if="!isObfuscated"
+ :title="user.email?.length > 20 ? user.email : null">
+ {{ user.email }}
+ </span>
+ </td>
+
+ <td class="row__cell row__cell--large row__cell--multiline" data-cy-user-list-cell-groups>
+ <template v-if="editing">
+ <label class="hidden-visually"
+ :for="'groups' + uniqueId">
+ {{ t('settings', 'Add account to group') }}
+ </label>
+ <NcSelect data-cy-user-list-input-groups
+ :data-loading="loading.groups || undefined"
+ :input-id="'groups' + uniqueId"
+ :close-on-select="false"
+ :disabled="isLoadingField || loading.groupsDetails"
+ :loading="loading.groups"
+ :multiple="true"
+ :append-to-body="false"
+ :options="availableGroups"
+ :placeholder="t('settings', 'Add account to group')"
+ :taggable="settings.isAdmin || settings.isDelegatedAdmin"
+ :value="userGroups"
+ label="name"
+ :no-wrap="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" />
+ </template>
+ <span v-else-if="!isObfuscated"
+ :title="userGroupsLabels?.length > 40 ? userGroupsLabels : null">
+ {{ userGroupsLabels }}
+ </span>
+ </td>
+
+ <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)">
+ <label class="hidden-visually"
+ :for="'subadmins' + uniqueId">
+ {{ t('settings', 'Set account as admin for') }}
+ </label>
+ <NcSelect data-cy-user-list-input-subadmins
+ :data-loading="loading.subadmins || undefined"
+ :input-id="'subadmins' + uniqueId"
+ :close-on-select="false"
+ :disabled="isLoadingField || loading.subAdminGroupsDetails"
+ :loading="loading.subadmins"
+ label="name"
+ :append-to-body="false"
+ :multiple="true"
+ :no-wrap="true"
+ :options="availableSubAdminGroups"
+ :placeholder="t('settings', 'Set account as admin for')"
+ :value="userSubAdminGroups"
+ @search="searchGroups"
+ @option:deselected="removeUserSubAdmin"
+ @option:selected="options => addUserSubAdmin(options.at(-1))" />
+ </template>
+ <span v-else-if="!isObfuscated"
+ :title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null">
+ {{ userSubAdminGroupsLabels }}
+ </span>
+ </td>
+
+ <td class="row__cell" data-cy-user-list-cell-quota>
+ <template v-if="editing">
+ <label class="hidden-visually"
+ :for="'quota' + uniqueId">
+ {{ t('settings', 'Select account quota') }}
+ </label>
+ <NcSelect v-model="editedUserQuota"
+ :close-on-select="true"
+ :create-option="validateQuota"
+ data-cy-user-list-input-quota
+ :data-loading="loading.quota || undefined"
+ :disabled="isLoadingField"
+ :loading="loading.quota"
+ :append-to-body="false"
+ :clearable="false"
+ :input-id="'quota' + uniqueId"
+ :options="quotaOptions"
+ :placeholder="t('settings', 'Select account quota')"
+ :taggable="true"
+ @option:selected="setUserQuota" />
+ </template>
+ <template v-else-if="!isObfuscated">
+ <span :id="'quota-progress' + uniqueId">{{ userQuota }} ({{ usedSpace }})</span>
+ <NcProgressBar :aria-labelledby="'quota-progress' + uniqueId"
+ class="row__progress"
+ :class="{
+ 'row__progress--warn': usedQuota > 80,
+ }"
+ :value="usedQuota" />
+ </template>
+ </td>
+
+ <td v-if="showConfig.showLanguages"
+ class="row__cell row__cell--large"
+ data-cy-user-list-cell-language>
+ <template v-if="editing">
+ <label class="hidden-visually"
+ :for="'language' + uniqueId">
+ {{ t('settings', 'Set the language') }}
+ </label>
+ <NcSelect :id="'language' + uniqueId"
+ data-cy-user-list-input-language
+ :data-loading="loading.languages || undefined"
+ :allow-empty="false"
+ :disabled="isLoadingField"
+ :loading="loading.languages"
+ :clearable="false"
+ :append-to-body="false"
+ :options="availableLanguages"
+ :placeholder="t('settings', 'No language set')"
+ :value="userLanguage"
+ label="name"
+ @input="setUserLanguage" />
+ </template>
+ <span v-else-if="!isObfuscated">
+ {{ userLanguage.name }}
+ </span>
+ </td>
+
+ <td v-if="showConfig.showUserBackend || showConfig.showStoragePath"
+ data-cy-user-list-cell-storage-location
+ class="row__cell row__cell--large">
+ <template v-if="!isObfuscated">
+ <span v-if="showConfig.showUserBackend">{{ user.backend }}</span>
+ <span v-if="showConfig.showStoragePath"
+ :title="user.storageLocation"
+ class="row__subtitle">
+ {{ user.storageLocation }}
+ </span>
+ </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"
+ data-cy-user-list-cell-last-login>
+ <span v-if="!isObfuscated">{{ userLastLogin }}</span>
+ </td>
+
+ <td class="row__cell row__cell--large row__cell--fill" data-cy-user-list-cell-manager>
+ <template v-if="editing">
+ <label class="hidden-visually"
+ :for="'manager' + uniqueId">
+ {{ managerLabel }}
+ </label>
+ <NcSelect v-model="currentManager"
+ class="select--fill"
+ data-cy-user-list-input-manager
+ :data-loading="loading.manager || undefined"
+ :input-id="'manager' + uniqueId"
+ :disabled="isLoadingField"
+ :loading="loadingPossibleManagers || loading.manager"
+ :options="possibleManagers"
+ :placeholder="managerLabel"
+ label="displayname"
+ :filterable="false"
+ :internal-search="false"
+ :clearable="true"
+ @open="searchInitialUserManager"
+ @search="searchUserManager"
+ @update:model-value="updateUserManager" />
+ </template>
+ <span v-else-if="!isObfuscated">
+ {{ user.manager }}
+ </span>
+ </td>
+
+ <td class="row__cell row__cell--actions" data-cy-user-list-cell-actions>
+ <UserRowActions v-if="visible && !isObfuscated && canEdit && !loading.all"
+ :actions="userActions"
+ :disabled="isLoadingField"
+ :edit="editing"
+ :user="user"
+ @update:edit="toggleEdit" />
+ </td>
+ </tr>
+</template>
+
+<script>
+import { formatFileSize, parseFileSize } from '@nextcloud/files'
+import { getCurrentUser } from '@nextcloud/auth'
+import { showSuccess, showError } from '@nextcloud/dialogs'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+
+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',
+
+ components: {
+ NcAvatar,
+ NcLoadingIcon,
+ NcProgressBar,
+ NcSelect,
+ NcTextField,
+ UserRowActions,
+ },
+
+ mixins: [
+ UserRowMixin,
+ ],
+
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ hasObfuscated: {
+ type: Boolean,
+ required: true,
+ },
+ quotaOptions: {
+ type: Array,
+ required: true,
+ },
+ languages: {
+ type: Array,
+ required: true,
+ },
+ settings: {
+ type: Object,
+ required: true,
+ },
+ externalActions: {
+ type: Array,
+ default: () => [],
+ },
+ },
+
+ data() {
+ return {
+ selectedQuota: false,
+ rand: Math.random().toString(36).substring(2),
+ loadingPossibleManagers: false,
+ possibleManagers: [],
+ currentManager: '',
+ editing: false,
+ loading: {
+ all: false,
+ displayName: false,
+ password: false,
+ mailAddress: false,
+ groups: false,
+ groupsDetails: false,
+ subAdminGroupsDetails: false,
+ subadmins: false,
+ quota: false,
+ delete: false,
+ disable: false,
+ languages: false,
+ wipe: false,
+ manager: false,
+ },
+ editedDisplayName: this.user.displayname,
+ editedPassword: '',
+ editedMail: this.user.email ?? '',
+ // Cancelable promise for search groups request
+ promise: null,
+ }
+ },
+
+ computed: {
+ managerLabel() {
+ // TRANSLATORS This string describes a person's manager in the context of an organization
+ return t('settings', 'Set line manager')
+ },
+
+ isObfuscated() {
+ return isObfuscated(this.user)
+ },
+
+ showConfig() {
+ return this.$store.getters.getShowConfig
+ },
+
+ isLoadingUser() {
+ return this.loading.delete || this.loading.disable || this.loading.wipe
+ },
+
+ isLoadingField() {
+ return this.loading.delete || this.loading.disable || this.loading.all
+ },
+
+ uniqueId() {
+ 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 => {
+ // 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(', ')
+ },
+
+ 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(', ')
+ },
+
+ usedSpace() {
+ if (this.user.quota?.used) {
+ return t('settings', '{size} used', { size: formatFileSize(this.user.quota?.used) })
+ }
+ return t('settings', '{size} used', { size: formatFileSize(0) })
+ },
+
+ canEdit() {
+ return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin
+ },
+
+ userQuota() {
+ let quota = this.user.quota?.quota
+
+ if (quota === 'default') {
+ quota = this.settings.defaultQuota
+ if (quota !== 'none') {
+ // convert to numeric value to match what the server would usually return
+ quota = parseFileSize(quota, true)
+ }
+ }
+
+ // when the default quota is unlimited, the server returns -3 here, map it to "none"
+ if (quota === 'none' || quota === -3) {
+ return t('settings', 'Unlimited')
+ } else if (quota >= 0) {
+ return formatFileSize(quota)
+ }
+ return formatFileSize(0)
+ },
+
+ userActions() {
+ const actions = [
+ {
+ icon: 'icon-delete',
+ text: t('settings', 'Delete account'),
+ action: this.deleteUser,
+ },
+ {
+ icon: 'icon-delete',
+ text: t('settings', 'Disconnect all devices and delete local data'),
+ action: this.wipeUserDevices,
+ },
+ {
+ icon: this.user.enabled ? 'icon-close' : 'icon-add',
+ text: this.user.enabled ? t('settings', 'Disable account') : t('settings', 'Enable account'),
+ action: this.enableDisableUser,
+ },
+ ]
+ if (this.user.email !== null && this.user.email !== '') {
+ actions.push({
+ icon: 'icon-mail',
+ text: t('settings', 'Resend welcome email'),
+ action: this.sendWelcomeMail,
+ })
+ }
+ return actions.concat(this.externalActions)
+ },
+
+ // mapping saved values to objects
+ editedUserQuota: {
+ get() {
+ if (this.selectedQuota !== false) {
+ return this.selectedQuota
+ }
+ if (this.settings.defaultQuota !== unlimitedQuota.id && parseFileSize(this.settings.defaultQuota, true) >= 0) {
+ // if value is valid, let's map the quotaOptions or return custom quota
+ return { id: this.settings.defaultQuota, label: this.settings.defaultQuota }
+ }
+ return unlimitedQuota // unlimited
+ },
+ set(quota) {
+ this.selectedQuota = quota
+ },
+ },
+
+ availableLanguages() {
+ return this.languages[0].languages.concat(this.languages[1].languages)
+ },
+ },
+ async beforeMount() {
+ if (this.user.manager) {
+ await this.initManager(this.user.manager)
+ }
+ },
+
+ methods: {
+ async wipeUserDevices() {
+ const userid = this.user.id
+ await confirmPassword()
+ OC.dialogs.confirmDestructive(
+ t('settings', 'In case of lost device or exiting the organization, this can remotely wipe the Nextcloud data from all devices associated with {userid}. Only works if the devices are connected to the internet.', { userid }),
+ t('settings', 'Remote wipe of devices'),
+ {
+ type: OC.dialogs.YES_NO_BUTTONS,
+ confirm: t('settings', 'Wipe {userid}\'s devices', { userid }),
+ confirmClasses: 'error',
+ cancel: t('settings', 'Cancel'),
+ },
+ (result) => {
+ if (result) {
+ this.loading.wipe = true
+ this.loading.all = true
+ this.$store.dispatch('wipeUserDevices', userid)
+ .then(() => showSuccess(t('settings', 'Wiped {userid}\'s devices', { userid })), { timeout: 2000 })
+ .finally(() => {
+ this.loading.wipe = false
+ this.loading.all = false
+ })
+ }
+ },
+ true,
+ )
+ },
+
+ filterManagers(managers) {
+ return managers.filter((manager) => manager.id !== this.user.id)
+ },
+
+ async initManager(userId) {
+ await this.$store.dispatch('getUser', userId).then(response => {
+ this.currentManager = response?.data.ocs.data
+ })
+ },
+
+ async searchInitialUserManager() {
+ this.loadingPossibleManagers = true
+ await this.searchUserManager()
+ 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)) : []
+ if (users.length > 0) {
+ this.possibleManagers = users
+ }
+ })
+ },
+
+ 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,
+ key: 'manager',
+ value: this.currentManager ? this.currentManager.id : '',
+ })
+ } catch (error) {
+ // TRANSLATORS This string describes a line manager in the context of an organization
+ 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
+ }
+ },
+
+ async deleteUser() {
+ const userid = this.user.id
+ await confirmPassword()
+ OC.dialogs.confirmDestructive(
+ t('settings', 'Fully delete {userid}\'s account including all their personal files, app data, etc.', { userid }),
+ t('settings', 'Account deletion'),
+ {
+ type: OC.dialogs.YES_NO_BUTTONS,
+ confirm: t('settings', 'Delete {userid}\'s account', { userid }),
+ confirmClasses: 'error',
+ cancel: t('settings', 'Cancel'),
+ },
+ (result) => {
+ if (result) {
+ this.loading.delete = true
+ this.loading.all = true
+ return this.$store.dispatch('deleteUser', userid)
+ .then(() => {
+ this.loading.delete = false
+ this.loading.all = false
+ })
+ }
+ },
+ true,
+ )
+ },
+
+ enableDisableUser() {
+ this.loading.delete = true
+ this.loading.all = true
+ const userid = this.user.id
+ const enabled = !this.user.enabled
+ return this.$store.dispatch('enableDisableUser', {
+ userid,
+ enabled,
+ })
+ .then(() => {
+ this.loading.delete = false
+ this.loading.all = false
+ })
+ },
+
+ /**
+ * Set user displayName
+ */
+ async updateDisplayName() {
+ this.loading.displayName = true
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'displayname',
+ value: this.editedDisplayName,
+ })
+
+ if (this.editedDisplayName === this.user.displayname) {
+ showSuccess(t('settings', 'Display name was successfully changed'))
+ }
+ } finally {
+ this.loading.displayName = false
+ }
+ },
+
+ /**
+ * Set user password
+ */
+ async updatePassword() {
+ this.loading.password = true
+ if (this.editedPassword.length === 0) {
+ showError(t('settings', "Password can't be empty"))
+ this.loading.password = false
+ } else {
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'password',
+ value: this.editedPassword,
+ })
+ this.editedPassword = ''
+ showSuccess(t('settings', 'Password was successfully changed'))
+ } finally {
+ this.loading.password = false
+ }
+ }
+ },
+
+ /**
+ * Set user mailAddress
+ */
+ async updateEmail() {
+ this.loading.mailAddress = true
+ if (this.editedMail === '') {
+ showError(t('settings', "Email can't be empty"))
+ this.loading.mailAddress = false
+ this.editedMail = this.user.email
+ } else {
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'email',
+ value: this.editedMail,
+ })
+
+ if (this.editedMail === this.user.email) {
+ showSuccess(t('settings', 'Email was successfully changed'))
+ }
+ } finally {
+ this.loading.mailAddress = false
+ }
+ }
+ },
+
+ /**
+ * Create a new group and add user to it
+ *
+ * @param {string} gid Group id
+ */
+ async createGroup({ name: gid }) {
+ 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) {
+ logger.error(t('settings', 'Failed to create group'), { error })
+ }
+ this.loading.groups = false
+ },
+
+ /**
+ * Add user to group
+ *
+ * @param {object} group Group object
+ */
+ async addUserGroup(group) {
+ if (group.isCreating) {
+ // This is NcSelect's internal value for a new inputted group name
+ // Ignore
+ return
+ }
+ const userid = this.user.id
+ const gid = group.id
+ if (group.canAdd === false) {
+ return
+ }
+ this.loading.groups = true
+ try {
+ await this.$store.dispatch('addUserGroup', { userid, gid })
+ this.userGroups.push(group)
+ } catch (error) {
+ console.error(error)
+ }
+ this.loading.groups = false
+ },
+
+ /**
+ * Remove user from group
+ *
+ * @param {object} group Group object
+ */
+ async removeUserGroup(group) {
+ if (group.canRemove === false) {
+ return false
+ }
+ this.loading.groups = true
+ const userid = this.user.id
+ const gid = group.id
+ try {
+ await this.$store.dispatch('removeUserGroup', {
+ 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) {
+ this.$store.commit('deleteUser', userid)
+ }
+ } catch {
+ this.loading.groups = false
+ }
+ },
+
+ /**
+ * Add user to group
+ *
+ * @param {object} group Group object
+ */
+ async addUserSubAdmin(group) {
+ this.loading.subadmins = true
+ const userid = this.user.id
+ const gid = group.id
+ try {
+ await this.$store.dispatch('addUserSubAdmin', {
+ userid,
+ gid,
+ })
+ this.userSubAdminGroups.push(group)
+ } catch (error) {
+ console.error(error)
+ }
+ this.loading.subadmins = false
+ },
+
+ /**
+ * Remove user from group
+ *
+ * @param {object} group Group object
+ */
+ async removeUserSubAdmin(group) {
+ this.loading.subadmins = true
+ const userid = this.user.id
+ const gid = group.id
+
+ try {
+ await this.$store.dispatch('removeUserSubAdmin', {
+ userid,
+ gid,
+ })
+ this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid)
+ } catch (error) {
+ console.error(error)
+ } finally {
+ this.loading.subadmins = false
+ }
+ },
+
+ /**
+ * Dispatch quota set request
+ *
+ * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
+ * @return {string}
+ */
+ async setUserQuota(quota = 'none') {
+ // Make sure correct label is set for unlimited quota
+ if (quota === 'none') {
+ quota = unlimitedQuota
+ }
+ this.loading.quota = true
+
+ // ensure we only send the preset id
+ quota = quota.id ? quota.id : quota
+
+ try {
+ // If human readable format, convert to raw float format
+ // Else just send the raw string
+ const value = (parseFileSize(quota, true) || quota).toString()
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'quota',
+ value,
+ })
+ } catch (error) {
+ console.error(error)
+ } finally {
+ this.loading.quota = false
+ }
+ return quota
+ },
+
+ /**
+ * Validate quota string to make sure it's a valid human file size
+ *
+ * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
+ * @return {object} The validated quota object or unlimited quota if input is invalid
+ */
+ validateQuota(quota) {
+ if (typeof quota === 'object') {
+ quota = quota?.id || quota.label
+ }
+ // only used for new presets sent through @Tag
+ const validQuota = parseFileSize(quota, true)
+ if (validQuota === null) {
+ return unlimitedQuota
+ } else {
+ // unify format output
+ quota = formatFileSize(parseFileSize(quota, true))
+ return { id: quota, label: quota }
+ }
+ },
+
+ /**
+ * Dispatch language set request
+ *
+ * @param {object} lang language object {code:'en', name:'English'}
+ * @return {object}
+ */
+ async setUserLanguage(lang) {
+ this.loading.languages = true
+ // ensure we only send the preset id
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'language',
+ value: lang.code,
+ })
+ this.loading.languages = false
+ } catch (error) {
+ console.error(error)
+ }
+ return lang
+ },
+
+ /**
+ * Dispatch new welcome mail request
+ */
+ sendWelcomeMail() {
+ this.loading.all = true
+ this.$store.dispatch('sendWelcomeMail', this.user.id)
+ .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 }))
+ .finally(() => {
+ this.loading.all = false
+ })
+ },
+
+ async toggleEdit() {
+ this.editing = !this.editing
+ 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
+ } else if (this.editedMail !== this.user.email) {
+ this.editedMail = this.user.email ?? ''
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use './shared/styles';
+
+.user-list__row {
+ @include styles.row;
+
+ &:hover {
+ background-color: var(--color-background-hover);
+
+ .row__cell:not(.row__cell--actions) {
+ background-color: var(--color-background-hover);
+ }
+ }
+
+ // Limit width of select in fill cell
+ .select--fill {
+ max-width: calc(var(--cell-width-large) - (2 * var(--cell-padding)));
+ }
+}
+
+.row {
+ @include styles.cell;
+
+ &__cell {
+ border-bottom: 1px solid var(--color-border);
+
+ :deep {
+ .v-select.select {
+ min-width: var(--cell-min-width);
+ }
+ }
+ }
+
+ &__progress {
+ margin-top: 4px;
+
+ &--warn {
+ &::-moz-progress-bar {
+ background: var(--color-warning) !important;
+ }
+ &::-webkit-progress-value {
+ background: var(--color-warning) !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue
new file mode 100644
index 00000000000..efd70d879a7
--- /dev/null
+++ b/apps/settings/src/components/Users/UserRowActions.vue
@@ -0,0 +1,119 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcActions :aria-label="t('settings', 'Toggle account actions menu')"
+ :disabled="disabled"
+ :inline="1">
+ <NcActionButton :data-cy-user-list-action-toggle-edit="`${edit}`"
+ :disabled="disabled"
+ @click="toggleEdit">
+ {{ edit ? t('settings', 'Done') : t('settings', 'Edit') }}
+ <template #icon>
+ <NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" />
+ </template>
+ </NcActionButton>
+ <NcActionButton v-for="({ action, icon, text }, index) in enabledActions"
+ :key="index"
+ :disabled="disabled"
+ :aria-label="text"
+ :icon="icon"
+ close-after-click
+ @click="(event) => action(event, { ...user })">
+ {{ text }}
+ <template v-if="isSvg(icon)" #icon>
+ <NcIconSvgWrapper :svg="icon" aria-hidden="true" />
+ </template>
+ </NcActionButton>
+ </NcActions>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import { defineComponent } from 'vue'
+import isSvg from 'is-svg'
+
+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-outline.svg?raw'
+
+interface UserAction {
+ action: (event: MouseEvent, user: Record<string, unknown>) => void,
+ enabled?: (user: Record<string, unknown>) => boolean,
+ icon: string,
+ text: string,
+}
+
+export default defineComponent({
+ components: {
+ NcActionButton,
+ NcActions,
+ NcIconSvgWrapper,
+ },
+
+ props: {
+ /**
+ * Array of user actions
+ */
+ actions: {
+ type: Array as PropType<readonly UserAction[]>,
+ required: true,
+ },
+
+ /**
+ * The state whether the row is currently disabled
+ */
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+
+ /**
+ * The state whether the row is currently edited
+ */
+ edit: {
+ type: Boolean,
+ required: true,
+ },
+
+ /**
+ * Target of this actions
+ */
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ /**
+ * Current MDI logo to show for edit toggle
+ */
+ editSvg(): string {
+ return this.edit ? SvgCheck : SvgPencil
+ },
+
+ /**
+ * Enabled user row actions
+ */
+ enabledActions(): UserAction[] {
+ return this.actions.filter(action => typeof action.enabled === 'function' ? action.enabled(this.user) : true)
+ },
+ },
+
+ methods: {
+ isSvg,
+
+ /**
+ * Toggle edit mode by emitting the update event
+ */
+ toggleEdit() {
+ this.$emit('update:edit', !this.edit)
+ },
+ },
+})
+</script>
diff --git a/apps/settings/src/components/Users/UserSettingsDialog.vue b/apps/settings/src/components/Users/UserSettingsDialog.vue
new file mode 100644
index 00000000000..94c77d320dd
--- /dev/null
+++ b/apps/settings/src/components/Users/UserSettingsDialog.vue
@@ -0,0 +1,337 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcAppSettingsDialog :open.sync="isModalOpen"
+ :show-navigation="true"
+ :name="t('settings', 'Account management settings')">
+ <NcAppSettingsSection id="visibility-settings"
+ :name="t('settings', 'Visibility')">
+ <NcCheckboxRadioSwitch type="switch"
+ data-test="showLanguages"
+ :checked.sync="showLanguages">
+ {{ t('settings', 'Show language') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch type="switch"
+ data-test="showUserBackend"
+ :checked.sync="showUserBackend">
+ {{ t('settings', 'Show account backend') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch type="switch"
+ data-test="showStoragePath"
+ :checked.sync="showStoragePath">
+ {{ 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') }}
+ </NcCheckboxRadioSwitch>
+ </NcAppSettingsSection>
+
+ <NcAppSettingsSection id="groups-sorting"
+ :name="t('settings', 'Sorting')">
+ <NcNoteCard v-if="isGroupSortingEnforced" type="warning">
+ {{ t('settings', 'The system config enforces sorting the groups by name. This also disables showing the member count.') }}
+ </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"
+ :disabled="isGroupSortingEnforced"
+ name="group-sorting-mode"
+ value="member-count">
+ {{ t('settings', 'By member count') }}
+ </NcCheckboxRadioSwitch>
+ <NcCheckboxRadioSwitch type="radio"
+ :checked.sync="groupSorting"
+ data-test="sortGroupsByName"
+ :disabled="isGroupSortingEnforced"
+ name="group-sorting-mode"
+ value="name">
+ {{ t('settings', 'By name') }}
+ </NcCheckboxRadioSwitch>
+ </fieldset>
+ </NcAppSettingsSection>
+
+ <NcAppSettingsSection id="email-settings"
+ :name="t('settings', 'Send email')">
+ <NcCheckboxRadioSwitch type="switch"
+ data-test="sendWelcomeMail"
+ :checked.sync="sendWelcomeMail"
+ :disabled="loadingSendMail">
+ {{ t('settings', 'Send welcome email to new accounts') }}
+ </NcCheckboxRadioSwitch>
+ </NcAppSettingsSection>
+
+ <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')"
+ :options="quotaOptions"
+ placement="top"
+ :placeholder="t('settings', 'Select default quota')"
+ taggable
+ @option:selected="setDefaultQuota" />
+ </NcAppSettingsSection>
+ </NcAppSettingsDialog>
+</template>
+
+<script>
+import { formatFileSize, parseFileSize } from '@nextcloud/files'
+import { generateUrl } from '@nextcloud/router'
+
+import axios from '@nextcloud/axios'
+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',
+
+ components: {
+ NcAppSettingsDialog,
+ NcAppSettingsSection,
+ NcCheckboxRadioSwitch,
+ NcNoteCard,
+ NcSelect,
+ },
+
+ props: {
+ open: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ selectedQuota: false,
+ loadingSendMail: false,
+ }
+ },
+
+ computed: {
+ groupSorting: {
+ get() {
+ return this.$store.getters.getGroupSorting === GroupSorting.GroupName ? 'name' : 'member-count'
+ },
+ set(sorting) {
+ this.$store.commit('setGroupSorting', sorting === 'name' ? GroupSorting.GroupName : GroupSorting.UserCount)
+ },
+ },
+
+ /**
+ * Admin has configured `sort_groups_by_name` in the system config
+ */
+ isGroupSortingEnforced() {
+ return this.$store.getters.getServerData.forceSortGroupByName
+ },
+
+ isModalOpen: {
+ get() {
+ return this.open
+ },
+ set(open) {
+ this.$emit('update:open', open)
+ },
+ },
+
+ showConfig() {
+ return this.$store.getters.getShowConfig
+ },
+
+ settings() {
+ return this.$store.getters.getServerData
+ },
+
+ showLanguages: {
+ get() {
+ return this.showConfig.showLanguages
+ },
+ set(status) {
+ this.setShowConfig('showLanguages', status)
+ },
+ },
+
+ showFirstLogin: {
+ get() {
+ return this.showConfig.showFirstLogin
+ },
+ set(status) {
+ this.setShowConfig('showFirstLogin', status)
+ },
+ },
+
+ showLastLogin: {
+ get() {
+ return this.showConfig.showLastLogin
+ },
+ set(status) {
+ this.setShowConfig('showLastLogin', status)
+ },
+ },
+
+ showUserBackend: {
+ get() {
+ return this.showConfig.showUserBackend
+ },
+ set(status) {
+ this.setShowConfig('showUserBackend', status)
+ },
+ },
+
+ showStoragePath: {
+ get() {
+ return this.showConfig.showStoragePath
+ },
+ set(status) {
+ this.setShowConfig('showStoragePath', status)
+ },
+ },
+
+ quotaOptions() {
+ // convert the preset array into objects
+ const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({ id: cur, label: cur }), [])
+ // add default presets
+ if (this.settings.allowUnlimitedQuota) {
+ quotaPreset.unshift(unlimitedQuota)
+ }
+ return quotaPreset
+ },
+
+ defaultQuota: {
+ get() {
+ if (this.selectedQuota !== false) {
+ return this.selectedQuota
+ }
+ if (this.settings.defaultQuota !== unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) {
+ // if value is valid, let's map the quotaOptions or return custom quota
+ return { id: this.settings.defaultQuota, label: this.settings.defaultQuota }
+ }
+ return unlimitedQuota // unlimited
+ },
+ set(quota) {
+ this.selectedQuota = quota
+ },
+ },
+
+ sendWelcomeMail: {
+ get() {
+ return this.settings.newUserSendEmail
+ },
+ async set(value) {
+ try {
+ this.loadingSendMail = true
+ this.$store.commit('setServerData', {
+ ...this.settings,
+ newUserSendEmail: value,
+ })
+ await axios.post(generateUrl('/settings/users/preferences/newUser.sendEmail'), { value: value ? 'yes' : 'no' })
+ } catch (error) {
+ logger.error('Could not update newUser.sendEmail preference', { error })
+ } finally {
+ this.loadingSendMail = false
+ }
+ },
+ },
+ },
+
+ 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 })
+ },
+
+ /**
+ * Validate quota string to make sure it's a valid human file size
+ *
+ * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
+ * @return {object} The validated quota object or unlimited quota if input is invalid
+ */
+ validateQuota(quota) {
+ if (typeof quota === 'object') {
+ quota = quota?.id || quota.label
+ }
+ // only used for new presets sent through @Tag
+ const validQuota = parseFileSize(quota, true)
+ if (validQuota === null) {
+ return unlimitedQuota
+ }
+ // unify format output
+ quota = formatFileSize(validQuota)
+ return { id: quota, label: quota }
+ },
+
+ /**
+ * Dispatch default quota set request
+ *
+ * @param {string | object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
+ */
+ setDefaultQuota(quota = 'none') {
+ // Make sure correct label is set for unlimited quota
+ if (quota === 'none') {
+ quota = unlimitedQuota
+ }
+ this.$store.dispatch('setAppConfig', {
+ app: 'files',
+ key: 'default_quota',
+ // ensure we only send the preset id
+ value: quota.id ? quota.id : quota,
+ }).then(() => {
+ if (typeof quota !== 'object') {
+ quota = { id: quota, label: quota }
+ }
+ this.defaultQuota = quota
+ })
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.dialog {
+ &__note {
+ font-weight: normal;
+ }
+}
+
+fieldset {
+ font-weight: bold;
+}
+</style>
diff --git a/apps/settings/src/components/Users/VirtualList.vue b/apps/settings/src/components/Users/VirtualList.vue
new file mode 100644
index 00000000000..20dc70ef830
--- /dev/null
+++ b/apps/settings/src/components/Users/VirtualList.vue
@@ -0,0 +1,184 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <table class="user-list">
+ <slot name="before" />
+
+ <thead ref="thead"
+ role="rowgroup"
+ class="user-list__header">
+ <slot name="header" />
+ </thead>
+
+ <tbody :style="tbodyStyle"
+ class="user-list__body">
+ <component :is="dataComponent"
+ v-for="(item, i) in renderedItems"
+ :key="item[dataKey]"
+ :user="item"
+ :visible="(i >= bufferItems || index <= bufferItems) && (i < shownItems - bufferItems)"
+ v-bind="extraProps" />
+ </tbody>
+
+ <tfoot ref="tfoot"
+ v-element-visibility="handleFooterVisibility"
+ role="rowgroup"
+ class="user-list__footer">
+ <slot name="footer" />
+ </tfoot>
+ </table>
+</template>
+
+<script lang="ts">
+import Vue from 'vue'
+import { vElementVisibility } from '@vueuse/components'
+import debounce from 'debounce'
+
+import logger from '../../logger.ts'
+
+Vue.directive('elementVisibility', vElementVisibility)
+
+// Items to render before and after the visible area
+const bufferItems = 3
+
+export default Vue.extend({
+ name: 'VirtualList',
+
+ props: {
+ dataComponent: {
+ type: [Object, Function],
+ required: true,
+ },
+ dataKey: {
+ type: String,
+ required: true,
+ },
+ dataSources: {
+ type: Array,
+ required: true,
+ },
+ itemHeight: {
+ type: Number,
+ required: true,
+ },
+ extraProps: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+
+ data() {
+ return {
+ bufferItems,
+ index: 0,
+ headerHeight: 0,
+ tableHeight: 0,
+ resizeObserver: null as ResizeObserver | null,
+ }
+ },
+
+ computed: {
+ startIndex() {
+ return Math.max(0, this.index - bufferItems)
+ },
+
+ shownItems() {
+ return Math.ceil((this.tableHeight - this.headerHeight) / this.itemHeight) + bufferItems * 2
+ },
+
+ renderedItems() {
+ return this.dataSources.slice(this.startIndex, this.startIndex + this.shownItems)
+ },
+
+ tbodyStyle() {
+ const isOverScrolled = this.startIndex + this.shownItems > this.dataSources.length
+ const lastIndex = this.dataSources.length - this.startIndex - this.shownItems
+ const hiddenAfterItems = Math.min(this.dataSources.length - this.startIndex, lastIndex)
+ return {
+ paddingTop: `${this.startIndex * this.itemHeight}px`,
+ paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * this.itemHeight}px`,
+ }
+ },
+ },
+
+ mounted() {
+ const root = this.$el as HTMLElement
+ const tfoot = this.$refs?.tfoot as HTMLElement
+ const thead = this.$refs?.thead as HTMLElement
+
+ this.resizeObserver = new ResizeObserver(debounce(() => {
+ this.headerHeight = thead?.clientHeight ?? 0
+ this.tableHeight = root?.clientHeight ?? 0
+ logger.debug('VirtualList resizeObserver updated')
+ this.onScroll()
+ }, 100, false))
+
+ this.resizeObserver.observe(root)
+ this.resizeObserver.observe(tfoot)
+ this.resizeObserver.observe(thead)
+
+ this.$el.addEventListener('scroll', this.onScroll)
+ },
+
+ beforeDestroy() {
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect()
+ }
+ },
+
+ methods: {
+ handleFooterVisibility(visible: boolean) {
+ if (visible) {
+ this.$emit('scroll-end')
+ }
+ },
+
+ onScroll() {
+ // Max 0 to prevent negative index
+ this.index = Math.max(0, Math.round(this.$el.scrollTop / this.itemHeight))
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.user-list {
+ --avatar-cell-width: 48px;
+ --cell-padding: 7px;
+ --cell-width: 200px;
+ --cell-width-large: 300px;
+ --cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding)));
+ --sticky-column-z-index: calc(var(--vs-dropdown-z-index) + 1); // Keep the sticky column on top of the select dropdown
+
+ // Necessary for virtual scroll optimized rendering
+ display: block;
+ overflow: auto;
+ height: 100%;
+ will-change: scroll-position;
+
+ &__header,
+ &__footer {
+ position: sticky;
+ // Fix sticky positioning in Firefox
+ display: block;
+ }
+
+ &__header {
+ top: 0;
+ z-index: calc(var(--sticky-column-z-index) + 1);
+ }
+
+ &__footer {
+ inset-inline-start: 0;
+ }
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ }
+}
+</style>
diff --git a/apps/settings/src/components/Users/shared/styles.scss b/apps/settings/src/components/Users/shared/styles.scss
new file mode 100644
index 00000000000..4dfdd58af6d
--- /dev/null
+++ b/apps/settings/src/components/Users/shared/styles.scss
@@ -0,0 +1,110 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+@mixin row {
+ position: relative;
+ display: flex;
+ min-width: 100%;
+ width: fit-content;
+ height: var(--row-height);
+ background-color: var(--color-main-background);
+}
+
+@mixin cell {
+ &__cell {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 0 var(--cell-padding);
+ min-width: var(--cell-width);
+ width: var(--cell-width);
+ color: var(--color-main-text);
+
+ strong,
+ span,
+ label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow-wrap: anywhere;
+ }
+
+ @media (min-width: 670px) { /* Show one &--large column between stickied columns */
+ &--avatar,
+ &--displayname {
+ position: sticky;
+ z-index: var(--sticky-column-z-index);
+ background-color: var(--color-main-background);
+ }
+
+ &--avatar {
+ inset-inline-start: 0;
+ }
+
+ &--displayname {
+ inset-inline-start: var(--avatar-cell-width);
+ border-inline-end: 1px solid var(--color-border);
+ }
+ }
+
+ &--username {
+ padding-inline-start: calc(var(--default-grid-baseline) * 3);
+ }
+
+ &--avatar {
+ min-width: var(--avatar-cell-width);
+ width: var(--avatar-cell-width);
+ align-items: center;
+ padding: 0;
+ user-select: none;
+ }
+
+ &--multiline {
+ span {
+ line-height: 1.3em;
+ white-space: unset;
+
+ @supports (-webkit-line-clamp: 2) {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+ }
+ }
+
+ &--large {
+ min-width: var(--cell-width-large);
+ width: var(--cell-width-large);
+ }
+
+ &--obfuscated {
+ min-width: 400px;
+ width: 400px;
+ }
+
+ // Fill remaining row space with cell
+ &--fill {
+ min-width: var(--cell-width-large);
+ width: 100%;
+ }
+
+ &--actions {
+ position: sticky;
+ inset-inline-end: 0;
+ z-index: var(--sticky-column-z-index);
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ min-width: 110px;
+ width: 110px;
+ background-color: var(--color-main-background);
+ border-inline-start: 1px solid var(--color-border);
+ }
+ }
+
+ &__subtitle {
+ color: var(--color-text-maxcontrast);
+ }
+}
diff --git a/apps/settings/src/components/WebAuthn/AddDevice.vue b/apps/settings/src/components/WebAuthn/AddDevice.vue
index 463f5d9029a..db00bae451a 100644
--- a/apps/settings/src/components/WebAuthn/AddDevice.vue
+++ b/apps/settings/src/components/WebAuthn/AddDevice.vue
@@ -1,34 +1,18 @@
<!--
- - @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
- -
- - @author Roeland Jago Douma <roeland@famdouma.nl>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div v-if="!isHttps && !isLocalhost">
{{ t('settings', 'Passwordless authentication requires a secure connection.') }}
</div>
<div v-else>
- <div v-if="step === RegistrationSteps.READY">
- <button @click="start">
- {{ t('settings', 'Add WebAuthn device') }}
- </button>
- </div>
+ <NcButton v-if="step === RegistrationSteps.READY"
+ type="primary"
+ @click="start">
+ {{ t('settings', 'Add WebAuthn device') }}
+ </NcButton>
<div v-else-if="step === RegistrationSteps.REGISTRATION"
class="new-webauthn-device">
@@ -39,13 +23,16 @@
<div v-else-if="step === RegistrationSteps.NAMING"
class="new-webauthn-device">
<span class="icon-loading-small webauthn-loading" />
- <input v-model="name"
- type="text"
- :placeholder="t('settings', 'Name your device')"
- @:keyup.enter="submit">
- <button @click="submit">
- {{ t('settings', 'Add') }}
- </button>
+ <form @submit.prevent="submit">
+ <NcTextField ref="nameInput"
+ class="new-webauthn-device__name"
+ :label="t('settings', 'Device name')"
+ :value.sync="name"
+ show-trailing-button
+ :trailing-button-label="t('settings', 'Add')"
+ trailing-button-icon="arrowRight"
+ @trailing-button-click="submit" />
+ </form>
</div>
<div v-else-if="step === RegistrationSteps.PERSIST"
@@ -61,13 +48,18 @@
</template>
<script>
-import confirmPassword from '@nextcloud/password-confirmation'
+import { showError } from '@nextcloud/dialogs'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-import logger from '../../logger'
+import logger from '../../logger.ts'
import {
startRegistration,
finishRegistration,
-} from '../../service/WebAuthnRegistrationSerice'
+} from '../../service/WebAuthnRegistrationSerice.ts'
+
+import '@nextcloud/password-confirmation/dist/style.css'
const logAndPass = (text) => (data) => {
logger.debug(text)
@@ -83,6 +75,12 @@ const RegistrationSteps = Object.freeze({
export default {
name: 'AddDevice',
+
+ components: {
+ NcButton,
+ NcTextField,
+ },
+
props: {
httpWarning: Boolean,
isHttps: {
@@ -94,83 +92,55 @@ export default {
default: false,
},
},
+
+ setup() {
+ // non reactive props
+ return {
+ RegistrationSteps,
+ }
+ },
+
data() {
return {
name: '',
credential: {},
- RegistrationSteps,
step: RegistrationSteps.READY,
}
},
- methods: {
- arrayToBase64String(a) {
- return btoa(String.fromCharCode(...a))
+
+ watch: {
+ /**
+ * Auto focus the name input when naming a device
+ */
+ step() {
+ if (this.step === RegistrationSteps.NAMING) {
+ this.$nextTick(() => this.$refs.nameInput?.focus())
+ }
},
- start() {
+ },
+
+ methods: {
+ /**
+ * Start the registration process by loading the authenticator parameters
+ * The next step is the naming of the device
+ */
+ async start() {
this.step = RegistrationSteps.REGISTRATION
console.debug('Starting WebAuthn registration')
- return confirmPassword()
- .then(this.getRegistrationData)
- .then(this.register.bind(this))
- .then(() => { this.step = RegistrationSteps.NAMING })
- .catch(err => {
- console.error(err.name, err.message)
- this.step = RegistrationSteps.READY
- })
- },
-
- getRegistrationData() {
- console.debug('Fetching webauthn registration data')
-
- const base64urlDecode = function(input) {
- // Replace non-url compatible chars with base64 standard chars
- input = input
- .replace(/-/g, '+')
- .replace(/_/g, '/')
-
- // Pad out with standard base64 required padding characters
- const pad = input.length % 4
- if (pad) {
- if (pad === 1) {
- throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding')
- }
- input += new Array(5 - pad).join('=')
- }
-
- return window.atob(input)
+ try {
+ await confirmPassword()
+ this.credential = await startRegistration()
+ this.step = RegistrationSteps.NAMING
+ } catch (err) {
+ showError(err)
+ this.step = RegistrationSteps.READY
}
-
- return startRegistration()
- .then(publicKey => {
- console.debug(publicKey)
- publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
- publicKey.user.id = Uint8Array.from(publicKey.user.id, c => c.charCodeAt(0))
- return publicKey
- })
- .catch(err => {
- console.error('Error getting webauthn registration data from server', err)
- throw new Error(t('settings', 'Server error while trying to add WebAuthn device'))
- })
- },
-
- register(publicKey) {
- console.debug('starting webauthn registration')
-
- return navigator.credentials.create({ publicKey })
- .then(data => {
- this.credential = {
- id: data.id,
- type: data.type,
- rawId: this.arrayToBase64String(new Uint8Array(data.rawId)),
- response: {
- clientDataJSON: this.arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
- attestationObject: this.arrayToBase64String(new Uint8Array(data.response.attestationObject)),
- },
- }
- })
},
+ /**
+ * Save the new device with the given name on the server
+ */
submit() {
this.step = RegistrationSteps.PERSIST
@@ -180,12 +150,12 @@ export default {
.then(logAndPass('registration data saved'))
.then(() => this.reset())
.then(logAndPass('app reset'))
- .catch(console.error.bind(this))
+ .catch(console.error)
},
async saveRegistrationData() {
try {
- const device = await finishRegistration(this.name, JSON.stringify(this.credential))
+ const device = await finishRegistration(this.name, this.credential)
logger.info('new device added', { device })
@@ -205,15 +175,20 @@ export default {
}
</script>
-<style scoped>
- .webauthn-loading {
- display: inline-block;
- vertical-align: sub;
- margin-left: 2px;
- margin-right: 2px;
- }
+<style scoped lang="scss">
+.webauthn-loading {
+ display: inline-block;
+ vertical-align: sub;
+ margin-inline: 2px;
+}
+
+.new-webauthn-device {
+ display: flex;
+ gap: 22px;
+ align-items: center;
- .new-webauthn-device {
- line-height: 300%;
+ &__name {
+ max-width: min(100vw, 400px);
}
+}
</style>
diff --git a/apps/settings/src/components/WebAuthn/Device.vue b/apps/settings/src/components/WebAuthn/Device.vue
index fc1bab3c8b0..4e10c1f234d 100644
--- a/apps/settings/src/components/WebAuthn/Device.vue
+++ b/apps/settings/src/components/WebAuthn/Device.vue
@@ -1,45 +1,29 @@
<!--
- - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div class="webauthn-device">
+ <li class="webauthn-device">
<span class="icon-webauthn-device" />
{{ name || t('settings', 'Unnamed device') }}
- <Actions :force-menu="true">
- <ActionButton icon="icon-delete" @click="$emit('delete')">
+ <NcActions :force-menu="true">
+ <NcActionButton icon="icon-delete" @click="$emit('delete')">
{{ t('settings', 'Delete') }}
- </ActionButton>
- </Actions>
- </div>
+ </NcActionButton>
+ </NcActions>
+ </li>
</template>
<script>
-import Actions from '@nextcloud/vue/dist/Components/Actions'
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
export default {
name: 'Device',
components: {
- ActionButton,
- Actions,
+ NcActionButton,
+ NcActions,
},
props: {
name: {
diff --git a/apps/settings/src/components/WebAuthn/Section.vue b/apps/settings/src/components/WebAuthn/Section.vue
index 56832e4eea3..fa818c24355 100644
--- a/apps/settings/src/components/WebAuthn/Section.vue
+++ b/apps/settings/src/components/WebAuthn/Section.vue
@@ -1,23 +1,7 @@
<!--
- - @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
- -
- - @author Roeland Jago Douma <roeland@famdouma.nl>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div id="security-webauthn" class="section">
@@ -25,22 +9,25 @@
<p class="settings-hint hidden-when-empty">
{{ t('settings', 'Set up your account for passwordless authentication following the FIDO2 standard.') }}
</p>
- <p v-if="devices.length === 0">
+ <NcNoteCard v-if="devices.length === 0" type="info">
{{ t('settings', 'No devices configured.') }}
- </p>
- <p v-else>
+ </NcNoteCard>
+
+ <h3 v-else id="security-webauthn__active-devices">
{{ t('settings', 'The following devices are configured for your account:') }}
- </p>
- <Device v-for="device in sortedDevices"
- :key="device.id"
- :name="device.name"
- @delete="deleteDevice(device.id)" />
+ </h3>
+ <ul aria-labelledby="security-webauthn__active-devices" class="security-webauthn__device-list">
+ <Device v-for="device in sortedDevices"
+ :key="device.id"
+ :name="device.name"
+ @delete="deleteDevice(device.id)" />
+ </ul>
- <p v-if="!hasPublicKeyCredential" class="warning">
+ <NcNoteCard v-if="!supportsWebauthn" type="warning">
{{ t('settings', 'Your browser does not support WebAuthn.') }}
- </p>
+ </NcNoteCard>
- <AddDevice v-if="hasPublicKeyCredential"
+ <AddDevice v-if="supportsWebauthn"
:is-https="isHttps"
:is-localhost="isLocalhost"
@added="deviceAdded" />
@@ -48,13 +35,17 @@
</template>
<script>
-import confirmPassword from '@nextcloud/password-confirmation'
-import sortBy from 'lodash/fp/sortBy'
+import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import sortBy from 'lodash/fp/sortBy.js'
-import AddDevice from './AddDevice'
-import Device from './Device'
-import logger from '../../logger'
-import { removeRegistration } from '../../service/WebAuthnRegistrationSerice'
+import AddDevice from './AddDevice.vue'
+import Device from './Device.vue'
+import logger from '../../logger.ts'
+import { removeRegistration } from '../../service/WebAuthnRegistrationSerice.js'
+
+import '@nextcloud/password-confirmation/dist/style.css'
const sortByName = sortBy('name')
@@ -62,6 +53,7 @@ export default {
components: {
AddDevice,
Device,
+ NcNoteCard,
},
props: {
initialDevices: {
@@ -76,11 +68,15 @@ export default {
type: Boolean,
default: false,
},
- hasPublicKeyCredential: {
- type: Boolean,
- default: false,
- },
},
+
+ setup() {
+ // Non reactive properties
+ return {
+ supportsWebauthn: browserSupportsWebAuthn(),
+ }
+ },
+
data() {
return {
devices: this.initialDevices,
@@ -112,5 +108,7 @@ export default {
</script>
<style scoped>
-
+.security-webauthn__device-list {
+ margin-block: 12px 18px;
+}
</style>