From 4e4974957f928248d0f36f420b6b82c86e61ced8 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 12 Feb 2026 17:08:39 +0800 Subject: [PATCH 1/4] feat: dataobject count sub account permissions --- app/(dashboard)/browser/page.tsx | 10 ++- hooks/use-system.ts | 2 +- lib/api-client.ts | 12 ++++ lib/console-policy-parser.ts | 117 +++++++++++++++++++++++++++++-- 4 files changed, 132 insertions(+), 9 deletions(-) diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx index 8f1dff57..54825b3b 100644 --- a/app/(dashboard)/browser/page.tsx +++ b/app/(dashboard)/browser/page.tsx @@ -58,8 +58,15 @@ function BrowserBucketsPage() { } try { - const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } + const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined if (fetchId !== fetchIdRef.current) return + + // If usage is undefined (e.g., 403 error), don't update the data + // This allows the table to show "--" instead of "0" and "0 B" + if (!usage) { + return + } + const bucketUsage = usage?.buckets_usage ?? {} setData((prev) => @@ -77,6 +84,7 @@ function BrowserBucketsPage() { ) } catch { if (fetchId !== fetchIdRef.current) return + // On error, don't update the data - keep showing "--" } finally { if (fetchId === fetchIdRef.current) { setUsageLoading(false) diff --git a/hooks/use-system.ts b/hooks/use-system.ts index 363008f8..352350e2 100644 --- a/hooks/use-system.ts +++ b/hooks/use-system.ts @@ -15,7 +15,7 @@ export function useSystem() { }, [api]) const getDataUsageInfo = useCallback(async () => { - return api.get("/datausageinfo") + return api.get("/datausageinfo", { suppress403Redirect: true }) }, [api]) const getSystemMetrics = useCallback(async () => { diff --git a/lib/api-client.ts b/lib/api-client.ts index 74e7905d..02c1568c 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -16,6 +16,11 @@ interface RequestOptions { body?: unknown params?: Record dedupe?: boolean + /** + * If true, 403 errors will throw an error instead of triggering global error handler + * This allows components to handle permission errors gracefully + */ + suppress403Redirect?: boolean } const inflightGetRequests = new Map>() @@ -78,6 +83,13 @@ export class ApiClient { return } if (response.status === 403) { + // If suppress403Redirect is true, throw error instead of triggering global handler + // This allows components to handle permission errors gracefully + if (options.suppress403Redirect) { + const errorMsg = await parseApiError(response) + throw new Error(errorMsg) + } + try { const cloned = response.clone() let codeText = "" diff --git a/lib/console-policy-parser.ts b/lib/console-policy-parser.ts index 8a4fa591..ae7371f1 100644 --- a/lib/console-policy-parser.ts +++ b/lib/console-policy-parser.ts @@ -3,7 +3,8 @@ import { CONSOLE_SCOPES } from "./console-permissions" export interface ConsoleStatement { Effect: "Allow" | "Deny" - Action: string[] + Action?: string[] + NotAction?: string[] Resource?: string[] } @@ -12,10 +13,33 @@ export interface ConsolePolicy { Statement: ConsoleStatement[] } -function matchAction(policyActions: string[], requestAction: string): boolean { +/** + * Check if an action matches the policy actions + * @param policyActions - Array of action patterns (empty array means match all) + * @param requestAction - The action to check + * @returns true if the action matches + */ +function matchAction(policyActions: string[] | undefined, requestAction: string): boolean { + // Empty array or undefined means match all actions + if (!policyActions || policyActions.length === 0) { + return true + } return policyActions.some((pattern) => resourceMatch(pattern, requestAction)) } +/** + * Check if an action matches the NotAction patterns + * @param notActions - Array of action patterns to exclude + * @param requestAction - The action to check + * @returns true if the action should be excluded + */ +function matchNotAction(notActions: string[] | undefined, requestAction: string): boolean { + if (!notActions || notActions.length === 0) { + return false + } + return notActions.some((pattern) => resourceMatch(pattern, requestAction)) +} + const IMPLIED_SCOPES: Record = { [CONSOLE_SCOPES.VIEW_BROWSER]: [ "s3:ListAllMyBuckets", @@ -88,6 +112,13 @@ function matchResource(policyResources: string[] | undefined, requestResource: s return policyResources.some((pattern) => resourceMatch(pattern, requestResource)) } +/** + * Check if an action is a console scope (starts with "console:" or is "consoleAdmin") + */ +function isConsoleScope(action: string): boolean { + return action.startsWith("console:") || action === CONSOLE_SCOPES.CONSOLE_ADMIN +} + export function hasConsolePermission( policy: ConsolePolicy | ConsoleStatement[] | undefined, action: string, @@ -98,28 +129,100 @@ export function hasConsolePermission( const statements = Array.isArray(policy) ? policy : policy.Statement || [] if (statements.length === 0) return false - const denied = statements.some( - (s) => s.Effect === "Deny" && matchAction(s.Action, action) && matchResource(s.Resource, resource), - ) + // For console scopes, we should ignore Resource restrictions if Resource doesn't match "console" + // This allows policies with S3 resources to still grant console permissions + const isConsoleAction = isConsoleScope(action) + const shouldCheckResource = (s: ConsoleStatement): boolean => { + // If action is a console scope and Resource is specified but doesn't match "console", + // we should still allow if Action matches (console scopes are management permissions) + if (isConsoleAction && s.Resource && s.Resource.length > 0) { + // Check if Resource contains console-related resources + const hasConsoleResource = s.Resource.some((r) => r === "console" || r === "*" || r.includes("console")) + // If Resource doesn't contain console resources, skip resource check for console actions + if (!hasConsoleResource) { + return false + } + } + return true + } + + // Check Deny statements first + const denied = statements.some((s) => { + if (s.Effect !== "Deny") return false + + // If NotAction is present, deny applies to all actions EXCEPT those in NotAction + if (s.NotAction && s.NotAction.length > 0) { + // Deny if action is NOT in NotAction list + if (!matchNotAction(s.NotAction, action)) { + return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true + } + return false + } + + // If Action is present (or empty array), deny applies to matching actions + if (matchAction(s.Action, action)) { + return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true + } + + return false + }) if (denied) return false + // Check Allow statements const allowed = statements.some((s) => { if (s.Effect !== "Allow") return false + // Handle NotAction: allow all actions EXCEPT those in NotAction + if (s.NotAction && s.NotAction.length > 0) { + // If Action is also present, first check if action matches Action + if (s.Action && s.Action.length > 0) { + // Both Action and NotAction present: action must match Action AND not be in NotAction + const actionMatches = matchAction(s.Action, action) + const adminMatch = matchAction(s.Action, CONSOLE_SCOPES.CONSOLE_ADMIN) + const wildcardMatch = matchAction(s.Action, "console:*") + const adminStarMatch = matchAction(s.Action, "admin:*") + + if (!(actionMatches || adminMatch || wildcardMatch || adminStarMatch)) { + // Check implied actions + const impliedActions = IMPLIED_SCOPES[action] + if (!impliedActions || !impliedActions.some((implied) => matchAction(s.Action, implied))) { + return false // Action doesn't match Action list + } + } + + // Action matches Action list, now check NotAction exclusion + if (matchNotAction(s.NotAction, action)) { + return false // Action is excluded by NotAction + } + } else { + // Only NotAction present: allow all actions except those in NotAction + if (matchNotAction(s.NotAction, action)) { + return false // Action is excluded + } + } + // Action is allowed (matches Action if present, and not in NotAction), check resource + return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true + } + + // Only Action is present (or empty array), allow applies to matching actions const actionMatch = matchAction(s.Action, action) const adminMatch = matchAction(s.Action, CONSOLE_SCOPES.CONSOLE_ADMIN) const wildcardMatch = matchAction(s.Action, "console:*") const adminStarMatch = matchAction(s.Action, "admin:*") const explicitMatch = - (actionMatch || adminMatch || wildcardMatch || adminStarMatch) && matchResource(s.Resource, resource) + actionMatch || adminMatch || wildcardMatch || adminStarMatch + ? shouldCheckResource(s) + ? matchResource(s.Resource, resource) + : true + : false if (explicitMatch) return true const impliedActions = IMPLIED_SCOPES[action] if (impliedActions) { if (impliedActions.some((implied) => matchAction(s.Action, implied))) { - return true + return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true } } From 29491622d5af4b5a6b193a4d25696972955db0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E6=AD=A3=E8=B6=85?= Date: Fri, 27 Feb 2026 16:47:33 +0800 Subject: [PATCH 2/4] Update lib/console-policy-parser.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/console-policy-parser.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/console-policy-parser.ts b/lib/console-policy-parser.ts index ae7371f1..7864f8f2 100644 --- a/lib/console-policy-parser.ts +++ b/lib/console-policy-parser.ts @@ -15,13 +15,16 @@ export interface ConsolePolicy { /** * Check if an action matches the policy actions - * @param policyActions - Array of action patterns (empty array means match all) + * @param policyActions - Array of action patterns (empty array means match all, undefined means no match) * @param requestAction - The action to check * @returns true if the action matches */ function matchAction(policyActions: string[] | undefined, requestAction: string): boolean { - // Empty array or undefined means match all actions - if (!policyActions || policyActions.length === 0) { + // Undefined means no match; explicit empty array means match all actions + if (policyActions === undefined) { + return false + } + if (policyActions.length === 0) { return true } return policyActions.some((pattern) => resourceMatch(pattern, requestAction)) From f5b3c981c2b063e4fd7ee4c5b12b0e1c87f852bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E6=AD=A3=E8=B6=85?= Date: Fri, 27 Feb 2026 16:47:45 +0800 Subject: [PATCH 3/4] Update hooks/use-system.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- hooks/use-system.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/hooks/use-system.ts b/hooks/use-system.ts index 352350e2..3f9c3a91 100644 --- a/hooks/use-system.ts +++ b/hooks/use-system.ts @@ -15,7 +15,16 @@ export function useSystem() { }, [api]) const getDataUsageInfo = useCallback(async () => { - return api.get("/datausageinfo", { suppress403Redirect: true }) + try { + return await api.get("/datausageinfo", { suppress403Redirect: true }) + } catch (error: any) { + const status = error?.status ?? error?.response?.status + if (status === 403) { + // Preserve previous behavior: treat 403 as "no data" instead of rejecting. + return undefined + } + throw error + } }, [api]) const getSystemMetrics = useCallback(async () => { From 7e8c3009b0b96c52ab1dd94314f2c44eeeb1651f Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Fri, 27 Feb 2026 20:22:12 +0800 Subject: [PATCH 4/4] fix: resolve lint errors (no-explicit-any, no-unused-vars) --- app/(dashboard)/browser/page.tsx | 7 ++----- components/object/info.tsx | 1 - contexts/api-context.tsx | 1 - contexts/s3-context.tsx | 1 - hooks/use-system.ts | 5 +++-- lib/console-policy-parser.ts | 4 ++-- 6 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx index 54825b3b..bcbf9468 100644 --- a/app/(dashboard)/browser/page.tsx +++ b/app/(dashboard)/browser/page.tsx @@ -16,7 +16,6 @@ import { Spinner } from "@/components/ui/spinner" import { useBucket } from "@/hooks/use-bucket" import { useObject } from "@/hooks/use-object" import { useSystem } from "@/hooks/use-system" -import { useAuth } from "@/contexts/auth-context" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { niceBytes } from "@/lib/functions" @@ -39,7 +38,6 @@ function BrowserBucketsPage() { const router = useRouter() const message = useMessage() const dialog = useDialog() - const { isAdmin } = useAuth() const { listBuckets, deleteBucket } = useBucket() const { getDataUsageInfo } = useSystem() @@ -60,9 +58,8 @@ function BrowserBucketsPage() { try { const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined if (fetchId !== fetchIdRef.current) return - - // If usage is undefined (e.g., 403 error), don't update the data - // This allows the table to show "--" instead of "0" and "0 B" + + // getDataUsageInfo returns undefined on 403; don't update data so table shows "--" if (!usage) { return } diff --git a/components/object/info.tsx b/components/object/info.tsx index 07d31ce7..3a63bc1e 100644 --- a/components/object/info.tsx +++ b/components/object/info.tsx @@ -44,7 +44,6 @@ export function ObjectInfo({ objectKey, open, onOpenChange, - onRefresh, autoPreview = false, onPreviewChange, }: ObjectInfoProps) { diff --git a/contexts/api-context.tsx b/contexts/api-context.tsx index e563bf3d..b921d5e6 100644 --- a/contexts/api-context.tsx +++ b/contexts/api-context.tsx @@ -7,7 +7,6 @@ import { AwsClient } from "@/lib/aws4fetch" import { ApiErrorHandler } from "@/lib/api-error-handler" import { useAuth } from "@/contexts/auth-context" import { configManager } from "@/lib/config" -import { getLoginRoute, buildRoute } from "@/lib/routes" interface ApiContextValue { api: ApiClient | null diff --git a/contexts/s3-context.tsx b/contexts/s3-context.tsx index f54a9a6a..41ff19b8 100644 --- a/contexts/s3-context.tsx +++ b/contexts/s3-context.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation" import { S3Client } from "@aws-sdk/client-s3" import { useAuth } from "@/contexts/auth-context" import { configManager } from "@/lib/config" -import { getLoginRoute } from "@/lib/routes" import type { SiteConfig } from "@/types/config" interface S3Response { diff --git a/hooks/use-system.ts b/hooks/use-system.ts index 3f9c3a91..88f59d21 100644 --- a/hooks/use-system.ts +++ b/hooks/use-system.ts @@ -17,8 +17,9 @@ export function useSystem() { const getDataUsageInfo = useCallback(async () => { try { return await api.get("/datausageinfo", { suppress403Redirect: true }) - } catch (error: any) { - const status = error?.status ?? error?.response?.status + } catch (error: unknown) { + const status = + (error as { status?: number })?.status ?? (error as { response?: { status?: number } })?.response?.status if (status === 403) { // Preserve previous behavior: treat 403 as "no data" instead of rejecting. return undefined diff --git a/lib/console-policy-parser.ts b/lib/console-policy-parser.ts index 7864f8f2..9cc85845 100644 --- a/lib/console-policy-parser.ts +++ b/lib/console-policy-parser.ts @@ -157,14 +157,14 @@ export function hasConsolePermission( if (s.NotAction && s.NotAction.length > 0) { // Deny if action is NOT in NotAction list if (!matchNotAction(s.NotAction, action)) { - return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true + return shouldCheckResource(s) ? matchResource(s.Resource, resource) : false } return false } // If Action is present (or empty array), deny applies to matching actions if (matchAction(s.Action, action)) { - return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true + return shouldCheckResource(s) ? matchResource(s.Resource, resource) : false } return false