aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src')
-rw-r--r--apps/settings/src/App.vue38
-rw-r--r--apps/settings/src/admin-settings-sharing.ts22
-rw-r--r--apps/settings/src/admin.js88
-rw-r--r--apps/settings/src/app-types.ts118
-rw-r--r--apps/settings/src/apps.js4
-rw-r--r--apps/settings/src/components/AdminAI.vue137
-rw-r--r--apps/settings/src/components/AdminDelegating.vue6
-rw-r--r--apps/settings/src/components/AdminDelegation/GroupSelect.vue8
-rw-r--r--apps/settings/src/components/AdminSettingsSharingForm.vue184
-rw-r--r--apps/settings/src/components/AdminTwoFactor.vue19
-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.vue262
-rw-r--r--apps/settings/src/components/AppList.vue257
-rw-r--r--apps/settings/src/components/AppList/AppDaemonBadge.vue37
-rw-r--r--apps/settings/src/components/AppList/AppItem.vue336
-rw-r--r--apps/settings/src/components/AppList/AppLevelBadge.vue56
-rw-r--r--apps/settings/src/components/AppList/AppScore.vue94
-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.vue45
-rw-r--r--apps/settings/src/components/AuthTokenList.vue23
-rw-r--r--apps/settings/src/components/AuthTokenSection.vue23
-rw-r--r--apps/settings/src/components/AuthTokenSetup.vue31
-rw-r--r--apps/settings/src/components/AuthTokenSetupDialog.vue31
-rw-r--r--apps/settings/src/components/BasicSettings/BackgroundJob.vue44
-rw-r--r--apps/settings/src/components/BasicSettings/ProfileSettings.vue30
-rw-r--r--apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue275
-rw-r--r--apps/settings/src/components/Encryption.vue210
-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.vue54
-rw-r--r--apps/settings/src/components/Markdown.cy.ts58
-rw-r--r--apps/settings/src/components/Markdown.vue100
-rw-r--r--apps/settings/src/components/PasswordSection.vue26
-rw-r--r--apps/settings/src/components/PersonalInfo/AvatarSection.vue36
-rw-r--r--apps/settings/src/components/PersonalInfo/BiographySection.vue23
-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.vue42
-rw-r--r--apps/settings/src/components/PersonalInfo/DisplayNameSection.vue21
-rw-r--r--apps/settings/src/components/PersonalInfo/EmailSection/Email.vue303
-rw-r--r--apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue44
-rw-r--r--apps/settings/src/components/PersonalInfo/FediverseSection.vue74
-rw-r--r--apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue126
-rw-r--r--apps/settings/src/components/PersonalInfo/HeadlineSection.vue21
-rw-r--r--apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue86
-rw-r--r--apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue46
-rw-r--r--apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue107
-rw-r--r--apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue36
-rw-r--r--apps/settings/src/components/PersonalInfo/LocationSection.vue21
-rw-r--r--apps/settings/src/components/PersonalInfo/OrganisationSection.vue21
-rw-r--r--apps/settings/src/components/PersonalInfo/PhoneSection.vue25
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue23
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue27
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue35
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue23
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue23
-rw-r--r--apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue28
-rw-r--r--apps/settings/src/components/PersonalInfo/PronounsSection.vue47
-rw-r--r--apps/settings/src/components/PersonalInfo/RoleSection.vue21
-rw-r--r--apps/settings/src/components/PersonalInfo/TwitterSection.vue56
-rw-r--r--apps/settings/src/components/PersonalInfo/WebsiteSection.vue21
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue171
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/FederationControl.vue79
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue94
-rw-r--r--apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue40
-rw-r--r--apps/settings/src/components/PrefixMixin.vue32
-rw-r--r--apps/settings/src/components/SelectSharingPermissions.vue23
-rw-r--r--apps/settings/src/components/SvgFilterMixin.vue23
-rw-r--r--apps/settings/src/components/UserList.vue126
-rw-r--r--apps/settings/src/components/Users/NewUserDialog.vue (renamed from apps/settings/src/components/Users/NewUserModal.vue)288
-rw-r--r--apps/settings/src/components/Users/UserListFooter.vue43
-rw-r--r--apps/settings/src/components/Users/UserListHeader.vue54
-rw-r--r--apps/settings/src/components/Users/UserRow.vue350
-rw-r--r--apps/settings/src/components/Users/UserRowActions.vue56
-rw-r--r--apps/settings/src/components/Users/UserSettingsDialog.vue179
-rw-r--r--apps/settings/src/components/Users/VirtualList.vue28
-rw-r--r--apps/settings/src/components/Users/shared/styles.scss35
-rw-r--r--apps/settings/src/components/WebAuthn/AddDevice.vue186
-rw-r--r--apps/settings/src/components/WebAuthn/Device.vue30
-rw-r--r--apps/settings/src/components/WebAuthn/Section.vue63
-rw-r--r--apps/settings/src/composables/useAppIcon.ts62
-rw-r--r--apps/settings/src/composables/useGetLocalizedValue.ts29
-rw-r--r--apps/settings/src/composables/useGroupsNavigation.ts59
-rw-r--r--apps/settings/src/constants/AccountPropertyConstants.ts (renamed from apps/settings/src/constants/AccountPropertyConstants.js)87
-rw-r--r--apps/settings/src/constants/AppDiscoverTypes.ts117
-rw-r--r--apps/settings/src/constants/AppsConstants.js22
-rw-r--r--apps/settings/src/constants/AppstoreCategoryIcons.ts63
-rw-r--r--apps/settings/src/constants/GroupManagement.ts12
-rw-r--r--apps/settings/src/constants/ProfileConstants.js23
-rw-r--r--apps/settings/src/logger.js28
-rw-r--r--apps/settings/src/logger.ts11
-rw-r--r--apps/settings/src/main-admin-ai.js27
-rw-r--r--apps/settings/src/main-admin-basic-settings.js27
-rw-r--r--apps/settings/src/main-admin-delegation.js21
-rw-r--r--apps/settings/src/main-admin-security.js31
-rw-r--r--apps/settings/src/main-apps-users-management.js55
-rw-r--r--apps/settings/src/main-apps-users-management.ts40
-rw-r--r--apps/settings/src/main-declarative-settings-forms.ts62
-rw-r--r--apps/settings/src/main-nextcloud-pdf.js21
-rw-r--r--apps/settings/src/main-personal-info.js61
-rw-r--r--apps/settings/src/main-personal-password.js25
-rw-r--r--apps/settings/src/main-personal-security.js33
-rw-r--r--apps/settings/src/main-personal-webauth.js28
-rw-r--r--apps/settings/src/mixins/AppManagement.js200
-rw-r--r--apps/settings/src/mixins/UserRowMixin.js109
-rw-r--r--apps/settings/src/router.js136
-rw-r--r--apps/settings/src/router/index.ts22
-rw-r--r--apps/settings/src/router/routes.ts63
-rw-r--r--apps/settings/src/service/PersonalInfo/EmailService.js28
-rw-r--r--apps/settings/src/service/PersonalInfo/PersonalInfoService.js28
-rw-r--r--apps/settings/src/service/ProfileService.js21
-rw-r--r--apps/settings/src/service/WebAuthnRegistrationSerice.js54
-rw-r--r--apps/settings/src/service/WebAuthnRegistrationSerice.ts57
-rw-r--r--apps/settings/src/service/groups.ts83
-rw-r--r--apps/settings/src/service/rebuild-navigation.js4
-rw-r--r--apps/settings/src/store/admin-security.js22
-rw-r--r--apps/settings/src/store/api.js25
-rw-r--r--apps/settings/src/store/app-api-store.ts325
-rw-r--r--apps/settings/src/store/apps-store.ts87
-rw-r--r--apps/settings/src/store/apps.js67
-rw-r--r--apps/settings/src/store/authtoken.ts24
-rw-r--r--apps/settings/src/store/index.js55
-rw-r--r--apps/settings/src/store/oc.js22
-rw-r--r--apps/settings/src/store/settings.js38
-rw-r--r--apps/settings/src/store/users-settings.js23
-rw-r--r--apps/settings/src/store/users.js255
-rw-r--r--apps/settings/src/utils/appDiscoverParser.spec.ts79
-rw-r--r--apps/settings/src/utils/appDiscoverParser.ts48
-rw-r--r--apps/settings/src/utils/handlers.js48
-rw-r--r--apps/settings/src/utils/handlers.ts33
-rw-r--r--apps/settings/src/utils/sorting.ts14
-rw-r--r--apps/settings/src/utils/userUtils.ts29
-rw-r--r--apps/settings/src/utils/validate.js23
-rw-r--r--apps/settings/src/views/AdminSettingsSharing.vue30
-rw-r--r--apps/settings/src/views/AppStore.vue88
-rw-r--r--apps/settings/src/views/AppStoreNavigation.vue146
-rw-r--r--apps/settings/src/views/AppStoreSidebar.vue159
-rw-r--r--apps/settings/src/views/Apps.vue414
-rw-r--r--apps/settings/src/views/SettingsApp.vue16
-rw-r--r--apps/settings/src/views/UserManagement.vue104
-rw-r--r--apps/settings/src/views/UserManagementNavigation.vue172
-rw-r--r--apps/settings/src/views/Users.vue393
-rw-r--r--apps/settings/src/views/user-types.d.ts35
-rw-r--r--apps/settings/src/webpack.shim.d.ts5
159 files changed, 8297 insertions, 4941 deletions
diff --git a/apps/settings/src/App.vue b/apps/settings/src/App.vue
deleted file mode 100644
index f7f81da5063..00000000000
--- a/apps/settings/src/App.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<!--
- - @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/>.
- -
- -->
-
-<template>
- <router-view />
-</template>
-
-<script>
-export default {
- name: 'App',
- beforeMount() {
- // importing server data into the store
- const serverDataElmt = document.getElementById('serverData')
- if (serverDataElmt !== null) {
- this.$store.commit('setServerData', JSON.parse(document.getElementById('serverData').dataset.server))
- }
- },
-}
-</script>
diff --git a/apps/settings/src/admin-settings-sharing.ts b/apps/settings/src/admin-settings-sharing.ts
index 2cb269f9a5d..ae73cbd519a 100644
--- a/apps/settings/src/admin-settings-sharing.ts
+++ b/apps/settings/src/admin-settings-sharing.ts
@@ -1,25 +1,7 @@
/**
- * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
import Vue from 'vue'
import AdminSettingsSharing from './views/AdminSettingsSharing.vue'
diff --git a/apps/settings/src/admin.js b/apps/settings/src/admin.js
index 35f5266acba..66848162d28 100644
--- a/apps/settings/src/admin.js
+++ b/apps/settings/src/admin.js
@@ -1,6 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { generateUrl } from '@nextcloud/router'
+import $ from 'jquery'
+import axios from '@nextcloud/axios'
+
window.addEventListener('DOMContentLoaded', () => {
$('#loglevel').change(function() {
- $.post(OC.generateUrl('/settings/admin/log/level'), { level: $(this).val() }, () => {
+ $.post(generateUrl('/settings/admin/log/level'), { level: $(this).val() }, () => {
OC.Log.reload()
})
})
@@ -40,17 +49,12 @@ window.addEventListener('DOMContentLoaded', () => {
}
OC.msg.startSaving('#mail_settings_msg')
- $.ajax({
- url: OC.generateUrl('/settings/admin/mailsettings'),
- type: 'POST',
- data: $('#mail_general_settings_form').serialize(),
- success: () => {
+ axios.post(generateUrl('/settings/admin/mailsettings'), $('#mail_general_settings_form').serialize())
+ .then(() => {
OC.msg.finishedSuccess('#mail_settings_msg', t('settings', 'Saved'))
- },
- error: (xhr) => {
- OC.msg.finishedError('#mail_settings_msg', xhr.responseJSON)
- },
- })
+ }).catch((error) => {
+ OC.msg.finishedError('#mail_settings_msg', error)
+ })
}
const toggleEmailCredentials = function() {
@@ -60,17 +64,12 @@ window.addEventListener('DOMContentLoaded', () => {
}
OC.msg.startSaving('#mail_settings_msg')
- $.ajax({
- url: OC.generateUrl('/settings/admin/mailsettings/credentials'),
- type: 'POST',
- data: $('#mail_credentials_settings').serialize(),
- success: () => {
+ axios.post(generateUrl('/settings/admin/mailsettings/credentials'), $('#mail_credentials_settings').serialize())
+ .then(() => {
OC.msg.finishedSuccess('#mail_settings_msg', t('settings', 'Saved'))
- },
- error: (xhr) => {
- OC.msg.finishedError('#mail_settings_msg', xhr.responseJSON)
- },
- })
+ }).catch((error) => {
+ OC.msg.finishedError('#mail_settings_msg', error)
+ })
}
$('#mail_general_settings_form').change(changeEmailSettings)
@@ -86,38 +85,22 @@ window.addEventListener('DOMContentLoaded', () => {
event.preventDefault()
OC.msg.startAction('#sendtestmail_msg', t('settings', 'Sending…'))
- $.ajax({
- url: OC.generateUrl('/settings/admin/mailtest'),
- type: 'POST',
- success: () => {
+ axios.post(generateUrl('/settings/admin/mailtest'))
+ .then(() => {
OC.msg.finishedSuccess('#sendtestmail_msg', t('settings', 'Email sent'))
- },
- error: (xhr) => {
- OC.msg.finishedError('#sendtestmail_msg', xhr.responseJSON)
- },
- })
+ }).catch((error) => {
+ OC.msg.finishedError('#sendtestmail_msg', error)
+ })
})
const setupChecks = () => {
// run setup checks then gather error messages
$.when(
- OC.SetupChecks.checkWebDAV(),
- OC.SetupChecks.checkWellKnownUrl('GET', '/.well-known/webfinger', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true, [200, 404], true),
- OC.SetupChecks.checkWellKnownUrl('GET', '/.well-known/nodeinfo', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true, [200, 404], true),
- OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/caldav', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true),
- OC.SetupChecks.checkWellKnownUrl('PROPFIND', '/.well-known/carddav', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true),
- OC.SetupChecks.checkProviderUrl(OC.getRootPath() + '/ocm-provider/', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true),
- OC.SetupChecks.checkProviderUrl(OC.getRootPath() + '/ocs-provider/', OC.theme.docPlaceholderUrl, $('#postsetupchecks').data('check-wellknown') === true),
OC.SetupChecks.checkSetup(),
- OC.SetupChecks.checkGeneric(),
- OC.SetupChecks.checkWOFF2Loading(OC.filePath('core', '', 'fonts/NotoSans-Regular-latin.woff2'), OC.theme.docPlaceholderUrl),
- OC.SetupChecks.checkDataProtected(),
- ).then((check1, check2, check3, check4, check5, check6, check7, check8, check9, check10, check11) => {
- const messages = [].concat(check1, check2, check3, check4, check5, check6, check7, check8, check9, check10, check11)
+ ).then((messages) => {
const $el = $('#postsetupchecks')
$('#security-warning-state-loading').addClass('hidden')
- let hasMessages = false
const $errorsEl = $el.find('.errors')
const $warningsEl = $el.find('.warnings')
const $infoEl = $el.find('.info')
@@ -136,33 +119,30 @@ window.addEventListener('DOMContentLoaded', () => {
}
}
+ let hasErrors = false
+ let hasWarnings = false
+
if ($errorsEl.find('li').length > 0) {
$errorsEl.removeClass('hidden')
- hasMessages = true
+ hasErrors = true
}
if ($warningsEl.find('li').length > 0) {
$warningsEl.removeClass('hidden')
- hasMessages = true
+ hasWarnings = true
}
if ($infoEl.find('li').length > 0) {
$infoEl.removeClass('hidden')
- hasMessages = true
}
- if (hasMessages) {
+ if (hasErrors || hasWarnings) {
$('#postsetupchecks-hint').removeClass('hidden')
- if ($errorsEl.find('li').length > 0) {
+ if (hasErrors) {
$('#security-warning-state-failure').removeClass('hidden')
} else {
$('#security-warning-state-warning').removeClass('hidden')
}
} else {
- const securityWarning = $('#security-warning')
- if (securityWarning.children('ul').children().length === 0) {
- $('#security-warning-state-ok').removeClass('hidden')
- } else {
- $('#security-warning-state-failure').removeClass('hidden')
- }
+ $('#security-warning-state-ok').removeClass('hidden')
}
})
}
diff --git a/apps/settings/src/app-types.ts b/apps/settings/src/app-types.ts
new file mode 100644
index 00000000000..0c448ca907c
--- /dev/null
+++ b/apps/settings/src/app-types.ts
@@ -0,0 +1,118 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export interface IAppstoreCategory {
+ /**
+ * The category ID
+ */
+ id: string
+ /**
+ * The display name (can be localized)
+ */
+ displayName: string
+ /**
+ * Inline SVG path
+ */
+ icon: string
+}
+
+export interface IAppstoreAppRelease {
+ version: string
+ translations: {
+ [key: string]: {
+ changelog: string
+ }
+ }
+}
+
+export interface IAppstoreApp {
+ id: string
+ name: string
+ summary: string
+ description: string
+ licence: string
+ author: string[] | Record<string, string>
+ level: number
+ version: string
+ category: string|string[]
+
+ preview?: string
+ screenshot?: string
+
+ app_api: boolean
+ active: boolean
+ internal: boolean
+ removable: boolean
+ installed: boolean
+ canInstall: boolean
+ canUnInstall: boolean
+ isCompatible: boolean
+ needsDownload: boolean
+ update?: string
+
+ appstoreData: Record<string, never>
+ releases?: IAppstoreAppRelease[]
+}
+
+export interface IComputeDevice {
+ id: string,
+ label: string,
+}
+
+export interface IDeployConfig {
+ computeDevice: IComputeDevice,
+ net: string,
+ nextcloud_url: string,
+}
+
+export interface IDeployDaemon {
+ accepts_deploy_id: string,
+ deploy_config: IDeployConfig,
+ display_name: string,
+ host: string,
+ id: number,
+ name: string,
+ protocol: string,
+ exAppsCount: number,
+}
+
+export interface IExAppStatus {
+ action: string
+ deploy: number
+ deploy_start_time: number
+ error: string
+ init: number
+ init_start_time: number
+ type: string
+}
+
+export interface IDeployEnv {
+ envName: string
+ displayName: string
+ description: string
+ default?: string
+}
+
+export interface IDeployMount {
+ hostPath: string
+ containerPath: string
+ readOnly: boolean
+}
+
+export interface IDeployOptions {
+ environment_variables: IDeployEnv[]
+ mounts: IDeployMount[]
+}
+
+export interface IAppstoreExAppRelease extends IAppstoreAppRelease {
+ environmentVariables?: IDeployEnv[]
+}
+
+export interface IAppstoreExApp extends IAppstoreApp {
+ daemon: IDeployDaemon | null | undefined
+ status: IExAppStatus | Record<string, never>
+ error: string
+ releases: IAppstoreExAppRelease[]
+}
diff --git a/apps/settings/src/apps.js b/apps/settings/src/apps.js
index 61eea28f9d9..b6431c943b8 100644
--- a/apps/settings/src/apps.js
+++ b/apps/settings/src/apps.js
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
import rebuildNavigation from './service/rebuild-navigation.js'
window.OC.Settings = window.OC.Settings || {}
diff --git a/apps/settings/src/components/AdminAI.vue b/apps/settings/src/components/AdminAI.vue
index 6a3f30451e9..0d3e9154bb9 100644
--- a/apps/settings/src/components/AdminAI.vue
+++ b/apps/settings/src/components/AdminAI.vue
@@ -1,5 +1,47 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div>
+ <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">
@@ -18,24 +60,6 @@
</div>
</draggable>
</NcSettingsSection>
- <NcSettingsSection :name="t('settings', 'Speech-To-Text')"
- :description="t('settings', 'Speech-To-Text can be implemented by different apps. Here you can set which app should be used.')">
- <template v-for="provider in sttProviders">
- <NcCheckboxRadioSwitch :key="provider.class"
- :checked.sync="settings['ai.stt_provider']"
- :value="provider.class"
- name="stt_provider"
- type="radio"
- @update:checked="saveChanges">
- {{ provider.name }}
- </NcCheckboxRadioSwitch>
- </template>
- <template v-if="!hasStt">
- <NcCheckboxRadioSwitch disabled type="radio">
- {{ t('settings', 'None of your currently installed apps provide Speech-To-Text functionality') }}
- </NcCheckboxRadioSwitch>
- </template>
- </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">
@@ -49,19 +73,20 @@
</NcCheckboxRadioSwitch>
</template>
<template v-if="!hasText2ImageProviders">
- <NcCheckboxRadioSwitch disabled type="radio">
+ <NcNoteCard type="info">
{{ t('settings', 'None of your currently installed apps provide image generation functionality') }}
- </NcCheckboxRadioSwitch>
+ </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:') }} {{ getTaskType(type).name }}</h3>
- <p>{{ getTaskType(type).description }}</p>
+ <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">
@@ -75,8 +100,11 @@
<p>&nbsp;</p>
</div>
</template>
- <template v-if="!hasTextProcessing">
- <p>{{ t('settings', 'None of your currently installed apps provide Text processing functionality') }}</p>
+ <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>
@@ -84,16 +112,17 @@
<script>
import axios from '@nextcloud/axios'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import 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 {
@@ -107,6 +136,7 @@ export default {
ArrowDownIcon,
ArrowUpIcon,
NcButton,
+ NcNoteCard,
},
data() {
return {
@@ -119,22 +149,32 @@ export default {
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: {
- hasStt() {
- return this.sttProviders.length > 0
- },
hasTextProcessing() {
return Object.keys(this.settings['ai.textprocessing_provider_preferences']).length > 0 && Array.isArray(this.textProcessingTaskTypes)
},
tpTaskTypes() {
- return Object.keys(this.settings['ai.textprocessing_provider_preferences']).filter(type => !!this.getTaskType(type))
+ 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) {
@@ -155,6 +195,7 @@ export default {
},
async saveChanges() {
this.loading = true
+ await nextTick()
const data = { settings: this.settings }
try {
await axios.put(generateUrl('/settings/api/admin/ai'), data)
@@ -163,7 +204,7 @@ export default {
}
this.loading = false
},
- getTaskType(type) {
+ getTextProcessingTaskType(type) {
if (!Array.isArray(this.textProcessingTaskTypes)) {
return null
}
@@ -186,13 +227,31 @@ export default {
.draggable__number {
border-radius: 20px;
- border: 2px solid var(--color-primary-default);
- color: var(--color-primary-default);
- padding: 0px 7px;
- margin-right: 3px;
+ 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 c614b2bd2f4..521ff8f0155 100644
--- a/apps/settings/src/components/AdminDelegating.vue
+++ b/apps/settings/src/components/AdminDelegating.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcSettingsSection :name="t('settings', 'Administration privileges')"
:description="t('settings', 'Here you can decide which group can access certain sections of the administration settings.')"
@@ -13,7 +17,7 @@
<script>
import GroupSelect from './AdminDelegation/GroupSelect.vue'
-import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { loadState } from '@nextcloud/initial-state'
export default {
diff --git a/apps/settings/src/components/AdminDelegation/GroupSelect.vue b/apps/settings/src/components/AdminDelegation/GroupSelect.vue
index 91593516760..28d3deb0afa 100644
--- a/apps/settings/src/components/AdminDelegation/GroupSelect.vue
+++ b/apps/settings/src/components/AdminDelegation/GroupSelect.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcSelect v-model="selected"
:input-id="setting.id"
@@ -10,11 +14,11 @@
</template>
<script>
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
-import logger from '../../logger.js'
+import logger from '../../logger.ts'
export default {
name: 'GroupSelect',
diff --git a/apps/settings/src/components/AdminSettingsSharingForm.vue b/apps/settings/src/components/AdminSettingsSharingForm.vue
index de23adf67d2..b0e142d8480 100644
--- a/apps/settings/src/components/AdminSettingsSharingForm.vue
+++ b/apps/settings/src/components/AdminSettingsSharingForm.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<form class="sharing">
@@ -37,6 +20,22 @@
<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">
@@ -49,15 +48,19 @@
<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.passwordExcludedGroupsFeatureEnabled" class="sharing__labeled-entry sharing__input">
+ <label v-if="settings.enforceLinksPasswordExcludedGroupsEnabled" class="sharing__labeled-entry sharing__input">
<span>{{ t('settings', 'Exclude groups from password requirements') }}</span>
- <NcSettingsSelectGroup v-model="settings.passwordExcludedGroups"
+ <NcSettingsSelectGroup v-model="settings.enforceLinksPasswordExcludedGroups"
style="width: 100%"
:disabled="!settings.enforceLinksPassword || !settings.enableLinkPasswordByDefault" />
</label>
@@ -69,26 +72,62 @@
</label>
</fieldset>
- <NcCheckboxRadioSwitch type="switch" :checked.sync="settings.excludeGroups">
- {{ t('settings', 'Exclude groups from sharing') }}
+ <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 v-show="settings.excludeGroups" class="sharing__sub-section">
- <div class="sharing__labeled-entry sharing__input">
- <label for="settings-sharing-excluded-groups">{{ t('settings', 'Groups excluded from sharing') }}</label>
+ <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="t('settings', 'Groups excluded from sharing')"
- :disabled="!settings.excludeGroups"
+ :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', 'These groups will still be able to receive shares, but not to initiate them.') }}</em>
+ <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 shares') }}
+ {{ 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">
@@ -123,9 +162,9 @@
: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-link-api-expiration" class="sharing__sub-section">
+ <fieldset v-show="settings.allowLinks && settings.defaultExpireDate" id="settings-sharing-api-api-expiration" class="sharing__sub-section">
<NcCheckboxRadioSwitch :checked.sync="settings.enforceExpireDate">
- {{ t('settings', 'Enforce expiration date for remote shares') }}
+ {{ t('settings', 'Enforce expiration date for link or mail shares') }}
</NcCheckboxRadioSwitch>
<NcTextField type="number"
class="sharing__input"
@@ -141,17 +180,17 @@
<NcCheckboxRadioSwitch type="switch"
aria-controls="settings-sharing-privacy-user-enumeration"
:checked.sync="settings.allowShareDialogUserEnumeration">
- {{ t('settings', 'Allow username autocompletion in share dialog and allow access to the system address book') }}
+ {{ 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', 'Allow username autocompletion to users within the same groups and limit system address books to users in the same groups') }}
+ {{ 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', 'Allow username autocompletion to users based on phone number integration') }}
+ {{ t('settings', 'Restrict account name autocompletion to users based on phone number integration') }}
</NcCheckboxRadioSwitch>
</fieldset>
@@ -159,17 +198,15 @@
{{ 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"
- aria-controls="settings-sharing-privary-related-disclaimer"
- :checked.sync="publicShareDisclaimerEnabled">
+ <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="typeof settings.publicShareDisclaimerText === 'string'"
- id="settings-sharing-privary-related-disclaimer"
+ <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">
@@ -186,19 +223,19 @@
</template>
<script lang="ts">
-import {
- NcCheckboxRadioSwitch,
- NcSettingsSelectGroup,
- NcTextArea,
- NcTextField,
-} from '@nextcloud/vue'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { snakeCase } from 'lodash'
import { defineComponent } from 'vue'
+import debounce from 'debounce'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcSettingsSelectGroup from '@nextcloud/vue/components/NcSettingsSelectGroup'
+import NcTextArea from '@nextcloud/vue/components/NcTextArea'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import SelectSharingPermissions from './SelectSharingPermissions.vue'
-import { snakeCase, debounce } from 'lodash'
interface IShareSettings {
enabled: boolean
@@ -208,6 +245,7 @@ interface IShareSettings {
allowPublicUpload: boolean
allowResharing: boolean
allowShareDialogUserEnumeration: boolean
+ allowFederationOnPublicShares: boolean
restrictUserEnumerationToGroup: boolean
restrictUserEnumerationToPhone: boolean
restrictUserEnumerationFullMatch: boolean
@@ -215,15 +253,16 @@ interface IShareSettings {
restrictUserEnumerationFullMatchEmail: boolean
restrictUserEnumerationFullMatchIgnoreSecondDN: boolean
enforceLinksPassword: boolean
- passwordExcludedGroups: string[]
- passwordExcludedGroupsFeatureEnabled: boolean
+ enforceLinksPasswordExcludedGroups: string[]
+ enforceLinksPasswordExcludedGroupsEnabled: boolean
onlyShareWithGroupMembers: boolean
+ onlyShareWithGroupMembersExcludeGroupList: string[]
defaultExpireDate: boolean
expireAfterNDays: string
enforceExpireDate: boolean
- excludeGroups: boolean
+ excludeGroups: string
excludeGroupsList: string[]
- publicShareDisclaimerText?: string
+ publicShareDisclaimerText: string
enableLinkPasswordByDefault: boolean
defaultPermissions: number
defaultInternalExpireDate: boolean
@@ -232,6 +271,8 @@ interface IShareSettings {
defaultRemoteExpireDate: boolean
remoteExpireAfterNDays: string
enforceRemoteExpireDate: boolean
+ allowCustomTokens: boolean
+ allowViewWithoutDownload: boolean
}
export default defineComponent({
@@ -239,13 +280,16 @@ export default defineComponent({
components: {
NcCheckboxRadioSwitch,
NcSettingsSelectGroup,
+ NcNoteCard,
NcTextArea,
NcTextField,
SelectSharingPermissions,
},
data() {
+ const settingsData = loadState<IShareSettings>('settings', 'sharingSettings')
return {
- settingsData: loadState<IShareSettings>('settings', 'sharingSettings'),
+ settingsData,
+ publicShareDisclaimerEnabled: settingsData.publicShareDisclaimerText !== '',
}
},
computed: {
@@ -264,26 +308,24 @@ export default defineComponent({
},
})
},
- publicShareDisclaimerEnabled: {
- get() {
- return typeof this.settingsData.publicShareDisclaimerText === 'string'
- },
- set(value) {
- if (value) {
- this.settingsData.publicShareDisclaimerText = ''
- } else {
- this.onUpdateDisclaimer()
- }
- },
+ },
+
+ watch: {
+ publicShareDisclaimerEnabled() {
+ // When disabled we just remove the disclaimer content
+ if (this.publicShareDisclaimerEnabled === false) {
+ this.onUpdateDisclaimer('')
+ }
},
},
+
methods: {
t,
- onUpdateDisclaimer: debounce(function(value?: string) {
+ onUpdateDisclaimer: debounce(function(value: string) {
const options = {
success() {
- if (value) {
+ if (value !== '') {
showSuccess(t('settings', 'Changed disclaimer text'))
} else {
showSuccess(t('settings', 'Deleted disclaimer text'))
@@ -293,13 +335,17 @@ export default defineComponent({
showError(t('settings', 'Could not set disclaimer text'))
},
}
- if (!value) {
+ 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>
@@ -342,6 +388,10 @@ export default defineComponent({
width: 100%;
}
}
+
+ & &__note {
+ margin: 2px 0;
+ }
}
@media only screen and (max-width: 350px) {
diff --git a/apps/settings/src/components/AdminTwoFactor.vue b/apps/settings/src/components/AdminTwoFactor.vue
index aba6dc7537f..e24bee02593 100644
--- a/apps/settings/src/components/AdminTwoFactor.vue
+++ b/apps/settings/src/components/AdminTwoFactor.vue
@@ -1,6 +1,10 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<NcSettingsSection :name="t('settings', 'Two-Factor Authentication')"
- :description="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.')"
+ :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" />
@@ -50,7 +54,7 @@
<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>
@@ -67,10 +71,10 @@
<script>
import axios from '@nextcloud/axios'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { loadState } from '@nextcloud/initial-state'
import sortedUniq from 'lodash/sortedUniq.js'
@@ -171,8 +175,7 @@ export default {
.two-factor-loading {
display: inline-block;
vertical-align: sub;
- margin-left: -2px;
- margin-right: 1px;
+ margin-inline: -2px 1px;
}
.top-margin {
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 0440741f4c9..00000000000
--- a/apps/settings/src/components/AppDetails.vue
+++ /dev/null
@@ -1,262 +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="">
- <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"
- @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)"
- :title="enableButtonTooltip"
- :aria-label="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"
- :title="forceEnableButtonTooltip"
- :aria-label="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 NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-
-import AppManagement from '../mixins/AppManagement.js'
-import PrefixMixin from './PrefixMixin.vue'
-import Markdown from './Markdown.vue'
-
-export default {
- name: 'AppDetails',
-
- components: {
- NcSelect,
- 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;
- a.appslink {
- display: block;
- }
- }
- &__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 18c4331e0f9..3e40e08b257 100644
--- a/apps/settings/src/components/AppList.vue
+++ b/apps/settings/src/components/AppList.vue
@@ -1,30 +1,18 @@
<!--
- - @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) }}
<NcButton v-if="showUpdateAll"
id="app-list-update-all"
@@ -34,25 +22,25 @@
</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="table" class="apps-list-container">
- <tr key="app-list-view-header" class="apps-header">
- <th class="app-image">
+ <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 class="app-name">
+ <th>
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
</th>
- <th class="app-version">
+ <th>
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
</th>
- <th class="app-level">
+ <th>
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
</th>
- <th class="actions">
+ <th>
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
</th>
</tr>
@@ -60,33 +48,33 @@
:key="app.id"
:app="app"
:category="category" />
- </transition-group>
+ </TransitionGroup>
</template>
<table v-if="useBundleView"
- class="apps-list-container">
- <tr key="app-list-view-header" class="apps-header">
- <th id="app-table-col-icon" class="app-image">
+ 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" class="app-name">
+ <th id="app-table-col-name">
<span class="hidden-visually">{{ t('settings', 'Name') }}</span>
</th>
- <th id="app-table-col-version" class="app-version">
+ <th id="app-table-col-version">
<span class="hidden-visually">{{ t('settings', 'Version') }}</span>
</th>
- <th id="app-table-col-level" class="app-level">
+ <th id="app-table-col-level">
<span class="hidden-visually">{{ t('settings', 'Level') }}</span>
</th>
- <th id="app-table-col-actions" class="actions">
+ <th id="app-table-col-actions">
<span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
</th>
</tr>
<template v-for="bundle in bundles">
<tr :key="bundle.id">
<th :id="`app-table-rowgroup-${bundle.id}`" colspan="5" scope="rowgroup">
- <div class="app-bundle-heading">
- <span class="app-bundle-header">
+ <div class="apps-list__bundle-heading">
+ <span class="apps-list__bundle-header">
{{ bundle.name }}
</span>
<NcButton type="secondary" @click="toggleBundle(bundle.id)">
@@ -103,7 +91,7 @@
:category="category" />
</template>
</table>
- <ul v-if="useAppStoreView" class="apps-store-view">
+ <ul v-if="useAppStoreView" class="apps-list__store-container">
<AppItem v-for="app in apps"
:key="app.id"
:app="app"
@@ -112,20 +100,34 @@
</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" />
- </template>
+ </table>
</div>
</div>
@@ -133,16 +135,17 @@
<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.vue'
-import PrefixMixin from './PrefixMixin.vue'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import pLimit from 'p-limit'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import AppItem from './AppList/AppItem.vue'
+import AppManagement from '../mixins/AppManagement'
+import { useAppApiStore } from '../store/app-api-store'
+import { useAppsStore } from '../store/apps-store'
export default {
name: 'AppList',
@@ -150,14 +153,40 @@ export default {
AppItem,
NcButton,
},
- mixins: [PrefixMixin],
- props: ['category', 'app', 'search'],
+
+ mixins: [AppManagement],
+
+ props: {
+ category: {
+ type: String,
+ required: true,
+ },
+ },
+
+ setup() {
+ const appApiStore = useAppApiStore()
+ const store = useAppsStore()
+
+ return {
+ appApiStore,
+ store,
+ }
+ },
+
+ data() {
+ return {
+ 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
@@ -166,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') {
@@ -197,6 +232,7 @@ export default {
// 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
@@ -204,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) {
@@ -218,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))
@@ -249,7 +286,24 @@ export default {
}
},
},
+
+ 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)
@@ -275,28 +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>
- .app-bundle-heading {
+$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;
- margin: 20px 10px 20px 0;
}
- .app-bundle-header {
- margin: 0 10px 0 50px;
+
+ &--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-store-view {
- display: flex;
- flex-wrap: wrap;
+}
+
+#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 0838f2c8822..95a98a93cde 100644
--- a/apps/settings/src/components/AppList/AppItem.vue
+++ b/apps/settings/src/components/AppList/AppItem.vue
@@ -1,35 +1,26 @@
<!--
- - @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>
- <component :is="listView ? `tr` : `li`"
- class="section"
- :class="{ selected: isSelected }">
+ <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="(listView && !app.preview) || (!listView && !screenshotLoaded)" class="icon-settings-dark" />
+ <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">
@@ -47,7 +38,14 @@
<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 }}"
+ <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>
@@ -67,26 +65,21 @@
</component>
<component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-level`)" class="app-level">
- <span v-if="app.level === 300"
- :title="t('settings', 'This app is supported via your current Nextcloud subscription.')"
- :aria-label="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"
- :title="t('settings', 'Featured apps are developed by and within the community. They offer central functionality and are ready for production use.')"
- :aria-label="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>
+ <AppLevelBadge :level="app.level" />
<AppScore v-if="hasRating && !listView" :score="app.score" />
</component>
- <component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-actions`)" class="actions">
+ <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" />
+ <div v-if="isLoading || isInitializing" class="icon icon-loading-small" />
<NcButton v-if="app.update"
type="primary"
- :disabled="installing || isLoading"
+ :disabled="installing || isLoading || !defaultDeployDaemonAccessible || isManualInstall"
+ :title="updateButtonText"
@click.stop="update(app.id)">
{{ t('settings', 'Update to {update}', {update:app.update}) }}
</NcButton>
@@ -98,46 +91,66 @@
{{ t('settings', 'Remove') }}
</NcButton>
<NcButton v-if="app.active"
- :disabled="installing || isLoading"
+ :disabled="installing || isLoading || isInitializing || isDeploying"
@click.stop="disable(app.id)">
- {{ t('settings','Disable') }}
+ {{ disableButtonText }}
</NcButton>
<NcButton v-if="!app.active && (app.canInstall || app.isCompatible)"
:title="enableButtonTooltip"
:aria-label="enableButtonTooltip"
type="primary"
- :disabled="!app.canInstall || installing || isLoading"
- @click.stop="enable(app.id)">
+ :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"
+ :disabled="installing || isLoading || !defaultDeployDaemonAccessible"
@click.stop="forceEnable(app.id)">
{{ forceEnableButtonText }}
</NcButton>
+
+ <DaemonSelectionDialog v-if="app?.app_api && showSelectDaemonModal"
+ :show.sync="showSelectDaemonModal"
+ :app="app" />
</component>
</component>
</template>
<script>
+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/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { mdiCogOutline } from '@mdi/js'
+import { useAppApiStore } from '../../store/app-api-store.ts'
+import DaemonSelectionDialog from '../AppAPI/DaemonSelectionDialog.vue'
export default {
name: 'AppItem',
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,
@@ -150,12 +163,27 @@ export default {
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: {
@@ -165,6 +193,12 @@ export default {
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) {
@@ -175,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
@@ -192,26 +226,210 @@ export default {
getDataItemHeaders(columnName) {
return this.useBundleView ? [this.headers, columnName].join(' ') : null
},
+ showSelectionModal() {
+ this.showSelectDaemonModal = true
+ },
+ async enableButtonAction() {
+ if (!this.app?.app_api) {
+ this.enable(this.app.id)
+ return
+ }
+ await this.appApiStore.fetchDockerDaemons()
+ if (this.appApiStore.dockerDaemons.length === 1 && this.app.needsDownload) {
+ this.enable(this.app.id, this.appApiStore.dockerDaemons[0])
+ } else if (this.app.needsDownload) {
+ this.showSelectionModal()
+ } else {
+ this.enable(this.app.id, this.app.daemon)
+ }
+ },
},
}
</script>
<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);
+ }
+
+ &--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 img {
- width: 100%;
-}
+.app-image {
+ position: relative;
+ height: 150px;
+ opacity: 1;
+ overflow: hidden;
-.app-name--link::after {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+ 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 69290645ef9..a1dd4c03842 100644
--- a/apps/settings/src/components/AppList/AppScore.vue
+++ b/apps/settings/src/components/AppList/AppScore.vue
@@ -1,42 +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" :alt="t('settings', 'Rating: {score}/10', {score:appScore})" 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'],
- computed: {
- appScore() {
- return Math.round(this.score * 10)
+ components: {
+ NcIconSvgWrapper,
+ },
+ props: {
+ score: {
+ type: Number,
+ required: true,
},
- scoreImage() {
- const imageName = 'rating/s' + this.appScore + '.svg'
- return imagePath('core', imageName)
+ },
+ setup() {
+ return {
+ mdiStar,
+ mdiStarHalfFull,
+ mdiStarOutline,
}
},
-}
+ computed: {
+ 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 12d801d0db1..15286adb135 100644
--- a/apps/settings/src/components/AuthToken.vue
+++ b/apps/settings/src/components/AuthToken.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- - @author Ferdinand Thiessen <opensource@fthiessen.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: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<tr :class="['auth-token', { 'auth-token--wiping': wiping }]" :data-id="token.id">
@@ -97,18 +80,18 @@
import type { PropType } from 'vue'
import type { IToken } from '../store/authtoken'
-import { mdiCheck, mdiCellphone, mdiTablet, mdiMonitor, mdiWeb, mdiKey, mdiMicrosoftEdge, mdiFirefox, mdiGoogleChrome, mdiAppleSafari, mdiAndroid, mdiAppleIos } from '@mdi/js'
+import { mdiCheck, mdiCellphone, mdiTablet, mdiMonitor, mdiWeb, mdiKeyOutline, mdiMicrosoftEdge, mdiFirefox, mdiGoogleChrome, mdiAppleSafari, mdiAndroid, mdiAppleIos } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import { TokenType, useAuthTokenStore } from '../store/authtoken.ts'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcDateTime from '@nextcloud/vue/dist/Components/NcDateTime.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
// When using capture groups the following parts are extracted the first is used as the version number, the second as the OS
const userAgentMap = {
@@ -192,8 +175,8 @@ export default defineComponent({
return this.token.type === TokenType.PERMANENT_TOKEN
},
/**
- * Object ob the current user agend used by the token
- * @return Either an object containing user agent information or null if unknown
+ * 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
@@ -232,7 +215,7 @@ export default defineComponent({
tokenIcon() {
// For custom created app tokens / app passwords
if (this.token.type === TokenType.PERMANENT_TOKEN) {
- return mdiKey
+ return mdiKeyOutline
}
switch (this.client?.id) {
diff --git a/apps/settings/src/components/AuthTokenList.vue b/apps/settings/src/components/AuthTokenList.vue
index e4759adba8d..dbe3b9596d8 100644
--- a/apps/settings/src/components/AuthTokenList.vue
+++ b/apps/settings/src/components/AuthTokenList.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- - @author Ferdinand Thiessen <opensource@fthiessen.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: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<table id="app-tokens-table" class="token-list">
diff --git a/apps/settings/src/components/AuthTokenSection.vue b/apps/settings/src/components/AuthTokenSection.vue
index a1689846130..3a216f6407f 100644
--- a/apps/settings/src/components/AuthTokenSection.vue
+++ b/apps/settings/src/components/AuthTokenSection.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- - @author Ferdinand Thiessen <opensource@fthiessen.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: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div id="security" class="section">
diff --git a/apps/settings/src/components/AuthTokenSetup.vue b/apps/settings/src/components/AuthTokenSetup.vue
index 9e709397362..b93086c9e88 100644
--- a/apps/settings/src/components/AuthTokenSetup.vue
+++ b/apps/settings/src/components/AuthTokenSetup.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- - @author Ferdinand Thiessen <opensource@fthiessen.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: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<form id="generate-app-token-section"
@@ -48,8 +31,8 @@ import { translate as t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import { useAuthTokenStore, type ITokenResponse } from '../store/authtoken'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue'
import logger from '../logger'
@@ -98,8 +81,8 @@ export default defineComponent({
<style lang="scss" scoped>
.app-name-text-field {
height: 44px !important;
- padding-left: 12px;
- margin-right: 12px;
+ padding-inline-start: 12px;
+ margin-inline-end: 12px;
width: 200px;
}
diff --git a/apps/settings/src/components/AuthTokenSetupDialog.vue b/apps/settings/src/components/AuthTokenSetupDialog.vue
index f40fe722cef..3b8fac8dc1d 100644
--- a/apps/settings/src/components/AuthTokenSetupDialog.vue
+++ b/apps/settings/src/components/AuthTokenSetupDialog.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog :open.sync="open"
@@ -27,7 +10,7 @@
{{ 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', 'Username')" :value="loginName" readonly />
+ <NcTextField :label="t('settings', 'Login')" :value="loginName" readonly />
<NcButton type="tertiary"
:title="copyLoginNameLabel"
:aria-label="copyLoginNameLabel"
@@ -70,10 +53,10 @@ import { getRootUrl } from '@nextcloud/router'
import { defineComponent, type PropType } from 'vue'
import QR from '@chenfengyuan/vue-qrcode'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import logger from '../logger'
diff --git a/apps/settings/src/components/BasicSettings/BackgroundJob.vue b/apps/settings/src/components/BasicSettings/BackgroundJob.vue
index 04ef607e03b..a9a3cbb9cef 100644
--- a/apps/settings/src/components/BasicSettings/BackgroundJob.vue
+++ b/apps/settings/src/components/BasicSettings/BackgroundJob.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- -
- - @author Carl Schwan <carl@carlschwan.eu>
- -
- - @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>
@@ -54,7 +37,7 @@
@update:checked="onBackgroundJobModeChanged">
{{ t('settings', 'AJAX') }}
</NcCheckboxRadioSwitch>
- <em>{{ t('settings', 'Execute one task with each page loaded. Use case: Single user instance.') }}</em>
+ <em>{{ t('settings', 'Execute one task with each page loaded. Use case: Single account instance.') }}</em>
<NcCheckboxRadioSwitch type="radio"
:checked.sync="backgroundJobsMode"
@@ -63,7 +46,7 @@
@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 users depending on the usage).') }}</em>
+ <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"
@@ -73,6 +56,7 @@
@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>
@@ -80,13 +64,15 @@
<script>
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
-import moment from '@nextcloud/moment'
-import axios from '@nextcloud/axios'
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')
@@ -121,12 +107,12 @@ export default {
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 user "{user}".', { user: this.cliBasedCronUser })
+ 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, sanitize: false })
+ }, undefined, { escape: false })
}
return desc
},
@@ -199,6 +185,7 @@ export default {
background-color: var(--color-error);
width: initial;
}
+
.warning {
margin-top: 8px;
padding: 5px;
@@ -207,6 +194,7 @@ export default {
background-color: var(--color-warning);
width: initial;
}
+
.ajaxSwitch {
margin-top: 1rem;
}
diff --git a/apps/settings/src/components/BasicSettings/ProfileSettings.vue b/apps/settings/src/components/BasicSettings/ProfileSettings.vue
index 8b63940e257..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,7 +11,7 @@
</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>
<NcCheckboxRadioSwitch type="switch"
@@ -45,9 +28,9 @@ import { showError } from '@nextcloud/dialogs'
import { saveProfileDefault } from '../../service/ProfileService.js'
import { validateBoolean } from '../../utils/validate.js'
-import logger from '../../logger.js'
+import logger from '../../logger.ts'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const profileEnabledByDefault = loadState('settings', 'profileEnabledByDefault', true)
@@ -97,6 +80,3 @@ export default {
},
}
</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.vue b/apps/settings/src/components/Encryption.vue
deleted file mode 100644
index b6a37b41c8b..00000000000
--- a/apps/settings/src/components/Encryption.vue
+++ /dev/null
@@ -1,210 +0,0 @@
-<!--
- - @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- -
- - @author Carl Schwan <carl@carlschwan.eu>
- -
- - @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>
- <NcSettingsSection :name="t('settings', 'Server-side encryption')"
- :description="t('settings', 'Server-side encryption makes it possible to encrypt files which are uploaded to this server. This comes with limitations like a performance penalty, so enable this only if needed.')"
- :doc-url="encryptionAdminDoc">
- <NcCheckboxRadioSwitch :checked="encryptionEnabled || shouldDisplayWarning"
- :disabled="encryptionEnabled"
- type="switch"
- @update:checked="displayWarning">
- {{ t('settings', 'Enable server-side encryption') }}
- </NcCheckboxRadioSwitch>
-
- <div v-if="shouldDisplayWarning && !encryptionEnabled" class="notecard warning" role="alert">
- <p>{{ t('settings', 'Please read carefully before activating server-side encryption:') }}</p>
- <ul>
- <li>{{ t('settings', 'Once encryption is enabled, all files uploaded to the server from that point forward will be encrypted at rest on the server. It will only be possible to disable encryption at a later date if the active encryption module supports that function, and all pre-conditions (e.g. setting a recover key) are met.') }}</li>
- <li>{{ t('settings', 'Encryption alone does not guarantee security of the system. Please see documentation for more information about how the encryption app works, and the supported use cases.') }}</li>
- <li>{{ t('settings', 'Be aware that encryption always increases the file size.') }}</li>
- <li>{{ t('settings', 'It is always good to create regular backups of your data, in case of encryption make sure to backup the encryption keys along with your data.') }}</li>
- </ul>
-
- <p class="margin-bottom">
- {{ t('settings', 'This is the final warning: Do you really want to enable encryption?') }}
- </p>
- <NcButton type="primary"
- @click="enableEncryption()">
- {{ t('settings', "Enable encryption") }}
- </NcButton>
- </div>
-
- <div v-if="encryptionEnabled">
- <div v-if="encryptionReady">
- <p v-if="encryptionModules.length === 0">
- {{ t('settings', 'No encryption module loaded, please enable an encryption module in the app menu.') }}
- </p>
- <template v-else>
- <h3>{{ t('settings', 'Select default encryption module:') }}</h3>
- <fieldset>
- <NcCheckboxRadioSwitch v-for="(module, id) in encryptionModules"
- :key="id"
- :checked.sync="defaultCheckedModule"
- :value="id"
- type="radio"
- name="default_encryption_module"
- @update:checked="checkDefaultModule">
- {{ module.displayName }}
- </NcCheckboxRadioSwitch>
- </fieldset>
- </template>
- </div>
-
- <div v-else-if="externalBackendsEnabled" v-html="migrationMessage" />
- </div>
- </NcSettingsSection>
-</template>
-
-<script>
-import axios from '@nextcloud/axios'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
-import { loadState } from '@nextcloud/initial-state'
-import { getLoggerBuilder } from '@nextcloud/logger'
-
-import { generateOcsUrl } from '@nextcloud/router'
-import { confirmPassword } from '@nextcloud/password-confirmation'
-import '@nextcloud/password-confirmation/dist/style.css'
-import { showError } from '@nextcloud/dialogs'
-
-const logger = getLoggerBuilder()
- .setApp('settings')
- .detectUser()
- .build()
-
-export default {
- name: 'Encryption',
- components: {
- NcCheckboxRadioSwitch,
- NcSettingsSection,
- NcButton,
- },
- data() {
- const encryptionModules = loadState('settings', 'encryption-modules')
- return {
- encryptionReady: loadState('settings', 'encryption-ready'),
- encryptionEnabled: loadState('settings', 'encryption-enabled'),
- externalBackendsEnabled: loadState('settings', 'external-backends-enabled'),
- encryptionAdminDoc: loadState('settings', 'encryption-admin-doc'),
- encryptionModules,
- shouldDisplayWarning: false,
- migrating: false,
- defaultCheckedModule: Object.entries(encryptionModules).find((module) => module[1].default)[0],
- }
- },
- computed: {
- migrationMessage() {
- return 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"',
- })
- },
- },
- methods: {
- displayWarning() {
- if (!this.encryptionEnabled) {
- this.shouldDisplayWarning = !this.shouldDisplayWarning
- } else {
- this.encryptionEnabled = false
- this.shouldDisplayWarning = false
- }
- },
- async update(key, value) {
- await confirmPassword()
-
- const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
- appId: 'core',
- key,
- })
-
- const stringValue = value ? 'yes' : 'no'
- try {
- const { data } = await axios.post(url, {
- value: stringValue,
- })
- this.handleResponse({
- status: data.ocs?.meta?.status,
- })
- } catch (e) {
- this.handleResponse({
- errorMessage: t('settings', 'Unable to update server side encryption config'),
- error: e,
- })
- }
- },
- async checkDefaultModule() {
- await this.update('default_encryption_module', this.defaultCheckedModule)
- },
- async enableEncryption() {
- this.encryptionEnabled = true
- await this.update('encryption_enabled', true)
- },
- async handleResponse({ status, errorMessage, error }) {
- if (status !== 'ok') {
- showError(errorMessage)
- logger.error(errorMessage, { error })
- }
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-
-.notecard.success {
- --note-background: rgba(var(--color-success-rgb), 0.2);
- --note-theme: var(--color-success);
-}
-
-.notecard.error {
- --note-background: rgba(var(--color-error-rgb), 0.2);
- --note-theme: var(--color-error);
-}
-
-.notecard.warning {
- --note-background: rgba(var(--color-warning-rgb), 0.2);
- --note-theme: var(--color-warning);
-}
-
-#body-settings .notecard {
- color: var(--color-text-light);
- background-color: var(--note-background);
- border: 1px solid var(--color-border);
- border-left: 4px solid var(--note-theme);
- border-radius: var(--border-radius);
- box-shadow: rgba(43, 42, 51, 0.05) 0px 1px 2px 0px;
- margin: 1rem 0;
- margin-top: 1rem;
- padding: 1rem;
-}
-
-li {
- list-style-type: initial;
- margin-left: 1rem;
- padding: 0.25rem 0;
-}
-
-.margin-bottom {
- margin-bottom: 0.75rem;
-}
-</style>
diff --git a/apps/settings/src/components/Encryption/EncryptionSettings.vue b/apps/settings/src/components/Encryption/EncryptionSettings.vue
new file mode 100644
index 00000000000..f4db63ce53c
--- /dev/null
+++ b/apps/settings/src/components/Encryption/EncryptionSettings.vue
@@ -0,0 +1,197 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import { showError, spawnDialog } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import { generateOcsUrl } from '@nextcloud/router'
+import { ref } from 'vue'
+import { textExistingFilesNotEncrypted } from './sharedTexts.ts'
+
+import axios from '@nextcloud/axios'
+import logger from '../../logger.ts'
+
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+import EncryptionWarningDialog from './EncryptionWarningDialog.vue'
+
+interface EncryptionModule {
+ default?: boolean
+ displayName: string
+}
+
+const allEncryptionModules = loadState<never[]|Record<string, EncryptionModule>>('settings', 'encryption-modules')
+/** Available encryption modules on the backend */
+const encryptionModules = Array.isArray(allEncryptionModules) ? [] : Object.entries(allEncryptionModules).map(([id, module]) => ({ ...module, id }))
+/** ID of the default encryption module */
+const defaultCheckedModule = encryptionModules.find((module) => module.default)?.id
+
+/** Is the server side encryptio ready to be enabled */
+const encryptionReady = loadState<boolean>('settings', 'encryption-ready')
+/** Are external backends enabled (legacy ownCloud stuff) */
+const externalBackendsEnabled = loadState<boolean>('settings', 'external-backends-enabled')
+/** URL to the admin docs */
+const encryptionAdminDoc = loadState<string>('settings', 'encryption-admin-doc')
+
+/** Is the encryption enabled */
+const encryptionEnabled = ref(loadState<boolean>('settings', 'encryption-enabled'))
+
+/** Loading state while enabling encryption (e.g. because the confirmation dialog is open) */
+const loadingEncryptionState = ref(false)
+
+/**
+ * Open the encryption-enabling warning (spawns a dialog)
+ * @param enabled The enabled state of encryption
+ */
+function displayWarning(enabled: boolean) {
+ if (loadingEncryptionState.value || enabled === false) {
+ return
+ }
+
+ loadingEncryptionState.value = true
+ spawnDialog(EncryptionWarningDialog, {}, async (confirmed) => {
+ try {
+ if (confirmed) {
+ await enableEncryption()
+ }
+ } finally {
+ loadingEncryptionState.value = false
+ }
+ })
+}
+
+/**
+ * Update an encryption setting on the backend
+ * @param key The setting to update
+ * @param value The new value
+ */
+async function update(key: string, value: string) {
+ await confirmPassword()
+
+ const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
+ appId: 'core',
+ key,
+ })
+
+ try {
+ const { data } = await axios.post<OCSResponse>(url, {
+ value,
+ })
+ if (data.ocs.meta.status !== 'ok') {
+ throw new Error('Unsuccessful OCS response', { cause: data.ocs })
+ }
+ } catch (error) {
+ showError(t('settings', 'Unable to update server side encryption config'))
+ logger.error('Unable to update server side encryption config', { error })
+ return false
+ }
+ return true
+}
+
+/**
+ * Choose the default encryption module
+ */
+async function checkDefaultModule(): Promise<void> {
+ if (defaultCheckedModule) {
+ await update('default_encryption_module', defaultCheckedModule)
+ }
+}
+
+/**
+ * Enable encryption - sends an async POST request
+ */
+async function enableEncryption(): Promise<void> {
+ encryptionEnabled.value = await update('encryption_enabled', 'yes')
+}
+</script>
+
+<template>
+ <NcSettingsSection :name="t('settings', 'Server-side encryption')"
+ :description="t('settings', 'Server-side encryption makes it possible to encrypt files which are uploaded to this server. This comes with limitations like a performance penalty, so enable this only if needed.')"
+ :doc-url="encryptionAdminDoc">
+ <NcNoteCard v-if="encryptionEnabled" type="info">
+ <p>
+ {{ textExistingFilesNotEncrypted }}
+ {{ t('settings', 'To encrypt all existing files run this OCC command:') }}
+ </p>
+ <code>
+ <pre>occ encryption:encrypt-all</pre>
+ </code>
+ </NcNoteCard>
+
+ <NcCheckboxRadioSwitch :class="{ disabled: encryptionEnabled }"
+ :checked="encryptionEnabled"
+ :aria-disabled="encryptionEnabled ? 'true' : undefined"
+ :aria-describedby="encryptionEnabled ? 'server-side-encryption-disable-hint' : undefined"
+ :loading="loadingEncryptionState"
+ type="switch"
+ @update:checked="displayWarning">
+ {{ t('settings', 'Enable server-side encryption') }}
+ </NcCheckboxRadioSwitch>
+ <p v-if="encryptionEnabled" id="server-side-encryption-disable-hint" class="disable-hint">
+ {{ t('settings', 'Disabling server side encryption is only possible using OCC, please refer to the documentation.') }}
+ </p>
+
+ <NcNoteCard v-if="encryptionModules.length === 0"
+ type="warning"
+ :text="t('settings', 'No encryption module loaded, please enable an encryption module in the app menu.')" />
+
+ <template v-else-if="encryptionEnabled">
+ <div v-if="encryptionReady && encryptionModules.length > 0">
+ <h3>{{ t('settings', 'Select default encryption module:') }}</h3>
+ <fieldset>
+ <NcCheckboxRadioSwitch v-for="module in encryptionModules"
+ :key="module.id"
+ :checked.sync="defaultCheckedModule"
+ :value="module.id"
+ type="radio"
+ name="default_encryption_module"
+ @update:checked="checkDefaultModule">
+ {{ module.displayName }}
+ </NcCheckboxRadioSwitch>
+ </fieldset>
+ </div>
+
+ <div v-else-if="externalBackendsEnabled">
+ {{
+ t(
+ 'settings',
+ 'You need to migrate your encryption keys from the old encryption (ownCloud <= 8.0) to the new one. Please enable the "Default encryption module" and run {command}',
+ { command: '"occ encryption:migrate"' },
+ )
+ }}
+ </div>
+ </template>
+ </NcSettingsSection>
+</template>
+
+<style scoped>
+code {
+ background-color: var(--color-background-dark);
+ color: var(--color-main-text);
+
+ display: block;
+ margin-block-start: 0.5rem;
+ padding: .25lh .5lh;
+ width: fit-content;
+}
+
+.disabled {
+ opacity: .75;
+}
+
+.disabled :deep(*) {
+ cursor: not-allowed !important;
+}
+
+.disable-hint {
+ color: var(--color-text-maxcontrast);
+ padding-inline-start: 10px;
+}
+</style>
diff --git a/apps/settings/src/components/Encryption/EncryptionWarningDialog.vue b/apps/settings/src/components/Encryption/EncryptionWarningDialog.vue
new file mode 100644
index 00000000000..f229544a7d9
--- /dev/null
+++ b/apps/settings/src/components/Encryption/EncryptionWarningDialog.vue
@@ -0,0 +1,91 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import type { IDialogButton } from '@nextcloud/dialogs'
+
+import { t } from '@nextcloud/l10n'
+import { textExistingFilesNotEncrypted } from './sharedTexts.ts'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+const emit = defineEmits<{
+ (e: 'close', encrypt: boolean): void
+}>()
+
+const buttons: IDialogButton[] = [
+ {
+ label: t('settings', 'Cancel encryption'),
+ // @ts-expect-error Needs to be fixed in the dialogs library - value is allowed but missing from the types
+ type: 'tertiary',
+ callback: () => emit('close', false),
+ },
+ {
+ label: t('settings', 'Enable encryption'),
+ type: 'error',
+ callback: () => emit('close', true),
+ },
+]
+
+/**
+ * When closed we need to emit the close event
+ * @param isOpen open state of the dialog
+ */
+function onUpdateOpen(isOpen: boolean) {
+ if (!isOpen) {
+ emit('close', false)
+ }
+}
+</script>
+
+<template>
+ <NcDialog :buttons="buttons"
+ :name="t('settings', 'Confirm enabling encryption')"
+ size="normal"
+ @update:open="onUpdateOpen">
+ <NcNoteCard type="warning">
+ <p>
+ {{ t('settings', 'Please read carefully before activating server-side encryption:') }}
+ <ul>
+ <li>
+ {{ t('settings', 'Once encryption is enabled, all files uploaded to the server from that point forward will be encrypted at rest on the server. It will only be possible to disable encryption at a later date if the active encryption module supports that function, and all pre-conditions (e.g. setting a recover key) are met.') }}
+ </li>
+ <li>
+ {{ t('settings', 'By default a master key for the whole instance will be generated. Please check if that level of access is compliant with your needs.') }}
+ </li>
+ <li>
+ {{ t('settings', 'Encryption alone does not guarantee security of the system. Please see documentation for more information about how the encryption app works, and the supported use cases.') }}
+ </li>
+ <li>
+ {{ t('settings', 'Be aware that encryption always increases the file size.') }}
+ </li>
+ <li>
+ {{ t('settings', 'It is always good to create regular backups of your data, in case of encryption make sure to backup the encryption keys along with your data.') }}
+ </li>
+ <li>
+ {{ textExistingFilesNotEncrypted }}
+ {{ t('settings', 'Refer to the admin documentation on how to manually also encrypt existing files.') }}
+ </li>
+ </ul>
+ </p>
+ </NcNoteCard>
+ <p>
+ {{ t('settings', 'This is the final warning: Do you really want to enable encryption?') }}
+ </p>
+ </NcDialog>
+</template>
+
+<style scoped>
+li {
+ list-style-type: initial;
+ margin-inline-start: 1rem;
+ padding: 0.25rem 0;
+}
+
+p + p,
+div + p {
+ margin-block: 0.75rem;
+}
+</style>
diff --git a/apps/settings/src/components/Encryption/sharedTexts.ts b/apps/settings/src/components/Encryption/sharedTexts.ts
new file mode 100644
index 00000000000..94d23be07f2
--- /dev/null
+++ b/apps/settings/src/components/Encryption/sharedTexts.ts
@@ -0,0 +1,7 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { t } from '@nextcloud/l10n'
+
+export const textExistingFilesNotEncrypted = t('settings', 'For performance reasons, when you enable encryption on a Nextcloud server only new and changed files are encrypted.')
diff --git a/apps/settings/src/components/GroupListItem.vue b/apps/settings/src/components/GroupListItem.vue
index cba0b7b3748..69bb8a3f575 100644
--- a/apps/settings/src/components/GroupListItem.vue
+++ b/apps/settings/src/components/GroupListItem.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<Fragment>
@@ -30,7 +13,7 @@
</h2>
<NcNoteCard type="warning"
show-alert>
- {{ t('settings', 'You are about to remove the group "{group}". The users will NOT be deleted.', { group: name }) }}
+ {{ t('settings', 'You are about to delete the group "{group}". The accounts will NOT be deleted.', { group: name }) }}
</NcNoteCard>
<div class="modal__button-row">
<NcButton type="secondary"
@@ -46,6 +29,7 @@
</NcModal>
<NcAppNavigationItem :key="id"
+ ref="listItem"
:exact="true"
:name="name"
:to="{ name: 'group', params: { selectedGroup: encodeURIComponent(id) } }"
@@ -62,7 +46,7 @@
</NcCounterBubble>
</template>
<template #actions>
- <NcActionInput v-if="id !== 'admin' && id !== 'disabled' && settings.isAdmin"
+ <NcActionInput v-if="id !== 'admin' && id !== 'disabled' && (settings.isAdmin || settings.isDelegatedAdmin)"
ref="displayNameInput"
:trailing-button-label="t('settings', 'Submit')"
type="text"
@@ -73,12 +57,12 @@
<Pencil :size="20" />
</template>
</NcActionInput>
- <NcActionButton v-if="id !== 'admin' && id !== 'disabled' && settings.isAdmin"
+ <NcActionButton v-if="id !== 'admin' && id !== 'disabled' && (settings.isAdmin || settings.isDelegatedAdmin)"
@click="showRemoveGroupModal = true">
<template #icon>
<Delete :size="20" />
</template>
- {{ t('settings', 'Remove group') }}
+ {{ t('settings', 'Delete group') }}
</NcActionButton>
</template>
</NcAppNavigationItem>
@@ -88,17 +72,17 @@
<script>
import { Fragment } from 'vue-frag'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionInput from '@nextcloud/vue/components/NcActionInput'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
+import NcModal from '@nextcloud/vue/components/NcModal'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
-import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
-import Delete from 'vue-material-design-icons/Delete.vue'
-import Pencil from 'vue-material-design-icons/Pencil.vue'
+import AccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue'
+import Delete from 'vue-material-design-icons/DeleteOutline.vue'
+import Pencil from 'vue-material-design-icons/PencilOutline.vue'
import { showError } from '@nextcloud/dialogs'
@@ -195,7 +179,7 @@ export default {
await this.$store.dispatch('removeGroup', this.id)
this.showRemoveGroupModal = false
} catch (error) {
- showError(t('settings', 'Failed to remove group "{group}"', { group: this.name }))
+ showError(t('settings', 'Failed to delete group "{group}"', { group: this.name }))
}
},
},
diff --git a/apps/settings/src/components/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 dcbd44b186b..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(), {
@@ -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
index 931a0f007fe..44845c51ff4 100644
--- a/apps/settings/src/components/PasswordSection.vue
+++ b/apps/settings/src/components/PasswordSection.vue
@@ -1,21 +1,7 @@
<!--
- - @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- -
- - @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>
<NcSettingsSection :name="t('settings', 'Password')">
<form id="passwordform" method="POST" @submit.prevent="changePassword">
@@ -46,9 +32,9 @@
</template>
<script>
-import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { showSuccess, showError } from '@nextcloud/dialogs'
diff --git a/apps/settings/src/components/PersonalInfo/AvatarSection.vue b/apps/settings/src/components/PersonalInfo/AvatarSection.vue
index ed6cd2c423c..a99f228668c 100644
--- a/apps/settings/src/components/PersonalInfo/AvatarSection.vue
+++ b/apps/settings/src/components/PersonalInfo/AvatarSection.vue
@@ -1,30 +1,10 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
<section id="vue-avatar-section">
- <h3 class="hidden-visually">
- {{ t('settings', 'Your profile information') }}
- </h3>
<HeaderBar :is-heading="true"
:readable="avatar.readable"
:scope.sync="avatar.scope" />
@@ -100,15 +80,15 @@ import { getCurrentUser } from '@nextcloud/auth'
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
import VueCropper from 'vue-cropperjs'
// eslint-disable-next-line n/no-extraneous-import
import 'cropperjs/dist/cropper.css'
import Upload from 'vue-material-design-icons/Upload.vue'
import Folder from 'vue-material-design-icons/Folder.vue'
-import Delete from 'vue-material-design-icons/Delete.vue'
+import Delete from 'vue-material-design-icons/DeleteOutline.vue'
import HeaderBar from './shared/HeaderBar.vue'
import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
@@ -275,10 +255,12 @@ export default {
<style lang="scss" scoped>
section {
grid-row: 1/3;
+ padding: 10px 10px;
}
+
.avatar {
&__container {
- margin: 0 auto;
+ margin: calc(var(--default-grid-baseline) * 2) auto 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
@@ -315,7 +297,7 @@ section {
justify-content: space-between;
}
- &::v-deep .cropper-view-box {
+ :deep(.cropper-view-box) {
border-radius: 50%;
}
}
diff --git a/apps/settings/src/components/PersonalInfo/BiographySection.vue b/apps/settings/src/components/PersonalInfo/BiographySection.vue
index 30c240dee1a..bbfb25e25cc 100644
--- a/apps/settings/src/components/PersonalInfo/BiographySection.vue
+++ b/apps/settings/src/components/PersonalInfo/BiographySection.vue
@@ -1,28 +1,11 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
<AccountPropertySection v-bind.sync="biography"
- :placeholder="t('settings', 'Your biography')"
+ :placeholder="t('settings', 'Your biography. Markdown is supported.')"
:multi-line="true" />
</template>
diff --git a/apps/settings/src/components/PersonalInfo/BirthdaySection.vue b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue
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
index 075ed6f71e2..d4bb0ce16ec 100644
--- a/apps/settings/src/components/PersonalInfo/DetailsSection.vue
+++ b/apps/settings/src/components/PersonalInfo/DetailsSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
@@ -37,6 +20,7 @@
<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"
@@ -49,9 +33,10 @@
<script>
import { loadState } from '@nextcloud/initial-state'
-import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
+import { t } from '@nextcloud/l10n'
-import Account from 'vue-material-design-icons/Account.vue'
+import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
+import Account from 'vue-material-design-icons/AccountOutline.vue'
import CircleSlice from 'vue-material-design-icons/CircleSlice3.vue'
import HeaderBar from './shared/HeaderBar.vue'
@@ -81,12 +66,14 @@ export default {
computed: {
quotaText() {
if (quota === SPACE_UNLIMITED) {
- return t('settings', 'You are using <strong>{usage}</strong>', { usage })
+ return t('settings', 'You are using {s}{usage}{/s}', { usage, s: '<strong>', '/s': '</strong>' }, undefined, { escape: false })
}
return t(
'settings',
- 'You are using <strong>{usage}</strong> of <strong>{totalSpace}</strong> (<strong>{usageRelative}%</strong>)',
- { usage, totalSpace, usageRelative },
+ 'You are using {s}{usage}{/s} of {s}{totalSpace}{/s} ({s}{usageRelative}%{/s})',
+ { usage, totalSpace, usageRelative, s: '<strong>', '/s': '</strong>' },
+ undefined,
+ { escape: false },
)
},
},
@@ -97,9 +84,10 @@ export default {
.details {
display: flex;
flex-direction: column;
- margin: 10px 32px 10px 0;
+ margin-block: 10px;
+ margin-inline: 0 32px;
gap: 16px 0;
- color: var(--color-text-lighter);
+ color: var(--color-text-maxcontrast);
&__groups,
&__quota {
@@ -117,7 +105,7 @@ export default {
font-weight: bold;
}
- &::v-deep .material-design-icon {
+ &:deep(.material-design-icon) {
align-self: flex-start;
margin-top: 2px;
}
diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue
index 00a629c4d54..431dfbecc9a 100644
--- a/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue
+++ b/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
index 1fff440c50e..6a6baef8817 100644
--- a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
+++ b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
@@ -1,85 +1,62 @@
<!--
- - @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="inputIdWithDefault"
- ref="email"
- type="email"
- autocomplete="email"
- :aria-label="inputPlaceholder"
- :placeholder="inputPlaceholder"
- :value="email"
- :aria-describedby="helperText ? `${inputIdWithDefault}-helper-text` : undefined"
- autocapitalize="none"
- spellcheck="false"
- @input="onEmailChange">
-
- <div class="email__actions-container">
- <transition name="fade">
- <Check v-if="showCheckmarkIcon" :size="20" />
- <AlertOctagon v-else-if="showErrorIcon" :size="20" />
- </transition>
-
- <template v-if="!primary">
- <FederationControl :readable="propertyReadable"
- :additional="true"
- :additional-value="email"
- :disabled="federationDisabled"
- :handle-additional-scope-change="saveAdditionalEmailScope"
- :scope.sync="localScope"
- @update:scope="onScopeChange" />
- </template>
-
- <NcActions class="email__actions"
- :aria-label="t('settings', 'Email options')"
- :force-menu="true">
- <NcActionButton :aria-label="deleteEmailLabel"
- :close-after-click="true"
- :disabled="deleteDisabled"
- icon="icon-delete"
- @click.stop.prevent="deleteEmail">
- {{ deleteEmailLabel }}
- </NcActionButton>
- <NcActionButton v-if="!primary || !isNotificationEmail"
- :aria-label="setNotificationMailLabel"
- :close-after-click="true"
- :disabled="setNotificationMailDisabled"
- icon="icon-favorite"
- @click.stop.prevent="setNotificationMail">
- {{ setNotificationMailLabel }}
- </NcActionButton>
- </NcActions>
+ <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>
- <p v-if="helperText"
- :id="`${inputIdWithDefault}-helper-text`"
- class="email__helper-text-message email__helper-text-message--error">
- <AlertCircle class="email__helper-text-message__icon" :size="18" />
- {{ helperText }}
- </p>
-
<em v-if="isNotificationEmail">
{{ t('settings', 'Primary email for password reset and notifications') }}
</em>
@@ -87,15 +64,17 @@
</template>
<script>
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import AlertCircle from 'vue-material-design-icons/AlertCircleOutline.vue'
-import AlertOctagon from 'vue-material-design-icons/AlertOctagon.vue'
-import Check from 'vue-material-design-icons/Check.vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
import debounce from 'debounce'
+import { mdiArrowLeft, mdiLockOutline, mdiStar, mdiStarOutline, mdiTrashCanOutline } from '@mdi/js'
+
import FederationControl from '../shared/FederationControl.vue'
-import { handleError } from '../../../utils/handlers.js'
+import { handleError } from '../../../utils/handlers.ts'
import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js'
import {
@@ -114,9 +93,8 @@ export default {
components: {
NcActions,
NcActionButton,
- AlertCircle,
- AlertOctagon,
- Check,
+ NcIconSvgWrapper,
+ NcTextField,
FederationControl,
},
@@ -152,19 +130,38 @@ export default {
},
},
+ setup() {
+ return {
+ mdiArrowLeft,
+ mdiLockOutline,
+ mdiStar,
+ mdiStarOutline,
+ mdiTrashCanOutline,
+ saveAdditionalEmailScope,
+ }
+ },
+
data() {
return {
- propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
+ hasError: false,
+ helperText: null,
initialEmail: this.email,
+ isSuccess: false,
localScope: this.scope,
- saveAdditionalEmailScope,
- helperText: null,
- 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
@@ -183,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
+ },
+
+ 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() {
+ 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')
},
@@ -213,25 +222,31 @@ export default {
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) {
- this.helperText = null
- if (this.$refs.email?.validationMessage) {
- this.helperText = this.$refs.email.validationMessage
+ // 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 === '') {
@@ -247,7 +262,7 @@ export default {
}
}
}
- }, 500),
+ }, 1000),
async deleteEmail() {
if (this.primary) {
@@ -341,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'),
@@ -356,12 +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 {
handleError(error, errorMessage)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
+ this.hasError = true
+ setTimeout(() => { this.hasError = false }, 2000)
}
},
@@ -374,66 +392,29 @@ export default {
<style lang="scss" scoped>
.email {
- display: grid;
- align-items: center;
-
- input {
- grid-area: 1 / 1;
- width: 100%;
- }
-
- .email__actions-container {
- grid-area: 1 / 1;
- justify-self: flex-end;
- height: 30px;
-
- display: flex;
- gap: 0 2px;
- margin-right: 5px;
-
- .email__actions {
- &:hover,
- &:focus,
- &:active {
- opacity: 0.8 !important;
- }
-
- &::v-deep button {
- height: 30px !important;
- min-height: 30px !important;
- width: 30px !important;
- min-width: 30px !important;
- }
- }
- }
-
- &__helper-text-message {
- padding: 4px 0;
+ &__label-container {
+ height: var(--default-clickable-area);
display: flex;
+ flex-direction: row;
align-items: center;
+ gap: calc(var(--default-grid-baseline) * 2);
+ }
- &__icon {
- margin-right: 8px;
- align-self: start;
- margin-top: 4px;
- }
+ &__input-container {
+ position: relative;
+ }
- &--error {
- color: var(--color-error);
+ &__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 0cc94b4998a..f9674a3163b 100644
--- a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue
+++ b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue
@@ -1,27 +1,10 @@
<!--
- - @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>
+ <section class="section-emails">
<HeaderBar :input-id="inputId"
:readable="primaryEmail.readable"
:is-editable="true"
@@ -30,7 +13,7 @@
:scope.sync="primaryEmail.scope"
@add-additional="onAddAdditionalEmail" />
- <template v-if="displayNameChangeSupported">
+ <template v-if="emailChangeSupported">
<Email :input-id="inputId"
:primary="true"
:scope.sync="primaryEmail.scope"
@@ -45,10 +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="additionalEmail.key"
+ class="section-emails__additional-email"
:index="index"
:scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value"
@@ -70,10 +53,10 @@ import HeaderBar from '../shared/HeaderBar.vue'
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.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',
@@ -87,7 +70,7 @@ export default {
return {
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
additionalEmails: additionalEmails.map(properties => ({ ...properties, key: this.generateUniqueKey() })),
- displayNameChangeSupported,
+ emailChangeSupported,
primaryEmail: { ...primaryEmail, readable: NAME_READABLE_ENUM[primaryEmail.name] },
notificationEmail,
}
@@ -196,16 +179,11 @@ export default {
</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
index 3975308d587..043fa6e64b9 100644
--- a/apps/settings/src/components/PersonalInfo/FediverseSection.vue
+++ b/apps/settings/src/components/PersonalInfo/FediverseSection.vue
@@ -1,50 +1,50 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
- <AccountPropertySection v-bind.sync="fediverse"
+ <AccountPropertySection v-bind.sync="value"
+ :readable="readable"
+ :on-validate="onValidate"
:placeholder="t('settings', 'Your handle')" />
</template>
-<script>
+<script setup lang="ts">
+import type { AccountProperties } from '../../constants/AccountPropertyConstants.js'
import { loadState } from '@nextcloud/initial-state'
-
-import AccountPropertySection from './shared/AccountPropertySection.vue'
-
+import { t } from '@nextcloud/l10n'
+import { ref } from 'vue'
import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
-const { fediverse } = loadState('settings', 'personalInfoParameters', {})
-
-export default {
- name: 'FediverseSection',
-
- components: {
- AccountPropertySection,
- },
+import AccountPropertySection from './shared/AccountPropertySection.vue'
- data() {
- return {
- fediverse: { ...fediverse, readable: NAME_READABLE_ENUM[fediverse.name] },
- }
- },
+const { fediverse } = loadState<AccountProperties>('settings', 'personalInfoParameters')
+
+const value = ref({ ...fediverse })
+const readable = NAME_READABLE_ENUM[fediverse.name]
+
+/**
+ * Validate a fediverse handle
+ * @param text The potential fediverse handle
+ */
+function onValidate(text: string): boolean {
+ // allow to clear the value
+ if (text === '') {
+ return true
+ }
+
+ // check its in valid format
+ const result = text.match(/^@?([^@/]+)@([^@/]+)$/)
+ if (result === null) {
+ return false
+ }
+
+ // check its a valid URL
+ try {
+ return URL.parse(`https://${result[2]}/`) !== null
+ } catch {
+ return false
+ }
}
</script>
diff --git a/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue b/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue
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
index 0275c1df80b..25fbde5b2f5 100644
--- a/apps/settings/src/components/PersonalInfo/HeadlineSection.vue
+++ b/apps/settings/src/components/PersonalInfo/HeadlineSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue
index cf921b5809f..8f42b2771c0 100644
--- a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue
+++ b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue
@@ -1,44 +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="inputId" @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"
@@ -52,11 +27,17 @@
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.js'
+import { handleError } from '../../../utils/handlers.ts'
+
+import NcSelect from '@nextcloud/vue/components/NcSelect'
export default {
name: 'Language',
+ components: {
+ NcSelect,
+ },
+
props: {
inputId: {
type: String,
@@ -83,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)) {
@@ -108,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'),
@@ -117,13 +99,6 @@ 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
@@ -132,10 +107,6 @@ export default {
handleError(error, errorMessage)
}
},
-
- reloadPage() {
- location.reload()
- },
},
}
</script>
@@ -144,12 +115,11 @@ export default {
.language {
display: grid;
- select {
- width: 100%;
+ #{&}__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 fdc1d31d10c..4e92436fd63 100644
--- a/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue
+++ b/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.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,12 +8,11 @@
<HeaderBar :input-id="inputId"
:readable="propertyReadable" />
- <template v-if="isEditable">
- <Language :input-id="inputId"
- :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') }}
@@ -56,11 +38,17 @@ export default {
HeaderBar,
},
- data() {
+ setup() {
+ // Non reactive instance properties
return {
- propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
commonLanguages,
otherLanguages,
+ propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE,
+ }
+ },
+
+ data() {
+ return {
language: activeLanguage,
}
},
@@ -80,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
index b405d7fced4..73300756472 100644
--- a/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue
+++ b/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue
@@ -1,47 +1,22 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
<div class="locale">
- <select :id="inputId" @change="onLocaleChange">
- <option v-for="currentLocale in localesForLanguage"
- :key="currentLocale.code"
- :selected="locale.code === currentLocale.code"
- :value="currentLocale.code">
- {{ currentLocale.name }}
- </option>
- <option disabled>
- ──────────
- </option>
- <option v-for="currentLocale in otherLocales"
- :key="currentLocale.code"
- :selected="locale.code === currentLocale.code"
- :value="currentLocale.code">
- {{ currentLocale.name }}
- </option>
- </select>
+ <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">
- <Web :size="20" />
+ <MapClock :size="20" />
<div class="example__text">
<p>
<span>{{ example.date }}</span>
@@ -57,18 +32,19 @@
<script>
import moment from '@nextcloud/moment'
-import Web from 'vue-material-design-icons/Web.vue'
+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 { validateLocale } from '../../../utils/validate.js'
-import { handleError } from '../../../utils/handlers.js'
+import { handleError } from '../../../utils/handlers.ts'
export default {
name: 'Locale',
components: {
- Web,
+ MapClock,
+ NcSelect,
},
props: {
@@ -93,6 +69,7 @@ export default {
data() {
return {
initialLocale: this.locale,
+ intervalId: 0,
example: {
date: moment().format('L'),
time: moment().format('LTS'),
@@ -102,28 +79,25 @@ export default {
},
computed: {
+ /**
+ * All available locale, sorted like: current, common, other
+ */
allLocales() {
- return Object.freeze(
- [...this.localesForLanguage, ...this.otherLocales]
- .reduce((acc, { code, name }) => ({ ...acc, [code]: name }), {}),
- )
+ 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]
},
},
- created() {
- setInterval(this.refreshExample, 1000)
+ mounted() {
+ this.intervalId = window.setInterval(this.refreshExample, 1000)
},
- methods: {
- async onLocaleChange(e) {
- const locale = this.constructLocale(e.target.value)
- this.$emit('update:locale', locale)
-
- if (validateLocale(locale)) {
- await this.updateLocale(locale)
- }
- },
+ beforeDestroy() {
+ window.clearInterval(this.intervalId)
+ },
+ methods: {
async updateLocale(locale) {
try {
const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LOCALE, locale.code)
@@ -131,7 +105,7 @@ export default {
locale,
status: responseData.ocs?.meta?.status,
})
- this.reloadPage()
+ window.location.reload()
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update locale'),
@@ -140,13 +114,6 @@ export default {
}
},
- constructLocale(localeCode) {
- return {
- code: localeCode,
- name: this.allLocales[localeCode],
- }
- },
-
handleResponse({ locale, status, errorMessage, error }) {
if (status === 'ok') {
this.initialLocale = locale
@@ -163,10 +130,6 @@ export default {
firstDayOfWeek: window.dayNames[window.firstDay],
}
},
-
- reloadPage() {
- location.reload()
- },
},
}
</script>
@@ -175,8 +138,8 @@ export default {
.locale {
display: grid;
- select {
- width: 100%;
+ #{&}__select {
+ margin-top: 6px; // align with other inputs
}
}
@@ -184,9 +147,9 @@ export default {
margin: 10px 0;
display: flex;
gap: 0 10px;
- color: var(--color-text-lighter);
+ color: var(--color-text-maxcontrast);
- &::v-deep .material-design-icon {
+ &:deep(.material-design-icon) {
align-self: flex-start;
margin-top: 2px;
}
diff --git a/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue b/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue
index 61c98f3a27a..d4488e77efd 100644
--- a/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue
+++ b/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
@@ -25,12 +8,11 @@
<HeaderBar :input-id="inputId"
:readable="propertyReadable" />
- <template v-if="isEditable">
- <Locale :input-id="inputId"
- :locales-for-language="localesForLanguage"
- :other-locales="otherLocales"
- :locale.sync="locale" />
- </template>
+ <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') }}
@@ -80,9 +62,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/LocationSection.vue b/apps/settings/src/components/PersonalInfo/LocationSection.vue
index 57811ddf3b0..a32f86b3442 100644
--- a/apps/settings/src/components/PersonalInfo/LocationSection.vue
+++ b/apps/settings/src/components/PersonalInfo/LocationSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection.vue
index b8ae3d846e5..b951b938919 100644
--- a/apps/settings/src/components/PersonalInfo/OrganisationSection.vue
+++ b/apps/settings/src/components/PersonalInfo/OrganisationSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
diff --git a/apps/settings/src/components/PersonalInfo/PhoneSection.vue b/apps/settings/src/components/PersonalInfo/PhoneSection.vue
index 3a156bd9403..8ddeada960e 100644
--- a/apps/settings/src/components/PersonalInfo/PhoneSection.vue
+++ b/apps/settings/src/components/PersonalInfo/PhoneSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
@@ -56,6 +39,10 @@ export default {
methods: {
onValidate(value) {
+ if (value === '') {
+ return true
+ }
+
if (defaultPhoneRegion) {
return isValidPhoneNumber(value, defaultPhoneRegion)
}
diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue
index a47936ba7ae..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>
@@ -83,7 +66,7 @@ a {
display: inline-block;
vertical-align: middle;
margin-top: 6px;
- margin-right: 8px;
+ margin-inline-end: 8px;
}
&:hover,
diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue
index b8e8d6301d3..6eb7cf8c34c 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.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>
@@ -26,7 +9,7 @@
:checked.sync="isProfileEnabled"
:loading="loading"
@update:checked="saveEnableProfile">
- {{ t('settings', 'Enable Profile') }}
+ {{ t('settings', 'Enable profile') }}
</NcCheckboxRadioSwitch>
</div>
</template>
@@ -36,8 +19,8 @@ import { emit } from '@nextcloud/event-bus'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import { handleError } from '../../../utils/handlers.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import { handleError } from '../../../utils/handlers.ts'
export default {
name: 'ProfileCheckbox',
diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue
index 5ef6a00b1f2..47894f64f34 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.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>
@@ -44,7 +27,7 @@
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
export default {
name: 'ProfilePreviewCard',
@@ -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,7 +128,7 @@ export default {
span {
position: absolute;
- left: 78px;
+ inset-inline-start: 78px;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
@@ -168,7 +151,8 @@ export default {
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;
}
}
@@ -180,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 6b775092c6c..22c03f72697 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.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>
@@ -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 af45359d5a5..8acec883842 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.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>
@@ -135,7 +118,7 @@ section {
pointer-events: none;
& *,
- &::v-deep * {
+ &:deep(*) {
cursor: default;
pointer-events: none;
}
diff --git a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue
index 305410156a2..aaa13e63e92 100644
--- a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue
+++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.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>
@@ -31,6 +14,7 @@
:clearable="false"
:options="visibilityOptions"
:value="visibilityObject"
+ label-outside
@option:selected="onVisibilityChange" />
</div>
</template>
@@ -39,11 +23,11 @@
import { loadState } from '@nextcloud/initial-state'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
import { saveProfileParameterVisibility } from '../../../service/ProfileService.js'
import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants.js'
-import { handleError } from '../../../utils/handlers.js'
+import { handleError } from '../../../utils/handlers.ts'
const { profileEnabled } = loadState('settings', 'personalInfoParameters', false)
@@ -158,7 +142,7 @@ export default {
pointer-events: none;
& *,
- &::v-deep * {
+ &:deep(*) {
cursor: default;
pointer-events: none;
}
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
index 82cb034600d..3581112fe1b 100644
--- a/apps/settings/src/components/PersonalInfo/RoleSection.vue
+++ b/apps/settings/src/components/PersonalInfo/RoleSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
diff --git a/apps/settings/src/components/PersonalInfo/TwitterSection.vue b/apps/settings/src/components/PersonalInfo/TwitterSection.vue
index dda773a0179..43d08f81e3f 100644
--- a/apps/settings/src/components/PersonalInfo/TwitterSection.vue
+++ b/apps/settings/src/components/PersonalInfo/TwitterSection.vue
@@ -1,50 +1,34 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
- <AccountPropertySection v-bind.sync="twitter"
+ <AccountPropertySection v-bind.sync="value"
+ :readable="readable"
+ :on-validate="onValidate"
:placeholder="t('settings', 'Your X (formerly Twitter) handle')" />
</template>
-<script>
-import { loadState } from '@nextcloud/initial-state'
+<script setup lang="ts">
+import type { AccountProperties } from '../../constants/AccountPropertyConstants.js'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { ref } from 'vue'
+import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts'
import AccountPropertySection from './shared/AccountPropertySection.vue'
-import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js'
-
-const { twitter } = loadState('settings', 'personalInfoParameters', {})
-
-export default {
- name: 'TwitterSection',
+const { twitter } = loadState<AccountProperties>('settings', 'personalInfoParameters')
- components: {
- AccountPropertySection,
- },
+const value = ref({ ...twitter })
+const readable = NAME_READABLE_ENUM[twitter.name]
- data() {
- return {
- twitter: { ...twitter, readable: NAME_READABLE_ENUM[twitter.name] },
- }
- },
+/**
+ * Validate that the text might be a twitter handle
+ * @param text The potential twitter handle
+ */
+function onValidate(text: string): boolean {
+ return text === '' || text.match(/^@?([a-zA-Z0-9_]{2,15})$/) !== null
}
</script>
diff --git a/apps/settings/src/components/PersonalInfo/WebsiteSection.vue b/apps/settings/src/components/PersonalInfo/WebsiteSection.vue
index 79e7a90de00..762909139dd 100644
--- a/apps/settings/src/components/PersonalInfo/WebsiteSection.vue
+++ b/apps/settings/src/components/PersonalInfo/WebsiteSection.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
diff --git a/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue
index c9b74eeb3f4..d039641ec72 100644
--- a/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue
+++ b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue
@@ -1,94 +1,66 @@
<!--
- - @copyright 2022 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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>
<section>
- <HeaderBar :scope.sync="scope"
- :readable.sync="readable"
+ <HeaderBar :scope="scope"
+ :readable="readable"
:input-id="inputId"
- :is-editable="isEditable" />
+ :is-editable="isEditable"
+ @update:scope="(scope) => $emit('update:scope', scope)" />
<div v-if="isEditable" class="property">
- <textarea v-if="multiLine"
+ <NcTextArea v-if="multiLine"
:id="inputId"
- :placeholder="placeholder"
- :value="value"
- rows="8"
autocapitalize="none"
autocomplete="off"
+ :error="hasError || !!helperText"
+ :helper-text="helperText"
+ label-outside
+ :placeholder="placeholder"
+ rows="8"
spellcheck="false"
- @input="onPropertyChange" />
- <input v-else
+ :success="isSuccess"
+ :value.sync="inputValue" />
+ <NcInputField v-else
:id="inputId"
ref="input"
- :placeholder="placeholder"
- :type="type"
- :value="value"
- :aria-describedby="helperText ? `${name}-helper-text` : undefined"
autocapitalize="none"
- spellcheck="false"
:autocomplete="autocomplete"
- @input="onPropertyChange">
-
- <div class="property__actions-container">
- <Transition name="fade">
- <Check v-if="showCheckmarkIcon" :size="20" />
- <AlertOctagon v-else-if="showErrorIcon" :size="20" />
- </Transition>
- </div>
+ :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>
-
- <p v-if="helperText"
- :id="`${name}-helper-text`"
- class="property__helper-text-message property__helper-text-message--error">
- <AlertCircle class="property__helper-text-message__icon" :size="18" />
- {{ helperText }}
- </p>
</section>
</template>
<script>
import debounce from 'debounce'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcTextArea from '@nextcloud/vue/components/NcTextArea'
-import AlertCircle from 'vue-material-design-icons/AlertCircleOutline.vue'
-import AlertOctagon from 'vue-material-design-icons/AlertOctagon.vue'
-import Check from 'vue-material-design-icons/Check.vue'
-
-import HeaderBar from '../shared/HeaderBar.vue'
+import HeaderBar from './HeaderBar.vue'
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js'
-import { handleError } from '../../../utils/handlers.js'
+import { handleError } from '../../../utils/handlers.ts'
export default {
name: 'AccountPropertySection',
components: {
- AlertCircle,
- AlertOctagon,
- Check,
HeaderBar,
+ NcInputField,
+ NcTextArea,
},
props: {
@@ -138,12 +110,14 @@ export default {
},
},
+ emits: ['update:scope', 'update:value'],
+
data() {
return {
initialValue: this.value,
- helperText: null,
- showCheckmarkIcon: false,
- showErrorIcon: false,
+ helperText: '',
+ isSuccess: false,
+ hasError: false,
}
},
@@ -151,28 +125,37 @@ export default {
inputId() {
return `account-property-${this.name}`
},
- },
- methods: {
- onPropertyChange(e) {
- this.$emit('update:value', e.target.value)
- this.debouncePropertyChange(e.target.value.trim())
+ inputValue: {
+ get() {
+ return this.value
+ },
+ set(value) {
+ this.$emit('update:value', value)
+ this.debouncePropertyChange(value.trim())
+ },
},
- debouncePropertyChange: debounce(async function(value) {
- this.helperText = null
- if (this.$refs.input && this.$refs.input.validationMessage) {
- this.helperText = this.$refs.input.validationMessage
- return
- }
- if (this.onValidate && !this.onValidate(value)) {
- return
- }
- await this.updateProperty(value)
- }, 500),
+ 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,
@@ -195,13 +178,11 @@ export default {
if (this.onSave) {
this.onSave(value)
}
- this.showCheckmarkIcon = true
- setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
+ this.isSuccess = true
+ setTimeout(() => { this.isSuccess = false }, 2000)
} else {
- this.$emit('update:value', this.initialValue)
handleError(error, errorMessage)
- this.showErrorIcon = true
- setTimeout(() => { this.showErrorIcon = false }, 2000)
+ this.hasError = true
}
},
},
@@ -212,34 +193,20 @@ export default {
section {
padding: 10px 10px;
- &::v-deep button:disabled {
- cursor: default;
- }
-
.property {
- display: grid;
- align-items: center;
-
- textarea {
- resize: vertical;
- grid-area: 1 / 1;
- width: 100%;
- }
-
- input {
- grid-area: 1 / 1;
- width: 100%;
- }
+ display: flex;
+ flex-direction: row;
+ align-items: start;
+ gap: 4px;
.property__actions-container {
- grid-area: 1 / 1;
+ margin-top: 6px;
justify-self: flex-end;
align-self: flex-end;
- height: 30px;
display: flex;
gap: 0 2px;
- margin-right: 5px;
+ margin-inline-end: 5px;
margin-bottom: 5px;
}
}
@@ -250,7 +217,7 @@ section {
align-items: center;
&__icon {
- margin-right: 8px;
+ margin-inline-end: 8px;
align-self: start;
margin-top: 4px;
}
diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue
index e02cbb7db01..e55a50056d3 100644
--- a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue
+++ b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue
@@ -1,60 +1,52 @@
<!--
- - @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>
- <NcActions :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" />
+ :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 NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import { loadState } from '@nextcloud/initial-state'
-import FederationControlAction from './FederationControlAction.vue'
-
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.js'
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js'
-import { handleError } from '../../../utils/handlers.js'
+import { handleError } from '../../../utils/handlers.ts'
const {
federationEnabled,
@@ -66,7 +58,8 @@ export default {
components: {
NcActions,
- FederationControlAction,
+ NcActionButton,
+ NcIconSvgWrapper,
},
props: {
@@ -97,6 +90,8 @@ export default {
},
},
+ emits: ['update:scope'],
+
data() {
return {
readableLowerCase: this.readable.toLocaleLowerCase(),
@@ -114,7 +109,7 @@ export default {
},
scopeIcon() {
- return SCOPE_PROPERTY_ENUM[this.scope].iconClass
+ return SCOPE_PROPERTY_ENUM[this.scope].icon
},
federationScopes() {
@@ -149,6 +144,9 @@ export default {
} else {
await this.updateAdditionalScope(scope)
}
+
+ // TODO: provide focus method from NcActions
+ this.$refs.federationActions.$refs?.triggerButton?.$el?.focus?.()
},
async updatePrimaryScope(scope) {
@@ -194,14 +192,15 @@ export default {
</script>
<style lang="scss" scoped>
- .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 4f52a9da546..00000000000
--- a/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue
+++ /dev/null
@@ -1,94 +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>
- <NcActionButton class="federation-actions__btn"
- :class="{ 'federation-actions__btn--active': activeScope === name }"
- :close-after-click="true"
- :disabled="!isSupportedScope"
- :icon="iconClass"
- :name="displayName"
- @click.stop.prevent="updateScope">
- {{ isSupportedScope ? tooltip : tooltipDisabled }}
- </NcActionButton>
-</template>
-
-<script>
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-
-export default {
- name: 'FederationControlAction',
-
- components: {
- NcActionButton,
- },
-
- 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--active {
- background-color: var(--color-primary-element-light) !important;
- box-shadow: inset 2px 0 var(--color-primary-element) !important;
- border-radius: 0px !important;
-}
-</style>
diff --git a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue
index b149d8405f4..7c95c2b8f4c 100644
--- a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue
+++ b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue
@@ -1,31 +1,14 @@
<!--
- - @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>
- <component :is="isHeading ? `h3` : `div`" class="headerbar-label" :class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }">
- <span v-if="isHeading">
+ <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 -->
{{ readable }}
- </span>
+ </h3>
<label v-else :for="inputId">
<!-- Already translated as required by prop validator -->
{{ readable }}
@@ -49,11 +32,11 @@
{{ t('settings', 'Add') }}
</NcButton>
</template>
- </component>
+ </div>
</template>
<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
import Plus from 'vue-material-design-icons/Plus.vue'
import FederationControl from './FederationControl.vue'
@@ -147,7 +130,7 @@ export default {
}
&.setting-property {
- height: 44px;
+ height: 34px;
}
label {
@@ -155,11 +138,16 @@ export default {
}
}
+ .headerbar__heading {
+ margin: 0;
+ }
+
.federation-control {
margin: 0;
}
.button-vue {
- margin: 0 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
index 278b7b623df..ef24bcda026 100644
--- a/apps/settings/src/components/SelectSharingPermissions.vue
+++ b/apps/settings/src/components/SelectSharingPermissions.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<fieldset class="permissions-select">
@@ -38,8 +21,8 @@
<script lang="ts">
import { translate } from '@nextcloud/l10n'
-import { NcCheckboxRadioSwitch } from '@nextcloud/vue'
import { defineComponent } from 'vue'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
export default defineComponent({
name: 'SelectSharingPermissions',
diff --git a/apps/settings/src/components/SvgFilterMixin.vue b/apps/settings/src/components/SvgFilterMixin.vue
index 15713514436..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 {
diff --git a/apps/settings/src/components/UserList.vue b/apps/settings/src/components/UserList.vue
index 92c823e8bc5..459548fad26 100644
--- a/apps/settings/src/components/UserList.vue
+++ b/apps/settings/src/components/UserList.vue
@@ -1,43 +1,25 @@
<!--
- - @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>
<Fragment>
- <NewUserModal v-if="showConfig.showNewUserForm"
+ <NewUserDialog v-if="showConfig.showNewUserForm"
:loading="loading"
:new-user="newUser"
:quota-options="quotaOptions"
@reset="resetForm"
- @close="closeModal" />
+ @closing="closeDialog" />
<NcEmptyContent v-if="filteredUsers.length === 0"
class="empty"
- :name="isInitialLoad && loading.users ? null : t('settings', 'No users')">
+ :name="isInitialLoad && loading.users ? null : t('settings', 'No accounts')">
<template #icon>
<NcLoadingIcon v-if="isInitialLoad && loading.users"
- :name="t('settings', 'Loading users …')"
+ :name="t('settings', 'Loading accounts …')"
:size="64" />
- <NcIconSvgWrapper v-else
- :svg="usersSvg" />
+ <NcIconSvgWrapper v-else :path="mdiAccountGroupOutline" :size="64" />
</template>
</NcEmptyContent>
@@ -52,8 +34,6 @@
users,
settings,
hasObfuscated,
- groups,
- subAdminsGroups,
quotaOptions,
languages,
externalActions,
@@ -61,7 +41,7 @@
@scroll-end="handleScrollEnd">
<template #before>
<caption class="hidden-visually">
- {{ t('settings', 'List of users. This list is not fully rendered for performance reasons. The users will be rendered as you navigate through the list.') }}
+ {{ 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>
@@ -78,28 +58,26 @@
</template>
<script>
-import Vue from 'vue'
+import { mdiAccountGroupOutline } from '@mdi/js'
+import { showError } from '@nextcloud/dialogs'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Fragment } from 'vue-frag'
-import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-
-import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import { showError } from '@nextcloud/dialogs'
+import Vue from 'vue'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import VirtualList from './Users/VirtualList.vue'
-import NewUserModal from './Users/NewUserModal.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'
import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts'
-import logger from '../logger.js'
-
-import usersSvg from '../../img/users.svg?raw'
+import logger from '../logger.ts'
-const newUser = {
+const newUser = Object.freeze({
id: '',
displayName: '',
password: '',
@@ -112,7 +90,7 @@ const newUser = {
code: 'en',
name: t('settings', 'Default language'),
},
-}
+})
export default {
name: 'UserList',
@@ -122,7 +100,7 @@ export default {
NcEmptyContent,
NcIconSvgWrapper,
NcLoadingIcon,
- NewUserModal,
+ NewUserDialog,
UserListFooter,
UserListHeader,
VirtualList,
@@ -139,19 +117,26 @@ export default {
},
},
- data() {
+ setup() {
+ // non reactive properties
return {
+ mdiAccountGroupOutline,
+ rowHeight: 55,
+
UserRow,
+ }
+ },
+
+ data() {
+ return {
loading: {
all: false,
groups: false,
users: false,
},
+ newUser: { ...newUser },
isInitialLoad: true,
- rowHeight: 55,
- usersSvg,
searchQuery: '',
- newUser: Object.assign({}, newUser),
}
},
@@ -182,23 +167,12 @@ export default {
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))
- },
-
- 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() {
@@ -252,7 +226,7 @@ export default {
watch: {
// watch url change and group select
- async selectedGroup(val, old) {
+ async selectedGroup(val) {
this.isInitialLoad = true
// if selected is the disabled group but it's empty
await this.redirectIfDisabled()
@@ -309,6 +283,13 @@ export default {
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', {
@@ -320,14 +301,14 @@ export default {
}
logger.debug(`${this.users.length} total user(s) loaded`)
} catch (error) {
- logger.error('Failed to load users', { error })
- showError('Failed to load users')
+ logger.error('Failed to load accounts', { error })
+ showError('Failed to load accounts')
}
this.loading.users = false
this.isInitialLoad = false
},
- closeModal() {
+ closeDialog() {
this.$store.commit('setShowConfig', {
key: 'showNewUserForm',
value: false,
@@ -367,8 +348,19 @@ export default {
},
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]
@@ -399,7 +391,7 @@ export default {
</script>
<style lang="scss" scoped>
-@import './Users/shared/styles.scss';
+@use './Users/shared/styles' as *;
.empty {
:deep {
diff --git a/apps/settings/src/components/Users/NewUserModal.vue b/apps/settings/src/components/Users/NewUserDialog.vue
index ca80ae6d7ec..ef401b565fa 100644
--- a/apps/settings/src/components/Users/NewUserModal.vue
+++ b/apps/settings/src/components/Users/NewUserDialog.vue
@@ -1,36 +1,21 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <NcModal class="modal"
+ <NcDialog class="dialog"
size="small"
+ :name="t('settings', 'New account')"
+ out-transition
v-on="$listeners">
- <form class="modal__form"
+ <form id="new-user-form"
+ class="dialog__form"
data-test="form"
:disabled="loading.all"
@submit.prevent="createUser">
- <h2>{{ t('settings', 'New user') }}</h2>
<NcTextField ref="username"
- class="modal__item"
+ class="dialog__item"
data-test="username"
:value.sync="newUser.id"
:disabled="settings.newUserGenerateUserID"
@@ -40,7 +25,7 @@
spellcheck="false"
pattern="[a-zA-Z0-9 _\.@\-']+"
required />
- <NcTextField class="modal__item"
+ <NcTextField class="dialog__item"
data-test="displayName"
:value.sync="newUser.displayName"
:label="t('settings', 'Display name')"
@@ -49,11 +34,11 @@
spellcheck="false" />
<span v-if="!settings.newUserRequireEmail"
id="password-email-hint"
- class="modal__hint">
+ class="dialog__hint">
{{ t('settings', 'Either password or email is required') }}
</span>
<NcPasswordField ref="password"
- class="modal__item"
+ class="dialog__item"
data-test="password"
:value.sync="newUser.password"
:minlength="minPasswordLength"
@@ -64,7 +49,7 @@
autocomplete="new-password"
spellcheck="false"
:required="newUser.mailAddress === ''" />
- <NcTextField class="modal__item"
+ <NcTextField class="dialog__item"
data-test="email"
type="email"
:value.sync="newUser.mailAddress"
@@ -74,72 +59,54 @@
autocomplete="off"
spellcheck="false"
:required="newUser.password === '' || settings.newUserRequireEmail" />
- <div class="modal__item">
- <!-- hidden input trick for vanilla html5 form validation -->
- <NcTextField v-if="!settings.isAdmin"
- id="new-user-groups-input"
- tabindex="-1"
- :class="{ 'icon-loading-small': loading.groups }"
- :value="newUser.groups"
- :required="!settings.isAdmin" />
- <label class="modal__label"
- for="new-user-groups">
- {{ !settings.isAdmin ? t('settings', 'Groups (required)') : t('settings', 'Groups') }}
- </label>
- <NcSelect class="modal__select"
- input-id="new-user-groups"
- :placeholder="t('settings', 'Set user groups')"
+ <div class="dialog__item">
+ <NcSelect class="dialog__select"
+ data-test="groups"
+ :input-label="!settings.isAdmin && !settings.isDelegatedAdmin ? t('settings', 'Member of the following groups (required)') : t('settings', 'Member of the following groups')"
+ :placeholder="t('settings', 'Set account groups')"
:disabled="loading.groups || loading.all"
- :options="canAddGroups"
+ :options="availableGroups"
:value="newUser.groups"
label="name"
:close-on-select="false"
:multiple="true"
- :taggable="true"
- @input="handleGroupInput"
- @option:created="createGroup" />
- <!-- If user is not admin, he is a subadmin.
+ :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 v-if="subAdminsGroups.length > 0 && settings.isAdmin"
- class="modal__item">
- <label class="modal__label"
- for="new-user-sub-admin">
- {{ t('settings', 'Administered groups') }}
- </label>
+ <div class="dialog__item">
<NcSelect v-model="newUser.subAdminsGroups"
- class="modal__select"
- input-id="new-user-sub-admin"
- :placeholder="t('settings', 'Set user as admin for …')"
- :options="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" />
+ label="name"
+ @search="searchGroups" />
</div>
- <div class="modal__item">
- <label class="modal__label"
- for="new-user-quota">
- {{ t('settings', 'Quota') }}
- </label>
+ <div class="dialog__item">
<NcSelect v-model="newUser.quota"
- class="modal__select"
- input-id="new-user-quota"
- :placeholder="t('settings', 'Set user 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="modal__item">
- <label class="modal__label"
- for="new-user-language">
- {{ t('settings', 'Language') }}
- </label>
- <NcSelect v-model="newUser.language"
- class="modal__select"
- input-id="new-user-language"
+ 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"
@@ -147,44 +114,47 @@
:options="languages"
label="name" />
</div>
- <div :class="['modal__item managers', { 'icon-loading-small': loading.manager }]">
- <label class="modal__label"
- for="new-user-manager">
- <!-- TRANSLATORS This string describes a manager in the context of an organization -->
- {{ t('settings', 'Manager') }}
- </label>
+ <div :class="['dialog__item dialog__managers', { 'icon-loading-small': loading.manager }]">
<NcSelect v-model="newUser.manager"
- class="modal__select"
- input-id="new-user-manager"
+ class="dialog__select"
+ :input-label="managerInputLabel"
:placeholder="managerLabel"
:options="possibleManagers"
:user-select="true"
label="displayname"
@search="searchUserManager" />
</div>
- <NcButton class="modal__submit"
+ </form>
+
+ <template #actions>
+ <NcButton class="dialog__submit"
data-test="submit"
+ form="new-user-form"
type="primary"
native-type="submit">
- {{ t('settings', 'Add new user') }}
+ {{ t('settings', 'Add new account') }}
</NcButton>
- </form>
- </NcModal>
+ </template>
+ </NcDialog>
</template>
<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import { 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: 'NewUserModal',
+ name: 'NewUserDialog',
components: {
NcButton,
- NcModal,
+ NcDialog,
NcPasswordField,
NcSelect,
NcTextField,
@@ -211,7 +181,11 @@ export default {
return {
possibleManagers: [],
// TRANSLATORS This string describes a manager in the context of an organization
- managerLabel: t('settings', 'Set user manager'),
+ 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,
}
},
@@ -226,36 +200,21 @@ export default {
usernameLabel() {
if (this.settings.newUserGenerateUserID) {
- return t('settings', 'Username will be autogenerated')
+ return t('settings', 'Account name will be autogenerated')
}
- return t('settings', 'Username (required)')
+ return t('settings', 'Account name (required)')
},
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength
},
- 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))
- },
+ availableGroups() {
+ const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
+ ? this.$store.getters.getSortedGroups
+ : this.$store.getters.getSubAdminGroups
- subAdminsGroups() {
- // data provided php side
- return this.$store.getters.getSubadminGroups
- },
-
- canAddGroups() {
- // disabled if no permission to add new users to group
- return this.groups.map(group => {
- // clone object because we don't want
- // to edit the original groups
- group = Object.assign({}, group)
- group.$isDisabled = group.canAdd === false
- return group
- })
+ return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
},
languages() {
@@ -278,6 +237,10 @@ export default {
await this.searchUserManager()
},
+ mounted() {
+ this.$refs.username?.focus?.()
+ },
+
methods: {
async createUser() {
this.loading.all = true
@@ -295,30 +258,49 @@ export default {
})
this.$emit('reset')
- this.$refs.username?.$refs?.inputField?.$refs?.input?.focus?.()
- this.$emit('close')
+ 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?.$refs?.inputField?.$refs?.input?.focus?.()
+ this.$refs.username?.focus?.()
} else if (statuscode === 107) {
// wrong password
- this.$refs.password?.$refs?.inputField?.$refs?.input?.focus?.()
+ this.$refs.password?.focus?.()
}
}
}
},
- handleGroupInput(groups) {
- /**
- * Filter out groups with no id to prevent duplicate selected options
- *
- * Created groups are added programmatically by `createGroup()`
- */
- this.newUser.groups = groups.filter(group => Boolean(group.id))
+ async searchGroups(query, toggleLoading) {
+ if (!this.settings.isAdmin && !this.settings.isDelegatedAdmin) {
+ // managers cannot search for groups
+ return
+ }
+
+ if (this.promise) {
+ this.promise.cancel()
+ }
+ toggleLoading(true)
+ try {
+ this.promise = searchGroups({
+ search: query,
+ offset: 0,
+ limit: 25,
+ })
+ const groups = await this.promise
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ } catch (error) {
+ logger.error(t('settings', 'Failed to search groups'), { error })
+ }
+ this.promise = null
+ toggleLoading(false)
},
/**
@@ -331,11 +313,26 @@ export default {
this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
- this.newUser.groups.push(this.groups.find(group => group.id === gid))
- this.loading.groups = false
+ this.newUser.groups.push({ id: gid, name: gid })
} catch (error) {
- this.loading.groups = false
+ logger.error(t('settings', 'Failed to create group'), { error })
+ }
+ this.loading.groups = false
+ },
+
+ /**
+ * Add user to group
+ *
+ * @param {object} group Group object
+ */
+ async addGroup(group) {
+ if (group.isCreating) {
+ return
+ }
+ if (group.canAdd === false) {
+ return
}
+ this.newUser.groups.push(group)
},
/**
@@ -349,7 +346,7 @@ export default {
const validQuota = OC.Util.computerFileSize(quota)
if (validQuota !== null && validQuota >= 0) {
// unify format output
- quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota))
+ quota = formatFileSize(parseFileSize(quota, true))
this.newUser.quota = { id: quota, label: quota }
return this.newUser.quota
}
@@ -389,25 +386,13 @@ export default {
</script>
<style lang="scss" scoped>
-.modal {
+.dialog {
&__form {
display: flex;
flex-direction: column;
align-items: center;
- padding: 20px;
+ padding: 0 8px;
gap: 4px 0;
-
- /* fake input for groups validation */
- #new-user-groups-input {
- position: absolute;
- opacity: 0;
- /* The "hidden" input is behind the NcSelect, 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;
- }
}
&__item {
@@ -433,8 +418,19 @@ export default {
width: 100%;
}
+ &__managers {
+ margin-bottom: 12px;
+ }
+
&__submit {
- margin-top: 20px;
+ 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
index d8974658354..bf9aa43b6d3 100644
--- a/apps/settings/src/components/Users/UserListFooter.vue
+++ b/apps/settings/src/components/Users/UserListFooter.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -28,7 +11,7 @@
</th>
<td class="footer__cell footer__cell--loading">
<NcLoadingIcon v-if="loading"
- :title="t('settings', 'Loading users …')"
+ :title="t('settings', 'Loading accounts …')"
:size="32" />
</td>
<td class="footer__cell footer__cell--count footer__cell--multiline">
@@ -43,7 +26,7 @@
<script lang="ts">
import Vue from 'vue'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import {
translate as t,
@@ -73,8 +56,8 @@ export default Vue.extend({
if (this.loading) {
return this.n(
'settings',
- '{userCount} user …',
- '{userCount} users …',
+ '{userCount} account …',
+ '{userCount} accounts …',
this.filteredUsers.length,
{
userCount: this.filteredUsers.length,
@@ -83,8 +66,8 @@ export default Vue.extend({
}
return this.n(
'settings',
- '{userCount} user',
- '{userCount} users',
+ '{userCount} account',
+ '{userCount} accounts',
this.filteredUsers.length,
{
userCount: this.filteredUsers.length,
@@ -101,18 +84,18 @@ export default Vue.extend({
</script>
<style lang="scss" scoped>
-@import './shared/styles.scss';
+@use './shared/styles';
.footer {
- @include row;
- @include cell;
+ @include styles.row;
+ @include styles.cell;
&__cell {
position: sticky;
color: var(--color-text-maxcontrast);
&--loading {
- left: 0;
+ inset-inline-start: 0;
min-width: var(--avatar-cell-width);
width: var(--avatar-cell-width);
align-items: center;
@@ -120,7 +103,7 @@ export default Vue.extend({
}
&--count {
- left: var(--avatar-cell-width);
+ inset-inline-start: var(--avatar-cell-width);
min-width: var(--cell-width);
width: var(--cell-width);
}
diff --git a/apps/settings/src/components/Users/UserListHeader.vue b/apps/settings/src/components/Users/UserListHeader.vue
index e314bcb6a73..a85306d84d3 100644
--- a/apps/settings/src/components/Users/UserListHeader.vue
+++ b/apps/settings/src/components/Users/UserListHeader.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -35,8 +18,12 @@
<strong>
{{ t('settings', 'Display name') }}
</strong>
- <span class="header__subtitle">
- {{ t('settings', 'Username') }}
+ </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"
@@ -55,7 +42,7 @@
scope="col">
<span>{{ t('settings', 'Groups') }}</span>
</th>
- <th v-if="subAdminsGroups.length > 0 && settings.isAdmin"
+ <th v-if="settings.isAdmin || settings.isDelegatedAdmin"
class="header__cell header__cell--large"
data-cy-user-list-header-subadmins
scope="col">
@@ -77,13 +64,19 @@
data-cy-user-list-header-storage-location
scope="col">
<span v-if="showConfig.showUserBackend">
- {{ t('settings', 'User backend') }}
+ {{ 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
@@ -100,7 +93,7 @@
data-cy-user-list-header-actions
scope="col">
<span class="hidden-visually">
- {{ t('settings', 'User actions') }}
+ {{ t('settings', 'Account actions') }}
</span>
</th>
</tr>
@@ -132,11 +125,6 @@ export default Vue.extend({
return this.$store.getters.getServerData
},
- subAdminsGroups() {
- // @ts-expect-error: allow untyped $store
- return this.$store.getters.getSubadminGroups
- },
-
passwordLabel(): string {
if (this.hasObfuscated) {
// TRANSLATORS This string is for a column header labelling either a password or a message that the current user has insufficient permissions
@@ -153,12 +141,12 @@ export default Vue.extend({
</script>
<style lang="scss" scoped>
-@import './shared/styles.scss';
+@use './shared/styles';
.header {
- @include row;
- @include cell;
-
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
index a27b41e11a6..43668725972 100644
--- a/apps/settings/src/components/Users/UserRow.vue
+++ b/apps/settings/src/components/Users/UserRow.vue
@@ -1,34 +1,14 @@
<!--
- - @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- - @author Gary Kim <gary@garykim.dev>
- - @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>
<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 user …')"
+ :name="t('settings', 'Loading account …')"
:size="32" />
<NcAvatar v-else-if="visible"
disable-menu
@@ -54,13 +34,14 @@
spellcheck="false"
@trailing-button-click="updateDisplayName" />
</template>
- <template v-else>
- <strong v-if="!isObfuscated"
- :title="user.displayname?.length > 20 ? user.displayname : null">
- {{ user.displayname }}
- </strong>
- <span class="row__subtitle">{{ user.id }}</span>
- </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
@@ -87,7 +68,7 @@
@trailing-button-click="updatePassword" />
</template>
<span v-else-if="isObfuscated">
- {{ t('settings', 'You do not have permissions to see the details of this user') }}
+ {{ t('settings', 'You do not have permissions to see the details of this account') }}
</span>
</td>
@@ -119,23 +100,24 @@
<template v-if="editing">
<label class="hidden-visually"
:for="'groups' + uniqueId">
- {{ t('settings', 'Add user to group') }}
+ {{ 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"
+ :disabled="isLoadingField || loading.groupsDetails"
:loading="loading.groups"
:multiple="true"
:append-to-body="false"
:options="availableGroups"
- :placeholder="t('settings', 'Add user to group')"
- :taggable="settings.isAdmin"
+ :placeholder="t('settings', 'Add account to group')"
+ :taggable="settings.isAdmin || settings.isDelegatedAdmin"
:value="userGroups"
label="name"
:no-wrap="true"
- :create-option="(value) => ({ name: value, isCreating: true })"
+ :create-option="(value) => ({ id: value, name: value, isCreating: true })"
+ @search="searchGroups"
@option:created="createGroup"
@option:selected="options => addUserGroup(options.at(-1))"
@option:deselected="removeUserGroup" />
@@ -146,33 +128,34 @@
</span>
</td>
- <td v-if="subAdminsGroups.length > 0 && settings.isAdmin"
+ <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 && subAdminsGroups.length > 0">
+ <template v-if="editing && (settings.isAdmin || settings.isDelegatedAdmin)">
<label class="hidden-visually"
:for="'subadmins' + uniqueId">
- {{ t('settings', 'Set user as admin for') }}
+ {{ 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"
+ :disabled="isLoadingField || loading.subAdminGroupsDetails"
:loading="loading.subadmins"
label="name"
:append-to-body="false"
:multiple="true"
:no-wrap="true"
- :options="subAdminsGroups"
- :placeholder="t('settings', 'Set user as admin for')"
- :value="userSubAdminsGroups"
+ :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="userSubAdminsGroupsLabels?.length > 40 ? userSubAdminsGroupsLabels : null">
- {{ userSubAdminsGroupsLabels }}
+ :title="userSubAdminGroupsLabels?.length > 40 ? userSubAdminGroupsLabels : null">
+ {{ userSubAdminGroupsLabels }}
</span>
</td>
@@ -180,7 +163,7 @@
<template v-if="editing">
<label class="hidden-visually"
:for="'quota' + uniqueId">
- {{ t('settings', 'Select user quota') }}
+ {{ t('settings', 'Select account quota') }}
</label>
<NcSelect v-model="editedUserQuota"
:close-on-select="true"
@@ -193,7 +176,7 @@
:clearable="false"
:input-id="'quota' + uniqueId"
:options="quotaOptions"
- :placeholder="t('settings', 'Select user quota')"
+ :placeholder="t('settings', 'Select account quota')"
:taggable="true"
@option:selected="setUserQuota" />
</template>
@@ -248,6 +231,12 @@
</template>
</td>
+ <td v-if="showConfig.showFirstLogin"
+ class="row__cell"
+ data-cy-user-list-cell-first-login>
+ <span v-if="!isObfuscated">{{ userFirstLogin }}</span>
+ </td>
+
<td v-if="showConfig.showLastLogin"
:title="userLastLoginTooltip"
class="row__cell"
@@ -266,16 +255,17 @@
data-cy-user-list-input-manager
:data-loading="loading.manager || undefined"
:input-id="'manager' + uniqueId"
- :close-on-select="true"
:disabled="isLoadingField"
- :append-to-body="false"
:loading="loadingPossibleManagers || loading.manager"
- label="displayname"
:options="possibleManagers"
:placeholder="managerLabel"
+ label="displayname"
+ :filterable="false"
+ :internal-search="false"
+ :clearable="true"
@open="searchInitialUserManager"
@search="searchUserManager"
- @option:selected="updateUserManager" />
+ @update:model-value="updateUserManager" />
</template>
<span v-else-if="!isObfuscated">
{{ user.manager }}
@@ -297,17 +287,20 @@
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/dist/Components/NcAvatar.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-import NcProgressBar from '@nextcloud/vue/dist/Components/NcProgressBar.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
import UserRowActions from './UserRowActions.vue'
import UserRowMixin from '../../mixins/UserRowMixin.js'
-import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts';
+import { isObfuscated, unlimitedQuota } from '../../utils/userUtils.ts'
+import { searchGroups, loadUserGroups, loadUserSubAdminGroups } from '../../service/groups.ts'
+import logger from '../../logger.ts'
export default {
name: 'UserRow',
@@ -342,14 +335,6 @@ export default {
type: Boolean,
required: true,
},
- groups: {
- type: Array,
- default: () => [],
- },
- subAdminsGroups: {
- type: Array,
- required: true,
- },
quotaOptions: {
type: Array,
required: true,
@@ -382,6 +367,8 @@ export default {
password: false,
mailAddress: false,
groups: false,
+ groupsDetails: false,
+ subAdminGroupsDetails: false,
subadmins: false,
quota: false,
delete: false,
@@ -393,13 +380,15 @@ export default {
editedDisplayName: this.user.displayname,
editedPassword: '',
editedMail: this.user.email ?? '',
+ // Cancelable promise for search groups request
+ promise: null,
}
},
computed: {
managerLabel() {
- // TRANSLATORS This string describes a manager in the context of an organization
- return t('settings', 'Set user manager')
+ // TRANSLATORS This string describes a person's manager in the context of an organization
+ return t('settings', 'Set line manager')
},
isObfuscated() {
@@ -422,15 +411,35 @@ export default {
return encodeURIComponent(this.user.id + this.rand)
},
+ availableGroups() {
+ const groups = (this.settings.isAdmin || this.settings.isDelegatedAdmin)
+ ? this.$store.getters.getSortedGroups
+ : this.$store.getters.getSubAdminGroups
+
+ return groups.filter(group => group.id !== '__nc_internal_recent' && group.id !== 'disabled')
+ },
+
+ availableSubAdminGroups() {
+ return this.availableGroups.filter(group => group.id !== 'admin')
+ },
+
userGroupsLabels() {
return this.userGroups
- .map(group => group.name)
+ .map(group => {
+ // Try to match with more extensive group data
+ const availableGroup = this.availableGroups.find(g => g.id === group.id)
+ return availableGroup?.name ?? group.name ?? group.id
+ })
.join(', ')
},
- userSubAdminsGroupsLabels() {
- return this.userSubAdminsGroups
- .map(group => group.name)
+ userSubAdminGroupsLabels() {
+ return this.userSubAdminGroups
+ .map(group => {
+ // Try to match with more extensive group data
+ const availableGroup = this.availableSubAdminGroups.find(g => g.id === group.id)
+ return availableGroup?.name ?? group.name ?? group.id
+ })
.join(', ')
},
@@ -442,7 +451,7 @@ export default {
},
canEdit() {
- return getCurrentUser().uid !== this.user.id || this.settings.isAdmin
+ return getCurrentUser().uid !== this.user.id || this.settings.isAdmin || this.settings.isDelegatedAdmin
},
userQuota() {
@@ -469,17 +478,17 @@ export default {
const actions = [
{
icon: 'icon-delete',
- text: t('settings', 'Delete user'),
+ text: t('settings', 'Delete account'),
action: this.deleteUser,
},
{
icon: 'icon-delete',
- text: t('settings', 'Wipe all devices'),
+ 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 user') : t('settings', 'Enable user'),
+ text: this.user.enabled ? t('settings', 'Disable account') : t('settings', 'Enable account'),
action: this.enableDisableUser,
},
]
@@ -514,7 +523,6 @@ export default {
return this.languages[0].languages.concat(this.languages[1].languages)
},
},
-
async beforeMount() {
if (this.user.manager) {
await this.initManager(this.user.manager)
@@ -522,8 +530,9 @@ export default {
},
methods: {
- wipeUserDevices() {
+ 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'),
@@ -565,6 +574,66 @@ export default {
this.loadingPossibleManagers = false
},
+ async loadGroupsDetails() {
+ this.loading.groups = true
+ this.loading.groupsDetails = true
+ try {
+ const groups = await loadUserGroups({ userId: this.user.id })
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ this.selectedGroups = this.selectedGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup)
+ } catch (error) {
+ logger.error(t('settings', 'Failed to load groups with details'), { error })
+ }
+ this.loading.groups = false
+ this.loading.groupsDetails = false
+ },
+
+ async loadSubAdminGroupsDetails() {
+ this.loading.subadmins = true
+ this.loading.subAdminGroupsDetails = true
+ try {
+ const groups = await loadUserSubAdminGroups({ userId: this.user.id })
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ this.selectedSubAdminGroups = this.selectedSubAdminGroups.map(selectedGroup => groups.find(group => group.id === selectedGroup.id) ?? selectedGroup)
+ } catch (error) {
+ logger.error(t('settings', 'Failed to load sub admin groups with details'), { error })
+ }
+ this.loading.subadmins = false
+ this.loading.subAdminGroupsDetails = false
+ },
+
+ async searchGroups(query, toggleLoading) {
+ if (query === '') {
+ return // Prevent unexpected search behaviour e.g. on option:created
+ }
+ if (this.promise) {
+ this.promise.cancel()
+ }
+ toggleLoading(true)
+ try {
+ this.promise = await searchGroups({
+ search: query,
+ offset: 0,
+ limit: 25,
+ })
+ const groups = await this.promise
+ // Populate store from server request
+ for (const group of groups) {
+ this.$store.commit('addGroup', group)
+ }
+ } catch (error) {
+ logger.error(t('settings', 'Failed to search groups'), { error })
+ }
+ this.promise = null
+ toggleLoading(false)
+ },
+
async searchUserManager(query) {
await this.$store.dispatch('searchUsers', { offset: 0, limit: 10, search: query }).then(response => {
const users = response?.data ? this.filterManagers(Object.values(response?.data.ocs.data.users)) : []
@@ -574,11 +643,12 @@ export default {
})
},
- async updateUserManager(manager) {
- if (manager === null) {
- this.currentManager = ''
- }
+ async updateUserManager() {
this.loading.manager = true
+
+ // Store the current manager before making changes
+ const previousManager = this.user.manager
+
try {
await this.$store.dispatch('setUserData', {
userid: this.user.id,
@@ -586,16 +656,20 @@ export default {
value: this.currentManager ? this.currentManager.id : '',
})
} catch (error) {
- // TRANSLATORS This string describes a manager in the context of an organization
- showError(t('setting', 'Failed to update user manager'))
- console.error(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
}
},
- deleteUser() {
+ 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'),
@@ -637,68 +711,70 @@ export default {
/**
* Set user displayName
- *
- * @param {string} displayName The display name
*/
- updateDisplayName() {
+ async updateDisplayName() {
this.loading.displayName = true
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'displayname',
- value: this.editedDisplayName,
- }).then(() => {
- this.loading.displayName = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'displayname',
+ value: this.editedDisplayName,
+ })
+
if (this.editedDisplayName === this.user.displayname) {
- showSuccess(t('setting', 'Display name was successfully changed'))
+ showSuccess(t('settings', 'Display name was successfully changed'))
}
- })
+ } finally {
+ this.loading.displayName = false
+ }
},
/**
* Set user password
- *
- * @param {string} password The email address
*/
- updatePassword() {
+ async updatePassword() {
this.loading.password = true
if (this.editedPassword.length === 0) {
- showError(t('setting', "Password can't be empty"))
+ showError(t('settings', "Password can't be empty"))
this.loading.password = false
} else {
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'password',
- value: this.editedPassword,
- }).then(() => {
- this.loading.password = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'password',
+ value: this.editedPassword,
+ })
this.editedPassword = ''
- showSuccess(t('setting', 'Password was successfully changed'))
- })
+ showSuccess(t('settings', 'Password was successfully changed'))
+ } finally {
+ this.loading.password = false
+ }
}
},
/**
* Set user mailAddress
- *
- * @param {string} mailAddress The email address
*/
- updateEmail() {
+ async updateEmail() {
this.loading.mailAddress = true
if (this.editedMail === '') {
- showError(t('setting', "Email can't be empty"))
+ showError(t('settings', "Email can't be empty"))
this.loading.mailAddress = false
this.editedMail = this.user.email
} else {
- this.$store.dispatch('setUserData', {
- userid: this.user.id,
- key: 'email',
- value: this.editedMail,
- }).then(() => {
- this.loading.mailAddress = false
+ try {
+ await this.$store.dispatch('setUserData', {
+ userid: this.user.id,
+ key: 'email',
+ value: this.editedMail,
+ })
+
if (this.editedMail === this.user.email) {
- showSuccess(t('setting', 'Email was successfully changed'))
+ showSuccess(t('settings', 'Email was successfully changed'))
}
- })
+ } finally {
+ this.loading.mailAddress = false
+ }
}
},
@@ -708,17 +784,16 @@ export default {
* @param {string} gid Group id
*/
async createGroup({ name: gid }) {
- this.loading = { groups: true, subadmins: true }
+ this.loading.groups = true
try {
await this.$store.dispatch('addGroup', gid)
const userid = this.user.id
await this.$store.dispatch('addUserGroup', { userid, gid })
+ this.userGroups.push({ id: gid, name: gid })
} catch (error) {
- console.error(error)
- } finally {
- this.loading = { groups: false, subadmins: false }
+ logger.error(t('settings', 'Failed to create group'), { error })
}
- return this.$store.getters.getGroups[this.groups.length]
+ this.loading.groups = false
},
/**
@@ -732,19 +807,19 @@ export default {
// Ignore
return
}
- this.loading.groups = true
const userid = this.user.id
const gid = group.id
if (group.canAdd === false) {
- return false
+ return
}
+ this.loading.groups = true
try {
await this.$store.dispatch('addUserGroup', { userid, gid })
+ this.userGroups.push(group)
} catch (error) {
console.error(error)
- } finally {
- this.loading.groups = false
}
+ this.loading.groups = false
},
/**
@@ -764,6 +839,7 @@ export default {
userid,
gid,
})
+ this.userGroups = this.userGroups.filter(group => group.id !== gid)
this.loading.groups = false
// remove user from current list if current list is the removed group
if (this.$route.params.selectedGroup === gid) {
@@ -788,10 +864,11 @@ export default {
userid,
gid,
})
- this.loading.subadmins = false
+ this.userSubAdminGroups.push(group)
} catch (error) {
console.error(error)
}
+ this.loading.subadmins = false
},
/**
@@ -809,6 +886,7 @@ export default {
userid,
gid,
})
+ this.userSubAdminGroups = this.userSubAdminGroups.filter(group => group.id !== gid)
} catch (error) {
console.error(error)
} finally {
@@ -898,7 +976,7 @@ export default {
sendWelcomeMail() {
this.loading.all = true
this.$store.dispatch('sendWelcomeMail', this.user.id)
- .then(() => showSuccess(t('setting', 'Welcome mail sent!'), { timeout: 2000 }))
+ .then(() => showSuccess(t('settings', 'Welcome mail sent!'), { timeout: 2000 }))
.finally(() => {
this.loading.all = false
})
@@ -909,6 +987,8 @@ export default {
if (this.editing) {
await this.$nextTick()
this.$refs.displayNameField?.$refs?.inputField?.$refs?.input?.focus()
+ this.loadGroupsDetails()
+ this.loadSubAdminGroupsDetails()
}
if (this.editedDisplayName !== this.user.displayname) {
this.editedDisplayName = this.user.displayname
@@ -921,10 +1001,10 @@ export default {
</script>
<style lang="scss" scoped>
-@import './shared/styles.scss';
+@use './shared/styles';
.user-list__row {
- @include row;
+ @include styles.row;
&:hover {
background-color: var(--color-background-hover);
@@ -941,7 +1021,7 @@ export default {
}
.row {
- @include cell;
+ @include styles.cell;
&__cell {
border-bottom: 1px solid var(--color-border);
diff --git a/apps/settings/src/components/Users/UserRowActions.vue b/apps/settings/src/components/Users/UserRowActions.vue
index fc7881aba6a..efd70d879a7 100644
--- a/apps/settings/src/components/Users/UserRowActions.vue
+++ b/apps/settings/src/components/Users/UserRowActions.vue
@@ -1,28 +1,10 @@
<!--
- - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
- <NcActions :aria-label="t('settings', 'Toggle user actions menu')"
+ <NcActions :aria-label="t('settings', 'Toggle account actions menu')"
:disabled="disabled"
:inline="1">
<NcActionButton :data-cy-user-list-action-toggle-edit="`${edit}`"
@@ -33,30 +15,37 @@
<NcIconSvgWrapper :key="editSvg" :svg="editSvg" aria-hidden="true" />
</template>
</NcActionButton>
- <NcActionButton v-for="({ action, icon, text }, index) in actions"
+ <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 { PropType, defineComponent } from 'vue'
+import type { PropType } from 'vue'
+import { defineComponent } from 'vue'
+import isSvg from 'is-svg'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import SvgCheck from '@mdi/svg/svg/check.svg?raw'
-import SvgPencil from '@mdi/svg/svg/pencil.svg?raw'
+import SvgPencil from '@mdi/svg/svg/pencil-outline.svg?raw'
interface UserAction {
action: (event: MouseEvent, user: Record<string, unknown>) => void,
+ enabled?: (user: Record<string, unknown>) => boolean,
icon: string,
- text: string
+ text: string,
}
export default defineComponent({
@@ -104,12 +93,21 @@ export default defineComponent({
/**
* Current MDI logo to show for edit toggle
*/
- editSvg() {
+ 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
*/
diff --git a/apps/settings/src/components/Users/UserSettingsDialog.vue b/apps/settings/src/components/Users/UserSettingsDialog.vue
index 79f7d72c5d5..94c77d320dd 100644
--- a/apps/settings/src/components/Users/UserSettingsDialog.vue
+++ b/apps/settings/src/components/Users/UserSettingsDialog.vue
@@ -1,29 +1,12 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 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', 'User management settings')">
+ :name="t('settings', 'Account management settings')">
<NcAppSettingsSection id="visibility-settings"
:name="t('settings', 'Visibility')">
<NcCheckboxRadioSwitch type="switch"
@@ -34,7 +17,7 @@
<NcCheckboxRadioSwitch type="switch"
data-test="showUserBackend"
:checked.sync="showUserBackend">
- {{ t('settings', 'Show user backend') }}
+ {{ t('settings', 'Show account backend') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch type="switch"
data-test="showStoragePath"
@@ -42,48 +25,86 @@
{{ 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 users') }}
+ {{ t('settings', 'Send welcome email to new accounts') }}
</NcCheckboxRadioSwitch>
</NcAppSettingsSection>
<NcAppSettingsSection id="default-settings"
:name="t('settings', 'Defaults')">
- <label for="default-quota-select">{{ t('settings', 'Default quota') }}</label>
<NcSelect v-model="defaultQuota"
- input-id="default-quota-select"
- placement="top"
- :taggable="true"
- :options="quotaOptions"
+ :clearable="false"
:create-option="validateQuota"
+ :filter-by="filterQuotas"
+ :input-label="t('settings', 'Default quota')"
+ :options="quotaOptions"
+ placement="top"
:placeholder="t('settings', 'Select default quota')"
- :clearable="false"
+ taggable
@option:selected="setDefaultQuota" />
</NcAppSettingsSection>
</NcAppSettingsDialog>
</template>
<script>
-import axios from '@nextcloud/axios'
+import { formatFileSize, parseFileSize } from '@nextcloud/files'
import { generateUrl } from '@nextcloud/router'
-import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js'
-import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+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',
@@ -92,6 +113,7 @@ export default {
NcAppSettingsDialog,
NcAppSettingsSection,
NcCheckboxRadioSwitch,
+ NcNoteCard,
NcSelect,
},
@@ -110,6 +132,22 @@ export default {
},
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
@@ -129,37 +167,46 @@ export default {
showLanguages: {
get() {
- return this.getLocalstorage('showLanguages')
+ return this.showConfig.showLanguages
+ },
+ set(status) {
+ this.setShowConfig('showLanguages', status)
+ },
+ },
+
+ showFirstLogin: {
+ get() {
+ return this.showConfig.showFirstLogin
},
set(status) {
- this.setLocalStorage('showLanguages', status)
+ this.setShowConfig('showFirstLogin', status)
},
},
showLastLogin: {
get() {
- return this.getLocalstorage('showLastLogin')
+ return this.showConfig.showLastLogin
},
set(status) {
- this.setLocalStorage('showLastLogin', status)
+ this.setShowConfig('showLastLogin', status)
},
},
showUserBackend: {
get() {
- return this.getLocalstorage('showUserBackend')
+ return this.showConfig.showUserBackend
},
set(status) {
- this.setLocalStorage('showUserBackend', status)
+ this.setShowConfig('showUserBackend', status)
},
},
showStoragePath: {
get() {
- return this.getLocalstorage('showStoragePath')
+ return this.showConfig.showStoragePath
},
set(status) {
- this.setLocalStorage('showStoragePath', status)
+ this.setShowConfig('showStoragePath', status)
},
},
@@ -201,8 +248,8 @@ export default {
newUserSendEmail: value,
})
await axios.post(generateUrl('/settings/users/preferences/newUser.sendEmail'), { value: value ? 'yes' : 'no' })
- } catch (e) {
- console.error('could not update newUser.sendEmail preference: ' + e.message, e)
+ } catch (error) {
+ logger.error('Could not update newUser.sendEmail preference', { error })
} finally {
this.loadingSendMail = false
}
@@ -211,18 +258,24 @@ export default {
},
methods: {
- getLocalstorage(key) {
- // force initialization
- const localConfig = this.$localStorage.get(key)
- // if localstorage is null, fallback to original values
- this.$store.commit('setShowConfig', { key, value: localConfig !== null ? localConfig === 'true' : this.showConfig[key] })
- return this.showConfig[key]
+ /**
+ * 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
},
- setLocalStorage(key, status) {
+ setShowConfig(key, status) {
this.$store.commit('setShowConfig', { key, value: status })
- this.$localStorage.set(key, status)
- return status
},
/**
@@ -236,14 +289,13 @@ export default {
quota = quota?.id || quota.label
}
// only used for new presets sent through @Tag
- const validQuota = OC.Util.computerFileSize(quota)
+ const validQuota = parseFileSize(quota, true)
if (validQuota === null) {
return unlimitedQuota
- } else {
- // unify format output
- quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota))
- return { id: quota, label: quota }
}
+ // unify format output
+ quota = formatFileSize(validQuota)
+ return { id: quota, label: quota }
},
/**
@@ -272,9 +324,14 @@ export default {
}
</script>
-<style lang="scss" scoped>
-label[for="default-quota-select"] {
- display: block;
- padding: 4px 0;
+<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
index a90f778b48e..20dc70ef830 100644
--- a/apps/settings/src/components/Users/VirtualList.vue
+++ b/apps/settings/src/components/Users/VirtualList.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Christopher Ng <chrng8@gmail.com>
- -
- - @author Christopher Ng <chrng8@gmail.com>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
@@ -52,9 +35,9 @@
<script lang="ts">
import Vue from 'vue'
import { vElementVisibility } from '@vueuse/components'
-import { debounce } from 'debounce'
+import debounce from 'debounce'
-import logger from '../../logger.js'
+import logger from '../../logger.ts'
Vue.directive('elementVisibility', vElementVisibility)
@@ -174,6 +157,7 @@ export default Vue.extend({
display: block;
overflow: auto;
height: 100%;
+ will-change: scroll-position;
&__header,
&__footer {
@@ -188,7 +172,7 @@ export default Vue.extend({
}
&__footer {
- left: 0;
+ inset-inline-start: 0;
}
&__body {
diff --git a/apps/settings/src/components/Users/shared/styles.scss b/apps/settings/src/components/Users/shared/styles.scss
index a2ddcd8c8be..4dfdd58af6d 100644
--- a/apps/settings/src/components/Users/shared/styles.scss
+++ b/apps/settings/src/components/Users/shared/styles.scss
@@ -1,23 +1,6 @@
/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
@mixin row {
@@ -57,15 +40,19 @@
}
&--avatar {
- left: 0;
+ inset-inline-start: 0;
}
&--displayname {
- left: var(--avatar-cell-width);
- border-right: 1px solid var(--color-border);
+ 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);
@@ -105,7 +92,7 @@
&--actions {
position: sticky;
- right: 0;
+ inset-inline-end: 0;
z-index: var(--sticky-column-z-index);
display: flex;
flex-direction: row;
@@ -113,7 +100,7 @@
min-width: 110px;
width: 110px;
background-color: var(--color-main-background);
- border-left: 1px solid var(--color-border);
+ border-inline-start: 1px solid var(--color-border);
}
}
diff --git a/apps/settings/src/components/WebAuthn/AddDevice.vue b/apps/settings/src/components/WebAuthn/AddDevice.vue
index f9b3223d8cb..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">
- <NcButton @click="start" type="primary">
- {{ t('settings', 'Add WebAuthn device') }}
- </NcButton>
- </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">
- <NcButton @click="submit" type="primary">
- {{ t('settings', 'Add') }}
- </NcButton>
+ <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,15 +48,18 @@
</template>
<script>
+import { showError } from '@nextcloud/dialogs'
import { confirmPassword } from '@nextcloud/password-confirmation'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import '@nextcloud/password-confirmation/dist/style.css'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-import logger from '../../logger.js'
+import logger from '../../logger.ts'
import {
startRegistration,
finishRegistration,
-} from '../../service/WebAuthnRegistrationSerice.js'
+} from '../../service/WebAuthnRegistrationSerice.ts'
+
+import '@nextcloud/password-confirmation/dist/style.css'
const logAndPass = (text) => (data) => {
logger.debug(text)
@@ -88,6 +78,7 @@ export default {
components: {
NcButton,
+ NcTextField,
},
props: {
@@ -101,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
@@ -187,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 })
@@ -212,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 1de2661b8dc..4e10c1f234d 100644
--- a/apps/settings/src/components/WebAuthn/Device.vue
+++ b/apps/settings/src/components/WebAuthn/Device.vue
@@ -1,26 +1,10 @@
<!--
- - @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') }}
<NcActions :force-menu="true">
@@ -28,12 +12,12 @@
{{ t('settings', 'Delete') }}
</NcActionButton>
</NcActions>
- </div>
+ </li>
</template>
<script>
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
export default {
name: 'Device',
diff --git a/apps/settings/src/components/WebAuthn/Section.vue b/apps/settings/src/components/WebAuthn/Section.vue
index 5a323f39fd9..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">
@@ -28,19 +12,22 @@
<NcNoteCard v-if="devices.length === 0" type="info">
{{ t('settings', 'No devices configured.') }}
</NcNoteCard>
- <h3 v-else>
+
+ <h3 v-else id="security-webauthn__active-devices">
{{ t('settings', 'The following devices are configured for your account:') }}
</h3>
- <Device v-for="device in sortedDevices"
- :key="device.id"
- :name="device.name"
- @delete="deleteDevice(device.id)" />
+ <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>
- <NcNoteCard v-if="!hasPublicKeyCredential" type="warning">
+ <NcNoteCard v-if="!supportsWebauthn" type="warning">
{{ t('settings', 'Your browser does not support WebAuthn.') }}
</NcNoteCard>
- <AddDevice v-if="hasPublicKeyCredential"
+ <AddDevice v-if="supportsWebauthn"
:is-https="isHttps"
:is-localhost="isLocalhost"
@added="deviceAdded" />
@@ -48,16 +35,18 @@
</template>
<script>
+import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
import { confirmPassword } from '@nextcloud/password-confirmation'
-import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
-import '@nextcloud/password-confirmation/dist/style.css'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import sortBy from 'lodash/fp/sortBy.js'
import AddDevice from './AddDevice.vue'
import Device from './Device.vue'
-import logger from '../../logger.js'
+import logger from '../../logger.ts'
import { removeRegistration } from '../../service/WebAuthnRegistrationSerice.js'
+import '@nextcloud/password-confirmation/dist/style.css'
+
const sortByName = sortBy('name')
export default {
@@ -79,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,
@@ -115,5 +108,7 @@ export default {
</script>
<style scoped>
-
+.security-webauthn__device-list {
+ margin-block: 12px 18px;
+}
</style>
diff --git a/apps/settings/src/composables/useAppIcon.ts b/apps/settings/src/composables/useAppIcon.ts
new file mode 100644
index 00000000000..b5e211aa1bc
--- /dev/null
+++ b/apps/settings/src/composables/useAppIcon.ts
@@ -0,0 +1,62 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Ref } from 'vue'
+import type { IAppstoreApp } from '../app-types.ts'
+
+import { mdiCog, mdiCogOutline } from '@mdi/js'
+import { computed, ref, watchEffect } from 'vue'
+import AppstoreCategoryIcons from '../constants/AppstoreCategoryIcons.ts'
+import logger from '../logger.ts'
+
+/**
+ * Get the app icon raw SVG for use with `NcIconSvgWrapper` (do never use without sanitizing)
+ * It has a fallback to the categroy icon.
+ *
+ * @param app The app to get the icon for
+ */
+export function useAppIcon(app: Ref<IAppstoreApp>) {
+ const appIcon = ref<string|null>(null)
+
+ /**
+ * Fallback value if no app icon available
+ */
+ const categoryIcon = computed(() => {
+ let path: string
+ if (app.value?.app_api) {
+ // Use different default icon for ExApps (AppAPI)
+ path = mdiCogOutline
+ } else {
+ path = [app.value?.category ?? []].flat()
+ .map((name) => AppstoreCategoryIcons[name])
+ .filter((icon) => !!icon)
+ .at(0)
+ ?? (!app.value?.app_api ? mdiCog : mdiCogOutline)
+ }
+ return path ? `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="${path}" /></svg>` : null
+ })
+
+ watchEffect(async () => {
+ // Note: Only variables until the first `await` will be watched!
+ if (!app.value?.preview) {
+ appIcon.value = categoryIcon.value
+ } else {
+ appIcon.value = null
+ // Now try to load the real app icon
+ try {
+ const response = await window.fetch(app.value.preview)
+ const blob = await response.blob()
+ const rawSvg = await blob.text()
+ appIcon.value = rawSvg.replaceAll(/fill="#(fff|ffffff)([a-z0-9]{1,2})?"/ig, 'fill="currentColor"')
+ } catch (error) {
+ appIcon.value = categoryIcon.value
+ logger.error('Could not load app icon', { error })
+ }
+ }
+ })
+
+ return {
+ appIcon,
+ }
+}
diff --git a/apps/settings/src/composables/useGetLocalizedValue.ts b/apps/settings/src/composables/useGetLocalizedValue.ts
new file mode 100644
index 00000000000..a8a6f39f380
--- /dev/null
+++ b/apps/settings/src/composables/useGetLocalizedValue.ts
@@ -0,0 +1,29 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ILocalizedValue } from '../constants/AppDiscoverTypes'
+
+import { getLanguage } from '@nextcloud/l10n'
+import { computed, type Ref } from 'vue'
+
+/**
+ * Helper to get the localized value for the current users language
+ * @param dict The dictionary to get the value from
+ * @param language The language to use
+ */
+const getLocalizedValue = <T, >(dict: ILocalizedValue<T>, language: string) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en ?? null
+
+/**
+ * Get the localized value of the dictionary provided
+ * @param dict Dictionary
+ * @return String or null if invalid dictionary
+ */
+export const useLocalizedValue = <T, >(dict: Ref<ILocalizedValue<T|undefined>|undefined|null>) => {
+ /**
+ * Language of the current user
+ */
+ const language = getLanguage()
+
+ return computed(() => !dict?.value ? null : getLocalizedValue<T>(dict.value as ILocalizedValue<T>, language))
+}
diff --git a/apps/settings/src/composables/useGroupsNavigation.ts b/apps/settings/src/composables/useGroupsNavigation.ts
new file mode 100644
index 00000000000..d9f0637843b
--- /dev/null
+++ b/apps/settings/src/composables/useGroupsNavigation.ts
@@ -0,0 +1,59 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ComputedRef, Ref } from 'vue'
+import type { IGroup } from '../views/user-types'
+
+import { computed } from 'vue'
+
+/**
+ * Format a group to a menu entry
+ *
+ * @param group the group
+ */
+function formatGroupMenu(group?: IGroup) {
+ if (typeof group === 'undefined') {
+ return null
+ }
+
+ return {
+ id: group.id,
+ title: group.name,
+ usercount: group.usercount ?? 0,
+ count: Math.max(0, (group.usercount ?? 0) - (group.disabled ?? 0)),
+ }
+}
+
+export const useFormatGroups = (groups: Ref<IGroup[]>|ComputedRef<IGroup[]>) => {
+ /**
+ * All non-disabled non-admin groups
+ */
+ const userGroups = computed(() => {
+ const formatted = groups.value
+ // filter out disabled and admin
+ .filter(group => group.id !== 'disabled' && group.id !== '__nc_internal_recent' && group.id !== 'admin')
+ // format group
+ .map(group => formatGroupMenu(group))
+ // remove invalid
+ .filter(group => group !== null)
+ return formatted as NonNullable<ReturnType<typeof formatGroupMenu>>[]
+ })
+
+ /**
+ * The admin group if found otherwise null
+ */
+ const adminGroup = computed(() => formatGroupMenu(groups.value.find(group => group.id === 'admin')))
+
+ /**
+ * The group of disabled users
+ */
+ const disabledGroup = computed(() => formatGroupMenu(groups.value.find(group => group.id === 'disabled')))
+
+ /**
+ * The group of recent users
+ */
+ const recentGroup = computed(() => formatGroupMenu(groups.value.find(group => group.id === '__nc_internal_recent')))
+
+ return { adminGroup, recentGroup, disabledGroup, userGroups }
+}
diff --git a/apps/settings/src/constants/AccountPropertyConstants.js b/apps/settings/src/constants/AccountPropertyConstants.ts
index 04367fde811..575a2744cc6 100644
--- a/apps/settings/src/constants/AccountPropertyConstants.js
+++ b/apps/settings/src/constants/AccountPropertyConstants.ts
@@ -1,29 +1,13 @@
/**
- * @copyright 2021, Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
/*
* SYNC to be kept in sync with `lib/public/Accounts/IAccountManager.php`
*/
+import { mdiAccountGroupOutline, mdiCellphone, mdiLockOutline, mdiWeb } from '@mdi/js'
import { translate as t } from '@nextcloud/l10n'
/** Enum of account properties */
@@ -31,17 +15,20 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
ADDRESS: 'address',
AVATAR: 'avatar',
BIOGRAPHY: 'biography',
+ BIRTHDATE: 'birthdate',
DISPLAYNAME: 'displayname',
EMAIL_COLLECTION: 'additional_mail',
EMAIL: 'email',
+ FEDIVERSE: 'fediverse',
HEADLINE: 'headline',
NOTIFICATION_EMAIL: 'notify_email',
- FEDIVERSE: 'fediverse',
ORGANISATION: 'organisation',
PHONE: 'phone',
PROFILE_ENABLED: 'profile_enabled',
+ PRONOUNS: 'pronouns',
ROLE: 'role',
TWITTER: 'twitter',
+ BLUESKY: 'bluesky',
WEBSITE: 'website',
})
@@ -50,16 +37,19 @@ export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({
ADDRESS: t('settings', 'Location'),
AVATAR: t('settings', 'Profile picture'),
BIOGRAPHY: t('settings', 'About'),
+ BIRTHDATE: t('settings', 'Date of birth'),
DISPLAYNAME: t('settings', 'Full name'),
EMAIL_COLLECTION: t('settings', 'Additional email'),
EMAIL: t('settings', 'Email'),
+ FEDIVERSE: t('settings', 'Fediverse (e.g. Mastodon)'),
HEADLINE: t('settings', 'Headline'),
ORGANISATION: t('settings', 'Organisation'),
PHONE: t('settings', 'Phone number'),
PROFILE_ENABLED: t('settings', 'Profile'),
+ PRONOUNS: t('settings', 'Pronouns'),
ROLE: t('settings', 'Role'),
TWITTER: t('settings', 'X (formerly Twitter)'),
- FEDIVERSE: t('settings', 'Fediverse (e.g. Mastodon)'),
+ BLUESKY: t('settings', 'Bluesky'),
WEBSITE: t('settings', 'Website'),
})
@@ -76,8 +66,11 @@ export const NAME_READABLE_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED]: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED,
[ACCOUNT_PROPERTY_ENUM.ROLE]: ACCOUNT_PROPERTY_READABLE_ENUM.ROLE,
[ACCOUNT_PROPERTY_ENUM.TWITTER]: ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER,
+ [ACCOUNT_PROPERTY_ENUM.BLUESKY]: ACCOUNT_PROPERTY_READABLE_ENUM.BLUESKY,
[ACCOUNT_PROPERTY_ENUM.FEDIVERSE]: ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE,
[ACCOUNT_PROPERTY_ENUM.WEBSITE]: ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE,
+ [ACCOUNT_PROPERTY_ENUM.BIRTHDATE]: ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE,
+ [ACCOUNT_PROPERTY_ENUM.PRONOUNS]: ACCOUNT_PROPERTY_READABLE_ENUM.PRONOUNS,
})
/** Enum of profile specific sections to human readable names */
@@ -99,8 +92,11 @@ export const PROPERTY_READABLE_KEYS_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED,
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: ACCOUNT_PROPERTY_ENUM.ROLE,
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: ACCOUNT_PROPERTY_ENUM.TWITTER,
+ [ACCOUNT_PROPERTY_READABLE_ENUM.BLUESKY]: ACCOUNT_PROPERTY_ENUM.BLUESKY,
[ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE]: ACCOUNT_PROPERTY_ENUM.FEDIVERSE,
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: ACCOUNT_PROPERTY_ENUM.WEBSITE,
+ [ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE]: ACCOUNT_PROPERTY_ENUM.BIRTHDATE,
+ [ACCOUNT_PROPERTY_READABLE_ENUM.PRONOUNS]: ACCOUNT_PROPERTY_ENUM.PRONOUNS,
})
/**
@@ -111,21 +107,23 @@ export const PROPERTY_READABLE_KEYS_ENUM = Object.freeze({
export const ACCOUNT_SETTING_PROPERTY_ENUM = Object.freeze({
LANGUAGE: 'language',
LOCALE: 'locale',
+ FIRST_DAY_OF_WEEK: 'first_day_of_week',
})
/** Enum of account setting properties to human readable setting properties */
export const ACCOUNT_SETTING_PROPERTY_READABLE_ENUM = Object.freeze({
LANGUAGE: t('settings', 'Language'),
LOCALE: t('settings', 'Locale'),
+ FIRST_DAY_OF_WEEK: t('settings', 'First day of week'),
})
/** Enum of scopes */
-export const SCOPE_ENUM = Object.freeze({
- PRIVATE: 'v2-private',
- LOCAL: 'v2-local',
- FEDERATED: 'v2-federated',
- PUBLISHED: 'v2-published',
-})
+export enum SCOPE_ENUM {
+ PRIVATE = 'v2-private',
+ LOCAL = 'v2-local',
+ FEDERATED = 'v2-federated',
+ PUBLISHED = 'v2-published',
+}
/** Enum of readable account properties to supported scopes */
export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({
@@ -141,8 +139,11 @@ export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({
[ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
+ [ACCOUNT_PROPERTY_READABLE_ENUM.BLUESKY]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.FEDIVERSE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
[ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
+ [ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
+ [ACCOUNT_PROPERTY_READABLE_ENUM.PRONOUNS]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE],
})
/** List of readable account properties which aren't published to the lookup server */
@@ -151,6 +152,7 @@ export const UNPUBLISHED_READABLE_PROPERTIES = Object.freeze([
ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE,
ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION,
ACCOUNT_PROPERTY_READABLE_ENUM.ROLE,
+ ACCOUNT_PROPERTY_READABLE_ENUM.BIRTHDATE,
])
/** Scope suffix */
@@ -167,28 +169,28 @@ export const SCOPE_PROPERTY_ENUM = Object.freeze({
displayName: t('settings', 'Private'),
tooltip: t('settings', 'Only visible to people matched via phone number integration through Talk on mobile'),
tooltipDisabled: t('settings', 'Not available as this property is required for core functionality including file sharing and calendar invitations'),
- iconClass: 'icon-phone',
+ icon: mdiCellphone,
},
[SCOPE_ENUM.LOCAL]: {
name: SCOPE_ENUM.LOCAL,
displayName: t('settings', 'Local'),
tooltip: t('settings', 'Only visible to people on this instance and guests'),
// tooltipDisabled is not required here as this scope is supported by all account properties
- iconClass: 'icon-password',
+ icon: mdiLockOutline,
},
[SCOPE_ENUM.FEDERATED]: {
name: SCOPE_ENUM.FEDERATED,
displayName: t('settings', 'Federated'),
tooltip: t('settings', 'Only synchronize to trusted servers'),
- tooltipDisabled: t('settings', 'Not available as federation has been disabled for your account, contact your system administrator if you have any questions'),
- iconClass: 'icon-contacts-dark',
+ tooltipDisabled: t('settings', 'Not available as federation has been disabled for your account, contact your system administration if you have any questions'),
+ icon: mdiAccountGroupOutline,
},
[SCOPE_ENUM.PUBLISHED]: {
name: SCOPE_ENUM.PUBLISHED,
displayName: t('settings', 'Published'),
tooltip: t('settings', 'Synchronize to trusted servers and the global and public address book'),
- tooltipDisabled: t('settings', 'Not available as publishing user specific data to the lookup server is not allowed, contact your system administrator if you have any questions'),
- iconClass: 'icon-link',
+ tooltipDisabled: t('settings', 'Not available as publishing account specific data to the lookup server is not allowed, contact your system administration if you have any questions'),
+ icon: mdiWeb,
},
})
@@ -196,11 +198,11 @@ export const SCOPE_PROPERTY_ENUM = Object.freeze({
export const DEFAULT_ADDITIONAL_EMAIL_SCOPE = SCOPE_ENUM.LOCAL
/** Enum of verification constants, according to IAccountManager */
-export const VERIFICATION_ENUM = Object.freeze({
- NOT_VERIFIED: 0,
- VERIFICATION_IN_PROGRESS: 1,
- VERIFIED: 2,
-})
+export enum VERIFICATION_ENUM {
+ NOT_VERIFIED = 0,
+ VERIFICATION_IN_PROGRESS = 1,
+ VERIFIED = 2,
+}
/**
* Email validation regex
@@ -209,3 +211,12 @@ export const VERIFICATION_ENUM = Object.freeze({
*/
// eslint-disable-next-line no-control-regex
export const VALIDATE_EMAIL_REGEX = /^(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){255,})(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){65,}@)(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22))(?:\.(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*\.){1,126}){1,}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-+[a-z0-9]+)*)|(?:\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\]))$/i
+
+export interface IAccountProperty {
+ name: string
+ value: string
+ scope: SCOPE_ENUM
+ verified: VERIFICATION_ENUM
+}
+
+export type AccountProperties = Record<(typeof ACCOUNT_PROPERTY_ENUM)[keyof (typeof ACCOUNT_PROPERTY_ENUM)], IAccountProperty>
diff --git a/apps/settings/src/constants/AppDiscoverTypes.ts b/apps/settings/src/constants/AppDiscoverTypes.ts
new file mode 100644
index 00000000000..bc2736eac9f
--- /dev/null
+++ b/apps/settings/src/constants/AppDiscoverTypes.ts
@@ -0,0 +1,117 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Currently known types of app discover section elements
+ */
+export const APP_DISCOVER_KNOWN_TYPES = ['post', 'showcase', 'carousel'] as const
+
+/**
+ * Helper for localized values
+ */
+export type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
+
+export interface IAppDiscoverElement {
+ /**
+ * Type of the element
+ */
+ type: typeof APP_DISCOVER_KNOWN_TYPES[number]
+
+ /**
+ * Identifier for this element
+ */
+ id: string,
+
+ /**
+ * Order of this element to pin elements (smaller = shown on top)
+ */
+ order?: number
+
+ /**
+ * Optional, localized, headline for the element
+ */
+ headline?: ILocalizedValue<string>
+
+ /**
+ * Optional link target for the element
+ */
+ link?: string
+
+ /**
+ * Optional date when this element will get valid (only show since then)
+ */
+ date?: number
+
+ /**
+ * Optional date when this element will be invalid (only show until then)
+ */
+ expiryDate?: number
+}
+
+/** Wrapper for media source and MIME type */
+type MediaSource = { src: string, mime: string }
+
+/**
+ * Media content type for posts
+ */
+interface IAppDiscoverMediaContent {
+ /**
+ * The media source to show - either one or a list of sources with their MIME type for fallback options
+ */
+ src: MediaSource | MediaSource[]
+
+ /**
+ * Alternative text for the media
+ */
+ alt: string
+
+ /**
+ * Optional link target for the media (e.g. to the full video)
+ */
+ link?: string
+}
+
+/**
+ * Wrapper for post media
+ */
+interface IAppDiscoverMedia {
+ /**
+ * The alignment of the media element
+ */
+ alignment?: 'start' | 'end' | 'center'
+
+ /**
+ * The (localized) content
+ */
+ content: ILocalizedValue<IAppDiscoverMediaContent>
+}
+
+/**
+ * An app element only used for the showcase type
+ */
+export interface IAppDiscoverApp {
+ /** The App ID */
+ type: 'app'
+ appId: string
+}
+
+export interface IAppDiscoverPost extends IAppDiscoverElement {
+ type: 'post'
+ text?: ILocalizedValue<string>
+ media?: IAppDiscoverMedia
+}
+
+export interface IAppDiscoverShowcase extends IAppDiscoverElement {
+ type: 'showcase'
+ content: (IAppDiscoverPost | IAppDiscoverApp)[]
+}
+
+export interface IAppDiscoverCarousel extends IAppDiscoverElement {
+ type: 'carousel'
+ text?: ILocalizedValue<string>
+ content: IAppDiscoverPost[]
+}
+
+export type IAppDiscoverElements = IAppDiscoverPost | IAppDiscoverCarousel | IAppDiscoverShowcase
diff --git a/apps/settings/src/constants/AppsConstants.js b/apps/settings/src/constants/AppsConstants.js
index 8df7d44815a..c90e35c84ce 100644
--- a/apps/settings/src/constants/AppsConstants.js
+++ b/apps/settings/src/constants/AppsConstants.js
@@ -1,29 +1,13 @@
/**
- * @copyright 2022, Julia Kirschenheuter <julia.kirschenheuter@nextcloud.com>
- *
- * @author Julia Kirschenheuter <julia.kirschenheuter@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import { translate as t } from '@nextcloud/l10n'
/** Enum of verification constants, according to Apps */
export const APPS_SECTION_ENUM = Object.freeze({
+ discover: t('settings', 'Discover'),
installed: t('settings', 'Your apps'),
enabled: t('settings', 'Active apps'),
disabled: t('settings', 'Disabled apps'),
diff --git a/apps/settings/src/constants/AppstoreCategoryIcons.ts b/apps/settings/src/constants/AppstoreCategoryIcons.ts
new file mode 100644
index 00000000000..989ffe79c22
--- /dev/null
+++ b/apps/settings/src/constants/AppstoreCategoryIcons.ts
@@ -0,0 +1,63 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import {
+ mdiAccountMultipleOutline,
+ mdiAccountOutline,
+ mdiArchiveOutline,
+ mdiCheck,
+ mdiClipboardFlowOutline,
+ mdiClose,
+ mdiCogOutline,
+ mdiControllerClassicOutline,
+ mdiCreationOutline,
+ mdiDownload,
+ mdiFileDocumentEdit,
+ mdiFolder,
+ mdiKeyOutline,
+ mdiMagnify,
+ mdiMonitorEye,
+ mdiMultimedia,
+ mdiOfficeBuildingOutline,
+ mdiOpenInApp,
+ mdiSecurity,
+ mdiStar,
+ mdiStarCircleOutline,
+ mdiStarShootingOutline,
+ mdiTools,
+ mdiViewColumnOutline,
+} from '@mdi/js'
+
+/**
+ * SVG paths used for appstore category icons
+ */
+export default Object.freeze({
+ // system special categories
+ discover: mdiStarCircleOutline,
+ installed: mdiAccountOutline,
+ enabled: mdiCheck,
+ disabled: mdiClose,
+ bundles: mdiArchiveOutline,
+ supported: mdiStarShootingOutline,
+ featured: mdiStar,
+ updates: mdiDownload,
+
+ // generic category
+ ai: mdiCreationOutline,
+ auth: mdiKeyOutline,
+ customization: mdiCogOutline,
+ dashboard: mdiViewColumnOutline,
+ files: mdiFolder,
+ games: mdiControllerClassicOutline,
+ integration: mdiOpenInApp,
+ monitoring: mdiMonitorEye,
+ multimedia: mdiMultimedia,
+ office: mdiFileDocumentEdit,
+ organization: mdiOfficeBuildingOutline,
+ search: mdiMagnify,
+ security: mdiSecurity,
+ social: mdiAccountMultipleOutline,
+ tools: mdiTools,
+ workflow: mdiClipboardFlowOutline,
+})
diff --git a/apps/settings/src/constants/GroupManagement.ts b/apps/settings/src/constants/GroupManagement.ts
new file mode 100644
index 00000000000..51ecac27383
--- /dev/null
+++ b/apps/settings/src/constants/GroupManagement.ts
@@ -0,0 +1,12 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34
+ */
+export enum GroupSorting {
+ UserCount = 1,
+ GroupName = 2
+}
diff --git a/apps/settings/src/constants/ProfileConstants.js b/apps/settings/src/constants/ProfileConstants.js
index f9fd3d26fb7..896adce8708 100644
--- a/apps/settings/src/constants/ProfileConstants.js
+++ b/apps/settings/src/constants/ProfileConstants.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2021 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
/*
@@ -41,7 +24,7 @@ export const VISIBILITY_PROPERTY_ENUM = Object.freeze({
},
[VISIBILITY_ENUM.SHOW_USERS_ONLY]: {
name: VISIBILITY_ENUM.SHOW_USERS_ONLY,
- label: t('settings', 'Show to logged in users only'),
+ label: t('settings', 'Show to logged in accounts only'),
},
[VISIBILITY_ENUM.HIDE]: {
name: VISIBILITY_ENUM.HIDE,
diff --git a/apps/settings/src/logger.js b/apps/settings/src/logger.js
deleted file mode 100644
index 81730c83b66..00000000000
--- a/apps/settings/src/logger.js
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import { getLoggerBuilder } from '@nextcloud/logger'
-
-export default getLoggerBuilder()
- .setApp('settings')
- .detectUser()
- .build()
diff --git a/apps/settings/src/logger.ts b/apps/settings/src/logger.ts
new file mode 100644
index 00000000000..44019a2b586
--- /dev/null
+++ b/apps/settings/src/logger.ts
@@ -0,0 +1,11 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+
+export default getLoggerBuilder()
+ .setApp('settings')
+ .detectUser()
+ .build()
diff --git a/apps/settings/src/main-admin-ai.js b/apps/settings/src/main-admin-ai.js
index 485b219ed78..79bc785a4f6 100644
--- a/apps/settings/src/main-admin-ai.js
+++ b/apps/settings/src/main-admin-ai.js
@@ -1,33 +1,14 @@
/**
- * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
+import { getCSPNonce } from '@nextcloud/auth'
import Vue from 'vue'
import ArtificialIntelligence from './components/AdminAI.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
Vue.prototype.t = t
diff --git a/apps/settings/src/main-admin-basic-settings.js b/apps/settings/src/main-admin-basic-settings.js
index 00ea8a36c4e..80f9c44a35a 100644
--- a/apps/settings/src/main-admin-basic-settings.js
+++ b/apps/settings/src/main-admin-basic-settings.js
@@ -1,36 +1,19 @@
/**
- * @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
*/
import Vue from 'vue'
-import { getRequestToken } from '@nextcloud/auth'
+import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
-import logger from './logger.js'
+import logger from './logger.ts'
import ProfileSettings from './components/BasicSettings/ProfileSettings.vue'
import BackgroundJob from './components/BasicSettings/BackgroundJob.vue'
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
const profileEnabledGlobally = loadState('settings', 'profileEnabledGlobally', true)
diff --git a/apps/settings/src/main-admin-delegation.js b/apps/settings/src/main-admin-delegation.js
index 772f0596542..c6b7ae1e5a2 100644
--- a/apps/settings/src/main-admin-delegation.js
+++ b/apps/settings/src/main-admin-delegation.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2021 Carl Schwan <carl@carlschwan.eu>
- *
- * @author Carl Schwan <carl@carlschwan.eu>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import Vue from 'vue'
diff --git a/apps/settings/src/main-admin-security.js b/apps/settings/src/main-admin-security.js
index a5c239683e7..26961dcc13e 100644
--- a/apps/settings/src/main-admin-security.js
+++ b/apps/settings/src/main-admin-security.js
@@ -1,36 +1,17 @@
/**
- * @copyright Copyright (c) 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
+import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import Vue from 'vue'
import AdminTwoFactor from './components/AdminTwoFactor.vue'
-import Encryption from './components/Encryption.vue'
+import EncryptionSettings from './components/Encryption/EncryptionSettings.vue'
import store from './store/admin-security.js'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
Vue.prototype.t = t
@@ -47,5 +28,5 @@ new View({
store,
}).$mount('#two-factor-auth-settings')
-const EncryptionView = Vue.extend(Encryption)
+const EncryptionView = Vue.extend(EncryptionSettings)
new EncryptionView().$mount('#vue-admin-encryption')
diff --git a/apps/settings/src/main-apps-users-management.js b/apps/settings/src/main-apps-users-management.js
deleted file mode 100644
index f81670fa624..00000000000
--- a/apps/settings/src/main-apps-users-management.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author rakekniven <mark.ziegler@rakekniven.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import Vue from 'vue'
-import VTooltip from 'v-tooltip'
-import { sync } from 'vuex-router-sync'
-
-import App from './App.vue'
-import router from './router.js'
-import store from './store/index.js'
-
-Vue.use(VTooltip, { defaultHtml: false })
-
-sync(store, router)
-
-// CSP config for webpack dynamic chunk loading
-// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
-
-// bind to window
-Vue.prototype.t = t
-Vue.prototype.n = n
-Vue.prototype.OC = OC
-Vue.prototype.OCA = OCA
-// eslint-disable-next-line camelcase
-Vue.prototype.oc_userconfig = oc_userconfig
-
-const app = new Vue({
- router,
- store,
- render: h => h(App),
-}).$mount('#content')
-
-export { app, router, store }
diff --git a/apps/settings/src/main-apps-users-management.ts b/apps/settings/src/main-apps-users-management.ts
new file mode 100644
index 00000000000..62ea009de11
--- /dev/null
+++ b/apps/settings/src/main-apps-users-management.ts
@@ -0,0 +1,40 @@
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Vue from 'vue'
+import Vuex from 'vuex'
+import VTooltipPlugin from 'v-tooltip'
+import { sync } from 'vuex-router-sync'
+import { t, n } from '@nextcloud/l10n'
+
+import SettingsApp from './views/SettingsApp.vue'
+import router from './router/index.ts'
+import { useStore } from './store/index.js'
+import { getCSPNonce } from '@nextcloud/auth'
+import { PiniaVuePlugin, createPinia } from 'pinia'
+
+// CSP config for webpack dynamic chunk loading
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+// bind to window
+Vue.prototype.t = t
+Vue.prototype.n = n
+Vue.use(PiniaVuePlugin)
+Vue.use(VTooltipPlugin, { defaultHtml: false })
+Vue.use(Vuex)
+
+const store = useStore()
+sync(store, router)
+
+const pinia = createPinia()
+
+export default new Vue({
+ router,
+ store,
+ pinia,
+ render: h => h(SettingsApp),
+ el: '#content',
+})
diff --git a/apps/settings/src/main-declarative-settings-forms.ts b/apps/settings/src/main-declarative-settings-forms.ts
new file mode 100644
index 00000000000..d6f5973baea
--- /dev/null
+++ b/apps/settings/src/main-declarative-settings-forms.ts
@@ -0,0 +1,62 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ComponentInstance } from 'vue'
+
+import { loadState } from '@nextcloud/initial-state'
+import { t, n } from '@nextcloud/l10n'
+import Vue from 'vue'
+import DeclarativeSection from './components/DeclarativeSettings/DeclarativeSection.vue'
+import logger from './logger'
+
+interface DeclarativeFormField {
+ id: string,
+ title: string,
+ description: string,
+ type: string,
+ placeholder: string,
+ label: string,
+ options: Array<unknown>|null,
+ value: unknown,
+ default: unknown,
+ sensitive: boolean,
+}
+
+interface DeclarativeForm {
+ id: number,
+ priority: number,
+ section_type: string,
+ section_id: string,
+ storage_type: string,
+ title: string,
+ description: string,
+ doc_url: string,
+ app: string,
+ fields: Array<DeclarativeFormField>,
+}
+
+const forms = loadState<DeclarativeForm[]>('settings', 'declarative-settings-forms', [])
+
+/**
+ * @param forms The forms to render
+ */
+function renderDeclarativeSettingsSections(forms: Array<DeclarativeForm>): ComponentInstance[] {
+ Vue.mixin({ methods: { t, n } })
+ const DeclarativeSettingsSection = Vue.extend(DeclarativeSection as never)
+
+ return forms.map((form) => {
+ const el = `#${form.app}_${form.id}`
+ return new DeclarativeSettingsSection({
+ el,
+ propsData: {
+ form,
+ },
+ })
+ })
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ logger.debug('Loaded declarative forms', { forms })
+ renderDeclarativeSettingsSections(forms)
+})
diff --git a/apps/settings/src/main-nextcloud-pdf.js b/apps/settings/src/main-nextcloud-pdf.js
index 613e5386d78..55d689c0f4a 100644
--- a/apps/settings/src/main-nextcloud-pdf.js
+++ b/apps/settings/src/main-nextcloud-pdf.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Jan C. Borchardt <hey@jancborchardt.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import { loadState } from '@nextcloud/initial-state'
diff --git a/apps/settings/src/main-personal-info.js b/apps/settings/src/main-personal-info.js
index cf0031ce26c..5ccfc9848c0 100644
--- a/apps/settings/src/main-personal-info.js
+++ b/apps/settings/src/main-personal-info.js
@@ -1,49 +1,36 @@
/**
- * @copyright 2021, Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import Vue from 'vue'
-import { getRequestToken } from '@nextcloud/auth'
+import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import AvatarSection from './components/PersonalInfo/AvatarSection.vue'
+import BiographySection from './components/PersonalInfo/BiographySection.vue'
+import BirthdaySection from './components/PersonalInfo/BirthdaySection.vue'
import DetailsSection from './components/PersonalInfo/DetailsSection.vue'
import DisplayNameSection from './components/PersonalInfo/DisplayNameSection.vue'
import EmailSection from './components/PersonalInfo/EmailSection/EmailSection.vue'
-import PhoneSection from './components/PersonalInfo/PhoneSection.vue'
-import LocationSection from './components/PersonalInfo/LocationSection.vue'
-import WebsiteSection from './components/PersonalInfo/WebsiteSection.vue'
-import TwitterSection from './components/PersonalInfo/TwitterSection.vue'
import FediverseSection from './components/PersonalInfo/FediverseSection.vue'
+import FirstDayOfWeekSection from './components/PersonalInfo/FirstDayOfWeekSection.vue'
+import HeadlineSection from './components/PersonalInfo/HeadlineSection.vue'
import LanguageSection from './components/PersonalInfo/LanguageSection/LanguageSection.vue'
import LocaleSection from './components/PersonalInfo/LocaleSection/LocaleSection.vue'
-import ProfileSection from './components/PersonalInfo/ProfileSection/ProfileSection.vue'
+import LocationSection from './components/PersonalInfo/LocationSection.vue'
import OrganisationSection from './components/PersonalInfo/OrganisationSection.vue'
-import RoleSection from './components/PersonalInfo/RoleSection.vue'
-import HeadlineSection from './components/PersonalInfo/HeadlineSection.vue'
-import BiographySection from './components/PersonalInfo/BiographySection.vue'
+import PhoneSection from './components/PersonalInfo/PhoneSection.vue'
+import ProfileSection from './components/PersonalInfo/ProfileSection/ProfileSection.vue'
import ProfileVisibilitySection from './components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue'
+import PronounsSection from './components/PersonalInfo/PronounsSection.vue'
+import RoleSection from './components/PersonalInfo/RoleSection.vue'
+import TwitterSection from './components/PersonalInfo/TwitterSection.vue'
+import BlueskySection from './components/PersonalInfo/BlueskySection.vue'
+import WebsiteSection from './components/PersonalInfo/WebsiteSection.vue'
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
const profileEnabledGlobally = loadState('settings', 'profileEnabledGlobally', true)
@@ -54,16 +41,20 @@ Vue.mixin({
})
const AvatarView = Vue.extend(AvatarSection)
+const BirthdayView = Vue.extend(BirthdaySection)
const DetailsView = Vue.extend(DetailsSection)
const DisplayNameView = Vue.extend(DisplayNameSection)
const EmailView = Vue.extend(EmailSection)
-const PhoneView = Vue.extend(PhoneSection)
-const LocationView = Vue.extend(LocationSection)
-const WebsiteView = Vue.extend(WebsiteSection)
-const TwitterView = Vue.extend(TwitterSection)
const FediverseView = Vue.extend(FediverseSection)
+const FirstDayOfWeekView = Vue.extend(FirstDayOfWeekSection)
const LanguageView = Vue.extend(LanguageSection)
const LocaleView = Vue.extend(LocaleSection)
+const LocationView = Vue.extend(LocationSection)
+const PhoneView = Vue.extend(PhoneSection)
+const PronounsView = Vue.extend(PronounsSection)
+const TwitterView = Vue.extend(TwitterSection)
+const BlueskyView = Vue.extend(BlueskySection)
+const WebsiteView = Vue.extend(WebsiteSection)
new AvatarView().$mount('#vue-avatar-section')
new DetailsView().$mount('#vue-details-section')
@@ -73,9 +64,13 @@ new PhoneView().$mount('#vue-phone-section')
new LocationView().$mount('#vue-location-section')
new WebsiteView().$mount('#vue-website-section')
new TwitterView().$mount('#vue-twitter-section')
+new BlueskyView().$mount('#vue-bluesky-section')
new FediverseView().$mount('#vue-fediverse-section')
new LanguageView().$mount('#vue-language-section')
new LocaleView().$mount('#vue-locale-section')
+new FirstDayOfWeekView().$mount('#vue-fdow-section')
+new BirthdayView().$mount('#vue-birthday-section')
+new PronounsView().$mount('#vue-pronouns-section')
if (profileEnabledGlobally) {
const ProfileView = Vue.extend(ProfileSection)
diff --git a/apps/settings/src/main-personal-password.js b/apps/settings/src/main-personal-password.js
index cd27832a7df..b74f5f71aa2 100644
--- a/apps/settings/src/main-personal-password.js
+++ b/apps/settings/src/main-personal-password.js
@@ -1,30 +1,15 @@
/**
- * @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
+import { getCSPNonce } from '@nextcloud/auth'
+import { t, n } from '@nextcloud/l10n'
import Vue from 'vue'
-
import PasswordSection from './components/PasswordSection.vue'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
Vue.prototype.t = t
Vue.prototype.n = n
diff --git a/apps/settings/src/main-personal-security.js b/apps/settings/src/main-personal-security.js
index d2aef1039ea..583a375fb0e 100644
--- a/apps/settings/src/main-personal-security.js
+++ b/apps/settings/src/main-personal-security.js
@@ -1,43 +1,24 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
+import { getCSPNonce } from '@nextcloud/auth'
+import { PiniaVuePlugin, createPinia } from 'pinia'
+import VTooltipPlugin from 'v-tooltip'
import Vue from 'vue'
-import VTooltip from 'v-tooltip'
import AuthTokenSection from './components/AuthTokenSection.vue'
-import { getRequestToken } from '@nextcloud/auth'
-import { PiniaVuePlugin, createPinia } from 'pinia'
import '@nextcloud/password-confirmation/dist/style.css'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
const pinia = createPinia()
Vue.use(PiniaVuePlugin)
-Vue.use(VTooltip, { defaultHtml: false })
+Vue.use(VTooltipPlugin, { defaultHtml: false })
Vue.prototype.t = t
const View = Vue.extend(AuthTokenSection)
diff --git a/apps/settings/src/main-personal-webauth.js b/apps/settings/src/main-personal-webauth.js
index dc11ecdbba2..f451fa8c73b 100644
--- a/apps/settings/src/main-personal-webauth.js
+++ b/apps/settings/src/main-personal-webauth.js
@@ -1,32 +1,15 @@
/**
- * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
-
-import Vue from 'vue'
+import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
+import Vue from 'vue'
import WebAuthnSection from './components/WebAuthn/Section.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
Vue.prototype.t = t
@@ -37,6 +20,5 @@ new View({
initialDevices: devices,
isHttps: window.location.protocol === 'https:',
isLocalhost: window.location.hostname === 'localhost',
- hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
},
}).$mount('#security-webauthn')
diff --git a/apps/settings/src/mixins/AppManagement.js b/apps/settings/src/mixins/AppManagement.js
index 748e462f7da..3822658589d 100644
--- a/apps/settings/src/mixins/AppManagement.js
+++ b/apps/settings/src/mixins/AppManagement.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import { showError } from '@nextcloud/dialogs'
@@ -29,16 +12,76 @@ export default {
return this.app.groups.map(group => { return { id: group, name: group } })
},
installing() {
+ if (this.app?.app_api) {
+ return this.app && this?.appApiStore.getLoading('install') === true
+ }
return this.$store.getters.loading('install')
},
isLoading() {
+ if (this.app?.app_api) {
+ return this.app && this?.appApiStore.getLoading(this.app.id) === true
+ }
return this.app && this.$store.getters.loading(this.app.id)
},
+ isInitializing() {
+ if (this.app?.app_api) {
+ return this.app && (this.app?.status?.action === 'init' || this.app?.status?.action === 'healthcheck')
+ }
+ return false
+ },
+ isDeploying() {
+ if (this.app?.app_api) {
+ return this.app && this.app?.status?.action === 'deploy'
+ }
+ return false
+ },
+ isManualInstall() {
+ if (this.app?.app_api) {
+ return this.app?.daemon?.accepts_deploy_id === 'manual-install'
+ }
+ return false
+ },
+ updateButtonText() {
+ if (this.app?.app_api && this.app?.daemon?.accepts_deploy_id === 'manual-install') {
+ return t('settings', 'Manually installed apps cannot be updated')
+ }
+ return t('settings', 'Update to {version}', { version: this.app?.update })
+ },
enableButtonText() {
- if (this.app.needsDownload) {
- return t('settings', 'Download and enable')
+ if (this.app?.app_api) {
+ if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
+ return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy ?? 0 })
+ }
+ if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
+ return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init ?? 0 })
+ }
+ if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
+ return t('settings', 'Health checking')
+ }
+ if (this.app.needsDownload) {
+ return t('settings', 'Deploy and Enable')
+ }
+ return t('settings', 'Enable')
+ } else {
+ if (this.app.needsDownload) {
+ return t('settings', 'Download and enable')
+ }
+ return t('settings', 'Enable')
+ }
+ },
+ disableButtonText() {
+ if (this.app?.app_api) {
+ if (this.app && this.app?.status?.action && this.app?.status?.action === 'deploy') {
+ return t('settings', '{progress}% Deploying …', { progress: this.app?.status?.deploy })
+ }
+ if (this.app && this.app?.status?.action && this.app?.status?.action === 'init') {
+ return t('settings', '{progress}% Initializing …', { progress: this.app?.status?.init })
+ }
+ if (this.app && this.app?.status?.action && this.app?.status?.action === 'healthcheck') {
+ return t('settings', 'Health checking')
+ }
}
- return t('settings', 'Enable')
+ return t('settings', 'Disable')
},
forceEnableButtonText() {
if (this.app.needsDownload) {
@@ -47,7 +90,7 @@ export default {
return t('settings', 'Allow untested app')
},
enableButtonTooltip() {
- if (this.app.needsDownload) {
+ if (!this.app?.app_api && this.app.needsDownload) {
return t('settings', 'The app will be downloaded from the App Store')
}
return null
@@ -59,6 +102,19 @@ export default {
}
return base
},
+ defaultDeployDaemonAccessible() {
+ if (this.app?.app_api) {
+ if (this.app?.daemon && this.app?.daemon?.accepts_deploy_id === 'manual-install') {
+ return true
+ }
+ if (this.app?.daemon?.accepts_deploy_id === 'docker-install'
+ && this.appApiStore.getDefaultDaemon?.name === this.app?.daemon?.name) {
+ return this?.appApiStore.getDaemonAccessible === true
+ }
+ return this?.appApiStore.getDaemonAccessible
+ }
+ return true
+ },
},
data() {
@@ -78,12 +134,15 @@ export default {
return this.$store.dispatch('getGroups', { search: query, limit: 5, offset: 0 })
},
isLimitedToGroups(app) {
- if (this.app.groups.length || this.groupCheckedAppsData) {
- return true
+ if (this.app?.app_api) {
+ return false
}
- return false
+ return this.app.groups.length || this.groupCheckedAppsData
},
setGroupLimit() {
+ if (this.app?.app_api) {
+ return // not supported for app_api apps
+ }
if (!this.groupCheckedAppsData) {
this.$store.dispatch('enableApp', { appId: this.app.id, groups: [] })
}
@@ -93,17 +152,24 @@ export default {
|| app.types.includes('prelogin')
|| app.types.includes('authentication')
|| app.types.includes('logging')
- || app.types.includes('prevent_group_restriction')) {
+ || app.types.includes('prevent_group_restriction')
+ || app?.app_api) {
return false
}
return true
},
addGroupLimitation(groupArray) {
+ if (this.app?.app_api) {
+ return // not supported for app_api apps
+ }
const group = groupArray.pop()
const groups = this.app.groups.concat([]).concat([group.id])
this.$store.dispatch('enableApp', { appId: this.app.id, groups })
},
removeGroupLimitation(group) {
+ if (this.app?.app_api) {
+ return // not supported for app_api apps
+ }
const currentGroups = this.app.groups.concat([])
const index = currentGroups.indexOf(group.id)
if (index > -1) {
@@ -112,34 +178,74 @@ export default {
this.$store.dispatch('enableApp', { appId: this.app.id, groups: currentGroups })
},
forceEnable(appId) {
- this.$store.dispatch('forceEnableApp', { appId, groups: [] })
- .then((response) => { rebuildNavigation() })
- .catch((error) => { showError(error) })
+ if (this.app?.app_api) {
+ this.appApiStore.forceEnableApp(appId)
+ .then(() => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ } else {
+ this.$store.dispatch('forceEnableApp', { appId, groups: [] })
+ .then((response) => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ }
},
- enable(appId) {
- this.$store.dispatch('enableApp', { appId, groups: [] })
- .then((response) => { rebuildNavigation() })
- .catch((error) => { showError(error) })
+ enable(appId, daemon = null, deployOptions = {}) {
+ if (this.app?.app_api) {
+ this.appApiStore.enableApp(appId, daemon, deployOptions)
+ .then(() => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ } else {
+ this.$store.dispatch('enableApp', { appId, groups: [] })
+ .then((response) => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ }
},
disable(appId) {
- this.$store.dispatch('disableApp', { appId })
- .then((response) => { rebuildNavigation() })
- .catch((error) => { showError(error) })
+ if (this.app?.app_api) {
+ this.appApiStore.disableApp(appId)
+ .then(() => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ } else {
+ this.$store.dispatch('disableApp', { appId })
+ .then((response) => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ }
},
- remove(appId) {
- this.$store.dispatch('uninstallApp', { appId })
- .then((response) => { rebuildNavigation() })
- .catch((error) => { showError(error) })
+ async remove(appId, removeData = false) {
+ try {
+ if (this.app?.app_api) {
+ await this.appApiStore.uninstallApp(appId, removeData)
+ } else {
+ await this.$store.dispatch('uninstallApp', { appId, removeData })
+ }
+ await rebuildNavigation()
+ } catch (error) {
+ showError(error)
+ }
},
install(appId) {
- this.$store.dispatch('enableApp', { appId })
- .then((response) => { rebuildNavigation() })
- .catch((error) => { showError(error) })
+ if (this.app?.app_api) {
+ this.appApiStore.enableApp(appId)
+ .then(() => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ } else {
+ this.$store.dispatch('enableApp', { appId })
+ .then((response) => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ }
},
update(appId) {
- this.$store.dispatch('updateApp', { appId })
- .then((response) => { rebuildNavigation() })
- .catch((error) => { showError(error) })
+ if (this.app?.app_api) {
+ this.appApiStore.updateApp(appId)
+ .then(() => { rebuildNavigation() })
+ .catch((error) => { showError(error) })
+ } else {
+ this.$store.dispatch('updateApp', { appId })
+ .catch((error) => { showError(error) })
+ .then(() => {
+ rebuildNavigation()
+ this.store.updateCount = Math.max(this.store.updateCount - 1, 0)
+ })
+ }
},
},
}
diff --git a/apps/settings/src/mixins/UserRowMixin.js b/apps/settings/src/mixins/UserRowMixin.js
index f7e98c9a895..9e46d8e25d7 100644
--- a/apps/settings/src/mixins/UserRowMixin.js
+++ b/apps/settings/src/mixins/UserRowMixin.js
@@ -1,27 +1,11 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Greta Doci <gretadoci@gmail.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
+import { formatFileSize } from '@nextcloud/files'
+import { useFormatDateTime } from '@nextcloud/vue'
+
export default {
props: {
user: {
@@ -32,14 +16,6 @@ export default {
type: Object,
default: () => ({}),
},
- groups: {
- type: Array,
- default: () => [],
- },
- subAdminsGroups: {
- type: Array,
- default: () => [],
- },
quotaOptions: {
type: Array,
default: () => [],
@@ -53,45 +29,37 @@ export default {
default: () => [],
},
},
+ setup(props) {
+ const { formattedFullTime } = useFormatDateTime(props.user.firstLoginTimestamp * 1000, {
+ relativeTime: false,
+ format: {
+ timeStyle: 'short',
+ dateStyle: 'short',
+ },
+ })
+ return {
+ formattedFullTime,
+ }
+ },
+ data() {
+ return {
+ selectedGroups: this.user.groups.map(id => ({ id, name: id })),
+ selectedSubAdminGroups: this.user.subadmin.map(id => ({ id, name: id })),
+ userGroups: this.user.groups.map(id => ({ id, name: id })),
+ userSubAdminGroups: this.user.subadmin.map(id => ({ id, name: id })),
+ }
+ },
computed: {
showConfig() {
return this.$store.getters.getShowConfig
},
- /* GROUPS MANAGEMENT */
- userGroups() {
- const userGroups = this.groups.filter(group => this.user.groups.includes(group.id))
- return userGroups
- },
- userSubAdminsGroups() {
- const userSubAdminsGroups = this.subAdminsGroups.filter(group => this.user.subadmin.includes(group.id))
- return userSubAdminsGroups
- },
- availableGroups() {
- return this.groups.map((group) => {
- // clone object because we don't want
- // to edit the original groups
- const groupClone = Object.assign({}, group)
-
- // two settings here:
- // 1. user NOT in group but no permission to add
- // 2. user is in group but no permission to remove
- groupClone.$isDisabled
- = (group.canAdd === false
- && !this.user.groups.includes(group.id))
- || (group.canRemove === false
- && this.user.groups.includes(group.id))
- return groupClone
- })
- },
-
/* QUOTA MANAGEMENT */
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) })
+ const quotaUsed = this.user.quota.used > 0 ? this.user.quota.used : 0
+ return t('settings', '{size} used', { size: formatFileSize(quotaUsed, true) })
},
+
usedQuota() {
let quota = this.user.quota.quota
if (quota > 0) {
@@ -103,11 +71,12 @@ export default {
}
return isNaN(quota) ? 0 : quota
},
+
// Mapping saved values to objects
userQuota() {
if (this.user.quota.quota >= 0) {
// if value is valid, let's map the quotaOptions or return custom quota
- const humanQuota = OC.Util.humanFileSize(this.user.quota.quota)
+ const humanQuota = formatFileSize(this.user.quota.quota)
const userQuota = this.quotaOptions.find(quota => quota.id === humanQuota)
return userQuota || { id: humanQuota, label: humanQuota }
} else if (this.user.quota.quota === 'default') {
@@ -137,16 +106,26 @@ export default {
return userLang
},
+ userFirstLogin() {
+ if (this.user.firstLoginTimestamp > 0) {
+ return this.formattedFullTime
+ }
+ if (this.user.firstLoginTimestamp < 0) {
+ return t('settings', 'Unknown')
+ }
+ return t('settings', 'Never')
+ },
+
/* LAST LOGIN */
userLastLoginTooltip() {
- if (this.user.lastLogin > 0) {
- return OC.Util.formatDate(this.user.lastLogin)
+ if (this.user.lastLoginTimestamp > 0) {
+ return OC.Util.formatDate(this.user.lastLoginTimestamp * 1000)
}
return ''
},
userLastLogin() {
- if (this.user.lastLogin > 0) {
- return OC.Util.relativeModifiedDate(this.user.lastLogin)
+ if (this.user.lastLoginTimestamp > 0) {
+ return OC.Util.relativeModifiedDate(this.user.lastLoginTimestamp * 1000)
}
return t('settings', 'Never')
},
diff --git a/apps/settings/src/router.js b/apps/settings/src/router.js
deleted file mode 100644
index 977cab2de96..00000000000
--- a/apps/settings/src/router.js
+++ /dev/null
@@ -1,136 +0,0 @@
-/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import Vue from 'vue'
-import Router from 'vue-router'
-import { generateUrl } from '@nextcloud/router'
-import { APPS_SECTION_ENUM } from './constants/AppsConstants.js'
-import store from './store/index.js'
-import { setPageHeading } from '../../../core/src/OCP/accessibility.js'
-
-// Dynamic loading
-const Users = () => import(/* webpackChunkName: 'settings-users' */'./views/Users.vue')
-const Apps = () => import(/* webpackChunkName: 'settings-apps-view' */'./views/Apps.vue')
-
-Vue.use(Router)
-
-/*
- * This is the list of routes where the vuejs app will
- * take over php to provide data
- * You need to forward the php routing (routes.php) to
- * the settings-vue template, where the vue-router will
- * ensure the proper route.
- * ⚠️ Routes needs to match the php routes.
- */
-const baseTitle = document.title
-const router = new Router({
- mode: 'history',
- // if index.php is in the url AND we got this far, then it's working:
- // let's keep using index.php in the url
- base: generateUrl(''),
- linkActiveClass: 'active',
- routes: [
- {
- path: '/:index(index.php/)?settings/users',
- component: Users,
- props: true,
- name: 'users',
- meta: {
- title: () => {
- return t('settings', 'Active users')
- },
- },
- children: [
- {
- path: ':selectedGroup',
- name: 'group',
- meta: {
- title: (to) => {
- if (to.params.selectedGroup === 'admin') {
- return t('settings', 'Admins')
- }
- if (to.params.selectedGroup === 'disabled') {
- return t('settings', 'Disabled users')
- }
- return decodeURIComponent(to.params.selectedGroup)
- },
- },
- component: Users,
- },
- ],
- },
- {
- path: '/:index(index.php/)?settings/apps',
- component: Apps,
- props: true,
- name: 'apps',
- meta: {
- title: () => {
- return t('settings', 'Your apps')
- },
- },
- children: [
- {
- path: ':category',
- name: 'apps-category',
- meta: {
- title: async (to) => {
- if (to.name === 'apps') {
- return t('settings', 'Your apps')
- }
- if (APPS_SECTION_ENUM[to.params.category]) {
- return APPS_SECTION_ENUM[to.params.category]
- }
- await store.dispatch('getCategories')
- const category = store.getters.getCategoryById(to.params.category)
- if (category.displayName) {
- return category.displayName
- }
- },
- },
- component: Apps,
- children: [
- {
- path: ':id',
- name: 'apps-details',
- component: Apps,
- },
- ],
- },
- ],
- },
- ],
-})
-
-router.afterEach(async (to) => {
- const metaTitle = await to.meta.title?.(to)
- if (metaTitle) {
- document.title = `${metaTitle} - ${baseTitle}`
- setPageHeading(metaTitle)
- } else {
- document.title = baseTitle
- }
-})
-
-export default router
diff --git a/apps/settings/src/router/index.ts b/apps/settings/src/router/index.ts
new file mode 100644
index 00000000000..9afa2e78146
--- /dev/null
+++ b/apps/settings/src/router/index.ts
@@ -0,0 +1,22 @@
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Vue from 'vue'
+import Router from 'vue-router'
+import { generateUrl } from '@nextcloud/router'
+import routes from './routes.ts'
+
+Vue.use(Router)
+
+const router = new Router({
+ mode: 'history',
+ // if index.php is in the url AND we got this far, then it's working:
+ // let's keep using index.php in the url
+ base: generateUrl(''),
+ linkActiveClass: 'active',
+ routes,
+})
+
+export default router
diff --git a/apps/settings/src/router/routes.ts b/apps/settings/src/router/routes.ts
new file mode 100644
index 00000000000..35b3b1306d5
--- /dev/null
+++ b/apps/settings/src/router/routes.ts
@@ -0,0 +1,63 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { RouteConfig } from 'vue-router'
+import { loadState } from '@nextcloud/initial-state'
+
+const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
+
+// Dynamic loading
+const AppStore = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStore.vue')
+const AppStoreNavigation = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStoreNavigation.vue')
+const AppStoreSidebar = () => import(/* webpackChunkName: 'settings-apps-view' */'../views/AppStoreSidebar.vue')
+
+const UserManagement = () => import(/* webpackChunkName: 'settings-users' */'../views/UserManagement.vue')
+const UserManagementNavigation = () => import(/* webpackChunkName: 'settings-users' */'../views/UserManagementNavigation.vue')
+
+const routes: RouteConfig[] = [
+ {
+ name: 'users',
+ path: '/:index(index.php/)?settings/users',
+ components: {
+ default: UserManagement,
+ navigation: UserManagementNavigation,
+ },
+ props: true,
+ children: [
+ {
+ path: ':selectedGroup',
+ name: 'group',
+ },
+ ],
+ },
+ {
+ path: '/:index(index.php/)?settings/apps',
+ name: 'apps',
+ redirect: {
+ name: 'apps-category',
+ params: {
+ category: appstoreEnabled ? 'discover' : 'installed',
+ },
+ },
+ components: {
+ default: AppStore,
+ navigation: AppStoreNavigation,
+ sidebar: AppStoreSidebar,
+ },
+ children: [
+ {
+ path: ':category',
+ name: 'apps-category',
+ children: [
+ {
+ path: ':id',
+ name: 'apps-details',
+ },
+ ],
+ },
+ ],
+ },
+]
+
+export default routes
diff --git a/apps/settings/src/service/PersonalInfo/EmailService.js b/apps/settings/src/service/PersonalInfo/EmailService.js
index f8256f0bdc0..0adbe5225bc 100644
--- a/apps/settings/src/service/PersonalInfo/EmailService.js
+++ b/apps/settings/src/service/PersonalInfo/EmailService.js
@@ -1,32 +1,16 @@
/**
- * @copyright 2021, Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
-import axios from '@nextcloud/axios'
import { getCurrentUser } from '@nextcloud/auth'
import { generateOcsUrl } from '@nextcloud/router'
import { confirmPassword } from '@nextcloud/password-confirmation'
-import '@nextcloud/password-confirmation/dist/style.css'
+import axios from '@nextcloud/axios'
-import { ACCOUNT_PROPERTY_ENUM, SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants.js'
+import { ACCOUNT_PROPERTY_ENUM, SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants.ts'
+
+import '@nextcloud/password-confirmation/dist/style.css'
/**
* Save the primary email of the user
diff --git a/apps/settings/src/service/PersonalInfo/PersonalInfoService.js b/apps/settings/src/service/PersonalInfo/PersonalInfoService.js
index 2e386a98bec..f2eaac91301 100644
--- a/apps/settings/src/service/PersonalInfo/PersonalInfoService.js
+++ b/apps/settings/src/service/PersonalInfo/PersonalInfoService.js
@@ -1,32 +1,16 @@
/**
- * @copyright 2021, Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
-import axios from '@nextcloud/axios'
import { getCurrentUser } from '@nextcloud/auth'
import { generateOcsUrl } from '@nextcloud/router'
import { confirmPassword } from '@nextcloud/password-confirmation'
-import '@nextcloud/password-confirmation/dist/style.css'
+import axios from '@nextcloud/axios'
-import { SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants.js'
+import { SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants.ts'
+
+import '@nextcloud/password-confirmation/dist/style.css'
/**
* Save the primary account property value for the user
diff --git a/apps/settings/src/service/ProfileService.js b/apps/settings/src/service/ProfileService.js
index 1df52983c90..3e6c7cd622f 100644
--- a/apps/settings/src/service/ProfileService.js
+++ b/apps/settings/src/service/ProfileService.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2021 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import axios from '@nextcloud/axios'
diff --git a/apps/settings/src/service/WebAuthnRegistrationSerice.js b/apps/settings/src/service/WebAuthnRegistrationSerice.js
deleted file mode 100644
index 185dbd8cf28..00000000000
--- a/apps/settings/src/service/WebAuthnRegistrationSerice.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import axios from '@nextcloud/axios'
-import { generateUrl } from '@nextcloud/router'
-
-/**
- *
- */
-export async function startRegistration() {
- const url = generateUrl('/settings/api/personal/webauthn/registration')
-
- const resp = await axios.get(url)
- return resp.data
-}
-
-/**
- * @param {any} name -
- * @param {any} data -
- */
-export async function finishRegistration(name, data) {
- const url = generateUrl('/settings/api/personal/webauthn/registration')
-
- const resp = await axios.post(url, { name, data })
- return resp.data
-}
-
-/**
- * @param {any} id -
- */
-export async function removeRegistration(id) {
- const url = generateUrl(`/settings/api/personal/webauthn/registration/${id}`)
-
- await axios.delete(url)
-}
diff --git a/apps/settings/src/service/WebAuthnRegistrationSerice.ts b/apps/settings/src/service/WebAuthnRegistrationSerice.ts
new file mode 100644
index 00000000000..0d1689ab90a
--- /dev/null
+++ b/apps/settings/src/service/WebAuthnRegistrationSerice.ts
@@ -0,0 +1,57 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { PublicKeyCredentialCreationOptionsJSON, RegistrationResponseJSON } from '@simplewebauthn/browser'
+
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl } from '@nextcloud/router'
+import { startRegistration as registerWebAuthn } from '@simplewebauthn/browser'
+
+import axios, { isAxiosError } from '@nextcloud/axios'
+import logger from '../logger'
+
+/**
+ * Start registering a new device
+ * @return The device attributes
+ */
+export async function startRegistration() {
+ const url = generateUrl('/settings/api/personal/webauthn/registration')
+
+ try {
+ logger.debug('Fetching webauthn registration data')
+ const { data } = await axios.get<PublicKeyCredentialCreationOptionsJSON>(url)
+ logger.debug('Start webauthn registration')
+ const attrs = await registerWebAuthn({ optionsJSON: data })
+ return attrs
+ } catch (e) {
+ logger.error(e as Error)
+ if (isAxiosError(e)) {
+ throw new Error(t('settings', 'Could not register device: Network error'))
+ } else if ((e as Error).name === 'InvalidStateError') {
+ throw new Error(t('settings', 'Could not register device: Probably already registered'))
+ }
+ throw new Error(t('settings', 'Could not register device'))
+ }
+}
+
+/**
+ * @param name Name of the device
+ * @param data Device attributes
+ */
+export async function finishRegistration(name: string, data: RegistrationResponseJSON) {
+ const url = generateUrl('/settings/api/personal/webauthn/registration')
+
+ const resp = await axios.post(url, { name, data: JSON.stringify(data) })
+ return resp.data
+}
+
+/**
+ * @param id Remove registered device with that id
+ */
+export async function removeRegistration(id: string | number) {
+ const url = generateUrl(`/settings/api/personal/webauthn/registration/${id}`)
+
+ await axios.delete(url)
+}
diff --git a/apps/settings/src/service/groups.ts b/apps/settings/src/service/groups.ts
new file mode 100644
index 00000000000..a8cfd842451
--- /dev/null
+++ b/apps/settings/src/service/groups.ts
@@ -0,0 +1,83 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IGroup } from '../views/user-types.d.ts'
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+import { CancelablePromise } from 'cancelable-promise'
+
+interface Group {
+ id: string
+ displayname: string
+ usercount: number
+ disabled: number
+ canAdd: boolean
+ canRemove: boolean
+}
+
+const formatGroup = (group: Group): Required<IGroup> => ({
+ id: group.id,
+ name: group.displayname,
+ usercount: group.usercount,
+ disabled: group.disabled,
+ canAdd: group.canAdd,
+ canRemove: group.canRemove,
+})
+
+/**
+ * Search groups
+ *
+ * @param {object} options Options
+ * @param {string} options.search Search query
+ * @param {number} options.offset Offset
+ * @param {number} options.limit Limit
+ */
+export const searchGroups = ({ search, offset, limit }): CancelablePromise<Required<IGroup>[]> => {
+ const controller = new AbortController()
+ return new CancelablePromise(async (resolve, reject, onCancel) => {
+ onCancel(() => controller.abort())
+ try {
+ const { data } = await axios.get(
+ generateOcsUrl('/cloud/groups/details?search={search}&offset={offset}&limit={limit}', { search, offset, limit }), {
+ signal: controller.signal,
+ },
+ )
+ const groups: Group[] = data.ocs?.data?.groups ?? []
+ const formattedGroups = groups.map(formatGroup)
+ resolve(formattedGroups)
+ } catch (error) {
+ reject(error)
+ }
+ })
+}
+
+/**
+ * Load user groups
+ *
+ * @param {object} options Options
+ * @param {string} options.userId User id
+ */
+export const loadUserGroups = async ({ userId }): Promise<Required<IGroup>[]> => {
+ const url = generateOcsUrl('/cloud/users/{userId}/groups/details', { userId })
+ const { data } = await axios.get(url)
+ const groups: Group[] = data.ocs?.data?.groups ?? []
+ const formattedGroups = groups.map(formatGroup)
+ return formattedGroups
+}
+
+/**
+ * Load user subadmin groups
+ *
+ * @param {object} options Options
+ * @param {string} options.userId User id
+ */
+export const loadUserSubAdminGroups = async ({ userId }): Promise<Required<IGroup>[]> => {
+ const url = generateOcsUrl('/cloud/users/{userId}/subadmins/details', { userId })
+ const { data } = await axios.get(url)
+ const groups: Group[] = data.ocs?.data?.groups ?? []
+ const formattedGroups = groups.map(formatGroup)
+ return formattedGroups
+}
diff --git a/apps/settings/src/service/rebuild-navigation.js b/apps/settings/src/service/rebuild-navigation.js
index b252234df83..56317f7f5e1 100644
--- a/apps/settings/src/service/rebuild-navigation.js
+++ b/apps/settings/src/service/rebuild-navigation.js
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import { emit } from '@nextcloud/event-bus'
diff --git a/apps/settings/src/store/admin-security.js b/apps/settings/src/store/admin-security.js
index 4bd1443273a..2cb5eb101ef 100644
--- a/apps/settings/src/store/admin-security.js
+++ b/apps/settings/src/store/admin-security.js
@@ -1,24 +1,6 @@
/**
- * @copyright 2019 Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import Vue from 'vue'
diff --git a/apps/settings/src/store/api.js b/apps/settings/src/store/api.js
index 23965b6d2e4..f36d44cc5c0 100644
--- a/apps/settings/src/store/api.js
+++ b/apps/settings/src/store/api.js
@@ -1,27 +1,6 @@
/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Sujith Haridasan <sujith.h@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import axios from '@nextcloud/axios'
diff --git a/apps/settings/src/store/app-api-store.ts b/apps/settings/src/store/app-api-store.ts
new file mode 100644
index 00000000000..769f212ebd7
--- /dev/null
+++ b/apps/settings/src/store/app-api-store.ts
@@ -0,0 +1,325 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import { showError, showInfo } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+
+import api from './api'
+import logger from '../logger'
+
+import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../app-types.ts'
+import Vue from 'vue'
+
+interface AppApiState {
+ apps: IAppstoreExApp[]
+ updateCount: number
+ loading: Record<string, boolean>
+ loadingList: boolean
+ statusUpdater: number | null | undefined
+ daemonAccessible: boolean
+ defaultDaemon: IDeployDaemon | null
+ dockerDaemons: IDeployDaemon[]
+}
+
+export const useAppApiStore = defineStore('app-api-apps', {
+ state: (): AppApiState => ({
+ apps: [],
+ updateCount: loadState('settings', 'appstoreExAppUpdateCount', 0),
+ loading: {},
+ loadingList: false,
+ statusUpdater: null,
+ daemonAccessible: loadState('settings', 'defaultDaemonConfigAccessible', false),
+ defaultDaemon: loadState('settings', 'defaultDaemonConfig', null),
+ dockerDaemons: [],
+ }),
+
+ getters: {
+ getLoading: (state) => (id: string) => state.loading[id] ?? false,
+ getAllApps: (state) => state.apps,
+ getUpdateCount: (state) => state.updateCount,
+ getDaemonAccessible: (state) => state.daemonAccessible,
+ getDefaultDaemon: (state) => state.defaultDaemon,
+ getAppStatus: (state) => (appId: string) =>
+ state.apps.find((app) => app.id === appId)?.status || null,
+ getStatusUpdater: (state) => state.statusUpdater,
+ getInitializingOrDeployingApps: (state) =>
+ state.apps.filter((app) =>
+ app?.status?.action
+ && (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck')
+ && app.status.type !== '',
+ ),
+ },
+
+ actions: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ appsApiFailure(error: any) {
+ showError(t('settings', 'An error occurred during the request. Unable to proceed.') + '<br>' + error.error.response.data.data.message, { isHTML: true })
+ logger.error(error)
+ },
+
+ setLoading(id: string, value: boolean) {
+ Vue.set(this.loading, id, value)
+ },
+
+ setError(appId: string | string[], error: string) {
+ const appIds = Array.isArray(appId) ? appId : [appId]
+ appIds.forEach((_id) => {
+ const app = this.apps.find((app) => app.id === _id)
+ if (app) {
+ app.error = error
+ }
+ })
+ },
+
+ enableApp(appId: string, daemon: IDeployDaemon, deployOptions: IDeployOptions) {
+ this.setLoading(appId, true)
+ this.setLoading('install', true)
+ return confirmPassword().then(() => {
+
+ return axios.post(generateUrl(`/apps/app_api/apps/enable/${appId}/${daemon.name}`), { deployOptions })
+ .then((response) => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+
+ const app = this.apps.find((app) => app.id === appId)
+ if (app) {
+ if (!app.installed) {
+ app.installed = true
+ app.needsDownload = false
+ app.daemon = daemon
+ app.status = {
+ type: 'install',
+ action: 'deploy',
+ init: 0,
+ deploy: 0,
+ } as IExAppStatus
+ }
+ app.active = true
+ app.canUnInstall = false
+ app.removable = true
+ app.error = ''
+ }
+
+ this.updateAppsStatus()
+
+ return axios.get(generateUrl('apps/files'))
+ .then(() => {
+ if (response.data.update_required) {
+ showInfo(
+ t('settings', 'The app has been enabled but needs to be updated.'),
+ {
+ onClick: () => window.location.reload(),
+ close: false,
+ },
+ )
+ setTimeout(() => {
+ location.reload()
+ }, 5000)
+ }
+ })
+ .catch(() => {
+ this.setError(appId, t('settings', 'Error: This app cannot be enabled because it makes the server unstable'))
+ })
+ })
+ .catch((error) => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+ this.setError(appId, error.response.data.data.message)
+ this.appsApiFailure({ appId, error })
+ })
+ }).catch(() => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+ })
+ },
+
+ forceEnableApp(appId: string) {
+ this.setLoading(appId, true)
+ this.setLoading('install', true)
+ return confirmPassword().then(() => {
+
+ return api.post(generateUrl('/apps/app_api/apps/force'), { appId })
+ .then(() => {
+ location.reload()
+ })
+ .catch((error) => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+ this.setError(appId, error.response.data.data.message)
+ this.appsApiFailure({ appId, error })
+ })
+ }).catch(() => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+ })
+ },
+
+ disableApp(appId: string) {
+ this.setLoading(appId, true)
+ return confirmPassword().then(() => {
+
+ return api.get(generateUrl(`apps/app_api/apps/disable/${appId}`))
+ .then(() => {
+ this.setLoading(appId, false)
+ const app = this.apps.find((app) => app.id === appId)
+ if (app) {
+ app.active = false
+ if (app.removable) {
+ app.canUnInstall = true
+ }
+ }
+ return true
+ })
+ .catch((error) => {
+ this.setLoading(appId, false)
+ this.appsApiFailure({ appId, error })
+ })
+ }).catch(() => {
+ this.setLoading(appId, false)
+ })
+ },
+
+ uninstallApp(appId: string, removeData: boolean) {
+ this.setLoading(appId, true)
+ return confirmPassword().then(() => {
+
+ return api.get(generateUrl(`/apps/app_api/apps/uninstall/${appId}?removeData=${removeData}`))
+ .then(() => {
+ this.setLoading(appId, false)
+ const app = this.apps.find((app) => app.id === appId)
+ if (app) {
+ app.active = false
+ app.needsDownload = true
+ app.installed = false
+ app.canUnInstall = false
+ app.canInstall = true
+ app.daemon = null
+ app.status = {}
+ if (app.update !== null) {
+ this.updateCount--
+ }
+ app.update = undefined
+ }
+ return true
+ })
+ .catch((error) => {
+ this.setLoading(appId, false)
+ this.appsApiFailure({ appId, error })
+ })
+ })
+ },
+
+ updateApp(appId: string) {
+ this.setLoading(appId, true)
+ this.setLoading('install', true)
+ return confirmPassword().then(() => {
+
+ return api.get(generateUrl(`/apps/app_api/apps/update/${appId}`))
+ .then(() => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+ const app = this.apps.find((app) => app.id === appId)
+ if (app) {
+ const version = app.update
+ app.update = undefined
+ app.version = version || app.version
+ app.status = {
+ type: 'update',
+ action: 'deploy',
+ init: 0,
+ deploy: 0,
+ } as IExAppStatus
+ app.error = ''
+ }
+ this.updateCount--
+ this.updateAppsStatus()
+ return true
+ })
+ .catch((error) => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+ this.appsApiFailure({ appId, error })
+ })
+ }).catch(() => {
+ this.setLoading(appId, false)
+ this.setLoading('install', false)
+ })
+ },
+
+ async fetchAllApps() {
+ this.loadingList = true
+ try {
+ const response = await api.get(generateUrl('/apps/app_api/apps/list'))
+ this.apps = response.data.apps
+ this.loadingList = false
+ return true
+ } catch (error) {
+ logger.error(error as string)
+ showError(t('settings', 'An error occurred during the request. Unable to proceed.'))
+ this.loadingList = false
+ }
+ },
+
+ async fetchAppStatus(appId: string) {
+ return api.get(generateUrl(`/apps/app_api/apps/status/${appId}`))
+ .then((response) => {
+ const app = this.apps.find((app) => app.id === appId)
+ if (app) {
+ app.status = response.data
+ }
+ const initializingOrDeployingApps = this.getInitializingOrDeployingApps
+ console.debug('initializingOrDeployingApps after setAppStatus', initializingOrDeployingApps)
+ if (initializingOrDeployingApps.length === 0) {
+ console.debug('clearing interval')
+ clearInterval(this.statusUpdater as number)
+ this.statusUpdater = null
+ }
+ if (Object.hasOwn(response.data, 'error')
+ && response.data.error !== ''
+ && initializingOrDeployingApps.length === 1) {
+ clearInterval(this.statusUpdater as number)
+ this.statusUpdater = null
+ }
+ })
+ .catch((error) => {
+ this.appsApiFailure({ appId, error })
+ this.apps = this.apps.filter((app) => app.id !== appId)
+ this.updateAppsStatus()
+ })
+ },
+
+ async fetchDockerDaemons() {
+ try {
+ const { data } = await axios.get(generateUrl('/apps/app_api/daemons'))
+ this.defaultDaemon = data.daemons.find((daemon: IDeployDaemon) => daemon.name === data.default_daemon_config)
+ this.dockerDaemons = data.daemons.filter((daemon: IDeployDaemon) => daemon.accepts_deploy_id === 'docker-install')
+ } catch (error) {
+ logger.error('[app-api-store] Failed to fetch Docker daemons', { error })
+ return false
+ }
+ return true
+ },
+
+ updateAppsStatus() {
+ clearInterval(this.statusUpdater as number)
+ const initializingOrDeployingApps = this.getInitializingOrDeployingApps
+ if (initializingOrDeployingApps.length === 0) {
+ return
+ }
+ this.statusUpdater = setInterval(() => {
+ const initializingOrDeployingApps = this.getInitializingOrDeployingApps
+ console.debug('initializingOrDeployingApps', initializingOrDeployingApps)
+ initializingOrDeployingApps.forEach(app => {
+ this.fetchAppStatus(app.id)
+ })
+ }, 2000) as unknown as number
+ },
+ },
+})
diff --git a/apps/settings/src/store/apps-store.ts b/apps/settings/src/store/apps-store.ts
new file mode 100644
index 00000000000..eaf8a0d7f86
--- /dev/null
+++ b/apps/settings/src/store/apps-store.ts
@@ -0,0 +1,87 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IAppstoreApp, IAppstoreCategory } from '../app-types.ts'
+
+import { showError } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+
+import axios from '@nextcloud/axios'
+
+import logger from '../logger'
+import APPSTORE_CATEGORY_ICONS from '../constants/AppstoreCategoryIcons.ts'
+
+const showApiError = () => showError(t('settings', 'An error occurred during the request. Unable to proceed.'))
+
+export const useAppsStore = defineStore('settings-apps', {
+ state: () => ({
+ apps: [] as IAppstoreApp[],
+ categories: [] as IAppstoreCategory[],
+ updateCount: loadState<number>('settings', 'appstoreUpdateCount', 0),
+ loading: {
+ apps: false,
+ categories: false,
+ },
+ loadingList: false,
+ gettingCategoriesPromise: null,
+ }),
+
+ actions: {
+ async loadCategories(force = false) {
+ if (this.categories.length > 0 && !force) {
+ return
+ }
+
+ try {
+ this.loading.categories = true
+ const { data: categories } = await axios.get<IAppstoreCategory[]>(generateUrl('settings/apps/categories'))
+
+ for (const category of categories) {
+ category.icon = APPSTORE_CATEGORY_ICONS[category.id] ?? ''
+ }
+
+ this.$patch({
+ categories,
+ })
+ } catch (error) {
+ logger.error(error as Error)
+ showApiError()
+ } finally {
+ this.loading.categories = false
+ }
+ },
+
+ async loadApps(force = false) {
+ if (this.apps.length > 0 && !force) {
+ return
+ }
+
+ try {
+ this.loading.apps = true
+ const { data } = await axios.get<{ apps: IAppstoreApp[] }>(generateUrl('settings/apps/list'))
+
+ this.$patch({
+ apps: data.apps,
+ })
+ } catch (error) {
+ logger.error(error as Error)
+ showApiError()
+ } finally {
+ this.loading.apps = false
+ }
+ },
+
+ getCategoryById(categoryId: string) {
+ return this.categories.find(({ id }) => id === categoryId) ?? null
+ },
+
+ getAppById(appId: string): IAppstoreApp|null {
+ return this.apps.find(({ id }) => id === appId) ?? null
+ },
+ },
+})
diff --git a/apps/settings/src/store/apps.js b/apps/settings/src/store/apps.js
index 86ac15f69a2..e0068d3892e 100644
--- a/apps/settings/src/store/apps.js
+++ b/apps/settings/src/store/apps.js
@@ -1,39 +1,23 @@
/**
- * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import api from './api.js'
import Vue from 'vue'
+import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { showError, showInfo } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
const state = {
apps: [],
+ bundles: loadState('settings', 'appstoreBundles', []),
categories: [],
- updateCount: 0,
+ updateCount: loadState('settings', 'appstoreUpdateCount', 0),
loading: {},
- loadingList: false,
gettingCategoriesPromise: null,
+ appApiEnabled: loadState('settings', 'appApiEnabled', false),
}
const mutations = {
@@ -88,6 +72,16 @@ const mutations = {
const app = state.apps.find(app => app.id === appId)
app.active = true
app.groups = groups
+ if (app.id === 'app_api') {
+ state.appApiEnabled = true
+ }
+ },
+
+ setInstallState(state, { appId, canInstall }) {
+ const app = state.apps.find(app => app.id === appId)
+ if (app) {
+ app.canInstall = canInstall === true
+ }
},
disableApp(state, appId) {
@@ -97,6 +91,9 @@ const mutations = {
if (app.removable) {
app.canUnInstall = true
}
+ if (app.id === 'app_api') {
+ state.appApiEnabled = false
+ }
},
uninstallApp(state, appId) {
@@ -106,6 +103,9 @@ const mutations = {
state.apps.find(app => app.id === appId).installed = false
state.apps.find(app => app.id === appId).canUnInstall = false
state.apps.find(app => app.id === appId).canInstall = true
+ if (appId === 'app_api') {
+ state.appApiEnabled = false
+ }
},
updateApp(state, appId) {
@@ -146,6 +146,9 @@ const mutations = {
}
const getters = {
+ isAppApiEnabled(state) {
+ return state.appApiEnabled
+ },
loading(state) {
return function(id) {
return state.loading[id]
@@ -157,6 +160,9 @@ const getters = {
getAllApps(state) {
return state.apps
},
+ getAppBundles(state) {
+ return state.bundles
+ },
getUpdateCount(state) {
return state.updateCount
},
@@ -186,19 +192,19 @@ const actions = {
})
// check for server health
- return api.get(generateUrl('apps/files'))
+ return axios.get(generateUrl('apps/files/'))
.then(() => {
if (response.data.update_required) {
showInfo(
t(
'settings',
- 'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.'
+ 'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.',
),
{
onClick: () => window.location.reload(),
close: false,
- }
+ },
)
setTimeout(function() {
location.reload()
@@ -207,10 +213,12 @@ const actions = {
})
.catch(() => {
if (!Array.isArray(appId)) {
+ showError(t('settings', 'Error: This app cannot be enabled because it makes the server unstable'))
context.commit('setError', {
appId: apps,
error: t('settings', 'Error: This app cannot be enabled because it makes the server unstable'),
})
+ context.dispatch('disableApp', { appId })
}
})
})
@@ -237,8 +245,7 @@ const actions = {
context.commit('startLoading', 'install')
return api.post(generateUrl('settings/apps/force'), { appId })
.then((response) => {
- // TODO: find a cleaner solution
- location.reload()
+ context.commit('setInstallState', { appId, canInstall: true })
})
.catch((error) => {
context.commit('stopLoading', apps)
@@ -249,6 +256,10 @@ const actions = {
})
context.commit('APPS_API_FAILURE', { appId, error })
})
+ .finally(() => {
+ context.commit('stopLoading', apps)
+ context.commit('stopLoading', 'install')
+ })
}).catch((error) => context.commit('API_FAILURE', { appId, error }))
},
disableApp(context, { appId }) {
diff --git a/apps/settings/src/store/authtoken.ts b/apps/settings/src/store/authtoken.ts
index 399c39faae7..daf5583ab8c 100644
--- a/apps/settings/src/store/authtoken.ts
+++ b/apps/settings/src/store/authtoken.ts
@@ -1,23 +1,6 @@
/**
- * @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @author Ferdinand Thiessen <opensource@fthiessen.de>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
@@ -29,6 +12,8 @@ import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import logger from '../logger'
+import '@nextcloud/password-confirmation/dist/style.css'
+
const BASE_URL = generateUrl('/settings/personal/authtokens')
const confirm = () => {
@@ -153,6 +138,7 @@ export const useAuthTokenStore = defineStore('auth-token', {
logger.debug('App token marked for wipe', { token })
token.type = TokenType.WIPING_TOKEN
+ token.canRename = false // wipe tokens can not be renamed
return true
} catch (error) {
logger.error('Could not wipe app token', { error })
diff --git a/apps/settings/src/store/index.js b/apps/settings/src/store/index.js
index abb1f374691..9ecda7e37ad 100644
--- a/apps/settings/src/store/index.js
+++ b/apps/settings/src/store/index.js
@@ -1,36 +1,15 @@
/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
-import Vue from 'vue'
-import Vuex, { Store } from 'vuex'
+import { Store } from 'vuex'
import users from './users.js'
import apps from './apps.js'
-import settings from './settings.js'
+import settings from './users-settings.js'
import oc from './oc.js'
import { showError } from '@nextcloud/dialogs'
-Vue.use(Vuex)
-
const debug = process.env.NODE_ENV !== 'production'
const mutations = {
@@ -45,14 +24,20 @@ const mutations = {
},
}
-export default new Store({
- modules: {
- users,
- apps,
- settings,
- oc,
- },
- strict: debug,
+let store = null
- mutations,
-})
+export const useStore = () => {
+ if (store === null) {
+ store = new Store({
+ modules: {
+ users,
+ apps,
+ settings,
+ oc,
+ },
+ strict: debug,
+ mutations,
+ })
+ }
+ return store
+}
diff --git a/apps/settings/src/store/oc.js b/apps/settings/src/store/oc.js
index 1b8d6c98cad..cb9b8782bce 100644
--- a/apps/settings/src/store/oc.js
+++ b/apps/settings/src/store/oc.js
@@ -1,24 +1,6 @@
/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import api from './api.js'
diff --git a/apps/settings/src/store/settings.js b/apps/settings/src/store/settings.js
deleted file mode 100644
index 28d40a08028..00000000000
--- a/apps/settings/src/store/settings.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-const state = {
- serverData: {},
-}
-const mutations = {
- setServerData(state, data) {
- state.serverData = data
- },
-}
-const getters = {
- getServerData(state) {
- return state.serverData
- },
-}
-const actions = {}
-
-export default { state, mutations, getters, actions }
diff --git a/apps/settings/src/store/users-settings.js b/apps/settings/src/store/users-settings.js
new file mode 100644
index 00000000000..6e52b8ec0f5
--- /dev/null
+++ b/apps/settings/src/store/users-settings.js
@@ -0,0 +1,23 @@
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { loadState } from '@nextcloud/initial-state'
+
+const state = {
+ serverData: loadState('settings', 'usersSettings', {}),
+}
+const mutations = {
+ setServerData(state, data) {
+ state.serverData = data
+ },
+}
+const getters = {
+ getServerData(state) {
+ return state.serverData
+ },
+}
+const actions = {}
+
+export default { state, mutations, getters, actions }
diff --git a/apps/settings/src/store/users.js b/apps/settings/src/store/users.js
index 499aa73170d..7e4b9c4aebb 100644
--- a/apps/settings/src/store/users.js
+++ b/apps/settings/src/store/users.js
@@ -1,52 +1,29 @@
/**
- * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Vincent Petry <vincent@nextcloud.com>
- * @author Stephan Orbaugh <stephan.orbaugh@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
-import api from './api.js'
-import axios from '@nextcloud/axios'
-import { generateOcsUrl } from '@nextcloud/router'
+import { getBuilder } from '@nextcloud/browser-storage'
import { getCapabilities } from '@nextcloud/capabilities'
-import logger from '../logger.js'
-import { parseFileSize } from "@nextcloud/files"
+import { parseFileSize } from '@nextcloud/files'
+import { showError } from '@nextcloud/dialogs'
+import { generateOcsUrl, generateUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import axios from '@nextcloud/axios'
-const orderGroups = function(groups, orderBy) {
- /* const SORT_USERCOUNT = 1;
- * const SORT_GROUPNAME = 2;
- * https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34
- */
- if (orderBy === 1) {
- return groups.sort((a, b) => a.usercount - a.disabled < b.usercount - b.disabled)
- } else {
- return groups.sort((a, b) => a.name.localeCompare(b.name))
- }
-}
+import { GroupSorting } from '../constants/GroupManagement.ts'
+import { naturalCollator } from '../utils/sorting.ts'
+import api from './api.js'
+import logger from '../logger.ts'
+
+const usersSettings = loadState('settings', 'usersSettings', {})
+
+const localStorage = getBuilder('settings').persist(true).build()
const defaults = {
+ /**
+ * @type {import('../views/user-types').IGroup}
+ */
group: {
id: '',
name: '',
@@ -59,20 +36,24 @@ const defaults = {
const state = {
users: [],
- groups: [],
- orderBy: 1,
+ groups: [
+ ...(usersSettings.getSubAdminGroups ?? []),
+ ...(usersSettings.systemGroups ?? []),
+ ],
+ orderBy: usersSettings.sortGroups ?? GroupSorting.UserCount,
minPasswordLength: 0,
usersOffset: 0,
usersLimit: 25,
disabledUsersOffset: 0,
disabledUsersLimit: 25,
- userCount: 0,
+ userCount: usersSettings.userCount ?? 0,
showConfig: {
- showStoragePath: false,
- showUserBackend: false,
- showLastLogin: false,
- showNewUserForm: false,
- showLanguages: false,
+ showStoragePath: localStorage.getItem('account_settings__showStoragePath') === 'true',
+ showUserBackend: localStorage.getItem('account_settings__showUserBackend') === 'true',
+ showFirstLogin: localStorage.getItem('account_settings__showFirstLogin') === 'true',
+ showLastLogin: localStorage.getItem('account_settings__showLastLogin') === 'true',
+ showNewUserForm: localStorage.getItem('account_settings__showNewUserForm') === 'true',
+ showLanguages: localStorage.getItem('account_settings__showLanguages') === 'true',
},
}
@@ -92,25 +73,18 @@ const mutations = {
setPasswordPolicyMinLength(state, length) {
state.minPasswordLength = length !== '' ? length : 0
},
- initGroups(state, { groups, orderBy, userCount }) {
- state.groups = groups.map(group => Object.assign({}, defaults.group, group))
- state.orderBy = orderBy
- state.userCount = userCount
- state.groups = orderGroups(state.groups, state.orderBy)
-
- },
- addGroup(state, { gid, displayName }) {
+ /**
+ * @param {object} state store state
+ * @param {import('../views/user-types.js').IGroup} newGroup new group
+ */
+ addGroup(state, newGroup) {
try {
- if (typeof state.groups.find((group) => group.id === gid) !== 'undefined') {
+ if (typeof state.groups.find((group) => group.id === newGroup.id) !== 'undefined') {
return
}
// extend group to default values
- const group = Object.assign({}, defaults.group, {
- id: gid,
- name: displayName,
- })
+ const group = Object.assign({}, defaults.group, newGroup)
state.groups.unshift(group)
- state.groups = orderGroups(state.groups, state.orderBy)
} catch (e) {
console.error('Can\'t create group', e)
}
@@ -121,7 +95,6 @@ const mutations = {
const updatedGroup = state.groups[groupIndex]
updatedGroup.name = displayName
state.groups.splice(groupIndex, 1, updatedGroup)
- state.groups = orderGroups(state.groups, state.orderBy)
}
},
removeGroup(state, gid) {
@@ -139,7 +112,6 @@ const mutations = {
}
const groups = user.groups
groups.push(gid)
- state.groups = orderGroups(state.groups, state.orderBy)
},
removeUserGroup(state, { userid, gid }) {
const group = state.groups.find(groupSearch => groupSearch.id === gid)
@@ -150,7 +122,6 @@ const mutations = {
}
const groups = user.groups
groups.splice(groups.indexOf(gid), 1)
- state.groups = orderGroups(state.groups, state.orderBy)
},
addUserSubAdmin(state, { userid, gid }) {
const groups = state.users.find(user => user.id === userid).subadmin
@@ -182,28 +153,37 @@ const mutations = {
return
}
+ const recentGroup = state.groups.find(group => group.id === '__nc_internal_recent')
const disabledGroup = state.groups.find(group => group.id === 'disabled')
switch (actionType) {
case 'enable':
case 'disable':
disabledGroup.usercount += user.enabled ? -1 : 1 // update Disabled Users count
+ recentGroup.usercount += user.enabled ? 1 : -1
state.userCount += user.enabled ? 1 : -1 // update Active Users count
user.groups.forEach(userGroup => {
const group = state.groups.find(groupSearch => groupSearch.id === userGroup)
+ if (!group) {
+ return
+ }
group.disabled += user.enabled ? -1 : 1 // update group disabled count
})
break
case 'create':
+ recentGroup.usercount++
state.userCount++ // increment Active Users count
user.groups.forEach(userGroup => {
- state.groups
- .find(groupSearch => groupSearch.id === userGroup)
- .usercount++ // increment group total count
+ const group = state.groups.find(groupSearch => groupSearch.id === userGroup)
+ if (!group) {
+ return
+ }
+ group.usercount++ // increment group total count
})
break
case 'remove':
if (user.enabled) {
+ recentGroup.usercount--
state.userCount-- // decrement Active Users count
user.groups.forEach(userGroup => {
const group = state.groups.find(groupSearch => groupSearch.id === userGroup)
@@ -217,6 +197,9 @@ const mutations = {
disabledGroup.usercount-- // decrement Disabled Users count
user.groups.forEach(userGroup => {
const group = state.groups.find(groupSearch => groupSearch.id === userGroup)
+ if (!group) {
+ return
+ }
group.disabled-- // decrement group disabled count
})
}
@@ -246,9 +229,39 @@ const mutations = {
state.disabledUsersOffset = 0
},
+ /**
+ * Reset group list
+ *
+ * @param {object} state the store state
+ */
+ resetGroups(state) {
+ state.groups = [
+ ...(usersSettings.getSubAdminGroups ?? []),
+ ...(usersSettings.systemGroups ?? []),
+ ]
+ },
+
setShowConfig(state, { key, value }) {
+ localStorage.setItem(`account_settings__${key}`, JSON.stringify(value))
state.showConfig[key] = value
},
+
+ setGroupSorting(state, sorting) {
+ const oldValue = state.orderBy
+ state.orderBy = sorting
+
+ // Persist the value on the server
+ axios.post(
+ generateUrl('/settings/users/preferences/group.sortBy'),
+ {
+ value: String(sorting),
+ },
+ ).catch((error) => {
+ state.orderBy = oldValue
+ showError(t('settings', 'Could not set group sorting'))
+ logger.error(error)
+ })
+ },
}
const getters = {
@@ -258,9 +271,24 @@ const getters = {
getGroups(state) {
return state.groups
},
- getSubadminGroups(state) {
- // Can't be subadmin of admin or disabled
- return state.groups.filter(group => group.id !== 'admin' && group.id !== 'disabled')
+ getSubAdminGroups() {
+ return usersSettings.subAdminGroups ?? []
+ },
+
+ getSortedGroups(state) {
+ const groups = [...state.groups]
+ if (state.orderBy === GroupSorting.UserCount) {
+ return groups.sort((a, b) => {
+ const numA = a.usercount - a.disabled
+ const numB = b.usercount - b.disabled
+ return (numA < numB) ? 1 : (numB < numA ? -1 : naturalCollator.compare(a.name, b.name))
+ })
+ } else {
+ return groups.sort((a, b) => naturalCollator.compare(a.name, b.name))
+ }
+ },
+ getGroupSorting(state) {
+ return state.orderBy
},
getPasswordPolicyMinLength(state) {
return state.minPasswordLength
@@ -387,16 +415,41 @@ const actions = {
},
/**
+ * Get recent users with full details
+ *
+ * @param {object} context store context
+ * @param {object} options destructuring object
+ * @param {number} options.offset List offset to request
+ * @param {number} options.limit List number to return from offset
+ * @param {string} options.search Search query
+ * @return {Promise<number>}
+ */
+ async getRecentUsers(context, { offset, limit, search }) {
+ const url = generateOcsUrl('cloud/users/recent?offset={offset}&limit={limit}&search={search}', { offset, limit, search })
+ try {
+ const response = await api.get(url)
+ const usersCount = Object.keys(response.data.ocs.data.users).length
+ if (usersCount > 0) {
+ context.commit('appendUsers', response.data.ocs.data.users)
+ }
+ return usersCount
+ } catch (error) {
+ context.commit('API_FAILURE', error)
+ }
+ },
+
+ /**
* Get disabled users with full details
*
* @param {object} context store context
* @param {object} options destructuring object
* @param {number} options.offset List offset to request
* @param {number} options.limit List number to return from offset
+ * @param options.search
* @return {Promise<number>}
*/
- async getDisabledUsers(context, { offset, limit }) {
- const url = generateOcsUrl('cloud/users/disabled?offset={offset}&limit={limit}', { offset, limit })
+ async getDisabledUsers(context, { offset, limit, search }) {
+ const url = generateOcsUrl('cloud/users/disabled?offset={offset}&limit={limit}&search={search}', { offset, limit, search })
try {
const response = await api.get(url)
const usersCount = Object.keys(response.data.ocs.data.users).length
@@ -417,7 +470,7 @@ const actions = {
.then((response) => {
if (Object.keys(response.data.ocs.data.groups).length > 0) {
response.data.ocs.data.groups.forEach(function(group) {
- context.commit('addGroup', { gid: group, displayName: group })
+ context.commit('addGroup', { id: group, name: group })
})
return true
}
@@ -484,7 +537,7 @@ const actions = {
return api.requireAdmin().then((response) => {
return api.post(generateOcsUrl('cloud/groups'), { groupid: gid })
.then((response) => {
- context.commit('addGroup', { gid, displayName: gid })
+ context.commit('addGroup', { id: gid, name: gid })
return { gid, displayName: gid }
})
.catch((error) => { throw error })
@@ -615,11 +668,14 @@ const actions = {
* @param {string} userid User id
* @return {Promise}
*/
- wipeUserDevices(context, userid) {
- return api.requireAdmin().then((response) => {
- return api.post(generateOcsUrl('cloud/users/{userid}/wipe', { userid }))
- .catch((error) => { throw error })
- }).catch((error) => context.commit('API_FAILURE', { userid, error }))
+ async wipeUserDevices(context, userid) {
+ try {
+ await api.requireAdmin()
+ return await api.post(generateOcsUrl('cloud/users/{userid}/wipe', { userid }))
+ } catch (error) {
+ context.commit('API_FAILURE', { userid, error })
+ return Promise.reject(new Error('Failed to wipe user devices'))
+ }
},
/**
@@ -709,24 +765,27 @@ const actions = {
* @param {string} options.value Value of the change
* @return {Promise}
*/
- setUserData(context, { userid, key, value }) {
+ async setUserData(context, { userid, key, value }) {
const allowedEmpty = ['email', 'displayname', 'manager']
- if (['email', 'language', 'quota', 'displayname', 'password', 'manager'].indexOf(key) !== -1) {
- // We allow empty email or displayname
- if (typeof value === 'string'
- && (
- (allowedEmpty.indexOf(key) === -1 && value.length > 0)
- || allowedEmpty.indexOf(key) !== -1
- )
- ) {
- return api.requireAdmin().then((response) => {
- return api.put(generateOcsUrl('cloud/users/{userid}', { userid }), { key, value })
- .then((response) => context.commit('setUserData', { userid, key, value }))
- .catch((error) => { throw error })
- }).catch((error) => context.commit('API_FAILURE', { userid, error }))
- }
+ const validKeys = ['email', 'language', 'quota', 'displayname', 'password', 'manager']
+
+ if (!validKeys.includes(key)) {
+ throw new Error('Invalid request data')
+ }
+
+ // If value is empty and the key doesn't allow empty values, throw error
+ if (value === '' && !allowedEmpty.includes(key)) {
+ throw new Error('Value cannot be empty for this field')
+ }
+
+ try {
+ await api.requireAdmin()
+ await api.put(generateOcsUrl('cloud/users/{userid}', { userid }), { key, value })
+ return context.commit('setUserData', { userid, key, value })
+ } catch (error) {
+ context.commit('API_FAILURE', { userid, error })
+ throw error
}
- return Promise.reject(new Error('Invalid request data'))
},
/**
diff --git a/apps/settings/src/utils/appDiscoverParser.spec.ts b/apps/settings/src/utils/appDiscoverParser.spec.ts
new file mode 100644
index 00000000000..2a631014679
--- /dev/null
+++ b/apps/settings/src/utils/appDiscoverParser.spec.ts
@@ -0,0 +1,79 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IAppDiscoverElement } from '../constants/AppDiscoverTypes'
+
+import { describe, expect, it } from 'vitest'
+import { filterElements, parseApiResponse } from './appDiscoverParser'
+
+describe('App Discover API parser', () => {
+ describe('filterElements', () => {
+ it('can filter expired elements', () => {
+ const result = filterElements({ id: 'test', type: 'post', expiryDate: 100 })
+ expect(result).toBe(false)
+ })
+
+ it('can filter upcoming elements', () => {
+ const result = filterElements({ id: 'test', type: 'post', date: Date.now() + 10000 })
+ expect(result).toBe(false)
+ })
+
+ it('ignores element without dates', () => {
+ const result = filterElements({ id: 'test', type: 'post' })
+ expect(result).toBe(true)
+ })
+
+ it('allows not yet expired elements', () => {
+ const result = filterElements({ id: 'test', type: 'post', expiryDate: Date.now() + 10000 })
+ expect(result).toBe(true)
+ })
+
+ it('allows yet included elements', () => {
+ const result = filterElements({ id: 'test', type: 'post', date: 100 })
+ expect(result).toBe(true)
+ })
+
+ it('allows elements included and not expired', () => {
+ const result = filterElements({ id: 'test', type: 'post', date: 100, expiryDate: Date.now() + 10000 })
+ expect(result).toBe(true)
+ })
+
+ it('can handle null values', () => {
+ const result = filterElements({ id: 'test', type: 'post', date: null, expiryDate: null } as unknown as IAppDiscoverElement)
+ expect(result).toBe(true)
+ })
+ })
+
+ describe('parseApiResponse', () => {
+ it('can handle basic post', () => {
+ const result = parseApiResponse({ id: 'test', type: 'post' })
+ expect(result).toEqual({ id: 'test', type: 'post' })
+ })
+
+ it('can handle carousel', () => {
+ const result = parseApiResponse({ id: 'test', type: 'carousel' })
+ expect(result).toEqual({ id: 'test', type: 'carousel' })
+ })
+
+ it('can handle showcase', () => {
+ const result = parseApiResponse({ id: 'test', type: 'showcase' })
+ expect(result).toEqual({ id: 'test', type: 'showcase' })
+ })
+
+ it('throws on unknown type', () => {
+ expect(() => parseApiResponse({ id: 'test', type: 'foo-bar' })).toThrow()
+ })
+
+ it('parses the date', () => {
+ const result = parseApiResponse({ id: 'test', type: 'showcase', date: '2024-03-19T17:28:19+0000' })
+ expect(result).toEqual({ id: 'test', type: 'showcase', date: 1710869299000 })
+ })
+
+ it('parses the expiryDate', () => {
+ const result = parseApiResponse({ id: 'test', type: 'showcase', expiryDate: '2024-03-19T17:28:19Z' })
+ expect(result).toEqual({ id: 'test', type: 'showcase', expiryDate: 1710869299000 })
+ })
+ })
+})
diff --git a/apps/settings/src/utils/appDiscoverParser.ts b/apps/settings/src/utils/appDiscoverParser.ts
new file mode 100644
index 00000000000..1be44f01068
--- /dev/null
+++ b/apps/settings/src/utils/appDiscoverParser.ts
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IAppDiscoverCarousel, IAppDiscoverElement, IAppDiscoverElements, IAppDiscoverPost, IAppDiscoverShowcase } from '../constants/AppDiscoverTypes.ts'
+
+/**
+ * Helper to transform the JSON API results to proper frontend objects (app discover section elements)
+ *
+ * @param element The JSON API element to transform
+ */
+export const parseApiResponse = (element: Record<string, unknown>): IAppDiscoverElements => {
+ const appElement = { ...element }
+ if (appElement.date) {
+ appElement.date = Date.parse(appElement.date as string)
+ }
+ if (appElement.expiryDate) {
+ appElement.expiryDate = Date.parse(appElement.expiryDate as string)
+ }
+
+ if (appElement.type === 'post') {
+ return appElement as unknown as IAppDiscoverPost
+ } else if (appElement.type === 'showcase') {
+ return appElement as unknown as IAppDiscoverShowcase
+ } else if (appElement.type === 'carousel') {
+ return appElement as unknown as IAppDiscoverCarousel
+ }
+ throw new Error(`Invalid argument, app discover element with type ${element.type ?? 'unknown'} is unknown`)
+}
+
+/**
+ * Filter outdated or upcoming elements
+ * @param element Element to check
+ */
+export const filterElements = (element: IAppDiscoverElement) => {
+ const now = Date.now()
+ // Element not yet published
+ if (element.date && element.date > now) {
+ return false
+ }
+
+ // Element expired
+ if (element.expiryDate && element.expiryDate < now) {
+ return false
+ }
+ return true
+}
diff --git a/apps/settings/src/utils/handlers.js b/apps/settings/src/utils/handlers.js
deleted file mode 100644
index 59f55d172f4..00000000000
--- a/apps/settings/src/utils/handlers.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import { showError } from '@nextcloud/dialogs'
-import { translate as t } from '@nextcloud/l10n'
-
-import logger from '../logger.js'
-
-/**
- * @param {import('axios').AxiosError} error the error
- * @param {string?} message the message to display
- */
-export const handleError = (error, message) => {
- let fullMessage = ''
-
- if (message) {
- fullMessage += message
- }
-
- if (error.response?.status === 429) {
- if (fullMessage) {
- fullMessage += '\n'
- }
- fullMessage += t('settings', 'There were too many requests from your network. Retry later or contact your administrator if this is an error.')
- }
-
- showError(fullMessage)
- logger.error(fullMessage || t('Error'), error)
-}
diff --git a/apps/settings/src/utils/handlers.ts b/apps/settings/src/utils/handlers.ts
new file mode 100644
index 00000000000..edd9a6c0cff
--- /dev/null
+++ b/apps/settings/src/utils/handlers.ts
@@ -0,0 +1,33 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { AxiosError } from '@nextcloud/axios'
+import { showError } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+
+import logger from '../logger.ts'
+
+/**
+ * @param error the error
+ * @param message the message to display
+ */
+export function handleError(error: AxiosError, message: string) {
+ let fullMessage = ''
+
+ if (message) {
+ fullMessage += message
+ }
+
+ if (error.response?.status === 429) {
+ if (fullMessage) {
+ fullMessage += '\n'
+ }
+ fullMessage += t('settings', 'There were too many requests from your network. Retry later or contact your administrator if this is an error.')
+ }
+
+ fullMessage = fullMessage || t('settings', 'Error')
+ showError(fullMessage)
+ logger.error(fullMessage, { error })
+}
diff --git a/apps/settings/src/utils/sorting.ts b/apps/settings/src/utils/sorting.ts
new file mode 100644
index 00000000000..88f877733cc
--- /dev/null
+++ b/apps/settings/src/utils/sorting.ts
@@ -0,0 +1,14 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCanonicalLocale, getLanguage } from '@nextcloud/l10n'
+
+export const naturalCollator = Intl.Collator(
+ [getLanguage(), getCanonicalLocale()],
+ {
+ numeric: true,
+ usage: 'sort',
+ },
+)
diff --git a/apps/settings/src/utils/userUtils.ts b/apps/settings/src/utils/userUtils.ts
index b6c96624139..7d9a516a542 100644
--- a/apps/settings/src/utils/userUtils.ts
+++ b/apps/settings/src/utils/userUtils.ts
@@ -1,25 +1,10 @@
/**
- * @copyright 2023 Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { translate as t } from '@nextcloud/l10n'
+
export const unlimitedQuota = {
id: 'none',
label: t('settings', 'Unlimited'),
@@ -33,10 +18,10 @@ export const defaultQuota = {
/**
* Return `true` if the logged in user does not have permissions to view the
* data of `user`
- * @param user
- * @param user.id
+ * @param user The user to check
+ * @param user.id Id of the user
*/
-export const isObfuscated = (user: { id: string, [key: string]: any }) => {
+export const isObfuscated = (user: { id: string, [key: string]: unknown }) => {
const keys = Object.keys(user)
return keys.length === 1 && keys.at(0) === 'id'
}
diff --git a/apps/settings/src/utils/validate.js b/apps/settings/src/utils/validate.js
index 4ea95593fbc..0f76f4e6dc5 100644
--- a/apps/settings/src/utils/validate.js
+++ b/apps/settings/src/utils/validate.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2021, Christopher Ng <chrng8@gmail.com>
- *
- * @author Christopher Ng <chrng8@gmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
/*
@@ -26,7 +9,7 @@
* TODO add nice validation errors for Profile page settings modal
*/
-import { VALIDATE_EMAIL_REGEX } from '../constants/AccountPropertyConstants.js'
+import { VALIDATE_EMAIL_REGEX } from '../constants/AccountPropertyConstants.ts'
/**
* Validate the email input
diff --git a/apps/settings/src/views/AdminSettingsSharing.vue b/apps/settings/src/views/AdminSettingsSharing.vue
index c0846969a11..d26fba6c8fa 100644
--- a/apps/settings/src/views/AdminSettingsSharing.vue
+++ b/apps/settings/src/views/AdminSettingsSharing.vue
@@ -1,23 +1,6 @@
<!--
- - @copyright 2023 Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @author Ferdinand Thiessen <opensource@fthiessen.de>
- -
- - @license AGPL-3.0-or-later
- -
- - 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: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcSettingsSection data-cy-settings-sharing-section
@@ -33,13 +16,12 @@
</template>
<script lang="ts">
-import {
- NcNoteCard,
- NcSettingsSection,
-} from '@nextcloud/vue'
import { loadState } from '@nextcloud/initial-state'
-import { translate as t } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
+
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import AdminSettingsSharingForm from '../components/AdminSettingsSharingForm.vue'
export default defineComponent({
diff --git a/apps/settings/src/views/AppStore.vue b/apps/settings/src/views/AppStore.vue
new file mode 100644
index 00000000000..82c8c31e75d
--- /dev/null
+++ b/apps/settings/src/views/AppStore.vue
@@ -0,0 +1,88 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <!-- Apps list -->
+ <NcAppContent class="app-settings-content"
+ :page-heading="appStoreLabel">
+ <h2 class="app-settings-content__label" v-text="viewLabel" />
+
+ <AppStoreDiscoverSection v-if="currentCategory === 'discover'" />
+ <NcEmptyContent v-else-if="isLoading"
+ class="empty-content__loading"
+ :name="t('settings', 'Loading app list')">
+ <template #icon>
+ <NcLoadingIcon :size="64" />
+ </template>
+ </NcEmptyContent>
+ <AppList v-else :category="currentCategory" />
+ </NcAppContent>
+</template>
+
+<script setup lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { computed, getCurrentInstance, onBeforeMount, onBeforeUnmount, watchEffect } from 'vue'
+import { useRoute } from 'vue-router/composables'
+
+import { useAppsStore } from '../store/apps-store'
+import { APPS_SECTION_ENUM } from '../constants/AppsConstants'
+
+import NcAppContent from '@nextcloud/vue/components/NcAppContent'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import AppList from '../components/AppList.vue'
+import AppStoreDiscoverSection from '../components/AppStoreDiscover/AppStoreDiscoverSection.vue'
+import { useAppApiStore } from '../store/app-api-store.ts'
+
+const route = useRoute()
+const store = useAppsStore()
+const appApiStore = useAppApiStore()
+
+/**
+ * ID of the current active category, default is `discover`
+ */
+const currentCategory = computed(() => route.params?.category ?? 'discover')
+
+const appStoreLabel = t('settings', 'App Store')
+const viewLabel = computed(() => APPS_SECTION_ENUM[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName ?? appStoreLabel)
+
+watchEffect(() => {
+ window.document.title = `${viewLabel.value} - ${appStoreLabel} - Nextcloud`
+})
+
+// TODO this part should be migrated to pinia
+const instance = getCurrentInstance()
+/** Is the app list loading */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const isLoading = computed(() => (instance?.proxy as any).$store.getters.loading('list'))
+onBeforeMount(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (instance?.proxy as any).$store.dispatch('getCategories', { shouldRefetchCategories: true });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (instance?.proxy as any).$store.dispatch('getAllApps')
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if ((instance?.proxy as any).$store.getters.isAppApiEnabled) {
+ appApiStore.fetchAllApps()
+ appApiStore.updateAppsStatus()
+ }
+})
+onBeforeUnmount(() => {
+ clearInterval(appApiStore.getStatusUpdater)
+})
+</script>
+
+<style scoped>
+.empty-content__loading {
+ height: 100%;
+}
+
+.app-settings-content__label {
+ margin-block-start: var(--app-navigation-padding);
+ margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
+ min-height: var(--default-clickable-area);
+ line-height: var(--default-clickable-area);
+ vertical-align: center;
+}
+</style>
diff --git a/apps/settings/src/views/AppStoreNavigation.vue b/apps/settings/src/views/AppStoreNavigation.vue
new file mode 100644
index 00000000000..83191baac40
--- /dev/null
+++ b/apps/settings/src/views/AppStoreNavigation.vue
@@ -0,0 +1,146 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <!-- Categories & filters -->
+ <NcAppNavigation :aria-label="t('settings', 'Apps')">
+ <template #list>
+ <NcAppNavigationItem v-if="appstoreEnabled"
+ id="app-category-discover"
+ :to="{ name: 'apps-category', params: { category: 'discover'} }"
+ :name="APPS_SECTION_ENUM.discover">
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.discover" />
+ </template>
+ </NcAppNavigationItem>
+ <NcAppNavigationItem id="app-category-installed"
+ :to="{ name: 'apps-category', params: { category: 'installed'} }"
+ :name="APPS_SECTION_ENUM.installed">
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.installed" />
+ </template>
+ </NcAppNavigationItem>
+ <NcAppNavigationItem id="app-category-enabled"
+ :to="{ name: 'apps-category', params: { category: 'enabled' } }"
+ :name="APPS_SECTION_ENUM.enabled">
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.enabled" />
+ </template>
+ </NcAppNavigationItem>
+ <NcAppNavigationItem id="app-category-disabled"
+ :to="{ name: 'apps-category', params: { category: 'disabled' } }"
+ :name="APPS_SECTION_ENUM.disabled">
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.disabled" />
+ </template>
+ </NcAppNavigationItem>
+ <NcAppNavigationItem v-if="store.updateCount > 0"
+ id="app-category-updates"
+ :to="{ name: 'apps-category', params: { category: 'updates' } }"
+ :name="APPS_SECTION_ENUM.updates">
+ <template #counter>
+ <NcCounterBubble>{{ store.updateCount }}</NcCounterBubble>
+ </template>
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.updates" />
+ </template>
+ </NcAppNavigationItem>
+ <NcAppNavigationItem id="app-category-your-bundles"
+ :to="{ name: 'apps-category', params: { category: 'app-bundles' } }"
+ :name="APPS_SECTION_ENUM['app-bundles']">
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.bundles" />
+ </template>
+ </NcAppNavigationItem>
+
+ <NcAppNavigationSpacer />
+
+ <!-- App store categories -->
+ <li v-if="appstoreEnabled && categoriesLoading" class="categories--loading">
+ <NcLoadingIcon :size="20" :aria-label="t('settings', 'Loading categories')" />
+ </li>
+ <template v-else-if="appstoreEnabled && !categoriesLoading">
+ <NcAppNavigationItem v-if="isSubscribed"
+ id="app-category-supported"
+ :to="{ name: 'apps-category', params: { category: 'supported' } }"
+ :name="APPS_SECTION_ENUM.supported">
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.supported" />
+ </template>
+ </NcAppNavigationItem>
+ <NcAppNavigationItem id="app-category-featured"
+ :to="{ name: 'apps-category', params: { category: 'featured' } }"
+ :name="APPS_SECTION_ENUM.featured">
+ <template #icon>
+ <NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.featured" />
+ </template>
+ </NcAppNavigationItem>
+
+ <NcAppNavigationItem v-for="category in categories"
+ :id="`app-category-${category.id}`"
+ :key="category.id"
+ :name="category.displayName"
+ :to="{
+ name: 'apps-category',
+ params: { category: category.id },
+ }">
+ <template #icon>
+ <NcIconSvgWrapper :path="category.icon" />
+ </template>
+ </NcAppNavigationItem>
+ </template>
+
+ <NcAppNavigationItem id="app-developer-docs"
+ :name="t('settings', 'Developer documentation ↗')"
+ :href="developerDocsUrl" />
+ </template>
+ </NcAppNavigation>
+</template>
+
+<script setup lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { computed, onBeforeMount } from 'vue'
+import { APPS_SECTION_ENUM } from '../constants/AppsConstants'
+import { useAppsStore } from '../store/apps-store'
+
+import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
+import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+import APPSTORE_CATEGORY_ICONS from '../constants/AppstoreCategoryIcons.ts'
+
+const appstoreEnabled = loadState<boolean>('settings', 'appstoreEnabled', true)
+const developerDocsUrl = loadState<string>('settings', 'appstoreDeveloperDocs', '')
+
+const store = useAppsStore()
+const categories = computed(() => store.categories)
+const categoriesLoading = computed(() => store.loading.categories)
+
+/**
+ * Check if the current instance has a support subscription from the Nextcloud GmbH
+ *
+ * For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
+ */
+const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300) !== undefined)
+
+// load categories when component is mounted
+onBeforeMount(() => {
+ store.loadCategories()
+ store.loadApps()
+})
+</script>
+
+<style scoped>
+/* The categories-loading indicator */
+.categories--loading {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+</style>
diff --git a/apps/settings/src/views/AppStoreSidebar.vue b/apps/settings/src/views/AppStoreSidebar.vue
new file mode 100644
index 00000000000..b4041066c67
--- /dev/null
+++ b/apps/settings/src/views/AppStoreSidebar.vue
@@ -0,0 +1,159 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <!-- Selected app details -->
+ <NcAppSidebar v-if="showSidebar"
+ class="app-sidebar"
+ :class="{ 'app-sidebar--with-screenshot': hasScreenshot }"
+ :active.sync="activeTab"
+ :background="hasScreenshot ? app.screenshot : undefined"
+ :compact="!hasScreenshot"
+ :name="app.name"
+ :title="app.name"
+ :subname="licenseText"
+ :subtitle="licenseText"
+ @close="hideAppDetails">
+ <!-- Fallback icon incase no app icon is available -->
+ <template v-if="!hasScreenshot" #header>
+ <NcIconSvgWrapper class="app-sidebar__fallback-icon"
+ :svg="appIcon ?? ''"
+ :size="64" />
+ </template>
+
+ <template #description>
+ <!-- Featured/Supported badges -->
+ <div class="app-sidebar__badges">
+ <AppLevelBadge :level="app.level" />
+ <AppDaemonBadge v-if="app.app_api && app.daemon" :daemon="app.daemon" />
+ <AppScore v-if="hasRating" :score="rating" />
+ </div>
+ </template>
+
+ <!-- Tab content -->
+ <AppDescriptionTab :app="app" />
+ <AppDetailsTab :app="app" />
+ <AppReleasesTab :app="app" />
+ <AppDeployDaemonTab :app="app" />
+ </NcAppSidebar>
+</template>
+
+<script setup lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { computed, onMounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router/composables'
+import { useAppsStore } from '../store/apps-store'
+
+import NcAppSidebar from '@nextcloud/vue/components/NcAppSidebar'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import AppScore from '../components/AppList/AppScore.vue'
+import AppDescriptionTab from '../components/AppStoreSidebar/AppDescriptionTab.vue'
+import AppDetailsTab from '../components/AppStoreSidebar/AppDetailsTab.vue'
+import AppReleasesTab from '../components/AppStoreSidebar/AppReleasesTab.vue'
+import AppDeployDaemonTab from '../components/AppStoreSidebar/AppDeployDaemonTab.vue'
+import AppLevelBadge from '../components/AppList/AppLevelBadge.vue'
+import AppDaemonBadge from '../components/AppList/AppDaemonBadge.vue'
+import { useAppIcon } from '../composables/useAppIcon.ts'
+import { useStore } from '../store'
+import { useAppApiStore } from '../store/app-api-store.ts'
+
+const route = useRoute()
+const router = useRouter()
+const store = useAppsStore()
+const appApiStore = useAppApiStore()
+const legacyStore = useStore()
+
+const appId = computed(() => route.params.id ?? '')
+const app = computed(() => {
+ if (legacyStore.getters.isAppApiEnabled) {
+ const exApp = appApiStore.getAllApps
+ .find((app) => app.id === appId.value) ?? null
+ if (exApp) {
+ return exApp
+ }
+ }
+ return store.getAppById(appId.value)!
+})
+const hasRating = computed(() => app.value.appstoreData?.ratingNumOverall > 5)
+const rating = computed(() => app.value.appstoreData?.ratingNumRecent > 5
+ ? app.value.appstoreData.ratingRecent
+ : (app.value.appstoreData?.ratingOverall ?? 0.5))
+const showSidebar = computed(() => app.value !== null)
+
+const { appIcon } = useAppIcon(app)
+
+/**
+ * The second text line shown on the sidebar
+ */
+const licenseText = computed(() => {
+ if (!app.value) {
+ return ''
+ }
+ if (app.value.license !== '') {
+ return t('settings', 'Version {version}, {license}-licensed', { version: app.value.version, license: app.value.licence.toString().toUpperCase() })
+ }
+ return t('settings', 'Version {version}', { version: app.value.version })
+})
+
+const activeTab = ref('details')
+watch([app], () => { activeTab.value = 'details' })
+
+/**
+ * Hide the details sidebar by pushing a new route
+ */
+const hideAppDetails = () => {
+ router.push({
+ name: 'apps-category',
+ params: { category: route.params.category },
+ })
+}
+
+/**
+ * Whether the app screenshot is loaded
+ */
+const screenshotLoaded = ref(false)
+const hasScreenshot = computed(() => app.value?.screenshot && screenshotLoaded.value)
+/**
+ * Preload the app screenshot
+ */
+const loadScreenshot = () => {
+ if (app.value?.releases && app.value?.screenshot) {
+ const image = new Image()
+ image.onload = () => {
+ screenshotLoaded.value = true
+ }
+ image.src = app.value.screenshot
+ }
+}
+// Watch app and set screenshot loaded when
+watch([app], loadScreenshot)
+onMounted(loadScreenshot)
+</script>
+
+<style scoped lang="scss">
+.app-sidebar {
+ // If a screenshot is available it should cover the whole figure
+ &--with-screenshot {
+ :deep(.app-sidebar-header__figure) {
+ background-size: cover;
+ }
+ }
+
+ &__fallback-icon {
+ // both 100% to center the icon
+ width: 100%;
+ height: 100%;
+ }
+
+ &__badges {
+ display: flex;
+ flex-direction: row;
+ gap: 12px;
+ }
+
+ &__version {
+ color: var(--color-text-maxcontrast);
+ }
+}
+</style>
diff --git a/apps/settings/src/views/Apps.vue b/apps/settings/src/views/Apps.vue
deleted file mode 100644
index b5cfad5632f..00000000000
--- a/apps/settings/src/views/Apps.vue
+++ /dev/null
@@ -1,414 +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>
- <NcContent app-name="settings"
- :class="{ 'with-app-sidebar': app}">
- <!-- Categories & filters -->
- <NcAppNavigation :class="{ 'icon-loading': loading }"
- :aria-label="t('settings', 'Apps')">
- <template #list>
- <NcAppNavigationItem id="app-category-your-apps"
- :to="{ name: 'apps' }"
- :exact="true"
- icon="icon-category-installed"
- :name="$options.APPS_SECTION_ENUM.installed" />
- <NcAppNavigationItem id="app-category-enabled"
- :to="{ name: 'apps-category', params: { category: 'enabled' } }"
- icon="icon-category-enabled"
- :name="$options.APPS_SECTION_ENUM.enabled" />
- <NcAppNavigationItem id="app-category-disabled"
- :to="{ name: 'apps-category', params: { category: 'disabled' } }"
- icon="icon-category-disabled"
- :name="$options.APPS_SECTION_ENUM.disabled" />
- <NcAppNavigationItem v-if="updateCount > 0"
- id="app-category-updates"
- :to="{ name: 'apps-category', params: { category: 'updates' } }"
- icon="icon-download"
- :name="$options.APPS_SECTION_ENUM.updates">
- <template #counter>
- <NcCounterBubble>{{ updateCount }}</NcCounterBubble>
- </template>
- </NcAppNavigationItem>
- <NcAppNavigationItem v-if="isSubscribed"
- id="app-category-supported"
- :to="{ name: 'apps-category', params: { category: 'supported' } }"
- :name="$options.APPS_SECTION_ENUM.supported">
- <template #icon>
- <IconStarShooting :size="20" />
- </template>
- </NcAppNavigationItem>
- <NcAppNavigationItem id="app-category-your-bundles"
- :to="{ name: 'apps-category', params: { category: 'app-bundles' } }"
- icon="icon-category-app-bundles"
- :name="$options.APPS_SECTION_ENUM['app-bundles']" />
-
- <NcAppNavigationSpacer />
-
- <!-- App store categories -->
- <template v-if="settings.appstoreEnabled">
- <NcAppNavigationItem id="app-category-featured"
- :to="{ name: 'apps-category', params: { category: 'featured' } }"
- icon="icon-favorite"
- :name="$options.APPS_SECTION_ENUM.featured" />
-
- <NcAppNavigationItem v-for="cat in categories"
- :key="'icon-category-' + cat.ident"
- :icon="'icon-category-' + cat.ident"
- :to="{
- name: 'apps-category',
- params: { category: cat.ident },
- }"
- :name="cat.displayName" />
- </template>
-
- <NcAppNavigationItem id="app-developer-docs"
- :name="t('settings', 'Developer documentation') + ' ↗'"
- @click="openDeveloperDocumentation" />
- </template>
- </NcAppNavigation>
-
- <!-- Apps list -->
- <NcAppContent class="app-settings-content"
- :class="{ 'icon-loading': loadingList }"
- :page-heading="pageHeading">
- <AppList :category="category" :app="app" :search="searchQuery" />
- </NcAppContent>
-
- <!-- Selected app details -->
- <NcAppSidebar v-if="id && app"
- v-bind="appSidebar"
- :class="{'app-sidebar--without-background': !appSidebar.background}"
- @close="hideAppDetails">
- <template v-if="!appSidebar.background" #header>
- <div class="app-sidebar-header__figure--default-app-icon icon-settings-dark" />
- </template>
-
- <template #description>
- <!-- Featured/Supported badges -->
- <div v-if="app.level === 300 || app.level === 200 || hasRating" class="app-level">
- <span v-if="app.level === 300"
- :title="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"
- :title="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" :score="app.appstoreData.ratingOverall" />
- </div>
- <div class="app-version">
- <p>{{ app.version }}</p>
- </div>
- </template>
-
- <!-- Tab content -->
-
- <NcAppSidebarTab id="desc"
- icon="icon-category-office"
- :name="t('settings', 'Details')"
- :order="0">
- <AppDetails :app="app" />
- </NcAppSidebarTab>
- <NcAppSidebarTab v-if="app.appstoreData && app.releases[0].translations.en.changelog"
- id="desca"
- icon="icon-category-organization"
- :name="t('settings', 'Changelog')"
- :order="1">
- <div v-for="release in app.releases" :key="release.version" class="app-sidebar-tabs__release">
- <h2>{{ release.version }}</h2>
- <Markdown v-if="changelog(release)" :text="changelog(release)" />
- </div>
- </NcAppSidebarTab>
- </NcAppSidebar>
- </NcContent>
-</template>
-
-<script>
-import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import Vue from 'vue'
-import VueLocalStorage from 'vue-localstorage'
-
-import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
-import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcAppNavigationSpacer from '@nextcloud/vue/dist/Components/NcAppNavigationSpacer.js'
-import NcAppSidebar from '@nextcloud/vue/dist/Components/NcAppSidebar.js'
-import NcAppSidebarTab from '@nextcloud/vue/dist/Components/NcAppSidebarTab.js'
-import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js'
-import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
-import IconStarShooting from 'vue-material-design-icons/StarShooting.vue'
-
-import AppList from '../components/AppList.vue'
-import AppDetails from '../components/AppDetails.vue'
-import AppManagement from '../mixins/AppManagement.js'
-import AppScore from '../components/AppList/AppScore.vue'
-import Markdown from '../components/Markdown.vue'
-
-import { APPS_SECTION_ENUM } from './../constants/AppsConstants.js'
-
-Vue.use(VueLocalStorage)
-
-export default {
- name: 'Apps',
- APPS_SECTION_ENUM,
- components: {
- NcAppContent,
- AppDetails,
- AppList,
- IconStarShooting,
- NcAppNavigation,
- NcAppNavigationItem,
- NcAppNavigationSpacer,
- NcCounterBubble,
- AppScore,
- NcAppSidebar,
- NcAppSidebarTab,
- NcContent,
- Markdown,
- },
-
- mixins: [AppManagement],
-
- props: {
- category: {
- type: String,
- default: 'installed',
- },
- id: {
- type: String,
- default: '',
- },
- },
-
- data() {
- return {
- searchQuery: '',
- screenshotLoaded: false,
- }
- },
-
- computed: {
- pageHeading() {
- if (this.$options.APPS_SECTION_ENUM[this.category]) {
- return this.$options.APPS_SECTION_ENUM[this.category]
- }
- const category = this.$store.getters.getCategoryById(this.category)
- return category.displayName
- },
- loading() {
- return this.$store.getters.loading('categories')
- },
- loadingList() {
- return this.$store.getters.loading('list')
- },
- app() {
- return this.apps.find(app => app.id === this.id)
- },
- categories() {
- return this.$store.getters.getCategories
- },
- apps() {
- return this.$store.getters.getAllApps
- },
- updateCount() {
- return this.$store.getters.getUpdateCount
- },
- settings() {
- return this.$store.getters.getServerData
- },
-
- hasRating() {
- return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5
- },
-
- // sidebar app binding
- appSidebar() {
- const authorName = (xmlNode) => {
- if (xmlNode['@value']) {
- // Complex node (with email or homepage attribute)
- return xmlNode['@value']
- }
-
- // Simple text node
- return xmlNode
- }
-
- const author = Array.isArray(this.app.author)
- ? this.app.author.map(authorName).join(', ')
- : authorName(this.app.author)
- const license = t('settings', '{license}-licensed', { license: ('' + this.app.licence).toUpperCase() })
-
- const subname = t('settings', 'by {author}\n{license}', { author, license })
-
- return {
- background: this.app.screenshot && this.screenshotLoaded
- ? this.app.screenshot
- : this.app.preview,
- compact: !(this.app.screenshot && this.screenshotLoaded),
- name: this.app.name,
- subname,
- }
- },
- changelog() {
- return (release) => release.translations.en.changelog
- },
- /**
- * Check if the current instance has a support subscription from the Nextcloud GmbH
- */
- isSubscribed() {
- // For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
- return this.apps.some(app => app.level === 300)
- },
- },
-
- watch: {
- category() {
- this.searchQuery = ''
- },
-
- app() {
- this.screenshotLoaded = false
- if (this.app?.releases && this.app?.screenshot) {
- const image = new Image()
- image.onload = (e) => {
- this.screenshotLoaded = true
- }
- image.src = this.app.screenshot
- }
- },
- },
-
- beforeMount() {
- this.$store.dispatch('getCategories', { shouldRefetchCategories: true })
- this.$store.dispatch('getAllApps')
- this.$store.dispatch('getGroups', { offset: 0, limit: 5 })
- this.$store.commit('setUpdateCount', this.$store.getters.getServerData.updateCount)
- },
-
- mounted() {
- subscribe('nextcloud:unified-search.search', this.setSearch)
- subscribe('nextcloud:unified-search.reset', this.resetSearch)
- },
- beforeDestroy() {
- unsubscribe('nextcloud:unified-search.search', this.setSearch)
- unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
- },
-
- methods: {
- setSearch({ query }) {
- this.searchQuery = query
- },
- resetSearch() {
- this.searchQuery = ''
- },
-
- hideAppDetails() {
- this.$router.push({
- name: 'apps-category',
- params: { category: this.category },
- })
- },
- openDeveloperDocumentation() {
- window.open(this.settings.developerDocumentation)
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.app-sidebar::v-deep {
- &:not(.app-sidebar--without-background) {
- // with full screenshot, let's fill the figure
- :not(.app-sidebar-header--compact) .app-sidebar-header__figure {
- background-size: cover;
- }
- // revert sidebar app icon so it is black
- .app-sidebar-header--compact .app-sidebar-header__figure {
- background-size: 32px;
-
- filter: var(--background-invert-if-bright);
- }
- }
-
- .app-sidebar-header__description {
- .app-version {
- padding-left: 10px;
- }
- }
-
- // default icon slot styling
- &.app-sidebar--without-background {
- .app-sidebar-header__figure {
- display: flex;
- align-items: center;
- justify-content: center;
- &--default-app-icon {
- width: 32px;
- height: 32px;
- background-size: 32px;
- }
- }
- }
-
- // TODO: migrate to components
- .app-sidebar-header__desc {
- // allow multi line subtitle for the license
- .app-sidebar-header__subtitle {
- overflow: visible !important;
- height: auto;
- white-space: normal !important;
- line-height: 16px;
- }
- }
-
- .app-sidebar-header__action {
- // align with tab content
- margin: 0 20px;
- input {
- margin: 3px;
- }
- }
-}
-
-// Align the appNavigation toggle with the apps header toolbar
-.app-navigation::v-deep button.app-navigation-toggle {
- top: 8px;
- right: -8px;
-}
-
-.app-sidebar-tabs__release {
- h2 {
- border-bottom: 1px solid var(--color-border);
- }
-
- // Overwrite changelog heading styles
- ::v-deep {
- h3 {
- font-size: 20px;
- }
- h4 {
- font-size: 17px;
- }
- }
-}
-</style>
diff --git a/apps/settings/src/views/SettingsApp.vue b/apps/settings/src/views/SettingsApp.vue
new file mode 100644
index 00000000000..7e135175ef6
--- /dev/null
+++ b/apps/settings/src/views/SettingsApp.vue
@@ -0,0 +1,16 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcContent app-name="settings">
+ <router-view name="navigation" />
+ <router-view />
+ <router-view name="sidebar" />
+ </NcContent>
+</template>
+
+<script setup lang="ts">
+import NcContent from '@nextcloud/vue/components/NcContent'
+</script>
diff --git a/apps/settings/src/views/UserManagement.vue b/apps/settings/src/views/UserManagement.vue
new file mode 100644
index 00000000000..9ab76f921a0
--- /dev/null
+++ b/apps/settings/src/views/UserManagement.vue
@@ -0,0 +1,104 @@
+<!--
+ - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcAppContent :page-heading="pageHeading">
+ <UserList :selected-group="selectedGroupDecoded"
+ :external-actions="externalActions" />
+ </NcAppContent>
+</template>
+
+<script>
+import { translate as t } from '@nextcloud/l10n'
+import { emit } from '@nextcloud/event-bus'
+import { defineComponent } from 'vue'
+
+import NcAppContent from '@nextcloud/vue/components/NcAppContent'
+import UserList from '../components/UserList.vue'
+
+export default defineComponent({
+ name: 'UserManagement',
+
+ components: {
+ NcAppContent,
+ UserList,
+ },
+
+ data() {
+ return {
+ // temporary value used for multiselect change
+ externalActions: [],
+ }
+ },
+
+ computed: {
+ pageHeading() {
+ if (this.selectedGroupDecoded === null) {
+ return t('settings', 'All accounts')
+ }
+ const matchHeading = {
+ admin: t('settings', 'Admins'),
+ disabled: t('settings', 'Disabled accounts'),
+ }
+ return matchHeading[this.selectedGroupDecoded] ?? t('settings', 'Account group: {group}', { group: this.selectedGroupDecoded })
+ },
+
+ selectedGroup() {
+ return this.$route.params.selectedGroup
+ },
+
+ selectedGroupDecoded() {
+ return this.selectedGroup ? decodeURIComponent(this.selectedGroup) : null
+ },
+ },
+
+ beforeMount() {
+ this.$store.dispatch('getPasswordPolicyMinLength')
+ },
+
+ created() {
+ // init the OCA.Settings.UserList object
+ window.OCA = window.OCA ?? {}
+ window.OCA.Settings = window.OCA.Settings ?? {}
+ window.OCA.Settings.UserList = window.OCA.Settings.UserList ?? {}
+ // and add the registerAction method
+ window.OCA.Settings.UserList.registerAction = this.registerAction
+ emit('settings:user-management:loaded')
+ },
+
+ methods: {
+ t,
+
+ /**
+ * Register a new action for the user menu
+ *
+ * @param {string} icon the icon class
+ * @param {string} text the text to display
+ * @param {Function} action the function to run
+ * @param {(user: Record<string, unknown>) => boolean} enabled return true if the action is enabled for the user
+ * @return {Array}
+ */
+ registerAction(icon, text, action, enabled) {
+ this.externalActions.push({
+ icon,
+ text,
+ action,
+ enabled,
+ })
+ return this.externalActions
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.app-content {
+ // Virtual list needs to be full height and is scrollable
+ display: flex;
+ overflow: hidden;
+ flex-direction: column;
+ max-height: 100%;
+}
+</style>
diff --git a/apps/settings/src/views/UserManagementNavigation.vue b/apps/settings/src/views/UserManagementNavigation.vue
new file mode 100644
index 00000000000..95a12ac7c51
--- /dev/null
+++ b/apps/settings/src/views/UserManagementNavigation.vue
@@ -0,0 +1,172 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcAppNavigation class="account-management__navigation"
+ :aria-label="t('settings', 'Account management')">
+ <NcAppNavigationNew button-id="new-user-button"
+ :text="t('settings','New account')"
+ @click="showNewUserMenu"
+ @keyup.enter="showNewUserMenu"
+ @keyup.space="showNewUserMenu">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiPlus" />
+ </template>
+ </NcAppNavigationNew>
+
+ <NcAppNavigationList class="account-management__system-list"
+ data-cy-users-settings-navigation-groups="system">
+ <NcAppNavigationItem id="everyone"
+ :exact="true"
+ :name="t('settings', 'All accounts')"
+ :to="{ name: 'users' }">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiAccountOutline" />
+ </template>
+ <template #counter>
+ <NcCounterBubble v-if="userCount" :type="!selectedGroupDecoded ? 'highlighted' : undefined">
+ {{ userCount }}
+ </NcCounterBubble>
+ </template>
+ </NcAppNavigationItem>
+
+ <NcAppNavigationItem v-if="settings.isAdmin"
+ id="admin"
+ :exact="true"
+ :name="t('settings', 'Admins')"
+ :to="{ name: 'group', params: { selectedGroup: 'admin' } }">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiShieldAccountOutline" />
+ </template>
+ <template #counter>
+ <NcCounterBubble v-if="adminGroup && adminGroup.count > 0"
+ :type="selectedGroupDecoded === 'admin' ? 'highlighted' : undefined">
+ {{ adminGroup.count }}
+ </NcCounterBubble>
+ </template>
+ </NcAppNavigationItem>
+
+ <NcAppNavigationItem v-if="isAdminOrDelegatedAdmin"
+ id="recent"
+ :exact="true"
+ :name="t('settings', 'Recently active')"
+ :to="{ name: 'group', params: { selectedGroup: '__nc_internal_recent' } }">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiHistory" />
+ </template>
+ <template #counter>
+ <NcCounterBubble v-if="recentGroup?.usercount"
+ :type="selectedGroupDecoded === '__nc_internal_recent' ? 'highlighted' : undefined">
+ {{ recentGroup.usercount }}
+ </NcCounterBubble>
+ </template>
+ </NcAppNavigationItem>
+
+ <!-- Hide the disabled if none, if we don't have the data (-1) show it -->
+ <NcAppNavigationItem v-if="disabledGroup && (disabledGroup.usercount > 0 || disabledGroup.usercount === -1)"
+ id="disabled"
+ :exact="true"
+ :name="t('settings', 'Disabled accounts')"
+ :to="{ name: 'group', params: { selectedGroup: 'disabled' } }">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiAccountOffOutline" />
+ </template>
+ <template v-if="disabledGroup.usercount > 0" #counter>
+ <NcCounterBubble :type="selectedGroupDecoded === 'disabled' ? 'highlighted' : undefined">
+ {{ disabledGroup.usercount }}
+ </NcCounterBubble>
+ </template>
+ </NcAppNavigationItem>
+ </NcAppNavigationList>
+
+ <AppNavigationGroupList />
+
+ <template #footer>
+ <NcButton class="account-management__settings-toggle"
+ type="tertiary"
+ @click="isDialogOpen = true">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCogOutline" />
+ </template>
+ {{ t('settings', 'Account management settings') }}
+ </NcButton>
+ <UserSettingsDialog :open.sync="isDialogOpen" />
+ </template>
+ </NcAppNavigation>
+</template>
+
+<script setup lang="ts">
+import { mdiAccountOutline, mdiAccountOffOutline, mdiCogOutline, mdiPlus, mdiShieldAccountOutline, mdiHistory } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { computed, ref } from 'vue'
+
+import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
+import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
+import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
+import NcAppNavigationNew from '@nextcloud/vue/components/NcAppNavigationNew'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+import UserSettingsDialog from '../components/Users/UserSettingsDialog.vue'
+import AppNavigationGroupList from '../components/AppNavigationGroupList.vue'
+
+import { useStore } from '../store'
+import { useRoute } from 'vue-router/composables'
+import { useFormatGroups } from '../composables/useGroupsNavigation'
+
+const route = useRoute()
+const store = useStore()
+
+/** State of the 'new-account' dialog */
+const isDialogOpen = ref(false)
+
+/** 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)
+
+/** Overall user count */
+const userCount = computed(() => store.getters.getUserCount)
+/** All available groups */
+const groups = computed(() => store.getters.getSortedGroups)
+const { adminGroup, recentGroup, disabledGroup } = 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)
+
+/**
+ * Open the new-user form dialog
+ */
+function showNewUserMenu() {
+ store.commit('setShowConfig', {
+ key: 'showNewUserForm',
+ value: true,
+ })
+}
+</script>
+
+<style scoped lang="scss">
+.account-management {
+ &__navigation {
+ :deep(.app-navigation__body) {
+ will-change: scroll-position;
+ }
+ }
+ &__system-list {
+ height: auto !important;
+ overflow: visible !important;
+ }
+
+ &__group-list {
+ height: 100% !important;
+ }
+
+ &__settings-toggle {
+ margin-bottom: 12px;
+ }
+}
+</style>
diff --git a/apps/settings/src/views/Users.vue b/apps/settings/src/views/Users.vue
deleted file mode 100644
index dc17b9f9362..00000000000
--- a/apps/settings/src/views/Users.vue
+++ /dev/null
@@ -1,393 +0,0 @@
-<!--
- - @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/>.
- -
- -->
-
-<template>
- <Fragment>
- <NcContent app-name="settings">
- <NcAppNavigation :aria-label="t('settings', 'User management')">
- <NcAppNavigationNew button-id="new-user-button"
- :text="t('settings','New user')"
- @click="showNewUserMenu"
- @keyup.enter="showNewUserMenu"
- @keyup.space="showNewUserMenu">
- <template #icon>
- <Plus :size="20" />
- </template>
- </NcAppNavigationNew>
-
- <template #list>
- <NcAppNavigationItem id="everyone"
- :exact="true"
- :name="t('settings', 'Active users')"
- :to="{ name: 'users' }">
- <template #icon>
- <AccountGroup :size="20" />
- </template>
- <template #counter>
- <NcCounterBubble v-if="userCount" :type="!selectedGroupDecoded ? 'highlighted' : undefined">
- {{ userCount }}
- </NcCounterBubble>
- </template>
- </NcAppNavigationItem>
-
- <NcAppNavigationItem v-if="settings.isAdmin"
- id="admin"
- :exact="true"
- :name="t('settings', 'Admins')"
- :to="{ name: 'group', params: { selectedGroup: 'admin' } }">
- <template #icon>
- <ShieldAccount :size="20" />
- </template>
- <template v-if="adminGroupMenu.count > 0" #counter>
- <NcCounterBubble :type="selectedGroupDecoded === 'admin' ? 'highlighted' : undefined">
- {{ adminGroupMenu.count }}
- </NcCounterBubble>
- </template>
- </NcAppNavigationItem>
-
- <!-- Hide the disabled if none, if we don't have the data (-1) show it -->
- <NcAppNavigationItem v-if="disabledGroupMenu.usercount > 0 || disabledGroupMenu.usercount === -1"
- id="disabled"
- :exact="true"
- :name="t('settings', 'Disabled users')"
- :to="{ name: 'group', params: { selectedGroup: 'disabled' } }">
- <template #icon>
- <AccountOff :size="20" />
- </template>
- <template v-if="disabledGroupMenu.usercount > 0" #counter>
- <NcCounterBubble :type="selectedGroupDecoded === 'disabled' ? 'highlighted' : undefined">
- {{ disabledGroupMenu.usercount }}
- </NcCounterBubble>
- </template>
- </NcAppNavigationItem>
-
- <NcAppNavigationCaption :name="t('settings', 'Groups')"
- :disabled="loadingAddGroup"
- :aria-label="loadingAddGroup ? t('settings', 'Creating group …') : t('settings', 'Create group')"
- force-menu
- :open.sync="isAddGroupOpen">
- <template #actionsTriggerIcon>
- <NcLoadingIcon v-if="loadingAddGroup" />
- <Plus v-else :size="20" />
- </template>
- <template #actions>
- <NcActionText>
- <template #icon>
- <AccountGroup :size="20" />
- </template>
- {{ t('settings', 'Create group') }}
- </NcActionText>
- <NcActionInput :label="t('settings', 'Group name')"
- data-cy-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>
-
- <GroupListItem v-for="group in groupList"
- :id="group.id"
- :key="group.id"
- :active="selectedGroupDecoded === group.id"
- :name="group.title"
- :count="group.count" />
- </template>
-
- <template #footer>
- <ul class="app-navigation-entry__settings">
- <NcAppNavigationItem :name="t('settings', 'User management settings')"
- @click="isDialogOpen = true">
- <template #icon>
- <Cog :size="20" />
- </template>
- </NcAppNavigationItem>
- </ul>
- </template>
- </NcAppNavigation>
-
- <NcAppContent :page-heading="pageHeading">
- <UserList :selected-group="selectedGroupDecoded"
- :external-actions="externalActions" />
- </NcAppContent>
- </NcContent>
-
- <UserSettingsDialog :open.sync="isDialogOpen" />
- </Fragment>
-</template>
-
-<script>
-import Vue from 'vue'
-import VueLocalStorage from 'vue-localstorage'
-import { Fragment } from 'vue-frag'
-import { translate as t } from '@nextcloud/l10n'
-import { showError } from '@nextcloud/dialogs'
-
-import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js'
-import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
-import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
-import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
-import NcAppNavigationCaption from '@nextcloud/vue/dist/Components/NcAppNavigationCaption.js'
-import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
-import NcAppNavigationNew from '@nextcloud/vue/dist/Components/NcAppNavigationNew.js'
-import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
-import NcCounterBubble from '@nextcloud/vue/dist/Components/NcCounterBubble.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
-
-import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
-import AccountOff from 'vue-material-design-icons/AccountOff.vue'
-import Cog from 'vue-material-design-icons/Cog.vue'
-import Plus from 'vue-material-design-icons/Plus.vue'
-import ShieldAccount from 'vue-material-design-icons/ShieldAccount.vue'
-
-import GroupListItem from '../components/GroupListItem.vue'
-import UserList from '../components/UserList.vue'
-import UserSettingsDialog from '../components/Users/UserSettingsDialog.vue'
-
-Vue.use(VueLocalStorage)
-
-export default {
- name: 'Users',
-
- components: {
- AccountGroup,
- AccountOff,
- Cog,
- Fragment,
- GroupListItem,
- NcActionInput,
- NcActionText,
- NcAppContent,
- NcAppNavigation,
- NcAppNavigationCaption,
- NcAppNavigationItem,
- NcAppNavigationNew,
- NcContent,
- NcCounterBubble,
- NcLoadingIcon,
- Plus,
- ShieldAccount,
- UserList,
- UserSettingsDialog,
- },
-
- props: {
- selectedGroup: {
- type: String,
- default: null,
- },
- },
-
- data() {
- return {
- // temporary value used for multiselect change
- externalActions: [],
- newGroupName: '',
- isAddGroupOpen: false,
- loadingAddGroup: false,
- hasAddGroupError: false,
- isDialogOpen: false,
- }
- },
-
- computed: {
- pageHeading() {
- if (this.selectedGroupDecoded === null) {
- return t('settings', 'Active users')
- }
- const matchHeading = {
- admin: t('settings', 'Admins'),
- disabled: t('settings', 'Disabled users'),
- }
- return matchHeading[this.selectedGroupDecoded] ?? t('settings', 'User group: {group}', { group: this.selectedGroupDecoded })
- },
-
- showConfig() {
- return this.$store.getters.getShowConfig
- },
-
- selectedGroupDecoded() {
- return this.selectedGroup ? decodeURIComponent(this.selectedGroup) : null
- },
-
- users() {
- return this.$store.getters.getUsers
- },
-
- groups() {
- return this.$store.getters.getGroups
- },
-
- usersOffset() {
- return this.$store.getters.getUsersOffset
- },
-
- usersLimit() {
- return this.$store.getters.getUsersLimit
- },
-
- userCount() {
- return this.$store.getters.getUserCount
- },
-
- settings() {
- return this.$store.getters.getServerData
- },
-
- groupList() {
- const groups = Array.isArray(this.groups) ? this.groups : []
-
- return groups
- // filter out disabled and admin
- .filter(group => group.id !== 'disabled' && group.id !== 'admin')
- .map(group => this.formatGroupMenu(group))
- },
-
- adminGroupMenu() {
- return this.formatGroupMenu(this.groups.find(group => group.id === 'admin'))
- },
-
- disabledGroupMenu() {
- return this.formatGroupMenu(this.groups.find(group => group.id === 'disabled'))
- },
- },
-
- beforeMount() {
- this.$store.commit('initGroups', {
- groups: this.$store.getters.getServerData.groups,
- orderBy: this.$store.getters.getServerData.sortGroups,
- userCount: this.$store.getters.getServerData.userCount,
- })
- this.$store.dispatch('getPasswordPolicyMinLength')
- },
-
- created() {
- // init the OCA.Settings.UserList object
- // and add the registerAction method
- Object.assign(OCA, {
- Settings: {
- UserList: {
- registerAction: this.registerAction,
- },
- },
- })
- },
-
- methods: {
- t,
-
- showNewUserMenu() {
- this.$store.commit('setShowConfig', {
- key: 'showNewUserForm',
- value: true,
- })
- },
-
- /**
- * Register a new action for the user menu
- *
- * @param {string} icon the icon class
- * @param {string} text the text to display
- * @param {Function} action the function to run
- * @return {Array}
- */
- registerAction(icon, text, action) {
- this.externalActions.push({
- icon,
- text,
- action,
- })
- return this.externalActions
- },
-
- /**
- * Create a new group
- */
- async createGroup() {
- this.hasAddGroupError = false
- const groupId = this.newGroupName.trim()
- if (groupId === '') {
- this.hasAddGroupError = true
- return
- }
-
- this.isAddGroupOpen = false
- this.loadingAddGroup = true
- try {
- await this.$store.dispatch('addGroup', groupId)
- await this.$router.push({
- name: 'group',
- params: {
- selectedGroup: encodeURIComponent(groupId),
- },
- })
- this.newGroupName = ''
- } catch {
- showError(t('settings', 'Failed to create group'))
- }
- this.loadingAddGroup = false
- },
-
- /**
- * Format a group to a menu entry
- *
- * @param {object} group the group
- * @return {object}
- */
- formatGroupMenu(group) {
- const item = {}
- if (typeof group === 'undefined') {
- return {}
- }
-
- item.id = group.id
- item.title = group.name
- item.usercount = group.usercount
-
- // users count for all groups
- if (group.usercount - group.disabled > 0) {
- item.count = group.usercount - group.disabled
- }
-
- return item
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.app-content {
- // Virtual list needs to be full height and is scrollable
- display: flex;
- overflow: hidden;
- flex-direction: column;
- max-height: 100%;
-}
-
-.app-navigation-entry__settings {
- height: auto !important;
- // Prevent shrinking or growing
- flex: 0 0 auto;
-}
-</style>
diff --git a/apps/settings/src/views/user-types.d.ts b/apps/settings/src/views/user-types.d.ts
new file mode 100644
index 00000000000..21c63a13b03
--- /dev/null
+++ b/apps/settings/src/views/user-types.d.ts
@@ -0,0 +1,35 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+export interface IGroup {
+ /**
+ * Id
+ */
+ id: string
+
+ /**
+ * Display name
+ */
+ name: string
+
+ /**
+ * Overall user count
+ */
+ usercount: number
+
+ /**
+ * Number of disabled users
+ */
+ disabled: number
+
+ /**
+ * True if users can be added to this group
+ */
+ canAdd?: boolean
+
+ /**
+ * True if users can be removed from this group
+ */
+ canRemove?: boolean
+}
diff --git a/apps/settings/src/webpack.shim.d.ts b/apps/settings/src/webpack.shim.d.ts
new file mode 100644
index 00000000000..3d330bb3128
--- /dev/null
+++ b/apps/settings/src/webpack.shim.d.ts
@@ -0,0 +1,5 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+declare let __webpack_nonce__: string | undefined