mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-30 17:22:11 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76adb16905 | |||
| 987b7e8625 | |||
| 91770dc5b2 | |||
| 3401deea8b | |||
| bd0aada4ea | |||
| e3a1b31860 | |||
| 928242a738 | |||
| 6766dec933 | |||
| a061500d50 | |||
| 985b7b30ce | |||
| 8f6cb2b1da |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.10.0"
|
||||
".": "0.11.1"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **bucket:** add functionality to create buckets with optional access keys and permissions ([6766dec](https://github.com/Noooste/garage-ui/commit/6766dec933f85ece64d89875ccc147c069669a0c))
|
||||
* **dropdown:** improve positioning logic and add popup positioning utility ([985b7b3](https://github.com/Noooste/garage-ui/commit/985b7b30ce1cc450f621169068efcfa1dccd2a25))
|
||||
* **frontend:** make bucket items clickable links to object view ([a061500](https://github.com/Noooste/garage-ui/commit/a061500d50ddd1ec1cc713c06fd24a9b9f1511ed))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** handle gzip decompression for API responses ([928242a](https://github.com/Noooste/garage-ui/commit/928242a7383b99cfcd678d0f60c8771d12568b98))
|
||||
|
||||
## [0.10.0](https://github.com/Noooste/garage-ui/compare/v0.9.0...v0.10.0) (2026-07-14)
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ package services
|
||||
import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -363,13 +362,12 @@ func (s *GarageV1AdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.RawBody.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(resp.RawBody)
|
||||
bodyBytes, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
return string(bodyBytes), nil
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ package services
|
||||
import (
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
logpkg "Noooste/garage-ui/pkg/logger"
|
||||
"Noooste/garage-ui/pkg/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -63,17 +62,38 @@ func (s *GarageV2AdminService) doRequest(ctx context.Context, method, path strin
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// decodeResponse decodes a JSON response into the target structure
|
||||
// readResponseBody reads and decompresses the response body.
|
||||
//
|
||||
// doRequest sets IgnoreBody, so azuretls does not read or decompress the body
|
||||
// for us. Over HTTP/1.1 the underlying transport transparently gunzips the body
|
||||
// and clears Content-Encoding, but over HTTP/2 (e.g. Garage behind a reverse
|
||||
// proxy) the body is delivered still-compressed with Content-Encoding set. We
|
||||
// therefore decode according to that header before touching the payload;
|
||||
// otherwise a gzip'd body reaches the JSON parser as raw bytes and fails with
|
||||
// "invalid character '\x1f'" (0x1f is the gzip magic byte). See issue #95.
|
||||
func readResponseBody(resp *azuretls.Response) ([]byte, error) {
|
||||
bodyBytes, err := azuretls.DecodeResponseBody(resp.RawBody, resp.Header.Get("Content-Encoding"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
return bodyBytes, nil
|
||||
}
|
||||
|
||||
// decodeResponse decodes a JSON response into the target structure.
|
||||
func decodeResponse(resp *azuretls.Response, target interface{}) error {
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
bodyBytes, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
if target != nil {
|
||||
if err := json.NewDecoder(resp.RawBody).Decode(target); err != nil {
|
||||
if err := json.Unmarshal(bodyBytes, target); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -535,14 +555,13 @@ func (s *GarageV2AdminService) GetMetrics(ctx context.Context) (string, error) {
|
||||
}
|
||||
defer resp.RawBody.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.RawBody)
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
bodyBytes, err := readResponseBody(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.RawBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return string(bodyBytes), nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -13,6 +15,9 @@ import (
|
||||
|
||||
"Noooste/garage-ui/internal/config"
|
||||
"Noooste/garage-ui/internal/models"
|
||||
|
||||
"github.com/Noooste/azuretls-client"
|
||||
fhttp "github.com/Noooste/fhttp"
|
||||
)
|
||||
|
||||
// newAdminTestServer wires an httptest.Server (with the supplied handler) to a
|
||||
@@ -531,6 +536,66 @@ func TestDoRequest_MalformedJSONReturnsDecodeError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// gzipBytes gzip-compresses b, mirroring what a reverse proxy or the Garage
|
||||
// admin API emits when Content-Encoding: gzip negotiation succeeds.
|
||||
func gzipBytes(t *testing.T, b []byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
if _, err := gz.Write(b); err != nil {
|
||||
t.Fatalf("gzip write: %v", err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatalf("gzip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestDecodeResponse_GzipEncodedBody reproduces issue #95. Over HTTP/2 (Garage
|
||||
// 2.3.0 behind a reverse proxy) the transport does NOT transparently gunzip, so
|
||||
// decodeResponse receives a still-compressed RawBody with Content-Encoding:gzip.
|
||||
// Feeding that raw gzip stream to the JSON decoder failed with:
|
||||
// "invalid character '\x1f' looking for beginning of value" (0x1f is the gzip
|
||||
// magic byte). decodeResponse must honor Content-Encoding and decompress first.
|
||||
func TestDecodeResponse_GzipEncodedBody(t *testing.T) {
|
||||
want := &models.GarageBucketInfo{ID: "gz-bucket"}
|
||||
payload, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
resp := &azuretls.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: fhttp.Header{"Content-Encoding": []string{"gzip"}},
|
||||
RawBody: io.NopCloser(bytes.NewReader(gzipBytes(t, payload))),
|
||||
}
|
||||
|
||||
var got models.GarageBucketInfo
|
||||
if err := decodeResponse(resp, &got); err != nil {
|
||||
t.Fatalf("decodeResponse with gzip body: %v", err)
|
||||
}
|
||||
if got.ID != want.ID {
|
||||
t.Errorf("ID = %q, want %q", got.ID, want.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeResponse_GzipEncodedErrorBody ensures a compressed non-2xx body is
|
||||
// also decompressed before being echoed into the error message.
|
||||
func TestDecodeResponse_GzipEncodedErrorBody(t *testing.T) {
|
||||
resp := &azuretls.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Header: fhttp.Header{"Content-Encoding": []string{"gzip"}},
|
||||
RawBody: io.NopCloser(bytes.NewReader(gzipBytes(t, []byte("boom")))),
|
||||
}
|
||||
|
||||
err := decodeResponse(resp, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("error %q should contain decompressed body %q", err.Error(), "boom")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllMethods_Non2xxReturnsError exercises the decodeResponse error branch
|
||||
// of every admin method by pointing them at a server that always returns 500.
|
||||
// This is a single sweep over the near-identical "if err := decodeResponse ...
|
||||
|
||||
Generated
+419
-1068
File diff suppressed because it is too large
Load Diff
@@ -17,17 +17,17 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.16.0",
|
||||
"axios": "^1.18.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, Check, Database, ShieldCheck, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -12,18 +13,67 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { CredentialField } from '@/components/ui/credential-field';
|
||||
import type { CreateBucketResult, KeyPermissions, NewKeyRequest } from '@/lib/create-bucket-with-key';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateBucketDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateBucket: (name: string) => Promise<boolean>;
|
||||
onCreateBucket: (name: string, key?: NewKeyRequest) => Promise<CreateBucketResult>;
|
||||
canCreateKey: boolean;
|
||||
}
|
||||
|
||||
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
|
||||
const [bucketName, setBucketName] = useState('');
|
||||
const DEFAULT_PERMISSIONS: KeyPermissions = { read: true, write: true, owner: false };
|
||||
|
||||
useEffect(() => { if (!open) setBucketName(''); }, [open]);
|
||||
const PERMISSION_ROWS = [
|
||||
{ field: 'read', label: 'Read', desc: 'GetObject, HeadObject, ListObjects' },
|
||||
{ field: 'write', label: 'Write', desc: 'PutObject, DeleteObject' },
|
||||
{ field: 'owner', label: 'Owner', desc: 'DeleteBucket, PutBucketPolicy' },
|
||||
] as const;
|
||||
|
||||
function StatusRow({ ok, children }: { ok: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<li className="flex items-start gap-2 text-[13.5px]">
|
||||
{ok ? (
|
||||
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--primary)]" />
|
||||
) : (
|
||||
<X className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="flex-1">{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateBucketDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreateBucket,
|
||||
canCreateKey,
|
||||
}: CreateBucketDialogProps) {
|
||||
const [bucketName, setBucketName] = useState('');
|
||||
const [withKey, setWithKey] = useState(false);
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [permissions, setPermissions] = useState<KeyPermissions>(DEFAULT_PERMISSIONS);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [result, setResult] = useState<CreateBucketResult | null>(null);
|
||||
|
||||
// Closing resets the form here rather than in an effect on `open`: Cancel,
|
||||
// Done, the close button, the backdrop and Escape all route through Dialog's
|
||||
// onOpenChange, so this is the single place a close can happen.
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
setBucketName('');
|
||||
setWithKey(false);
|
||||
setKeyName('');
|
||||
setPermissions(DEFAULT_PERMISSIONS);
|
||||
setCreating(false);
|
||||
setResult(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const derivedKeyName = `${bucketName || 'my-bucket'}-key`;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!bucketName) {
|
||||
@@ -31,15 +81,96 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onCreateBucket(bucketName);
|
||||
if (success) {
|
||||
setBucketName('');
|
||||
onOpenChange(false);
|
||||
setCreating(true);
|
||||
try {
|
||||
const outcome = await onCreateBucket(
|
||||
bucketName,
|
||||
withKey && canCreateKey ? { name: keyName || derivedKeyName, permissions } : undefined,
|
||||
);
|
||||
|
||||
// Which panel to show follows from the outcome, not from the form state.
|
||||
if (outcome.bucket === 'failed') return; // the axios interceptor already toasted
|
||||
if (outcome.key || outcome.keyError) {
|
||||
setResult(outcome);
|
||||
return;
|
||||
}
|
||||
handleOpenChange(false);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (result) {
|
||||
const degraded = !!result.keyError || !!result.grantError;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} size="form">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<IconTile
|
||||
icon={degraded ? <AlertTriangle /> : <ShieldCheck />}
|
||||
tone="primary"
|
||||
size="md"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle>{result.key ? 'Bucket and key created' : 'Bucket created'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{result.key
|
||||
? 'Copy your secret access key now, this is the only time it will be shown.'
|
||||
: 'The bucket is ready, but the access key was not created.'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-5">
|
||||
<ul className="space-y-2">
|
||||
<StatusRow ok>
|
||||
Bucket <span className="font-mono">{bucketName}</span> created
|
||||
</StatusRow>
|
||||
{result.key ? (
|
||||
<StatusRow ok>
|
||||
Access key <span className="font-mono">{result.key.name}</span> created
|
||||
</StatusRow>
|
||||
) : (
|
||||
<StatusRow ok={false}>
|
||||
Access key could not be created. You can create one from Access control.
|
||||
</StatusRow>
|
||||
)}
|
||||
{result.key && (
|
||||
<StatusRow ok={!result.grantError}>
|
||||
{result.grantError
|
||||
? 'Permissions were not applied on the bucket. Grant them from Access control.'
|
||||
: 'Permissions granted on the bucket'}
|
||||
</StatusRow>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{result.key && (
|
||||
<>
|
||||
<CredentialField label="Access Key ID" value={result.key.accessKeyId} breakAll />
|
||||
<CredentialField label="Secret Access Key" value={result.key.secretKey} breakAll />
|
||||
<div className="flex gap-3 rounded-lg border border-[var(--accent-primary-border)] bg-[var(--accent-primary-soft)] px-3.5 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--primary)]" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[13.5px] font-medium text-[var(--foreground)]">
|
||||
Save this key now
|
||||
</p>
|
||||
<p className="text-[12.5px] leading-[1.5] text-[var(--muted-foreground)]">
|
||||
The secret access key cannot be retrieved again. If lost, you'll need to create a new key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => handleOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} size="form">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||
@@ -50,10 +181,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody className="space-y-4">
|
||||
<DialogBody className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Bucket Name</label>
|
||||
<label htmlFor="new-bucket-name" className="text-sm font-medium">
|
||||
Bucket Name
|
||||
</label>
|
||||
<Input
|
||||
id="new-bucket-name"
|
||||
autoFocus
|
||||
placeholder="my-bucket-name"
|
||||
value={bucketName}
|
||||
@@ -68,17 +202,78 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
||||
Must be unique and follow DNS naming conventions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreateKey && (
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<Checkbox
|
||||
checked={withKey}
|
||||
className="mt-0.5"
|
||||
onCheckedChange={(checked) => setWithKey(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">Also create an access key</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Optional, an S3 key scoped to this bucket. You can also do this later from Access control.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{withKey && (
|
||||
<div className="space-y-4 border-t border-[var(--border)] pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="new-bucket-key-name" className="text-[13px] font-medium">
|
||||
Key name
|
||||
</label>
|
||||
<Input
|
||||
id="new-bucket-key-name"
|
||||
placeholder={derivedKeyName}
|
||||
value={keyName}
|
||||
onChange={(e) => setKeyName(e.target.value)}
|
||||
/>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Leave empty to use {derivedKeyName}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[13px] font-medium">Permissions</label>
|
||||
<div className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
|
||||
{PERMISSION_ROWS.map((p) => (
|
||||
<label
|
||||
key={p.field}
|
||||
htmlFor={`new-bucket-key-${p.field}`}
|
||||
className="flex cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[var(--accent)]"
|
||||
>
|
||||
<Checkbox
|
||||
id={`new-bucket-key-${p.field}`}
|
||||
checked={permissions[p.field]}
|
||||
className="mt-0.5"
|
||||
onCheckedChange={(checked) =>
|
||||
setPermissions((prev) => ({ ...prev, [p.field]: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-[13.5px] font-medium">{p.label}</div>
|
||||
<p className="mt-0.5 font-mono text-[12px] text-[var(--muted-foreground)]">
|
||||
{p.desc}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={!bucketName}
|
||||
>
|
||||
Create
|
||||
<Button variant="primary" onClick={handleCreate} disabled={!bucketName || creating}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function CredentialField({
|
||||
label,
|
||||
value,
|
||||
mono = true,
|
||||
breakAll = false,
|
||||
maskable = false,
|
||||
loading = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
breakAll?: boolean;
|
||||
maskable?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revealed, setRevealed] = useState(!maskable);
|
||||
const copy = () => {
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
toast.success(`${label} copied`);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
disabled={loading || !value}
|
||||
title="Click to copy"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
|
||||
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
|
||||
mono && 'font-mono',
|
||||
breakAll ? 'break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
display
|
||||
)}
|
||||
</button>
|
||||
{maskable && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
aria-label={revealed ? 'Hide' : 'Reveal'}
|
||||
disabled={loading || !value}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
|
||||
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {placeUnder} from '@/lib/popup-position';
|
||||
|
||||
interface DropdownMenuContextValue {
|
||||
open: boolean;
|
||||
@@ -62,36 +63,30 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
|
||||
({ className, children, align = 'start', ...props }) => {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenu();
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
||||
const [position, setPosition] = React.useState<React.CSSProperties>({});
|
||||
|
||||
// Positioned in viewport coordinates against the trigger, since the menu is
|
||||
// portalled to the body and rendered fixed.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Calculate position based on trigger element
|
||||
React.useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
if (open && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
|
||||
let left = rect.left + scrollX;
|
||||
const top = rect.bottom + scrollY + 8; // 8px gap (mt-2)
|
||||
|
||||
// Adjust horizontal alignment
|
||||
if (align === 'end') {
|
||||
left = rect.right + scrollX - 224; // 224px = w-56
|
||||
} else if (align === 'center') {
|
||||
left = rect.left + scrollX + (rect.width / 2) - 112; // 112px = half of w-56
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
let left = rect.left;
|
||||
if (align === 'end') {
|
||||
left = rect.right - 224; // 224px = w-56
|
||||
} else if (align === 'center') {
|
||||
left = rect.left + rect.width / 2 - 112; // 112px = half of w-56
|
||||
}
|
||||
|
||||
setPosition({ left, ...placeUnder(rect, window.innerHeight, 8) });
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
if (open) {
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
}
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
@@ -126,11 +121,10 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
|
||||
style={{
|
||||
backgroundColor: 'var(--popover)',
|
||||
position: 'fixed',
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
...position,
|
||||
}}
|
||||
className={cn(
|
||||
'z-50 w-56 origin-top-right rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
|
||||
'z-50 w-56 origin-top-right overflow-auto rounded-md text-popover-foreground shadow-lg ring-1 ring-border border border-border focus:outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {placeUnder} from '@/lib/popup-position';
|
||||
import {ChevronDown, Check} from 'lucide-react';
|
||||
|
||||
export interface SelectOption {
|
||||
@@ -32,11 +34,16 @@ const useSelectContext = () => {
|
||||
};
|
||||
|
||||
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, _ref) => {
|
||||
({ className, children, value, onChange, disabled, placeholder = 'Select an option...', ...props }, ref) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalValue, setInternalValue] = React.useState(value);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const popupRef = React.useRef<HTMLDivElement>(null);
|
||||
const [popupStyle, setPopupStyle] = React.useState<React.CSSProperties>({});
|
||||
|
||||
// The forwarded ref points at the trigger, as it does on DropdownMenuTrigger.
|
||||
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
|
||||
|
||||
const displayValue = React.useMemo(() => {
|
||||
const currentValue = value ?? internalValue;
|
||||
@@ -58,15 +65,47 @@ 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.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const trigger = buttonRef.current;
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
|
||||
setPopupStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
backgroundColor: 'var(--popover)',
|
||||
...placeUnder(rect, window.innerHeight),
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
const target = event.target as Node;
|
||||
if (containerRef.current?.contains(target) || popupRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (open) {
|
||||
@@ -106,13 +145,15 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', open && 'transform rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open && createPortal(
|
||||
<div
|
||||
className="absolute z-50 w-full mt-1 text-popover-foreground rounded-md border border-border shadow-lg max-h-60 overflow-auto"
|
||||
style={{ backgroundColor: 'var(--popover)' }}
|
||||
ref={popupRef}
|
||||
className="z-50 text-popover-foreground rounded-md border border-border shadow-lg overflow-auto"
|
||||
style={popupStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
</SelectContext.Provider>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { AccessKey } from '@/types';
|
||||
|
||||
export interface KeyPermissions {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
owner: boolean;
|
||||
}
|
||||
|
||||
export interface NewKeyRequest {
|
||||
name: string;
|
||||
permissions: KeyPermissions;
|
||||
}
|
||||
|
||||
export interface CreateBucketResult {
|
||||
bucket: 'ok' | 'failed';
|
||||
key?: { name: string; accessKeyId: string; secretKey: string };
|
||||
keyError?: string;
|
||||
grantError?: string;
|
||||
}
|
||||
|
||||
export interface CreateBucketDeps {
|
||||
createBucket: (name: string) => Promise<void>;
|
||||
createKey: (name: string) => Promise<AccessKey>;
|
||||
grant: (bucket: string, accessKeyId: string, permissions: KeyPermissions) => Promise<void>;
|
||||
}
|
||||
|
||||
const message = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
/**
|
||||
* Bucket, then key, then grant, against three separate endpoints. There is no
|
||||
* rollback: whatever succeeded stays and the caller renders the partial
|
||||
* outcome. A failed grant still reports the key because the API returns the
|
||||
* secret exactly once.
|
||||
*/
|
||||
export async function createBucketWithKey(
|
||||
deps: CreateBucketDeps,
|
||||
bucketName: string,
|
||||
key?: NewKeyRequest,
|
||||
): Promise<CreateBucketResult> {
|
||||
try {
|
||||
await deps.createBucket(bucketName);
|
||||
} catch {
|
||||
return { bucket: 'failed' };
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
return { bucket: 'ok' };
|
||||
}
|
||||
|
||||
let created: AccessKey;
|
||||
try {
|
||||
created = await deps.createKey(key.name);
|
||||
} catch (e) {
|
||||
return { bucket: 'ok', keyError: message(e) };
|
||||
}
|
||||
|
||||
const result: CreateBucketResult = {
|
||||
bucket: 'ok',
|
||||
key: {
|
||||
name: created.name || key.name,
|
||||
accessKeyId: created.accessKeyId,
|
||||
secretKey: created.secretKey ?? '',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await deps.grant(bucketName, created.accessKeyId, key.permissions);
|
||||
} catch (e) {
|
||||
result.grantError = message(e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface Placement {
|
||||
/** Distance from the viewport top, when the popup opens downwards. */
|
||||
top?: number;
|
||||
/** Distance from the viewport bottom, when the popup flips above its trigger. */
|
||||
bottom?: number;
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a floating layer against its trigger in viewport coordinates, for use
|
||||
* with `position: fixed` so no ancestor's overflow can clip it. Flips above the
|
||||
* trigger when the room below is too tight to be usable.
|
||||
*/
|
||||
export function placeUnder(
|
||||
rect: { top: number; bottom: number },
|
||||
viewportHeight: number,
|
||||
gap = 4,
|
||||
maxHeight = 240,
|
||||
): Placement {
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap * 2;
|
||||
const spaceAbove = rect.top - gap * 2;
|
||||
const flip = spaceBelow < Math.min(maxHeight, 160) && spaceAbove > spaceBelow;
|
||||
|
||||
// Floor the height so a trigger at the very edge still shows a scrollable
|
||||
// popup rather than collapsing to nothing.
|
||||
const fit = (space: number) => Math.max(120, Math.min(maxHeight, space));
|
||||
|
||||
return flip
|
||||
? { bottom: viewportHeight - rect.top + gap, maxHeight: fit(spaceAbove) }
|
||||
: { top: rect.bottom + gap, maxHeight: fit(spaceBelow) };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { CredentialField } from '@/components/ui/credential-field';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,82 +33,9 @@ import {accessApi, bucketsApi} from '@/lib/api';
|
||||
import {queryKeys} from '@/lib/query-client';
|
||||
import {formatDate} from '@/lib/utils';
|
||||
import type {AccessKey, Bucket, BucketPermission} from '@/types';
|
||||
import {AlertTriangle, Calendar, Check, Copy, Database, Edit, Eye, EyeOff, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
|
||||
import {AlertTriangle, Calendar, Copy, Database, Edit, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
function CredentialField({
|
||||
label,
|
||||
value,
|
||||
mono = true,
|
||||
breakAll = false,
|
||||
maskable = false,
|
||||
loading = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
breakAll?: boolean;
|
||||
maskable?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [revealed, setRevealed] = useState(!maskable);
|
||||
const copy = () => {
|
||||
if (!value) return;
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
toast.success(`${label} copied`);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
disabled={loading || !value}
|
||||
title="Click to copy"
|
||||
className={cn(
|
||||
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
|
||||
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
|
||||
mono && 'font-mono',
|
||||
breakAll ? 'break-all' : 'truncate',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading…
|
||||
</span>
|
||||
) : (
|
||||
display
|
||||
)}
|
||||
</button>
|
||||
{maskable && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => setRevealed((r) => !r)}
|
||||
aria-label={revealed ? 'Hide' : 'Reveal'}
|
||||
disabled={loading || !value}
|
||||
>
|
||||
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
|
||||
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||
|
||||
@@ -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,8 +1,12 @@
|
||||
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';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { accessApi, bucketsApi } from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query-client';
|
||||
import { createBucketWithKey, type NewKeyRequest } from '@/lib/create-bucket-with-key';
|
||||
import { BucketListView } from '@/components/buckets/BucketListView';
|
||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||
@@ -17,18 +21,41 @@ export function Buckets() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { hasAnyPerm } = usePermissions();
|
||||
const { data: buckets = [], isLoading } = useBuckets();
|
||||
const createMutation = useCreateBucket();
|
||||
const deleteMutation = useDeleteBucket();
|
||||
|
||||
const createBucket = async (name: string, region?: string) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({ name, region });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
// Both grant permissions are required by POST /buckets/:name/permissions, and
|
||||
// the bucket does not exist yet, so this is a best-effort check across the
|
||||
// subject's bindings. A 403 at call time surfaces in the dialog's outcome panel.
|
||||
const canCreateKey =
|
||||
hasAnyPerm('key.create') &&
|
||||
hasAnyPerm('permission.allow_bucket_key') &&
|
||||
hasAnyPerm('permission.deny_bucket_key');
|
||||
|
||||
const createBucket = async (name: string, key?: NewKeyRequest) => {
|
||||
const result = await createBucketWithKey(
|
||||
{
|
||||
createBucket: (n) => createMutation.mutateAsync({ name: n }),
|
||||
// Called directly, not through useCreateAccessKey and
|
||||
// useGrantBucketPermission: their success toasts would stack on top of
|
||||
// the dialog's own outcome panel.
|
||||
createKey: (n) => accessApi.createKey(n),
|
||||
grant: (bucket, accessKeyId, permissions) =>
|
||||
bucketsApi.grantPermission(bucket, accessKeyId, permissions),
|
||||
},
|
||||
name,
|
||||
key,
|
||||
);
|
||||
|
||||
if (result.key) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.accessKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.buckets.detail(name) });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
@@ -74,6 +101,7 @@ export function Buckets() {
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreateBucket={createBucket}
|
||||
canCreateKey={canCreateKey}
|
||||
/>
|
||||
|
||||
<DangerousConfirmDialog
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
|
||||
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';
|
||||
@@ -154,20 +155,25 @@ export function Dashboard() {
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--border)]">
|
||||
{buckets.slice(0, 5).map((bucket) => (
|
||||
<li key={bucket.name} className="flex items-center gap-3 py-3">
|
||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
|
||||
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
{bucket.size ? formatBytes(bucket.size) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<li key={bucket.name}>
|
||||
<Link
|
||||
to={`/buckets/${bucket.name}/objects`}
|
||||
className="-mx-2 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors hover:bg-[var(--muted)]"
|
||||
>
|
||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
|
||||
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
{bucket.size ? formatBytes(bucket.size) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -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.10.0 # x-release-please-version
|
||||
appVersion: v0.10.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