diff --git a/frontend/src/components/buckets/CreateBucketDialog.tsx b/frontend/src/components/buckets/CreateBucketDialog.tsx index d70dbdc..fcf5938 100644 --- a/frontend/src/components/buckets/CreateBucketDialog.tsx +++ b/frontend/src/components/buckets/CreateBucketDialog.tsx @@ -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; + onCreateBucket: (name: string, key?: NewKeyRequest) => Promise; + 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 ( +
  • + {ok ? ( + + ) : ( + + )} + {children} +
  • + ); +} + +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(DEFAULT_PERMISSIONS); + const [creating, setCreating] = useState(false); + const [result, setResult] = useState(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 ( + + + + : } + tone="primary" + size="md" + /> +
    + {result.key ? 'Bucket and key created' : 'Bucket created'} + + {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.'} + +
    +
    + +
      + + Bucket {bucketName} created + + {result.key ? ( + + Access key {result.key.name} created + + ) : ( + + Access key could not be created. You can create one from Access control. + + )} + {result.key && ( + + {result.grantError + ? 'Permissions were not applied on the bucket. Grant them from Access control.' + : 'Permissions granted on the bucket'} + + )} +
    + + {result.key && ( + <> + + +
    + +
    +

    + Save this key now +

    +

    + The secret access key cannot be retrieved again. If lost, you'll need to create a new key. +

    +
    +
    + + )} +
    + + + +
    +
    + ); + } + return ( - + } tone="primary" size="md" /> @@ -50,10 +181,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat - +
    - +
    + + {canCreateKey && ( +
    + + + {withKey && ( +
    +
    + + setKeyName(e.target.value)} + /> +

    + Leave empty to use {derivedKeyName}. +

    +
    + +
    + +
    + {PERMISSION_ROWS.map((p) => ( + + ))} +
    +
    +
    + )} +
    + )}
    - -
    diff --git a/frontend/src/components/ui/credential-field.tsx b/frontend/src/components/ui/credential-field.tsx new file mode 100644 index 0000000..e2cefa9 --- /dev/null +++ b/frontend/src/components/ui/credential-field.tsx @@ -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 ( +
    + +
    + + {maskable && ( + + )} + +
    +
    + ); +} diff --git a/frontend/src/lib/create-bucket-with-key.ts b/frontend/src/lib/create-bucket-with-key.ts new file mode 100644 index 0000000..c5fc317 --- /dev/null +++ b/frontend/src/lib/create-bucket-with-key.ts @@ -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; + createKey: (name: string) => Promise; + grant: (bucket: string, accessKeyId: string, permissions: KeyPermissions) => Promise; +} + +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 { + 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; +} diff --git a/frontend/src/pages/AccessControl.tsx b/frontend/src/pages/AccessControl.tsx index 3786c26..665993f 100644 --- a/frontend/src/pages/AccessControl.tsx +++ b/frontend/src/pages/AccessControl.tsx @@ -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 ( -
    - -
    - - {maskable && ( - - )} - -
    -
    - ); -} - export function AccessControl() { const queryClient = useQueryClient(); const [keys, setKeys] = useState([]); diff --git a/frontend/src/pages/Buckets.tsx b/frontend/src/pages/Buckets.tsx index f893a68..9a2d6f1 100644 --- a/frontend/src/pages/Buckets.tsx +++ b/frontend/src/pages/Buckets.tsx @@ -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(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} />