Compare commits

...

5 Commits

Author SHA1 Message Date
garage-ui-release-bot[bot] 76adb16905 chore(main): release 0.11.1 (#101)
Co-authored-by: garage-ui-release-bot[bot] <285030457+garage-ui-release-bot[bot]@users.noreply.github.com>
2026-07-29 18:00:40 +02:00
Noste 987b7e8625 fix(values.yaml): add comments for team-based access control configuration 2026-07-29 17:59:39 +02:00
Noste 91770dc5b2 chore(deps): upgraded react-router-dom to 8.3.0 2026-07-29 17:07:30 +02:00
dependabot[bot] 3401deea8b chore(deps-dev): bump js-yaml from 4.2.0 to 4.3.0 in /frontend (#97)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Noste <83548733+Noooste@users.noreply.github.com>
2026-07-29 16:20:55 +02:00
dependabot[bot] bd0aada4ea chore(deps): bump brace-expansion, eslint and typescript-eslint (#98)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) to 5.0.7 and updates ancestor dependencies [brace-expansion](https://github.com/juliangruber/brace-expansion), [eslint](https://github.com/eslint/eslint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint). These dependencies need to be updated together.


Updates `brace-expansion` from 2.0.2 to 5.0.7
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v5.0.7)

Updates `eslint` from 9.39.2 to 10.7.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v10.7.0)

Updates `typescript-eslint` from 8.50.1 to 8.65.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.65.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.7
  dependency-type: indirect
- dependency-name: eslint
  dependency-version: 10.7.0
  dependency-type: direct:development
- dependency-name: typescript-eslint
  dependency-version: 8.65.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 16:19:50 +02:00
41 changed files with 617 additions and 1216 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.11.0"
".": "0.11.1"
}
+7
View File
@@ -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)
+392 -1065
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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"
}
+3 -2
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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';
+23
View File
@@ -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;
};
+2 -23
View File
@@ -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;
};
-2
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -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;
+5 -2
View File
@@ -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.
+3 -2
View File
@@ -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) });
+1
View File
@@ -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;
}
+5 -3
View File
@@ -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.
+2 -2
View File
@@ -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
);
+7 -4
View File
@@ -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,
+26 -13
View File
@@ -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;
+6 -5
View File
@@ -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 =
+17 -6
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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';
+1 -1
View File
@@ -64,7 +64,7 @@ export const useAuthStore = create<AuthStore>()(
isAuthenticated: true,
isLoading: false
});
} catch (error) {
} catch {
// Not authenticated - this is okay
set({
user: null,
+3 -3
View File
@@ -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 {
+2 -2
View File
@@ -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 -2
View File
@@ -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.
[![Version](https://img.shields.io/badge/version-0.11.0?color=blue)](Chart.yaml) <!-- x-release-please-version -->
[![App Version](https://img.shields.io/badge/app%20version-v0.11.0?color=green)](Chart.yaml) <!-- x-release-please-version -->
[![Version](https://img.shields.io/badge/version-0.11.1?color=blue)](Chart.yaml) <!-- x-release-please-version -->
[![App Version](https://img.shields.io/badge/app%20version-v0.11.1?color=green)](Chart.yaml) <!-- x-release-please-version -->
## Table of Contents
+8
View File
@@ -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]