Previously it was an mixin which does not really work with modern script-setup SFC.
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
--- /dev/null
+<script setup lang="ts">
+import type { IAppStoreApp } from '../../constants/AppStoreTypes'
+
+import { t } from '@nextcloud/l10n'
+import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+
+defineProps<{
+ app: IAppStoreApp
+}>()
+
+const emit = defineEmits<{
+ (e: 'close', enable: boolean): void
+}>()
+
+const buttons = [
+ { label: t('settings', 'Cancel'), callback: () => emit('close', false) },
+ { label: t('settings', 'Force enable app'), type: 'primary', callback: () => emit('close', true) },
+]
+</script>
+
+<template>
+ <NcDialog :buttons="buttons"
+ :name="t('settings', 'Force enable {app}', { app: app.name })"
+ size="normal"
+ @update:open="emit('close', false)">
+ <p>
+ {{ t('settings', 'This app is not marked as compatible with your Nextcloud version or server installation. If you continue you will still be able to install the app. Note that the app might not work as expected.') }}
+ </p>
+ <h3 id="force-enable-dialog-dependency-heading" class="force-enable-dialog__heading">
+ {{ t('settings', 'Missing dependencies') }}
+ </h3>
+ <ul aria-labelledby="force-enable-dialog-dependency-heading" class="force-enable-dialog__list">
+ <li v-for="dependency, index in app.missingDependencies" :key="index">
+ {{ dependency }}
+ </li>
+ </ul>
+ </NcDialog>
+</template>
+
+<style scoped>
+.force-enable-dialog__heading {
+ font-size: 1.3em;
+}
+.force-enable-dialog__list {
+ list-style: disc;
+ padding-inline-start: 1.25em;
+}
+</style>
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IAppStoreApp } from '../constants/AppStoreTypes'
+
+import { showError, showInfo, showSuccess, spawnDialog, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import { defineAsyncComponent } from 'vue'
+import { useAppStore } from '../store/appStore'
+import * as api from '../service/AppStoreApi'
+import logger from '../logger'
+
+const ForceEnableDialog = defineAsyncComponent(() => import ('../components/AppStore/ForceEnableDialog.vue'))
+
+/**
+ * App management functions.
+ * This provides helper functions that use the app store API to provide
+ * app management functions with user feedback (notifications).
+ */
+export function useAppManagement() {
+ const store = useAppStore()
+
+ /**
+ * Disable an app
+ * @param app The app to disable
+ */
+ async function disableApp(app: IAppStoreApp) {
+ const info = showInfo(t('settings', 'Disabling {app}', { app: app.name }), { timeout: TOAST_PERMANENT_TIMEOUT })
+
+ try {
+ if (await api.disableApp(app.id)) {
+ store.updateApp(app, { active: false })
+ showSuccess(t('settings', 'Successfully disabled {app}', { app: app.name }))
+ }
+ } catch (error) {
+ logger.error('Could not disable app', { app, error })
+ showError(t('settings', 'Failed to disable {app}', { app: app.name }))
+ } finally {
+ info.hideToast()
+ }
+ }
+
+ /**
+ * Enabling an app
+ * @param app The app to enabled
+ */
+ async function enableApp(app: IAppStoreApp) {
+ const info = showInfo(t('settings', 'Enabling {app}', { app: app.name }), { timeout: TOAST_PERMANENT_TIMEOUT })
+ const groups = app.groups?.length > 0 ? app.groups : undefined
+
+ try {
+ if (await api.enableApp(app.id, groups) === false) {
+ return
+ }
+ } catch (error) {
+ logger.error('Could not enable app', { app, error })
+ showError(t('settings', 'Failed to enable {app}', { app: app.name }))
+ } finally {
+ info.hideToast()
+ }
+
+ if (await checkServerHealth(app)) {
+ store.updateApp(app, {
+ active: true,
+ installed: true,
+ needsDownload: false,
+ })
+ showSuccess(t('settings', 'Successfully enabled {app}', { app: app.name }))
+ }
+ }
+
+ /**
+ * Force enable an app
+ * @param app The app to force enable
+ */
+ async function forceEnable(app: IAppStoreApp) {
+ const userDecision = await new Promise((resolve) => spawnDialog(ForceEnableDialog, { app }, resolve))
+ if (userDecision === false) {
+ return
+ }
+
+ const info = showInfo(t('settings', 'Force enabling {app}', { app: app.name }))
+ try {
+ await api.forceEnableApp(app.id)
+ store.updateApp(app, {
+ isForceEnabled: true,
+ })
+ } catch (error) {
+ showError(t('settings', 'Force enabling {app} failed', { app: app.name }))
+ logger.error('Error while force enabling app', { app, error })
+ return
+ } finally {
+ info.hideToast()
+ }
+ }
+
+ /**
+ * Try to update an app on the server, returns true if the update was successful.
+ * @param app The app to update
+ */
+ async function updateApp(app: IAppStoreApp) {
+ const info = showInfo(t('settings', 'Starting update of {app}', { app: app.name }))
+ try {
+ await api.updateApp(app.id)
+ } catch (error) {
+ logger.error(`Could not update ${app.name}`, { error })
+ showError(t('settings', 'Update of {app} failed', { app: app.name }))
+ return
+ } finally {
+ info.hideToast()
+ }
+
+ if (await checkServerHealth(app)) {
+ store.updateApp(app, {
+ active: true,
+ installed: true,
+ update: undefined,
+ version: app.update ?? app.version,
+ })
+ showSuccess(t('settings', 'Update of {app} was successfull', { app: app.name }))
+ }
+ }
+
+ /**
+ * Uninstall an installed app
+ * @param app The app to uninstall
+ */
+ async function uninstallApp(app: IAppStoreApp) {
+ const info = showInfo(t('settings', 'Uninstalling {app}', { app: app.name }))
+ try {
+ await api.uninstallApp(app.id)
+ store.updateApp(app, {
+ active: false,
+ installed: false,
+ isForceEnabled: false,
+ needsDownload: true,
+ // We need to restore missing dependencies that were not included as the app was force-enabled when the site was loaded
+ missingDependencies: app.isCompatible
+ ? app.missingDependencies
+ : [t('settings', 'A lower server version is required')],
+ })
+ showSuccess(t('settings', 'Successfully uninstalled {app}', { app: app.name }))
+ } catch (error) {
+ logger.debug('Uninstall of app was cancelled', { app, error })
+ showInfo(t('settings', 'Uninstallation of {app} was cancelled', { app: app.name }))
+ } finally {
+ info.hideToast()
+ }
+ }
+
+ /**
+ * Helper to check the server health status after enabling / updating an app.
+ * If the server is instable after the action the app is disabled again.
+ * @param app The app that was enabled (to disable if it causes troubles)
+ */
+ async function checkServerHealth(app: IAppStoreApp): Promise<boolean> {
+ try {
+ if (await api.checkServerHealth()) {
+ return true
+ } else {
+ showInfo(
+ t(
+ 'settings',
+ '{app} has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.',
+ { app: app.name },
+ ),
+ {
+ onClick: () => window.location.reload(),
+ close: false,
+ },
+ )
+ setTimeout(function() {
+ location.reload()
+ }, 5000)
+ }
+ return true
+ } catch (error) {
+ logger.error('Error while updating or enabling app', { app, error })
+ showError(t('settings', '{app} has been disabled because it makes the server instable.', { app: app.name }))
+ await api.disableApp(app.id)
+
+ store.updateApp(app, { active: false })
+ return false
+ }
+ }
+
+ return {
+ disableApp,
+ enableApp,
+ forceEnable,
+ uninstallApp,
+ updateApp,
+ }
+}
--- /dev/null
+import type { IAppStoreApp } from '../constants/AppStoreTypes'
+import { toValue, type MaybeRef } from '@vueuse/core'
+import { computed } from 'vue'
+
+/**
+ * Get the state of an app store app.
+ * @param app The app to query the state
+ */
+export function useAppState(app: MaybeRef<IAppStoreApp>) {
+
+ const canInstall = computed(() => {
+ const appValue = toValue(app)
+ const compatible = appValue.isCompatible || appValue.isForceEnabled
+ const noMissingDependencies = appValue.missingDependencies === undefined
+ || appValue.missingDependencies.length === 0
+ // ignore the initial missing server dependency
+ || appValue.missingDependencies.length === 1 && !appValue.isCompatible
+ return compatible && noMissingDependencies
+ })
+
+ const canUninstall = computed(() => {
+ const appData = toValue(app)
+ // app is removable and not enabled
+ return appData.installed && !appData.shipped && !appData.active
+ })
+
+ return {
+ canInstall,
+ canUninstall,
+ }
+}
+++ /dev/null
-/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { showError } from '@nextcloud/dialogs'
-import rebuildNavigation from '../service/rebuild-navigation.js'
-
-export default {
- computed: {
- appGroups() {
- return this.app.groups.map(group => { return { id: group, name: group } })
- },
- installing() {
- return this.$store.getters.loading('install')
- },
- isLoading() {
- return this.app && this.$store.getters.loading(this.app.id)
- },
- enableButtonText() {
- if (this.app.needsDownload) {
- return t('settings', 'Download and enable')
- }
- return t('settings', 'Enable')
- },
- forceEnableButtonText() {
- if (this.app.needsDownload) {
- return t('settings', 'Allow untested app')
- }
- return t('settings', 'Allow untested app')
- },
- enableButtonTooltip() {
- if (this.app.needsDownload) {
- return t('settings', 'The app will be downloaded from the App Store')
- }
- return null
- },
- forceEnableButtonTooltip() {
- const base = t('settings', 'This app is not marked as compatible with your Nextcloud version. If you continue you will still be able to install the app. Note that the app might not work as expected.')
- if (this.app.needsDownload) {
- return base + ' ' + t('settings', 'The app will be downloaded from the App Store')
- }
- return base
- },
- },
-
- data() {
- return {
- groupCheckedAppsData: false,
- }
- },
-
- mounted() {
- if (this.app && this.app.groups && this.app.groups.length > 0) {
- this.groupCheckedAppsData = true
- }
- },
-
- methods: {
- asyncFindGroup(query) {
- return this.$store.dispatch('getGroups', { search: query, limit: 5, offset: 0 })
- },
- isLimitedToGroups(app) {
- if (this.app.groups.length || this.groupCheckedAppsData) {
- return true
- }
- return false
- },
- setGroupLimit() {
- if (!this.groupCheckedAppsData) {
- this.$store.dispatch('enableApp', { appId: this.app.id, groups: [] })
- }
- },
- canLimitToGroups(app) {
- if ((app.types && app.types.includes('filesystem'))
- || app.types.includes('prelogin')
- || app.types.includes('authentication')
- || app.types.includes('logging')
- || app.types.includes('prevent_group_restriction')) {
- return false
- }
- return true
- },
- addGroupLimitation(groupArray) {
- const group = groupArray.pop()
- const groups = this.app.groups.concat([]).concat([group.id])
- this.$store.dispatch('enableApp', { appId: this.app.id, groups })
- },
- removeGroupLimitation(group) {
- const currentGroups = this.app.groups.concat([])
- const index = currentGroups.indexOf(group.id)
- if (index > -1) {
- currentGroups.splice(index, 1)
- }
- 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) })
- },
- enable(appId) {
- 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) })
- },
- remove(appId) {
- this.$store.dispatch('uninstallApp', { appId })
- .then((response) => { rebuildNavigation() })
- .catch((error) => { showError(error) })
- },
- install(appId) {
- 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) })
- },
- },
-}
--- /dev/null
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IAppStoreApp, IAppStoreCategory } from '../constants/AppStoreTypes'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import { generateUrl } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
+
+/**
+ * @module AppStoreApi
+ * This module provides API abstractions for the app store API for app management.
+ */
+
+/**
+ * Enable one app - optionally limit it to some groups
+ *
+ * @param appId The app to enable
+ * @param groups Groups to limit the apps to (optionally)
+ */
+export async function enableApp(appId: string, groups: string[] = []) {
+ return enableApps([appId], groups)
+}
+
+/**
+ * Enable apps - optionally limit their usage to some groups.
+ * @param appIds Ids of the apps to enable
+ * @param groups Groups to limit the apps to (optionally)
+ * @return `true` on success, `false` if cancelled.
+ * @throws if the network request fails
+ */
+export async function enableApps(appIds: string[], groups: string[] = []): Promise<boolean> {
+ // This route requires password confirmation
+ try {
+ await confirmPassword()
+ } catch (error) {
+ return false
+ }
+
+ await axios.post(generateUrl('settings/apps/enable'), { appIds, groups })
+ return true
+}
+
+/**
+ * Disable one app
+ *
+ * @param appId The app to disable
+ */
+export async function disableApp(appId: string) {
+ return await disableApps([appId])
+}
+
+/**
+ * Disable multiple apps at once
+ * @param appIds Ids of the apps to disable
+ * @return `true` on success, `false` if cancelled
+ * @throws on network error
+ */
+export async function disableApps(appIds: string[]): Promise<boolean> {
+ // This route requires password confirmation
+ try {
+ await confirmPassword()
+ } catch (error) {
+ return false
+ }
+
+ await axios.post(generateUrl('settings/apps/disable'), { appIds })
+ return true
+}
+
+/**
+ * Force enables an app that does not officially work with the current server version.
+ *
+ * @param appId The app id
+ */
+export async function forceEnableApp(appId: string) {
+ // This route requires password confirmation
+ await confirmPassword()
+
+ await axios.post(generateUrl('settings/apps/force'), { appId })
+}
+
+/**
+ * Check the server health after an app was enabled
+ *
+ * @return True is everything is working, false if an update is required.
+ * @throws When an error occurred - in this case the app needs to be disabled again.
+ */
+export async function checkServerHealth(): Promise<boolean> {
+ const { data } = await axios.get(generateUrl('apps/files/'))
+
+ return !Object.hasOwn(data, 'update_required')
+}
+
+/**
+ * Uninstall an installed app
+ *
+ * @param appId The app id
+ */
+export async function uninstallApp(appId: string) {
+ await confirmPassword()
+ await axios.get(generateUrl(`settings/apps/uninstall/${appId}`))
+}
+
+/**
+ * Update an installed app
+ *
+ * @param appId The app id
+ * @param silent If set to true no notifications are shown to the user
+ */
+export async function updateApp(appId: string) {
+ await confirmPassword()
+ await axios.get(generateUrl(`settings/apps/update/${appId}`))
+}
+
+/**
+ * Get all available apps
+ */
+export async function getAllApps(): Promise<IAppStoreApp[]> {
+ const { data } = await axios.get<{ apps: IAppStoreApp[] }>(generateUrl('settings/apps/list'))
+ return data.apps
+}
+
+/**
+ * Get all available categories
+ */
+export async function getCategories(): Promise<IAppStoreCategory[]> {
+ const { data } = await axios.get<IAppStoreCategory[]>(generateUrl('settings/apps/categories'))
+ return data
+}