refactor(federatedfilesharing): migrate to Typescript and Vue 3

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/56942/head
Ferdinand Thiessen 2025-12-10 03:50:50 +07:00
parent 3efb1d80e9
commit 14f52a2303
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
22 changed files with 373 additions and 423 deletions

@ -16,12 +16,14 @@ use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\IAppContainer;
use OCP\Federation\ICloudFederationProviderManager; use OCP\Federation\ICloudFederationProviderManager;
class Application extends App implements IBootstrap { class Application extends App implements IBootstrap {
public const APP_ID = 'federatedfilesharing';
public function __construct() { public function __construct() {
parent::__construct('federatedfilesharing'); parent::__construct(self::APP_ID);
} }
public function register(IRegistrationContext $context): void { public function register(IRegistrationContext $context): void {
@ -33,14 +35,13 @@ class Application extends App implements IBootstrap {
$context->injectFn(Closure::fromCallable([$this, 'registerCloudFederationProvider'])); $context->injectFn(Closure::fromCallable([$this, 'registerCloudFederationProvider']));
} }
private function registerCloudFederationProvider(ICloudFederationProviderManager $manager, private function registerCloudFederationProvider(ICloudFederationProviderManager $manager): void {
IAppContainer $appContainer): void {
$fileResourceTypes = ['file', 'folder']; $fileResourceTypes = ['file', 'folder'];
foreach ($fileResourceTypes as $type) { foreach ($fileResourceTypes as $type) {
$manager->addCloudFederationProvider($type, $manager->addCloudFederationProvider($type,
'Federated Files Sharing', 'Federated Files Sharing',
function () use ($appContainer): CloudFederationProviderFiles { function (): CloudFederationProviderFiles {
return $appContainer->get(CloudFederationProviderFiles::class); return \OCP\Server::get(CloudFederationProviderFiles::class);
}); });
} }
} }

@ -8,6 +8,7 @@ declare(strict_types=1);
*/ */
namespace OCA\FederatedFileSharing\Listeners; namespace OCA\FederatedFileSharing\Listeners;
use OCA\FederatedFileSharing\AppInfo\Application;
use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\FederatedShareProvider;
use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCP\App\IAppManager; use OCP\App\IAppManager;
@ -35,7 +36,8 @@ class LoadAdditionalScriptsListener implements IEventListener {
if ($this->federatedShareProvider->isIncomingServer2serverShareEnabled()) { if ($this->federatedShareProvider->isIncomingServer2serverShareEnabled()) {
$this->initialState->provideInitialState('notificationsEnabled', $this->appManager->isEnabledForUser('notifications')); $this->initialState->provideInitialState('notificationsEnabled', $this->appManager->isEnabledForUser('notifications'));
Util::addInitScript('federatedfilesharing', 'external'); Util::addStyle(Application::APP_ID, 'init-files');
Util::addInitScript(Application::APP_ID, 'init-files');
} }
} }
} }

