From 521e80b0ae7732c3da12bd9bae43496d2b981459 Mon Sep 17 00:00:00 2001 From: Mahdi Baghbani Date: Fri, 24 Oct 2025 17:03:06 +0000 Subject: [PATCH] feat(ocm): implement WAYF page and enhance invitation workflow add: Wayf.vue component with provider selection and federation discovery add: useWayf.ts composable for WAYF logic and provider management add: InvitationAcceptanceModal.vue for invitation acceptance add: useInvitationAcceptance.ts composable to share acceptance logic add: wayf.ts types for federation and provider interfaces add: WAYF documentation add: /wayf route with anonymous authentication support add: /accept-invite route for invitation acceptance workflow add: Configure anonymous authContext for public WAYF access refactor: IncomingInvitations to use shared acceptance composable add: multiple token copy options to OutgoingInvitations (plain, base64, WAYF link) enhance: token display with separate copy buttons for each format enhance: Prevent users from accepting self-generated invitations enhance: Filter out current instance from federation provider lists enhance: Validate manual provider entries against self-domain Signed-off-by: Mahdi Baghbani --- .../unreleased/enhancement-ocm-wayf-page.md | 11 + .../docs/Where Are You From page.md | 122 ++++ .../composables/useInvitationAcceptance.ts | 66 ++ .../web-app-ocm/src/composables/useWayf.ts | 195 ++++++ packages/web-app-ocm/src/extensions.ts | 2 +- packages/web-app-ocm/src/index.ts | 28 + packages/web-app-ocm/src/types/wayf.ts | 35 ++ packages/web-app-ocm/src/views/App.vue | 59 +- .../src/views/ConnectionsPanel.vue | 13 +- .../src/views/IncomingInvitations.vue | 61 +- .../src/views/InvitationAcceptanceModal.vue | 101 ++++ .../src/views/OutgoingInvitations.vue | 104 +++- packages/web-app-ocm/src/views/Wayf.vue | 564 ++++++++++++++++++ .../piniaStores/inviteTokensList.ts | 2 + 14 files changed, 1297 insertions(+), 66 deletions(-) create mode 100644 changelog/unreleased/enhancement-ocm-wayf-page.md create mode 100644 packages/web-app-ocm/docs/Where Are You From page.md create mode 100644 packages/web-app-ocm/src/composables/useInvitationAcceptance.ts create mode 100644 packages/web-app-ocm/src/composables/useWayf.ts create mode 100644 packages/web-app-ocm/src/types/wayf.ts create mode 100644 packages/web-app-ocm/src/views/InvitationAcceptanceModal.vue create mode 100644 packages/web-app-ocm/src/views/Wayf.vue diff --git a/changelog/unreleased/enhancement-ocm-wayf-page.md b/changelog/unreleased/enhancement-ocm-wayf-page.md new file mode 100644 index 00000000000..5f5abd0517e --- /dev/null +++ b/changelog/unreleased/enhancement-ocm-wayf-page.md @@ -0,0 +1,11 @@ +Enhancement: Add WAYF page for OCM + +We've implemented a Where Are You From page that allows users to discover and select their cloud provider when accepting federated invitations. +The new page includes federation browsing, provider search functionality, and manual provider entry with automatic OCM discovery. + +The implementation added new routes (`/wayf` and `/accept-invite`) with anonymous authentication support, enabling users to access the provider selection interface without prior authentication. +Outgoing invitations now include multiple token copy options (plain, base64, and WAYF link) for improved sharing flexibility. + +Enhancements prevent users from accepting self-generated invitations and automatically exclude the current instance from available provider lists. + +https://github.com/owncloud/web/pull/13243 diff --git a/packages/web-app-ocm/docs/Where Are You From page.md b/packages/web-app-ocm/docs/Where Are You From page.md new file mode 100644 index 00000000000..01c0d02d2ec --- /dev/null +++ b/packages/web-app-ocm/docs/Where Are You From page.md @@ -0,0 +1,122 @@ +# Where Are You From + +## Overview + +The WAYF page allows users to select their cloud provider when accepting an invitation. It's accessible at the path `domain.tld/open-cloud-mesh/wayf?token=token`. + +## Usage + +### 1. Basic Functionality Test + +Navigate to: `http://localhost:3000/open-cloud-mesh/wayf?token=test-token-123` + +Expected behavior: + +- Page loads with "Where Are You From?" header +- Shows loading state initially +- Displays list of available providers grouped by federation +- Search functionality works +- Manual provider input is available + +### 2. URL Parameters Test + +Test with different URL parameters: + +- `?token=test-token-123` - Should show providers +- No token - Should show "You need a token for this feature to work" + +**Note**: `providerDomain` is ALWAYS auto-detected from `window.location.hostname` and should NOT be passed in the URL for security reasons. + +### 3. Provider Selection Test + +- Click on any provider from the list +- Should redirect to the provider's accept-invite page with token and domain parameters + +### 4. Manual Provider Test + +- Enter a domain in the manual provider field (e.g., "example.com") +- Press Enter or click the input +- Should attempt to discover the provider and redirect + +### 5. Search Functionality Test + +- Type in the search box to filter providers +- Should filter by provider name or FQDN +- Clear search should show all providers again + +### 6. Error Handling Test + +- Test with invalid provider domains +- Should show appropriate error messages +- Should not crash the application + +### 7. Self-Domain Prevention Test + +**Test that users cannot select their own instance:** + +- If `window.location.hostname` is `example.com`: + - Federation list should NOT include any provider with FQDN `example.com` + - Manual entry of `example.com` should show error + - Manual entry of `http://example.com` should show error + - Manual entry of `https://example.com:443` should show error +- Error message should be: "Invalid Provider Selection - You cannot select your own instance as a provider..." + +### How It Works + +1. **Federation List Filtering**: When loading federations from the backend, any provider whose domain matches `window.location.hostname` is automatically filtered out +2. **Provider Selection Validation**: If a user somehow selects their own domain from the list, an error message is shown +3. **Manual Entry Validation**: If a user manually enters their own domain, an error message is shown + +### Error Message + +When attempting to select own instance: + +``` +Title: Invalid Provider Selection +Message: You cannot select your own instance as a provider. + Please select a different provider to establish a federated connection. +``` + +### Implementation Details + +- Domain comparison is case-insensitive +- Protocols (`http://`, `https://`) are stripped before comparison +- Port numbers are stripped before comparison +- If backend returns the current instance in federations, it's automatically dropped from the list + +## API Endpoints Used + +### Backend Endpoints + +- `GET /sciencemesh/federations` - Loads list of available federations (public endpoint) +- `POST /sciencemesh/discover` - Discovers provider's OCM API endpoint for manual entry (public endpoint) + +### External OCM Discovery + +- `https://{provider-domain}/.well-known/ocm` - OCM discovery endpoint (called by backend, not frontend) +- `https://{provider-domain}/ocm-provider` - Legacy OCM discovery endpoint (fallback) + +## Expected URL Structure + +When a provider is selected, the user is redirected to: + +``` +{provider-invite-accept-dialog}?token={token}&providerDomain={providerDomain} +``` + +**Important Clarification**: + +- `providerDomain` = The domain **where the WAYF page is hosted** (YOUR domain, the inviting party) +- `provider-invite-accept-dialog` = The selected provider's invite acceptance URL + +**Example Flow**: + +1. User visits WAYF on: `your-domain.com/open-cloud-mesh/wayf?token=abc123` +2. User selects provider: CERNBox +3. User is redirected to: `qa.cernbox.cern.ch/accept?token=abc123&providerDomain=your-domain.com` + - Note: `providerDomain` is `your-domain.com` (NOT `qa.cernbox.cern.ch`) + +**How providerDomain is determined**: + +- **ALWAYS** automatically extracted from `window.location.hostname` +- **NEVER** accepted from query string (security: prevents domain spoofing) 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..b11579a89b2 --- /dev/null +++ b/packages/web-app-ocm/src/composables/useInvitationAcceptance.ts @@ -0,0 +1,66 @@ +import { ref, computed } from 'vue' +import { useClientService, useMessages, useInviteTokensListStore } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' + +export function useInvitationAcceptance() { + const clientService = useClientService() + const { showErrorMessage } = useMessages() + const inviteTokensListStore = useInviteTokensListStore() + const { $gettext } = useGettext() + + const loading = ref(false) + const error = ref(false) + + const isOwnGeneratedToken = (token: string) => { + return inviteTokensListStore.getTokensList().some((t) => t.token === token) + } + + const errorPopup = (error: Error) => { + console.error(error) + showErrorMessage({ + title: $gettext('Error'), + desc: $gettext('An error occurred'), + errors: [error] + }) + } + + const acceptInvitation = async (token: string, providerDomain: string) => { + loading.value = true + error.value = false + + try { + if (isOwnGeneratedToken(token)) { + throw new Error($gettext('Self-invitations are not permitted')) + } + + const response = await clientService.httpAuthenticated.post('/sciencemesh/accept-invite', { + token, + providerDomain + }) + + return true + } catch (err) { + console.error('Error accepting invitation:', err) + error.value = true + errorPopup(err) + throw err + } finally { + loading.value = false + } + } + + const validateParameters = (token: string | undefined, providerDomain: string | undefined) => { + if (!token || !providerDomain) { + throw new Error($gettext('Missing required parameters: token and providerDomain')) + } + } + + return { + loading: computed(() => loading.value), + error: computed(() => error.value), + isOwnGeneratedToken, + acceptInvitation, + validateParameters, + errorPopup + } +} 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..2cdab377780 --- /dev/null +++ b/packages/web-app-ocm/src/composables/useWayf.ts @@ -0,0 +1,195 @@ +import { ref, computed } from 'vue' +import { useClientService, useMessages } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' +import type { + WayfProvider, + WayfFederation, + FederationsApiResponse, + DiscoverRequest, + DiscoverResponse +} from '../types/wayf' + +export function useWayf() { + const clientService = useClientService() + const { showErrorMessage } = useMessages() + const { $gettext } = useGettext() + + const loading = ref(false) + const error = ref(false) + const federations = ref({}) + + const isSelfDomain = (domain: string): boolean => { + const currentHost = window.location.hostname.toLowerCase() + const checkDomain = domain + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/:\d+$/, '') + return currentHost === checkDomain + } + + const discoverProvider = async (domain: string): Promise => { + try { + loading.value = true + const response = await clientService.httpUnAuthenticated.post( + '/sciencemesh/discover', + { domain } as DiscoverRequest + ) + + if (!response.data || !response.data.inviteAcceptDialog) { + throw new Error('No invite accept dialog found in discovery response') + } + + return response.data.inviteAcceptDialog + } catch (err) { + console.error('Provider discovery failed:', err) + showErrorMessage({ + title: $gettext('Discovery Failed'), + desc: $gettext('Could not discover provider at %{domain}', { domain }), + errors: [err] + }) + throw err + } finally { + loading.value = false + } + } + + const buildProviderUrl = (baseUrl: string, token: string, providerDomain?: string): string => { + const url = new URL(baseUrl) + if (providerDomain) url.searchParams.set('providerDomain', providerDomain) + if (token) url.searchParams.set('token', token) + return url.toString() + } + + const navigateToProvider = async ( + provider: WayfProvider, + token: string, + providerDomain?: string + ) => { + if (isSelfDomain(provider.fqdn)) { + showErrorMessage({ + title: $gettext('Invalid Provider Selection'), + desc: $gettext( + 'You cannot select your own instance as a provider. Please select a different provider to establish a federated connection.' + ) + }) + return + } + + try { + loading.value = true + let inviteDialogUrl = provider.inviteAcceptDialog + + // If inviteAcceptDialog is empty, call backend discovery + if (!inviteDialogUrl || inviteDialogUrl.trim() === '') { + inviteDialogUrl = await discoverProvider(provider.fqdn) + } else { + // If it's a relative path, make it absolute + if (inviteDialogUrl.startsWith('/')) { + const baseUrl = `https://${provider.fqdn}` + inviteDialogUrl = `${baseUrl}${inviteDialogUrl}` + } + // If it's already absolute, use as-is + } + + // Build final URL with query parameters and redirect + const finalUrl = buildProviderUrl(inviteDialogUrl, token, providerDomain) + window.location.href = finalUrl + } catch (err) { + console.error('Failed to navigate to provider:', err) + // Error is already shown by discoverProvider, do not use showErrorMessage here + } finally { + loading.value = false + } + } + + const navigateToManualProvider = async ( + input: string, + token: string, + providerDomain?: string + ) => { + const trimmedInput = input.trim() + if (!trimmedInput) return + + if (isSelfDomain(trimmedInput)) { + showErrorMessage({ + title: $gettext('Invalid Provider Selection'), + desc: $gettext( + 'You cannot use your own instance as a provider. Please select a different provider to establish a federated connection.' + ) + }) + return + } + + try { + loading.value = true + const inviteDialogUrl = await discoverProvider(trimmedInput) + const finalUrl = buildProviderUrl(inviteDialogUrl, token, providerDomain) + window.location.href = finalUrl + } catch (err) { + console.error('Failed to navigate to manual provider:', err) + // Error is already shown by discoverProvider, do not use showErrorMessage here + } finally { + loading.value = false + } + } + + const loadFederations = async () => { + try { + loading.value = true + error.value = false + + const response = await clientService.httpUnAuthenticated.get( + '/sciencemesh/federations' + ) + + const transformedFederations: WayfFederation = {} + response.data.forEach((fed) => { + const providers = fed.servers + .map((server) => ({ + name: server.displayName, + fqdn: new URL(server.url).hostname, + // Keep empty if not provided by the server + inviteAcceptDialog: server.inviteAcceptDialog || '' + })) + .filter((provider) => !isSelfDomain(provider.fqdn)) + + if (providers.length > 0) { + transformedFederations[fed.federation] = providers + } + }) + + federations.value = transformedFederations + } catch (err) { + console.error('Failed to load federations:', err) + error.value = true + showErrorMessage({ + title: $gettext('Failed to Load Providers'), + desc: $gettext('Could not load the list of available providers'), + errors: [err] + }) + } finally { + loading.value = false + } + } + + const filterProviders = (providers: WayfProvider[], query: string): WayfProvider[] => { + const searchTerm = (query || '').toLowerCase() + return providers.filter( + (provider) => + provider.name.toLowerCase().includes(searchTerm) || + provider.fqdn.toLowerCase().includes(searchTerm) + ) + } + + return { + loading: computed(() => loading.value), + error: computed(() => error.value), + federations: computed(() => federations.value), + discoverProvider, + buildProviderUrl, + navigateToProvider, + navigateToManualProvider, + loadFederations, + filterProviders + } +} diff --git a/packages/web-app-ocm/src/extensions.ts b/packages/web-app-ocm/src/extensions.ts index 152d0e0d910..5e7b6b90fa8 100644 --- a/packages/web-app-ocm/src/extensions.ts +++ b/packages/web-app-ocm/src/extensions.ts @@ -40,7 +40,7 @@ export const extensions = (appInfo: ApplicationInformation) => { }) } } catch (error) { - console.log(error) + console.error(error) showErrorMessage({ title: $gettext('An error occurred'), desc: $gettext("Couldn't open remotely"), diff --git a/packages/web-app-ocm/src/index.ts b/packages/web-app-ocm/src/index.ts index 09b67f182b6..2b31e980e0c 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,33 @@ const routes: RouteRecordRaw[] = [ patchCleanPath: true, title: 'Invitations' } + }, + { + path: '/accept-invite', + name: 'open-cloud-mesh-accept-invite', + component: App, + meta: { + patchCleanPath: true, + title: 'Accept Invitation' + } + }, + { + path: '/wayf', + name: 'open-cloud-mesh-wayf', + component: Wayf, + meta: { + patchCleanPath: true, + title: 'Where Are You From', + /* + How authentication context works: + authContext: 'user' requires full authentication (default) + authContext: 'anonymous' no authentication required + authContext: 'hybrid' works with or without authentication (didn't work without login for me) + authContext: 'idp' requires IdP authentication only + authContext: 'publicLink' for public link contexts + */ + authContext: 'anonymous' + } } ] 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..a208afd76c9 --- /dev/null +++ b/packages/web-app-ocm/src/types/wayf.ts @@ -0,0 +1,35 @@ +// Frontend types +export interface WayfProvider { + name: string + fqdn: string + // Can be empty, relative path, or absolute URL + inviteAcceptDialog: string +} + +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 60bf2be87cb..899e53a2b0c 100644 --- a/packages/web-app-ocm/src/views/App.vue +++ b/packages/web-app-ocm/src/views/App.vue @@ -17,36 +17,87 @@ /> + + diff --git a/packages/web-app-ocm/src/views/IncomingInvitations.vue b/packages/web-app-ocm/src/views/IncomingInvitations.vue index 4a129b0f32e..93260f10c3c 100644 --- a/packages/web-app-ocm/src/views/IncomingInvitations.vue +++ b/packages/web-app-ocm/src/views/IncomingInvitations.vue @@ -48,24 +48,18 @@ + + diff --git a/packages/web-app-ocm/src/views/OutgoingInvitations.vue b/packages/web-app-ocm/src/views/OutgoingInvitations.vue index f902c307f3c..c10f6c5ec68 100644 --- a/packages/web-app-ocm/src/views/OutgoingInvitations.vue +++ b/packages/web-app-ocm/src/views/OutgoingInvitations.vue @@ -62,26 +62,47 @@ :highlighted="inviteTokensListStore.getLastCreatedToken()" >