mirror of
https://github.com/nextcloud/server.git
synced 2026-01-13 05:34:50 +00:00
feat(files_external): implement storage table
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
committed by
nextcloud-command
parent
1ec308a9dd
commit
a05c285979
@ -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',
|
||||
|
||||
@ -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
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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'
|
||||
|
||||
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExternalStorageTable',
|
||||
|
||||
setup() {
|
||||
// Non reactive props
|
||||
return {
|
||||
isAdmin,
|
||||
<style module>
|
||||
.storageTable {
|
||||
width: 100%;
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
.storageTable td,th {
|
||||
padding-block: calc(var(--default-grid-baseline) / 2);
|
||||
padding-inline: var(--default-grid-baseline);
|
||||
}
|
||||
|
||||
.storageTable__header {
|
||||
color: var(--color-text-maxcontrast);
|
||||
min-height: var(--default-clickable-area);
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
182
apps/files_external/src/components/ExternalStorageTableRow.vue
Normal file
182
apps/files_external/src/components/ExternalStorageTableRow.vue
Normal 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>
|
||||
63
apps/files_external/src/composables/useEntities.ts
Normal file
63
apps/files_external/src/composables/useEntities.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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>>
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
getters: {
|
||||
allStorages(state) {
|
||||
return [...state.globalStorages, state.userStorages]
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async loadGlobalStorages() {
|
||||
const url = 'apps/files_external/globalstorages'
|
||||
const { data } = await axios.get<IStorage[]>(generateUrl(url))
|
||||
|
||||
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);
|
||||
|
||||
// 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'));
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
)
|
||||
|
||||
overrideStorage(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
$rows = $rows.add($tr);
|
||||
});
|
||||
initApplicableUsersMultiselect($rows.find('.applicableUsers'), this._userListLimit);
|
||||
self.$el.find('tr#addMountPoint').before($rows);
|
||||
onCompletion.resolve();
|
||||
onLoaded2.resolve();
|
||||
}
|
||||
}, */
|
||||
})
|
||||
|
||||
21
apps/files_external/src/types.d.ts
vendored
21
apps/files_external/src/types.d.ts
vendored
@ -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'
|
||||
}
|
||||
152
apps/files_external/src/types.ts
Normal file
152
apps/files_external/src/types.ts
Normal 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'
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')}`))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user