mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-21 10:06:37 +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>
|
||||
)}
|
||||
|
||||
@@ -5,19 +5,21 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useBuckets } from '@/hooks/useApi';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TabSpec {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
perms?: string[];
|
||||
}
|
||||
|
||||
const tabs: TabSpec[] = [
|
||||
{ to: 'objects', label: 'Objects' },
|
||||
{ to: 'permissions', label: 'Permissions' },
|
||||
{ to: 'website', label: 'Website' },
|
||||
{ to: 'settings', label: 'Settings' },
|
||||
{ to: 'objects', label: 'Objects', perms: ['object.list'] },
|
||||
{ to: 'permissions', label: 'Permissions', perms: ['permission.allow_bucket_key', 'permission.deny_bucket_key'] },
|
||||
{ to: 'website', label: 'Website', perms: ['bucket.update'] },
|
||||
{ to: 'settings', label: 'Settings', perms: ['bucket.update'] },
|
||||
];
|
||||
|
||||
function formatBytes(n?: number) {
|
||||
@@ -36,6 +38,8 @@ export function BucketDetailShell() {
|
||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||
const { data: buckets = [] } = useBuckets();
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const canBucket = useBucketCan();
|
||||
const visibleTabs = tabs.filter((t) => !t.perms || t.perms.every((p) => canBucket(bucket, p)));
|
||||
|
||||
const s3Url = `s3://${bucketName}`;
|
||||
const copyUrl = async () => {
|
||||
@@ -68,16 +72,18 @@ export function BucketDetailShell() {
|
||||
<Button variant="secondary" onClick={copyUrl}>
|
||||
<Copy /> Copy URL
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
|
||||
<Upload /> Upload
|
||||
</Button>
|
||||
{canBucket(bucket, 'object.write') && (
|
||||
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
|
||||
<Upload /> Upload
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tabs */}
|
||||
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
|
||||
{tabs.map((t) => (
|
||||
{visibleTabs.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useState, useMemo } from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { NoAccess } from '@/pages/NoAccess';
|
||||
|
||||
function useCrumbs(): BreadcrumbItem[] {
|
||||
const location = useLocation();
|
||||
@@ -35,6 +37,7 @@ function useCrumbs(): BreadcrumbItem[] {
|
||||
export function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const crumbs = useCrumbs();
|
||||
const { noAccess } = usePermissions();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
|
||||
@@ -59,7 +62,7 @@ export function Layout() {
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar crumbs={crumbs} />
|
||||
<main className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
<Outlet />
|
||||
{noAccess ? <NoAccess /> : <Outlet />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,13 @@ import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { healthApi, garageApi } from '@/lib/api';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
visible?: (p: ReturnType<typeof usePermissions>) => boolean;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -22,13 +24,15 @@ const navGroups: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
|
||||
items: [
|
||||
{ title: 'Buckets', href: '/buckets', icon: Database, visible: (p) => p.hasAnyPerm('bucket.list') },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Cluster',
|
||||
items: [
|
||||
{ title: 'Cluster', href: '/cluster', icon: Server },
|
||||
{ title: 'Access Control', href: '/access', icon: Key },
|
||||
{ title: 'Cluster', href: '/cluster', icon: Server, visible: (p) => p.hasAnyClusterAccess },
|
||||
{ title: 'Access Control', href: '/access', icon: Key, visible: (p) => p.hasClusterPerm('key.list') },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -41,6 +45,7 @@ interface SidebarProps {
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const { config } = useAuthStore();
|
||||
const perms = usePermissions();
|
||||
|
||||
const { data: uiVersion } = useQuery({
|
||||
queryKey: ['ui-version'],
|
||||
@@ -77,38 +82,42 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
<span className="text-[18px] font-semibold tracking-tight">Garage UI</span>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
|
||||
{navGroups.map((group, gi) => (
|
||||
<div key={gi}>
|
||||
{group.label && (
|
||||
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
|
||||
active
|
||||
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
|
||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{navGroups.map((group, gi) => {
|
||||
const visibleItems = group.items.filter((item) => !item.visible || item.visible(perms));
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<div key={gi}>
|
||||
{group.label && (
|
||||
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
<ul className="space-y-0.5">
|
||||
{visibleItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
|
||||
active
|
||||
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
|
||||
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
|
||||
<a
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useDashboardData, useBuckets } from './useApi';
|
||||
export { useBucketObjects } from './useBucketObjects';
|
||||
export { usePermissions, bucketCan, useBucketCan } from './usePermissions';
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCapabilities } from './useCapabilities';
|
||||
|
||||
/**
|
||||
* Permission view derived from /api/v1/capabilities.
|
||||
*
|
||||
* Fail-closed: while capabilities are loading (or errored), every check
|
||||
* returns false. Gated UI stays hidden until the server has spoken.
|
||||
* When access control is disabled server-side, every check returns true.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { data, isLoading, isError } = useCapabilities();
|
||||
const ac = data?.access_control;
|
||||
const settled = !isLoading && !isError && data !== undefined;
|
||||
const enabled = ac?.enabled ?? false;
|
||||
const isAdmin = settled && (!enabled || (ac?.is_admin ?? false));
|
||||
const bindings = ac?.bindings ?? [];
|
||||
const clusterPerms = ac?.cluster_permissions ?? [];
|
||||
|
||||
const hasClusterPerm = (perm: string): boolean => {
|
||||
if (!settled) return false;
|
||||
if (!enabled || isAdmin) return true;
|
||||
return clusterPerms.includes(perm);
|
||||
};
|
||||
|
||||
const hasAnyPerm = (perm: string): boolean => {
|
||||
if (!settled) return false;
|
||||
if (!enabled || isAdmin) return true;
|
||||
return bindings.some((b) => b.permissions.includes(perm));
|
||||
};
|
||||
|
||||
const hasAnyClusterAccess =
|
||||
settled &&
|
||||
(!enabled || isAdmin || clusterPerms.some((p) => p.startsWith('cluster.') || p.startsWith('node.')));
|
||||
|
||||
const noAccess =
|
||||
settled && enabled && !isAdmin && bindings.length === 0 && clusterPerms.length === 0;
|
||||
|
||||
return { loading: !settled, enabled, isAdmin, hasClusterPerm, hasAnyPerm, hasAnyClusterAccess, noAccess };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket check against the server-computed effective_permissions carried
|
||||
* on bucket payloads. Does not know about loading/enabled state, so it treats
|
||||
* a missing effective_permissions field as "access control is disabled" and
|
||||
* allows. That's only correct when access control really is disabled, so
|
||||
* this is kept for internal use (by useBucketCan below); UI code should call
|
||||
* useBucketCan() instead, which fails closed when access control is enabled.
|
||||
*/
|
||||
export function bucketCan(
|
||||
bucket: { effective_permissions?: string[] } | undefined,
|
||||
perm: string,
|
||||
): boolean {
|
||||
const perms = bucket?.effective_permissions;
|
||||
if (!perms) return true;
|
||||
return perms.includes(perm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook returning a per-bucket permission check closed over the current
|
||||
* loading/enabled state from usePermissions().
|
||||
*
|
||||
* Fail-closed: while capabilities are loading, every check denies. When
|
||||
* access control is enabled, a bucket without effective_permissions (not
|
||||
* loaded yet, or filtered out of the bucket list) also denies instead of
|
||||
* falling open like bucketCan does. When access control is disabled, every
|
||||
* check allows, same as bucketCan.
|
||||
*/
|
||||
export function useBucketCan() {
|
||||
const { loading, enabled } = usePermissions();
|
||||
return (bucket: { effective_permissions?: string[] } | undefined, perm: string): boolean => {
|
||||
if (loading) return false;
|
||||
if (!enabled) return true;
|
||||
if (!bucket?.effective_permissions) return false;
|
||||
return bucketCan(bucket, perm);
|
||||
};
|
||||
}
|
||||
@@ -2,12 +2,20 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||
import { useBuckets } from '@/hooks/useApi';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
|
||||
export function BucketObjects() {
|
||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { data: buckets = [] } = useBuckets();
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const canBucket = useBucketCan();
|
||||
const canWrite = canBucket(bucket, 'object.write');
|
||||
const canDelete = canBucket(bucket, 'object.delete');
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [deepSearch, setDeepSearch] = useState(false);
|
||||
@@ -79,10 +87,12 @@ export function BucketObjects() {
|
||||
// CustomEvent bridge for the Upload button in BucketDetailShell hero.
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const handler = () => uploadInputRef.current?.click();
|
||||
const handler = () => {
|
||||
if (canWrite) uploadInputRef.current?.click();
|
||||
};
|
||||
document.addEventListener('bucket:upload', handler);
|
||||
return () => document.removeEventListener('bucket:upload', handler);
|
||||
}, []);
|
||||
}, [canWrite]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -112,11 +122,11 @@ export function BucketObjects() {
|
||||
onDeepSearchChange={setDeepSearch}
|
||||
onNavigateToFolder={handleNavigateToFolder}
|
||||
onBackToBuckets={handleBackToBuckets}
|
||||
onUploadFiles={uploadFiles}
|
||||
onUploadFiles={canWrite ? uploadFiles : undefined}
|
||||
uploadTasks={uploadTasks}
|
||||
onDeleteObject={deleteObject}
|
||||
onDeleteMultipleObjects={deleteMultipleObjects}
|
||||
onCreateDirectory={createDirectory}
|
||||
onDeleteObject={canDelete ? deleteObject : undefined}
|
||||
onDeleteMultipleObjects={canDelete ? deleteMultipleObjects : undefined}
|
||||
onCreateDirectory={canWrite ? createDirectory : undefined}
|
||||
onRefresh={handleRefresh}
|
||||
onPageChange={handlePageChange}
|
||||
onItemsPerPageChange={handleItemsPerPageChange}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useForm, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
@@ -86,6 +87,7 @@ export function BucketSettings() {
|
||||
const bucket = buckets.find((b) => b.name === bucketName);
|
||||
const deleteMutation = useDeleteBucket();
|
||||
const updateQuotasMutation = useUpdateBucketQuotas();
|
||||
const canBucket = useBucketCan();
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -295,25 +297,27 @@ export function BucketSettings() {
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
||||
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
|
||||
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
|
||||
Destructive actions for this bucket.
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium">Delete bucket</div>
|
||||
<div className="text-[13.5px] text-[var(--muted-foreground)]">
|
||||
All objects in this bucket will be permanently removed.
|
||||
{canBucket(bucket, 'bucket.delete') && (
|
||||
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
|
||||
<header className="border-b border-[var(--danger-border)] px-5 py-3">
|
||||
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
|
||||
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
|
||||
Destructive actions for this bucket.
|
||||
</p>
|
||||
</header>
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[14px] font-medium">Delete bucket</div>
|
||||
<div className="text-[13.5px] text-[var(--muted-foreground)]">
|
||||
All objects in this bucket will be permanently removed.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
Delete bucket
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
Delete bucket
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DangerousConfirmDialog
|
||||
open={deleteOpen}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { BucketListView } from '@/components/buckets/BucketListView';
|
||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||
@@ -16,6 +17,7 @@ export function Buckets() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const { hasAnyPerm } = usePermissions();
|
||||
const { data: buckets = [], isLoading } = useBuckets();
|
||||
const createMutation = useCreateBucket();
|
||||
const deleteMutation = useDeleteBucket();
|
||||
@@ -48,9 +50,11 @@ export function Buckets() {
|
||||
title="Buckets"
|
||||
subtitle={`${buckets.length} bucket${buckets.length === 1 ? '' : 's'}`}
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus /> Create bucket
|
||||
</Button>
|
||||
hasAnyPerm('bucket.create') && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus /> Create bucket
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="p-4 sm:p-6">
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ShieldOff } from 'lucide-react';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
/** Shown to authenticated users whose identity matches no team. */
|
||||
export function NoAccess() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-6 py-6">
|
||||
<EmptyState
|
||||
icon={<ShieldOff />}
|
||||
tone="neutral"
|
||||
title="You don't have access"
|
||||
description="Your account is signed in but isn't assigned to any team on this Garage UI. Contact your administrator to be added to a team."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface Bucket {
|
||||
errorDocument?: string;
|
||||
};
|
||||
quotas?: BucketQuotas | null;
|
||||
effective_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface BucketDetails extends Bucket {
|
||||
@@ -238,6 +239,19 @@ export interface MultiNodeStatisticsResponse {
|
||||
error: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AccessControlBinding {
|
||||
bucket_prefixes: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface AccessControlInfo {
|
||||
enabled: boolean;
|
||||
subject?: string;
|
||||
is_admin?: boolean;
|
||||
bindings?: AccessControlBinding[];
|
||||
cluster_permissions?: string[];
|
||||
}
|
||||
|
||||
export interface GarageCapabilities {
|
||||
garageApiVersion: string;
|
||||
features: {
|
||||
@@ -245,6 +259,7 @@ export interface GarageCapabilities {
|
||||
nodeInfo: boolean;
|
||||
nodeStatistics: boolean;
|
||||
};
|
||||
access_control?: AccessControlInfo;
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
|
||||
Reference in New Issue
Block a user