diff --git a/packages/web-app-ocm/src/composables/useInvitationAcceptance.ts b/packages/web-app-ocm/src/composables/useInvitationAcceptance.ts new file mode 100644 index 00000000000..07ef2e593e8 --- /dev/null +++ b/packages/web-app-ocm/src/composables/useInvitationAcceptance.ts @@ -0,0 +1,67 @@ +import { ref, unref } from 'vue' +import { useClientService, useMessages, useRouter, useRoute } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' + +export const useInvitationAcceptance = () => { + const { showErrorMessage } = useMessages() + const clientService = useClientService() + const router = useRouter() + const route = useRoute() + const { $gettext } = useGettext() + + const isAccepting = ref(false) + + const errorPopup = (error: Error) => { + console.error(error) + showErrorMessage({ + title: $gettext('Error'), + desc: $gettext('An error occurred'), + errors: [error] + }) + } + + const validateParameters = (token: string, providerDomain: string) => { + if (!token || !providerDomain) { + const error = new Error($gettext('Missing required parameters: token and providerDomain')) + errorPopup(error) + return false + } + return true + } + + const acceptInvitation = async (token: string, providerDomain: string): Promise => { + if (!validateParameters(token, providerDomain)) { + return false + } + + isAccepting.value = true + + try { + await clientService.httpAuthenticated.post('/sciencemesh/accept-invite', { + token, + providerDomain + }) + + // Remove query params + const { token: currentToken, providerDomain: currentProvider, ...query } = unref(route).query + await router.replace({ + name: 'open-cloud-mesh-invitations', + query + }) + + return true + } catch (error) { + errorPopup(error) + return false + } finally { + isAccepting.value = false + } + } + + return { + isAccepting, + acceptInvitation, + errorPopup, + validateParameters + } +} diff --git a/packages/web-app-ocm/src/composables/useWayf.ts b/packages/web-app-ocm/src/composables/useWayf.ts new file mode 100644 index 00000000000..6536962434f --- /dev/null +++ b/packages/web-app-ocm/src/composables/useWayf.ts @@ -0,0 +1,182 @@ +import { ref } from 'vue' +import { useClientService, useMessages } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' +import { + WayfFederation, + WayfProvider, + FederationsApiResponse, + DiscoverResponse +} from '../types/wayf' + +export const useWayf = () => { + const { showErrorMessage } = useMessages() + const clientService = useClientService() + const { $gettext } = useGettext() + + const federations = ref({}) + const isLoadingFederations = ref(false) + const isDiscovering = ref(false) + + const getCurrentHostname = (): string => { + return window.location.hostname + } + + const stripProtocolAndPort = (domain: string): string => { + try { + const url = new URL(domain.startsWith('http') ? domain : `https://${domain}`) + return url.hostname + } catch { + return domain.replace(/^https?:\/\//, '').split(':')[0] + } + } + + const isSelfDomain = (domain: string): boolean => { + const currentHost = stripProtocolAndPort(getCurrentHostname()) + const checkHost = stripProtocolAndPort(domain) + return currentHost === checkHost + } + + const loadFederations = async () => { + isLoadingFederations.value = true + try { + const { data } = await clientService.httpUnAuthenticated.get( + '/sciencemesh/federations' + ) + + const federationMap: WayfFederation = {} + + data.forEach((federation) => { + const providers = federation.servers + .filter((server) => !isSelfDomain(server.url)) + .map((server) => ({ + name: server.displayName, + fqdn: stripProtocolAndPort(server.url), + inviteAcceptDialog: server.inviteAcceptDialog || '' + })) + + if (providers.length > 0) { + federationMap[federation.federation] = providers + } + }) + + federations.value = federationMap + } catch (error) { + console.error('Failed to load federations:', error) + showErrorMessage({ + title: $gettext('Error'), + desc: $gettext('Failed to load federations'), + errors: [error] + }) + } finally { + isLoadingFederations.value = false + } + } + + const discoverProvider = async (domain: string): Promise => { + isDiscovering.value = true + try { + const { data } = await clientService.httpUnAuthenticated.post( + '/sciencemesh/discover', + { domain } + ) + return data + } catch (error) { + console.error('Failed to discover provider:', error) + showErrorMessage({ + title: $gettext('Error'), + desc: $gettext('Failed to discover provider'), + errors: [error] + }) + return null + } finally { + isDiscovering.value = false + } + } + + const navigateToProvider = ( + provider: WayfProvider, + token: string, + providerDomain: string + ): void => { + if (isSelfDomain(provider.fqdn)) { + showErrorMessage({ + title: $gettext('Error'), + desc: $gettext('You cannot select your own instance') + }) + return + } + + let targetUrl: string + const inviteDialogPath = provider.inviteAcceptDialog || '/open-cloud-mesh/accept-invite' + + if (inviteDialogPath.startsWith('http://') || inviteDialogPath.startsWith('https://')) { + // Absolute URL + targetUrl = inviteDialogPath + } else { + // Relative path + const cleanPath = inviteDialogPath.startsWith('/') ? inviteDialogPath : `/${inviteDialogPath}` + targetUrl = `https://${provider.fqdn}${cleanPath}` + } + + const url = new URL(targetUrl) + url.searchParams.set('token', token) + url.searchParams.set('providerDomain', providerDomain) + + window.location.href = url.toString() + } + + const navigateToManualProvider = async ( + input: string, + token: string, + providerDomain: string + ): Promise => { + const domain = stripProtocolAndPort(input) + + if (isSelfDomain(domain)) { + showErrorMessage({ + title: $gettext('Error'), + desc: $gettext('You cannot select your own instance') + }) + return + } + + const discoveryResult = await discoverProvider(domain) + if (!discoveryResult) { + return + } + + const provider: WayfProvider = { + name: domain, + fqdn: domain, + inviteAcceptDialog: discoveryResult.inviteAcceptDialog || '' + } + + navigateToProvider(provider, token, providerDomain) + } + + const filterProviders = (providers: WayfProvider[], query: string): WayfProvider[] => { + if (!query) { + return providers + } + + const lowerQuery = query.toLowerCase() + return providers.filter( + (provider) => + provider.name.toLowerCase().includes(lowerQuery) || + provider.fqdn.toLowerCase().includes(lowerQuery) + ) + } + + return { + federations, + isLoadingFederations, + isDiscovering, + loadFederations, + discoverProvider, + navigateToProvider, + navigateToManualProvider, + isSelfDomain, + filterProviders, + getCurrentHostname + } +} diff --git a/packages/web-app-ocm/src/index.ts b/packages/web-app-ocm/src/index.ts index 09b67f182b6..7949ad2a18a 100644 --- a/packages/web-app-ocm/src/index.ts +++ b/packages/web-app-ocm/src/index.ts @@ -1,4 +1,5 @@ import App from './views/App.vue' +import Wayf from './views/Wayf.vue' import { ApplicationInformation, defineWebApplication, useRouter } from '@ownclouders/web-pkg' import translations from '../l10n/translations.json' import { extensions } from './extensions' @@ -20,6 +21,25 @@ const routes: RouteRecordRaw[] = [ patchCleanPath: true, title: 'Invitations' } + }, + { + path: '/wayf', + name: 'open-cloud-mesh-wayf', + component: Wayf, + meta: { + patchCleanPath: true, + title: 'Where Are You From', + authContext: 'anonymous' // No authentication required + } + }, + { + path: '/accept-invite', + name: 'open-cloud-mesh-accept-invite', + component: App, + meta: { + patchCleanPath: true, + title: 'Accept Invitation' + } } ] diff --git a/packages/web-app-ocm/src/types/wayf.ts b/packages/web-app-ocm/src/types/wayf.ts new file mode 100644 index 00000000000..b4b864e0df8 --- /dev/null +++ b/packages/web-app-ocm/src/types/wayf.ts @@ -0,0 +1,34 @@ +// Frontend types +export interface WayfProvider { + name: string + fqdn: string + inviteAcceptDialog: string // Can be empty, relative path, or absolute URL +} + +export interface WayfFederation { + [federationName: string]: WayfProvider[] +} + +// Backend API response types +export interface FederationServerResponse { + displayName: string + url: string + inviteAcceptDialog: string +} + +export interface FederationResponse { + federation: string + servers: FederationServerResponse[] +} + +export type FederationsApiResponse = FederationResponse[] + +export interface DiscoverRequest { + domain: string +} + +export interface DiscoverResponse { + inviteAcceptDialog: string + provider?: string + apiVersion?: string +} diff --git a/packages/web-app-ocm/src/views/App.vue b/packages/web-app-ocm/src/views/App.vue index d18101e4373..2f35dc25bfc 100644 --- a/packages/web-app-ocm/src/views/App.vue +++ b/packages/web-app-ocm/src/views/App.vue @@ -17,19 +17,29 @@ /> + diff --git a/packages/web-app-ocm/src/views/OutgoingInvitations.vue b/packages/web-app-ocm/src/views/OutgoingInvitations.vue index 3f5a12babd4..4b21cecd769 100644 --- a/packages/web-app-ocm/src/views/OutgoingInvitations.vue +++ b/packages/web-app-ocm/src/views/OutgoingInvitations.vue @@ -3,7 +3,7 @@
-

+

@@ -41,8 +41,9 @@ " /> - + + +