From 5e7f45ace6877b831539d8d3bbfc2eaababab436 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Thu, 23 Apr 2026 02:58:15 +0200 Subject: [PATCH] refactor(appstore): migrate sidebar to Vue 3 and Typescript Signed-off-by: Ferdinand Thiessen --- .../appstore/lib/Controller/ApiController.php | 3 +- apps/appstore/src/actions/actionDisable.ts | 24 + apps/appstore/src/actions/actionEnable.ts | 27 + .../appstore/src/actions/actionForceEnable.ts | 28 + apps/appstore/src/actions/actionInstall.ts | 34 ++ .../src/actions/actionInstallForced.ts | 35 ++ apps/appstore/src/actions/actionInteract.ts | 65 +++ .../src/actions/actionLimitToGroup.ts | 27 + apps/appstore/src/actions/actionRemove.ts | 26 + apps/appstore/src/actions/actionUpdate.ts | 38 ++ apps/appstore/src/actions/index.ts | 54 ++ apps/appstore/src/apps.d.ts | 84 ++- apps/appstore/src/components/AppActions.vue | 103 ++++ .../AppStoreSidebar/AppDetailsTab.vue | 519 ------------------ .../AppStoreSidebar/AppReleasesTab.vue | 57 -- .../src/components/AppTable/AppTableRow.vue | 23 +- .../AppDeployDaemonTab.vue | 56 +- .../AppDeployOptionsModal.vue | 0 .../AppDescriptionTab.vue | 36 +- .../AppstoreSidebar/AppDetailsTab.vue | 267 +++++++++ .../AppstoreSidebar/AppReleasesTab.vue | 72 +++ .../src/components/LimitToGroupDialog.vue | 104 ++++ apps/appstore/src/composables/useActions.ts | 112 +--- apps/appstore/src/composables/useMarkdown.ts | 2 +- apps/appstore/src/main.ts | 1 + apps/appstore/src/mixins/AppManagement.js | 271 --------- apps/appstore/src/router/routes.ts | 6 +- apps/appstore/src/service/api.ts | 5 +- apps/appstore/src/service/exAppApi.ts | 12 +- apps/appstore/src/store/apps.ts | 36 +- apps/appstore/src/store/exApps.ts | 121 ++-- apps/appstore/src/store/groups.ts | 63 +++ apps/appstore/src/utils/appStatus.ts | 47 +- apps/appstore/src/views/AppStoreSidebar.vue | 162 ------ apps/appstore/src/views/AppstoreSidebar.vue | 137 +++++ lib/private/legacy/OC_App.php | 1 + 36 files changed, 1401 insertions(+), 1257 deletions(-) create mode 100644 apps/appstore/src/actions/actionDisable.ts create mode 100644 apps/appstore/src/actions/actionEnable.ts create mode 100644 apps/appstore/src/actions/actionForceEnable.ts create mode 100644 apps/appstore/src/actions/actionInstall.ts create mode 100644 apps/appstore/src/actions/actionInstallForced.ts create mode 100644 apps/appstore/src/actions/actionInteract.ts create mode 100644 apps/appstore/src/actions/actionLimitToGroup.ts create mode 100644 apps/appstore/src/actions/actionRemove.ts create mode 100644 apps/appstore/src/actions/actionUpdate.ts create mode 100644 apps/appstore/src/actions/index.ts create mode 100644 apps/appstore/src/components/AppActions.vue delete mode 100644 apps/appstore/src/components/AppStoreSidebar/AppDetailsTab.vue delete mode 100644 apps/appstore/src/components/AppStoreSidebar/AppReleasesTab.vue rename apps/appstore/src/components/{AppStoreSidebar => AppstoreSidebar}/AppDeployDaemonTab.vue (85%) rename apps/appstore/src/components/{AppStoreSidebar => AppstoreSidebar}/AppDeployOptionsModal.vue (100%) rename apps/appstore/src/components/{AppStoreSidebar => AppstoreSidebar}/AppDescriptionTab.vue (70%) create mode 100644 apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue create mode 100644 apps/appstore/src/components/AppstoreSidebar/AppReleasesTab.vue create mode 100644 apps/appstore/src/components/LimitToGroupDialog.vue delete mode 100644 apps/appstore/src/mixins/AppManagement.js create mode 100644 apps/appstore/src/store/groups.ts delete mode 100644 apps/appstore/src/views/AppStoreSidebar.vue create mode 100644 apps/appstore/src/views/AppstoreSidebar.vue diff --git a/apps/appstore/lib/Controller/ApiController.php b/apps/appstore/lib/Controller/ApiController.php index bf719b5df46..f6e7156ff2f 100644 --- a/apps/appstore/lib/Controller/ApiController.php +++ b/apps/appstore/lib/Controller/ApiController.php @@ -477,9 +477,8 @@ class ApiController extends OCSController { 'missingMaxNextcloudVersion' => false, 'missingMinNextcloudVersion' => false, 'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '', - 'score' => $app['ratingOverall'], + 'ratingOverall' => $app['ratingOverall'], 'ratingNumOverall' => $app['ratingNumOverall'], - 'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5, 'removable' => $existsLocally, 'active' => $this->appManager->isEnabledForUser($app['id']), 'needsDownload' => !$existsLocally, diff --git a/apps/appstore/src/actions/actionDisable.ts b/apps/appstore/src/actions/actionDisable.ts new file mode 100644 index 00000000000..f476897035b --- /dev/null +++ b/apps/appstore/src/actions/actionDisable.ts @@ -0,0 +1,24 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiClose } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { useAppsStore } from '../store/apps.ts' +import { canDisable } from '../utils/appStatus.ts' + +export const actionDisable: AppAction = { + id: 'disable', + icon: mdiClose, + order: 10, + enabled: canDisable, + label: () => t('appstore', 'Disable'), + async callback(app: IAppstoreApp | IAppstoreExApp) { + const store = useAppsStore() + await store.disableApp(app.id) + }, +} diff --git a/apps/appstore/src/actions/actionEnable.ts b/apps/appstore/src/actions/actionEnable.ts new file mode 100644 index 00000000000..f5ae16aadf6 --- /dev/null +++ b/apps/appstore/src/actions/actionEnable.ts @@ -0,0 +1,27 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiCheck } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { useAppsStore } from '../store/apps.ts' +import { canEnable, canInstall } from '../utils/appStatus.ts' + +export const actionEnable: AppAction = { + id: 'enable', + icon: mdiCheck, + order: 1, + variant: 'primary', + enabled(app: IAppstoreApp | IAppstoreExApp) { + return !canInstall(app) && canEnable(app) + }, + label: () => t('appstore', 'Enable'), + async callback(app: IAppstoreApp | IAppstoreExApp) { + const store = useAppsStore() + await store.enableApp(app.id) + }, +} diff --git a/apps/appstore/src/actions/actionForceEnable.ts b/apps/appstore/src/actions/actionForceEnable.ts new file mode 100644 index 00000000000..7b8f08768ee --- /dev/null +++ b/apps/appstore/src/actions/actionForceEnable.ts @@ -0,0 +1,28 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiAlertCircleCheckOutline } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { useAppsStore } from '../store/apps.ts' +import { canForceEnable, canInstall, needForceEnable } from '../utils/appStatus.ts' + +export const actionForceEnable: AppAction = { + id: 'force-enable', + icon: mdiAlertCircleCheckOutline, + order: 3, + inline: false, + variant: 'warning', + label: () => t('appstore', 'Force enable'), + enabled(app: IAppstoreApp | IAppstoreExApp) { + return !canInstall(app) && canForceEnable(app) && needForceEnable(app) + }, + async callback(app: IAppstoreApp | IAppstoreExApp) { + const store = useAppsStore() + await store.forceEnableApp(app.id) + }, +} diff --git a/apps/appstore/src/actions/actionInstall.ts b/apps/appstore/src/actions/actionInstall.ts new file mode 100644 index 00000000000..4a40bfce018 --- /dev/null +++ b/apps/appstore/src/actions/actionInstall.ts @@ -0,0 +1,34 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiDownload } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { useAppsStore } from '../store/apps.ts' +import { canInstall, needForceEnable } from '../utils/appStatus.ts' + +export const actionInstall: AppAction = { + id: 'install', + icon: mdiDownload, + order: 5, + enabled(app) { + return canInstall(app) && !needForceEnable(app) + }, + label: (app: IAppstoreApp | IAppstoreExApp) => { + if (app.app_api) { + return t('appstore', 'Deploy and enable') + } + if (app.needsDownload) { + return t('appstore', 'Download and enable') + } + return t('appstore', 'Install and enable') + }, + async callback(app: IAppstoreApp | IAppstoreExApp) { + const store = useAppsStore() + await store.enableApp(app.id) + }, +} diff --git a/apps/appstore/src/actions/actionInstallForced.ts b/apps/appstore/src/actions/actionInstallForced.ts new file mode 100644 index 00000000000..2f0bb409244 --- /dev/null +++ b/apps/appstore/src/actions/actionInstallForced.ts @@ -0,0 +1,35 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiDownload } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { useAppsStore } from '../store/apps.ts' +import { canInstall, needForceEnable } from '../utils/appStatus.ts' + +export const actionInstallForced: AppAction = { + id: 'install-forced', + icon: mdiDownload, + order: 5, + inline: false, + enabled(app) { + return canInstall(app) && needForceEnable(app) + }, + label: (app: IAppstoreApp | IAppstoreExApp) => { + if (app.app_api) { + return t('appstore', 'Deploy and force enable') + } + if (app.needsDownload) { + return t('appstore', 'Download and force enable') + } + return t('appstore', 'Install and force enable') + }, + async callback(app: IAppstoreApp | IAppstoreExApp) { + const store = useAppsStore() + await store.enableApp(app.id, true) + }, +} diff --git a/apps/appstore/src/actions/actionInteract.ts b/apps/appstore/src/actions/actionInteract.ts new file mode 100644 index 00000000000..452eadbf22a --- /dev/null +++ b/apps/appstore/src/actions/actionInteract.ts @@ -0,0 +1,65 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiBugOutline, mdiForumOutline, mdiStarOutline, mdiWeb } from '@mdi/js' +import { t } from '@nextcloud/l10n' + +export const actionsInteract: AppAction[] = [ + { + id: 'rate', + icon: mdiStarOutline, + order: 30, + inline: false, + label: () => t('appstore', 'Rate the app'), + enabled(app: IAppstoreApp | IAppstoreExApp) { + return !!app.fromAppStore + }, + href(app: IAppstoreApp | IAppstoreExApp) { + return `https://apps.nextcloud.com/apps/${encodeURIComponent(app.id)}#comments` + }, + }, + { + id: 'report-bug', + icon: mdiBugOutline, + order: 32, + inline: false, + label: () => t('appstore', 'Report a bug'), + enabled(app: IAppstoreApp | IAppstoreExApp) { + return !!app.bugs + }, + href(app: IAppstoreApp | IAppstoreExApp) { + return app.bugs! + }, + }, + { + id: 'discussion', + icon: mdiForumOutline, + order: 35, + inline: false, + label: () => t('appstore', 'Ask questions or discuss the app'), + enabled(app: IAppstoreApp | IAppstoreExApp) { + return !!app.discussion + }, + href(app: IAppstoreApp | IAppstoreExApp) { + return app.discussion! + }, + }, + { + id: 'website', + icon: mdiWeb, + order: 38, + inline: false, + label: () => t('appstore', 'Visit the website'), + enabled(app: IAppstoreApp | IAppstoreExApp) { + return !!app.website + }, + href(app: IAppstoreApp | IAppstoreExApp) { + return app.website! + }, + }, +] diff --git a/apps/appstore/src/actions/actionLimitToGroup.ts b/apps/appstore/src/actions/actionLimitToGroup.ts new file mode 100644 index 00000000000..1138cbe8768 --- /dev/null +++ b/apps/appstore/src/actions/actionLimitToGroup.ts @@ -0,0 +1,27 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiAccountGroup } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { spawnDialog } from '@nextcloud/vue' +import { defineAsyncComponent } from 'vue' +import { canLimitToGroups } from '../utils/appStatus.ts' + +const LimitToGroupDialog = defineAsyncComponent(() => import('../components/LimitToGroupDialog.vue')) + +export const actionLimitToGroup: AppAction = { + id: 'limit-to-group', + icon: mdiAccountGroup, + order: 16, + inline: false, + label: () => t('appstore', 'Limit to groups'), + enabled: canLimitToGroups, + async callback(app: IAppstoreApp | IAppstoreExApp) { + await spawnDialog(LimitToGroupDialog, { app }) + }, +} diff --git a/apps/appstore/src/actions/actionRemove.ts b/apps/appstore/src/actions/actionRemove.ts new file mode 100644 index 00000000000..0e9969eec01 --- /dev/null +++ b/apps/appstore/src/actions/actionRemove.ts @@ -0,0 +1,26 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiTrashCanOutline } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { useAppsStore } from '../store/apps.ts' +import { canUninstall } from '../utils/appStatus.ts' + +export const actionRemove: AppAction = { + id: 'remove', + order: 20, + icon: mdiTrashCanOutline, + variant: 'error', + inline: false, + enabled: canUninstall, + label: () => t('appstore', 'Remove'), + async callback(app: IAppstoreApp | IAppstoreExApp) { + const store = useAppsStore() + await store.uninstallApp(app.id) + }, +} diff --git a/apps/appstore/src/actions/actionUpdate.ts b/apps/appstore/src/actions/actionUpdate.ts new file mode 100644 index 00000000000..15596685b76 --- /dev/null +++ b/apps/appstore/src/actions/actionUpdate.ts @@ -0,0 +1,38 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' +import type { AppAction } from './index.ts' + +import { mdiUpdate } from '@mdi/js' +import { t } from '@nextcloud/l10n' +import { useExAppsStore } from '../store/exApps.ts' +import { useUpdatesStore } from '../store/updates.ts' +import { canUpdate } from '../utils/appStatus.ts' + +export const actionUpdate: AppAction = { + id: 'update', + icon: mdiUpdate, + variant: 'primary', + order: 0, + enabled(app) { + if (!canUpdate(app)) { + return false + } + if (app.app_api) { + if (app.daemon && app.daemon?.accepts_deploy_id === 'manual-install') { + return true + } + const exAppsStore = useExAppsStore() + return exAppsStore.daemonAccessible + } + return true + }, + label: (app: IAppstoreApp | IAppstoreExApp) => t('appstore', 'Update to {version}', { version: app.update! }), + async callback(app: IAppstoreApp | IAppstoreExApp) { + const store = useUpdatesStore() + await store.updateApp(app.id) + }, +} diff --git a/apps/appstore/src/actions/index.ts b/apps/appstore/src/actions/index.ts new file mode 100644 index 00000000000..03643d97924 --- /dev/null +++ b/apps/appstore/src/actions/index.ts @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + */ + +import type { RouteLocationRaw } from 'vue-router' +import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' + +import { actionDisable } from './actionDisable.ts' +import { actionEnable } from './actionEnable.ts' +import { actionForceEnable } from './actionForceEnable.ts' +import { actionInstall } from './actionInstall.ts' +import { actionInstallForced } from './actionInstallForced.ts' +import { actionsInteract } from './actionInteract.ts' +import { actionLimitToGroup } from './actionLimitToGroup.ts' +import { actionRemove } from './actionRemove.ts' +import { actionUpdate } from './actionUpdate.ts' + +interface AppActionBase { + enabled: (app: IAppstoreApp | IAppstoreExApp) => boolean + + id: string + icon: string + order: number + label: (app: IAppstoreApp | IAppstoreExApp) => string + variant?: 'primary' | 'error' | 'warning' + inline?: boolean +} + +interface AppActionWithCallback extends AppActionBase { + callback: (app: IAppstoreApp | IAppstoreExApp) => Promise +} + +interface AppActionWithHref extends AppActionBase { + href: (app: IAppstoreApp | IAppstoreExApp) => string +} + +interface AppActionWithRoute extends AppActionBase { + to: (app: IAppstoreApp | IAppstoreExApp) => RouteLocationRaw +} + +export type AppAction = AppActionWithCallback | AppActionWithHref | AppActionWithRoute + +export const actions = [ + actionUpdate, + actionEnable, + actionDisable, + actionForceEnable, + actionInstall, + actionInstallForced, + actionRemove, + actionLimitToGroup, + ...actionsInteract, +].sort((a, b) => a.order - b.order) diff --git a/apps/appstore/src/apps.d.ts b/apps/appstore/src/apps.d.ts index 348de979279..6ad395e4fe3 100644 --- a/apps/appstore/src/apps.d.ts +++ b/apps/appstore/src/apps.d.ts @@ -20,6 +20,7 @@ export interface IAppstoreCategory { export interface IAppstoreAppRelease { version: string + lastModified?: string translations: { [key: string]: { changelog: string @@ -27,50 +28,89 @@ export interface IAppstoreAppRelease { } } -export interface IAppstoreAppData extends Record { - ratingOverall: number - ratingNumOverall: number - ratingRecent: number - ratingNumRecent: number +type IAppInfoTypes = 'prelogin' | 'filesystem' | 'authentication' | 'extended_authentication' | 'logging' | 'dav' | 'prevent_group_restriction' | 'session' - releases: IAppstoreAppRelease[] -} - -export interface IAppstoreAppResponse { +/** + * The metadata that is available in the info.xml of an app. + * This is sourced by the appstore but also available for already installed apps (e.g. shipped apps). + */ +interface IAppInfoData { id: string name: string summary: string description: string + /** The license of the app */ license: string + /** The author(s) of the app (either list of names or object for XML nodes) */ author: string[] | Record + /** The support level of this app (e.g. maintained by Nextcloud GmbH) */ level: number + /** The version of the app */ version: string + /** The category(s) this app belongs to */ category: string | string[] - - icon?: string + /** The URL of the app's screenshot */ screenshot?: string + /** The types this app supports */ + types?: IAppInfoTypes[] - /** - * Groups this app is limited to. - * (only available if app is already installed) - */ - groups?: string[] + documentation?: { + admin: string + user: string + developer: string + } + website?: string + discussion?: string + bugs?: string +} - score: number - ratingNumThresholdReached: boolean +/** + * Metadata added when this app is sourced from the appstore. + * It is not available for non-appstore apps. + */ +interface IAppstoreMetadata { + fromAppStore: true + /** List of appstore release information (e.g. changelog) */ + releases: IAppstoreAppRelease[] + /** The overall rating of the app */ + ratingOverall: number + /** The number of ratings for the app */ + ratingNumOverall: number +} +export interface IAppstoreAppResponse extends IAppInfoData, Partial { + /** The app icon to use */ + icon?: string + + // App dependency information + dependencies: unknown + missingMaxNextcloudVersion: boolean + missingMinNextcloudVersion: boolean + + // App state information + + /** Whether the app is an ExApp (docker based app) */ app_api: false - active: boolean + /** Whether the app is internal = always enabled an cannot be disabled */ internal: boolean + /** Whether the app is shipped / bundled with Nextcloud (not from appstore) */ + shipped: boolean + /** Whether the app is currently active (enabled) */ + active: boolean + /** Whether the app can be removed */ removable: boolean + /** Whether the app is installed */ installed: boolean + /** If all dependencies are met */ isCompatible: boolean + /** Whether the app needs to be downloaded (not locally available) */ needsDownload: boolean + /** List of missing dependencies */ missingDependencies?: string[] + /** Available update version */ update?: string - - appstoreData?: IAppstoreAppData - releases?: IAppstoreAppRelease[] + /** User groups this app is limited to */ + groups?: string[] } export interface IAppstoreApp extends IAppstoreAppResponse { diff --git a/apps/appstore/src/components/AppActions.vue b/apps/appstore/src/components/AppActions.vue new file mode 100644 index 00000000000..1e1c35ea3ba --- /dev/null +++ b/apps/appstore/src/components/AppActions.vue @@ -0,0 +1,103 @@ + + + + + + + diff --git a/apps/appstore/src/components/AppStoreSidebar/AppDetailsTab.vue b/apps/appstore/src/components/AppStoreSidebar/AppDetailsTab.vue deleted file mode 100644 index 2f1669f8350..00000000000 --- a/apps/appstore/src/components/AppStoreSidebar/AppDetailsTab.vue +++ /dev/null @@ -1,519 +0,0 @@ - - - - - - - diff --git a/apps/appstore/src/components/AppStoreSidebar/AppReleasesTab.vue b/apps/appstore/src/components/AppStoreSidebar/AppReleasesTab.vue deleted file mode 100644 index 23073419177..00000000000 --- a/apps/appstore/src/components/AppStoreSidebar/AppReleasesTab.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - diff --git a/apps/appstore/src/components/AppTable/AppTableRow.vue b/apps/appstore/src/components/AppTable/AppTableRow.vue index e93ed1b800b..9e51c609858 100644 --- a/apps/appstore/src/components/AppTable/AppTableRow.vue +++ b/apps/appstore/src/components/AppTable/AppTableRow.vue @@ -4,17 +4,19 @@ --> - - - diff --git a/apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue b/apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue new file mode 100644 index 00000000000..a373e9e3b1a --- /dev/null +++ b/apps/appstore/src/components/AppstoreSidebar/AppDetailsTab.vue @@ -0,0 +1,267 @@ + + + + + + + diff --git a/apps/appstore/src/components/AppstoreSidebar/AppReleasesTab.vue b/apps/appstore/src/components/AppstoreSidebar/AppReleasesTab.vue new file mode 100644 index 00000000000..e2ea0ac4e09 --- /dev/null +++ b/apps/appstore/src/components/AppstoreSidebar/AppReleasesTab.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/apps/appstore/src/components/LimitToGroupDialog.vue b/apps/appstore/src/components/LimitToGroupDialog.vue new file mode 100644 index 00000000000..249eeb1b172 --- /dev/null +++ b/apps/appstore/src/components/LimitToGroupDialog.vue @@ -0,0 +1,104 @@ + + + + + + + diff --git a/apps/appstore/src/composables/useActions.ts b/apps/appstore/src/composables/useActions.ts index 8e1b541542a..295613e853f 100644 --- a/apps/appstore/src/composables/useActions.ts +++ b/apps/appstore/src/composables/useActions.ts @@ -6,120 +6,14 @@ import type { MaybeRefOrGetter } from 'vue' import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' -import { mdiAlertCircleCheckOutline, mdiCheck, mdiClose, mdiDownload, mdiTrashCanOutline, mdiUpdate } from '@mdi/js' -import { t } from '@nextcloud/l10n' import { computed, toValue } from 'vue' -import { useAppsStore } from '../store/apps.ts' -import { useUpdatesStore } from '../store/updates.ts' -import { canDisable, canEnable, canInstall, canUninstall, canUpdate, needForceEnable } from '../utils/appStatus.ts' - -type AppAction = { - id: string - icon: string - label: (app: IAppstoreApp | IAppstoreExApp) => string - callback: (app: IAppstoreApp | IAppstoreExApp) => Promise - variant?: 'primary' | 'error' | 'warning' - inline?: boolean -} - -const AppAction = Object.freeze({ - INSTALL: { - id: 'install', - icon: mdiDownload, - label: (app: IAppstoreApp | IAppstoreExApp) => { - if (app.app_api) { - return t('appstore', 'Deploy and enable') - } - if (app.needsDownload) { - return t('appstore', 'Download and enable') - } - return t('appstore', 'Install and enable') - }, - async callback(app: IAppstoreApp | IAppstoreExApp) { - const store = useAppsStore() - await store.enableApp(app.id) - }, - } as AppAction, - ENABLE: { - id: 'enable', - icon: mdiCheck, - variant: 'primary', - label: () => t('appstore', 'Enable'), - async callback(app: IAppstoreApp | IAppstoreExApp) { - const store = useAppsStore() - await store.enableApp(app.id) - }, - } as AppAction, - FORCE_ENABLE: { - id: 'force-enable', - icon: mdiAlertCircleCheckOutline, - inline: false, - label: () => t('appstore', 'Force enable'), - variant: 'warning', - async callback(app: IAppstoreApp | IAppstoreExApp) { - const store = useAppsStore() - await store.forceEnableApp(app.id) - }, - } as AppAction, - DISABLE: { - id: 'disable', - icon: mdiClose, - label: () => t('appstore', 'Disable'), - async callback(app: IAppstoreApp | IAppstoreExApp) { - const store = useAppsStore() - await store.disableApp(app.id) - }, - } as AppAction, - REMOVE: { - id: 'remove', - icon: mdiTrashCanOutline, - variant: 'error', - inline: false, - label: () => t('appstore', 'Remove'), - async callback(app: IAppstoreApp | IAppstoreExApp) { - const store = useAppsStore() - await store.uninstallApp(app.id) - }, - } as AppAction, - UPDATE: { - id: 'update', - icon: mdiUpdate, - variant: 'primary', - label: (app: IAppstoreApp | IAppstoreExApp) => t('appstore', 'Update to {version}', { version: app.update! }), - async callback(app: IAppstoreApp | IAppstoreExApp) { - const store = useUpdatesStore() - await store.updateApp(app.id) - }, - } as AppAction, -}) +import { actions } from '../actions/index.ts' /** * Get the available actions for an app * * @param app - The app to get the actions for */ -export function useActions(app: MaybeRefOrGetter) { - return computed(() => { - const actions: typeof AppAction[keyof typeof AppAction][] = [] - if (canUpdate(toValue(app))) { - actions.push(AppAction.UPDATE) - } - - if (canDisable(toValue(app))) { - actions.push(AppAction.DISABLE) - } - - if (needForceEnable(toValue(app))) { - actions.push(AppAction.FORCE_ENABLE) - } else if (canInstall(toValue(app))) { - actions.push(AppAction.INSTALL) - } else if (canEnable(toValue(app))) { - actions.push(AppAction.ENABLE) - } - - if (canUninstall(toValue(app))) { - actions.push(AppAction.REMOVE) - } - return actions - }) +export function useActions(app: MaybeRefOrGetter) { + return computed(() => toValue(app) ? actions.filter((action) => action.enabled(toValue(app)!)) : []) } diff --git a/apps/appstore/src/composables/useMarkdown.ts b/apps/appstore/src/composables/useMarkdown.ts index 104f621f83c..bbe077d58d0 100644 --- a/apps/appstore/src/composables/useMarkdown.ts +++ b/apps/appstore/src/composables/useMarkdown.ts @@ -86,7 +86,7 @@ function markedLink({ href, title, text }: Tokens.Link) { if (title) { out += ' title="' + title + '"' } - out += '>' + text + '' + out += '>' + text.replaceAll(/(?' return out } diff --git a/apps/appstore/src/main.ts b/apps/appstore/src/main.ts index 79b66f2a90c..98455d774cc 100644 --- a/apps/appstore/src/main.ts +++ b/apps/appstore/src/main.ts @@ -12,6 +12,7 @@ import 'vite/modulepreload-polyfill' const pinia = createPinia() const app = createApp(AppstoreApp) +app.config.idPrefix = 'appstore' app.use(pinia) app.use(router) app.mount('#content') diff --git a/apps/appstore/src/mixins/AppManagement.js b/apps/appstore/src/mixins/AppManagement.js deleted file mode 100644 index a58f3c3b0e8..00000000000 --- a/apps/appstore/src/mixins/AppManagement.js +++ /dev/null @@ -1,271 +0,0 @@ -/** - * 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.ts' - -const productName = window.OC.theme.productName - -export default { - computed: { - appGroups() { - 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?.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', 'Disable') - }, - forceEnableButtonText() { - if (this.app.needsDownload) { - return t('settings', 'Allow untested app') - } - return t('settings', 'Allow untested app') - }, - enableButtonTooltip() { - if (!this.app?.app_api && 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 {productName} version.', { productName }) - + ' ' - + t('settings', '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 - }, - 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() { - 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() { - if (this.app?.app_api) { - 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: [] }) - } - }, - 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') - || app?.app_api) { - return false - } - return true - }, - addGroupLimitation(groupArray) { - if (this.app?.app_api) { - return - } - const group = groupArray.pop() - const groups = this.app.groups.concat([]).concat([group.id]) - - if (this.store && this.store.updateAppGroups) { - this.store.updateAppGroups(this.app.id, groups) - } - - this.$store.dispatch('enableApp', { appId: this.app.id, groups }) - }, - removeGroupLimitation(group) { - if (this.app?.app_api) { - return - } - const currentGroups = this.app.groups.concat([]) - const index = currentGroups.indexOf(group.id) - if (index > -1) { - currentGroups.splice(index, 1) - } - - if (this.store && this.store.updateAppGroups) { - this.store.updateAppGroups(this.app.id, currentGroups) - } - - if (currentGroups.length === 0) { - this.groupCheckedAppsData = false - } - - this.$store.dispatch('enableApp', { appId: this.app.id, groups: currentGroups }) - }, - forceEnable(appId) { - if (this.app?.app_api) { - this.appApiStore.forceEnableApp(appId) - .then(() => { rebuildNavigation() }) - .catch((error) => { showError(error) }) - } else { - this.$store.dispatch('forceEnableApp', { appId, groups: [] }) - .then(() => { 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(() => { rebuildNavigation() }) - .catch((error) => { showError(error) }) - } - }, - disable(appId) { - if (this.app?.app_api) { - this.appApiStore.disableApp(appId) - .then(() => { rebuildNavigation() }) - .catch((error) => { showError(error) }) - } else { - this.$store.dispatch('disableApp', { appId }) - .then(() => { 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) { - if (this.app?.app_api) { - this.appApiStore.enableApp(appId) - .then(() => { rebuildNavigation() }) - .catch((error) => { showError(error) }) - } else { - this.$store.dispatch('enableApp', { appId }) - .then(() => { rebuildNavigation() }) - .catch((error) => { showError(error) }) - } - }, - update(appId) { - if (this.app?.app_api) { - return this.appApiStore.updateApp(appId) - .then(() => { rebuildNavigation() }) - .catch((error) => { showError(error) }) - } else { - return 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/appstore/src/router/routes.ts b/apps/appstore/src/router/routes.ts index b1e79e1e842..e4919327a3a 100644 --- a/apps/appstore/src/router/routes.ts +++ b/apps/appstore/src/router/routes.ts @@ -11,9 +11,9 @@ import { defineAsyncComponent } from 'vue' const appstoreEnabled = loadState('appstore', 'appstoreEnabled', true) // Dynamic loading -const AppstoreDiscover = defineAsyncComponent(() => import('../views/AppstoreDiscover.vue')) -const AppstoreManage = defineAsyncComponent(() => import('../views/AppstoreManage.vue')) -const AppstoreBundles = defineAsyncComponent(() => import('../views/AppstoreBundles.vue')) +const AppstoreDiscover = () => import('../views/AppstoreDiscover.vue') +const AppstoreManage = () => import('../views/AppstoreManage.vue') +const AppstoreBundles = () => import('../views/AppstoreBundles.vue') const routes: RouteRecordRaw[] = [ { diff --git a/apps/appstore/src/service/api.ts b/apps/appstore/src/service/api.ts index 94248dbc229..89f1c3c1754 100644 --- a/apps/appstore/src/service/api.ts +++ b/apps/appstore/src/service/api.ts @@ -32,10 +32,11 @@ const queue = new PQueue({ concurrency: 1 }) * * @param appId - The app to enable * @param force - Whether to force enable the app + * @param groups - The groups to enable the app for */ -export async function enableApp(appId: string, force = false) { +export async function enableApp(appId: string, force = false, groups?: string[]) { return queue.add(async () => { - await axios.post(Url.enable, { appId, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict }) + await axios.post(Url.enable, { appId, groups, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict }) }) } diff --git a/apps/appstore/src/service/exAppApi.ts b/apps/appstore/src/service/exAppApi.ts index 033455f8123..7e542f9a6a6 100644 --- a/apps/appstore/src/service/exAppApi.ts +++ b/apps/appstore/src/service/exAppApi.ts @@ -3,7 +3,7 @@ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors */ -import type { IAppstoreExApp, IDeployDaemon, IDeployOptions } from '../apps.d.ts' +import type { IAppstoreExApp, IDeployDaemon, IDeployOptions, IExAppStatus } from '../apps.d.ts' import axios from '@nextcloud/axios' import { confirmPassword } from '@nextcloud/password-confirmation' @@ -17,6 +17,16 @@ export async function fetchApps() { return data.apps as IAppstoreExApp[] } +/** + * Get the status of an external app. + * + * @param appId - The app to fetch + */ +export async function fetchAppStatus(appId: string) { + const { data } = await axios.get(generateUrl(`/apps/app_api/apps/status/${appId}`)) + return data +} + /** * Enable an external app. * diff --git a/apps/appstore/src/store/apps.ts b/apps/appstore/src/store/apps.ts index b86b83e4f28..11fac2ba464 100644 --- a/apps/appstore/src/store/apps.ts +++ b/apps/appstore/src/store/apps.ts @@ -12,7 +12,7 @@ import { defineStore } from 'pinia' import { computed, readonly, ref } from 'vue' import * as api from '../service/api.ts' import { rebuildNavigation } from '../service/rebuild-navigation.ts' -import { canDisable, canInstall, canUninstall, needForceEnable } from '../utils/appStatus.ts' +import { canDisable, canInstall, canLimitToGroups, canUninstall, needForceEnable } from '../utils/appStatus.ts' import logger from '../utils/logger.ts' import { useExAppsStore } from './exApps.ts' @@ -132,6 +132,8 @@ export const useAppsStore = defineStore('apps', () => { await api.disableApp(appId) } app.active = false + // revert "force enable" + app.isCompatible = app.missingDependencies === undefined || app.missingDependencies.length === 0 await rebuildNavigation() } finally { app.loading = false @@ -169,15 +171,31 @@ export const useAppsStore = defineStore('apps', () => { } /** - * Update the groups of an app + * Limit access to an app to specific groups * - * @param appId - The app to update - * @param groups - The new groups + * @param appId - The app to limit access to + * @param groups - The groups which should have access */ - function updateAppGroups(appId: string, groups: string[]) { - const app = apps.value.find(({ id }) => id === appId) - if (app) { - app.groups = [...groups] + async function limitAppToGroups(appId: string, groups: string[]) { + const app = getAppById(appId) + if (!app) { + throw new Error(`App with id ${appId} not found`) + } + + if (!canLimitToGroups(app)) { + throw new Error(`App with id ${appId} cannot be limited to groups`) + } + + if (app.app_api) { + return + } + + try { + app.loading = true + await api.enableApp(appId, false, groups) + app.groups = groups + } finally { + app.loading = false } } @@ -262,6 +280,6 @@ export const useAppsStore = defineStore('apps', () => { getAppById, getAppsByCategory, getCategoryById, - updateAppGroups, + limitAppToGroups, } }) diff --git a/apps/appstore/src/store/exApps.ts b/apps/appstore/src/store/exApps.ts index ea4aa188462..0f8f7dc55f9 100644 --- a/apps/appstore/src/store/exApps.ts +++ b/apps/appstore/src/store/exApps.ts @@ -33,14 +33,30 @@ export const useExAppsStore = defineStore('external-apps', () => { */ const updateCount = ref(loadState('appstore', 'appstoreExAppUpdateCount', 0)) - const statusUpdater = ref(null) + /** + * The interval ID for the status updater + */ + let statusUpdater: number | null = null + + /** + * Whether at least one of the configured daemons is accessible. + */ const daemonAccessible = ref(loadState('appstore', 'defaultDaemonConfigAccessible', false)) + + /** + * The default daemon, used for apps that don't specify a daemon or have a daemon that is not accessible. + */ const defaultDaemon = ref(loadState('appstore', 'defaultDaemonConfig', null)) + + /** + * The list of daemons that support docker-based deployment, used to show the daemon selection when enabling an app. + */ const dockerDaemons = ref([]) - const initializingOrDeployingApps = computed(() => apps.value.filter((app) => app?.status?.action - && (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck') - && app.status.type !== '')) + const initializingOrDeployingApps = computed(() => apps.value + .filter((app) => app?.status?.action + && app.status.type !== '' + && (app?.status?.action === 'deploy' || app.status.action === 'init' || app.status.action === 'healthcheck'))) /** * Get an external app by its ID @@ -89,6 +105,8 @@ export const useExAppsStore = defineStore('external-apps', () => { } app.removable = true delete app.error + + await fetchAppStatus(appId) } finally { app.loading = false } @@ -186,8 +204,8 @@ export const useExAppsStore = defineStore('external-apps', () => { delete app.update delete app.error updateCount.value-- - // Trigger status updates - // updateAppsStatus() + + await fetchAppStatus(appId) } catch (error) { logger.error('Failed to update ex app', { appId, error }) showError(t('appstore', 'Could not update the app. Please try again later.')) @@ -218,6 +236,7 @@ export const useExAppsStore = defineStore('external-apps', () => { updateCount, defaultDaemon, dockerDaemons, + daemonAccessible, getById, disableApp, @@ -255,48 +274,54 @@ export const useExAppsStore = defineStore('external-apps', () => { } } - /* - 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 - logger.debug('initializingOrDeployingApps after setAppStatus', { initializingOrDeployingApps }) - if (initializingOrDeployingApps.length === 0) { - logger.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() - }) - }, + /** + * Get the status of an external app. + * + * @param appId - The app ID to fetch the status for + */ + async function fetchAppStatus(appId: string) { + const app = getById(appId) + if (!app) { + logger.error('[app-api-store] app not found while fetching status', { appId }) + return + } - updateAppsStatus() { - clearInterval(this.statusUpdater as number) - const initializingOrDeployingApps = this.getInitializingOrDeployingApps - if (initializingOrDeployingApps.length === 0) { - return + app.loading = true + try { + const status = await exAppApi.fetchAppStatus(appId) + app.status = status + logger.debug('[app-api-store] initializingOrDeployingApps after setAppStatus', { initializingOrDeployingApps }) + if (initializingOrDeployingApps.value.length === 0) { + logger.debug('[app-api-store] Clearing interval') + clearInterval(statusUpdater as number) + statusUpdater = null } - this.statusUpdater = setInterval(() => { - const initializingOrDeployingApps = this.getInitializingOrDeployingApps - logger.debug('initializingOrDeployingApps', { initializingOrDeployingApps }) - initializingOrDeployingApps.forEach((app) => { - this.fetchAppStatus(app.id) - }) - }, 2000) as unknown as number - }, - }, */ + if (app.status.error && initializingOrDeployingApps.value.length === 1) { + clearInterval(statusUpdater as number) + statusUpdater = null + } + } catch (e) { + updateAppsStatus() + throw e + } finally { + app.loading = false + } + } + + /** + * Update the status of all apps that are currently initializing or deploying + */ + function updateAppsStatus() { + clearInterval(statusUpdater as number) + if (initializingOrDeployingApps.value.length === 0) { + return + } + + statusUpdater = window.setInterval(() => { + logger.debug('[app-api-store] initializingOrDeployingApps', { initializingOrDeployingApps }) + for (const app of initializingOrDeployingApps.value) { + fetchAppStatus(app.id) + } + }, 2000) + } }) diff --git a/apps/appstore/src/store/groups.ts b/apps/appstore/src/store/groups.ts new file mode 100644 index 00000000000..703b9587dcc --- /dev/null +++ b/apps/appstore/src/store/groups.ts @@ -0,0 +1,63 @@ +/** + * SPDX-License-Identifier: AGPL-3.0-or-later + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + */ + +import type { OCSResponse } from '@nextcloud/typings/ocs' +import type { NcSelectUsersModel } from '@nextcloud/vue/components/NcSelectUsers' + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import logger from '../utils/logger.ts' + +export const useGroupsStore = defineStore('groups', () => { + const groups = ref(new Map()) + + /** + * Search the API for groups matching the query + * + * @param query - Query to search + */ + async function searchGroups(query: string) { + const url = generateOcsUrl('/cloud/groups/details') + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data } = await axios.get>(url, { + params: { + search: query.trim(), + limit: 10, + }, + }) + for (const group of data.ocs.data.groups) { + if (groups.value.has(group.id)) { + continue + } + + groups.value.set(group.id, { + id: group.id, + displayName: group.displayname, + isNoUser: true, + }) + } + } catch (error) { + logger.error('Failed to search groups', { error }) + } + } + + /** + * Get a group by its id + * + * @param groupId - The id of the group to retrieve + */ + function getGroupById(groupId: string) { + return groups.value.get(groupId) + } + + return { + groups: computed(() => Array.from(groups.value.values())), + searchGroups, + getGroupById, + } +}) diff --git a/apps/appstore/src/utils/appStatus.ts b/apps/appstore/src/utils/appStatus.ts index 08516109df9..85c813844f2 100644 --- a/apps/appstore/src/utils/appStatus.ts +++ b/apps/appstore/src/utils/appStatus.ts @@ -42,7 +42,7 @@ export function canUninstall(app: IAppstoreApp | IAppstoreExApp) { * @param app - The app to check */ export function canEnable(app: IAppstoreApp | IAppstoreExApp) { - return canForceEnable(app) && app.isCompatible + return !isInitializing(app) && !isDeploying(app) && canForceEnable(app) && app.isCompatible } /** @@ -69,7 +69,7 @@ export function needForceEnable(app: IAppstoreApp | IAppstoreExApp) { * @param app - The app to check */ export function canDisable(app: IAppstoreApp | IAppstoreExApp) { - return app.active && !app.internal + return !isInitializing(app) && !isDeploying(app) && app.active && !app.internal } /** @@ -80,3 +80,46 @@ export function canDisable(app: IAppstoreApp | IAppstoreExApp) { export function canUpdate(app: IAppstoreApp | IAppstoreExApp) { return app.update !== undefined } + +const restrictedTypes = ['filesystem', 'prelogin', 'authentication', 'logging', 'prevent_group_restriction'] + +/** + * Check if an app can be limited to groups + * + * @param app - The app to check if can be limited to groups + */ +export function canLimitToGroups(app: IAppstoreApp | IAppstoreExApp) { + if (!app.active && !app.installed) { + return false + } + + if (!app.active && needForceEnable(app)) { + return false + } + + if (!app.types) { + return true + } + + return app.types.every((type) => !restrictedTypes.includes(type)) +} + +/** + * Check if an app is currently being initialized. + * + * @param app - The app to check + */ +function isInitializing(app: IAppstoreApp | IAppstoreExApp) { + return app.app_api + && (app.status.action === 'init' || app.status.action === 'healthcheck') +} + +/** + * Check if an app is currently being deployed. + * + * @param app - The app to check + */ +function isDeploying(app: IAppstoreApp | IAppstoreExApp) { + return app.app_api + && app.status.action === 'deploy' +} diff --git a/apps/appstore/src/views/AppStoreSidebar.vue b/apps/appstore/src/views/AppStoreSidebar.vue deleted file mode 100644 index 4c874e36a36..00000000000 --- a/apps/appstore/src/views/AppStoreSidebar.vue +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - diff --git a/apps/appstore/src/views/AppstoreSidebar.vue b/apps/appstore/src/views/AppstoreSidebar.vue new file mode 100644 index 00000000000..ff2d28e71f6 --- /dev/null +++ b/apps/appstore/src/views/AppstoreSidebar.vue @@ -0,0 +1,137 @@ + + + + + + + diff --git a/lib/private/legacy/OC_App.php b/lib/private/legacy/OC_App.php index 4dd5673ec45..ff466558b9e 100644 --- a/lib/private/legacy/OC_App.php +++ b/lib/private/legacy/OC_App.php @@ -470,6 +470,7 @@ class OC_App { } } + $info['license'] ??= $info['licence']; $info['version'] = $appManager->getAppVersion($app); $info['license'] ??= $info['licence']; $appList[] = $info;