Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(nvm use:*)",
"Bash(pnpm -C .. ls)",
"Bash(pnpm lint:*)",
"Bash(git add:*)"
]
}
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
/.vscode/
.cursor/

# claude ai workspace
/.claude/

# production
/build

Expand Down
333 changes: 333 additions & 0 deletions BUTTON_PERMISSION_CONTROL_SUMMARY.md

Large diffs are not rendered by default.

39 changes: 26 additions & 13 deletions app/(dashboard)/access-keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table/data-table"
import { DataTablePagination } from "@/components/data-table/data-table-pagination"
import { useDataTable } from "@/hooks/use-data-table"
import { usePermissions } from "@/hooks/use-permissions"
import { AccessKeysNewItem } from "@/components/access-keys/new-item"
import { AccessKeysEditItem } from "@/components/access-keys/edit-item"
import { UserNotice } from "@/components/user/notice"
Expand All @@ -34,6 +35,7 @@ export default function AccessKeysPage() {
const dialog = useDialog()
const message = useMessage()
const { listUserServiceAccounts, deleteServiceAccount } = useAccessKeys()
const { canCapability } = usePermissions()

const [data, setData] = useState<RowData[]>([])
const [loading, setLoading] = useState(false)
Expand All @@ -47,6 +49,11 @@ export default function AccessKeysPage() {
url?: string
} | null>(null)

const canCreateAccessKey = canCapability("accessKeys.create")
const canEditAccessKey = canCapability("accessKeys.edit")
const canDeleteAccessKey = canCapability("accessKeys.delete")
const canBulkDeleteAccessKeys = canCapability("accessKeys.bulkDelete")

const listUserAccounts = async () => {
setLoading(true)
try {
Expand Down Expand Up @@ -112,14 +119,18 @@ export default function AccessKeysPage() {
meta: { width: 200 },
cell: ({ row }) => (
<div className="flex justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
<Button variant="outline" size="sm" onClick={() => confirmDeleteSingle(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canEditAccessKey ? (
<Button variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
) : null}
{canDeleteAccessKey ? (
<Button variant="outline" size="sm" onClick={() => confirmDeleteSingle(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
Expand Down Expand Up @@ -207,16 +218,18 @@ export default function AccessKeysPage() {
clearable
className="max-w-xs"
/>
{selectedKeys.length > 0 && (
{selectedKeys.length > 0 && canBulkDeleteAccessKeys && (
<Button variant="outline" disabled={!selectedKeys.length} onClick={deleteSelected}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete Selected")}</span>
</Button>
)}
<Button variant="outline" onClick={() => setNewItemVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Access Key")}</span>
</Button>
{canCreateAccessKey ? (
<Button variant="outline" onClick={() => setNewItemVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Access Key")}</span>
</Button>
) : null}
</>
}
>
Expand Down
38 changes: 37 additions & 1 deletion app/(dashboard)/browser/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { buildBucketPath } from "@/lib/bucket-path"
import { useTasks } from "@/contexts/task-context"
import { ObjectPreviewModal } from "@/components/object/preview-modal"
import { useObject } from "@/hooks/use-object"
import { usePermissions } from "@/hooks/use-permissions"

interface BrowserContentProps {
bucketName: string
Expand All @@ -29,6 +30,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
const router = useRouter()
const searchParams = useSearchParams()
const message = useMessage()
const { canCapability } = usePermissions()
const { headBucket } = useBucket()

const isObjectList = keyPath.endsWith("/") || keyPath === ""
Expand All @@ -41,6 +43,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
const [showPreview, setShowPreview] = React.useState(false)
const [previewObject, setPreviewObject] = React.useState<Record<string, unknown> | null>(null)
const objectApi = useObject(bucketName)
const canUploadObjects = canCapability("objects.upload", { bucket: bucketName, prefix })

React.useEffect(() => {
if (!bucketName) return
Expand Down Expand Up @@ -95,6 +98,25 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
[objectApi],
)

const clearPreviewQueryParams = React.useCallback(() => {
if (!searchParams.has("preview") && !searchParams.has("previewKey")) return

const params = new URLSearchParams(searchParams.toString())
params.delete("preview")
params.delete("previewKey")
const query = params.toString()
router.replace(query ? `/browser?${query}` : "/browser")
}, [router, searchParams])

React.useEffect(() => {
if (!preview || !previewKey) return

handleOpenPreview({ key: previewKey }).catch((error) => {
message.error((error as Error)?.message ?? t("Failed to fetch object info"))
clearPreviewQueryParams()
})
}, [preview, previewKey, handleOpenPreview, message, t, clearPreviewQueryParams])

const tasks = useTasks()
const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const prevCompletedIdsRef = React.useRef(new Set<string>())
Expand Down Expand Up @@ -123,6 +145,18 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
}
}, [])

const handlePreviewModalChange = React.useCallback(
(show: boolean) => {
setShowPreview(show)

if (!show) {
setPreviewObject(null)
clearPreviewQueryParams()
}
},
[clearPreviewQueryParams],
)

return (
<Page>
<PageHeader>
Expand Down Expand Up @@ -152,6 +186,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
path={prefix}
onOpenInfo={handleOpenInfo}
onUploadClick={() => setUploadPickerOpen(true)}
canUpload={canUploadObjects}
onRefresh={handleRefresh}
refreshTrigger={refreshTrigger}
onPreview={handleOpenPreview}
Expand All @@ -175,9 +210,10 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
onShowChange={setUploadPickerOpen}
bucketName={bucketName}
prefix={prefix}
canUpload={canUploadObjects}
/>

<ObjectPreviewModal show={showPreview} onShowChange={(show) => setShowPreview(show)} object={previewObject} />
<ObjectPreviewModal show={showPreview} onShowChange={handlePreviewModalChange} object={previewObject} />
</Page>
)
}
24 changes: 16 additions & 8 deletions app/(dashboard)/browser/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 { usePermissions } from "@/hooks/use-permissions"
import { useDialog } from "@/lib/feedback/dialog"
import { useMessage } from "@/lib/feedback/message"
import { niceBytes } from "@/lib/functions"
Expand All @@ -39,6 +40,7 @@ function BrowserBucketsPage() {
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const { canCapability } = usePermissions()
const { listBuckets, deleteBucket, getBucketPolicyStatus } = useBucket()
const { getDataUsageInfo } = useSystem()

Expand All @@ -50,6 +52,8 @@ function BrowserBucketsPage() {
const [policyLoading, setPolicyLoading] = useState(false)
const fetchIdRef = useRef(0)

const canCreateBucket = canCapability("bucket.create")

const loadBucketUsage = useCallback(
async (fetchId: number, bucketNames: string[]) => {
if (bucketNames.length === 0) {
Expand Down Expand Up @@ -270,10 +274,12 @@ function BrowserBucketsPage() {
<RiSettings5Line className="size-4" />
<span>{t("Settings")}</span>
</Button>
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canCapability("bucket.delete", { bucket: row.original.Name }) ? (
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
})
Expand Down Expand Up @@ -334,10 +340,12 @@ function BrowserBucketsPage() {
clearable
className="max-w-sm"
/>
<Button variant="outline" onClick={() => setFormVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Create Bucket")}</span>
</Button>
{canCreateBucket ? (
<Button variant="outline" onClick={() => setFormVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Create Bucket")}</span>
</Button>
) : null}
<Button variant="outline" onClick={() => fetchBuckets({ force: true })}>
<RiRefreshLine className="size-4" />
<span>{t("Refresh")}</span>
Expand Down
6 changes: 5 additions & 1 deletion app/(dashboard)/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useDataTable } from "@/hooks/use-data-table"
import { EventsNewForm } from "@/components/events/new-form"
import { getEventsColumns } from "@/components/events/columns"
import { useBucket } from "@/hooks/use-bucket"
import { usePermissions } from "@/hooks/use-permissions"
import { useDialog } from "@/lib/feedback/dialog"
import { useMessage } from "@/lib/feedback/message"
import type { NotificationItem } from "@/lib/events"
Expand All @@ -21,12 +22,15 @@ export default function EventsPage() {
const message = useMessage()
const dialog = useDialog()
const { listBucketNotifications, putBucketNotifications } = useBucket()
const { canCapability } = usePermissions()

const [bucketName, setBucketName] = useState<string | null>(null)
const [data, setData] = useState<NotificationItem[]>([])
const [loading, setLoading] = useState(false)
const [newFormOpen, setNewFormOpen] = useState(false)

const canEditEvents = bucketName ? canCapability("bucket.events.edit", { bucket: bucketName }) : false

const loadData = useCallback(async () => {
if (!bucketName) {
setData([])
Expand Down Expand Up @@ -145,7 +149,7 @@ export default function EventsPage() {
[bucketName, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t],
)

const columns = useMemo(() => getEventsColumns(t, handleRowDelete), [t, handleRowDelete])
const columns = useMemo(() => getEventsColumns(t, handleRowDelete, canEditEvents), [t, handleRowDelete, canEditEvents])

const { table } = useDataTable<NotificationItem>({
data,
Expand Down
35 changes: 23 additions & 12 deletions app/(dashboard)/policies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table/data-table"
import { DataTablePagination } from "@/components/data-table/data-table-pagination"
import { useDataTable } from "@/hooks/use-data-table"
import { usePermissions } from "@/hooks/use-permissions"
import { PolicyForm, type PolicyItem } from "@/components/policies/form"
import { usePolicies } from "@/hooks/use-policies"
import { useDialog } from "@/lib/feedback/dialog"
Expand All @@ -22,6 +23,10 @@ export default function PoliciesPage() {
const message = useMessage()
const dialog = useDialog()
const { listPolicies: fetchPolicies, removePolicy } = usePolicies()
const { canCapability } = usePermissions()
const canCreatePolicy = canCapability("policies.create")
const canEditPolicy = canCapability("policies.edit")
const canDeletePolicy = canCapability("policies.delete")

const [data, setData] = useState<PolicyItem[]>([])
const [loading, setLoading] = useState(false)
Expand Down Expand Up @@ -88,14 +93,18 @@ export default function PoliciesPage() {
meta: { width: 200 },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canEditPolicy ? (
<Button variant="outline" size="sm" onClick={() => handleEdit(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
) : null}
{canDeletePolicy ? (
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
Expand Down Expand Up @@ -140,10 +149,12 @@ export default function PoliciesPage() {
clearable
className="max-w-sm"
/>
<Button variant="outline" onClick={handleNew}>
<RiAddLine className="size-4" />
<span>{t("New Policy")}</span>
</Button>
{canCreatePolicy ? (
<Button variant="outline" onClick={handleNew}>
<RiAddLine className="size-4" />
<span>{t("New Policy")}</span>
</Button>
) : null}
</>
}
>
Expand Down
Loading
Loading