Admin UX overhaul: P0 fixes, error-UX foundations, nav cleanup, tenant detail page
From the 2026-07-14 UI/UX audit (17/40). Four phases: P1 — Settings route crashed on Agents→Edit (Input/Textarea used but never imported). Added imports + a shared route-level error boundary (components/route-error.tsx) re-exported from all shell routes, so one crashing panel degrades to an explained card with the nav intact instead of replacing the whole app with a stack trace. Corrected the stale CLAUDE.md claim that `npm run typecheck` crashes — it works, and would have caught the missing import. P2 — Error/empty/feedback foundations. Fixed useSession identity churn that fired ~3x duplicate fetches per screen and self-inflicted 429s (referentially stable snapshot). New lib/errors.ts (describeError → plain-language + the fix) and components/data-state.tsx (DataState renders exactly one of error/loading/empty/content, so a failed load never shows as "empty"; DialogError for in-dialog failures; 429 auto-retry). Rolled across all 15 list routes; every mutation now toasts. Surfaced+fixed two silent-failure bugs (sso + buckets-CORS swallowed load errors; the latter could wipe rules on save). P3 — Nav IA + trust cleanup. Default-expanded rail; regrouped into 7 coherent sections; collapsed the Apps/Plan/Entitlements stub triplication into one honest /billing page; deleted fabricated seeded notifications and the dead Help menu item; fixed the 403 copy (referenced a tenant switcher that doesn't exist); removed orphan /assistant + /library routes; gated dev-seed login hints behind DEV; renamed /activity → /audit-log with a redirect. P4 — Tenant detail page (routes/tenants.$id.tsx + components/tenant-detail/*), closing the provision→configure gap. 8 tabs (Overview, Plan & quotas, Branding, Localization, Email & SMS, Feature flags, IP rules, Inbound webhooks), each verified saving to the real backend. Row name links to detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,8 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useArcadiaClient } from "@crema/arcadia-core-client"
|
||||
import { useToast } from "@crema/notification-ui"
|
||||
import {
|
||||
ActionsCell,
|
||||
DataTable,
|
||||
@@ -28,11 +29,13 @@ import {
|
||||
type Column,
|
||||
} from "@crema/table-ui"
|
||||
import { SearchInput } from "@crema/search-ui"
|
||||
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
||||
import { EmptyState } from "@crema/feedback-ui"
|
||||
import { FileGrid, FileList, formatBytes, type FileItem } from "@crema/file-ui"
|
||||
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
|
||||
|
||||
import { AppShell } from "~/components/layout/app-shell"
|
||||
import { DataState, DialogError } from "~/components/data-state"
|
||||
import { errorMessage } from "~/lib/errors"
|
||||
import { Badge } from "~/components/ui/badge"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import {
|
||||
@@ -102,6 +105,7 @@ type Editor =
|
||||
export default function BucketsRoute() {
|
||||
const session = useSession()
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
|
||||
const [configs, setConfigs] = useState<StorageConfig[]>([])
|
||||
const [configId, setConfigId] = useState<string>(() =>
|
||||
@@ -111,8 +115,13 @@ export default function BucketsRoute() {
|
||||
)
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [info, setInfo] = useState<string | null>(null)
|
||||
// Two independently-loaded lists, two error slots. A dead /storage_configs
|
||||
// must not read as "no buckets", and a dead /buckets must not blank the
|
||||
// config picker.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [configsError, setConfigsError] = useState<unknown>(null)
|
||||
const [configsLoading, setConfigsLoading] = useState(true)
|
||||
const [configsReloadKey, setConfigsReloadKey] = useState(0)
|
||||
const [view, setView] = useState<View>({ kind: "list" })
|
||||
const [editor, setEditor] = useState<Editor>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<Bucket | null>(null)
|
||||
@@ -126,6 +135,8 @@ export default function BucketsRoute() {
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
let mounted = true
|
||||
setConfigsLoading(true)
|
||||
setConfigsError(null)
|
||||
listStorageConfigs(arcadia)
|
||||
.then((rows) => {
|
||||
if (!mounted) return
|
||||
@@ -142,16 +153,17 @@ export default function BucketsRoute() {
|
||||
setConfigId(def?.id ?? "")
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
setError(
|
||||
err instanceof ArcadiaError ? err.message : "Failed to load storage configs.",
|
||||
),
|
||||
)
|
||||
.catch((err) => {
|
||||
if (mounted) setConfigsError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setConfigsLoading(false)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session, arcadia])
|
||||
}, [session, arcadia, configsReloadKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (configId) localStorage.setItem(SELECTED_CONFIG_KEY, configId)
|
||||
@@ -167,7 +179,7 @@ export default function BucketsRoute() {
|
||||
try {
|
||||
setBuckets(await listBuckets(arcadia, configId))
|
||||
} catch (err) {
|
||||
setError(err instanceof ArcadiaError ? err.message : "Failed to load buckets.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -177,6 +189,14 @@ export default function BucketsRoute() {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
// The buckets table can't do its job without a config, so a failed config
|
||||
// load surfaces there too — otherwise it would read as "pick a config" with
|
||||
// an empty picker and no explanation.
|
||||
const retryAll = useCallback(() => {
|
||||
setConfigsReloadKey((n) => n + 1)
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
storage_config: activeConfig
|
||||
@@ -249,17 +269,6 @@ export default function BucketsRoute() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
||||
{error}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
{info ? (
|
||||
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
||||
{info}
|
||||
</AlertBanner>
|
||||
) : null}
|
||||
|
||||
{view.kind === "list" ? (
|
||||
<>
|
||||
<Card>
|
||||
@@ -317,7 +326,9 @@ export default function BucketsRoute() {
|
||||
|
||||
<BucketsTable
|
||||
buckets={buckets}
|
||||
loading={loading}
|
||||
loading={loading || configsLoading}
|
||||
error={error ?? configsError}
|
||||
onRetry={retryAll}
|
||||
hasConfig={!!configId}
|
||||
onOpen={(b) => setView({ kind: "objects", bucket: b })}
|
||||
onConfigure={(b) => setEditor({ kind: "configure", bucket: b })}
|
||||
@@ -325,11 +336,7 @@ export default function BucketsRoute() {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ObjectBrowser
|
||||
storageConfigId={configId}
|
||||
bucket={view.bucket}
|
||||
onError={setError}
|
||||
/>
|
||||
<ObjectBrowser storageConfigId={configId} bucket={view.bucket} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -338,12 +345,11 @@ export default function BucketsRoute() {
|
||||
open={editor?.kind === "create"}
|
||||
configId={configId}
|
||||
onClose={() => setEditor(null)}
|
||||
onCreated={async (msg) => {
|
||||
onCreated={async (name) => {
|
||||
setEditor(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
toast.success(`Created bucket ${name}`)
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
{/* Configure (versioning / CORS / policy) */}
|
||||
@@ -352,10 +358,9 @@ export default function BucketsRoute() {
|
||||
configId={configId}
|
||||
onClose={() => setEditor(null)}
|
||||
onChanged={async (msg) => {
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
toast.success(msg)
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
{/* Delete */}
|
||||
@@ -363,12 +368,11 @@ export default function BucketsRoute() {
|
||||
bucket={pendingDelete}
|
||||
configId={configId}
|
||||
onClose={() => setPendingDelete(null)}
|
||||
onDeleted={async (msg) => {
|
||||
onDeleted={async (name) => {
|
||||
setPendingDelete(null)
|
||||
if (msg) setInfo(msg)
|
||||
await refresh()
|
||||
toast.success(`Deleted bucket ${name}`)
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</AppShell>
|
||||
)
|
||||
@@ -379,6 +383,8 @@ export default function BucketsRoute() {
|
||||
function BucketsTable({
|
||||
buckets,
|
||||
loading,
|
||||
error,
|
||||
onRetry,
|
||||
hasConfig,
|
||||
onOpen,
|
||||
onConfigure,
|
||||
@@ -386,6 +392,9 @@ function BucketsTable({
|
||||
}: {
|
||||
buckets: Bucket[]
|
||||
loading: boolean
|
||||
/** Raw thrown value from either the buckets load or the configs load. */
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
hasConfig: boolean
|
||||
onOpen: (b: Bucket) => void
|
||||
onConfigure: (b: Bucket) => void
|
||||
@@ -525,41 +534,47 @@ function BucketsTable({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-0">
|
||||
<LoadingOverlay active={loading && buckets.length === 0} label="Loading buckets…" />
|
||||
{!hasConfig ? (
|
||||
<EmptyState
|
||||
icon={<Boxes className="size-6" />}
|
||||
title="Pick a storage configuration"
|
||||
description="Buckets are scoped to a credential. Add one under Storage if you don't have any yet."
|
||||
className="py-12"
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={table.total === 0}
|
||||
onRetry={onRetry}
|
||||
loadingLabel="Loading buckets…"
|
||||
empty={
|
||||
!hasConfig ? (
|
||||
<EmptyState
|
||||
icon={<Boxes className="size-6" />}
|
||||
title="Pick a storage configuration"
|
||||
description="Buckets are scoped to a credential. Add one under Storage if you don't have any yet."
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Boxes className="size-6" />}
|
||||
title={search ? "No buckets match." : "No buckets in this account."}
|
||||
description={search ? "Try a different search." : "Create your first bucket."}
|
||||
className="py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(b) => b.name}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && buckets.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
) : table.total === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<Boxes className="size-6" />}
|
||||
title={search ? "No buckets match." : "No buckets in this account."}
|
||||
description={search ? "Try a different search." : "Create your first bucket."}
|
||||
className="py-12"
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={table.pageRows}
|
||||
getRowId={(b) => b.name}
|
||||
sort={table.sort}
|
||||
onSortToggle={table.toggleSort}
|
||||
loading={loading && buckets.length > 0}
|
||||
stickyHeader
|
||||
/>
|
||||
<Pagination
|
||||
page={table.page}
|
||||
pageSize={table.pageSize}
|
||||
total={table.total}
|
||||
onPageChange={table.setPage}
|
||||
onPageSizeChange={table.setPageSize}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataState>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -570,23 +585,25 @@ function BucketsTable({
|
||||
function ObjectBrowser({
|
||||
storageConfigId,
|
||||
bucket,
|
||||
onError,
|
||||
}: {
|
||||
storageConfigId: string
|
||||
bucket: Bucket
|
||||
onError: (msg: string | null) => void
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const toast = useToast()
|
||||
const [objects, setObjects] = useState<BucketObject[]>([])
|
||||
const [prefix, setPrefix] = useState("")
|
||||
const [loading, setLoading] = useState(true)
|
||||
// The object listing owns its failure: a 403 on this bucket must not read as
|
||||
// "Empty bucket."
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [layout, setLayout] = useState<"grid" | "list">("list")
|
||||
const [previewUrl, setPreviewUrl] = useState<{ url: string; key: string } | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
onError(null)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await listObjects(arcadia, {
|
||||
storage_config_id: storageConfigId,
|
||||
@@ -596,11 +613,11 @@ function ObjectBrowser({
|
||||
})
|
||||
setObjects(res.objects ?? [])
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Failed to load objects.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [arcadia, storageConfigId, bucket.name, prefix, onError])
|
||||
}, [arcadia, storageConfigId, bucket.name, prefix])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
@@ -635,10 +652,10 @@ function ObjectBrowser({
|
||||
})
|
||||
setPreviewUrl({ url: res.url, key })
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Presign failed.")
|
||||
toast.error(errorMessage(err, `sign a link for ${key}`))
|
||||
}
|
||||
},
|
||||
[arcadia, storageConfigId, bucket.name, onError],
|
||||
[arcadia, storageConfigId, bucket.name, toast],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -697,44 +714,52 @@ function ObjectBrowser({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="relative p-4">
|
||||
<LoadingOverlay active={loading && objects.length === 0} label="Loading objects…" />
|
||||
{fileItems.length === 0 && !loading ? (
|
||||
<EmptyState
|
||||
icon={<FolderOpen className="size-6" />}
|
||||
title={search || prefix ? "No matches." : "Empty bucket."}
|
||||
description={
|
||||
search || prefix
|
||||
? "Adjust the filter or prefix."
|
||||
: "Upload an object via your application; this view is read-only for now."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
) : layout === "list" ? (
|
||||
<FileList
|
||||
files={fileItems}
|
||||
onItemClick={(f) => openPresigned(f.id)}
|
||||
renderAction={(f) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openPresigned(f.id)
|
||||
}}
|
||||
data-action={`object-${f.id}-presign`}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Link
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FileGrid
|
||||
files={fileItems}
|
||||
onItemClick={(f) => openPresigned(f.id)}
|
||||
minItemWidth={180}
|
||||
/>
|
||||
)}
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
isEmpty={fileItems.length === 0}
|
||||
onRetry={refresh}
|
||||
loadingLabel="Loading objects…"
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={<FolderOpen className="size-6" />}
|
||||
title={search || prefix ? "No matches." : "Empty bucket."}
|
||||
description={
|
||||
search || prefix
|
||||
? "Adjust the filter or prefix."
|
||||
: "Upload an object via your application; this view is read-only for now."
|
||||
}
|
||||
className="py-12"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{layout === "list" ? (
|
||||
<FileList
|
||||
files={fileItems}
|
||||
onItemClick={(f) => openPresigned(f.id)}
|
||||
renderAction={(f) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openPresigned(f.id)
|
||||
}}
|
||||
data-action={`object-${f.id}-presign`}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Link
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FileGrid
|
||||
files={fileItems}
|
||||
onItemClick={(f) => openPresigned(f.id)}
|
||||
minItemWidth={180}
|
||||
/>
|
||||
)}
|
||||
</DataState>
|
||||
</CardContent>
|
||||
|
||||
<PresignDialog reveal={previewUrl} onClose={() => setPreviewUrl(null)} />
|
||||
@@ -812,13 +837,11 @@ function CreateBucketDialog({
|
||||
configId,
|
||||
onClose,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean
|
||||
configId: string
|
||||
onClose: () => void
|
||||
onCreated: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onCreated: (name: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [name, setName] = useState("")
|
||||
@@ -827,6 +850,10 @@ function CreateBucketDialog({
|
||||
const [versioning, setVersioning] = useState(false)
|
||||
const [regions, setRegions] = useState<string[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// "Bucket names must be globally unique" is the single most likely failure
|
||||
// here, and the provider's message is the only thing that explains it — so
|
||||
// it renders in the dialog, next to the name the operator chose.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -834,8 +861,10 @@ function CreateBucketDialog({
|
||||
setRegion("")
|
||||
setAcl("private")
|
||||
setVersioning(false)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
if (configId) {
|
||||
listRegions(arcadia, configId)
|
||||
.then(setRegions)
|
||||
@@ -844,7 +873,7 @@ function CreateBucketDialog({
|
||||
}, [open, arcadia, configId])
|
||||
|
||||
const submit = async () => {
|
||||
onError(null)
|
||||
setError(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
await createBucket(arcadia, {
|
||||
@@ -854,15 +883,9 @@ function CreateBucketDialog({
|
||||
acl,
|
||||
versioning,
|
||||
})
|
||||
await onCreated(`Bucket ${name} created.`)
|
||||
await onCreated(name)
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Create failed.",
|
||||
)
|
||||
setError(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -942,6 +965,8 @@ function CreateBucketDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <DialogError error={error} context="create the bucket" /> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving} data-action="bucket-form-cancel">
|
||||
Cancel
|
||||
@@ -961,13 +986,11 @@ function ConfigureBucketDialog({
|
||||
configId,
|
||||
onClose,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
state: { kind: "configure"; bucket: Bucket } | null
|
||||
configId: string
|
||||
onClose: () => void
|
||||
onChanged: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onChanged: (msg: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [versioningOn, setVersioningOn] = useState(false)
|
||||
@@ -976,38 +999,58 @@ function ConfigureBucketDialog({
|
||||
const [corsRules, setCorsRules] = useState<CorsRule[]>([])
|
||||
const [corsSaving, setCorsSaving] = useState(false)
|
||||
const [corsLoading, setCorsLoading] = useState(false)
|
||||
// The CORS load used to fail *silently* into an empty rule list — so a 500
|
||||
// looked exactly like "this bucket has no CORS rules", and saving from that
|
||||
// state would have wiped the real ones.
|
||||
const [corsError, setCorsError] = useState<unknown>(null)
|
||||
const [corsReloadKey, setCorsReloadKey] = useState(0)
|
||||
|
||||
const [policyText, setPolicyText] = useState("")
|
||||
const [policySaving, setPolicySaving] = useState(false)
|
||||
|
||||
// Each section saves independently, so each failure speaks in its own tab.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [errorContext, setErrorContext] = useState("save")
|
||||
|
||||
const open = state !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !state) return
|
||||
setCorsLoading(true)
|
||||
setCorsError(null)
|
||||
getCors(arcadia, configId, state.bucket.name)
|
||||
.then((res) => {
|
||||
setCorsRules(res?.rules ?? [])
|
||||
})
|
||||
.catch(() => setCorsRules([]))
|
||||
.catch((err) => {
|
||||
setCorsRules([])
|
||||
setCorsError(err)
|
||||
})
|
||||
.finally(() => setCorsLoading(false))
|
||||
}, [open, state, arcadia, configId])
|
||||
}, [open, state, arcadia, configId, corsReloadKey])
|
||||
|
||||
if (!state) return null
|
||||
const { bucket } = state
|
||||
|
||||
const fail = (err: unknown, context: string) => {
|
||||
setError(err)
|
||||
setErrorContext(context)
|
||||
}
|
||||
|
||||
const saveVersioning = async () => {
|
||||
setVersioningSaving(true)
|
||||
onError(null)
|
||||
setError(null)
|
||||
try {
|
||||
await configureVersioning(arcadia, {
|
||||
storage_config_id: configId,
|
||||
bucket_name: bucket.name,
|
||||
enabled: versioningOn,
|
||||
})
|
||||
await onChanged(`Versioning ${versioningOn ? "enabled" : "suspended"}.`)
|
||||
await onChanged(
|
||||
`${versioningOn ? "Enabled" : "Suspended"} versioning on ${bucket.name}`,
|
||||
)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
|
||||
fail(err, `save versioning on ${bucket.name}`)
|
||||
} finally {
|
||||
setVersioningSaving(false)
|
||||
}
|
||||
@@ -1015,16 +1058,16 @@ function ConfigureBucketDialog({
|
||||
|
||||
const saveCors = async () => {
|
||||
setCorsSaving(true)
|
||||
onError(null)
|
||||
setError(null)
|
||||
try {
|
||||
await configureCors(arcadia, {
|
||||
storage_config_id: configId,
|
||||
bucket_name: bucket.name,
|
||||
rules: corsRules,
|
||||
})
|
||||
await onChanged("CORS rules saved.")
|
||||
await onChanged(`Saved CORS rules on ${bucket.name}`)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
|
||||
fail(err, `save the CORS rules on ${bucket.name}`)
|
||||
} finally {
|
||||
setCorsSaving(false)
|
||||
}
|
||||
@@ -1032,23 +1075,19 @@ function ConfigureBucketDialog({
|
||||
|
||||
const savePolicy = async () => {
|
||||
setPolicySaving(true)
|
||||
onError(null)
|
||||
setError(null)
|
||||
try {
|
||||
// Parsed here so malformed JSON never reaches the API — and so the
|
||||
// SyntaxError describeError() surfaces names the real problem.
|
||||
const policy = policyText.trim() === "" ? {} : JSON.parse(policyText)
|
||||
await configurePolicy(arcadia, {
|
||||
storage_config_id: configId,
|
||||
bucket_name: bucket.name,
|
||||
policy,
|
||||
})
|
||||
await onChanged("Bucket policy saved.")
|
||||
await onChanged(`Saved the bucket policy on ${bucket.name}`)
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? `Invalid JSON or save failed: ${err.message}`
|
||||
: "Save failed.",
|
||||
)
|
||||
fail(err, `save the bucket policy on ${bucket.name}`)
|
||||
} finally {
|
||||
setPolicySaving(false)
|
||||
}
|
||||
@@ -1112,16 +1151,22 @@ function ConfigureBucketDialog({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cors" className="pt-4">
|
||||
{corsLoading ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading rules…
|
||||
</p>
|
||||
) : (
|
||||
<DataState
|
||||
loading={corsLoading}
|
||||
error={corsError}
|
||||
isEmpty={corsRules.length === 0}
|
||||
onRetry={() => setCorsReloadKey((n) => n + 1)}
|
||||
loadingLabel="Loading CORS rules…"
|
||||
// A bucket with no rules is a real, reachable state — CorsEditor
|
||||
// already says so. It just must never be shown for a failed read.
|
||||
empty={<CorsEditor rules={[]} onChange={setCorsRules} />}
|
||||
>
|
||||
<CorsEditor rules={corsRules} onChange={setCorsRules} />
|
||||
)}
|
||||
</DataState>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={corsLoading || !!corsError}
|
||||
onClick={() =>
|
||||
setCorsRules([
|
||||
...corsRules,
|
||||
@@ -1140,7 +1185,9 @@ function ConfigureBucketDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveCors}
|
||||
disabled={corsSaving}
|
||||
// Saving rules we never managed to read would silently wipe
|
||||
// whatever is actually on the bucket.
|
||||
disabled={corsSaving || corsLoading || !!corsError}
|
||||
data-action="configure-cors-save"
|
||||
>
|
||||
{corsSaving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
@@ -1182,6 +1229,8 @@ function ConfigureBucketDialog({
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{error ? <DialogError error={error} context={errorContext} /> : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} data-action="configure-close">
|
||||
Close
|
||||
@@ -1303,38 +1352,38 @@ function DeleteBucketFlow({
|
||||
configId,
|
||||
onClose,
|
||||
onDeleted,
|
||||
onError,
|
||||
}: {
|
||||
bucket: Bucket | null
|
||||
configId: string
|
||||
onClose: () => void
|
||||
onDeleted: (msg?: string) => Promise<void>
|
||||
onError: (msg: string | null) => void
|
||||
onDeleted: (name: string) => Promise<void>
|
||||
}) {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [code, setCode] = useState("")
|
||||
const [forceEmpty, setForceEmpty] = useState(false)
|
||||
const [issuingCode, setIssuingCode] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
// A refused delete (wrong code, bucket not empty) has to be readable right
|
||||
// where the operator typed the code — this dialog is the whole confirmation.
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bucket) {
|
||||
setCode("")
|
||||
setForceEmpty(false)
|
||||
}
|
||||
setError(null)
|
||||
}, [bucket])
|
||||
|
||||
const requestCode = async () => {
|
||||
if (!bucket) return
|
||||
setIssuingCode(true)
|
||||
onError(null)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await generateConfirmationCode(arcadia, configId, bucket.name)
|
||||
setCode(res.code ?? "")
|
||||
} catch (err) {
|
||||
onError(
|
||||
err instanceof ArcadiaError ? err.message : "Failed to generate confirmation code.",
|
||||
)
|
||||
setError(err)
|
||||
} finally {
|
||||
setIssuingCode(false)
|
||||
}
|
||||
@@ -1343,7 +1392,7 @@ function DeleteBucketFlow({
|
||||
const doDelete = async () => {
|
||||
if (!bucket) return
|
||||
setDeleting(true)
|
||||
onError(null)
|
||||
setError(null)
|
||||
try {
|
||||
await deleteBucket(arcadia, {
|
||||
storage_config_id: configId,
|
||||
@@ -1352,9 +1401,9 @@ function DeleteBucketFlow({
|
||||
force_empty: forceEmpty,
|
||||
dry_run: false,
|
||||
})
|
||||
await onDeleted(`${bucket.name} deleted.`)
|
||||
await onDeleted(bucket.name)
|
||||
} catch (err) {
|
||||
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
||||
setError(err)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -1417,8 +1466,20 @@ function DeleteBucketFlow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<DialogError
|
||||
error={error}
|
||||
context={bucket ? `delete ${bucket.name}` : "delete the bucket"}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={deleting}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={deleting}
|
||||
data-action="bucket-delete-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1468,3 +1529,5 @@ function guessMime(key: string): string {
|
||||
}
|
||||
return m[ext] ?? "application/octet-stream"
|
||||
}
|
||||
|
||||
export { RouteErrorBoundary as ErrorBoundary } from "~/components/route-error"
|
||||
|
||||
Reference in New Issue
Block a user