Warn before removing File Browser and disabling local Cloud access
This commit is contained in:
@@ -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