diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index 7ae5e84..6b372a0 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -105,8 +105,9 @@ interface UploadBatch { uploadedBytes: number; totalFiles: number; filesUploaded: number; - status: 'uploading' | 'complete' | 'error'; + status: 'uploading' | 'complete' | 'error' | 'canceled'; error?: string; + abortController?: AbortController; } interface Toast { @@ -908,8 +909,11 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { uploadedBytes: 0, totalFiles, filesUploaded: 0, - status: 'uploading' - }]); + status: 'uploading' as const, + abortController: new AbortController() + }; + + setUploads(prev => [...prev, newBatch]); const MAX_CONCURRENT = 4; let currentIndex = 0; @@ -934,8 +938,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const uploadPath = path ? (destPath === '.' ? path : `${destPath}/${path}`) : destPath; try { + if (newBatch.abortController.signal.aborted) throw new Error('Upload canceled'); await api.upload(file, uploadPath, { overwrite, + signal: newBatch.abortController.signal, onProgress: (percent) => { fileProgressMap.set(file.name + path, (percent / 100) * file.size); updateProgress(); @@ -945,7 +951,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { fileProgressMap.set(file.name + path, file.size); updateProgress(); } catch (err: any) { - setUploads(prev => prev.map(b => b.id === batchId ? { ...b, status: 'error', error: err.message || 'Upload failed' } : b)); + setUploads(prev => prev.map(b => b.id === batchId ? { + ...b, + status: err.message === 'Upload canceled' ? 'canceled' : 'error', + error: err.message || 'Upload failed' + } : b)); + if (err.message === 'Upload canceled') return; } } }; @@ -2093,7 +2104,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { + )} + {batch.totalFiles > 1 && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 05b53be..91c72a7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -95,6 +95,7 @@ class ApiClient { } private async refreshAccessToken(): Promise { + if (!this.refreshToken) return false; try { const response = await fetch(`${API_BASE}/api/auth/refresh`, { method: 'POST', @@ -105,17 +106,35 @@ class ApiClient { if (response.ok) { const data = await response.json(); - this.accessToken = data.access_token; - localStorage.setItem('access_token', data.access_token); + this.setTokens(data.access_token, data.refresh_token); return true; } - } catch { + // Refresh failed + this.clearTokens(); + return false; + } catch (e) { + this.clearTokens(); + return false; } + } - this.clearTokens(); - if (typeof window !== 'undefined') window.dispatchEvent(new Event('auth_error')); - return false; + async ensureTokenFresh(): Promise { + if (!this.accessToken) return false; + + try { + const parts = this.accessToken.split('.'); + if (parts.length !== 3) return false; + const payload = JSON.parse(atob(parts[1])); + const exp = payload.exp * 1000; + // If expiring within 60 seconds, refresh it + if (Date.now() > exp - 60000) { + return await this.refreshAccessToken(); + } + return true; + } catch (e) { + return false; + } } // --- Setup --- @@ -227,13 +246,17 @@ class ApiClient { endpoint: `${API_BASE}/api/tus/`, chunkSize: 5 * 1024 * 1024, // 5MB chunks bypass Next.js 10MB proxy limit retryDelays: [0, 3000, 5000, 10000, 20000], - headers: this.getStreamHeaders(), metadata: { filename: file.name, filetype: file.type, path: destPath, overwrite: options?.overwrite ? "true" : "false", }, + onBeforeRequest: async (req) => { + await this.ensureTokenFresh(); + const xhr = req.getUnderlyingObject(); + xhr.setRequestHeader('Authorization', `Bearer ${this.accessToken}`); + }, onError: function (error) { reject(error); }, @@ -245,6 +268,15 @@ class ApiClient { resolve(); }, }); + + if (options?.signal) { + options.signal.addEventListener('abort', () => { + upload.abort(true).then(() => { + reject(new Error('Upload canceled')); + }).catch(reject); + }); + } + upload.start(); }); }