diff --git a/frontend/src/components/DragDropUpload.css b/frontend/src/components/DragDropUpload.css index 55b3467..ca4112d 100644 --- a/frontend/src/components/DragDropUpload.css +++ b/frontend/src/components/DragDropUpload.css @@ -42,6 +42,17 @@ border-style: solid; } +.drop-zone--disabled { + cursor: not-allowed; + opacity: 0.6; + background: var(--bg-disabled, #f5f5f5); +} + +.drop-zone--disabled:hover { + border-color: var(--border-color, #ddd); + background: var(--bg-disabled, #f5f5f5); +} + .drop-zone__input { display: none; } diff --git a/frontend/src/components/DragDropUpload.tsx b/frontend/src/components/DragDropUpload.tsx index a2b65c8..e9f6a90 100644 --- a/frontend/src/components/DragDropUpload.tsx +++ b/frontend/src/components/DragDropUpload.tsx @@ -89,6 +89,8 @@ export interface DragDropUploadProps { maxRetries?: number; tag?: string; className?: string; + disabled?: boolean; + disabledReason?: string; } // Utility functions @@ -230,6 +232,8 @@ export function DragDropUpload({ maxRetries = 3, tag, className = '', + disabled = false, + disabledReason, }: DragDropUploadProps) { const [isDragOver, setIsDragOver] = useState(false); const [uploadQueue, setUploadQueue] = useState([]); @@ -649,20 +653,22 @@ export function DragDropUpload({ const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); + if (disabled) return; dragCounterRef.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragOver(true); } - }, []); + }, [disabled]); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); + if (disabled) return; dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDragOver(false); } - }, []); + }, [disabled]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -675,18 +681,22 @@ export function DragDropUpload({ setIsDragOver(false); dragCounterRef.current = 0; + if (disabled) return; + const files = e.dataTransfer.files; if (files && files.length > 0) { addFiles(files); } - }, [addFiles]); + }, [addFiles, disabled]); // Click to browse const handleClick = useCallback(() => { + if (disabled) return; fileInputRef.current?.click(); - }, []); + }, [disabled]); const handleFileChange = useCallback((e: React.ChangeEvent) => { + if (disabled) return; const files = e.target.files; if (files && files.length > 0) { addFiles(files); @@ -695,7 +705,7 @@ export function DragDropUpload({ if (fileInputRef.current) { fileInputRef.current.value = ''; } - }, [addFiles]); + }, [addFiles, disabled]); // Remove item from queue const removeItem = useCallback((id: string) => { @@ -738,15 +748,17 @@ export function DragDropUpload({ )}
e.key === 'Enter' && handleClick()} + aria-disabled={disabled} + title={disabled ? disabledReason : undefined} >

- Drag files here or click to browse -

-

- {maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} - {!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`} + {disabled ? ( + {disabledReason || 'Upload disabled'} + ) : ( + <>Drag files here or click to browse + )}

+ {!disabled && ( +

+ {maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} + {!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`} +

+ )}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index b63c4f6..187784a 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,6 +1,11 @@ -import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; -import { User } from '../types'; -import { getCurrentUser, login as apiLogin, logout as apiLogout } from '../api'; +import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; +import { User, AccessLevel } from '../types'; +import { getCurrentUser, login as apiLogin, logout as apiLogout, getMyProjectAccess } from '../api'; + +interface PermissionCacheEntry { + accessLevel: AccessLevel | null; + timestamp: number; +} interface AuthContextType { user: User | null; @@ -10,6 +15,8 @@ interface AuthContextType { logout: () => Promise; refreshUser: () => Promise; clearError: () => void; + getProjectPermission: (projectName: string) => Promise; + invalidatePermissionCache: (projectName?: string) => void; } const AuthContext = createContext(undefined); @@ -18,10 +25,19 @@ interface AuthProviderProps { children: ReactNode; } +// Cache TTL in milliseconds (5 minutes) +const PERMISSION_CACHE_TTL = 5 * 60 * 1000; + export function AuthProvider({ children }: AuthProviderProps) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const permissionCacheRef = useRef>(new Map()); + + // Clear permission cache + const clearPermissionCache = useCallback(() => { + permissionCacheRef.current.clear(); + }, []); // Check session on initial load useEffect(() => { @@ -44,6 +60,8 @@ export function AuthProvider({ children }: AuthProviderProps) { try { const loggedInUser = await apiLogin({ username, password }); setUser(loggedInUser); + // Clear permission cache on login - permissions may have changed + clearPermissionCache(); } catch (err) { const message = err instanceof Error ? err.message : 'Login failed'; setError(message); @@ -51,7 +69,7 @@ export function AuthProvider({ children }: AuthProviderProps) { } finally { setLoading(false); } - }, []); + }, [clearPermissionCache]); const logout = useCallback(async () => { setLoading(true); @@ -59,6 +77,8 @@ export function AuthProvider({ children }: AuthProviderProps) { try { await apiLogout(); setUser(null); + // Clear permission cache on logout + clearPermissionCache(); } catch (err) { const message = err instanceof Error ? err.message : 'Logout failed'; setError(message); @@ -66,7 +86,7 @@ export function AuthProvider({ children }: AuthProviderProps) { } finally { setLoading(false); } - }, []); + }, [clearPermissionCache]); const clearError = useCallback(() => { setError(null); @@ -81,8 +101,57 @@ export function AuthProvider({ children }: AuthProviderProps) { } }, []); + // Get project permission with caching + const getProjectPermission = useCallback(async (projectName: string): Promise => { + const cached = permissionCacheRef.current.get(projectName); + const now = Date.now(); + + // Return cached value if still valid + if (cached && (now - cached.timestamp) < PERMISSION_CACHE_TTL) { + return cached.accessLevel; + } + + // Fetch fresh permission + try { + const result = await getMyProjectAccess(projectName); + const entry: PermissionCacheEntry = { + accessLevel: result.access_level, + timestamp: now, + }; + permissionCacheRef.current.set(projectName, entry); + return result.access_level; + } catch { + // On error, cache null to avoid repeated failed requests + const entry: PermissionCacheEntry = { + accessLevel: null, + timestamp: now, + }; + permissionCacheRef.current.set(projectName, entry); + return null; + } + }, []); + + // Invalidate permission cache for a specific project or all projects + const invalidatePermissionCache = useCallback((projectName?: string) => { + if (projectName) { + permissionCacheRef.current.delete(projectName); + } else { + clearPermissionCache(); + } + }, [clearPermissionCache]); + return ( - + {children} ); diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index 80f4f33..76284b2 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -329,35 +329,41 @@ function PackagePage() { {error &&
{error}
} {uploadSuccess &&
{uploadSuccess}
} - {canWrite ? ( + {user && (

Upload Artifact

-
-
- - setUploadTag(e.target.value)} - placeholder="v1.0.0, latest, stable..." + {canWrite ? ( +
+
+ + setUploadTag(e.target.value)} + placeholder="v1.0.0, latest, stable..." + /> +
+
+ ) : ( -
+ )}
- ) : user ? ( -
-

Upload Artifact

-

You have read-only access to this project and cannot upload artifacts.

-
- ) : null} + )}

Tags / Versions