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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user