mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-30 17:22:11 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76adb16905 | |||
| 987b7e8625 | |||
| 91770dc5b2 | |||
| 3401deea8b | |||
| bd0aada4ea |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.11.0"
|
||||
".": "0.11.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [0.11.1](https://github.com/Noooste/garage-ui/compare/v0.11.0...v0.11.1) (2026-07-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **values.yaml:** add comments for team-based access control configuration ([987b7e8](https://github.com/Noooste/garage-ui/commit/987b7e862513fedc4a0173d60b26ac6ad59a5db0))
|
||||
|
||||
## [0.11.0](https://github.com/Noooste/garage-ui/compare/v0.10.0...v0.11.0) (2026-07-29)
|
||||
|
||||
|
||||
|
||||
Generated
+392
-1065
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,11 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.554.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"react-router": "^8.3.0",
|
||||
"recharts": "^3.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -45,15 +45,15 @@
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom';
|
||||
import {BrowserRouter, Navigate, Route, Routes} from 'react-router';
|
||||
import {QueryClientProvider} from '@tanstack/react-query';
|
||||
import {ThemeProvider, useTheme} from '@/components/theme-provider';
|
||||
import {ThemeProvider} from '@/components/theme-provider';
|
||||
import {useTheme} from '@/components/theme-context';
|
||||
import {Layout} from '@/components/layout/layout';
|
||||
import {BucketDetailShell} from '@/components/layout/bucket-detail-shell';
|
||||
import {Dashboard} from '@/pages/Dashboard';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { FolderPlus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -24,7 +24,12 @@ interface CreateDirectoryDialogProps {
|
||||
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
|
||||
const [dirName, setDirName] = useState('');
|
||||
|
||||
useEffect(() => { if (!open) setDirName(''); }, [open]);
|
||||
// Clear the field when the dialog closes (adjust-during-render, not an effect).
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (!open) setDirName('');
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!dirName) {
|
||||
|
||||
@@ -112,10 +112,10 @@ export function ObjectBrowserView({
|
||||
});
|
||||
|
||||
// Helper function to traverse file/directory tree
|
||||
const traverseFileTree = async (item: any, path: string, files: File[]): Promise<void> => {
|
||||
const traverseFileTree = async (item: FileSystemEntry, path: string, files: File[]): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (item.isFile) {
|
||||
item.file((file: File) => {
|
||||
(item as FileSystemFileEntry).file((file: File) => {
|
||||
const fullPath = path + file.name;
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: fullPath,
|
||||
@@ -125,8 +125,8 @@ export function ObjectBrowserView({
|
||||
resolve();
|
||||
});
|
||||
} else if (item.isDirectory) {
|
||||
const dirReader = item.createReader();
|
||||
dirReader.readEntries(async (entries: any[]) => {
|
||||
const dirReader = (item as FileSystemDirectoryEntry).createReader();
|
||||
dirReader.readEntries(async (entries) => {
|
||||
for (const entry of entries) {
|
||||
await traverseFileTree(entry, path + item.name + '/', files);
|
||||
}
|
||||
@@ -389,7 +389,7 @@ export function ObjectBrowserView({
|
||||
<input
|
||||
id="folder-input"
|
||||
type="file"
|
||||
{...({ webkitdirectory: '', directory: '', mozdirectory: '' } as any)}
|
||||
{...({ webkitdirectory: '', directory: '', mozdirectory: '' } as unknown as React.InputHTMLAttributes<HTMLInputElement>)}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useNavigate, useParams, Link } from 'react-router';
|
||||
import { objectsApi } from '@/lib/api';
|
||||
import { useBuckets } from '@/hooks/useApi';
|
||||
import { useBucketCan } from '@/hooks/usePermissions';
|
||||
@@ -45,33 +45,46 @@ export function ObjectDetailsView() {
|
||||
const canDelete = canBucket(bucket, 'object.delete');
|
||||
const canRead = canBucket(bucket, 'object.read');
|
||||
|
||||
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// The fetch result is keyed by its target, so loading/error/metadata are all
|
||||
// derived: a result for a previous object is simply ignored, which also
|
||||
// protects against a slow response landing after navigation.
|
||||
const target = bucketName && objectKey ? `${bucketName}/${objectKey}` : null;
|
||||
const [result, setResult] = useState<{
|
||||
target: string;
|
||||
metadata?: ObjectMetadata;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucketName || !objectKey) {
|
||||
setError('Bucket name and object key are required');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await objectsApi.getMetadata(bucketName, objectKey);
|
||||
setMetadata(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (!bucketName || !objectKey) return;
|
||||
const requestTarget = `${bucketName}/${objectKey}`;
|
||||
let stale = false;
|
||||
objectsApi
|
||||
.getMetadata(bucketName, objectKey)
|
||||
.then((data) => {
|
||||
if (!stale) setResult({ target: requestTarget, metadata: data });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!stale) {
|
||||
setResult({
|
||||
target: requestTarget,
|
||||
error: err instanceof Error ? err.message : 'Failed to load object metadata',
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
fetchMetadata();
|
||||
}, [bucketName, objectKey]);
|
||||
|
||||
const current = result && result.target === target ? result : null;
|
||||
const metadata = current?.metadata ?? null;
|
||||
const error = !target ? 'Bucket name and object key are required' : current?.error ?? null;
|
||||
const isLoading = Boolean(target) && !current;
|
||||
|
||||
const parentPath = objectKey?.split('/').slice(0, -1).join('/') ?? '';
|
||||
const fileName = objectKey?.split('/').pop() || objectKey || '';
|
||||
const backHref = `/buckets/${bucketName}/objects${parentPath ? `?prefix=${encodeURIComponent(parentPath + '/')}` : ''}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useNavigate} from 'react-router';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Checkbox} from '@/components/ui/checkbox';
|
||||
@@ -82,23 +82,25 @@ export function ObjectsTable({
|
||||
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
|
||||
const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]);
|
||||
const [currentPageIndex, setCurrentPageIndex] = useState(0);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
// Never read during render, so a ref rather than state.
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Initialize from URL params on first load
|
||||
useEffect(() => {
|
||||
if (!initialized && initialItemsPerPage && initialItemsPerPage !== itemsPerPage) {
|
||||
if (initializedRef.current) return;
|
||||
if (initialItemsPerPage && initialItemsPerPage !== itemsPerPage) {
|
||||
onItemsPerPageChange(initialItemsPerPage);
|
||||
setInitialized(true);
|
||||
initializedRef.current = true;
|
||||
}
|
||||
if (!initialized && initialPageToken && initialPageToken !== nextContinuationToken) {
|
||||
if (initialPageToken && initialPageToken !== nextContinuationToken) {
|
||||
// If we have an initial page token, trigger page change
|
||||
onPageChange(initialPageToken);
|
||||
setInitialized(true);
|
||||
initializedRef.current = true;
|
||||
}
|
||||
if (!initialized && !initialPageToken && !initialItemsPerPage) {
|
||||
setInitialized(true);
|
||||
if (!initialPageToken && !initialItemsPerPage) {
|
||||
initializedRef.current = true;
|
||||
}
|
||||
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||
}, [initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||
|
||||
const filteredObjects = useMemo(() => {
|
||||
// Filter on the debounced query, not the raw input, so the list only
|
||||
@@ -133,28 +135,25 @@ export function ObjectsTable({
|
||||
});
|
||||
}, [objects, filterQuery, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
// Effect 2: Reset pagination on path navigation or when a search begins/ends.
|
||||
// Search results are a single flat list, so page-token state must not leak
|
||||
// across the search/browse boundary.
|
||||
useEffect(() => {
|
||||
// Reset pagination on path navigation or when a search begins/ends. Search
|
||||
// results are a single flat list, so page-token state must not leak across
|
||||
// the search/browse boundary. Otherwise, record the next continuation token
|
||||
// as it arrives. Both are state adjustments done during render, not effects.
|
||||
const resetKey = JSON.stringify([currentPath, searchQuery, deepSearch]);
|
||||
const [prevResetKey, setPrevResetKey] = useState(resetKey);
|
||||
if (resetKey !== prevResetKey) {
|
||||
setPrevResetKey(resetKey);
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [currentPath, searchQuery, deepSearch]);
|
||||
|
||||
// Update page tokens when we get a new next token
|
||||
useEffect(() => {
|
||||
if (nextContinuationToken && isTruncated) {
|
||||
setPageTokens(prev => {
|
||||
const newTokens = [...prev];
|
||||
// Only add the token if we don't have it yet
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
if (nextIndex >= newTokens.length) {
|
||||
newTokens[nextIndex] = nextContinuationToken;
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
} else if (nextContinuationToken && isTruncated) {
|
||||
// Only add the token if we don't have it yet
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
if (nextIndex >= pageTokens.length) {
|
||||
const newTokens = [...pageTokens];
|
||||
newTokens[nextIndex] = nextContinuationToken;
|
||||
setPageTokens(newTokens);
|
||||
}
|
||||
}, [nextContinuationToken, isTruncated, currentPageIndex]);
|
||||
}
|
||||
|
||||
// Prefix search and normal browsing are server-paginated (query folded into
|
||||
// the prefix; continuation tokens for pages). Deep search loads the whole
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink, Outlet, useParams } from 'react-router-dom';
|
||||
import { NavLink, Outlet, useParams } from 'react-router';
|
||||
import { Database, Copy, Upload } from 'lucide-react';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Outlet, useLocation, useParams } from 'react-router-dom';
|
||||
import { Outlet, useLocation, useParams } from 'react-router';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { TopBar } from './top-bar';
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { useTheme } from '@/components/theme-context';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { User, LogOut, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { Breadcrumb, type BreadcrumbItem } from '@/components/ui/breadcrumb';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { useTheme } from '@/components/theme-context';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
interface ThemeProviderState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ThemeProviderContext, type Theme } from '@/components/theme-context';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -8,18 +7,6 @@ interface ThemeProviderProps {
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
interface ThemeProviderState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
@@ -61,11 +48,3 @@ export function ThemeProvider({
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -30,5 +30,3 @@ export interface BadgeProps
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { badgeVariants };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -52,4 +52,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -40,7 +40,13 @@ export function DangerousConfirmDialog({
|
||||
onConfirm,
|
||||
}: DangerousConfirmDialogProps) {
|
||||
const [value, setValue] = React.useState('');
|
||||
React.useEffect(() => { if (!open) setValue(''); }, [open]);
|
||||
|
||||
// Clear the field when the dialog closes (adjust-during-render, not an effect).
|
||||
const [wasOpen, setWasOpen] = React.useState(open);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (!open) setValue('');
|
||||
}
|
||||
|
||||
const matches = value === confirmationText;
|
||||
|
||||
|
||||
@@ -65,9 +65,12 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
return currentValue;
|
||||
}, [value, internalValue, children, placeholder]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Track the controlled value in internal state (adjust-during-render).
|
||||
const [prevValue, setPrevValue] = React.useState(value);
|
||||
if (value !== prevValue) {
|
||||
setPrevValue(value);
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
}
|
||||
|
||||
// The popup is portalled to the body so an ancestor with overflow-hidden
|
||||
// (a dialog card, a scroll container) cannot clip it.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { bucketsApi, objectsApi, accessApi, garageApi, analyticsApi } from '@/lib/api';
|
||||
import type { BucketPermission } from '@/types';
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -173,7 +174,7 @@ export function useCreateAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, permissions }: { name: string; permissions?: any[] }) =>
|
||||
mutationFn: ({ name, permissions }: { name: string; permissions?: BucketPermission[] }) =>
|
||||
accessApi.createKey(name, permissions),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
@@ -199,7 +200,7 @@ export function useUpdateAccessKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ keyId, updates }: { keyId: string; updates: any }) =>
|
||||
mutationFn: ({ keyId, updates }: { keyId: string; updates: { name?: string; status?: 'active' | 'inactive'; expiration?: string } }) =>
|
||||
accessApi.updateKey(keyId, updates),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.detail(variables.keyId) });
|
||||
|
||||
@@ -98,6 +98,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
||||
|
||||
// Deep search: recursive substring scan across the current subtree.
|
||||
if (debouncedSearch && deepSearch) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- imperative data fetching: the loading flag must flip synchronously at request start
|
||||
searchObjects(debouncedSearch);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,15 +146,17 @@ describe('useObjectPreview', () => {
|
||||
it('ignores a pending text decode after unmount', async () => {
|
||||
let resolveText: (value: string) => void = () => {};
|
||||
const blob = new Blob(['hello']);
|
||||
vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
|
||||
const textSpy = vi.spyOn(blob, 'text').mockReturnValue(new Promise<string>((res) => { resolveText = res; }));
|
||||
mockedGet.mockResolvedValue(blob);
|
||||
const { result, unmount } = renderHook(() => useObjectPreview('b', 'a.txt', 5, 'text/plain'), { wrapper: createWrapper() });
|
||||
await waitFor(() => expect(result.current.objectUrl).toBe('blob:mock-url'));
|
||||
await waitFor(() => expect(textSpy).toHaveBeenCalled());
|
||||
|
||||
// Unmount before the decode resolves, then resolve it. The cancelled
|
||||
// guard must swallow the late result rather than set state.
|
||||
// guard must swallow the late result rather than set state, and the
|
||||
// never-committed object url must still be revoked.
|
||||
unmount();
|
||||
act(() => resolveText('hello'));
|
||||
expect(result.current.text).toBeNull();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -228,7 +228,7 @@ export const bucketsApi = {
|
||||
name: string,
|
||||
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
|
||||
) => {
|
||||
const response = await api.put<ApiResponse<any>>(
|
||||
const response = await api.put<ApiResponse<unknown>>(
|
||||
`/v1/buckets/${encodeURIComponent(name)}/website`,
|
||||
payload
|
||||
);
|
||||
@@ -244,7 +244,7 @@ export const bucketsApi = {
|
||||
const body: { maxSize?: number; maxObjects?: number } = {};
|
||||
if (payload.maxSize !== null) body.maxSize = payload.maxSize;
|
||||
if (payload.maxObjects !== null) body.maxObjects = payload.maxObjects;
|
||||
const response = await api.put<ApiResponse<any>>(
|
||||
const response = await api.put<ApiResponse<unknown>>(
|
||||
`/v1/buckets/${encodeURIComponent(name)}/quotas`,
|
||||
body
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
|
||||
import { useBucketObjects } from '@/hooks/useBucketObjects';
|
||||
import { useBuckets } from '@/hooks/useApi';
|
||||
@@ -26,13 +26,16 @@ export function BucketObjects() {
|
||||
parseInt(searchParams.get('limit') ?? '25', 10),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from the URL when it changes (back/forward navigation) — done as a
|
||||
// state adjustment during render rather than an effect.
|
||||
const [prevSearchParams, setPrevSearchParams] = useState(searchParams);
|
||||
if (searchParams !== prevSearchParams) {
|
||||
setPrevSearchParams(searchParams);
|
||||
const prefix = searchParams.get('prefix') ?? '';
|
||||
if (prefix !== currentPath) setCurrentPath(prefix);
|
||||
setInitialPageToken(searchParams.get('page') ?? undefined);
|
||||
setInitialItemsPerPage(parseInt(searchParams.get('limit') ?? '25', 10));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]);
|
||||
}
|
||||
|
||||
const {
|
||||
objects,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { KeyRound, ShieldCheck } from 'lucide-react';
|
||||
import { useAccessKeys, useGrantBucketPermission } from '@/hooks/useApi';
|
||||
import type { AccessKey } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectOption } from '@/components/ui/select';
|
||||
@@ -9,9 +10,13 @@ import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Stable fallback so the render-adjust below doesn't see a fresh array
|
||||
// identity on every render while the keys are still loading.
|
||||
const NO_KEYS: AccessKey[] = [];
|
||||
|
||||
export function BucketPermissions() {
|
||||
const { bucketName = '' } = useParams<{ bucketName: string }>();
|
||||
const { data: availableKeys = [] } = useAccessKeys();
|
||||
const { data: availableKeys = NO_KEYS } = useAccessKeys();
|
||||
const grant = useGrantBucketPermission();
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState('');
|
||||
@@ -19,19 +24,27 @@ export function BucketPermissions() {
|
||||
const [write, setWrite] = useState(false);
|
||||
const [owner, setOwner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Prefill the checkboxes from the selected key's existing grant (adjust
|
||||
// during render, not an effect).
|
||||
const [prevSync, setPrevSync] = useState({ selectedKey, availableKeys, bucketName });
|
||||
if (
|
||||
prevSync.selectedKey !== selectedKey ||
|
||||
prevSync.availableKeys !== availableKeys ||
|
||||
prevSync.bucketName !== bucketName
|
||||
) {
|
||||
setPrevSync({ selectedKey, availableKeys, bucketName });
|
||||
if (!selectedKey) {
|
||||
setRead(false); setWrite(false); setOwner(false);
|
||||
return;
|
||||
} else {
|
||||
const key = availableKeys.find((k) => k.accessKeyId === selectedKey);
|
||||
const existing = key?.permissions.find(
|
||||
(p) => p.bucketName === bucketName || p.bucketId === bucketName,
|
||||
);
|
||||
setRead(existing?.read ?? false);
|
||||
setWrite(existing?.write ?? false);
|
||||
setOwner(existing?.owner ?? false);
|
||||
}
|
||||
const key = availableKeys.find((k) => k.accessKeyId === selectedKey);
|
||||
const existing = key?.permissions.find(
|
||||
(p) => p.bucketName === bucketName || p.bucketId === bucketName,
|
||||
);
|
||||
setRead(existing?.read ?? false);
|
||||
setWrite(existing?.write ?? false);
|
||||
setOwner(existing?.owner ?? false);
|
||||
}, [selectedKey, availableKeys, bucketName]);
|
||||
}
|
||||
|
||||
const canSubmit = !!selectedKey && (read || write || owner) && !grant.isPending;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { AlertTriangle, Gauge, Info } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useForm, useWatch, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useBuckets, useDeleteBucket, useUpdateBucketQuotas } from '@/hooks/useApi';
|
||||
@@ -98,7 +98,6 @@ export function BucketSettings() {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<QuotaFormValues>({
|
||||
@@ -106,13 +105,15 @@ export function BucketSettings() {
|
||||
values: defaults,
|
||||
});
|
||||
|
||||
const watched = watch();
|
||||
// useWatch rather than watch(): same values, but compatible with React
|
||||
// Compiler memoization (watch() reads mutable form state during render).
|
||||
const watched = useWatch({ control });
|
||||
|
||||
const currentSize = bucket?.size ?? 0;
|
||||
const currentObjects = bucket?.objectCount ?? 0;
|
||||
|
||||
const newMaxSizeBytes =
|
||||
watched.maxSizeEnabled && watched.maxSizeValue !== '' && !Number.isNaN(Number(watched.maxSizeValue))
|
||||
watched.maxSizeEnabled && watched.maxSizeUnit && watched.maxSizeValue !== '' && !Number.isNaN(Number(watched.maxSizeValue))
|
||||
? quotaValueToBytes(Number(watched.maxSizeValue), watched.maxSizeUnit)
|
||||
: null;
|
||||
const newMaxObjects =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, Globe } from 'lucide-react';
|
||||
import { useBuckets } from '@/hooks/useApi';
|
||||
@@ -22,13 +22,24 @@ export function BucketWebsite() {
|
||||
const [errorDocument, setErrorDocument] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Sync local form state whenever the underlying bucket changes.
|
||||
useEffect(() => {
|
||||
if (!bucket) return;
|
||||
// Sync local form state whenever the underlying bucket changes (adjust
|
||||
// during render, not an effect). Starts at null so the first render with
|
||||
// bucket data populates the form.
|
||||
const websiteKey = bucket
|
||||
? JSON.stringify([
|
||||
bucket.name,
|
||||
bucket.websiteAccess,
|
||||
bucket.websiteConfig?.indexDocument ?? null,
|
||||
bucket.websiteConfig?.errorDocument ?? null,
|
||||
])
|
||||
: null;
|
||||
const [prevWebsiteKey, setPrevWebsiteKey] = useState<string | null>(null);
|
||||
if (bucket && websiteKey !== prevWebsiteKey) {
|
||||
setPrevWebsiteKey(websiteKey);
|
||||
setEnabled(bucket.websiteAccess);
|
||||
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
|
||||
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
|
||||
}, [bucket?.name, bucket?.websiteAccess, bucket?.websiteConfig?.indexDocument, bucket?.websiteConfig?.errorDocument]);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading…</div>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { BasicLoginForm } from '@/components/auth/BasicLoginForm';
|
||||
import { OIDCLoginView } from '@/components/auth/OIDCLoginView';
|
||||
|
||||
@@ -64,7 +64,7 @@ export const useAuthStore = create<AuthStore>()(
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Not authenticated - this is okay
|
||||
set({
|
||||
user: null,
|
||||
|
||||
@@ -106,7 +106,7 @@ export interface PolicyStatement {
|
||||
principal: string | string[];
|
||||
action: string | string[];
|
||||
resource: string | string[];
|
||||
condition?: Record<string, any>;
|
||||
condition?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// User types
|
||||
@@ -185,7 +185,7 @@ export interface ClusterStatistics {
|
||||
timestamp: number;
|
||||
uptime: number;
|
||||
freeform: string;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ClusterStatus {
|
||||
@@ -274,7 +274,7 @@ export interface NodeInfo {
|
||||
objectVersionTableSize: number;
|
||||
bucketTableSize: number;
|
||||
bucketAliasTableSize: number;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RequestTypeMetrics {
|
||||
|
||||
@@ -3,8 +3,8 @@ name: garage-ui
|
||||
description: A Helm chart for Garage UI - Web interface for Garage S3 object storage
|
||||
icon: https://helm.noste.dev/garage.png
|
||||
type: application
|
||||
version: 0.11.0 # x-release-please-version
|
||||
appVersion: v0.11.0 # x-release-please-version
|
||||
version: 0.11.1 # x-release-please-version
|
||||
appVersion: v0.11.1 # x-release-please-version
|
||||
keywords:
|
||||
- garage
|
||||
- s3
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A Helm chart for deploying [Garage UI](https://github.com/Noooste/garage-ui), a modern web interface for managing [Garage](https://garagehq.deuxfleurs.fr/) distributed object storage systems.
|
||||
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
[](Chart.yaml) <!-- x-release-please-version -->
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
@@ -160,6 +160,14 @@ config:
|
||||
# Options: json, text
|
||||
format: "json"
|
||||
|
||||
# Optional: team-based access control (see docs/access-control.md).
|
||||
# Absent -> every authenticated user has full access.
|
||||
# Present -> default-deny: OIDC users get only what their teams grant; users
|
||||
# matching no team get 403 everywhere. admin_role users, admin
|
||||
# password logins, and token logins are always full-admin.
|
||||
# NOTE: this is UI-layer policy, NOT a security boundary. Anyone holding the
|
||||
# Garage admin token or S3 keys bypasses it entirely.
|
||||
#
|
||||
# access_control:
|
||||
# presets:
|
||||
# bucket_readonly: [bucket.list, bucket.read, object.list, object.read]
|
||||
|
||||
Reference in New Issue
Block a user