Add permission-aware upload controls and permission caching

- Add disabled/disabledReason props to DragDropUpload component
- Block drag, drop, and click events when upload is disabled
- Add visual disabled state with tooltip explanation
- Add permission caching to AuthContext with 5-minute TTL
- Clear permission cache on login/logout
- Show disabled upload zone for read-only users with explanation
This commit is contained in:
Mondo Diaz
2026-01-12 16:32:39 +00:00
parent 1c31fe79cd
commit 9e31134785
4 changed files with 141 additions and 36 deletions

View File

@@ -42,6 +42,17 @@
border-style: solid; 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 { .drop-zone__input {
display: none; display: none;
} }

View File

@@ -89,6 +89,8 @@ export interface DragDropUploadProps {
maxRetries?: number; maxRetries?: number;
tag?: string; tag?: string;
className?: string; className?: string;
disabled?: boolean;
disabledReason?: string;
} }
// Utility functions // Utility functions
@@ -230,6 +232,8 @@ export function DragDropUpload({
maxRetries = 3, maxRetries = 3,
tag, tag,
className = '', className = '',
disabled = false,
disabledReason,
}: DragDropUploadProps) { }: DragDropUploadProps) {
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]); const [uploadQueue, setUploadQueue] = useState<UploadItem[]>([]);
@@ -649,20 +653,22 @@ export function DragDropUpload({
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (disabled) return;
dragCounterRef.current++; dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragOver(true); setIsDragOver(true);
} }
}, []); }, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (disabled) return;
dragCounterRef.current--; dragCounterRef.current--;
if (dragCounterRef.current === 0) { if (dragCounterRef.current === 0) {
setIsDragOver(false); setIsDragOver(false);
} }
}, []); }, [disabled]);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -675,18 +681,22 @@ export function DragDropUpload({
setIsDragOver(false); setIsDragOver(false);
dragCounterRef.current = 0; dragCounterRef.current = 0;
if (disabled) return;
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files && files.length > 0) { if (files && files.length > 0) {
addFiles(files); addFiles(files);
} }
}, [addFiles]); }, [addFiles, disabled]);
// Click to browse // Click to browse
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (disabled) return;
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, [disabled]);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const files = e.target.files; const files = e.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
addFiles(files); addFiles(files);
@@ -695,7 +705,7 @@ export function DragDropUpload({
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}, [addFiles]); }, [addFiles, disabled]);
// Remove item from queue // Remove item from queue
const removeItem = useCallback((id: string) => { const removeItem = useCallback((id: string) => {
@@ -738,15 +748,17 @@ export function DragDropUpload({
)} )}
<div <div
className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''}`} className={`drop-zone ${isDragOver ? 'drop-zone--active' : ''} ${disabled ? 'drop-zone--disabled' : ''}`}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
onClick={handleClick} onClick={handleClick}
role="button" role="button"
tabIndex={0} tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => e.key === 'Enter' && handleClick()} onKeyDown={(e) => e.key === 'Enter' && handleClick()}
aria-disabled={disabled}
title={disabled ? disabledReason : undefined}
> >
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -755,16 +767,23 @@ export function DragDropUpload({
onChange={handleFileChange} onChange={handleFileChange}
className="drop-zone__input" className="drop-zone__input"
accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined} accept={!allowAllTypes && allowedTypes ? allowedTypes.join(',') : undefined}
disabled={disabled}
/> />
<div className="drop-zone__content"> <div className="drop-zone__content">
<UploadIcon /> <UploadIcon />
<p className="drop-zone__text"> <p className="drop-zone__text">
<strong>Drag files here</strong> or click to browse {disabled ? (
<span>{disabledReason || 'Upload disabled'}</span>
) : (
<><strong>Drag files here</strong> or click to browse</>
)}
</p> </p>
{!disabled && (
<p className="drop-zone__hint"> <p className="drop-zone__hint">
{maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} {maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`}
{!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`} {!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`}
</p> </p>
)}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,11 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { User } from '../types'; import { User, AccessLevel } from '../types';
import { getCurrentUser, login as apiLogin, logout as apiLogout } from '../api'; import { getCurrentUser, login as apiLogin, logout as apiLogout, getMyProjectAccess } from '../api';
interface PermissionCacheEntry {
accessLevel: AccessLevel | null;
timestamp: number;
}
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
@@ -10,6 +15,8 @@ interface AuthContextType {
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
clearError: () => void; clearError: () => void;
getProjectPermission: (projectName: string) => Promise<AccessLevel | null>;
invalidatePermissionCache: (projectName?: string) => void;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -18,10 +25,19 @@ interface AuthProviderProps {
children: ReactNode; children: ReactNode;
} }
// Cache TTL in milliseconds (5 minutes)
const PERMISSION_CACHE_TTL = 5 * 60 * 1000;
export function AuthProvider({ children }: AuthProviderProps) { export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const permissionCacheRef = useRef<Map<string, PermissionCacheEntry>>(new Map());
// Clear permission cache
const clearPermissionCache = useCallback(() => {
permissionCacheRef.current.clear();
}, []);
// Check session on initial load // Check session on initial load
useEffect(() => { useEffect(() => {
@@ -44,6 +60,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
try { try {
const loggedInUser = await apiLogin({ username, password }); const loggedInUser = await apiLogin({ username, password });
setUser(loggedInUser); setUser(loggedInUser);
// Clear permission cache on login - permissions may have changed
clearPermissionCache();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Login failed'; const message = err instanceof Error ? err.message : 'Login failed';
setError(message); setError(message);
@@ -51,7 +69,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [clearPermissionCache]);
const logout = useCallback(async () => { const logout = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -59,6 +77,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
try { try {
await apiLogout(); await apiLogout();
setUser(null); setUser(null);
// Clear permission cache on logout
clearPermissionCache();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Logout failed'; const message = err instanceof Error ? err.message : 'Logout failed';
setError(message); setError(message);
@@ -66,7 +86,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [clearPermissionCache]);
const clearError = useCallback(() => { const clearError = useCallback(() => {
setError(null); setError(null);
@@ -81,8 +101,57 @@ export function AuthProvider({ children }: AuthProviderProps) {
} }
}, []); }, []);
// Get project permission with caching
const getProjectPermission = useCallback(async (projectName: string): Promise<AccessLevel | null> => {
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 ( return (
<AuthContext.Provider value={{ user, loading, error, login, logout, refreshUser, clearError }}> <AuthContext.Provider value={{
user,
loading,
error,
login,
logout,
refreshUser,
clearError,
getProjectPermission,
invalidatePermissionCache,
}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -329,9 +329,10 @@ function PackagePage() {
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>} {uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
{canWrite ? ( {user && (
<div className="upload-section card"> <div className="upload-section card">
<h3>Upload Artifact</h3> <h3>Upload Artifact</h3>
{canWrite ? (
<div className="upload-form"> <div className="upload-form">
<div className="form-group"> <div className="form-group">
<label htmlFor="upload-tag">Tag (optional)</label> <label htmlFor="upload-tag">Tag (optional)</label>
@@ -351,13 +352,18 @@ function PackagePage() {
onUploadError={handleUploadError} onUploadError={handleUploadError}
/> />
</div> </div>
) : (
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
disabled={true}
disabledReason="You have read-only access to this project and cannot upload artifacts."
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
)}
</div> </div>
) : user ? ( )}
<div className="upload-section card">
<h3>Upload Artifact</h3>
<p className="text-muted">You have read-only access to this project and cannot upload artifacts.</p>
</div>
) : null}
<div className="section-header"> <div className="section-header">
<h2>Tags / Versions</h2> <h2>Tags / Versions</h2>