Skip to content

Commit 08be310

Browse files
committed
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 <[email protected]>
1 parent 3ee643e commit 08be310

File tree

13 files changed

+1290
-66
lines changed

13 files changed

+1290
-66
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Where Are You From
2+
3+
## Overview
4+
5+
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`.
6+
7+
## Usage
8+
9+
### 1. Basic Functionality Test
10+
11+
Navigate to: `http://localhost:3000/open-cloud-mesh/wayf?token=test-token-123`
12+
13+
Expected behavior:
14+
15+
- Page loads with "Where Are You From?" header
16+
- Shows loading state initially
17+
- Displays list of available providers grouped by federation
18+
- Search functionality works
19+
- Manual provider input is available
20+
21+
### 2. URL Parameters Test
22+
23+
Test with different URL parameters:
24+
25+
- `?token=test-token-123` - Should show providers
26+
- No token - Should show "You need a token for this feature to work"
27+
28+
**Note**: `providerDomain` is ALWAYS auto-detected from `window.location.hostname` and should NOT be passed in the URL for security reasons.
29+
30+
### 3. Provider Selection Test
31+
32+
- Click on any provider from the list
33+
- Should redirect to the provider's accept-invite page with token and domain parameters
34+
35+
### 4. Manual Provider Test
36+
37+
- Enter a domain in the manual provider field (e.g., "example.com")
38+
- Press Enter or click the input
39+
- Should attempt to discover the provider and redirect
40+
41+
### 5. Search Functionality Test
42+
43+
- Type in the search box to filter providers
44+
- Should filter by provider name or FQDN
45+
- Clear search should show all providers again
46+
47+
### 6. Error Handling Test
48+
49+
- Test with invalid provider domains
50+
- Should show appropriate error messages
51+
- Should not crash the application
52+
53+
### 7. Self-Domain Prevention Test
54+
55+
**Test that users cannot select their own instance:**
56+
57+
- If `window.location.hostname` is `example.com`:
58+
- Federation list should NOT include any provider with FQDN `example.com`
59+
- Manual entry of `example.com` should show error
60+
- Manual entry of `http://example.com` should show error
61+
- Manual entry of `https://example.com:443` should show error
62+
- Error message should be: "Invalid Provider Selection - You cannot select your own instance as a provider..."
63+
64+
### How It Works
65+
66+
1. **Federation List Filtering**: When loading federations from the backend, any provider whose domain matches `window.location.hostname` is automatically filtered out
67+
2. **Provider Selection Validation**: If a user somehow selects their own domain from the list, an error message is shown
68+
3. **Manual Entry Validation**: If a user manually enters their own domain, an error message is shown
69+
70+
### Error Message
71+
72+
When attempting to select own instance:
73+
74+
```
75+
Title: Invalid Provider Selection
76+
Message: You cannot select your own instance as a provider.
77+
Please select a different provider to establish a federated connection.
78+
```
79+
80+
### Implementation Details
81+
82+
- Domain comparison is case-insensitive
83+
- Protocols (`http://`, `https://`) are stripped before comparison
84+
- Port numbers are stripped before comparison
85+
- If backend returns the current instance in federations, it's automatically dropped from the list
86+
87+
## API Endpoints Used
88+
89+
### Backend Endpoints
90+
91+
- `GET /sciencemesh/federations` - Loads list of available federations (public endpoint)
92+
- `POST /sciencemesh/discover` - Discovers provider's OCM API endpoint for manual entry (public endpoint)
93+
94+
### External OCM Discovery
95+
96+
- `https://{provider-domain}/.well-known/ocm` - OCM discovery endpoint (called by backend, not frontend)
97+
- `https://{provider-domain}/ocm-provider` - Legacy OCM discovery endpoint (fallback)
98+
99+
## Expected URL Structure
100+
101+
When a provider is selected, the user is redirected to:
102+
103+
```
104+
{provider-invite-accept-dialog}?token={token}&providerDomain={providerDomain}
105+
```
106+
107+
**Important Clarification**:
108+
109+
- `providerDomain` = The domain **where the WAYF page is hosted** (YOUR domain, the inviting party)
110+
- `provider-invite-accept-dialog` = The selected provider's invite acceptance URL
111+
112+
**Example Flow**:
113+
114+
1. User visits WAYF on: `your-domain.com/open-cloud-mesh/wayf?token=abc123`
115+
2. User selects provider: CERNBox
116+
3. User is redirected to: `qa.cernbox.cern.ch/accept?token=abc123&providerDomain=your-domain.com`
117+
- Note: `providerDomain` is `your-domain.com` (NOT `qa.cernbox.cern.ch`)
118+
119+
**How providerDomain is determined**:
120+
121+
- **ALWAYS** automatically extracted from `window.location.hostname`
122+
- **NEVER** accepted from query string (security: prevents domain spoofing)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ref, computed } from 'vue'
2+
import { useClientService, useMessages, useInviteTokensListStore } from '@ownclouders/web-pkg'
3+
import { useGettext } from 'vue3-gettext'
4+
5+
export function useInvitationAcceptance() {
6+
const clientService = useClientService()
7+
const { showErrorMessage } = useMessages()
8+
const inviteTokensListStore = useInviteTokensListStore()
9+
const { $gettext } = useGettext()
10+
11+
const loading = ref(false)
12+
const error = ref(false)
13+
14+
const isOwnGeneratedToken = (token: string) => {
15+
return inviteTokensListStore.getTokensList().some((t) => t.token === token)
16+
}
17+
18+
const errorPopup = (error: Error) => {
19+
console.error(error)
20+
showErrorMessage({
21+
title: $gettext('Error'),
22+
desc: $gettext('An error occurred'),
23+
errors: [error]
24+
})
25+
}
26+
27+
const acceptInvitation = async (token: string, providerDomain: string) => {
28+
loading.value = true
29+
error.value = false
30+
31+
try {
32+
if (isOwnGeneratedToken(token)) {
33+
throw new Error($gettext('Self-invitations are not permitted'))
34+
}
35+
36+
const response = await clientService.httpAuthenticated.post('/sciencemesh/accept-invite', {
37+
token,
38+
providerDomain
39+
})
40+
41+
return true
42+
} catch (err) {
43+
console.error('Error accepting invitation:', err)
44+
error.value = true
45+
errorPopup(err)
46+
throw err
47+
} finally {
48+
loading.value = false
49+
}
50+
}
51+
52+
const validateParameters = (token: string | undefined, providerDomain: string | undefined) => {
53+
if (!token || !providerDomain) {
54+
throw new Error($gettext('Missing required parameters: token and providerDomain'))
55+
}
56+
}
57+
58+
return {
59+
loading: computed(() => loading.value),
60+
error: computed(() => error.value),
61+
isOwnGeneratedToken,
62+
acceptInvitation,
63+
validateParameters,
64+
errorPopup
65+
}
66+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { ref, computed } from 'vue'
2+
import { useClientService, useMessages } from '@ownclouders/web-pkg'
3+
import { useGettext } from 'vue3-gettext'
4+
import type {
5+
WayfProvider,
6+
WayfFederation,
7+
FederationsApiResponse,
8+
DiscoverRequest,
9+
DiscoverResponse
10+
} from '../types/wayf'
11+
12+
export function useWayf() {
13+
const clientService = useClientService()
14+
const { showErrorMessage } = useMessages()
15+
const { $gettext } = useGettext()
16+
17+
const loading = ref(false)
18+
const error = ref(false)
19+
const federations = ref<WayfFederation>({})
20+
21+
const isSelfDomain = (domain: string): boolean => {
22+
const currentHost = window.location.hostname.toLowerCase()
23+
const checkDomain = domain
24+
.toLowerCase()
25+
.replace(/^https?:\/\//, '')
26+
.replace(/:\d+$/, '')
27+
return currentHost === checkDomain
28+
}
29+
30+
const discoverProvider = async (domain: string): Promise<string> => {
31+
try {
32+
loading.value = true
33+
const response = await clientService.httpUnAuthenticated.post<DiscoverResponse>(
34+
'/sciencemesh/discover',
35+
{ domain } as DiscoverRequest
36+
)
37+
38+
if (!response.data || !response.data.inviteAcceptDialog) {
39+
throw new Error('No invite accept dialog found in discovery response')
40+
}
41+
42+
return response.data.inviteAcceptDialog
43+
} catch (err) {
44+
console.error('Provider discovery failed:', err)
45+
showErrorMessage({
46+
title: $gettext('Discovery Failed'),
47+
desc: $gettext('Could not discover provider at %{domain}', { domain }),
48+
errors: [err]
49+
})
50+
throw err
51+
} finally {
52+
loading.value = false
53+
}
54+
}
55+
56+
const buildProviderUrl = (baseUrl: string, token: string, providerDomain?: string): string => {
57+
const url = new URL(baseUrl)
58+
if (providerDomain) url.searchParams.set('providerDomain', providerDomain)
59+
if (token) url.searchParams.set('token', token)
60+
return url.toString()
61+
}
62+
63+
const navigateToProvider = async (
64+
provider: WayfProvider,
65+
token: string,
66+
providerDomain?: string
67+
) => {
68+
if (isSelfDomain(provider.fqdn)) {
69+
showErrorMessage({
70+
title: $gettext('Invalid Provider Selection'),
71+
desc: $gettext(
72+
'You cannot select your own instance as a provider. Please select a different provider to establish a federated connection.'
73+
)
74+
})
75+
return
76+
}
77+
78+
try {
79+
loading.value = true
80+
let inviteDialogUrl = provider.inviteAcceptDialog
81+
82+
// If inviteAcceptDialog is empty, call backend discovery
83+
if (!inviteDialogUrl || inviteDialogUrl.trim() === '') {
84+
inviteDialogUrl = await discoverProvider(provider.fqdn)
85+
} else {
86+
// If it's a relative path, make it absolute
87+
if (inviteDialogUrl.startsWith('/')) {
88+
const baseUrl = `https://${provider.fqdn}`
89+
inviteDialogUrl = `${baseUrl}${inviteDialogUrl}`
90+
}
91+
// If it's already absolute, use as-is
92+
}
93+
94+
// Build final URL with query parameters and redirect
95+
const finalUrl = buildProviderUrl(inviteDialogUrl, token, providerDomain)
96+
window.location.href = finalUrl
97+
} catch (err) {
98+
console.error('Failed to navigate to provider:', err)
99+
// Error is already shown by discoverProvider, do not use showErrorMessage here
100+
} finally {
101+
loading.value = false
102+
}
103+
}
104+
105+
const navigateToManualProvider = async (
106+
input: string,
107+
token: string,
108+
providerDomain?: string
109+
) => {
110+
const trimmedInput = input.trim()
111+
if (!trimmedInput) return
112+
113+
if (isSelfDomain(trimmedInput)) {
114+
showErrorMessage({
115+
title: $gettext('Invalid Provider Selection'),
116+
desc: $gettext(
117+
'You cannot use your own instance as a provider. Please select a different provider to establish a federated connection.'
118+
)
119+
})
120+
return
121+
}
122+
123+
try {
124+
loading.value = true
125+
const inviteDialogUrl = await discoverProvider(trimmedInput)
126+
const finalUrl = buildProviderUrl(inviteDialogUrl, token, providerDomain)
127+
window.location.href = finalUrl
128+
} catch (err) {
129+
console.error('Failed to navigate to manual provider:', err)
130+
// Error is already shown by discoverProvider, do not use showErrorMessage here
131+
} finally {
132+
loading.value = false
133+
}
134+
}
135+
136+
const loadFederations = async () => {
137+
try {
138+
loading.value = true
139+
error.value = false
140+
141+
const response = await clientService.httpUnAuthenticated.get<FederationsApiResponse>(
142+
'/sciencemesh/federations'
143+
)
144+
145+
const transformedFederations: WayfFederation = {}
146+
response.data.forEach((fed) => {
147+
const providers = fed.servers
148+
.map((server) => ({
149+
name: server.displayName,
150+
fqdn: new URL(server.url).hostname,
151+
// Keep empty if not provided by the server
152+
inviteAcceptDialog: server.inviteAcceptDialog || ''
153+
}))
154+
.filter((provider) => !isSelfDomain(provider.fqdn))
155+
156+
if (providers.length > 0) {
157+
transformedFederations[fed.federation] = providers
158+
}
159+
})
160+
161+
federations.value = transformedFederations
162+
} catch (err) {
163+
console.error('Failed to load federations:', err)
164+
error.value = true
165+
showErrorMessage({
166+
title: $gettext('Failed to Load Providers'),
167+
desc: $gettext('Could not load the list of available providers'),
168+
errors: [err]
169+
})
170+
} finally {
171+
loading.value = false
172+
}
173+
}
174+
175+
const filterProviders = (providers: WayfProvider[], query: string): WayfProvider[] => {
176+
const searchTerm = (query || '').toLowerCase()
177+
return providers.filter(
178+
(provider) =>
179+
provider.name.toLowerCase().includes(searchTerm) ||
180+
provider.fqdn.toLowerCase().includes(searchTerm)
181+
)
182+
}
183+
184+
return {
185+
loading: computed(() => loading.value),
186+
error: computed(() => error.value),
187+
federations: computed(() => federations.value),
188+
discoverProvider,
189+
buildProviderUrl,
190+
navigateToProvider,
191+
navigateToManualProvider,
192+
loadFederations,
193+
filterProviders
194+
}
195+
}

packages/web-app-ocm/src/extensions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const extensions = (appInfo: ApplicationInformation) => {
4040
})
4141
}
4242
} catch (error) {
43-
console.log(error)
43+
console.error(error)
4444
showErrorMessage({
4545
title: $gettext('An error occurred'),
4646
desc: $gettext("Couldn't open remotely"),

0 commit comments

Comments
 (0)