mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
feat(bucket): add functionality to create buckets with optional access keys and permissions
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Database } from 'lucide-react';
|
import { AlertTriangle, Check, Database, ShieldCheck, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogBody,
|
DialogBody,
|
||||||
@@ -12,18 +13,67 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { IconTile } from '@/components/ui/icon-tile';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface CreateBucketDialogProps {
|
interface CreateBucketDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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 DEFAULT_PERMISSIONS: KeyPermissions = { read: true, write: true, owner: false };
|
||||||
const [bucketName, setBucketName] = useState('');
|
|
||||||
|
|
||||||
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 () => {
|
const handleCreate = async () => {
|
||||||
if (!bucketName) {
|
if (!bucketName) {
|
||||||
@@ -31,15 +81,96 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await onCreateBucket(bucketName);
|
setCreating(true);
|
||||||
if (success) {
|
try {
|
||||||
setBucketName('');
|
const outcome = await onCreateBucket(
|
||||||
onOpenChange(false);
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange} size="form">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||||
@@ -50,10 +181,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogBody className="space-y-4">
|
<DialogBody className="space-y-5">
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
|
id="new-bucket-name"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="my-bucket-name"
|
placeholder="my-bucket-name"
|
||||||
value={bucketName}
|
value={bucketName}
|
||||||
@@ -68,17 +202,78 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
|
|||||||
Must be unique and follow DNS naming conventions
|
Must be unique and follow DNS naming conventions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="primary" onClick={handleCreate} disabled={!bucketName || creating}>
|
||||||
variant="primary"
|
{creating ? 'Creating…' : 'Create'}
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={!bucketName}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { IconTile } from '@/components/ui/icon-tile';
|
import { IconTile } from '@/components/ui/icon-tile';
|
||||||
|
import { CredentialField } from '@/components/ui/credential-field';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -32,82 +33,9 @@ import {accessApi, bucketsApi} from '@/lib/api';
|
|||||||
import {queryKeys} from '@/lib/query-client';
|
import {queryKeys} from '@/lib/query-client';
|
||||||
import {formatDate} from '@/lib/utils';
|
import {formatDate} from '@/lib/utils';
|
||||||
import type {AccessKey, Bucket, BucketPermission} from '@/types';
|
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';
|
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() {
|
export function AccessControl() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [keys, setKeys] = useState<AccessKey[]>([]);
|
const [keys, setKeys] = useState<AccessKey[]>([]);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
|
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
|
||||||
import { usePermissions } from '@/hooks/usePermissions';
|
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 { BucketListView } from '@/components/buckets/BucketListView';
|
||||||
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
|
||||||
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
|
||||||
@@ -17,18 +21,41 @@ export function Buckets() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { hasAnyPerm } = usePermissions();
|
const { hasAnyPerm } = usePermissions();
|
||||||
const { data: buckets = [], isLoading } = useBuckets();
|
const { data: buckets = [], isLoading } = useBuckets();
|
||||||
const createMutation = useCreateBucket();
|
const createMutation = useCreateBucket();
|
||||||
const deleteMutation = useDeleteBucket();
|
const deleteMutation = useDeleteBucket();
|
||||||
|
|
||||||
const createBucket = async (name: string, region?: string) => {
|
// Both grant permissions are required by POST /buckets/:name/permissions, and
|
||||||
try {
|
// the bucket does not exist yet, so this is a best-effort check across the
|
||||||
await createMutation.mutateAsync({ name, region });
|
// subject's bindings. A 403 at call time surfaces in the dialog's outcome panel.
|
||||||
return true;
|
const canCreateKey =
|
||||||
} catch {
|
hasAnyPerm('key.create') &&
|
||||||
return false;
|
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 () => {
|
const confirmDelete = async () => {
|
||||||
@@ -74,6 +101,7 @@ export function Buckets() {
|
|||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={setCreateOpen}
|
onOpenChange={setCreateOpen}
|
||||||
onCreateBucket={createBucket}
|
onCreateBucket={createBucket}
|
||||||
|
canCreateKey={canCreateKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DangerousConfirmDialog
|
<DangerousConfirmDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user