Warn before removing File Browser and disabling local Cloud access
This commit is contained in:
@@ -167,6 +167,11 @@
|
||||
"noResults": "No apps matching \"{query}\"",
|
||||
"uninstallTitle": "Uninstall App?",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
||||
"fileBrowserWarningTitle": "Cloud needs File Browser",
|
||||
"fileBrowserWarning": "Removing File Browser stops access to your local files, photos, music and documents in Cloud. Installing Nextcloud or Immich does not replace this connection yet. Keep File Browser, or reinstall it to restore access. A different storage system needs a supported Cloud connection first.",
|
||||
"fileBrowserDeleteDataHelp": "Leave this off to keep your Cloud files for reinstalling File Browser. Turning it on also deletes its stored files and app data.",
|
||||
"keepFileBrowser": "Keep File Browser",
|
||||
"removeFileBrowser": "Remove File Browser",
|
||||
"deleteAppDataLabel": "Delete app data and reset it",
|
||||
"deleteAppDataHelp": "Check this to remove the app's data, backups, and persistent files. Leave it off to reinstall later without starting over.",
|
||||
"dismissError": "Dismiss error",
|
||||
|
||||
@@ -167,6 +167,11 @@
|
||||
"noResults": "No se encontraron aplicaciones para \"{query}\"",
|
||||
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
||||
"uninstallConfirm": "\u00bfEst\u00e1 seguro de que desea desinstalar {name}? Esto eliminar\u00e1 la aplicaci\u00f3n y detendr\u00e1 su contenedor.",
|
||||
"fileBrowserWarningTitle": "Cloud necesita File Browser",
|
||||
"fileBrowserWarning": "Eliminar File Browser impide acceder a tus archivos locales, fotos, m\u00fasica y documentos en Cloud. Instalar Nextcloud o Immich todav\u00eda no sustituye esta conexi\u00f3n. Conserva File Browser o vuelve a instalarlo para recuperar el acceso. Otro sistema de almacenamiento necesita primero una conexi\u00f3n compatible con Cloud.",
|
||||
"fileBrowserDeleteDataHelp": "Deja esta opci\u00f3n desactivada para conservar tus archivos de Cloud al reinstalar File Browser. Si la activas, tambi\u00e9n se eliminan sus archivos almacenados y los datos de la aplicaci\u00f3n.",
|
||||
"keepFileBrowser": "Conservar File Browser",
|
||||
"removeFileBrowser": "Eliminar File Browser",
|
||||
"deleteAppDataLabel": "Eliminar los datos de la aplicaci\u00f3n y restablecerla",
|
||||
"deleteAppDataHelp": "Marque esto para eliminar los datos, respaldos y archivos persistentes de la aplicaci\u00f3n. Desm\u00e1rquelo para reinstalar m\u00e1s tarde sin empezar de cero.",
|
||||
"dismissError": "Descartar error",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Cloud's local library currently uses File Browser, including legacy container IDs.
|
||||
export function isCloudStorageApp(appId: string): boolean {
|
||||
return appId === 'filebrowser' || appId === 'archy-filebrowser'
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-id="appId"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="pendingAction === 'uninstall'"
|
||||
@close="closeUninstallModal"
|
||||
|
||||
@@ -242,6 +242,7 @@
|
||||
|
||||
<AppsUninstallModal
|
||||
:show="uninstallModal.show"
|
||||
:app-id="uninstallModal.appId"
|
||||
:app-title="uninstallModal.appTitle"
|
||||
:uninstalling="actions.uninstalling.value"
|
||||
@close="closeUninstallModal"
|
||||
|
||||
@@ -125,6 +125,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import { isCloudStorageApp } from '@/utils/cloudStorageDependency'
|
||||
import { appConfirm } from '@/composables/useAppConfirm'
|
||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
@@ -326,9 +327,14 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
async function handleRemove() {
|
||||
const cloudStorageDependency = isCloudStorageApp(appId.value)
|
||||
const ok = await appConfirm({
|
||||
message: t('apps.uninstallConfirm', { name: appName.value }),
|
||||
confirmLabel: t('apps.uninstallTitle'),
|
||||
title: cloudStorageDependency ? t('apps.fileBrowserWarningTitle') : t('apps.uninstallTitle'),
|
||||
message: cloudStorageDependency
|
||||
? t('apps.fileBrowserWarning')
|
||||
: t('apps.uninstallConfirm', { name: appName.value }),
|
||||
confirmLabel: t(cloudStorageDependency ? 'apps.removeFileBrowser' : 'apps.uninstallTitle'),
|
||||
cancelLabel: t(cloudStorageDependency ? 'apps.keepFileBrowser' : 'common.cancel'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import ContainerAppDetails from '../ContainerAppDetails.vue'
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
confirm: vi.fn(), remove: vi.fn(), push: vi.fn(),
|
||||
}))
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: { id: 'filebrowser' } }),
|
||||
useRouter: () => ({ push: fixture.push }),
|
||||
}))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/composables/useAppConfirm', () => ({ appConfirm: fixture.confirm }))
|
||||
vi.mock('@/stores/container', () => ({ useContainerStore: () => ({
|
||||
getContainerStatus: async () => ({ id: 'fixture', image: 'fixture', state: 'running', created: '2026-10-08' }),
|
||||
getContainerLogs: async () => [], fetchHealthStatus: async () => {}, getHealthStatus: () => 'healthy',
|
||||
removeContainer: fixture.remove,
|
||||
}) }))
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => { wrapper?.unmount(); vi.clearAllMocks() })
|
||||
|
||||
describe('legacy File Browser removal route', () => {
|
||||
it.each([false, true])('requires the Cloud warning confirmation (%s)', async accepted => {
|
||||
fixture.confirm.mockResolvedValue(accepted)
|
||||
wrapper = mount(ContainerAppDetails, { global: { stubs: { BackButton: true, ContainerStatus: true } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === 'common.remove')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(fixture.confirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: 'apps.fileBrowserWarningTitle', message: 'apps.fileBrowserWarning',
|
||||
confirmLabel: 'apps.removeFileBrowser', cancelLabel: 'apps.keepFileBrowser', danger: true,
|
||||
}))
|
||||
expect(fixture.remove).toHaveBeenCalledTimes(accepted ? 1 : 0)
|
||||
if (accepted) expect(fixture.remove).toHaveBeenCalledWith('filebrowser')
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="uninstall-dialog-title"
|
||||
class="glass-card p-6 max-w-2xl w-full relative z-10"
|
||||
class="glass-card p-6 max-w-2xl w-full relative z-10 max-h-[90dvh] overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<div class="p-3 bg-red-500/20 rounded-lg">
|
||||
@@ -26,6 +26,10 @@
|
||||
<p class="text-white/70">
|
||||
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
||||
</p>
|
||||
<div v-if="cloudStorageDependency" role="note" class="mt-4 rounded-xl border border-amber-400/30 bg-amber-500/10 p-4">
|
||||
<p class="font-semibold text-white">{{ t('apps.fileBrowserWarningTitle') }}</p>
|
||||
<p class="mt-2 text-sm text-white/80">{{ t('apps.fileBrowserWarning') }}</p>
|
||||
</div>
|
||||
<div class="mt-4 rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
@@ -35,7 +39,7 @@
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-medium text-white">{{ t('apps.deleteAppDataLabel') }}</span>
|
||||
<span class="block text-xs text-white/60 mt-1">{{ t('apps.deleteAppDataHelp') }}</span>
|
||||
<span class="block text-xs text-white/60 mt-1">{{ t(cloudStorageDependency ? 'apps.fileBrowserDeleteDataHelp' : 'apps.deleteAppDataHelp') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -47,7 +51,7 @@
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
{{ t(cloudStorageDependency ? 'apps.keepFileBrowser' : 'common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm', deleteAppData)"
|
||||
@@ -64,7 +68,7 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
||||
<span>{{ uninstalling ? t('common.uninstalling') : t(cloudStorageDependency ? 'apps.removeFileBrowser' : 'common.uninstall') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,12 +80,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isCloudStorageApp } from '@/utils/cloudStorageDependency'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
appId: string
|
||||
appTitle: string
|
||||
uninstalling: boolean
|
||||
}>()
|
||||
@@ -91,6 +97,8 @@ const emit = defineEmits<{
|
||||
confirm: [deleteAppData: boolean]
|
||||
}>()
|
||||
|
||||
const cloudStorageDependency = computed(() => isCloudStorageApp(props.appId))
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||
const deleteAppData = ref(false)
|
||||
@@ -103,8 +111,8 @@ useModalKeyboard(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
() => [props.show, props.appId] as const,
|
||||
([show]) => {
|
||||
if (show) {
|
||||
deleteAppData.value = false
|
||||
}
|
||||
|
||||
@@ -1,37 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import AppsUninstallModal from '../AppsUninstallModal.vue'
|
||||
import en from '@/locales/en.json'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params?.name) return `${key} ${params.name}`
|
||||
return key
|
||||
const [group, name] = key.split('.')
|
||||
const messages = en as Record<string, Record<string, string>>
|
||||
const text = messages[group!]?.[name!] ?? key
|
||||
return params?.name ? text.replace('{name}', params.name) : text
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({ useModalKeyboard: vi.fn() }))
|
||||
|
||||
const mounted: VueWrapper[] = []
|
||||
function dialog(appId = 'filebrowser') {
|
||||
const wrapper = mount(AppsUninstallModal, {
|
||||
props: { show: true, appId, appTitle: 'File Browser', uninstalling: false },
|
||||
global: { stubs: { teleport: true } },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
afterEach(() => { mounted.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
|
||||
describe('AppsUninstallModal', () => {
|
||||
it('emits the delete-data choice when uninstall is confirmed', async () => {
|
||||
const wrapper = mount(AppsUninstallModal, {
|
||||
props: {
|
||||
show: true,
|
||||
appTitle: 'File Browser',
|
||||
uninstalling: false,
|
||||
},
|
||||
})
|
||||
it.each(['filebrowser', 'archy-filebrowser'])('warns for %s and preserves files by default', async appId => {
|
||||
const wrapper = dialog(appId)
|
||||
expect(wrapper.get('[role="note"]').text()).toContain('Cloud needs File Browser')
|
||||
expect(wrapper.text()).toContain('Installing Nextcloud or Immich does not replace this connection yet.')
|
||||
expect(wrapper.get<HTMLInputElement>('input[type="checkbox"]').element.checked).toBe(false)
|
||||
await wrapper.get('button.glass-button-danger').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toEqual([[false]])
|
||||
})
|
||||
|
||||
const checkbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox).not.toBeNull()
|
||||
checkbox?.click()
|
||||
const confirmButton = document.body.querySelector<HTMLButtonElement>('button.glass-button-danger')
|
||||
expect(confirmButton).not.toBeNull()
|
||||
confirmButton?.click()
|
||||
it('cancels without authorizing removal', async () => {
|
||||
const wrapper = dialog()
|
||||
const keep = wrapper.findAll('button').find(button => button.text() === 'Keep File Browser')!
|
||||
await keep.trigger('click')
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requires a fresh delete-data choice after closing or switching apps', async () => {
|
||||
const wrapper = dialog()
|
||||
await wrapper.get('input').setValue(true)
|
||||
await wrapper.get('button.glass-button-danger').trigger('click')
|
||||
expect(wrapper.emitted('confirm')?.[0]).toEqual([true])
|
||||
await wrapper.setProps({ show: false })
|
||||
await wrapper.setProps({ show: true })
|
||||
expect(wrapper.get<HTMLInputElement>('input').element.checked).toBe(false)
|
||||
await wrapper.get('input').setValue(true)
|
||||
await wrapper.setProps({ appId: 'nextcloud', appTitle: 'Nextcloud' })
|
||||
expect(wrapper.get<HTMLInputElement>('input').element.checked).toBe(false)
|
||||
expect(wrapper.find('[role="note"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('Keep File Browser')
|
||||
})
|
||||
|
||||
it('does not infer dependencies from a display name or allow repeat submission while busy', async () => {
|
||||
const wrapper = dialog('unrelated-app')
|
||||
expect(wrapper.find('[role="note"]').exists()).toBe(false)
|
||||
await wrapper.setProps({ uninstalling: true })
|
||||
await wrapper.get('button.glass-button-danger').trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user