-
{{ displayName }}
-
-
-
-
-
- {{ t('theming', 'Upload') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {{ label }}
+
-
-
- {{ errorMessage }}
-
+ v-if="mime.startsWith('image/')"
+ :class="$style.fileInputField__preview"
+ role="img"
+ :aria-label="t('theming', 'Preview of the selected image')" />
+
+
+
+
+
-
-
-
diff --git a/apps/theming/src/components/admin/TextField.vue b/apps/theming/src/components/admin/TextField.vue
index 55467700430..4892916b2a7 100644
--- a/apps/theming/src/components/admin/TextField.vue
+++ b/apps/theming/src/components/admin/TextField.vue
@@ -3,82 +3,59 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
-
-
-
-
-
+
-
+
+
+
+
+
+
+
diff --git a/apps/theming/src/components/admin/shared/field.scss b/apps/theming/src/components/admin/shared/field.scss
deleted file mode 100644
index 2347f31f7c5..00000000000
--- a/apps/theming/src/components/admin/shared/field.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-/*!
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-.field {
- display: flex;
- flex-direction: column;
- gap: 4px 0;
-
- &__row {
- display: flex;
- gap: 0 4px;
- }
-}
diff --git a/apps/theming/src/composables/useAdminThemingValue.ts b/apps/theming/src/composables/useAdminThemingValue.ts
new file mode 100644
index 00000000000..6c71836120a
--- /dev/null
+++ b/apps/theming/src/composables/useAdminThemingValue.ts
@@ -0,0 +1,111 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { MaybeRef, MaybeRefOrGetter, Ref } from 'vue'
+
+import axios, { isAxiosError } from '@nextcloud/axios'
+import { showError } from '@nextcloud/dialogs'
+import { generateUrl } from '@nextcloud/router'
+import { watchDebounced } from '@vueuse/core'
+import { isReadonly, isRef, readonly, ref, toValue } from 'vue'
+import { logger } from '../utils/logger.ts'
+
+/**
+ * @param name - The property name
+ * @param modelValue - The model value
+ * @param defaultValue - The default value
+ */
+export function useAdminThemingValue
(name: MaybeRefOrGetter, modelValue: Ref, defaultValue: MaybeRef) {
+ let resetted = false
+ const isSaving = ref(false)
+ const isSaved = ref(false)
+
+ watchDebounced(modelValue, async () => {
+ if (isSaving.value) {
+ return
+ }
+
+ if (resetted) {
+ resetted = false
+ return
+ }
+
+ isSaving.value = true
+ isSaved.value = false
+ try {
+ await setValue(toValue(name), toValue(modelValue))
+ isSaved.value = true
+ window.setTimeout(() => {
+ isSaved.value = false
+ }, 2000)
+ } finally {
+ isSaving.value = false
+ }
+ }, { debounce: 800, flush: 'sync' })
+
+ /**
+ * Reset to default value
+ */
+ async function reset() {
+ isSaving.value = true
+ isSaved.value = false
+ try {
+ const result = await resetValue(toValue(name))
+ if (result && isRef(defaultValue) && !isReadonly(defaultValue)) {
+ defaultValue.value = result as T
+ }
+ resetted = true
+ modelValue.value = toValue(defaultValue)
+ } finally {
+ isSaving.value = false
+ }
+ }
+
+ return {
+ isSaving: readonly(isSaving),
+ isSaved: readonly(isSaved),
+ reset,
+ }
+}
+
+/**
+ * @param setting - The setting name
+ * @param value - The setting value
+ */
+async function setValue(setting: string, value: unknown) {
+ const url = generateUrl('/apps/theming/ajax/updateStylesheet')
+ try {
+ await axios.post(url, {
+ setting,
+ value: String(value),
+ })
+ } catch (error) {
+ logger.error('Failed to save changes', { error, setting, value })
+ if (isAxiosError(error) && error.response?.data?.data?.message) {
+ showError(error.response.data.data.message)
+ }
+ throw error
+ }
+}
+
+/**
+ * Reset theming value for a given setting
+ *
+ * @param setting - The setting name
+ */
+async function resetValue(setting: string) {
+ const url = generateUrl('/apps/theming/ajax/undoChanges')
+ try {
+ const { data } = await axios.post<{ data: { value?: string } }>(url, { setting })
+ return data.data.value
+ } catch (error) {
+ logger.error('Failed to reset theming value', { error, setting })
+ if (isAxiosError(error) && error.response?.data?.data?.message) {
+ showError(error.response.data.data.message)
+ return false
+ }
+ throw error
+ }
+}
diff --git a/apps/theming/src/mixins/admin/FieldMixin.js b/apps/theming/src/mixins/admin/FieldMixin.js
deleted file mode 100644
index e9a2d3c3877..00000000000
--- a/apps/theming/src/mixins/admin/FieldMixin.js
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-const styleRefreshFields = [
- 'color',
- 'logo',
- 'background',
- 'logoheader',
- 'favicon',
- 'disable-user-theming',
-]
-
-export default {
- emits: [
- 'update:theming',
- ],
-
- data() {
- return {
- showSuccess: false,
- errorMessage: '',
- }
- },
-
- computed: {
- id() {
- return `admin-theming-${this.name}`
- },
- },
-
- methods: {
- reset() {
- this.showSuccess = false
- this.errorMessage = ''
- },
-
- handleSuccess() {
- this.showSuccess = true
- setTimeout(() => {
- this.showSuccess = false
- }, 2000)
- if (styleRefreshFields.includes(this.name)) {
- this.$emit('update:theming')
- }
- },
- },
-}
diff --git a/apps/theming/src/mixins/admin/TextValueMixin.js b/apps/theming/src/mixins/admin/TextValueMixin.js
deleted file mode 100644
index 0e83c1bd084..00000000000
--- a/apps/theming/src/mixins/admin/TextValueMixin.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import axios from '@nextcloud/axios'
-import { generateUrl } from '@nextcloud/router'
-import { logger } from '../../logger.ts'
-import FieldMixin from './FieldMixin.js'
-
-export default {
- mixins: [
- FieldMixin,
- ],
-
- watch: {
- value(value) {
- this.localValue = value
- },
- },
-
- data() {
- return {
- /** @type {string|boolean} */
- localValue: this.value,
- }
- },
-
- computed: {
- valueToPost() {
- if (this.type === 'url') {
- // if this is already encoded just make sure there is no doublequote (HTML XSS)
- // otherwise simply URL encode
- return this.isUrlEncoded(this.localValue)
- ? this.localValue.replaceAll('"', '%22')
- : encodeURI(this.localValue)
- }
- // Convert boolean to string as server expects string value
- if (typeof this.localValue === 'boolean') {
- return this.localValue ? 'yes' : 'no'
- }
- return this.localValue
- },
- },
-
- methods: {
- /**
- * Check if URL is percent-encoded
- *
- * @param {string} url The URL to check
- * @return {boolean}
- */
- isUrlEncoded(url) {
- try {
- return decodeURI(url) !== url
- } catch {
- return false
- }
- },
-
- async save() {
- this.reset()
- const url = generateUrl('/apps/theming/ajax/updateStylesheet')
-
- try {
- await axios.post(url, {
- setting: this.name,
- value: this.valueToPost,
- })
- this.$emit('update:value', this.localValue)
- this.handleSuccess()
- } catch (error) {
- logger.error('Failed to save changes', { error })
- this.errorMessage = error.response?.data.data?.message
- }
- },
-
- async undo() {
- this.reset()
- const url = generateUrl('/apps/theming/ajax/undoChanges')
- try {
- const { data } = await axios.post(url, {
- setting: this.name,
- })
-
- if (data.data.value) {
- this.$emit('update:defaultValue', data.data.value)
- }
- this.$emit('update:value', data.data.value || this.defaultValue)
- this.handleSuccess()
- } catch (e) {
- this.errorMessage = e.response.data.data?.message
- }
- },
- },
-}
diff --git a/apps/theming/src/personal-settings.js b/apps/theming/src/personal-settings.js
deleted file mode 100644
index fe815752332..00000000000
--- a/apps/theming/src/personal-settings.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { getCSPNonce } from '@nextcloud/auth'
-import Vue from 'vue'
-import App from './UserTheming.vue'
-import { refreshStyles } from './helpers/refreshStyles.js'
-
-__webpack_nonce__ = getCSPNonce()
-
-Vue.prototype.OC = OC
-Vue.prototype.t = t
-
-const View = Vue.extend(App)
-const theming = new View()
-theming.$mount('#theming')
-theming.$on('update:background', refreshStyles)
diff --git a/apps/theming/src/settings-admin.ts b/apps/theming/src/settings-admin.ts
new file mode 100644
index 00000000000..ec7a87ac9d8
--- /dev/null
+++ b/apps/theming/src/settings-admin.ts
@@ -0,0 +1,13 @@
+/*!
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createApp } from 'vue'
+import AdminTheming from './views/AdminTheming.vue'
+
+import 'vite/modulepreload-polyfill'
+
+const app = createApp(AdminTheming)
+app.config.idPrefix = 'settings'
+app.mount('#settings-admin-theming')
diff --git a/apps/theming/src/settings-personal.ts b/apps/theming/src/settings-personal.ts
new file mode 100644
index 00000000000..bff8415c994
--- /dev/null
+++ b/apps/theming/src/settings-personal.ts
@@ -0,0 +1,13 @@
+/*!
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createApp } from 'vue'
+import UserTheming from './views/UserTheming.vue'
+
+import 'vite/modulepreload-polyfill'
+
+const app = createApp(UserTheming)
+app.config.idPrefix = 'settings'
+app.mount('#settings-personal-theming')
diff --git a/apps/theming/src/types.d.ts b/apps/theming/src/types.d.ts
new file mode 100644
index 00000000000..1f52ace941d
--- /dev/null
+++ b/apps/theming/src/types.d.ts
@@ -0,0 +1,38 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Modifiable parameters for the admin theming settings.
+ */
+export interface AdminThemingParameters {
+ backgroundMime: string
+ backgroundURL: string
+ backgroundColor: string
+ faviconMime: string
+ legalNoticeUrl: string
+ logoheaderMime: string
+ logoMime: string
+ name: string
+ primaryColor: string
+ privacyPolicyUrl: string
+ slogan: string
+ url: string
+ disableUserTheming: boolean
+ defaultApps: string[]
+}
+
+/**
+ * Admin theming information.
+ */
+export interface AdminThemingInfo {
+ isThemeable: boolean
+ canThemeIcons: boolean
+
+ notThemeableErrorMessage: string
+ defaultBackgroundURL: string
+ defaultBackgroundColor: string
+ docUrl: string
+ docUrlIcons: string
+}
diff --git a/apps/theming/src/utils/color.spec.ts b/apps/theming/src/utils/color.spec.ts
new file mode 100644
index 00000000000..f72169b6780
--- /dev/null
+++ b/apps/theming/src/utils/color.spec.ts
@@ -0,0 +1,36 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, test } from 'vitest'
+import { getTextColor } from './color.ts'
+
+test('getTextColor returns black for light backgrounds', () => {
+ expect(getTextColor('#FFFFFF')).toBe('#000000') // white background
+ expect(getTextColor('#DDDDDD')).toBe('#000000') // light gray background
+ expect(getTextColor('#FFFFAA')).toBe('#000000') // light yellow background
+})
+
+test('getTextColor returns white for dark backgrounds', () => {
+ expect(getTextColor('#000000')).toBe('#ffffff') // black background
+ expect(getTextColor('#333333')).toBe('#ffffff') // dark gray background
+ expect(getTextColor('#0000AA')).toBe('#ffffff') // dark blue background
+})
+
+test('getTextColor handles edge cases', () => {
+ expect(getTextColor('#808080')).toBe('#ffffff') // medium gray background
+ expect(getTextColor('#C0C0C0')).toBe('#000000') // silver background
+ expect(getTextColor('#404040')).toBe('#ffffff') // dark gray background
+})
+
+test('getTextColor handles shorthand hex colors', () => {
+ expect(getTextColor('#FFF')).toBe('#000000') // white background
+ expect(getTextColor('#000')).toBe('#ffffff') // black background
+ expect(getTextColor('#888')).toBe('#ffffff') // medium gray background
+})
+
+test('getTextColor handles invalid hex colors', () => {
+ expect(getTextColor('invalid')).toBe('#ffffff')
+ expect(getTextColor('#GG')).toBe('#ffffff')
+})
diff --git a/apps/theming/src/utils/color.ts b/apps/theming/src/utils/color.ts
new file mode 100644
index 00000000000..4b060e93619
--- /dev/null
+++ b/apps/theming/src/utils/color.ts
@@ -0,0 +1,45 @@
+/*!
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Get the text color for a given background color
+ *
+ * @param color - The hex color
+ */
+export function getTextColor(color: string) {
+ return calculateLuma(color) > 0.6
+ ? '#000000'
+ : '#ffffff'
+}
+
+/**
+ * Calculate luminance of provided hex color
+ *
+ * @param color - The hex color
+ */
+function calculateLuma(color: string) {
+ const [red, green, blue] = hexToRGB(color)
+ return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
+}
+
+/**
+ * Convert hex color to RGB
+ *
+ * @param hex - The hex color
+ */
+function hexToRGB(hex: string): [number, number, number] {
+ if (hex.length < 6) {
+ // handle shorthand hex colors like #FFF
+ const result = /^#?([a-f\d])([a-f\d])([a-f\d])/i.exec(hex)
+ if (result) {
+ hex = `#${result[1]!.repeat(2)}${result[2]!.repeat(2)}${result[3]!.repeat(2)}`
+ }
+ }
+
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
+ return result
+ ? [parseInt(result[1]!, 16), parseInt(result[2]!, 16), parseInt(result[3]!, 16)]
+ : [0, 0, 0]
+}
diff --git a/apps/theming/src/logger.ts b/apps/theming/src/utils/logger.ts
similarity index 100%
rename from apps/theming/src/logger.ts
rename to apps/theming/src/utils/logger.ts
diff --git a/apps/theming/src/helpers/refreshStyles.js b/apps/theming/src/utils/refreshStyles.ts
similarity index 70%
rename from apps/theming/src/helpers/refreshStyles.js
rename to apps/theming/src/utils/refreshStyles.ts
index ba198be0a00..37db9321815 100644
--- a/apps/theming/src/helpers/refreshStyles.js
+++ b/apps/theming/src/utils/refreshStyles.ts
@@ -1,4 +1,4 @@
-/**
+/*!
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@@ -8,12 +8,13 @@
* This resolves when all themes are reloaded
*/
export async function refreshStyles() {
- const themes = [...document.head.querySelectorAll('link.theme')]
- const promises = themes.map((theme) => new Promise((resolve) => {
+ const themes = [...document.head.querySelectorAll('link.theme')] as HTMLLinkElement[]
+ const promises = themes.map((theme) => new Promise((resolve, reject) => {
const url = new URL(theme.href)
- url.searchParams.set('v', Date.now())
- const newTheme = theme.cloneNode()
+ url.searchParams.set('v', Date.now().toString())
+ const newTheme = theme.cloneNode() as HTMLLinkElement
newTheme.href = url.toString()
+ newTheme.onerror = reject
newTheme.onload = () => {
theme.remove()
resolve()
diff --git a/apps/theming/src/views/AdminTheming.vue b/apps/theming/src/views/AdminTheming.vue
new file mode 100644
index 00000000000..c34f7e64f8a
--- /dev/null
+++ b/apps/theming/src/views/AdminTheming.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/theming/src/views/UserTheming.vue b/apps/theming/src/views/UserTheming.vue
new file mode 100644
index 00000000000..96933ef901a
--- /dev/null
+++ b/apps/theming/src/views/UserTheming.vue
@@ -0,0 +1,268 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('theming', 'Misc accessibility options') }}
+
+ {{ t('theming', 'Enable blur background filter (may increase GPU load)') }}
+
+
+
+
+ {{ t('theming', 'Customization has been disabled by your administrator') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/theming/templates/settings-admin.php b/apps/theming/templates/settings-admin.php
index 05bbd3684b5..3f689304f2c 100644
--- a/apps/theming/templates/settings-admin.php
+++ b/apps/theming/templates/settings-admin.php
@@ -5,4 +5,4 @@
*/
?>
-
+
diff --git a/apps/theming/templates/settings-personal.php b/apps/theming/templates/settings-personal.php
index 494a466c840..ba8fa04f901 100644
--- a/apps/theming/templates/settings-personal.php
+++ b/apps/theming/templates/settings-personal.php
@@ -6,4 +6,4 @@
?>
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/apps/theming/tests/Controller/ThemingControllerTest.php b/apps/theming/tests/Controller/ThemingControllerTest.php
index fb461f03a28..cfac9125c10 100644
--- a/apps/theming/tests/Controller/ThemingControllerTest.php
+++ b/apps/theming/tests/Controller/ThemingControllerTest.php
@@ -89,20 +89,21 @@ class ThemingControllerTest extends TestCase {
['name', str_repeat('a', 250), 'Saved'],
['url', 'https://nextcloud.com/' . str_repeat('a', 478), 'Saved'],
['slogan', str_repeat('a', 500), 'Saved'],
- ['color', '#0082c9', 'Saved'],
- ['color', '#0082C9', 'Saved'],
- ['color', '#0082C9', 'Saved'],
+ ['primaryColor', '#0082c9', 'Saved', 'primary_color'],
+ ['primary_color', '#0082C9', 'Saved'],
+ ['backgroundColor', '#0082C9', 'Saved', 'background_color'],
+ ['background_color', '#0082C9', 'Saved'],
['imprintUrl', 'https://nextcloud.com/' . str_repeat('a', 478), 'Saved'],
['privacyUrl', 'https://nextcloud.com/' . str_repeat('a', 478), 'Saved'],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataUpdateStylesheetSuccess')]
- public function testUpdateStylesheetSuccess(string $setting, string $value, string $message): void {
+ public function testUpdateStylesheetSuccess(string $setting, string $value, string $message, ?string $realSetting = null): void {
$this->themingDefaults
->expects($this->once())
->method('set')
- ->with($setting, $value);
+ ->with($realSetting ?? $setting, $value);
$this->l10n
->expects($this->once())
->method('t')
@@ -149,6 +150,8 @@ class ThemingControllerTest extends TestCase {
['background_color', '#0082Z9', 'The given color is invalid'],
['background_color', 'Nextcloud', 'The given color is invalid'],
+ ['doesnotexist', 'value', 'Invalid setting key'],
+
...$urlTests,
];
}
diff --git a/apps/theming/tests/Settings/AdminTest.php b/apps/theming/tests/Settings/AdminTest.php
index 277b94900a8..5a26657574e 100644
--- a/apps/theming/tests/Settings/AdminTest.php
+++ b/apps/theming/tests/Settings/AdminTest.php
@@ -7,7 +7,6 @@ declare(strict_types=1);
*/
namespace OCA\Theming\Tests\Settings;
-use OCA\Theming\AppInfo\Application;
use OCA\Theming\ImageManager;
use OCA\Theming\Settings\Admin;
use OCA\Theming\ThemingDefaults;
@@ -41,7 +40,6 @@ class AdminTest extends TestCase {
$this->navigationManager = $this->createMock(INavigationManager::class);
$this->admin = new Admin(
- Application::APP_ID,
$this->config,
$this->l10n,
$this->themingDefaults,
diff --git a/apps/theming/tests/Settings/PersonalTest.php b/apps/theming/tests/Settings/PersonalTest.php
index 6fe790ceeef..6446464f02e 100644
--- a/apps/theming/tests/Settings/PersonalTest.php
+++ b/apps/theming/tests/Settings/PersonalTest.php
@@ -7,7 +7,6 @@ declare(strict_types=1);
*/
namespace OCA\Theming\Tests\Settings;
-use OCA\Theming\AppInfo\Application;
use OCA\Theming\ImageManager;
use OCA\Theming\ITheme;
use OCA\Theming\Service\BackgroundService;
@@ -60,7 +59,6 @@ class PersonalTest extends TestCase {
->willReturn($this->themes);
$this->admin = new Personal(
- Application::APP_ID,
'admin',
$this->config,
$this->themesService,
diff --git a/build/frontend-legacy/webpack.modules.cjs b/build/frontend-legacy/webpack.modules.cjs
index 61125c271b3..cdc0f8e58b9 100644
--- a/build/frontend-legacy/webpack.modules.cjs
+++ b/build/frontend-legacy/webpack.modules.cjs
@@ -79,10 +79,6 @@ module.exports = {
init: path.join(__dirname, 'apps/systemtags/src', 'init.ts'),
admin: path.join(__dirname, 'apps/systemtags/src', 'admin.ts'),
},
- theming: {
- 'personal-theming': path.join(__dirname, 'apps/theming/src', 'personal-settings.js'),
- 'admin-theming': path.join(__dirname, 'apps/theming/src', 'admin-settings.js'),
- },
updatenotification: {
init: path.join(__dirname, 'apps/updatenotification/src', 'init.ts'),
'view-changelog-page': path.join(__dirname, 'apps/updatenotification/src', 'view-changelog-page.ts'),
diff --git a/build/frontend-legacy/apps/theming b/build/frontend/apps/theming
similarity index 100%
rename from build/frontend-legacy/apps/theming
rename to build/frontend/apps/theming
diff --git a/build/frontend/vite.config.mts b/build/frontend/vite.config.ts
similarity index 95%
rename from build/frontend/vite.config.mts
rename to build/frontend/vite.config.ts
index aec4990fc91..198081027a1 100644
--- a/build/frontend/vite.config.mts
+++ b/build/frontend/vite.config.ts
@@ -32,6 +32,10 @@ const modules = {
sharebymail: {
'admin-settings': resolve(import.meta.dirname, 'apps/sharebymail/src', 'settings-admin.ts'),
},
+ theming: {
+ 'settings-personal': resolve(import.meta.dirname, 'apps/theming/src', 'settings-personal.ts'),
+ 'settings-admin': resolve(import.meta.dirname, 'apps/theming/src', 'settings-admin.ts'),
+ },
twofactor_backupcodes: {
'settings-personal': resolve(import.meta.dirname, 'apps/twofactor_backupcodes/src', 'settings-personal.ts'),
},
diff --git a/package-lock.json b/package-lock.json
index 9df2a9792ad..091e7a8a97c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
+ "@mdi/js": "^7.4.47",
"@mdi/svg": "^7.4.47",
"@nextcloud/auth": "^2.5.3",
"@nextcloud/axios": "^2.5.2",
@@ -28,8 +29,10 @@
"@nextcloud/sharing": "^0.3.0",
"@nextcloud/vue": "^9.3.1",
"@vueuse/core": "^14.1.0",
+ "@vueuse/integrations": "^14.1.0",
"debounce": "^3.0.0",
"pinia": "^3.0.4",
+ "sortablejs": "^1.15.6",
"vue": "^3.5.26",
"vuex": "^4.1.0",
"webdav": "^5.8.0"
@@ -225,6 +228,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -534,6 +538,7 @@
"integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@@ -663,6 +668,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
},
@@ -706,6 +712,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=18"
}
@@ -2328,6 +2335,7 @@
"resolved": "https://registry.npmjs.org/@nextcloud/l10n/-/l10n-3.4.1.tgz",
"integrity": "sha512-aTFinTcKiK2gEXwLgutXekpZZ8/v/4QiC8C3QCLH5m0o+WtxsBC+fqV142ebC/rfDnzCLhY4ZtswSu8bFbZocg==",
"license": "GPL-3.0-or-later",
+ "peer": true,
"dependencies": {
"@nextcloud/router": "^3.0.1",
"@nextcloud/typings": "^1.9.1",
@@ -2516,6 +2524,7 @@
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.1.tgz",
"integrity": "sha512-zNit83SI7IPT5iT9QsYPCYNwBYvKEqzLvWKTeJemqg9MZ8JGIC3/jjENeXzDolrTN/PixHns5lOYVCejATE1ag==",
"license": "AGPL-3.0-or-later",
+ "peer": true,
"dependencies": {
"@ckpack/vue-color": "^1.6.0",
"@floating-ui/dom": "^1.7.4",
@@ -3419,6 +3428,7 @@
"integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"json-schema-traverse": "^1.0.0",
@@ -3598,6 +3608,7 @@
"integrity": "sha512-IeZF+8H0ns6prg4VrkhgL+yrvDXWDH2cKchrbh80ejG9dQgZWp10epHMbgRuQvgchLII/lfh6Xn3lu6+6L86Hw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.0",
"@typescript-eslint/types": "^8.46.1",
@@ -4008,6 +4019,7 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -4458,6 +4470,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26",
@@ -4697,6 +4710,72 @@
"vue": "^3.5.0"
}
},
+ "node_modules/@vueuse/integrations": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.1.0.tgz",
+ "integrity": "sha512-eNQPdisnO9SvdydTIXnTE7c29yOsJBD/xkwEyQLdhDC/LKbqrFpXHb3uS//7NcIrQO3fWVuvMGp8dbK6mNEMCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vueuse/core": "14.1.0",
+ "@vueuse/shared": "14.1.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "async-validator": "^4",
+ "axios": "^1",
+ "change-case": "^5",
+ "drauu": "^0.4",
+ "focus-trap": "^7",
+ "fuse.js": "^7",
+ "idb-keyval": "^6",
+ "jwt-decode": "^4",
+ "nprogress": "^0.2",
+ "qrcode": "^1.5",
+ "sortablejs": "^1",
+ "universal-cookie": "^7 || ^8",
+ "vue": "^3.5.0"
+ },
+ "peerDependenciesMeta": {
+ "async-validator": {
+ "optional": true
+ },
+ "axios": {
+ "optional": true
+ },
+ "change-case": {
+ "optional": true
+ },
+ "drauu": {
+ "optional": true
+ },
+ "focus-trap": {
+ "optional": true
+ },
+ "fuse.js": {
+ "optional": true
+ },
+ "idb-keyval": {
+ "optional": true
+ },
+ "jwt-decode": {
+ "optional": true
+ },
+ "nprogress": {
+ "optional": true
+ },
+ "qrcode": {
+ "optional": true
+ },
+ "sortablejs": {
+ "optional": true
+ },
+ "universal-cookie": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vueuse/metadata": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz",
@@ -4746,6 +4825,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5521,6 +5601,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -6485,6 +6566,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@cypress/request": "^3.0.9",
"@cypress/xvfb": "^1.2.4",
@@ -7187,7 +7269,6 @@
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
@@ -7203,7 +7284,6 @@
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"engines": {
"node": ">=0.12"
},
@@ -7235,8 +7315,7 @@
"url": "https://github.com/sponsors/fb55"
}
],
- "license": "BSD-2-Clause",
- "peer": true
+ "license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
@@ -7244,7 +7323,6 @@
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"domelementtype": "^2.3.0"
},
@@ -7270,7 +7348,6 @@
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
@@ -7457,6 +7534,7 @@
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
@@ -7676,6 +7754,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9320,7 +9399,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
@@ -9334,7 +9412,6 @@
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
- "peer": true,
"engines": {
"node": ">=0.12"
},
@@ -12789,6 +12866,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -12804,7 +12882,6 @@
"integrity": "sha512-5mMeb1TgLWoRKxZ0Xh9RZDfwUUIqRrcxO2uXO+Ezl1N5lqpCiSU5Gk6+1kZediBfBHFtPCdopr2UZ2SgUsKcgQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"htmlparser2": "^8.0.0",
"js-tokens": "^9.0.0",
@@ -12820,16 +12897,14 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/postcss-media-query-parser": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
"integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/postcss-resolve-nested-selector": {
"version": "0.1.6",
@@ -12844,7 +12919,6 @@
"integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12.0"
},
@@ -12876,7 +12950,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12.0"
},
@@ -13676,6 +13749,7 @@
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -13880,6 +13954,7 @@
"integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -14296,6 +14371,13 @@
"node": ">=20"
}
},
+ "node_modules/sortablejs": {
+ "version": "1.15.6",
+ "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz",
+ "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -14832,6 +14914,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
@@ -14886,7 +14969,6 @@
"integrity": "sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": "^12 || >=14"
},
@@ -14914,7 +14996,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.12.0"
},
@@ -14976,7 +15057,6 @@
"integrity": "sha512-UJUfBFIvXfly8WKIgmqfmkGKPilKB4L5j38JfsDd+OCg2GBdU0vGUV08Uw82tsRZzd4TbsUURVVNGeOhJVF7pA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"css-tree": "^3.0.1",
"is-plain-object": "^5.0.0",
@@ -14999,16 +15079,14 @@
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.36.0.tgz",
"integrity": "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/stylelint-scss/node_modules/mdn-data": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.24.0.tgz",
"integrity": "sha512-i97fklrJl03tL1tdRVw0ZfLLvuDsdb6wxL+TrJ+PKkCbLrp2PCu2+OYdCKychIUm19nSM/35S6qz7pJpnXttoA==",
"dev": true,
- "license": "CC0-1.0",
- "peer": true
+ "license": "CC0-1.0"
},
"node_modules/stylelint-scss/node_modules/postcss-selector-parser": {
"version": "7.1.0",
@@ -15016,7 +15094,6 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -15133,6 +15210,7 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -15780,6 +15858,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16147,6 +16226,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16760,6 +16840,7 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -16851,6 +16932,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
diff --git a/package.json b/package.json
index c9e43a0f5fd..3f13d2440e3 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"extends @nextcloud/browserslist-config"
],
"dependencies": {
+ "@mdi/js": "^7.4.47",
"@mdi/svg": "^7.4.47",
"@nextcloud/auth": "^2.5.3",
"@nextcloud/axios": "^2.5.2",
@@ -57,8 +58,10 @@
"@nextcloud/sharing": "^0.3.0",
"@nextcloud/vue": "^9.3.1",
"@vueuse/core": "^14.1.0",
+ "@vueuse/integrations": "^14.1.0",
"debounce": "^3.0.0",
"pinia": "^3.0.4",
+ "sortablejs": "^1.15.6",
"vue": "^3.5.26",
"vuex": "^4.1.0",
"webdav": "^5.8.0"