Warn before removing File Browser and disabling local Cloud access
This commit is contained in:
@@ -167,6 +167,11 @@
|
|||||||
"noResults": "No apps matching \"{query}\"",
|
"noResults": "No apps matching \"{query}\"",
|
||||||
"uninstallTitle": "Uninstall App?",
|
"uninstallTitle": "Uninstall App?",
|
||||||
"uninstallConfirm": "Are you sure you want to uninstall {name}? This will remove the app and stop its container.",
|
"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",
|
"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.",
|
"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",
|
"dismissError": "Dismiss error",
|
||||||
|
|||||||
@@ -167,6 +167,11 @@
|
|||||||
"noResults": "No se encontraron aplicaciones para \"{query}\"",
|
"noResults": "No se encontraron aplicaciones para \"{query}\"",
|
||||||
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
"uninstallTitle": "\u00bfDesinstalar aplicaci\u00f3n?",
|
||||||
"uninstallConfirm": "\u00bfEst\u00e1 seguro de que desea desinstalar {name}? Esto eliminar\u00e1 la aplicaci\u00f3n y detendr\u00e1 su contenedor.",
|
"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",
|
"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.",
|
"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",
|
"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
|
<AppsUninstallModal
|
||||||
:show="uninstallModal.show"
|
:show="uninstallModal.show"
|
||||||
|
:app-id="appId"
|
||||||
:app-title="uninstallModal.appTitle"
|
:app-title="uninstallModal.appTitle"
|
||||||
:uninstalling="pendingAction === 'uninstall'"
|
:uninstalling="pendingAction === 'uninstall'"
|
||||||
@close="closeUninstallModal"
|
@close="closeUninstallModal"
|
||||||
|
|||||||
@@ -242,6 +242,7 @@
|
|||||||
|
|
||||||
<AppsUninstallModal
|
<AppsUninstallModal
|
||||||
:show="uninstallModal.show"
|
:show="uninstallModal.show"
|
||||||
|
:app-id="uninstallModal.appId"
|
||||||
:app-title="uninstallModal.appTitle"
|
:app-title="uninstallModal.appTitle"
|
||||||
:uninstalling="actions.uninstalling.value"
|
:uninstalling="actions.uninstalling.value"
|
||||||
@close="closeUninstallModal"
|
@close="closeUninstallModal"
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useContainerStore } from '@/stores/container'
|
import { useContainerStore } from '@/stores/container'
|
||||||
|
import { isCloudStorageApp } from '@/utils/cloudStorageDependency'
|
||||||
import { appConfirm } from '@/composables/useAppConfirm'
|
import { appConfirm } from '@/composables/useAppConfirm'
|
||||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||||
@@ -326,9 +327,14 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function handleRemove() {
|
async function handleRemove() {
|
||||||
|
const cloudStorageDependency = isCloudStorageApp(appId.value)
|
||||||
const ok = await appConfirm({
|
const ok = await appConfirm({
|
||||||
message: t('apps.uninstallConfirm', { name: appName.value }),
|
title: cloudStorageDependency ? t('apps.fileBrowserWarningTitle') : t('apps.uninstallTitle'),
|
||||||
confirmLabel: 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,
|
danger: true,
|
||||||
})
|
})
|
||||||
if (!ok) {
|
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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="uninstall-dialog-title"
|
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="flex items-start gap-4 mb-4">
|
||||||
<div class="p-3 bg-red-500/20 rounded-lg">
|
<div class="p-3 bg-red-500/20 rounded-lg">
|
||||||
@@ -26,6 +26,10 @@
|
|||||||
<p class="text-white/70">
|
<p class="text-white/70">
|
||||||
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
{{ t('apps.uninstallConfirm', { name: appTitle }) }}
|
||||||
</p>
|
</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">
|
<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">
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -35,7 +39,7 @@
|
|||||||
/>
|
/>
|
||||||
<span class="min-w-0">
|
<span class="min-w-0">
|
||||||
<span class="block text-sm font-medium text-white">{{ t('apps.deleteAppDataLabel') }}</span>
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +51,7 @@
|
|||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
class="px-4 py-2 glass-button rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
{{ t('common.cancel') }}
|
{{ t(cloudStorageDependency ? 'apps.keepFileBrowser' : 'common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="$emit('confirm', deleteAppData)"
|
@click="$emit('confirm', deleteAppData)"
|
||||||
@@ -64,7 +68,7 @@
|
|||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<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>
|
<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>
|
</svg>
|
||||||
<span>{{ uninstalling ? t('common.uninstalling') : t('common.uninstall') }}</span>
|
<span>{{ uninstalling ? t('common.uninstalling') : t(cloudStorageDependency ? 'apps.removeFileBrowser' : 'common.uninstall') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,12 +80,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { isCloudStorageApp } from '@/utils/cloudStorageDependency'
|
||||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
|
appId: string
|
||||||
appTitle: string
|
appTitle: string
|
||||||
uninstalling: boolean
|
uninstalling: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -91,6 +97,8 @@ const emit = defineEmits<{
|
|||||||
confirm: [deleteAppData: boolean]
|
confirm: [deleteAppData: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const cloudStorageDependency = computed(() => isCloudStorageApp(props.appId))
|
||||||
|
|
||||||
const modalRef = ref<HTMLElement | null>(null)
|
const modalRef = ref<HTMLElement | null>(null)
|
||||||
const restoreFocusRef = ref<HTMLElement | null>(null)
|
const restoreFocusRef = ref<HTMLElement | null>(null)
|
||||||
const deleteAppData = ref(false)
|
const deleteAppData = ref(false)
|
||||||
@@ -103,8 +111,8 @@ useModalKeyboard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => [props.show, props.appId] as const,
|
||||||
(show) => {
|
([show]) => {
|
||||||
if (show) {
|
if (show) {
|
||||||
deleteAppData.value = false
|
deleteAppData.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,70 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import AppsUninstallModal from '../AppsUninstallModal.vue'
|
import AppsUninstallModal from '../AppsUninstallModal.vue'
|
||||||
|
import en from '@/locales/en.json'
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
vi.mock('vue-i18n', () => ({
|
||||||
useI18n: () => ({
|
useI18n: () => ({
|
||||||
t: (key: string, params?: Record<string, string>) => {
|
t: (key: string, params?: Record<string, string>) => {
|
||||||
if (params?.name) return `${key} ${params.name}`
|
const [group, name] = key.split('.')
|
||||||
return key
|
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', () => ({
|
vi.mock('@/composables/useModalKeyboard', () => ({ useModalKeyboard: vi.fn() }))
|
||||||
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', () => {
|
describe('AppsUninstallModal', () => {
|
||||||
it('emits the delete-data choice when uninstall is confirmed', async () => {
|
it.each(['filebrowser', 'archy-filebrowser'])('warns for %s and preserves files by default', async appId => {
|
||||||
const wrapper = mount(AppsUninstallModal, {
|
const wrapper = dialog(appId)
|
||||||
props: {
|
expect(wrapper.get('[role="note"]').text()).toContain('Cloud needs File Browser')
|
||||||
show: true,
|
expect(wrapper.text()).toContain('Installing Nextcloud or Immich does not replace this connection yet.')
|
||||||
appTitle: 'File Browser',
|
expect(wrapper.get<HTMLInputElement>('input[type="checkbox"]').element.checked).toBe(false)
|
||||||
uninstalling: 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"]')
|
it('cancels without authorizing removal', async () => {
|
||||||
expect(checkbox).not.toBeNull()
|
const wrapper = dialog()
|
||||||
checkbox?.click()
|
const keep = wrapper.findAll('button').find(button => button.text() === 'Keep File Browser')!
|
||||||
const confirmButton = document.body.querySelector<HTMLButtonElement>('button.glass-button-danger')
|
await keep.trigger('click')
|
||||||
expect(confirmButton).not.toBeNull()
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
confirmButton?.click()
|
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])
|
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