From ee59c874961ffd8667b996c41b5ab881e65c2633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=A3=E5=AE=8F=E4=BC=9F?= Date: Mon, 27 Jul 2026 19:44:19 +0800 Subject: [PATCH] fix(browser): handle object list access denied --- app/(dashboard)/browser/content.tsx | 26 ------- app/(dashboard)/sse/page.tsx | 24 +++++-- components/data-table/data-table.tsx | 6 +- components/object/list.tsx | 94 +++++++++++++++++++------- i18n/locales/ar-MA.json | 1 + i18n/locales/de-DE.json | 1 + i18n/locales/en-US.json | 1 + i18n/locales/es-ES.json | 1 + i18n/locales/fr-FR.json | 1 + i18n/locales/id-ID.json | 1 + i18n/locales/it-IT.json | 1 + i18n/locales/ja-JP.json | 1 + i18n/locales/ko-KR.json | 1 + i18n/locales/pt-BR.json | 1 + i18n/locales/ru-RU.json | 1 + i18n/locales/tr-TR.json | 1 + i18n/locales/vi-VN.json | 1 + i18n/locales/zh-CN.json | 1 + lib/error-handler.ts | 24 +++++++ lib/object-list-state.ts | 7 ++ tests/lib/error-handler.test.ts | 9 +++ tests/lib/object-delete-safety.test.js | 11 ++- tests/lib/object-list-source.test.js | 31 ++++++++- tests/lib/object-list-state.test.ts | 34 ++++++++++ tests/lib/sse-safety.test.js | 2 +- 25 files changed, 221 insertions(+), 61 deletions(-) diff --git a/app/(dashboard)/browser/content.tsx b/app/(dashboard)/browser/content.tsx index 1eaeb0ea..13266982 100644 --- a/app/(dashboard)/browser/content.tsx +++ b/app/(dashboard)/browser/content.tsx @@ -11,7 +11,6 @@ import { ObjectList } from "@/components/object/list" import { ObjectView } from "@/components/object/view" import { ObjectInfo } from "@/components/object/info" import { ObjectUploadPicker } from "@/components/object/upload-picker" -import { useBucket } from "@/hooks/use-bucket" import { useMessage } from "@/lib/feedback/message" import { buildBucketPath } from "@/lib/bucket-path" import { useTasks } from "@/contexts/task-context" @@ -32,7 +31,6 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev const searchParams = useSearchParams() const message = useMessage() const { canCapability } = usePermissions() - const { headBucket } = useBucket() const isObjectList = keyPath.endsWith("/") || keyPath === "" const prefix = keyPath.endsWith("/") ? keyPath : keyPath ? `${keyPath}/` : "" @@ -46,30 +44,6 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev const objectApi = useObject(bucketName) const canUploadObjects = canCapability("objects.upload", { bucket: bucketName, prefix }) - React.useEffect(() => { - if (!bucketName) return - headBucket(bucketName) - .then(() => {}) - .catch((error: unknown) => { - const err = error as { $metadata?: { httpStatusCode?: number }; Code?: string; message?: string } - const status = err?.$metadata?.httpStatusCode - const code = (err?.Code ?? (error as Error)?.message ?? "").toLowerCase() - const isAccessDenied = - status === 403 || - code === "accessdenied" || - code === "forbidden" || - (typeof code === "string" && (code.includes("access denied") || code.includes("forbidden"))) - message.error(isAccessDenied ? t("Access Denied") : t("Bucket not found")) - const params = new URLSearchParams(searchParams.toString()) - params.delete("bucket") - params.delete("prefix") - params.delete("preview") - params.delete("previewKey") - const query = params.toString() - router.push(query ? `/browser?${query}` : "/browser") - }) - }, [bucketName, headBucket, message, router, t, searchParams]) - const bucketPath = React.useCallback((path?: string | string[]) => buildBucketPath(bucketName, path), [bucketName]) const handlePathClick = (path: string) => { diff --git a/app/(dashboard)/sse/page.tsx b/app/(dashboard)/sse/page.tsx index 799e8d98..cc8498c8 100644 --- a/app/(dashboard)/sse/page.tsx +++ b/app/(dashboard)/sse/page.tsx @@ -545,9 +545,7 @@ export default function SSEPage() { if (values.backendType === "static") { if (!values.secretKey.trim()) { return { - error: t( - "Please enter the static KMS secret key (base64-encoded 32-byte AES-256 key).", - ), + error: t("Please enter the static KMS secret key (base64-encoded 32-byte AES-256 key)."), field: "secretKey", } } @@ -1548,7 +1546,13 @@ export default function SSEPage() { + ) : undefined + } /> -
- - {t("Loaded {count} objects", { - count: data.length, - })} - - {t("Filtering and sorting apply to loaded objects")} -
+ {!listError ? ( +
+ + {t("Loaded {count} objects", { + count: data.length, + })} + + {t("Filtering and sorting apply to loaded objects")} +
+ ) : null} {nextToken ? (
diff --git a/i18n/locales/ar-MA.json b/i18n/locales/ar-MA.json index 6a6bda2d..76c242d7 100644 --- a/i18n/locales/ar-MA.json +++ b/i18n/locales/ar-MA.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "التحكم في الوصول", "Access Denied": "تم رفض الوصول", + "Ask your administrator to grant permission to list objects in this bucket.": "اطلب من المسؤول منحك إذن عرض الكائنات في هذه الحاوية.", "Access Key": "مفتاح الوصول", "Access Key *": "مفتاح الوصول *", "Access Key is required": "مفتاح الوصول مطلوب", diff --git a/i18n/locales/de-DE.json b/i18n/locales/de-DE.json index bc61f91f..51affb8a 100644 --- a/i18n/locales/de-DE.json +++ b/i18n/locales/de-DE.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Zugriffskontrolle", "Access Denied": "Zugriff verweigert", + "Ask your administrator to grant permission to list objects in this bucket.": "Bitten Sie Ihren Administrator, die Berechtigung zum Auflisten der Objekte in diesem Bucket zu erteilen.", "Access Key": "Zugriffsschlüssel", "Access Key *": "Zugriffsschlüssel *", "Access Key is required": "Zugriffsschlüssel ist erforderlich", diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index ee9dd6f1..52854bf2 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Access Control", "Access Denied": "Access Denied", + "Ask your administrator to grant permission to list objects in this bucket.": "Ask your administrator to grant permission to list objects in this bucket.", "Access Key": "Access Key", "Access Key *": "Access Key *", "Access Key is required": "Access Key is required", diff --git a/i18n/locales/es-ES.json b/i18n/locales/es-ES.json index e390b7f6..7aac0580 100644 --- a/i18n/locales/es-ES.json +++ b/i18n/locales/es-ES.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Control de Acceso", "Access Denied": "Acceso Denegado", + "Ask your administrator to grant permission to list objects in this bucket.": "Pida a su administrador que conceda permiso para enumerar los objetos de este bucket.", "Access Key": "Clave de Acceso", "Access Key *": "Clave de Acceso *", "Access Key is required": "La clave de acceso es obligatoria", diff --git a/i18n/locales/fr-FR.json b/i18n/locales/fr-FR.json index 9644a578..3da61477 100644 --- a/i18n/locales/fr-FR.json +++ b/i18n/locales/fr-FR.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Contrôle d'accès", "Access Denied": "Accès Refusé", + "Ask your administrator to grant permission to list objects in this bucket.": "Demandez à votre administrateur d’accorder l’autorisation de répertorier les objets de ce bucket.", "Access Key": "Clé d'accès", "Access Key *": "Clé d'accès *", "Access Key is required": "La clé d'accès est requise", diff --git a/i18n/locales/id-ID.json b/i18n/locales/id-ID.json index 523ad039..ece4c43c 100644 --- a/i18n/locales/id-ID.json +++ b/i18n/locales/id-ID.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Kontrol Akses", "Access Denied": "Akses Ditolak", + "Ask your administrator to grant permission to list objects in this bucket.": "Minta administrator Anda memberikan izin untuk mencantumkan objek dalam bucket ini.", "Access Key": "Access Key", "Access Key *": "Access Key *", "Access Key is required": "Access Key wajib diisi", diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 9b293117..673c5310 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Controllo accessi", "Access Denied": "Accesso Negato", + "Ask your administrator to grant permission to list objects in this bucket.": "Chiedi all’amministratore di concedere l’autorizzazione per elencare gli oggetti in questo bucket.", "Access Key": "Chiave di accesso", "Access Key *": "Chiave di accesso *", "Access Key is required": "La chiave di accesso è obbligatoria", diff --git a/i18n/locales/ja-JP.json b/i18n/locales/ja-JP.json index 35669768..f2279dfe 100644 --- a/i18n/locales/ja-JP.json +++ b/i18n/locales/ja-JP.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "アクセス制御", "Access Denied": "アクセス拒否", + "Ask your administrator to grant permission to list objects in this bucket.": "このバケット内のオブジェクトを一覧表示する権限を管理者に付与してもらってください。", "Access Key": "アクセスキー", "Access Key *": "アクセスキー *", "Access Key is required": "アクセスキーは必須です", diff --git a/i18n/locales/ko-KR.json b/i18n/locales/ko-KR.json index 69d78ee4..ba66fec1 100644 --- a/i18n/locales/ko-KR.json +++ b/i18n/locales/ko-KR.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "액세스 제어", "Access Denied": "액세스 거부", + "Ask your administrator to grant permission to list objects in this bucket.": "관리자에게 이 버킷의 객체를 나열할 수 있는 권한을 요청하세요.", "Access Key": "액세스 키", "Access Key *": "액세스 키 *", "Access Key is required": "액세스 키가 필요합니다", diff --git a/i18n/locales/pt-BR.json b/i18n/locales/pt-BR.json index f595c84f..c052d728 100644 --- a/i18n/locales/pt-BR.json +++ b/i18n/locales/pt-BR.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Controle de Acesso", "Access Denied": "Acesso Negado", + "Ask your administrator to grant permission to list objects in this bucket.": "Peça ao administrador para conceder permissão para listar os objetos deste bucket.", "Access Key": "Chave de Acesso", "Access Key *": "Chave de Acesso *", "Access Key is required": "Chave de acesso é obrigatória", diff --git a/i18n/locales/ru-RU.json b/i18n/locales/ru-RU.json index ee6d411c..714a4ad9 100644 --- a/i18n/locales/ru-RU.json +++ b/i18n/locales/ru-RU.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Контроль доступа", "Access Denied": "Доступ запрещен", + "Ask your administrator to grant permission to list objects in this bucket.": "Попросите администратора предоставить разрешение на просмотр списка объектов в этом бакете.", "Access Key": "Ключ доступа", "Access Key *": "Ключ доступа *", "Access Key is required": "Ключ доступа обязателен", diff --git a/i18n/locales/tr-TR.json b/i18n/locales/tr-TR.json index 2d293a1c..667eb8b6 100644 --- a/i18n/locales/tr-TR.json +++ b/i18n/locales/tr-TR.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Erişim Kontrolü", "Access Denied": "Erişim Reddedildi", + "Ask your administrator to grant permission to list objects in this bucket.": "Yöneticinizden bu bucket içindeki nesneleri listeleme izni vermesini isteyin.", "Access Key": "Erişim Anahtarı", "Access Key *": "Erişim Anahtarı *", "Access Key is required": "Erişim Anahtarı gerekli", diff --git a/i18n/locales/vi-VN.json b/i18n/locales/vi-VN.json index d89f2be1..6e8a08f4 100644 --- a/i18n/locales/vi-VN.json +++ b/i18n/locales/vi-VN.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "Kiểm soát truy cập", "Access Denied": "Truy cập bị từ chối", + "Ask your administrator to grant permission to list objects in this bucket.": "Hãy yêu cầu quản trị viên cấp quyền liệt kê các đối tượng trong bucket này.", "Access Key": "Khóa truy cập", "Access Key *": "Khóa truy cập *", "Access Key is required": "Yêu cầu nhập Khóa truy cập", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 5fcb89cd..0f7a5ceb 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -22,6 +22,7 @@ "AWS S3": "AWS S3", "Access Control": "访问控制", "Access Denied": "无权限访问", + "Ask your administrator to grant permission to list objects in this bucket.": "请联系管理员授予列出此存储桶中对象的权限。", "Access Key": "访问密钥", "Access Key *": "访问密钥 *", "Access Key is required": "访问密钥为必填项", diff --git a/lib/error-handler.ts b/lib/error-handler.ts index dfc8eb1c..7960fa1b 100644 --- a/lib/error-handler.ts +++ b/lib/error-handler.ts @@ -155,6 +155,30 @@ export const getServiceErrorMessage = (error: unknown): string | null => { return codeCandidates.find(Boolean) ?? messageCandidates.find(Boolean) ?? null } +export const isAccessDeniedError = (error: unknown): boolean => { + if (!error || typeof error !== "object") { + return false + } + + const candidate = error as { + name?: unknown + code?: unknown + Code?: unknown + Error?: { Code?: unknown } + status?: unknown + statusCode?: unknown + $metadata?: { httpStatusCode?: unknown } + } + const statuses = [candidate.status, candidate.statusCode, candidate.$metadata?.httpStatusCode] + if (statuses.some((status) => status === 403)) { + return true + } + + const accessDeniedCodes = new Set(["accessdenied", "forbidden"]) + const codes = [candidate.name, candidate.code, candidate.Code, candidate.Error?.Code] + return codes.some((code) => typeof code === "string" && accessDeniedCodes.has(code.toLowerCase())) +} + export class ConfigLoadError extends Error { code: "INVALID_URL" | "STORAGE_ERROR" | "NETWORK_ERROR" | "UNKNOWN_ERROR" originalError?: Error diff --git a/lib/object-list-state.ts b/lib/object-list-state.ts index 1fa7133b..a13d0d4d 100644 --- a/lib/object-list-state.ts +++ b/lib/object-list-state.ts @@ -13,6 +13,8 @@ interface ObjectListResponseGuardParams { } export type ObjectListDisplayState = + | "access-denied" + | "error" | "loading" | "empty" | "filtered-loading" @@ -20,12 +22,15 @@ export type ObjectListDisplayState = | "filtered-empty" | "content" +export type ObjectListErrorState = Extract | null + interface ObjectListDisplayStateParams { searchTerm: string filteredCount: number loadedCount: number hasMore: boolean loading: boolean + error: ObjectListErrorState } export function createObjectListScope(scope: ObjectListScope): ObjectListScope { @@ -60,7 +65,9 @@ export function resolveObjectListDisplayState({ loadedCount, hasMore, loading, + error, }: ObjectListDisplayStateParams): ObjectListDisplayState { + if (error) return error if (filteredCount > 0) return "content" const isFiltering = searchTerm.trim().length > 0 diff --git a/tests/lib/error-handler.test.ts b/tests/lib/error-handler.test.ts index 735e5936..56a26816 100644 --- a/tests/lib/error-handler.test.ts +++ b/tests/lib/error-handler.test.ts @@ -53,3 +53,12 @@ test("getXmlErrorMessage prefers detailed XML messages over generic error codes" "Object is under COMPLIANCE retention and cannot be deleted until 2026-05-13T00:00:00Z", ) }) + +test("isAccessDeniedError recognizes S3 access denied responses", async () => { + const { isAccessDeniedError } = await loadErrorHandler() + + assert.equal(isAccessDeniedError({ name: "AccessDenied" }), true) + assert.equal(isAccessDeniedError({ Code: "Forbidden" }), true) + assert.equal(isAccessDeniedError({ $metadata: { httpStatusCode: 403 } }), true) + assert.equal(isAccessDeniedError({ name: "NoSuchBucket", $metadata: { httpStatusCode: 404 } }), false) +}) diff --git a/tests/lib/object-delete-safety.test.js b/tests/lib/object-delete-safety.test.js index 69156c4f..d5b5f21b 100644 --- a/tests/lib/object-delete-safety.test.js +++ b/tests/lib/object-delete-safety.test.js @@ -19,7 +19,16 @@ test("unknown bucket versioning state never enables force delete", () => { test("object deletion stays blocked until versioning state is known", () => { assert.match(objectListSource, /setBucketVersioningState\("unknown"\)/) assert.match(objectListSource, /versioningError/) + assert.match( + objectListSource, + /if \(isAccessDeniedError\(error\)\) \{\s+setBucketVersioningState\("unknown"\)\s+setVersioningError\(t\("Unable to load versioning status\."\)\)/, + ) + assert.match(objectListSource, /const shouldLoadBucketVersioning = hasPermission\("s3:DeleteObject"\)/) + assert.match(objectListSource, /if \(!shouldLoadBucketVersioning\) \{[\s\S]{0,160}return/) assert.match(objectListSource, /bucketVersioningState === "unknown"/) assert.match(objectListSource, /role=\{versioningError \? "alert" : "status"\}/) - assert.doesNotMatch(objectListSource, /catch\s*\{[\s\S]{0,120}setBucketVersioningState\("disabled"\)/) + assert.doesNotMatch( + objectListSource, + /if \(isAccessDeniedError\(error\)\) \{[\s\S]{0,160}setBucketVersioningState\("disabled"\)/, + ) }) diff --git a/tests/lib/object-list-source.test.js b/tests/lib/object-list-source.test.js index 886a3371..c77deeab 100644 --- a/tests/lib/object-list-source.test.js +++ b/tests/lib/object-list-source.test.js @@ -14,10 +14,39 @@ test("object list falls back to an empty table instead of crashing the page on f const source = fs.readFileSync("components/object/list.tsx", "utf8") assert.equal(source.includes('console.error("Failed to fetch objects:", error)'), true) - assert.equal(source.includes('message.error((error as Error)?.message ?? t("Failed to load objects"))'), true) + assert.equal(source.includes('message.error(accessDenied ? t("Access Denied")'), true) + assert.equal(source.includes('t("Failed to load objects")'), true) assert.equal(source.includes("setData([])"), true) }) +test("object browser keeps the bucket open and explains missing list permission", () => { + const browserSource = fs.readFileSync("app/(dashboard)/browser/content.tsx", "utf8") + const listSource = fs.readFileSync("components/object/list.tsx", "utf8") + + assert.equal(browserSource.includes("headBucket(bucketName)"), false) + assert.equal( + listSource.includes("const [listError, setListError] = React.useState(null)"), + true, + ) + assert.match( + listSource, + /if \(accessDenied\) \{\s+setData\(\[\]\)\s+setListError\("access-denied"\)\s+\} else if \(!shouldAppend\)/, + ) + assert.equal( + listSource.includes('t("Ask your administrator to grant permission to list objects in this bucket.")'), + true, + ) +}) + +test("object list keeps read failures distinct from an empty bucket and offers retry", () => { + const source = fs.readFileSync("components/object/list.tsx", "utf8") + + assert.equal(source.includes('setListError("error")'), true) + assert.equal(source.includes('displayState === "error"'), true) + assert.equal(source.includes("emptyAction={"), true) + assert.equal(source.includes("onClick={resetAndFetchObjects}"), true) +}) + test("object list lazy loads additional object batches instead of showing a paginator", () => { const source = fs.readFileSync("components/object/list.tsx", "utf8") diff --git a/tests/lib/object-list-state.test.ts b/tests/lib/object-list-state.test.ts index 1781c3d9..b5e876e0 100644 --- a/tests/lib/object-list-state.test.ts +++ b/tests/lib/object-list-state.test.ts @@ -129,6 +129,7 @@ test("resolveObjectListDisplayState treats an unfiltered empty response as an em loadedCount: 0, hasMore: false, loading: false, + error: null, }), "empty", ) @@ -142,6 +143,7 @@ test("resolveObjectListDisplayState keeps a filtered append request in loading s loadedCount: 25, hasMore: true, loading: true, + error: null, }), "filtered-loading", ) @@ -155,6 +157,7 @@ test("resolveObjectListDisplayState reports when unsearched objects remain", () loadedCount: 25, hasMore: true, loading: false, + error: null, }), "filtered-partial", ) @@ -168,6 +171,7 @@ test("resolveObjectListDisplayState reports a final filtered-empty state after a loadedCount: 50, hasMore: false, loading: false, + error: null, }), "filtered-empty", ) @@ -181,6 +185,7 @@ test("resolveObjectListDisplayState shows matching rows while more objects load" loadedCount: 25, hasMore: true, loading: true, + error: null, }), "content", ) @@ -194,7 +199,36 @@ test("resolveObjectListDisplayState ignores whitespace around the filter term", loadedCount: 0, hasMore: false, loading: false, + error: null, }), "empty", ) }) + +test("resolveObjectListDisplayState prioritizes access denied over previously loaded rows", () => { + assert.equal( + resolveObjectListDisplayState({ + searchTerm: "", + filteredCount: 25, + loadedCount: 25, + hasMore: false, + loading: false, + error: "access-denied", + }), + "access-denied", + ) +}) + +test("resolveObjectListDisplayState keeps read failures distinct from an empty bucket", () => { + assert.equal( + resolveObjectListDisplayState({ + searchTerm: "", + filteredCount: 0, + loadedCount: 0, + hasMore: false, + loading: false, + error: "error", + }), + "error", + ) +}) diff --git a/tests/lib/sse-safety.test.js b/tests/lib/sse-safety.test.js index ce9966ef..7647fbdc 100644 --- a/tests/lib/sse-safety.test.js +++ b/tests/lib/sse-safety.test.js @@ -18,7 +18,7 @@ test("SSE status and key reads fail closed with persistent recovery states", () assert.match(source, /statusError \|\| keysError \|\| loadingKeys \|\| loadingStatus/) assert.match( source, - /disabled=\{Boolean\(activeMutation\) \|\| loadingKeys \|\| loadingStatus \|\| Boolean\(keysError\)/, + /disabled=\{\s*Boolean\(activeMutation\)\s*\|\|\s*loadingKeys\s*\|\|\s*loadingStatus\s*\|\|\s*Boolean\(keysError\)/, ) })