mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-25 11:56:48 +00:00
feat(oidc): add fine grained access control (#91)
* feat(access-control): fine grain tokens * fix(docs): clean wording * test(access-control): add tests for team extraction from access tokens and bucket info permissions * test(vocabulary): add test for ExpandGlob function to reject non-glob patterns * feat(helm): add multi-user access control documentation and schema support
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketListViewProps {
|
||||
@@ -35,6 +36,7 @@ export function BucketListView({
|
||||
onDeleteBucket,
|
||||
onWebsiteSettings,
|
||||
}: BucketListViewProps) {
|
||||
const canBucket = useBucketCan();
|
||||
const filteredBuckets = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
@@ -118,31 +120,39 @@ export function BucketListView({
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
View Objects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(bucket);
|
||||
}}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onWebsiteSettings(bucket);
|
||||
}}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Website Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBucket(bucket);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{canBucket(bucket, 'bucket.update') && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(bucket);
|
||||
}}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onWebsiteSettings(bucket);
|
||||
}}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Website Settings
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canBucket(bucket, 'bucket.delete') && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBucket(bucket);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
||||
@@ -25,11 +25,11 @@ interface ObjectBrowserViewProps {
|
||||
onDeepSearchChange: (enabled: boolean) => void;
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
onBackToBuckets: () => void;
|
||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||
onUploadFiles?: (files: File[]) => Promise<boolean>;
|
||||
uploadTasks: UploadTask[];
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
onDeleteObject?: (key: string) => Promise<boolean>;
|
||||
onDeleteMultipleObjects?: (keys: string[]) => Promise<boolean>;
|
||||
onCreateDirectory?: (name: string) => Promise<boolean>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onPageChange: (token?: string) => void;
|
||||
onItemsPerPageChange: (count: number) => void;
|
||||
@@ -75,6 +75,8 @@ export function ObjectBrowserView({
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: async (acceptedFiles, _fileRejections, event) => {
|
||||
if (!onUploadFiles) return;
|
||||
|
||||
// Get files with their full paths from DataTransferItems API
|
||||
const filesWithPaths: File[] = [];
|
||||
|
||||
@@ -101,6 +103,7 @@ export function ObjectBrowserView({
|
||||
setShowUploadZone(false);
|
||||
},
|
||||
noClick: true,
|
||||
disabled: !onUploadFiles,
|
||||
});
|
||||
|
||||
// Helper function to traverse file/directory tree
|
||||
@@ -153,13 +156,14 @@ export function ObjectBrowserView({
|
||||
};
|
||||
|
||||
const handleBulkDeleteFiles = async () => {
|
||||
if (selectedFileKeys.size === 0) return;
|
||||
if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return;
|
||||
|
||||
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
|
||||
setSelectedFileKeys(new Set());
|
||||
};
|
||||
|
||||
const handleDeleteObject = async (key: string): Promise<boolean> => {
|
||||
if (!onDeleteObject) return false;
|
||||
const success = await onDeleteObject(key);
|
||||
if (success) {
|
||||
setDeleteObjectDialogOpen(false);
|
||||
@@ -169,6 +173,7 @@ export function ObjectBrowserView({
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (!onUploadFiles) return;
|
||||
await onUploadFiles(files);
|
||||
setShowUploadZone(false);
|
||||
};
|
||||
@@ -232,7 +237,7 @@ export function ObjectBrowserView({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedFileKeys.size > 0 && (
|
||||
{onDeleteMultipleObjects && selectedFileKeys.size > 0 && (
|
||||
<Button
|
||||
onClick={handleBulkDeleteFiles}
|
||||
title={`Delete ${selectedFileKeys.size} selected file(s)`}
|
||||
@@ -242,14 +247,18 @@ export function ObjectBrowserView({
|
||||
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</Button>
|
||||
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Add Directory</span>
|
||||
</Button>
|
||||
{onUploadFiles && (
|
||||
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</Button>
|
||||
)}
|
||||
{onCreateDirectory && (
|
||||
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Add Directory</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
|
||||
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
@@ -257,7 +266,7 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && uploadTasks.length === 0 && (
|
||||
{onUploadFiles && showUploadZone && uploadTasks.length === 0 && (
|
||||
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 flex items-center justify-center">
|
||||
@@ -384,10 +393,10 @@ export function ObjectBrowserView({
|
||||
nextContinuationToken={nextContinuationToken}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onNavigateToFolder={onNavigateToFolder}
|
||||
onDeleteObject={(obj) => {
|
||||
onDeleteObject={onDeleteObject ? (obj) => {
|
||||
setSelectedObject(obj);
|
||||
setDeleteObjectDialogOpen(true);
|
||||
}}
|
||||
} : undefined}
|
||||
onToggleFileSelection={handleToggleFileSelection}
|
||||
onSelectAllFiles={handleSelectAllFiles}
|
||||
onPageChange={onPageChange}
|
||||
@@ -399,12 +408,14 @@ export function ObjectBrowserView({
|
||||
</div>
|
||||
|
||||
{/* Create Directory Dialog */}
|
||||
<CreateDirectoryDialog
|
||||
open={createDirDialogOpen}
|
||||
onOpenChange={setCreateDirDialogOpen}
|
||||
currentPath={currentPath}
|
||||
onCreateDirectory={onCreateDirectory}
|
||||
/>
|
||||
{onCreateDirectory && (
|
||||
<CreateDirectoryDialog
|
||||
open={createDirDialogOpen}
|
||||
onOpenChange={setCreateDirDialogOpen}
|
||||
currentPath={currentPath}
|
||||
onCreateDirectory={onCreateDirectory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Object Dialog */}
|
||||
<DeleteObjectDialog
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import { useBuckets } from '@/hooks/useApi';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
import type { ObjectMetadata } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -36,6 +38,11 @@ export function ObjectDetailsView() {
|
||||
const { bucketName, '*': encodedObjectKey } = useParams();
|
||||
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
|
||||
|
||||
const { data: buckets = [] } = useBuckets();
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const canBucket = useBucketCan();
|
||||
const canDelete = canBucket(bucket, 'object.delete');
|
||||
|
||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -161,9 +168,11 @@ export function ObjectDetailsView() {
|
||||
<Button variant="secondary" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="h-4 w-4" /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ interface ObjectsTableProps {
|
||||
nextContinuationToken?: string;
|
||||
itemsPerPage: number;
|
||||
onNavigateToFolder: (key: string) => void;
|
||||
onDeleteObject: (object: S3Object) => void;
|
||||
onDeleteObject?: (object: S3Object) => void;
|
||||
onToggleFileSelection: (key: string) => void;
|
||||
onSelectAllFiles: () => void;
|
||||
onPageChange: (token?: string) => void;
|
||||
@@ -66,6 +66,7 @@ export function ObjectsTable({
|
||||
initialItemsPerPage,
|
||||
}: ObjectsTableProps) {
|
||||
const navigate = useNavigate();
|
||||
const canDelete = Boolean(onDeleteObject);
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
|
||||
@@ -214,16 +215,18 @@ export function ObjectsTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
|
||||
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
|
||||
}
|
||||
onCheckedChange={onSelectAllFiles}
|
||||
aria-label="Select all files"
|
||||
/>
|
||||
</TableHead>
|
||||
{canDelete && (
|
||||
<TableHead className="w-[50px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
|
||||
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
|
||||
}
|
||||
onCheckedChange={onSelectAllFiles}
|
||||
aria-label="Select all files"
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('name')}
|
||||
@@ -250,7 +253,7 @@ export function ObjectsTable({
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading objects...</span>
|
||||
@@ -259,7 +262,7 @@ export function ObjectsTable({
|
||||
</TableRow>
|
||||
) : filteredObjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'No objects found matching your search'
|
||||
: isDragActive
|
||||
@@ -270,22 +273,24 @@ export function ObjectsTable({
|
||||
) : (
|
||||
pageObjects.map((obj) => (
|
||||
<TableRow key={obj.key}>
|
||||
<TableCell className="w-[50px]">
|
||||
{obj.isFolder ? (
|
||||
<Checkbox
|
||||
disabled
|
||||
checked={false}
|
||||
className="opacity-50 cursor-not-allowed bg-muted"
|
||||
aria-label="Folders cannot be selected"
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedFileKeys.has(obj.key)}
|
||||
onCheckedChange={() => onToggleFileSelection(obj.key)}
|
||||
aria-label={`Select file ${obj.key}`}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
{canDelete && (
|
||||
<TableCell className="w-[50px]">
|
||||
{obj.isFolder ? (
|
||||
<Checkbox
|
||||
disabled
|
||||
checked={false}
|
||||
className="opacity-50 cursor-not-allowed bg-muted"
|
||||
aria-label="Folders cannot be selected"
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedFileKeys.has(obj.key)}
|
||||
onCheckedChange={() => onToggleFileSelection(obj.key)}
|
||||
aria-label={`Select file ${obj.key}`}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{obj.isFolder ? (
|
||||
@@ -390,14 +395,18 @@ export function ObjectsTable({
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteObject(obj)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
{onDeleteObject && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteObject(obj)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user