aboutsummaryrefslogtreecommitdiffstats
path: root/apps/federatedfilesharing/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/federatedfilesharing/src')
-rw-r--r--apps/federatedfilesharing/src/components/AdminSettings.vue215
-rw-r--r--apps/federatedfilesharing/src/components/PersonalSettings.vue219
-rw-r--r--apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts123
-rw-r--r--apps/federatedfilesharing/src/components/RemoteShareDialog.vue67
-rw-r--r--apps/federatedfilesharing/src/external.js153
-rw-r--r--apps/federatedfilesharing/src/main-admin.js25
-rw-r--r--apps/federatedfilesharing/src/main-personal.js20
-rw-r--r--apps/federatedfilesharing/src/services/dialogService.spec.ts65
-rw-r--r--apps/federatedfilesharing/src/services/dialogService.ts36
-rw-r--r--apps/federatedfilesharing/src/services/logger.ts10
10 files changed, 933 insertions, 0 deletions
diff --git a/apps/federatedfilesharing/src/components/AdminSettings.vue b/apps/federatedfilesharing/src/components/AdminSettings.vue
new file mode 100644
index 00000000000..84bf6b565a3
--- /dev/null
+++ b/apps/federatedfilesharing/src/components/AdminSettings.vue
@@ -0,0 +1,215 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<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 type="switch"
+ :checked.sync="outgoingServer2serverShareEnabled"
+ @update:checked="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)') }}
+ </NcCheckboxRadioSwitch>
+
+ <NcCheckboxRadioSwitch type="switch"
+ :checked.sync="incomingServer2serverShareEnabled"
+ @update:checked="update('incoming_server2server_share_enabled', incomingServer2serverShareEnabled)">
+ {{ t('federatedfilesharing', 'Allow people on this server to receive shares from other servers') }}
+ </NcCheckboxRadioSwitch>
+
+ <NcCheckboxRadioSwitch v-if="federatedGroupSharingSupported"
+ type="switch"
+ :checked.sync="outgoingServer2serverGroupShareEnabled"
+ @update:checked="update('outgoing_server2server_group_share_enabled', outgoingServer2serverGroupShareEnabled)">
+ {{ t('federatedfilesharing', 'Allow people on this server to send shares to groups on other servers') }}
+ </NcCheckboxRadioSwitch>
+
+ <NcCheckboxRadioSwitch v-if="federatedGroupSharingSupported"
+ type="switch"
+ :checked.sync="incomingServer2serverGroupShareEnabled"
+ @update:checked="update('incoming_server2server_group_share_enabled', incomingServer2serverGroupShareEnabled)">
+ {{ t('federatedfilesharing', 'Allow people on this server to receive group shares from other servers') }}
+ </NcCheckboxRadioSwitch>
+
+ <fieldset>
+ <legend>{{ t('federatedfilesharing', 'The lookup server is only available for global scale.') }}</legend>
+
+ <NcCheckboxRadioSwitch type="switch"
+ :checked="lookupServerEnabled"
+ disabled
+ @update:checked="showLookupServerConfirmation">
+ {{ t('federatedfilesharing', 'Search global and public address book for people') }}
+ </NcCheckboxRadioSwitch>
+
+ <NcCheckboxRadioSwitch type="switch"
+ :checked="lookupServerUploadEnabled"
+ disabled
+ @update:checked="showLookupServerUploadConfirmation">
+ {{ t('federatedfilesharing', 'Allow people to publish their data to a global and public address book') }}
+ </NcCheckboxRadioSwitch>
+ </fieldset>
+
+ <!-- Trusted server handling -->
+ <div class="settings-subsection">
+ <h3 class="settings-subsection__name">
+ {{ t('federatedfilesharing', 'Trusted federation') }}
+ </h3>
+ <NcCheckboxRadioSwitch type="switch"
+ :checked.sync="federatedTrustedShareAutoAccept"
+ @update:checked="update('federatedTrustedShareAutoAccept', federatedTrustedShareAutoAccept)">
+ {{ t('federatedfilesharing', 'Automatically accept shares from trusted federated accounts and groups by default') }}
+ </NcCheckboxRadioSwitch>
+ </div>
+ </NcSettingsSection>
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+import { DialogBuilder, DialogSeverity, showError } from '@nextcloud/dialogs'
+import { generateOcsUrl } from '@nextcloud/router'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import axios from '@nextcloud/axios'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+
+import '@nextcloud/password-confirmation/dist/style.css'
+
+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(DialogSeverity.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(DialogSeverity.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)
+ console.error(errorMessage, error)
+ }
+ },
+ },
+}
+</script>
+<style scoped>
+.settings-subsection {
+ margin-top: 20px;
+}
+
+.settings-subsection__name {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ font-weight: bold;
+ max-width: 900px;
+ margin-top: 0;
+}
+</style>
diff --git a/apps/federatedfilesharing/src/components/PersonalSettings.vue b/apps/federatedfilesharing/src/components/PersonalSettings.vue
new file mode 100644
index 00000000000..e58031d5653
--- /dev/null
+++ b/apps/federatedfilesharing/src/components/PersonalSettings.vue
@@ -0,0 +1,219 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcSettingsSection :name="t('federatedfilesharing', 'Federated Cloud')"
+ :description="t('federatedfilesharing', 'You can share with anyone who uses a Nextcloud server or other Open Cloud Mesh (OCM) compatible servers and services! Just put their Federated Cloud ID in the share dialog. It looks like person@cloud.example.com')"
+ :doc-url="docUrlFederated">
+ <NcInputField class="federated-cloud__cloud-id"
+ readonly
+ :label="t('federatedfilesharing', 'Your Federated Cloud ID')"
+ :value="cloudId"
+ :success="isCopied"
+ show-trailing-button
+ :trailing-button-label="copyLinkTooltip"
+ @trailing-button-click="copyCloudId">
+ <template #trailing-button-icon>
+ <IconCheck v-if="isCopied" :size="20" fill-color="var(--color-success)" />
+ <IconClipboard v-else :size="20" />
+ </template>
+ </NcInputField>
+
+ <p class="social-button">
+ {{ t('federatedfilesharing', 'Share it so your friends can share files with you:') }}<br>
+ <NcButton :href="shareFacebookUrl">
+ {{ t('federatedfilesharing', 'Facebook') }}
+ <template #icon>
+ <img class="social-button__icon social-button__icon--bright" :src="urlFacebookIcon">
+ </template>
+ </NcButton>
+ <NcButton :aria-label="t('federatedfilesharing', 'X (formerly Twitter)')"
+ :href="shareXUrl">
+ {{ t('federatedfilesharing', 'formerly Twitter') }}
+ <template #icon>
+ <img class="social-button__icon" :src="urlXIcon">
+ </template>
+ </NcButton>
+ <NcButton :href="shareMastodonUrl">
+ {{ t('federatedfilesharing', 'Mastodon') }}
+ <template #icon>
+ <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">
+ <template #icon>
+ <IconWeb :size="20" />
+ </template>
+ {{ t('federatedfilesharing', 'Add to your website') }}
+ </NcButton>
+ </p>
+
+ <template v-if="showHtml">
+ <p style="margin: 10px 0">
+ <a target="_blank"
+ rel="noreferrer noopener"
+ :href="reference"
+ :style="backgroundStyle">
+ <span :style="linkStyle" />
+ {{ t('federatedfilesharing', 'Share with me via Nextcloud') }}
+ </a>
+ </p>
+
+ <p>
+ {{ t('federatedfilesharing', 'HTML Code:') }}
+ <br>
+ <pre>{{ htmlCode }}</pre>
+ </p>
+ </template>
+ </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 NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import IconWeb from 'vue-material-design-icons/Web.vue'
+import IconCheck from 'vue-material-design-icons/Check.vue'
+import IconClipboard from 'vue-material-design-icons/ContentCopy.vue'
+
+export default {
+ name: 'PersonalSettings',
+ components: {
+ NcButton,
+ NcInputField,
+ NcSettingsSection,
+ IconCheck,
+ IconClipboard,
+ IconWeb,
+ },
+ setup() {
+ return {
+ t,
+
+ 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 (e) {
+ // 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;
+
+ button, a {
+ display: inline-flex;
+ margin-inline-start: 0.5rem;
+ margin-top: 1rem;
+ }
+
+ &__website-button {
+ width: min(100%, 400px) !important;
+ }
+
+ &__icon {
+ height: 20px;
+ width: 20px;
+ filter: var(--background-invert-if-dark);
+
+ &--bright {
+ // Some logos like the Facebook logo have bright color schema
+ filter: var(--background-invert-if-bright);
+ }
+ }
+ }
+
+ .federated-cloud__cloud-id {
+ max-width: 300px;
+ }
+
+ pre {
+ margin-top: 0;
+ white-space: pre-wrap;
+ }
+</style>
diff --git a/apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts b/apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts
new file mode 100644
index 00000000000..79b5138327a
--- /dev/null
+++ b/apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts
@@ -0,0 +1,123 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import RemoteShareDialog from './RemoteShareDialog.vue'
+
+describe('RemoteShareDialog', () => {
+ it('can be mounted', () => {
+ cy.mount(RemoteShareDialog, {
+ propsData: {
+ owner: 'user123',
+ name: 'my-photos',
+ remote: 'nextcloud.local',
+ passwordRequired: false,
+ },
+ })
+
+ cy.findByRole('dialog')
+ .should('be.visible')
+ .and('contain.text', 'user123@nextcloud.local')
+ .and('contain.text', 'my-photos')
+ cy.findByRole('button', { name: 'Cancel' })
+ .should('be.visible')
+ cy.findByRole('button', { name: /add remote share/i })
+ .should('be.visible')
+ })
+
+ it('does not show password input if not enabled', () => {
+ cy.mount(RemoteShareDialog, {
+ propsData: {
+ owner: 'user123',
+ name: 'my-photos',
+ remote: 'nextcloud.local',
+ passwordRequired: false,
+ },
+ })
+
+ cy.findByRole('dialog')
+ .should('be.visible')
+ .find('input[type="password"]')
+ .should('not.exist')
+ })
+
+ it('emits true when accepted', () => {
+ const onClose = cy.spy().as('onClose')
+
+ cy.mount(RemoteShareDialog, {
+ listeners: {
+ close: onClose,
+ },
+ propsData: {
+ owner: 'user123',
+ name: 'my-photos',
+ remote: 'nextcloud.local',
+ passwordRequired: false,
+ },
+ })
+
+ cy.findByRole('button', { name: 'Cancel' }).click()
+ cy.get('@onClose')
+ .should('have.been.calledWith', false)
+ })
+
+ it('show password input if needed', () => {
+ cy.mount(RemoteShareDialog, {
+ propsData: {
+ owner: 'admin',
+ name: 'secret-data',
+ remote: 'nextcloud.local',
+ passwordRequired: true,
+ },
+ })
+
+ cy.findByRole('dialog')
+ .should('be.visible')
+ .find('input[type="password"]')
+ .should('be.visible')
+ })
+
+ it('emits the submitted password', () => {
+ const onClose = cy.spy().as('onClose')
+
+ cy.mount(RemoteShareDialog, {
+ listeners: {
+ close: onClose,
+ },
+ propsData: {
+ owner: 'admin',
+ name: 'secret-data',
+ remote: 'nextcloud.local',
+ passwordRequired: true,
+ },
+ })
+
+ cy.get('input[type="password"]')
+ .type('my password{enter}')
+ cy.get('@onClose')
+ .should('have.been.calledWith', true, 'my password')
+ })
+
+ it('emits no password if cancelled', () => {
+ const onClose = cy.spy().as('onClose')
+
+ cy.mount(RemoteShareDialog, {
+ listeners: {
+ close: onClose,
+ },
+ propsData: {
+ owner: 'admin',
+ name: 'secret-data',
+ remote: 'nextcloud.local',
+ passwordRequired: true,
+ },
+ })
+
+ cy.get('input[type="password"]')
+ .type('my password')
+ cy.findByRole('button', { name: 'Cancel' }).click()
+ cy.get('@onClose')
+ .should('have.been.calledWith', false)
+ })
+})
diff --git a/apps/federatedfilesharing/src/components/RemoteShareDialog.vue b/apps/federatedfilesharing/src/components/RemoteShareDialog.vue
new file mode 100644
index 00000000000..9ee44f586bf
--- /dev/null
+++ b/apps/federatedfilesharing/src/components/RemoteShareDialog.vue
@@ -0,0 +1,67 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { computed, ref } from 'vue'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+
+const props = defineProps<{
+ /** Name of the share */
+ name: string
+ /** Display name of the owner */
+ owner: string
+ /** The remote instance name */
+ remote: string
+ /** True if the user should enter a password */
+ passwordRequired: boolean
+}>()
+
+const emit = defineEmits<{
+ (e: 'close', state: boolean, password?: string): void
+}>()
+
+const password = ref('')
+
+/**
+ * The dialog buttons
+ */
+const buttons = computed(() => [
+ {
+ label: t('federatedfilesharing', 'Cancel'),
+ callback: () => emit('close', false),
+ },
+ {
+ label: t('federatedfilesharing', 'Add remote share'),
+ nativeType: props.passwordRequired ? 'submit' : undefined,
+ type: 'primary',
+ callback: () => emit('close', true, password.value),
+ },
+])
+</script>
+
+<template>
+ <NcDialog :buttons="buttons"
+ :is-form="passwordRequired"
+ :name="t('federatedfilesharing', 'Remote share')"
+ @submit="emit('close', true, password)">
+ <p>
+ {{ t('federatedfilesharing', 'Do you want to add the remote share {name} from {owner}@{remote}?', { name, owner, remote }) }}
+ </p>
+ <NcPasswordField v-if="passwordRequired"
+ class="remote-share-dialog__password"
+ :label="t('federatedfilesharing', 'Remote share password')"
+ :value.sync="password" />
+ </NcDialog>
+</template>
+
+<style scoped lang="scss">
+.remote-share-dialog {
+
+ &__password {
+ margin-block: 1em .5em;
+ }
+}
+</style>
diff --git a/apps/federatedfilesharing/src/external.js b/apps/federatedfilesharing/src/external.js
new file mode 100644
index 00000000000..3581a24e95a
--- /dev/null
+++ b/apps/federatedfilesharing/src/external.js
@@ -0,0 +1,153 @@
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2014-2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import axios, { isAxiosError } from '@nextcloud/axios'
+import { showError, showInfo } from '@nextcloud/dialogs'
+import { subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+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)
+ // eslint-disable-next-line n/no-callback-literal
+ .then((password) => callback(true, { ...share, password }))
+ // eslint-disable-next-line n/no-callback-literal
+ .catch(() => callback(false, share))
+}
+
+window.addEventListener('DOMContentLoaded', () => {
+ processIncomingShareFromUrl()
+
+ if (loadState('federatedfilesharing', 'notificationsEnabled', true) !== true) {
+ // No notification app, display the modal
+ processSharesToConfirm()
+ }
+
+ subscribe('notifications:action:executed', ({ action, notification }) => {
+ if (notification.app === 'files_sharing' && notification.object_type === 'remote_share' && action.type === 'POST') {
+ // User accepted a remote share reload
+ reloadFilesList()
+ }
+ })
+})
+
+/**
+ * Reload the files list to show accepted shares
+ */
+function reloadFilesList() {
+ if (!window?.OCP?.Files?.Router?.goToRoute) {
+ // No router, just reload the page
+ window.location.reload()
+ return
+ }
+
+ // Let's redirect to the root as any accepted share would be there
+ window.OCP.Files.Router.goToRoute(
+ null,
+ { ...window.OCP.Files.Router.params, fileid: undefined },
+ { ...window.OCP.Files.Router.query, dir: '/', openfile: undefined },
+ )
+}
+
+/**
+ * Process incoming remote share that might have been passed
+ * through the URL
+ */
+function processIncomingShareFromUrl() {
+ const params = window.OC.Util.History.parseUrlQuery()
+
+ // manually add server-to-server share
+ if (params.remote && params.token && params.name) {
+
+ const callbackAddShare = (result, share) => {
+ if (result === false) {
+ return
+ }
+
+ axios.post(
+ generateUrl('apps/federatedfilesharing/askForFederatedShare'),
+ {
+ remote: share.remote,
+ token: share.token,
+ owner: share.owner,
+ ownerDisplayName: share.ownerDisplayName || share.owner,
+ name: share.name,
+ password: share.password || '',
+ },
+ ).then(({ data }) => {
+ if (Object.hasOwn(data, 'legacyMount')) {
+ reloadFilesList()
+ } else {
+ showInfo(data.message)
+ }
+ }).catch((error) => {
+ logger.error('Error while processing incoming share', { error })
+
+ if (isAxiosError(error) && error.response.data.message) {
+ showError(error.response.data.message)
+ } else {
+ showError(t('federatedfilesharing', 'Incoming share could not be processed'))
+ }
+ })
+ }
+
+ // clear hash, it is unlikely that it contain any extra parameters
+ location.hash = ''
+ params.passwordProtected = parseInt(params.protected, 10) === 1
+ window.OCA.Sharing.showAddExternalDialog(
+ params,
+ params.passwordProtected,
+ callbackAddShare,
+ )
+ }
+}
+
+/**
+ * Retrieve a list of remote shares that need to be approved
+ */
+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(
+ shares[index],
+ false,
+ function(result, share) {
+ if (result === false) {
+ // Delete
+ axios.delete(generateUrl('/apps/files_sharing/api/externalShares/' + share.id))
+ } else {
+ // Accept
+ axios.post(generateUrl('/apps/files_sharing/api/externalShares'), { id: share.id })
+ .then(() => reloadFilesList())
+ }
+ },
+ )
+ }
+}
diff --git a/apps/federatedfilesharing/src/main-admin.js b/apps/federatedfilesharing/src/main-admin.js
new file mode 100644
index 00000000000..9e3e25fe7cb
--- /dev/null
+++ b/apps/federatedfilesharing/src/main-admin.js
@@ -0,0 +1,25 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import Vue from 'vue'
+import { getCSPNonce } from '@nextcloud/auth'
+import { translate as t } from '@nextcloud/l10n'
+import { loadState } from '@nextcloud/initial-state'
+
+import AdminSettings from './components/AdminSettings.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+Vue.mixin({
+ methods: {
+ t,
+ },
+})
+
+const internalOnly = loadState('federatedfilesharing', 'internalOnly', false)
+
+if (!internalOnly) {
+ const AdminSettingsView = Vue.extend(AdminSettings)
+ new AdminSettingsView().$mount('#vue-admin-federated')
+}
diff --git a/apps/federatedfilesharing/src/main-personal.js b/apps/federatedfilesharing/src/main-personal.js
new file mode 100644
index 00000000000..a4ff1e6a669
--- /dev/null
+++ b/apps/federatedfilesharing/src/main-personal.js
@@ -0,0 +1,20 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import Vue from 'vue'
+import { getCSPNonce } from '@nextcloud/auth'
+import { translate as t } from '@nextcloud/l10n'
+
+import PersonalSettings from './components/PersonalSettings.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+Vue.mixin({
+ methods: {
+ t,
+ },
+})
+
+const PersonalSettingsView = Vue.extend(PersonalSettings)
+new PersonalSettingsView().$mount('#vue-personal-federated')
diff --git a/apps/federatedfilesharing/src/services/dialogService.spec.ts b/apps/federatedfilesharing/src/services/dialogService.spec.ts
new file mode 100644
index 00000000000..0ad02fa4e00
--- /dev/null
+++ b/apps/federatedfilesharing/src/services/dialogService.spec.ts
@@ -0,0 +1,65 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it } from 'vitest'
+import { showRemoteShareDialog } from './dialogService'
+import { nextTick } from 'vue'
+
+describe('federatedfilesharing: dialog service', () => {
+ it('mounts dialog', async () => {
+ showRemoteShareDialog('share-name', 'user123', 'example.com')
+ await nextTick()
+ expect(document.querySelector('[role="dialog"]')).not.toBeNull()
+ expect(document.querySelector('[role="dialog"]')!.textContent).to.contain('share-name')
+ expect(document.querySelector('[role="dialog"]')!.textContent).to.contain('user123@example.com')
+ expect(document.querySelector('[role="dialog"] input[type="password"]')).toBeNull()
+ })
+
+ it('shows password input', async () => {
+ showRemoteShareDialog('share-name', 'user123', 'example.com', true)
+ await nextTick()
+ expect(document.querySelector('[role="dialog"]')).not.toBeNull()
+ expect(document.querySelector('[role="dialog"] input[type="password"]')).not.toBeNull()
+ })
+
+ it('resolves if accepted', async () => {
+ const promise = showRemoteShareDialog('share-name', 'user123', 'example.com')
+ await nextTick()
+
+ for (const button of document.querySelectorAll('button').values()) {
+ if (button.textContent?.match(/add remote share/i)) {
+ button.click()
+ }
+ }
+
+ expect(await promise).toBe(undefined)
+ })
+
+ it('resolves password if accepted', async () => {
+ const promise = showRemoteShareDialog('share-name', 'user123', 'example.com', true)
+ await nextTick()
+
+ for (const button of document.querySelectorAll('button').values()) {
+ if (button.textContent?.match(/add remote share/i)) {
+ button.click()
+ }
+ }
+
+ expect(await promise).toBe('')
+ })
+
+ it('rejects if cancelled', async () => {
+ const promise = showRemoteShareDialog('share-name', 'user123', 'example.com')
+ await nextTick()
+
+ for (const button of document.querySelectorAll('button').values()) {
+ if (button.textContent?.match(/cancel/i)) {
+ button.click()
+ }
+ }
+
+ expect(async () => await promise).rejects.toThrow()
+ })
+})
diff --git a/apps/federatedfilesharing/src/services/dialogService.ts b/apps/federatedfilesharing/src/services/dialogService.ts
new file mode 100644
index 00000000000..a38c6c57707
--- /dev/null
+++ b/apps/federatedfilesharing/src/services/dialogService.ts
@@ -0,0 +1,36 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { spawnDialog } from '@nextcloud/dialogs'
+import RemoteShareDialog from '../components/RemoteShareDialog.vue'
+
+/**
+ * Open a dialog to ask the user whether to add a remote share.
+ *
+ * @param name The name of the share
+ * @param owner The owner of the share
+ * @param remote The remote address
+ * @param passwordRequired True if the share is password protected
+ */
+export 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()
+ }
+ })
+
+ return promise
+}
diff --git a/apps/federatedfilesharing/src/services/logger.ts b/apps/federatedfilesharing/src/services/logger.ts
new file mode 100644
index 00000000000..04d975150f5
--- /dev/null
+++ b/apps/federatedfilesharing/src/services/logger.ts
@@ -0,0 +1,10 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getLoggerBuilder } from '@nextcloud/logger'
+
+const logger = getLoggerBuilder()
+ .setApp('federatedfilesharing')
+ .build()
+export default logger