Warn before removing File Browser and disabling local Cloud access

This commit is contained in:
archipelago
2026-10-08 05:41:35 -04:00
parent d5b73fc4e9
commit 96259f0e35
9 changed files with 128 additions and 29 deletions
+1
View File
@@ -57,6 +57,7 @@
<AppsUninstallModal
:show="uninstallModal.show"
:app-id="appId"
:app-title="uninstallModal.appTitle"
:uninstalling="pendingAction === 'uninstall'"
@close="closeUninstallModal"
+1
View File
@@ -242,6 +242,7 @@
<AppsUninstallModal
:show="uninstallModal.show"
:app-id="uninstallModal.appId"
:app-title="uninstallModal.appTitle"
:uninstalling="actions.uninstalling.value"
@close="closeUninstallModal"
+8 -2
View File
@@ -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')
})
})
+14 -6
View File
@@ -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()
})
})