Compare commits

..

4 Commits

Author SHA1 Message Date
Noooste 6766dec933 feat(bucket): add functionality to create buckets with optional access keys and permissions 2026-07-25 19:38:01 +02:00
Noooste a061500d50 feat(frontend): make bucket items clickable links to object view 2026-07-25 12:32:49 +02:00
Noooste 985b7b30ce feat(dropdown): improve positioning logic and add popup positioning utility 2026-07-25 12:29:31 +02:00
dependabot[bot] 8f6cb2b1da chore(deps): bump axios from 1.16.0 to 1.18.0 in /frontend (#96)
Bumps [axios](https://github.com/axios/axios) from 1.16.0 to 1.18.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.0...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 09:52:05 +02:00
11 changed files with 550 additions and 155 deletions
+30 -6
View File
@@ -12,7 +12,7 @@
"@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",
@@ -2808,6 +2808,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2951,13 +2963,14 @@
}
},
"node_modules/axios": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
@@ -3376,7 +3389,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4153,6 +4165,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4897,7 +4922,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
+1 -1
View File
@@ -17,7 +17,7 @@
"@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",
@@ -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>
@@ -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>
);
}
+20 -26
View File
@@ -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}
+46 -8
View File
@@ -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;
@@ -62,11 +69,40 @@ const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
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 +142,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>
@@ -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;
}
+31
View File
@@ -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) };
}
+2 -74
View File
@@ -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[]>([]);
+34 -6
View File
@@ -1,8 +1,12 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
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
+20 -14
View File
@@ -1,4 +1,5 @@
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
import { Link } from 'react-router-dom';
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>