Skip to content

Commit 08e5be3

Browse files
committed
feat: Enhance download flow handling by refactoring callback parsing and routing to AppComponent
1 parent fdc8c45 commit 08e5be3

File tree

3 files changed

+116
-35
lines changed

3 files changed

+116
-35
lines changed

src/app/app.component.ts

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Component, OnInit, inject } from '@angular/core';
44
import { PrimeNG } from 'primeng/config';
55
import { DataService } from './data.service';
6+
import { DatasetService } from './dataset.service';
67
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
78
import { PluginService } from './plugin.service';
89

@@ -15,6 +16,7 @@ import { PluginService } from './plugin.service';
1516
export class AppComponent implements OnInit {
1617
private primengConfig = inject(PrimeNG);
1718
dataService = inject(DataService);
19+
private datasetService = inject(DatasetService);
1820
private router = inject(Router);
1921
private route = inject(ActivatedRoute);
2022
private pluginService = inject(PluginService);
@@ -54,10 +56,12 @@ export class AppComponent implements OnInit {
5456
/**
5557
* Checks if the query params indicate a download flow that should skip login redirect.
5658
* Download flows allow guest access with Globus OAuth only.
59+
* Also handles navigation to /download when URL contains /download but route doesn't match.
5760
*/
5861
private isDownloadFlow(params: Record<string, string | undefined>): boolean {
5962
// For testing: allow overriding window.location.href
6063
const locationHref =
64+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6165
(this as any)._testWindowLocationHref ?? window.location.href;
6266

6367
// eslint-disable-next-line no-console
@@ -67,13 +71,26 @@ export class AppComponent implements OnInit {
6771
windowLocation: locationHref,
6872
});
6973

70-
// Check if we're on the download page (via router or window.location)
74+
// Check if we're on the download page via router
75+
if (this.router.url.includes('/download')) {
76+
// eslint-disable-next-line no-console
77+
console.debug(
78+
'[AppComponent] isDownloadFlow: true (router matches /download)',
79+
);
80+
return true;
81+
}
82+
83+
// Check if URL contains /download but Angular routing failed (e.g., /connect/download)
84+
// In this case, parse the callback and navigate to /download with the correct params
7185
if (
72-
this.router.url.includes('/download') ||
73-
locationHref.includes('/download')
86+
locationHref.includes('/download') &&
87+
!this.router.url.includes('/download')
7488
) {
7589
// eslint-disable-next-line no-console
76-
console.debug('[AppComponent] isDownloadFlow: true (download page)');
90+
console.debug(
91+
'[AppComponent] URL contains /download but route does not match, redirecting to /download',
92+
);
93+
this.redirectToDownload(locationHref);
7794
return true;
7895
}
7996

@@ -139,6 +156,90 @@ export class AppComponent implements OnInit {
139156
return false;
140157
}
141158

159+
/**
160+
* Parses the Globus callback from the URL and redirects to /download with the correct params.
161+
* The callback is a base64-encoded URL like:
162+
* https://example.com/api/v1/datasets/{datasetDbId}/globusDownloadParameters?locale=en&downloadId={uuid}
163+
*/
164+
private redirectToDownload(locationHref: string): void {
165+
try {
166+
const url = new URL(locationHref);
167+
const callback = url.searchParams.get('callback');
168+
169+
if (!callback) {
170+
// No callback, just navigate to /download
171+
this.router.navigate(['/download']);
172+
return;
173+
}
174+
175+
const callbackUrl = atob(callback);
176+
const parts = callbackUrl.split('/');
177+
if (parts.length <= 6) {
178+
// eslint-disable-next-line no-console
179+
console.warn(
180+
'[AppComponent] Invalid callback URL format:',
181+
callbackUrl,
182+
);
183+
this.router.navigate(['/download']);
184+
return;
185+
}
186+
187+
// Extract datasetDbId from URL (position 6 in the path)
188+
const datasetDbId = parts[6];
189+
190+
// Extract downloadId from query params
191+
let downloadId: string | undefined;
192+
const queryString = callbackUrl.split('?')[1];
193+
if (queryString) {
194+
const globusParams = queryString.split('&');
195+
for (const p of globusParams) {
196+
if (p.startsWith('downloadId=')) {
197+
downloadId = p.substring('downloadId='.length);
198+
break;
199+
}
200+
}
201+
}
202+
203+
// Get the persistent ID (DOI) from the dataset database ID
204+
const dvToken = localStorage.getItem('dataverseToken');
205+
this.datasetService
206+
.getDatasetVersion(datasetDbId, dvToken ?? undefined)
207+
.subscribe({
208+
next: (x) => {
209+
// eslint-disable-next-line no-console
210+
console.debug('[AppComponent] Redirecting to /download with:', {
211+
downloadId,
212+
datasetPid: x.persistentId,
213+
});
214+
this.router.navigate(['/download'], {
215+
queryParams: {
216+
downloadId: downloadId,
217+
datasetPid: x.persistentId,
218+
apiToken: dvToken,
219+
},
220+
});
221+
},
222+
error: (err) => {
223+
// eslint-disable-next-line no-console
224+
console.error('[AppComponent] Failed to get dataset version:', err);
225+
// Navigate anyway with what we have
226+
this.router.navigate(['/download'], {
227+
queryParams: {
228+
downloadId: downloadId,
229+
},
230+
});
231+
},
232+
});
233+
} catch (e) {
234+
// eslint-disable-next-line no-console
235+
console.error(
236+
'[AppComponent] Failed to parse URL for download redirect:',
237+
e,
238+
);
239+
this.router.navigate(['/download']);
240+
}
241+
}
242+
142243
private static readonly REDIRECT_STORAGE_KEY = 'loginRedirectAttempt';
143244
private static readonly MAX_REDIRECTS = 2;
144245
private static readonly REDIRECT_WINDOW_MS = 30000; // 30 seconds

src/app/connect/connect.component.advanced.spec.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
import { provideHttpClientTesting } from '@angular/common/http/testing';
66
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
77
import { provideNoopAnimations } from '@angular/platform-browser/animations';
8-
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
8+
import { ActivatedRoute, provideRouter } from '@angular/router';
99
import { SelectItem, TreeNode } from 'primeng/api';
1010
import { Observable, of } from 'rxjs';
1111
import { DataStateService } from '../data.state.service';
@@ -370,10 +370,8 @@ describe('ConnectComponent advanced behaviors', () => {
370370
expect(err2).toBe('Malformed source url');
371371
});
372372

373-
it('handleGlobusCallback restores dataset and navigates to download', fakeAsync(() => {
373+
it('handleGlobusCallback restores dataset from callback URL', fakeAsync(() => {
374374
const { comp } = createComponent();
375-
const router = TestBed.inject(Router);
376-
const navSpy = spyOn(router, 'navigate');
377375
const callback = btoa(
378376
'https://globus.example/api/files/visit/12345/details?downloadId=DLID&x=1',
379377
);
@@ -384,13 +382,7 @@ describe('ConnectComponent advanced behaviors', () => {
384382
apiToken: 'apiTok',
385383
});
386384
expect(comp.datasetId).toBe('doi:10.123/RESTORED');
387-
expect(navSpy).toHaveBeenCalledWith(['/download'], {
388-
queryParams: {
389-
downloadId: 'DLID',
390-
datasetPid: 'doi:10.123/RESTORED',
391-
apiToken: 'apiTok',
392-
},
393-
});
385+
// Note: Download redirect is now handled by AppComponent, not connect component
394386
}));
395387

396388
it('restoreFromOauthState populates selections and fetches token', fakeAsync(() => {

src/app/connect/connect.component.ts

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -292,22 +292,16 @@ export class ConnectComponent
292292
): void {
293293
const p = this.pluginService.getGlobusPlugin();
294294
if (!p) return;
295-
// Don't preselect plugin - only preselect the dataset
296-
// this.plugin = 'globus';
297-
// this.pluginId = 'globus';
295+
// If datasetPid is explicitly provided, no need to parse callback
298296
if (datasetPid) return;
297+
298+
// Parse callback to extract dataset info for the connect page (upload flow)
299+
// Download flows are now handled by AppComponent routing to /download directly
299300
const callbackUrl = atob(callback);
300301
const parts = callbackUrl.split('/');
301302
if (parts.length <= 6) return;
302303
const datasetDbId = parts[6];
303-
const g = callbackUrl.split('?');
304-
const globusParams = g[g.length - 1].split('&');
305-
let downloadId: string | undefined = undefined;
306-
globusParams.forEach((p) => {
307-
if (p.startsWith('downloadId=')) {
308-
downloadId = p.substring('downloadId='.length);
309-
}
310-
});
304+
311305
const versionSubscription = this.datasetService
312306
.getDatasetVersion(datasetDbId, apiToken)
313307
.subscribe((x) => {
@@ -325,15 +319,6 @@ export class ConnectComponent
325319
}
326320
this.subscriptions.delete(versionSubscription);
327321
versionSubscription.unsubscribe();
328-
if (downloadId) {
329-
this.router.navigate(['/download'], {
330-
queryParams: {
331-
downloadId: downloadId,
332-
datasetPid: x.persistentId,
333-
apiToken: apiToken,
334-
},
335-
});
336-
}
337322
});
338323
this.subscriptions.add(versionSubscription);
339324
}
@@ -349,8 +334,11 @@ export class ConnectComponent
349334
} catch {
350335
return; // malformed state
351336
}
337+
// Download flows with state.download are normally caught by AppComponent
338+
// and routed to /download. This is a fallback for edge cases.
352339
if (loginState.download) {
353340
this.router.navigate(['/download'], { queryParams: params });
341+
return;
354342
}
355343
this.sourceUrl = loginState.sourceUrl;
356344
this.url = loginState.url;

0 commit comments

Comments
 (0)