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 0000000000..01c0d02d2e --- /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 0000000000..9eded7b97c --- /dev/null +++ b/packages/web-app-ocm/src/composables/useInvitationAcceptance.ts @@ -0,0 +1,56 @@ +import { ref, computed } from 'vue' +import { useClientService, useMessages } from '@opencloud-eu/web-pkg' +import { useGettext } from 'vue3-gettext' + +export function useInvitationAcceptance() { + const clientService = useClientService() + const { showErrorMessage } = useMessages() + const { $gettext } = useGettext() + + const loading = ref(false) + const error = ref(false) + + 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 { + 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), + 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 0000000000..b3a56a582f --- /dev/null +++ b/packages/web-app-ocm/src/composables/useWayf.ts @@ -0,0 +1,195 @@ +import { ref, computed } from 'vue' +import { useClientService, useMessages } from '@opencloud-eu/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/index.ts b/packages/web-app-ocm/src/index.ts index d79f38316a..c382051df1 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 '@opencloud-eu/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 0000000000..a208afd76c --- /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 e5cdf748d5..03af706444 100644 --- a/packages/web-app-ocm/src/views/App.vue +++ b/packages/web-app-ocm/src/views/App.vue @@ -23,19 +23,31 @@ /> + + + + diff --git a/packages/web-app-ocm/src/views/OutgoingInvitations.vue b/packages/web-app-ocm/src/views/OutgoingInvitations.vue index dc2feaea6e..3f4dc64901 100644 --- a/packages/web-app-ocm/src/views/OutgoingInvitations.vue +++ b/packages/web-app-ocm/src/views/OutgoingInvitations.vue @@ -60,17 +60,37 @@ @@ -183,9 +203,18 @@ export default defineComponent({ } }) + const getTokenAtProvider = (token: string) => { + const url = new URL(configStore.serverUrl) + return `${token}@${url.host}` + } + const encodeInviteToken = (token: string) => { + return btoa(getTokenAtProvider(token)) + } + + const generateWayfLink = (token: string) => { const url = new URL(configStore.serverUrl) - return btoa(`${token}@${url.host}`) + return `${url.origin}/open-cloud-mesh/wayf?token=${token}` } const generateToken = async () => { @@ -228,7 +257,7 @@ export default defineComponent({ const quickToken = encodeInviteToken(tokenInfo.token) lastCreatedToken.value = quickToken - navigator.clipboard.writeText(quickToken) + await navigator.clipboard.writeText(quickToken) } } catch (error) { lastCreatedToken.value = '' @@ -267,17 +296,37 @@ export default defineComponent({ const copyLink = (rowData: { item: { link: string; token: string } }) => { navigator.clipboard.writeText(rowData.item.link) showMessage({ - title: $gettext('Invition link copied'), + title: $gettext('Invitation link copied'), desc: $gettext('Invitation link has been copied to your clipboard.') }) } + + const copyPlainToken = (rowData: { item: { token: string } }) => { + const tokenAtProvider = getTokenAtProvider(rowData.item.token) + navigator.clipboard.writeText(tokenAtProvider) + showMessage({ + title: $gettext('Plain token copied'), + desc: $gettext('Plain token has been copied to your clipboard.') + }) + } + const copyToken = (rowData: { item: { link: string; token: string } }) => { navigator.clipboard.writeText(encodeInviteToken(rowData.item.token)) showMessage({ - title: $gettext('Invite token copied'), - desc: $gettext('Invite token has been copied to your clipboard.') + title: $gettext('Base64 token copied'), + desc: $gettext('Base64 token has been copied to your clipboard.') }) } + + const copyWayfLink = (rowData: { item: { token: string } }) => { + const wayfLink = generateWayfLink(rowData.item.token) + navigator.clipboard.writeText(wayfLink) + showMessage({ + title: $gettext('Invite link copied'), + desc: $gettext('Invite link has been copied to your clipboard.') + }) + } + const errorPopup = (error: Error) => { console.error(error) showErrorMessage({ @@ -327,6 +376,8 @@ export default defineComponent({ sortedTokens, copyToken, copyLink, + copyPlainToken, + copyWayfLink, lastCreatedToken, fields, formatDate, diff --git a/packages/web-app-ocm/src/views/Wayf.vue b/packages/web-app-ocm/src/views/Wayf.vue new file mode 100644 index 0000000000..2352985873 --- /dev/null +++ b/packages/web-app-ocm/src/views/Wayf.vue @@ -0,0 +1,304 @@ + + + + +