refactor(federatedfilesharing): migrate to Typescript and Vue 3

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Ferdinand Thiessen 2025-12-10 03:50:50 +07:00
parent 75edec9d6c
commit da98dc73e0
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
21 changed files with 373 additions and 418 deletions

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

@ -8,6 +8,7 @@ declare(strict_types=1);
*/
namespace OCA\FederatedFileSharing\Listeners;
use OCA\FederatedFileSharing\AppInfo\Application;
use OCA\FederatedFileSharing\FederatedShareProvider;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCP\App\IAppManager;
@ -35,7 +36,8 @@ class LoadAdditionalScriptsListener implements IEventListener {
if ($this->federatedShareProvider->isIncomingServer2serverShareEnabled()) {
$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;
use OCA\FederatedFileSharing\AppInfo\Application;
use OCA\FederatedFileSharing\FederatedShareProvider;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@ -43,7 +44,9 @@ class Admin implements IDelegatedSettings {
$this->initialState->provideInitialState('lookupServerUploadEnabled', $this->fedShareProvider->isLookupServerUploadEnabled());
$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;
use OCA\FederatedFileSharing\AppInfo\Application;
use OCA\FederatedFileSharing\FederatedShareProvider;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@ -41,7 +42,9 @@ class Personal implements ISettings {
$this->initialState->provideInitialState('cloudId', $cloudID);
$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-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>
<NcSettingsSection
: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.')"
:doc-url="sharingFederatedDocUrl">
<NcCheckboxRadioSwitch
v-model="outgoingServer2serverShareEnabled"
type="switch"
@update:modelValue="update('outgoing_server2server_share_enabled', outgoingServer2serverShareEnabled)">
v-model="state.outgoingServer2serverShareEnabled"
type="switch">
{{ t('federatedfilesharing', 'Allow people on this server to send shares to other servers (this option also allows WebDAV access to public shares)') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="incomingServer2serverShareEnabled"
type="switch"
@update:modelValue="update('incoming_server2server_share_enabled', incomingServer2serverShareEnabled)">
v-model="state.incomingServer2serverShareEnabled"
type="switch">
{{ t('federatedfilesharing', 'Allow people on this server to receive shares from other servers') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-if="federatedGroupSharingSupported"
v-model="outgoingServer2serverGroupShareEnabled"
type="switch"
@update:modelValue="update('outgoing_server2server_group_share_enabled', outgoingServer2serverGroupShareEnabled)">
v-if="state.federatedGroupSharingSupported"
v-model="state.outgoingServer2serverGroupShareEnabled"
type="switch">
{{ t('federatedfilesharing', 'Allow people on this server to send shares to groups on other servers') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-if="federatedGroupSharingSupported"
v-model="incomingServer2serverGroupShareEnabled"
type="switch"
@update:modelValue="update('incoming_server2server_group_share_enabled', incomingServer2serverGroupShareEnabled)">
v-if="state.federatedGroupSharingSupported"
v-model="state.incomingServer2serverGroupShareEnabled"
type="switch">
{{ t('federatedfilesharing', 'Allow people on this server to receive group shares from other servers') }}
</NcCheckboxRadioSwitch>
@ -42,17 +188,17 @@
<NcCheckboxRadioSwitch
type="switch"
:model-value="lookupServerEnabled"
:model-value="state.lookupServerEnabled"
disabled
@update:modelValue="showLookupServerConfirmation">
@update:model-value="showLookupServerConfirmation">
{{ t('federatedfilesharing', 'Search global and public address book for people') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
type="switch"
:model-value="lookupServerUploadEnabled"
:model-value="state.lookupServerUploadEnabled"
disabled
@update:modelValue="showLookupServerUploadConfirmation">
@update:model-value="showLookupServerUploadConfirmation">
{{ t('federatedfilesharing', 'Allow people to publish their data to a global and public address book') }}
</NcCheckboxRadioSwitch>
</fieldset>
@ -63,147 +209,14 @@
{{ t('federatedfilesharing', 'Trusted federation') }}
</h3>
<NcCheckboxRadioSwitch
v-model="federatedTrustedShareAutoAccept"
type="switch"
@update:modelValue="update('federatedTrustedShareAutoAccept', federatedTrustedShareAutoAccept)">
v-model="state.federatedTrustedShareAutoAccept"
type="switch">
{{ t('federatedfilesharing', 'Automatically accept shares from trusted federated accounts and groups by default') }}
</NcCheckboxRadioSwitch>
</div>
</NcSettingsSection>
</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>
.settings-subsection {
margin-top: 20px;

@ -3,6 +3,75 @@
- 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>
<NcSettingsSection
:name="t('federatedfilesharing', 'Federated Cloud')"
@ -25,18 +94,16 @@
<p class="social-button">
{{ t('federatedfilesharing', 'Share it so your friends can share files with you:') }}<br>
<NcButton :href="shareFacebookUrl">
{{ t('federatedfilesharing', 'Facebook') }}
<NcButton :href="shareBlueSkyUrl">
{{ t('federatedfilesharing', 'Bluesky') }}
<template #icon>
<img class="social-button__icon social-button__icon--bright" :src="urlFacebookIcon">
<img class="social-button__icon" :src="urlBlueSkyIcon">
</template>
</NcButton>
<NcButton
:aria-label="t('federatedfilesharing', 'X (formerly Twitter)')"
:href="shareXUrl">
{{ t('federatedfilesharing', 'formerly Twitter') }}
<NcButton :href="shareFacebookUrl">
{{ t('federatedfilesharing', 'Facebook') }}
<template #icon>
<img class="social-button__icon" :src="urlXIcon">
<img class="social-button__icon social-button__icon--bright" :src="urlFacebookIcon">
</template>
</NcButton>
<NcButton :href="shareMastodonUrl">
@ -45,12 +112,6 @@
<img class="social-button__icon" :src="urlMastodonIcon">
</template>
</NcButton>
<NcButton :href="shareBlueSkyUrl">
{{ t('federatedfilesharing', 'Bluesky') }}
<template #icon>
<img class="social-button__icon" :src="urlBlueSkyIcon">
</template>
</NcButton>
<NcButton
class="social-button__website-button"
@click="showHtml = !showHtml">
@ -82,126 +143,6 @@
</NcSettingsSection>
</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>
.social-button {
margin-top: 0.5rem;

@ -3,31 +3,36 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cleanup, fireEvent, render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueWrapper } from '@vue/test-utils'
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'
describe('RemoteShareDialog', () => {
beforeEach(cleanup)
let component: VueWrapper
afterEach(() => {
component?.unmount()
})
it('can be mounted', async () => {
const component = render(RemoteShareDialog, {
component = mount(RemoteShareDialog, {
props: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
attachTo: 'body',
})
await expect(component.findByRole('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()
await expect(findByRole(document.body, 'dialog', { name: 'Remote share' })).resolves.not.toThrow()
})
it('does not show password input if not enabled', async () => {
const component = render(RemoteShareDialog, {
component = mount(RemoteShareDialog, {
props: {
owner: 'user123',
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 component = render(RemoteShareDialog, {
listeners: {
close: onClose,
component = mount(RemoteShareDialog, {
attrs: {
onClose,
},
props: {
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)
})
it('show password input if needed', async () => {
const component = render(RemoteShareDialog, {
component = mount(RemoteShareDialog, {
props: {
owner: 'admin',
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 () => {
const onClose = vi.fn()
const component = render(RemoteShareDialog, {
listeners: {
close: onClose,
component = mount(RemoteShareDialog, {
attrs: {
onClose,
},
props: {
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')
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')
})
it('emits no password if cancelled', async () => {
const onClose = vi.fn()
const component = render(RemoteShareDialog, {
listeners: {
close: onClose,
component = mount(RemoteShareDialog, {
attrs: {
onClose,
},
props: {
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')
component.getByRole('button', { name: 'Cancel' }).click()
const button = getByRole(component.element, 'button', { name: 'Cancel' })
await fireEvent.click(button)
expect(onClose).toHaveBeenCalledWith(false)
})
})

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

@ -13,33 +13,6 @@ import { generateUrl } from '@nextcloud/router'
import { showRemoteShareDialog } from './services/dialogService.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', () => {
processIncomingShareFromUrl()
@ -118,7 +91,7 @@ function processIncomingShareFromUrl() {
// clear hash, it is unlikely that it contain any extra parameters
location.hash = ''
params.passwordProtected = parseInt(params.protected, 10) === 1
window.OCA.Sharing.showAddExternalDialog(
showAddExternalDialog(
params,
params.passwordProtected,
callbackAddShare,
@ -133,7 +106,7 @@ async function processSharesToConfirm() {
// check for new server-to-server shares which need to be approved
const { data: shares } = await axios.get(generateUrl('/apps/files_sharing/api/externalShares'))
for (let index = 0; index < shares.length; ++index) {
window.OCA.Sharing.showAddExternalDialog(
showAddExternalDialog(
shares[index],
false,
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 () => {
@ -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 passwordRequired True if the share is password protected
*/
export function showRemoteShareDialog(
export async function showRemoteShareDialog(
name: string,
owner: string,
remote: string,
passwordRequired = false,
): Promise<string | void> {
const { promise, reject, resolve } = Promise.withResolvers<string | void>()
spawnDialog(RemoteShareDialog, { name, owner, remote, passwordRequired }, (status, password) => {
if (passwordRequired && status) {
resolve(password as string)
} else if (status) {
resolve(undefined)
} else {
reject()
}
const [status, password] = await spawnDialog(RemoteShareDialog, {
name,
owner,
remote,
passwordRequired,
})
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
*/
import { getCSPNonce } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import { createApp } from 'vue'
import AdminSettings from './components/AdminSettings.vue'
__webpack_nonce__ = getCSPNonce()
Vue.mixin({
methods: {
t,
},
})
import 'vite/modulepreload-polyfill'
const internalOnly = loadState('federatedfilesharing', 'internalOnly', false)
if (!internalOnly) {
const AdminSettingsView = Vue.extend(AdminSettings)
new AdminSettingsView().$mount('#vue-admin-federated')
const app = createApp(AdminSettings)
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-License-Identifier: AGPL-3.0-or-later
*/
\OCP\Util::addScript('federatedfilesharing', 'vue-settings-admin');
?>
<div id="vue-admin-federated"></div>

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

@ -57,11 +57,6 @@ module.exports = {
oauth2: {
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: {
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-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: {
init: resolve(import.meta.dirname, 'apps/files_reminders/src', 'files-init.ts'),
},

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

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