@ -6,6 +6,7 @@
*/ */
namespace OCA\FederatedFileSharing\Settings; namespace OCA\FederatedFileSharing\Settings;
use OCA\FederatedFileSharing\AppInfo\Application;
use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\FederatedShareProvider;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState; use OCP\AppFramework\Services\IInitialState;
@ -43,7 +44,9 @@ class Admin implements IDelegatedSettings {
$this->initialState->provideInitialState('lookupServerUploadEnabled', $this->fedShareProvider->isLookupServerUploadEnabled()); $this->initialState->provideInitialState('lookupServerUploadEnabled', $this->fedShareProvider->isLookupServerUploadEnabled());
$this->initialState->provideInitialState('federatedTrustedShareAutoAccept', $this->fedShareProvider->isFederatedTrustedShareAutoAccept()); $this->initialState->provideInitialState('federatedTrustedShareAutoAccept', $this->fedShareProvider->isFederatedTrustedShareAutoAccept());
return new TemplateResponse('federatedfilesharing', 'settings-admin', [], ''); \OCP\Util::addStyle(Application::APP_ID, 'settings-admin');
\OCP\Util::addScript(Application::APP_ID, 'settings-admin');
return new TemplateResponse(Application::APP_ID, 'settings-admin', renderAs: '');
} }
/** /**

@ -8,6 +8,7 @@ declare(strict_types=1);
*/ */
namespace OCA\FederatedFileSharing\Settings; namespace OCA\FederatedFileSharing\Settings;
use OCA\FederatedFileSharing\AppInfo\Application;
use OCA\FederatedFileSharing\FederatedShareProvider; use OCA\FederatedFileSharing\FederatedShareProvider;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState; use OCP\AppFramework\Services\IInitialState;
@ -41,7 +42,9 @@ class Personal implements ISettings {
$this->initialState->provideInitialState('cloudId', $cloudID); $this->initialState->provideInitialState('cloudId', $cloudID);
$this->initialState->provideInitialState('docUrlFederated', $this->urlGenerator->linkToDocs('user-sharing-federated')); $this->initialState->provideInitialState('docUrlFederated', $this->urlGenerator->linkToDocs('user-sharing-federated'));
return new TemplateResponse('federatedfilesharing', 'settings-personal', [], TemplateResponse::RENDER_AS_BLANK); \OCP\Util::addStyle(Application::APP_ID, 'settings-personal');
\OCP\Util::addScript(Application::APP_ID, 'settings-personal');
return new TemplateResponse(Application::APP_ID, 'settings-personal', renderAs: TemplateResponse::RENDER_AS_BLANK);
} }
/** /**

@ -2,38 +2,184 @@
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later - SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<script setup lang="ts">
import type { OCSResponse } from '@nextcloud/typings/ocs'
import axios from '@nextcloud/axios'
import { showConfirmation, showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { generateOcsUrl } from '@nextcloud/router'
import { reactive } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import logger from '../services/logger.ts'
const sharingFederatedDocUrl = loadState<string>('federatedfilesharing', 'sharingFederatedDocUrl')
const internalState = new Proxy({
outgoingServer2serverShareEnabled: [
loadState<boolean>('federatedfilesharing', 'outgoingServer2serverShareEnabled'),
'outgoing_server2server_share_enabled',
],
incomingServer2serverShareEnabled: [
loadState<boolean>('federatedfilesharing', 'incomingServer2serverShareEnabled'),
'incoming_server2server_share_enabled',
],
outgoingServer2serverGroupShareEnabled: [
loadState<boolean>('federatedfilesharing', 'outgoingServer2serverGroupShareEnabled'),
'outgoing_server2server_group_share_enabled',
],
incomingServer2serverGroupShareEnabled: [
loadState<boolean>('federatedfilesharing', 'incomingServer2serverGroupShareEnabled'),
'incoming_server2server_group_share_enabled',
],
federatedGroupSharingSupported: [
loadState<boolean>('federatedfilesharing', 'federatedGroupSharingSupported'),
'federated_group_sharing_supported',
],
federatedTrustedShareAutoAccept: [
loadState<boolean>('federatedfilesharing', 'federatedTrustedShareAutoAccept'),
'federatedTrustedShareAutoAccept',
],
lookupServerEnabled: [
loadState<boolean>('federatedfilesharing', 'lookupServerEnabled'),
'lookupServerEnabled',
],
lookupServerUploadEnabled: [
loadState<boolean>('federatedfilesharing', 'lookupServerUploadEnabled'),
'lookupServerUploadEnabled',
],
}, {
get(target, prop) {
return target[prop]?.[0]
},
set(target, prop, value) {
if (prop in target) {
target[prop][0] = value
updateAppConfig(target[prop][1], value)
return true
}
return false
},
})
const state = reactive<Record<string, boolean>>(internalState as never)
/**
* Show confirmation dialog for enabling lookup server upload
*
* @param value - The new state
*/
async function showLookupServerUploadConfirmation(value: boolean) {
// No confirmation needed for disabling
if (value === false) {
return state.lookupServerUploadEnabled = false
}
await showConfirmation({
name: t('federatedfilesharing', 'Confirm data upload to lookup server'),
text: t('federatedfilesharing', 'When enabled, all account properties (e.g. email address) with scope visibility set to "published", will be automatically synced and transmitted to an external system and made available in a public, global address book.'),
labelConfirm: t('federatedfilesharing', 'Enable data upload'),
labelReject: t('federatedfilesharing', 'Disable upload'),
severity: 'warning',
}).then(() => {
state.lookupServerUploadEnabled = true
}).catch(() => {
state.lookupServerUploadEnabled = false
})
}
/**
* Show confirmation dialog for enabling lookup server
*
* @param value - The new state
*/
async function showLookupServerConfirmation(value: boolean) {
// No confirmation needed for disabling
if (value === false) {
return state.lookupServerEnabled = false
}
await showConfirmation({
name: t('federatedfilesharing', 'Confirm querying lookup server'),
text: t('federatedfilesharing', 'When enabled, the search input when creating shares will be sent to an external system that provides a public and global address book.')
+ t('federatedfilesharing', 'This is used to retrieve the federated cloud ID to make federated sharing easier.')
+ t('federatedfilesharing', 'Moreover, email addresses of users might be sent to that system in order to verify them.'),
labelConfirm: t('federatedfilesharing', 'Enable querying'),
labelReject: t('federatedfilesharing', 'Disable querying'),
severity: 'warning',
}).then(() => {
state.lookupServerEnabled = true
}).catch(() => {
state.lookupServerEnabled = false
})
}
/**
* Update the app config
*
* @param key - The config key
* @param value - The config value
*/
async function updateAppConfig(key: string, value: boolean) {
await confirmPassword()
const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
appId: 'files_sharing',
key,
})
const stringValue = value ? 'yes' : 'no'
try {
const { data } = await axios.post<OCSResponse>(url, {
value: stringValue,
})
if (data.ocs.meta.status !== 'ok') {
if (data.ocs.meta.message) {
showError(data.ocs.meta.message)
logger.error('Error updating federated files sharing config', { error: data.ocs })
} else {
throw new Error(`Failed to update federatedfilesharing config, ${data.ocs.meta.statuscode}`)
}
}
} catch (error) {
logger.error('Error updating federated files sharing config', { error })
showError(t('federatedfilesharing', 'Unable to update federated files sharing config'))
}
}
</script>
<template> <template>
<NcSettingsSection <NcSettingsSection
:name="t('federatedfilesharing', 'Federated Cloud Sharing')" :name="t('federatedfilesharing', 'Federated Cloud Sharing')"
:description="t('federatedfilesharing', 'Adjust how people can share between servers. This includes shares between people on this server as well if they are using federated sharing.')" :description="t('federatedfilesharing', 'Adjust how people can share between servers. This includes shares between people on this server as well if they are using federated sharing.')"
:doc-url="sharingFederatedDocUrl"> :doc-url="sharingFederatedDocUrl">
<NcCheckboxRadioSwitch <NcCheckboxRadioSwitch
v-model="outgoingServer2serverShareEnabled" v-model="state.outgoingServer2serverShareEnabled"
type="switch" type="switch">
@update:modelValue="update('outgoing_server2server_share_enabled', outgoingServer2serverShareEnabled)">
{{ t('federatedfilesharing', 'Allow people on this server to send shares to other servers (this option also allows WebDAV access to public shares)') }} {{ t('federatedfilesharing', 'Allow people on this server to send shares to other servers (this option also allows WebDAV access to public shares)') }}
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch <NcCheckboxRadioSwitch
v-model="incomingServer2serverShareEnabled" v-model="state.incomingServer2serverShareEnabled"
type="switch" type="switch">
@update:modelValue="update('incoming_server2server_share_enabled', incomingServer2serverShareEnabled)">
{{ t('federatedfilesharing', 'Allow people on this server to receive shares from other servers') }} {{ t('federatedfilesharing', 'Allow people on this server to receive shares from other servers') }}
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch <NcCheckboxRadioSwitch
v-if="federatedGroupSharingSupported" v-if="state.federatedGroupSharingSupported"
v-model="outgoingServer2serverGroupShareEnabled" v-model="state.outgoingServer2serverGroupShareEnabled"
type="switch" type="switch">
@update:modelValue="update('outgoing_server2server_group_share_enabled', outgoingServer2serverGroupShareEnabled)">
{{ t('federatedfilesharing', 'Allow people on this server to send shares to groups on other servers') }} {{ t('federatedfilesharing', 'Allow people on this server to send shares to groups on other servers') }}
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch <NcCheckboxRadioSwitch
v-if="federatedGroupSharingSupported" v-if="state.federatedGroupSharingSupported"
v-model="incomingServer2serverGroupShareEnabled" v-model="state.incomingServer2serverGroupShareEnabled"
type="switch" type="switch">
@update:modelValue="update('incoming_server2server_group_share_enabled', incomingServer2serverGroupShareEnabled)">
{{ t('federatedfilesharing', 'Allow people on this server to receive group shares from other servers') }} {{ t('federatedfilesharing', 'Allow people on this server to receive group shares from other servers') }}
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
@ -42,17 +188,17 @@
<NcCheckboxRadioSwitch <NcCheckboxRadioSwitch
type="switch" type="switch"
:model-value="lookupServerEnabled" :model-value="state.lookupServerEnabled"
disabled disabled
@update:modelValue="showLookupServerConfirmation"> @update:model-value="showLookupServerConfirmation">
{{ t('federatedfilesharing', 'Search global and public address book for people') }} {{ t('federatedfilesharing', 'Search global and public address book for people') }}
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch <NcCheckboxRadioSwitch
type="switch" type="switch"
:model-value="lookupServerUploadEnabled" :model-value="state.lookupServerUploadEnabled"
disabled disabled
@update:modelValue="showLookupServerUploadConfirmation"> @update:model-value="showLookupServerUploadConfirmation">
{{ t('federatedfilesharing', 'Allow people to publish their data to a global and public address book') }} {{ t('federatedfilesharing', 'Allow people to publish their data to a global and public address book') }}
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
</fieldset> </fieldset>
@ -63,147 +209,14 @@
{{ t('federatedfilesharing', 'Trusted federation') }} {{ t('federatedfilesharing', 'Trusted federation') }}
</h3> </h3>
<NcCheckboxRadioSwitch <NcCheckboxRadioSwitch
v-model="federatedTrustedShareAutoAccept" v-model="state.federatedTrustedShareAutoAccept"
type="switch" type="switch">
@update:modelValue="update('federatedTrustedShareAutoAccept', federatedTrustedShareAutoAccept)">
{{ t('federatedfilesharing', 'Automatically accept shares from trusted federated accounts and groups by default') }} {{ t('federatedfilesharing', 'Automatically accept shares from trusted federated accounts and groups by default') }}
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
</div> </div>
</NcSettingsSection> </NcSettingsSection>
</template> </template>
<script>
import axios from '@nextcloud/axios'
import { DialogBuilder, showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { generateOcsUrl } from '@nextcloud/router'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import logger from '../services/logger.ts'
export default {
name: 'AdminSettings',
components: {
NcCheckboxRadioSwitch,
NcSettingsSection,
},
data() {
return {
outgoingServer2serverShareEnabled: loadState('federatedfilesharing', 'outgoingServer2serverShareEnabled'),
incomingServer2serverShareEnabled: loadState('federatedfilesharing', 'incomingServer2serverShareEnabled'),
outgoingServer2serverGroupShareEnabled: loadState('federatedfilesharing', 'outgoingServer2serverGroupShareEnabled'),
incomingServer2serverGroupShareEnabled: loadState('federatedfilesharing', 'incomingServer2serverGroupShareEnabled'),
federatedGroupSharingSupported: loadState('federatedfilesharing', 'federatedGroupSharingSupported'),
lookupServerEnabled: loadState('federatedfilesharing', 'lookupServerEnabled'),
lookupServerUploadEnabled: loadState('federatedfilesharing', 'lookupServerUploadEnabled'),
federatedTrustedShareAutoAccept: loadState('federatedfilesharing', 'federatedTrustedShareAutoAccept'),
internalOnly: loadState('federatedfilesharing', 'internalOnly'),
sharingFederatedDocUrl: loadState('federatedfilesharing', 'sharingFederatedDocUrl'),
}
},
methods: {
setLookupServerUploadEnabled(state) {
if (state === this.lookupServerUploadEnabled) {
return
}
this.lookupServerUploadEnabled = state
this.update('lookupServerUploadEnabled', state)
},
async showLookupServerUploadConfirmation(state) {
// No confirmation needed for disabling
if (state === false) {
return this.setLookupServerUploadEnabled(false)
}
const dialog = new DialogBuilder(t('federatedfilesharing', 'Confirm data upload to lookup server'))
await dialog
.setSeverity('warning')
.setText(t('federatedfilesharing', 'When enabled, all account properties (e.g. email address) with scope visibility set to "published", will be automatically synced and transmitted to an external system and made available in a public, global address book.'))
.addButton({
callback: () => this.setLookupServerUploadEnabled(false),
label: t('federatedfilesharing', 'Disable upload'),
})
.addButton({
callback: () => this.setLookupServerUploadEnabled(true),
label: t('federatedfilesharing', 'Enable data upload'),
type: 'error',
})
.build()
.show()
},
setLookupServerEnabled(state) {
if (state === this.lookupServerEnabled) {
return
}
this.lookupServerEnabled = state
this.update('lookupServerEnabled', state)
},
async showLookupServerConfirmation(state) {
// No confirmation needed for disabling
if (state === false) {
return this.setLookupServerEnabled(false)
}
const dialog = new DialogBuilder(t('federatedfilesharing', 'Confirm querying lookup server'))
await dialog
.setSeverity('warning')
.setText(t('federatedfilesharing', 'When enabled, the search input when creating shares will be sent to an external system that provides a public and global address book.')
+ t('federatedfilesharing', 'This is used to retrieve the federated cloud ID to make federated sharing easier.')
+ t('federatedfilesharing', 'Moreover, email addresses of users might be sent to that system in order to verify them.'))
.addButton({
callback: () => this.setLookupServerEnabled(false),
label: t('federatedfilesharing', 'Disable querying'),
})
.addButton({
callback: () => this.setLookupServerEnabled(true),
label: t('federatedfilesharing', 'Enable querying'),
type: 'error',
})
.build()
.show()
},
async update(key, value) {
await confirmPassword()
const url = generateOcsUrl('/apps/provisioning_api/api/v1/config/apps/{appId}/{key}', {
appId: 'files_sharing',
key,
})
const stringValue = value ? 'yes' : 'no'
try {
const { data } = await axios.post(url, {
value: stringValue,
})
this.handleResponse({
status: data.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('federatedfilesharing', 'Unable to update federated files sharing config'),
error: e,
})
}
},
async handleResponse({ status, errorMessage, error }) {
if (status !== 'ok') {
showError(errorMessage)
logger.error(errorMessage, { error })
}
},
},
}
</script>
<style scoped> <style scoped>
.settings-subsection { .settings-subsection {
margin-top: 20px; margin-top: 20px;

@ -3,6 +3,75 @@
- SPDX-License-Identifier: AGPL-3.0-or-later - SPDX-License-Identifier: AGPL-3.0-or-later
--> -->
<script setup lang="ts">
import { showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconClipboard from 'vue-material-design-icons/ContentCopy.vue'
import IconWeb from 'vue-material-design-icons/Web.vue'
const productName = window.OC.theme.productName
const color = loadState<string>('federatedfilesharing', 'color')
const textColor = loadState<string>('federatedfilesharing', 'textColor')
const cloudId = loadState<string>('federatedfilesharing', 'cloudId')
const docUrlFederated = loadState<string>('federatedfilesharing', 'docUrlFederated')
const logoPath = loadState<string>('federatedfilesharing', 'logoPath')
const reference = loadState<string>('federatedfilesharing', 'reference')
const urlFacebookIcon = imagePath('core', 'facebook')
const urlMastodonIcon = imagePath('core', 'mastodon')
const urlBlueSkyIcon = imagePath('core', 'bluesky')
const messageWithURL = t('federatedfilesharing', 'Share with me through my #Nextcloud Federated Cloud ID, see {url}', { url: reference })
const messageWithoutURL = t('federatedfilesharing', 'Share with me through my #Nextcloud Federated Cloud ID')
const shareMastodonUrl = `https://mastodon.social/?text=${encodeURIComponent(messageWithoutURL)}&url=${encodeURIComponent(reference)}`
const shareFacebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(reference)}`
const shareBlueSkyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(messageWithURL)}`
const logoPathAbsolute = new URL(logoPath, location.origin)
const showHtml = ref(false)
const isCopied = ref(false)
const backgroundStyle = computed(() => `
padding:10px;
background-color:${color};
color:${textColor};
border-radius:3px;
padding-inline-start:4px;`)
const linkStyle = `background-image:url(${logoPathAbsolute});width:50px;height:30px;position:relative;top:8px;background-size:contain;display:inline-block;background-repeat:no-repeat; background-position: center center;`
const htmlCode = computed(() => `<a target="_blank" rel="noreferrer noopener" href="${reference}" style="${backgroundStyle.value}">
<span style="${linkStyle}"></span>
${t('federatedfilesharing', 'Share with me via Nextcloud')}
</a>`)
const copyLinkTooltip = computed(() => isCopied.value
? t('federatedfilesharing', 'Cloud ID copied')
: t('federatedfilesharing', 'Copy'))
/**
*
*/
async function copyCloudId(): Promise<void> {
try {
await navigator.clipboard.writeText(cloudId)
showSuccess(t('federatedfilesharing', 'Cloud ID copied'))
} catch {
// no secure context or really old browser - need a fallback
window.prompt(t('federatedfilesharing', 'Clipboard not available. Please copy the cloud ID manually.'), cloudId)
}
isCopied.value = true
showSuccess(t('federatedfilesharing', 'Copied!'))
setTimeout(() => {
isCopied.value = false
}, 2000)
}
</script>
<template> <template>
<NcSettingsSection <NcSettingsSection
:name="t('federatedfilesharing', 'Federated Cloud')" :name="t('federatedfilesharing', 'Federated Cloud')"
@ -25,18 +94,16 @@
<p class="social-button"> <p class="social-button">
{{ t('federatedfilesharing', 'Share it so your friends can share files with you:') }}<br> {{ t('federatedfilesharing', 'Share it so your friends can share files with you:') }}<br>
<NcButton :href="shareFacebookUrl"> <NcButton :href="shareBlueSkyUrl">
{{ t('federatedfilesharing', 'Facebook') }} {{ t('federatedfilesharing', 'Bluesky') }}
<template #icon> <template #icon>
<img class="social-button__icon social-button__icon--bright" :src="urlFacebookIcon"> <img class="social-button__icon" :src="urlBlueSkyIcon">
</template> </template>
</NcButton> </NcButton>
<NcButton <NcButton :href="shareFacebookUrl">
:aria-label="t('federatedfilesharing', 'X (formerly Twitter)')" {{ t('federatedfilesharing', 'Facebook') }}
:href="shareXUrl">
{{ t('federatedfilesharing', 'formerly Twitter') }}
<template #icon> <template #icon>
<img class="social-button__icon" :src="urlXIcon"> <img class="social-button__icon social-button__icon--bright" :src="urlFacebookIcon">
</template> </template>
</NcButton> </NcButton>
<NcButton :href="shareMastodonUrl"> <NcButton :href="shareMastodonUrl">
@ -45,12 +112,6 @@
<img class="social-button__icon" :src="urlMastodonIcon"> <img class="social-button__icon" :src="urlMastodonIcon">
</template> </template>
</NcButton> </NcButton>
<NcButton :href="shareBlueSkyUrl">
{{ t('federatedfilesharing', 'Bluesky') }}
<template #icon>
<img class="social-button__icon" :src="urlBlueSkyIcon">
</template>
</NcButton>
<NcButton <NcButton
class="social-button__website-button" class="social-button__website-button"
@click="showHtml = !showHtml"> @click="showHtml = !showHtml">
@ -82,126 +143,6 @@
</NcSettingsSection> </NcSettingsSection>
</template> </template>
<script lang="ts">
import { showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconClipboard from 'vue-material-design-icons/ContentCopy.vue'
import IconWeb from 'vue-material-design-icons/Web.vue'
export default {
name: 'PersonalSettings',
components: {
NcButton,
NcInputField,
NcSettingsSection,
IconCheck,
IconClipboard,
IconWeb,
},
setup() {
return {
t,
productName: window.OC.theme.productName,
cloudId: loadState<string>('federatedfilesharing', 'cloudId'),
reference: loadState<string>('federatedfilesharing', 'reference'),
urlFacebookIcon: imagePath('core', 'facebook'),
urlMastodonIcon: imagePath('core', 'mastodon'),
urlBlueSkyIcon: imagePath('core', 'bluesky'),
urlXIcon: imagePath('core', 'x'),
}
},
data() {
return {
color: loadState('federatedfilesharing', 'color'),
textColor: loadState('federatedfilesharing', 'textColor'),
logoPath: loadState('federatedfilesharing', 'logoPath'),
docUrlFederated: loadState('federatedfilesharing', 'docUrlFederated'),
showHtml: false,
isCopied: false,
}
},
computed: {
messageWithURL() {
return t('federatedfilesharing', 'Share with me through my #Nextcloud Federated Cloud ID, see {url}', { url: this.reference })
},
messageWithoutURL() {
return t('federatedfilesharing', 'Share with me through my #Nextcloud Federated Cloud ID')
},
shareMastodonUrl() {
return `https://mastodon.social/?text=${encodeURIComponent(this.messageWithoutURL)}&url=${encodeURIComponent(this.reference)}`
},
shareXUrl() {
return `https://x.com/intent/tweet?text=${encodeURIComponent(this.messageWithURL)}`
},
shareFacebookUrl() {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.reference)}`
},
shareBlueSkyUrl() {
return `https://bsky.app/intent/compose?text=${encodeURIComponent(this.messageWithURL)}`
},
logoPathAbsolute() {
return window.location.protocol + '//' + window.location.host + this.logoPath
},
backgroundStyle() {
return `padding:10px;background-color:${this.color};color:${this.textColor};border-radius:3px;padding-inline-start:4px;`
},
linkStyle() {
return `background-image:url(${this.logoPathAbsolute});width:50px;height:30px;position:relative;top:8px;background-size:contain;display:inline-block;background-repeat:no-repeat; background-position: center center;`
},
htmlCode() {
return `<a target="_blank" rel="noreferrer noopener" href="${this.reference}" style="${this.backgroundStyle}">
<span style="${this.linkStyle}"></span>
${t('federatedfilesharing', 'Share with me via Nextcloud')}
</a>`
},
copyLinkTooltip() {
return this.isCopied ? t('federatedfilesharing', 'Cloud ID copied') : t('federatedfilesharing', 'Copy')
},
},
methods: {
async copyCloudId(): Promise<void> {
try {
await navigator.clipboard.writeText(this.cloudId)
showSuccess(t('federatedfilesharing', 'Cloud ID copied'))
} catch {
// no secure context or really old browser - need a fallback
window.prompt(t('federatedfilesharing', 'Clipboard not available. Please copy the cloud ID manually.'), this.reference)
}
this.isCopied = true
showSuccess(t('federatedfilesharing', 'Copied!'))
setTimeout(() => {
this.isCopied = false
}, 2000)
},
goTo(url: string): void {
window.location.href = url
},
},
}
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.social-button { .social-button {
margin-top: 0.5rem; margin-top: 0.5rem;

@ -3,31 +3,36 @@
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
import { cleanup, fireEvent, render } from '@testing-library/vue' import type { VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { findByLabelText, findByRole, fireEvent, getByLabelText, getByRole } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import RemoteShareDialog from './RemoteShareDialog.vue' import RemoteShareDialog from './RemoteShareDialog.vue'
describe('RemoteShareDialog', () => { describe('RemoteShareDialog', () => {
beforeEach(cleanup) let component: VueWrapper
afterEach(() => {
component?.unmount()
})
it('can be mounted', async () => { it('can be mounted', async () => {
const component = render(RemoteShareDialog, { component = mount(RemoteShareDialog, {
props: { props: {
owner: 'user123', owner: 'user123',
name: 'my-photos', name: 'my-photos',
remote: 'nextcloud.local', remote: 'nextcloud.local',
passwordRequired: false, passwordRequired: false,
}, },
attachTo: 'body',
}) })
await expect(component.findByRole('dialog', { name: 'Remote share' })).resolves.not.toThrow() await expect(findByRole(document.body, 'dialog', { name: 'Remote share' })).resolves.not.toThrow()
expect(component.getByRole('dialog').innerText).toContain(/my-photos from user123@nextcloud.local/)
await expect(component.findByRole('button', { name: 'Cancel' })).resolves.not.toThrow()
await expect(component.findByRole('button', { name: /Add remote share/ })).resolves.not.toThrow()
}) })
it('does not show password input if not enabled', async () => { it('does not show password input if not enabled', async () => {
const component = render(RemoteShareDialog, { component = mount(RemoteShareDialog, {
props: { props: {
owner: 'user123', owner: 'user123',
name: 'my-photos', name: 'my-photos',
@ -36,15 +41,15 @@ describe('RemoteShareDialog', () => {
}, },
}) })
await expect(component.findByLabelText('Remote share password')).rejects.toThrow() await expect(findByLabelText(component.element, 'Remote share password')).rejects.toThrow()
}) })
it('emits true when accepted', () => { it('emits true when accepted', async () => {
const onClose = vi.fn() const onClose = vi.fn()
const component = render(RemoteShareDialog, { component = mount(RemoteShareDialog, {
listeners: { attrs: {
close: onClose, onClose,
}, },
props: { props: {
owner: 'user123', owner: 'user123',
@ -54,12 +59,13 @@ describe('RemoteShareDialog', () => {
}, },
}) })
component.getByRole('button', { name: 'Cancel' }).click() const button = getByRole(component.element, 'button', { name: 'Cancel' })
await fireEvent.click(button)
expect(onClose).toHaveBeenCalledWith(false) expect(onClose).toHaveBeenCalledWith(false)
}) })
it('show password input if needed', async () => { it('show password input if needed', async () => {
const component = render(RemoteShareDialog, { component = mount(RemoteShareDialog, {
props: { props: {
owner: 'admin', owner: 'admin',
name: 'secret-data', name: 'secret-data',
@ -68,15 +74,15 @@ describe('RemoteShareDialog', () => {
}, },
}) })
await expect(component.findByLabelText('Remote share password')).resolves.not.toThrow() await expect(findByLabelText(component.element, 'Remote share password')).resolves.not.toThrow()
}) })
it('emits the submitted password', async () => { it('emits the submitted password', async () => {
const onClose = vi.fn() const onClose = vi.fn()
const component = render(RemoteShareDialog, { component = mount(RemoteShareDialog, {
listeners: { attrs: {
close: onClose, onClose,
}, },
props: { props: {
owner: 'admin', owner: 'admin',
@ -86,18 +92,19 @@ describe('RemoteShareDialog', () => {
}, },
}) })
const input = component.getByLabelText('Remote share password') const input = getByLabelText(component.element, 'Remote share password')
await fireEvent.update(input, 'my password') await fireEvent.update(input, 'my password')
component.getByRole('button', { name: 'Add remote share' }).click() const button = getByRole(component.element, 'button', { name: 'Add remote share' })
await fireEvent.click(button)
expect(onClose).toHaveBeenCalledWith(true, 'my password') expect(onClose).toHaveBeenCalledWith(true, 'my password')
}) })
it('emits no password if cancelled', async () => { it('emits no password if cancelled', async () => {
const onClose = vi.fn() const onClose = vi.fn()
const component = render(RemoteShareDialog, { component = mount(RemoteShareDialog, {
listeners: { attrs: {
close: onClose, onClose,
}, },
props: { props: {
owner: 'admin', owner: 'admin',
@ -107,9 +114,10 @@ describe('RemoteShareDialog', () => {
}, },
}) })
const input = component.getByLabelText('Remote share password') const input = getByLabelText(component.element, 'Remote share password')
await fireEvent.update(input, 'my password') await fireEvent.update(input, 'my password')
component.getByRole('button', { name: 'Cancel' }).click() const button = getByRole(component.element, 'button', { name: 'Cancel' })
await fireEvent.click(button)
expect(onClose).toHaveBeenCalledWith(false) expect(onClose).toHaveBeenCalledWith(false)
}) })
}) })

@ -20,15 +20,17 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close', state: boolean, password?: string): void close: [state: boolean, password?: string]
}>() }>()
const password = ref('') const password = ref('')
type INcDialogButtons = InstanceType<typeof NcDialog>['$props']['buttons']
/** /**
* The dialog buttons * The dialog buttons
*/ */
const buttons = computed(() => [ const buttons = computed<INcDialogButtons>(() => [
{ {
label: t('federatedfilesharing', 'Cancel'), label: t('federatedfilesharing', 'Cancel'),
callback: () => emit('close', false), callback: () => emit('close', false),
@ -54,16 +56,13 @@ const buttons = computed(() => [
<NcPasswordField <NcPasswordField
v-if="passwordRequired" v-if="passwordRequired"
v-model="password" v-model="password"
class="remote-share-dialog__password" :class="$style.remoteShareDialog__password"
:label="t('federatedfilesharing', 'Remote share password')" /> :label="t('federatedfilesharing', 'Remote share password')" />
</NcDialog> </NcDialog>
</template> </template>
<style scoped lang="scss"> <style module>
.remote-share-dialog { .remoteShareDialog__password {
margin-block: 1em .5em;
&__password {
margin-block: 1em .5em;
}
} }
</style> </style>

@ -13,33 +13,6 @@ import { generateUrl } from '@nextcloud/router'
import { showRemoteShareDialog } from './services/dialogService.ts' import { showRemoteShareDialog } from './services/dialogService.ts'
import logger from './services/logger.ts' import logger from './services/logger.ts'
window.OCA.Sharing = window.OCA.Sharing ?? {}
/**
* Shows "add external share" dialog.
*
* @param {object} share the share
* @param {string} share.remote remote server URL
* @param {string} share.owner owner name
* @param {string} share.name name of the shared folder
* @param {string} share.token authentication token
* @param {boolean} passwordProtected true if the share is password protected
* @param {Function} callback the callback
*/
window.OCA.Sharing.showAddExternalDialog = function(share, passwordProtected, callback) {
const owner = share.ownerDisplayName || share.owner
const name = share.name
// Clean up the remote URL for display
const remote = share.remote
.replace(/^https?:\/\//, '') // remove http:// or https://
.replace(/\/$/, '') // remove trailing slash
showRemoteShareDialog(name, owner, remote, passwordProtected)
.then((password) => callback(true, { ...share, password }))
.catch(() => callback(false, share))
}
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
processIncomingShareFromUrl() processIncomingShareFromUrl()
@ -118,7 +91,7 @@ function processIncomingShareFromUrl() {
// clear hash, it is unlikely that it contain any extra parameters // clear hash, it is unlikely that it contain any extra parameters
location.hash = '' location.hash = ''
params.passwordProtected = parseInt(params.protected, 10) === 1 params.passwordProtected = parseInt(params.protected, 10) === 1
window.OCA.Sharing.showAddExternalDialog( showAddExternalDialog(
params, params,
params.passwordProtected, params.passwordProtected,
callbackAddShare, callbackAddShare,
@ -133,7 +106,7 @@ async function processSharesToConfirm() {
// check for new server-to-server shares which need to be approved // check for new server-to-server shares which need to be approved
const { data: shares } = await axios.get(generateUrl('/apps/files_sharing/api/externalShares')) const { data: shares } = await axios.get(generateUrl('/apps/files_sharing/api/externalShares'))
for (let index = 0; index < shares.length; ++index) { for (let index = 0; index < shares.length; ++index) {
window.OCA.Sharing.showAddExternalDialog( showAddExternalDialog(
shares[index], shares[index],
false, false,
function(result, share) { function(result, share) {
@ -149,3 +122,28 @@ async function processSharesToConfirm() {
) )
} }
} }
/**
* Shows "add external share" dialog.
*
* @param {object} share the share
* @param {string} share.remote remote server URL
* @param {string} share.owner owner name
* @param {string} share.name name of the shared folder
* @param {string} share.token authentication token
* @param {boolean} passwordProtected true if the share is password protected
* @param {Function} callback the callback
*/
function showAddExternalDialog(share, passwordProtected, callback) {
const owner = share.ownerDisplayName || share.owner
const name = share.name
// Clean up the remote URL for display
const remote = share.remote
.replace(/^https?:\/\//, '') // remove http:// or https://
.replace(/\/$/, '') // remove trailing slash
showRemoteShareDialog(name, owner, remote, passwordProtected)
.then((password) => callback(true, { ...share, password }))
.catch(() => callback(false, share))
}

@ -1,20 +0,0 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCSPNonce } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import PersonalSettings from './components/PersonalSettings.vue'
__webpack_nonce__ = getCSPNonce()
Vue.mixin({
methods: {
t,
},
})
const PersonalSettingsView = Vue.extend(PersonalSettings)
new PersonalSettingsView().$mount('#vue-personal-federated')

@ -47,7 +47,7 @@ describe('federatedfilesharing: dialog service', () => {
} }
} }
expect(await promise).toBe('') await expect(promise).resolves.toBe('')
}) })
it('rejects if cancelled', async () => { it('rejects if cancelled', async () => {
@ -60,6 +60,6 @@ describe('federatedfilesharing: dialog service', () => {
} }
} }
expect(async () => await promise).rejects.toThrow() await expect(promise).rejects.toThrow()
}) })
}) })

@ -14,23 +14,24 @@ import RemoteShareDialog from '../components/RemoteShareDialog.vue'
* @param remote The remote address * @param remote The remote address
* @param passwordRequired True if the share is password protected * @param passwordRequired True if the share is password protected
*/ */
export function showRemoteShareDialog( export async function showRemoteShareDialog(
name: string, name: string,
owner: string, owner: string,
remote: string, remote: string,
passwordRequired = false, passwordRequired = false,
): Promise<string | void> { ): Promise<string | void> {
const { promise, reject, resolve } = Promise.withResolvers<string | void>() const [status, password] = await spawnDialog(RemoteShareDialog, {
name,
spawnDialog(RemoteShareDialog, { name, owner, remote, passwordRequired }, (status, password) => { owner,
if (passwordRequired && status) { remote,
resolve(password as string) passwordRequired,
} else if (status) {
resolve(undefined)
} else {
reject()
}
}) })
return promise if (passwordRequired && status) {
return password as string
} else if (status) {
return
} else {
throw new Error('Dialog was cancelled')
}
} }

@ -3,23 +3,15 @@
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state' import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n' import { createApp } from 'vue'
import Vue from 'vue'
import AdminSettings from './components/AdminSettings.vue' import AdminSettings from './components/AdminSettings.vue'
__webpack_nonce__ = getCSPNonce() import 'vite/modulepreload-polyfill'
Vue.mixin({
methods: {
t,
},
})
const internalOnly = loadState('federatedfilesharing', 'internalOnly', false) const internalOnly = loadState('federatedfilesharing', 'internalOnly', false)
if (!internalOnly) { if (!internalOnly) {
const AdminSettingsView = Vue.extend(AdminSettings) const app = createApp(AdminSettings)
new AdminSettingsView().$mount('#vue-admin-federated') app.mount('#vue-admin-federated')
} }

@ -0,0 +1,12 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createApp } from 'vue'
import PersonalSettings from './components/PersonalSettings.vue'
import 'vite/modulepreload-polyfill'
const app = createApp(PersonalSettings)
app.mount('#vue-personal-federated')

@ -3,8 +3,6 @@
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
\OCP\Util::addScript('federatedfilesharing', 'vue-settings-admin');
?> ?>
<div id="vue-admin-federated"></div> <div id="vue-admin-federated"></div>

@ -3,8 +3,6 @@
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later * SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
\OCP\Util::addScript('federatedfilesharing', 'vue-settings-personal');
?> ?>
<div id="vue-personal-federated"></div> <div id="vue-personal-federated"></div>

@ -57,11 +57,6 @@ module.exports = {
oauth2: { oauth2: {
oauth2: path.join(__dirname, 'apps/oauth2/src', 'main.js'), oauth2: path.join(__dirname, 'apps/oauth2/src', 'main.js'),
}, },
federatedfilesharing: {
external: path.join(__dirname, 'apps/federatedfilesharing/src', 'external.js'),
'vue-settings-admin': path.join(__dirname, 'apps/federatedfilesharing/src', 'main-admin.js'),
'vue-settings-personal': path.join(__dirname, 'apps/federatedfilesharing/src', 'main-personal.js'),
},
profile: { profile: {
main: path.join(__dirname, 'apps/profile/src', 'main.ts'), main: path.join(__dirname, 'apps/profile/src', 'main.ts'),
}, },

@ -12,6 +12,11 @@ const modules = {
'settings-admin-example-content': resolve(import.meta.dirname, 'apps/dav/src', 'settings-admin-example-content.ts'), 'settings-admin-example-content': resolve(import.meta.dirname, 'apps/dav/src', 'settings-admin-example-content.ts'),
'settings-personal-availability': resolve(import.meta.dirname, 'apps/dav/src', 'settings-personal-availability.ts'), 'settings-personal-availability': resolve(import.meta.dirname, 'apps/dav/src', 'settings-personal-availability.ts'),
}, },
federatedfilesharing: {
'init-files': resolve(import.meta.dirname, 'apps/federatedfilesharing/src', 'init-files.js'),
'settings-admin': resolve(import.meta.dirname, 'apps/federatedfilesharing/src', 'settings-admin.ts'),
'settings-personal': resolve(import.meta.dirname, 'apps/federatedfilesharing/src', 'settings-personal.ts'),
},
files_reminders: { files_reminders: {
init: resolve(import.meta.dirname, 'apps/files_reminders/src', 'files-init.ts'), init: resolve(import.meta.dirname, 'apps/files_reminders/src', 'files-init.ts'),
}, },

@ -1209,11 +1209,6 @@
)]]></code> )]]></code>
</DeprecatedMethod> </DeprecatedMethod>
</file> </file>
<file src="apps/federatedfilesharing/lib/AppInfo/Application.php">
<DeprecatedInterface>
<code><![CDATA[IAppContainer]]></code>
</DeprecatedInterface>
</file>
<file src="apps/federatedfilesharing/lib/Controller/RequestHandlerController.php"> <file src="apps/federatedfilesharing/lib/Controller/RequestHandlerController.php">
<InvalidArgument> <InvalidArgument>
<code><![CDATA[$id]]></code> <code><![CDATA[$id]]></code>

@ -3,7 +3,7 @@
"include": ["./apps/**/*.ts", "./apps/**/*.vue", "./core/**/*.ts", "./core/**/*.vue", "./*.d.ts"], "include": ["./apps/**/*.ts", "./apps/**/*.vue", "./core/**/*.ts", "./core/**/*.vue", "./*.d.ts"],
"exclude": ["./**/*.cy.ts"], "exclude": ["./**/*.cy.ts"],
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "ESNext"], "lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ESNext"],
"outDir": "./dist/", "outDir": "./dist/",
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",

@ -3,5 +3,11 @@
* SPDX-License-Identifier: CC0-1.0 * SPDX-License-Identifier: CC0-1.0
*/ */
import { defineConfig } from 'vitest/config'
// stub - for the moment see build/frontend/vitest.config.ts // stub - for the moment see build/frontend/vitest.config.ts
export default {} export default defineConfig({
test: {
projects: ['build/frontend*'],
},
})