feat(files_external): implement storage table

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen
2026-01-12 12:31:18 +01:00
committed by nextcloud-command
parent 1ec308a9dd
commit a05c285979
22 changed files with 1352 additions and 1765 deletions

View File

@ -43,7 +43,6 @@ return array(
'OCA\\Files_External\\Lib\\Auth\\IUserProvided' => $baseDir . '/../lib/Lib/Auth/IUserProvided.php',
'OCA\\Files_External\\Lib\\Auth\\InvalidAuth' => $baseDir . '/../lib/Lib/Auth/InvalidAuth.php',
'OCA\\Files_External\\Lib\\Auth\\NullMechanism' => $baseDir . '/../lib/Lib/Auth/NullMechanism.php',
'OCA\\Files_External\\Lib\\Auth\\OAuth2\\OAuth2' => $baseDir . '/../lib/Lib/Auth/OAuth2/OAuth2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV2' => $baseDir . '/../lib/Lib/Auth/OpenStack/OpenStackV2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV3' => $baseDir . '/../lib/Lib/Auth/OpenStack/OpenStackV3.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\Rackspace' => $baseDir . '/../lib/Lib/Auth/OpenStack/Rackspace.php',

View File

@ -58,7 +58,6 @@ class ComposerStaticInitFiles_External
'OCA\\Files_External\\Lib\\Auth\\IUserProvided' => __DIR__ . '/..' . '/../lib/Lib/Auth/IUserProvided.php',
'OCA\\Files_External\\Lib\\Auth\\InvalidAuth' => __DIR__ . '/..' . '/../lib/Lib/Auth/InvalidAuth.php',
'OCA\\Files_External\\Lib\\Auth\\NullMechanism' => __DIR__ . '/..' . '/../lib/Lib/Auth/NullMechanism.php',
'OCA\\Files_External\\Lib\\Auth\\OAuth2\\OAuth2' => __DIR__ . '/..' . '/../lib/Lib/Auth/OAuth2/OAuth2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV2' => __DIR__ . '/..' . '/../lib/Lib/Auth/OpenStack/OpenStackV2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV3' => __DIR__ . '/..' . '/../lib/Lib/Auth/OpenStack/OpenStackV3.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\Rackspace' => __DIR__ . '/..' . '/../lib/Lib/Auth/OpenStack/Rackspace.php',

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +0,0 @@
<div class="popovermenu open">
<ul>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsEncrypt" class="checkbox" name="encrypt" type="checkbox" value="true" checked="checked"/>
<label for="mountOptionsEncrypt">{{mountOptionsEncryptLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsPreviews" class="checkbox" name="previews" type="checkbox" value="true" checked="checked"/>
<label for="mountOptionsPreviews">{{mountOptionsPreviewsLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsSharing" class="checkbox" name="enable_sharing" type="checkbox" value="true"/>
<label for="mountOptionsSharing">{{mountOptionsSharingLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem icon-search">
<label for="mountOptionsFilesystemCheck">{{mountOptionsFilesystemCheckLabel}}</label>
<select id="mountOptionsFilesystemCheck" name="filesystem_check_changes" data-type="int">
<option value="0">{{mountOptionsFilesystemCheckOnce}}</option>
<option value="1" selected="selected">{{mountOptionsFilesystemCheckDA}}</option>
</select>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsEncoding" class="checkbox" name="encoding_compatibility" type="checkbox" value="true"/>
<label for="mountOptionsEncoding">{{mountOptionsEncodingLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsReadOnly" class="checkbox" name="readonly" type="checkbox" value="true"/>
<label for="mountOptionsReadOnly">{{mountOptionsReadOnlyLabel}}</label>
</span>
</li>
<li class="optionRow persistent">
<a href="#" class="menuitem remove icon-delete">
<span>{{deleteLabel}}</span>
</a>
</li>
</ul>
</div>

View File

@ -0,0 +1,149 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script lang="ts">
import { loadState } from '@nextcloud/initial-state'
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
const allowedBackendIds = loadState<string[]>('files_external', 'allowedBackends')
const backends = loadState<IBackend[]>('files_external', 'backends')
.filter((b) => allowedBackendIds.includes(b.identifier))
const allAuthMechanisms = loadState<IAuthMechanism[]>('files_external', 'authMechanisms')
</script>
<script setup lang="ts">
import type { IAuthMechanism, IBackend, IStorage } from '../../types.ts'
import { t } from '@nextcloud/l10n'
import { computed, ref, toRaw, watch, watchEffect } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import ApplicableEntities from './ApplicableEntities.vue'
import AuthMechanismConfiguration from './AuthMechanismConfiguration.vue'
import BackendConfiguration from './BackendConfiguration.vue'
import MountOptions from './MountOptions.vue'
const open = defineModel<boolean>('open', { default: true })
const {
storage = { backendOptions: {}, mountOptions: {}, type: isAdmin ? 'system' : 'personal' },
} = defineProps<{
storage?: Partial<IStorage> & { backendOptions: IStorage['backendOptions'] }
}>()
defineEmits<{
close: [storage?: Partial<IStorage>]
}>()
const internalStorage = ref(structuredClone(toRaw(storage)))
watchEffect(() => {
if (open.value) {
internalStorage.value = structuredClone(toRaw(storage))
}
})
const backend = computed({
get() {
return backends.find((b) => b.identifier === internalStorage.value.backend)
},
set(value?: IBackend) {
internalStorage.value.backend = value?.identifier
},
})
const authMechanisms = computed(() => allAuthMechanisms
.filter(({ scheme }) => backend.value?.authSchemes[scheme]))
const authMechanism = computed({
get() {
return authMechanisms.value.find((a) => a.identifier === internalStorage.value.authMechanism)
},
set(value?: IAuthMechanism) {
internalStorage.value.authMechanism = value?.identifier
},
})
// auto set the auth mechanism if there's only one available
watch(authMechanisms, () => {
if (authMechanisms.value.length === 1) {
internalStorage.value.authMechanism = authMechanisms.value[0]!.identifier
}
})
</script>
<template>
<NcDialog
v-model:open="open"
is-form
:content-classes="$style.externalStorageDialog"
:name="internalStorage.id ? t('files_external', 'Edit storage') : t('files_external', 'Add storage')"
@submit="$emit('close', internalStorage)"
@update:open="$event || $emit('close')">
<NcTextField
v-model="internalStorage.mountPoint"
:label="t('files_external', 'Folder name')"
required />
<MountOptions v-model="internalStorage.mountOptions" />
<ApplicableEntities
v-if="isAdmin"
v-model:groups="internalStorage.applicableGroups"
v-model:users="internalStorage.applicableUsers" />
<NcSelect
v-model="backend"
:options="backends"
:disabled="!!(internalStorage.id && internalStorage.backend)"
:input-label="t('files_external', 'External storage')"
label="name"
required />
<NcSelect
v-model="authMechanism"
:options="authMechanisms"
:disabled="!internalStorage.backend || authMechanisms.length <= 1 || !!(internalStorage.id && internalStorage.authMechanism)"
:input-label="t('files_external', 'Authentication')"
label="name"
required />
<BackendConfiguration
v-if="backend"
v-model="internalStorage.backendOptions"
:class="$style.externalStorageDialog__configuration"
:configuration="backend.configuration" />
<AuthMechanismConfiguration
v-if="authMechanism"
v-model="internalStorage.backendOptions"
:class="$style.externalStorageDialog__configuration"
:auth-mechanism="authMechanism" />
<template #actions>
<NcButton v-if="storage.id" @click="$emit('close')">
{{ t('files_external', 'Cancel') }}
</NcButton>
<NcButton variant="primary" type="submit">
{{ storage.id ? t('files_external', 'Edit') : t('files_external', 'Create') }}
</NcButton>
</template>
</NcDialog>
</template>
<style module>
.externalStorageDialog {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
min-height: calc(14 * var(--default-clickable-area)) !important;
}
.externalStorageDialog__configuration {
margin-block: 0.5rem;
}
</style>

View File

@ -0,0 +1,67 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import NcSelectUsers from '@nextcloud/vue/components/NcSelectUsers'
import { mapGroupToUserData, useGroups, useUsers } from '../../composables/useEntities.ts'
type IUserData = InstanceType<typeof NcSelectUsers>['$props']['options'][number]
const groups = defineModel<string[]>('groups', { default: () => [] })
const users = defineModel<string[]>('users', { default: () => [] })
const entities = ref<IUserData[]>([])
const selectedUsers = useUsers(users)
const selectedGroups = useGroups(groups)
const model = computed({
get() {
return [...selectedGroups.value, ...selectedUsers.value]
},
set(value: IUserData[]) {
users.value = value.filter((u) => u.user).map((u) => u.user!)
groups.value = value.filter((g) => g.isNoUser).map((g) => g.id)
},
})
const debouncedSearch = useDebounceFn(onSearch, 500)
/**
* Handle searching for users and groups
*
* @param pattern - The pattern to search
*/
async function onSearch(pattern: string) {
const { data } = await axios.get<{ groups: Record<string, string>, users: Record<string, string> }>(
generateUrl('apps/files_external/ajax/applicable'),
{ params: { pattern, limit: 20 } },
)
const newEntries = [
...entities.value.map((e) => [e.id, e]),
...Object.entries(data.groups)
.map(([id, displayName]) => [id, { ...mapGroupToUserData(id), displayName }]),
...Object.entries(data.users)
.map(([id, displayName]) => [`user:${id}`, { id: `user:${id}`, user: id, displayName }]),
] as [string, IUserData][]
entities.value = [...new Map(newEntries).values()]
}
</script>
<template>
<NcSelectUsers
v-model="model"
keep-open
multiple
:options="entities"
:input-label="t('files_external', 'Restrict to')"
@search="debouncedSearch" />
</template>

View File

@ -0,0 +1,111 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAuthMechanism } from '../../types.ts'
import { t } from '@nextcloud/l10n'
import { NcLoadingIcon } from '@nextcloud/vue'
import { computed, ref, watch, watchEffect } from 'vue'
import ConfigurationEntry from './ConfigurationEntry.vue'
import { ConfigurationFlag, ConfigurationType } from '../../types.ts'
const modelValue = defineModel<Record<string, string | boolean>>({ required: true })
const props = defineProps<{
authMechanism: IAuthMechanism
}>()
const configuration = computed(() => {
if (!props.authMechanism.configuration) {
return undefined
}
const entries = Object.entries(props.authMechanism.configuration)
.filter(([, option]) => !(option.flags & ConfigurationFlag.UserProvided))
return Object.fromEntries(entries) as typeof props.authMechanism.configuration
})
const customComponent = computed(() => window.OCA.FilesExternal.AuthMechanism!.getHandler(props.authMechanism))
const hasConfiguration = computed(() => {
if (!configuration.value) {
return false
}
for (const option of Object.values(configuration.value)) {
if ((option.flags & ConfigurationFlag.Hidden) || (option.flags & ConfigurationFlag.UserProvided)) {
continue
}
// a real config option
return true
}
return false
})
const isLoadingCustomComponent = ref(false)
watchEffect(async () => {
if (customComponent.value) {
isLoadingCustomComponent.value = true
await window.customElements.whenDefined(customComponent.value.tagName)
isLoadingCustomComponent.value = false
}
})
watch(configuration, () => {
for (const key in configuration.value) {
if (!(key in modelValue.value)) {
modelValue.value[key] = configuration.value[key]?.type === ConfigurationType.Boolean
? false
: ''
}
}
})
/**
* Update the model value when the custom component emits an update event.
*
* @param event - The custom event
*/
function onUpdateModelValue(event: CustomEvent) {
const config = [event.detail].flat()[0]
modelValue.value = { ...modelValue.value, ...config }
}
</script>
<template>
<fieldset v-if="hasConfiguration" :class="$style.authMechanismConfiguration">
<legend>
{{ t('files_external', 'Authentication') }}
</legend>
<template v-if="customComponent">
<NcLoadingIcon v-if="isLoadingCustomComponent" />
<!-- eslint-disable vue/attribute-hyphenation,vue/v-on-event-hyphenation -- for custom elements the casing is fixed! -->
<component
:is="customComponent.tagName"
v-else
:modelValue.prop="modelValue"
:authMechanism.prop="authMechanism"
@update:modelValue="onUpdateModelValue" />
</template>
<template v-else>
<ConfigurationEntry
v-for="(configOption, configKey) in configuration"
v-show="!(configOption.flags & ConfigurationFlag.Hidden)"
:key="configOption.value"
v-model="modelValue[configKey]!"
:config-key
:config-option />
</template>
</fieldset>
</template>
<style module>
.authMechanismConfiguration {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
}
</style>

View File

@ -0,0 +1,53 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IConfigurationOption } from '../../types.ts'
import { t } from '@nextcloud/l10n'
import { watch } from 'vue'
import ConfigurationEntry from './ConfigurationEntry.vue'
import { ConfigurationFlag, ConfigurationType } from '../../types.ts'
const modelValue = defineModel<Record<string, string | boolean>>({ required: true })
const props = defineProps<{
configuration: Record<string, IConfigurationOption>
}>()
watch(() => props.configuration, () => {
for (const key in props.configuration) {
if (!(key in modelValue.value)) {
modelValue.value[key] = props.configuration[key]?.type === ConfigurationType.Boolean
? false
: ''
}
}
})
</script>
<template>
<fieldset :class="$style.backendConfiguration">
<legend>
{{ t('files_external', 'Storage configuration') }}
</legend>
<ConfigurationEntry
v-for="configOption, configKey in configuration"
v-show="!(configOption.flags & ConfigurationFlag.Hidden)"
:key="configOption.value"
v-model="modelValue[configKey]!"
:config-key="configKey"
:config-option="configOption" />
</fieldset>
</template>
<style module>
.backendConfiguration {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
}
</style>

View File

@ -0,0 +1,38 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IConfigurationOption } from '../../types.ts'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { ConfigurationFlag, ConfigurationType } from '../../types.ts'
const value = defineModel<string | boolean>('modelValue', { default: '' })
defineProps<{
configKey: string
configOption: IConfigurationOption
}>()
</script>
<template>
<component
:is="configOption.type === ConfigurationType.Password ? NcPasswordField : NcTextField"
v-if="configOption.type !== ConfigurationType.Boolean"
v-model="value"
:name="configKey"
:required="!(configOption.flags & ConfigurationFlag.Optional)"
:label="configOption.value"
:title="configOption.tooltip" />
<NcCheckboxRadioSwitch
v-else
v-model="value"
type="switch"
:title="configOption.tooltip">
{{ configOption.value }}
</NcCheckboxRadioSwitch>
</template>

View File

@ -0,0 +1,123 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IMountOptions } from '../../types.ts'
import { mdiChevronDown, mdiChevronRight } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { computed, ref, useId, watchEffect } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { MountOptionsCheckFilesystem } from '../../types.ts'
const mountOptions = defineModel<Partial<IMountOptions>>({ required: true })
watchEffect(() => {
if (Object.keys(mountOptions.value).length === 0) {
mountOptions.value.encrypt = true
mountOptions.value.previews = true
mountOptions.value.enable_sharing = false
mountOptions.value.filesystem_check_changes = MountOptionsCheckFilesystem.OncePerRequest
mountOptions.value.encoding_compatibility = false
mountOptions.value.readonly = false
}
})
const { hasEncryption } = loadState<{ hasEncryption: boolean }>('files_external', 'settings')
const idButton = useId()
const idFieldset = useId()
const isExpanded = ref(false)
const checkFilesystemOptions = [
{
label: t('files_external', 'Never'),
value: MountOptionsCheckFilesystem.Never,
},
{
label: t('files_external', 'Once every direct access'),
value: MountOptionsCheckFilesystem.OncePerRequest,
},
{
label: t('files_external', 'Always'),
value: MountOptionsCheckFilesystem.Always,
},
]
const checkFilesystem = computed({
get() {
return checkFilesystemOptions.find((option) => option.value === mountOptions.value.filesystem_check_changes)
},
set(value) {
mountOptions.value.filesystem_check_changes = value?.value ?? MountOptionsCheckFilesystem.OncePerRequest
},
})
</script>
<template>
<div :class="$style.mountOptions">
<NcButton
:id="idButton"
:aria-controls="idFieldset"
:aria-expanded="isExpanded"
variant="tertiary-no-background"
@click="isExpanded = !isExpanded">
<template #icon>
<NcIconSvgWrapper directional :path="isExpanded ? mdiChevronDown : mdiChevronRight" />
</template>
{{ t('files_external', 'Mount options') }}
</NcButton>
<fieldset
v-show="isExpanded"
:id="idFieldset"
:class="$style.mountOptions__fieldset"
:aria-labelledby="idButton">
<NcSelect
v-model="checkFilesystem"
:input-label="t('files_external', 'Check filesystem changes')"
:options="checkFilesystemOptions" />
<NcCheckboxRadioSwitch v-model="modelValue.readonly" type="switch">
{{ t('files_external', 'Read only') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="modelValue.previews" type="switch">
{{ t('files_external', 'Enable previews') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="modelValue.enable_sharing" type="switch">
{{ t('files_external', 'Enable sharing') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-if="hasEncryption" v-model="modelValue.encrypt" type="switch">
{{ t('files_external', 'Enable encryption') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="modelValue.encoding_compatibility" type="switch">
{{ t('files_external', 'Compatibility with Mac NFD encoding (slow)') }}
</NcCheckboxRadioSwitch>
</fieldset>
</div>
</template>
<style module>
.mountOptions {
background-color: hsl(from var(--color-primary-element-light) h s calc(l * 1.045));
border-radius: var(--border-radius-element);
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
width: 100%;
}
.mountOptions__fieldset {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
padding-inline: calc(2 * var(--default-grid-baseline)) var(--default-grid-baseline);
}
</style>

View File

@ -1,40 +1,99 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import ExternalStorageTableRow from './ExternalStorageTableRow.vue'
import { useStorages } from '../store/storages.ts'
const store = useStorages()
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
const storages = computed(() => {
if (isAdmin) {
return store.globalStorages
} else {
return [
...store.userStorages,
...store.globalStorages,
]
}
})
</script>
<template>
<!-- The ID is just for backwards compatibility -->
<table id="externalStorage">
<thead>
<table :class="$style.storageTable" :aria-label="t('files_external', 'External storages')">
<thead :class="$style.storageTable__header">
<tr>
<th>
<span class="hidden-visually" v-text="t('files_external', 'Folder name')" />
<th :class="$style.storageTable__headerStatus">
<span class="hidden-visually">
{{ t('files_external', 'Status') }}
</span>
</th>
<th :class="$style.storageTable__headerFolder">
{{ t('files_external', 'Folder name') }}
</th>
<th :class="$style.storageTable__headerBackend">
{{ t('files_external', 'External storage') }}
</th>
<th :class="$style.storageTable__headerAuthentication">
{{ t('files_external', 'Authentication') }}
</th>
<th>{{ t('files_external', 'Folder name') }}</th>
<th>{{ t('files_external', 'External storage') }}</th>
<th>{{ t('files_external', 'Authentication') }}</th>
<th>{{ t('files_external', 'Configuration') }}</th>
<th v-if="isAdmin">
{{ t('files_external', 'Available for') }}
{{ t('files_external', 'Restricted to') }}
</th>
<th :class="$style.storageTable__headerActions">
<span class="hidden-visually">
{{ t('files_external', 'Actions') }}
</span>
</th>
</tr>
</thead>
<tbody>
<ExternalStorageRow />
<ExternalStorageTableRow
v-for="storage in storages"
:key="storage.id"
:is-admin
:storage="storage" />
</tbody>
</table>
</template>
<script lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { defineComponent } from 'vue'
<style module>
.storageTable {
width: 100%;
}
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
.storageTable td,th {
padding-block: calc(var(--default-grid-baseline) / 2);
padding-inline: var(--default-grid-baseline);
}
export default defineComponent({
name: 'ExternalStorageTable',
.storageTable__header {
color: var(--color-text-maxcontrast);
min-height: var(--default-clickable-area);
}
setup() {
// Non reactive props
return {
isAdmin,
}
},
})
</script>
.storageTable__headerStatus {
width: calc(var(--default-clickable-area) + 2 * var(--default-grid-baseline));
}
.storageTable__headerFolder {
width: 25%;
}
.storageTable__headerBackend {
width: 20%;
}
.storageTable__headerFAuthentication {
width: 20%;
}
.storageTable__headerActions {
width: calc(2 * var(--default-clickable-area) + 3 * var(--default-grid-baseline));
}
</style>

View File

@ -0,0 +1,182 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IBackend, IStorage } from '../types.ts'
import { mdiAccountGroupOutline, mdiInformationOutline, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { NcChip, NcLoadingIcon, NcUserBubble, spawnDialog } from '@nextcloud/vue'
import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import AddExternalStorageDialog from './AddExternalStorageDialog/AddExternalStorageDialog.vue'
import { useUsers } from '../composables/useEntities.ts'
import { useStorages } from '../store/storages.ts'
import { StorageStatus, StorageStatusIcons, StorageStatusMessage } from '../types.ts'
const props = defineProps<{
storage: IStorage
isAdmin: boolean
}>()
const store = useStorages()
const backends = loadState<IBackend[]>('files_external', 'backends')
const backendName = computed(() => backends.find((b) => b.identifier === props.storage.backend)!.name)
const authMechanisms = loadState<IBackend[]>('files_external', 'authMechanisms')
const authMechanismName = computed(() => authMechanisms.find((a) => a.identifier === props.storage.authMechanism)!.name)
const checkingStatus = ref(false)
const status = computed(() => {
if (checkingStatus.value) {
return {
icon: 'loading',
label: t('files_external', 'Checking …'),
}
}
const status = props.storage.status ?? StorageStatus.Indeterminate
const label = props.storage.statusMessage || StorageStatusMessage[status]
const icon = StorageStatusIcons[status]
const isWarning = status === StorageStatus.NetworkError || status === StorageStatus.Timeout
const isError = !isWarning && status !== StorageStatus.Success && status !== StorageStatus.Indeterminate
return { icon, label, isWarning, isError }
})
const users = useUsers(() => props.storage.applicableUsers || [])
/**
* Handle deletion of the external storage mount point
*/
async function onDelete() {
await store.deleteStorage(props.storage)
}
/**
* Handle editing of the external storage mount point
*/
async function onEdit() {
const storage = await spawnDialog(AddExternalStorageDialog, {
storage: props.storage,
})
if (!storage) {
return
}
await store.updateStorage(storage as IStorage)
}
/**
* Reload the status of the external storage mount point
*/
async function reloadStatus() {
checkingStatus.value = true
try {
await store.reloadStorage(props.storage)
} finally {
checkingStatus.value = false
}
}
</script>
<template>
<tr :class="$style.storageTableRow">
<td>
<span class="hidden-visually">{{ status.label }}</span>
<NcButton
:aria-label="t('files_external', 'Recheck status')"
:title="status.label"
variant="tertiary-no-background"
@click="reloadStatus">
<template #icon>
<NcLoadingIcon v-if="status.icon === 'loading'" />
<NcIconSvgWrapper
v-else
:class="{
[$style.storageTableRow__status_error]: status.isError,
[$style.storageTableRow__status_warning]: status.isWarning,
}"
:path="status.icon" />
</template>
</NcButton>
</td>
<td>{{ storage.mountPoint }}</td>
<td>{{ backendName }}</td>
<td>{{ authMechanismName }}</td>
<td v-if="isAdmin">
<div :class="$style.storageTableRow__cellApplicable">
<NcChip
v-for="group of storage.applicableGroups"
:key="group"
:icon-path="mdiAccountGroupOutline"
no-close
:text="group" />
<NcUserBubble
v-for="user of users"
:key="user.user"
:display-name="user.displayName"
:size="24"
:user="user.user" />
</div>
</td>
<td>
<div v-if="isAdmin || storage.type === 'personal'" :class="$style.storageTableRow__cellActions">
<NcButton
:aria-label="t('files_external', 'Edit')"
:title="t('files_external', 'Edit')"
@click="onEdit">
<template #icon>
<NcIconSvgWrapper :path="mdiPencilOutline" />
</template>
</NcButton>
<NcButton
:aria-label="t('files_external', 'Delete')"
:title="t('files_external', 'Delete')"
variant="error"
@click="onDelete">
<template #icon>
<NcIconSvgWrapper :path="mdiTrashCanOutline" />
</template>
</NcButton>
</div>
<NcIconSvgWrapper
v-else
inline
:path="mdiInformationOutline"
:name="t('files_external', 'System provided storage')"
:title="t('files_external', 'System provided storage')" />
</td>
</tr>
</template>
<style module>
.storageTableRow__cellActions {
display: flex;
gap: var(--default-grid-baseline);
}
.storageTableRow__cellApplicable {
display: flex;
flex-wrap: wrap;
gap: var(--default-grid-baseline);
align-items: center;
max-height: calc(48px + 2 * var(--default-grid-baseline));
overflow: scroll;
}
.storageTableRow__status_warning {
color: var(--color-element-warning);
}
.storageTableRow__status_error {
color: var(--color-element-error);
}
</style>

View File

@ -0,0 +1,63 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { MaybeRefOrGetter } from 'vue'
import svgAccountGroupOutline from '@mdi/svg/svg/account-group-outline.svg?raw'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { computed, reactive, toValue, watchEffect } from 'vue'
const displayNames = reactive(new Map<string, string>())
/**
* Fetch and provide user display names for given UIDs
*
* @param uids - The user ids to fetch display names for
*/
export function useUsers(uids: MaybeRefOrGetter<string[]>) {
const users = computed(() => toValue(uids).map((uid) => ({
id: `user:${uid}`,
user: uid,
displayName: displayNames.get(uid) || uid,
})))
watchEffect(async () => {
const missingUsers = toValue(uids).filter((uid) => !displayNames.has(uid))
if (missingUsers.length > 0) {
const { data } = await axios.post(generateUrl('/displaynames'), {
users: missingUsers,
})
for (const [uid, displayName] of Object.entries(data.users)) {
displayNames.set(uid, displayName as string)
}
}
})
return users
}
/**
* Map group ids to IUserData objects
*
* @param gids - The group ids to create entities for
*/
export function useGroups(gids: MaybeRefOrGetter<string[]>) {
return computed(() => toValue(gids).map(mapGroupToUserData))
}
/**
* Map a group id to an IUserData object
*
* @param gid - The group id to map
*/
export function mapGroupToUserData(gid: string) {
return {
id: gid,
isNoUser: true,
displayName: gid,
iconSvg: svgAccountGroupOutline,
}
}

View File

@ -6,6 +6,7 @@
import type { AxiosResponse } from '@nextcloud/axios'
import type { ContentsWithRoot } from '@nextcloud/files'
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { IStorage } from '../types.ts'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
@ -15,23 +16,6 @@ import { STORAGE_STATUS } from '../utils/credentialsUtils.ts'
export const rootPath = `/files/${getCurrentUser()?.uid}`
export type StorageConfig = {
applicableUsers?: string[]
applicableGroups?: string[]
authMechanism: string
backend: string
backendOptions: Record<string, string>
can_edit: boolean
id: number
mountOptions?: Record<string, string>
mountPoint: string
priority: number
status: number
statusMessage: string
type: 'system' | 'user'
userProvided: boolean
}
/**
* https://github.com/nextcloud/server/blob/ac2bc2384efe3c15ff987b87a7432bc60d545c67/apps/files_external/lib/Controller/ApiController.php#L71-L97
*/
@ -44,7 +28,7 @@ export type MountEntry = {
permissions: number
id: number
class: string
config: StorageConfig
config: IStorage
}
/**
@ -89,11 +73,12 @@ export async function getContents(): Promise<ContentsWithRoot> {
}
/**
* Get the status of an external storage mount
*
* @param id
* @param global
* @param id - The storage ID
* @param global - Whether the storage is global or user specific
*/
export function getStatus(id: number, global = true) {
const type = global ? 'userglobalstorages' : 'userstorages'
return axios.get(generateUrl(`apps/files_external/${type}/${id}?testOnly=false`)) as Promise<AxiosResponse<StorageConfig>>
return axios.get(generateUrl(`apps/files_external/${type}/${id}?testOnly=false`)) as Promise<AxiosResponse<IStorage>>
}

View File

@ -3,8 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import FilesExternalApp from './views/FilesExternalSettings.vue'
const pinia = createPinia()
const app = createApp(FilesExternalApp)
app.config.idPrefix = 'files-external'
app.use(pinia)
app.mount('#files-external')

View File

@ -6,52 +6,147 @@
import type { IStorage } from '../types.d.ts'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation'
import { generateUrl } from '@nextcloud/router'
import { defineStore } from 'pinia'
import { ref, toRaw } from 'vue'
export const useStorages = defineStore('files_external--storages', {
state() {
return {
globalStorages: [] as IStorage[],
userStorages: [] as IStorage[],
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
export const useStorages = defineStore('files_external--storages', () => {
const globalStorages = ref<IStorage[]>([])
const userStorages = ref<IStorage[]>([])
/**
* Create a new global storage
*
* @param storage - The storage to create
*/
async function createGlobalStorage(storage: Partial<IStorage>) {
const url = generateUrl('apps/files_external/globalstorages')
const { data } = await axios.post<IStorage>(
url,
toRaw(storage),
{ confirmPassword: PwdConfirmationMode.Strict },
)
globalStorages.value.push(data)
}
/**
* Create a new global storage
*
* @param storage - The storage to create
*/
async function createUserStorage(storage: Partial<IStorage>) {
const url = generateUrl('apps/files_external/userstorages')
const { data } = await axios.post<IStorage>(
url,
toRaw(storage),
{ confirmPassword: PwdConfirmationMode.Strict },
)
userStorages.value.push(data)
}
/**
* Delete a storage
*
* @param storage - The storage to delete
*/
async function deleteStorage(storage: IStorage) {
await axios.delete(getUrl(storage), {
confirmPassword: PwdConfirmationMode.Strict,
})
if (storage.type === 'personal') {
userStorages.value = userStorages.value.filter((s) => s.id !== storage.id)
} else {
globalStorages.value = globalStorages.value.filter((s) => s.id !== storage.id)
}
},
}
getters: {
allStorages(state) {
return [...state.globalStorages, state.userStorages]
},
},
/**
* Update an existing storage
*
* @param storage - The storage to update
*/
async function updateStorage(storage: IStorage) {
const { data } = await axios.put(
getUrl(storage),
toRaw(storage),
{ confirmPassword: PwdConfirmationMode.Strict },
)
actions: {
async loadGlobalStorages() {
const url = 'apps/files_external/globalstorages'
const { data } = await axios.get<IStorage[]>(generateUrl(url))
overrideStorage(data)
}
this.globalStorages = data
},
},
/* result = Object.values(result);
var onCompletion = jQuery.Deferred();
var $rows = $();
result.forEach(function(storageParams) {
storageParams.mountPoint = (storageParams.mountPoint === '/')? '/' : storageParams.mountPoint.substr(1); // trim leading slash
var storageConfig = new self._storageConfigClass();
_.extend(storageConfig, storageParams);
var $tr = self.newStorage(storageConfig, onCompletion, true);
/**
* Reload a storage from the server
*
* @param storage - The storage to reload
*/
async function reloadStorage(storage: IStorage) {
const { data } = await axios.get(getUrl(storage))
overrideStorage(data)
}
// don't recheck config automatically when there are a large number of storages
if (result.length < 20) {
self.recheckStorageConfig($tr);
} else {
self.updateStatus($tr, StorageConfig.Status.INDETERMINATE, t('files_external', 'Automatic status checking is disabled due to the large number of configured storages, click to check status'));
}
$rows = $rows.add($tr);
});
initApplicableUsersMultiselect($rows.find('.applicableUsers'), this._userListLimit);
self.$el.find('tr#addMountPoint').before($rows);
onCompletion.resolve();
onLoaded2.resolve();
}
}, */
// initialize the store
initialize()
return {
globalStorages,
userStorages,
createGlobalStorage,
createUserStorage,
deleteStorage,
reloadStorage,
updateStorage,
}
/**
* @param type - The type of storages to load
*/
async function loadStorages(type: string) {
const url = `apps/files_external/${type}`
const { data } = await axios.get<Record<number, IStorage>>(generateUrl(url))
return Object.values(data)
}
/**
* Load the storages based on the user role
*/
async function initialize() {
addPasswordConfirmationInterceptors(axios)
if (isAdmin) {
globalStorages.value = await loadStorages('globalstorages')
} else {
userStorages.value = await loadStorages('userstorages')
globalStorages.value = await loadStorages('userglobalstorages')
}
}
/**
* @param storage - The storage to get the URL for
*/
function getUrl(storage: IStorage) {
const type = storage.type === 'personal' ? 'userstorages' : 'globalstorages'
return generateUrl(`apps/files_external/${type}/${storage.id}`)
}
/**
* Override a storage in the store
*
* @param storage - The storage save
*/
function overrideStorage(storage: IStorage) {
if (storage.type === 'personal') {
const index = userStorages.value.findIndex((s) => s.id === storage.id)
userStorages.value.splice(index, 1, storage)
} else {
const index = globalStorages.value.findIndex((s) => s.id === storage.id)
globalStorages.value.splice(index, 1, storage)
}
}
})

View File

@ -1,21 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export interface IStorage {
id?: number
mountPoint: string
backend: string
authMechanism: string
backendOptions: Record<string, unknown>
priority?: number
applicableUsers?: string[]
applicableGroups?: string[]
mountOptions?: Record<string, unknown>
status?: number
statusMessage?: string
userProvided: bool
type: 'personal' | 'system'
}

View File

@ -0,0 +1,152 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mdiCheckNetworkOutline, mdiCloseNetworkOutline, mdiHelpNetworkOutline, mdiNetworkOffOutline, mdiNetworkOutline } from '@mdi/js'
import { t } from '@nextcloud/l10n'
export const Visibility = Object.freeze({
None: 0,
Personal: 1,
Admin: 2,
Default: 3,
})
export const ConfigurationType = Object.freeze({
String: 0,
Boolean: 1,
Password: 2,
})
export const ConfigurationFlag = Object.freeze({
None: 0,
Optional: 1,
UserProvided: 2,
Hidden: 4,
})
export const StorageStatus = Object.freeze({
Success: 0,
Error: 1,
Indeterminate: 2,
IncompleteConf: 3,
Unauthorized: 4,
Timeout: 5,
NetworkError: 6,
})
export const MountOptionsCheckFilesystem = Object.freeze({
/**
* Never check the underlying filesystem for updates
*/
Never: 0,
/**
* check the underlying filesystem for updates once every request for each file
*/
OncePerRequest: 1,
/**
* Always check the underlying filesystem for updates
*/
Always: 2,
})
export const StorageStatusIcons = Object.freeze({
[StorageStatus.Success]: mdiCheckNetworkOutline,
[StorageStatus.Error]: mdiCloseNetworkOutline,
[StorageStatus.Indeterminate]: mdiNetworkOutline,
[StorageStatus.IncompleteConf]: mdiHelpNetworkOutline,
[StorageStatus.Unauthorized]: mdiCloseNetworkOutline,
[StorageStatus.Timeout]: mdiNetworkOffOutline,
[StorageStatus.NetworkError]: mdiNetworkOffOutline,
})
export const StorageStatusMessage = Object.freeze({
[StorageStatus.Success]: t('files_external', 'Connected'),
[StorageStatus.Error]: t('files_external', 'Error'),
[StorageStatus.Indeterminate]: t('files_external', 'Indeterminate'),
[StorageStatus.IncompleteConf]: t('files_external', 'Incomplete configuration'),
[StorageStatus.Unauthorized]: t('files_external', 'Unauthorized'),
[StorageStatus.Timeout]: t('files_external', 'Timeout'),
[StorageStatus.NetworkError]: t('files_external', 'Network error'),
})
export interface IConfigurationOption {
/**
* Bitmask of ConfigurationFlag
*
* @see ConfigurationFlag
*/
flags: number
/**
* Type of the configuration option
*
* @see ConfigurationType
*/
type: typeof ConfigurationType[keyof typeof ConfigurationType]
/**
* Visible name of the configuration option
*/
value: string
/**
* Optional tooltip for the configuration option
*/
tooltip?: string
}
export interface IAuthMechanism {
name: string
identifier: string
identifierAliases: string[]
scheme: string
/**
* The visibility of this auth mechanism
*
* @see Visibility
*/
visibility: number
configuration: Record<string, IConfigurationOption>
}
export interface IBackend {
name: string
identifier: string
identifierAliases: string[]
authSchemes: Record<string, boolean>
priority: number
configuration: Record<string, IConfigurationOption>
}
export interface IMountOptions {
encrypt: boolean
previews: boolean
enable_sharing: boolean
/**
* @see MountOptionsCheckFilesystem
*/
filesystem_check_changes: typeof MountOptionsCheckFilesystem[keyof typeof MountOptionsCheckFilesystem]
encoding_compatibility: boolean
readonly: boolean
}
export interface IStorage {
id?: number
mountPoint: string
backend: string
authMechanism: string
backendOptions: Record<string, string | boolean>
priority?: number
applicableUsers?: string[]
applicableGroups?: string[]
mountOptions?: Record<string, unknown>
/**
* @see StorageStatus
*/
status?: typeof StorageStatus[keyof typeof StorageStatus]
statusMessage?: string
userProvided: boolean
type: 'personal' | 'system'
}

View File

@ -44,8 +44,8 @@ const dialogButtons: InstanceType<typeof NcDialog>['buttons'] = [{
<!-- Login -->
<NcTextField
ref="login"
v-model="login"
autofocus
class="external-storage-auth__login"
data-cy-external-storage-auth-dialog-login
:label="t('files_external', 'Login')"
@ -56,7 +56,6 @@ const dialogButtons: InstanceType<typeof NcDialog>['buttons'] = [{
<!-- Password -->
<NcPasswordField
ref="password"
v-model="password"
class="external-storage-auth__password"
data-cy-external-storage-auth-dialog-password

View File

@ -3,14 +3,23 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IStorage } from '../types.ts'
import { mdiPlus } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { n, t } from '@nextcloud/l10n'
import { ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import AddExternalStorageDialog from '../components/AddExternalStorageDialog/AddExternalStorageDialog.vue'
import ExternalStorageTable from '../components/ExternalStorageTable.vue'
import UserMountSettings from '../components/UserMountSettings.vue'
import filesExternalSvg from '../../img/app-dark.svg?raw'
import { useStorages } from '../store/storages.ts'
import logger from '../utils/logger.ts'
const settings = loadState('files_external', 'settings', {
docUrl: '',
@ -21,17 +30,51 @@ const settings = loadState('files_external', 'settings', {
isAdmin: false,
})
const store = useStorages()
/** List of dependency issue messages */
const dependencyIssues = settings.dependencyIssues?.messages ?? []
/** Map of missing modules -> list of dependant backends */
const missingModules = settings.dependencyIssues?.modules ?? {}
const showDialog = ref(false)
const newStorage = ref<Partial<IStorage>>()
/**
* Add a new external storage
*
* @param storage - The storage to add
*/
async function addStorage(storage?: Partial<IStorage>) {
showDialog.value = false
if (!storage) {
return
}
try {
if (settings.isAdmin) {
await store.createGlobalStorage(storage)
} else {
await store.createUserStorage(storage)
}
newStorage.value = undefined
} catch (error) {
logger.error('Failed to add external storage', { error })
showDialog.value = true
}
}
</script>
<template>
<NcSettingsSection
:doc-url="settings.docUrl"
:name="t('files_external', 'External storage')"
:description="t('files_external', 'External storage enables you to mount external storage services and devices as secondary Nextcloud storage devices. You may also allow people to mount their own external storage services.')">
:description="
t('files_external', 'External storage enables you to mount external storage services and devices as secondary Nextcloud storage devices.')
+ (settings.isAdmin
? ' ' + t('files_external', 'You may also allow people to mount their own external storage services.')
: ''
)">
<!-- Dependency error messages -->
<NcNoteCard
v-for="message, index of dependencyIssues"
@ -62,7 +105,7 @@ const missingModules = settings.dependencyIssues?.modules ?? {}
dependants.length,
) }}
</p>
<ul class="files-external__dependant-list" :aria-label="t('files_external', 'Dependant backends')">
<ul :class="$style.externalStoragesSection__dependantList" :aria-label="t('files_external', 'Dependant backends')">
<li v-for="backend of dependants" :key="backend">
{{ backend }}
</li>
@ -70,19 +113,42 @@ const missingModules = settings.dependencyIssues?.modules ?? {}
</NcNoteCard>
<!-- For user settings if the user has no permission or for user and admin settings if no storage was configured -->
<NcEmptyContent :description="t('files_external', 'No external storage configured or you do not have the permission to configure them')">
<NcEmptyContent
v-if="false"
:description="t('files_external', 'No external storage configured or you do not have the permission to configure them')">
<template #icon>
<NcIconSvgWrapper :svg="filesExternalSvg" :size="64" />
</template>
</NcEmptyContent>
<ExternalStorageTable />
<NcButton
:class="$style.externalStoragesSection__newStorageButton"
variant="primary"
@click="showDialog = !showDialog">
<template #icon>
<NcIconSvgWrapper :path="mdiPlus" />
</template>
{{ t('files_external', 'Add external storage') }}
</NcButton>
<AddExternalStorageDialog
v-model="newStorage"
v-model:open="showDialog"
@close="addStorage" />
<UserMountSettings v-if="settings.isAdmin" />
</NcSettingsSection>
</template>
<style scoped lang="scss">
.files-external__dependant-list {
list-style: disc;
margin-inline-start: 22px;
<style module>
.externalStoragesSection__dependantList {
list-style: disc !important;
margin-inline-start: calc(var(--default-clickable-area) / 2);
}
.externalStoragesSection__newStorageButton {
margin-top: var(--default-clickable-area);
}
</style>

View File

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { handlePasswordConfirmation } from '../settings/usersUtils.ts'
describe('files_external settings', () => {
before(() => {
cy.runOccCommand('app:enable files_external')
@ -13,118 +15,142 @@ describe('files_external settings', () => {
cy.runOccCommand('files_external:list --output json')
.then((exec) => {
const list = JSON.parse(exec.stdout)
for (const entry of list) {
cy.runOccCommand('files_external:delete ' + entry)
for (const { mount_id: mountId } of list) {
cy.runOccCommand('files_external:delete ' + mountId + ' --yes')
}
})
cy.visit('/settings/admin/externalstorages')
})
it('can see the settings section', () => {
cy.findByRole('heading', { name: 'External storage' })
cy.findByRole('heading', { name: /External storage/, level: 2 })
.should('be.visible')
cy.get('table#externalStorage')
cy.findByRole('table', { name: 'External storages' })
.should('be.visible')
})
it('populates the row and creates a new empty one', () => {
selectBackend('local')
it('can see the dialog', () => {
openDialog()
cy.findByRole('dialog', { name: 'Add storage' })
.within(() => {
cy.findByRole('textbox', { name: 'Folder name' })
.should('be.visible')
getComboBox(/External storage/)
.should('be.visible')
getComboBox(/Authentication/)
.should('be.visible')
getComboBox(/Restrict to/)
.should('be.visible')
cy.findByRole('button', { name: 'Create' })
.should('be.visible')
.and('have.attr', 'type', 'submit')
})
})
it('can create storage using the dialog', () => {
openDialog()
cy.findByRole('dialog', { name: 'Add storage' })
.within(() => {
cy.findByRole('textbox', { name: 'Folder name' })
.should('be.visible')
.type('My Storage')
getComboBox(/External storage/)
.should('be.visible')
.click()
cy.root().closest('body')
.findByRole('option', { name: 'WebDAV' })
.should('be.visible')
.click()
getComboBox(/Authentication/)
.should('be.visible')
.as('authComboBox')
.click()
cy.root().closest('body')
.findByRole('option', { name: /Login and password/ })
.should('be.visible')
.click()
cy.findByRole('textbox', { name: 'Login' })
.should('be.visible')
.type('admin')
cy.get('input[type="password"]')
.should('be.visible')
.type('admin')
cy.findByRole('button', { name: 'Create' })
.should('be.visible')
.click()
cy.findByRole('textbox', { name: 'URL' })
.should('be.visible')
.and((el) => el.is(':invalid'))
.type('http://localhost/remote.php/dav/files/admin')
cy.findByRole('checkbox', { name: /Secure/ })
.uncheck({ force: true })
cy.findByRole('button', { name: 'Create' })
.should('be.visible')
.click()
})
handlePasswordConfirmation('admin')
cy.findAllByRole('dialog').should('not.exist')
// See cell now contains the backend
getTable()
.findAllByRole('row')
.first()
.find('.backend')
.should('contain.text', 'Local')
// and the backend select is available but clear
getBackendSelect()
.should('have.value', null)
// the suggested mount point name is set to the backend
.should('have.length', 1)
getTable()
.findAllByRole('row')
.first()
.find('input[name="mountPoint"]')
.should('have.value', 'Local')
})
it('does not save the storage with missing configuration', function() {
selectBackend('local')
getTable()
.findAllByRole('row').first()
.findByRole('row')
.as('storageRow')
.findByRole('cell', { name: /My Storage/ })
.should('be.visible')
.within(() => {
cy.findByRole('checkbox', { name: 'All people' })
.check()
cy.get('button[title="Save"]')
.click()
})
cy.findByRole('dialog', { name: 'Authentication required' })
cy.get('@storageRow')
.findByRole('cell', { name: /WebDAV/ })
.should('be.visible')
cy.get('@storageRow')
.findByRole('cell', { name: /Login and password/ })
.should('be.visible')
cy.get('@storageRow')
.findByRole('button', { name: /Edit/ })
.should('be.visible')
cy.get('@storageRow')
.findByRole('button', { name: /Delete/ })
.should('be.visible')
.as('deleteButton')
cy.get('@deleteButton')
.click()
handlePasswordConfirmation('admin')
getTable()
.findByRole('row')
.should('not.exist')
})
it('does not save the storage with applicable configuration', function() {
selectBackend('local')
getTable()
.findAllByRole('row').first()
.should('be.visible')
.within(() => {
cy.get('input[placeholder="Location"]')
.type('/tmp')
cy.get('button[title="Save"]')
.click()
})
cy.findByRole('dialog', { name: 'Authentication required' })
.should('not.exist')
})
it('does save the storage with needed configuration', function() {
selectBackend('local')
getTable()
.findAllByRole('row').first()
.should('be.visible')
.within(() => {
cy.findByRole('checkbox', { name: 'All people' })
.check()
cy.get('input[placeholder="Location"]')
.type('/tmp')
cy.get('button[title="Save"]')
.click()
})
cy.findByRole('dialog', { name: 'Authentication required' })
.should('be.visible')
})
})
/**
* Get the external storages table
*/
function getTable() {
return cy.get('table#externalStorage')
return cy.findByRole('table', { name: 'External storages' })
.find('tbody')
}
/**
* Get the backend select element
*/
function getBackendSelect() {
return getTable()
.findAllByRole('row')
.last()
.findByRole('combobox')
function openDialog() {
cy.findByRole('button', { name: 'Add external storage' }).click()
cy.findByRole('dialog', { name: 'Add storage' }).should('be.visible')
}
/**
* @param backend - Backend to select
*/
function selectBackend(backend: string): void {
getBackendSelect()
.select(backend)
function getComboBox(match: RegExp) {
return cy.contains('label', match)
.should('be.visible')
.then((el) => Cypress.$(`#${el.attr('for')}`))
}