mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
feat: add button-level permission control (#89)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(nvm use:*)",
|
||||
"Bash(pnpm -C .. ls)",
|
||||
"Bash(pnpm lint:*)",
|
||||
"Bash(git add:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
/.vscode/
|
||||
.cursor/
|
||||
|
||||
# claude ai workspace
|
||||
/.claude/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
# 按钮权限控制改动整理
|
||||
|
||||
## 1. 变更目标
|
||||
|
||||
本轮改动的目标,是在现有“菜单级别权限控制”基础上,补齐非 admin 页面内关键按钮的权限控制,并让前端判断逻辑尽量贴近 RustFS 后端当前实际支持的策略语义。
|
||||
|
||||
当前实现定位如下:
|
||||
|
||||
- 前端按钮控制属于体验层控制,用于隐藏或禁用用户当前无权执行的操作。
|
||||
- 后端仍然是最终鉴权方,前端不替代服务端鉴权。
|
||||
- 权限判断以 S3/MinIO 风格策略为基础,同时对齐 RustFS 当前已实现的行为。
|
||||
- admin-only 菜单页面暂不纳入本轮按钮控制范围,因为页面入口本身已经由菜单权限控制。
|
||||
- 暂不处理未纳入权限矩阵的页面。
|
||||
|
||||
## 2. 本轮实现概览
|
||||
|
||||
### 2.1 策略解析层补强
|
||||
|
||||
| 改动点 | 说明 |
|
||||
| ------------------- | ----------------------------------------------------- |
|
||||
| `Deny` 优先 | 显式拒绝优先于允许,保持和后端一致 |
|
||||
| `NotAction` 支持 | 前端能力判断已支持 `NotAction` 语义 |
|
||||
| `NotResource` 支持 | `ConsoleStatement` 增加 `NotResource`,并参与资源匹配 |
|
||||
| 通配符处理 | 裸 `*` 视为 `s3:*`,同时兼容 `s3:*`、`admin:*` 等模式 |
|
||||
| Action alias 归一化 | 对齐 RustFS 当前实际支持的别名和映射 |
|
||||
| admin 资源校验放宽 | `admin:*` 动作前端忽略资源匹配,贴近后端行为 |
|
||||
| console scope 推导 | console 菜单 scope 会推导到其隐含的 S3/admin 动作集合 |
|
||||
|
||||
### 2.2 RustFS 对齐要点
|
||||
|
||||
| 语义 | 当前前端对齐方式 |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `*` | 视为 `s3:*` |
|
||||
| `DeleteBucketTagging` | 归并到 `s3:PutBucketTagging` |
|
||||
| `DeleteBucketReplication` | 归并到 `s3:PutReplicationConfiguration` |
|
||||
| `DeleteBucketLifecycle` | 归并到 `s3:PutBucketLifecycle` |
|
||||
| `DeleteBucketEncryption` | 通过桶加密编辑能力统一处理 |
|
||||
| 生命周期旧别名 | `GetLifecycleConfiguration` / `PutLifecycleConfiguration` 归一到 RustFS 当前桶生命周期动作名 |
|
||||
| 对象版本读取 | 目前以前端 `GetObject` 近似映射 `objects.version.view` |
|
||||
|
||||
### 2.3 新增能力层
|
||||
|
||||
新增两个基础文件:
|
||||
|
||||
- `lib/permission-capabilities.ts`
|
||||
- `lib/permission-resources.ts`
|
||||
|
||||
能力层职责:
|
||||
|
||||
- 将前端“页面按钮动作”抽象为 `ConsoleCapability`
|
||||
- 将 capability 映射到后端真实 action 集合
|
||||
- 按资源粒度构造 S3 ARN
|
||||
- 通过 `hasConsoleCapability(...)` 做统一判断
|
||||
|
||||
### 2.4 `usePermissions` 扩展
|
||||
|
||||
`hooks/use-permissions.tsx` 新增:
|
||||
|
||||
- `canCapability(capability, context?)`
|
||||
|
||||
它的用途是:
|
||||
|
||||
- 页面只关心“这个按钮能不能显示/能不能点”
|
||||
- 能力与 action/资源的复杂映射统一收敛到 lib 层
|
||||
|
||||
## 3. 新增与修改文件范围
|
||||
|
||||
### 3.1 新增文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
| -------------------------------- | ----------------------------------------- |
|
||||
| `lib/permission-capabilities.ts` | 定义 capability 与 action/resource 的映射 |
|
||||
| `lib/permission-resources.ts` | 构造 bucket/object/prefix 对应的 S3 ARN |
|
||||
|
||||
### 3.2 修改文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
| ---------------------------------------- | ------------------------------------------------------------ |
|
||||
| `lib/console-policy-parser.ts` | 补齐 `NotResource`、alias、wildcard、admin 资源规则 |
|
||||
| `hooks/use-permissions.tsx` | 暴露 `canCapability` |
|
||||
| `app/(dashboard)/browser/page.tsx` | 桶列表页按钮权限控制 |
|
||||
| `app/(dashboard)/browser/content.tsx` | 对象页上传能力下发 |
|
||||
| `components/object/list.tsx` | 对象列表按钮权限控制 |
|
||||
| `components/object/upload-picker.tsx` | 上传弹窗内部按钮权限控制 |
|
||||
| `components/object/info.tsx` | 对象详情抽屉按钮权限控制 |
|
||||
| `components/object/versions.tsx` | 对象版本列表按钮权限控制 |
|
||||
| `components/buckets/info.tsx` | 桶详情页按钮与提交兜底 |
|
||||
| `components/buckets/new-form.tsx` | 新建桶表单按钮与开关控制 |
|
||||
| `components/buckets/lifecycle-tab.tsx` | 生命周期规则按钮控制 |
|
||||
| `components/buckets/replication-tab.tsx` | 复制规则按钮控制 |
|
||||
| `components/buckets/events-tab.tsx` | 桶事件订阅按钮控制 |
|
||||
| `components/events/columns.tsx` | 事件列表删除按钮可配置 |
|
||||
| `app/(dashboard)/access-keys/page.tsx` | Access Keys 页面按钮控制 |
|
||||
| `app/(dashboard)/users/page.tsx` | Users 页面按钮控制 |
|
||||
| `components/user/edit-form.tsx` | 用户编辑弹窗内部权限收敛 |
|
||||
| `app/(dashboard)/policies/page.tsx` | Policies 页面按钮控制 |
|
||||
| `app/(dashboard)/events/page.tsx` | 适配 `getEventsColumns` 新签名,尚未真正接入 capability 控制 |
|
||||
|
||||
## 4. 能力与动作映射
|
||||
|
||||
### 4.1 Bucket 能力
|
||||
|
||||
| Capability | 后端 Action | 资源粒度 |
|
||||
| ------------------------- | ----------------------------------------------- | ---------- |
|
||||
| `bucket.create` | `s3:CreateBucket` | 全部桶 |
|
||||
| `bucket.delete` | `s3:DeleteBucket` | 桶 |
|
||||
| `bucket.policy.put` | `s3:PutBucketPolicy` | 桶 |
|
||||
| `bucket.policy.delete` | `s3:DeleteBucketPolicy` | 桶 |
|
||||
| `bucket.policy.edit` | `s3:PutBucketPolicy` 或 `s3:DeleteBucketPolicy` | 桶 |
|
||||
| `bucket.encryption.edit` | `s3:PutBucketEncryption` | 桶 |
|
||||
| `bucket.tag.edit` | `s3:PutBucketTagging` | 桶 |
|
||||
| `bucket.versioning.edit` | `s3:PutBucketVersioning` | 桶 |
|
||||
| `bucket.objectLock.edit` | `s3:PutBucketObjectLockConfiguration` | 桶 |
|
||||
| `bucket.quota.edit` | `admin:SetBucketQuota` | 无资源匹配 |
|
||||
| `bucket.lifecycle.edit` | `s3:PutBucketLifecycle` | 桶 |
|
||||
| `bucket.replication.edit` | `s3:PutReplicationConfiguration` | 桶 |
|
||||
| `bucket.events.edit` | `s3:PutBucketNotification` | 桶 |
|
||||
|
||||
### 4.2 Object 能力
|
||||
|
||||
| Capability | 后端 Action | 资源粒度 |
|
||||
| ------------------------ | ----------------------- | -------- |
|
||||
| `objects.upload` | `s3:PutObject` | 对象前缀 |
|
||||
| `objects.view` | `s3:GetObject` | 对象 |
|
||||
| `objects.preview` | `s3:GetObject` | 对象 |
|
||||
| `objects.download` | `s3:GetObject` | 对象 |
|
||||
| `objects.delete` | `s3:DeleteObject` | 对象 |
|
||||
| `objects.bulkDelete` | `s3:DeleteObject` | 对象前缀 |
|
||||
| `objects.tag.view` | `s3:GetObjectTagging` | 对象 |
|
||||
| `objects.tag.edit` | `s3:PutObjectTagging` | 对象 |
|
||||
| `objects.legalHold.edit` | `s3:PutObjectLegalHold` | 对象 |
|
||||
| `objects.retention.edit` | `s3:PutObjectRetention` | 对象 |
|
||||
| `objects.version.view` | `s3:GetObject` | 对象 |
|
||||
| `objects.share` | `s3:GetObject` | 对象 |
|
||||
|
||||
### 4.3 IAM/Policy 能力
|
||||
|
||||
| Capability | 后端 Action | 资源粒度 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------- | ---------- |
|
||||
| `accessKeys.create` | `admin:CreateServiceAccount` | 无资源匹配 |
|
||||
| `accessKeys.edit` | `admin:UpdateServiceAccount` | 无资源匹配 |
|
||||
| `accessKeys.delete` | `admin:RemoveServiceAccount` | 无资源匹配 |
|
||||
| `accessKeys.bulkDelete` | `admin:RemoveServiceAccount` | 无资源匹配 |
|
||||
| `users.create` | `admin:CreateUser` | 无资源匹配 |
|
||||
| `users.edit` | `admin:GetUser` 且 `admin:CreateUser` / `admin:EnableUser` / `admin:DisableUser` 任一 | 无资源匹配 |
|
||||
| `users.delete` | `admin:DeleteUser` | 无资源匹配 |
|
||||
| `users.bulkDelete` | `admin:DeleteUser` | 无资源匹配 |
|
||||
| `users.assignGroups` | `admin:AddUserToGroup` 或 `admin:RemoveUserFromGroup` | 无资源匹配 |
|
||||
| `users.policy.edit` | `admin:AttachUserOrGroupPolicy` 或 `admin:UpdatePolicyAssociation` | 无资源匹配 |
|
||||
| `policies.create` | `admin:CreatePolicy` | 无资源匹配 |
|
||||
| `policies.edit` | `admin:CreatePolicy` | 无资源匹配 |
|
||||
| `policies.delete` | `admin:DeletePolicy` | 无资源匹配 |
|
||||
|
||||
## 5. 按钮权限矩阵
|
||||
|
||||
### 5.1 页面入口与列表按钮
|
||||
|
||||
| 页面/组件 | 按钮 | Capability | 后端 Action | 资源上下文 | 当前控制方式 |
|
||||
| ---------------------------------------- | ------------------------ | -------------------------------------------------------------- | ---------------------------------------------------- | ----------------- | -------------------------- |
|
||||
| `/browser` 桶列表 | `Create Bucket` | `bucket.create` | `s3:CreateBucket` | 全部桶 | 无权限时隐藏 |
|
||||
| `/browser` 桶列表 | 行内 `Delete` | `bucket.delete` | `s3:DeleteBucket` | 当前桶 | 无权限时隐藏 |
|
||||
| `/browser?bucket=...` 对象列表 | `Upload File/Folder` | `objects.upload` | `s3:PutObject` | 当前桶 + 当前前缀 | 无权限时隐藏 |
|
||||
| `/browser?bucket=...` 对象列表 | `Delete Selected` | `objects.bulkDelete` | `s3:DeleteObject` | 当前桶 + 当前前缀 | 无权限时隐藏 |
|
||||
| `/browser?bucket=...` 对象列表 | `Download`(批量) | `objects.download` | `s3:GetObject` | 当前桶 + 当前前缀 | 无权限时隐藏 |
|
||||
| `/browser?bucket=...` 对象列表 | 行内 `Preview` | `objects.preview` | `s3:GetObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `/browser?bucket=...` 对象列表 | 行内 `Download` | `objects.download` | `s3:GetObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `/browser?bucket=...` 对象列表 | 行内 `Delete` | `objects.delete` | `s3:DeleteObject` | 当前对象/前缀 | 无权限时隐藏 |
|
||||
| `components/buckets/lifecycle-tab.tsx` | `Add Lifecycle Rule` | `bucket.lifecycle.edit` | `s3:PutBucketLifecycle` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/lifecycle-tab.tsx` | 行内 `Delete` | `bucket.lifecycle.edit` | `s3:PutBucketLifecycle` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/replication-tab.tsx` | `Add Replication Rule` | `bucket.replication.edit` | `s3:PutReplicationConfiguration` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/replication-tab.tsx` | 行内 `Delete` | `bucket.replication.edit` | `s3:PutReplicationConfiguration` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/events-tab.tsx` | `Add Event Subscription` | `bucket.events.edit` | `s3:PutBucketNotification` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/events-tab.tsx` | 行内 `Delete` | `bucket.events.edit` | `s3:PutBucketNotification` | 当前桶 | 无权限时隐藏 |
|
||||
| `/access-keys` | `Add Access Key` | `accessKeys.create` | `admin:CreateServiceAccount` | 无 | 无权限时隐藏 |
|
||||
| `/access-keys` | 行内 `Edit` | `accessKeys.edit` | `admin:UpdateServiceAccount` | 无 | 无权限时隐藏 |
|
||||
| `/access-keys` | 行内 `Delete` | `accessKeys.delete` | `admin:RemoveServiceAccount` | 无 | 无权限时隐藏 |
|
||||
| `/access-keys` | `Delete Selected` | `accessKeys.bulkDelete` | `admin:RemoveServiceAccount` | 无 | 仅在有权限且有选中项时显示 |
|
||||
| `/users` | `Add User` | `users.create` | `admin:CreateUser` | 无 | 无权限时隐藏 |
|
||||
| `/users` | 行内 `Edit` | `users.edit` / `users.assignGroups` / `users.policy.edit` 任一 | 组合能力 | 无 | 任一编辑相关能力满足即显示 |
|
||||
| `/users` | 行内 `Delete` | `users.delete` | `admin:DeleteUser` | 无 | 无权限时隐藏 |
|
||||
| `/users` | `Delete Selected` | `users.bulkDelete` | `admin:DeleteUser` | 无 | 无权限时禁用 |
|
||||
| `/users` | `Add to Group` | `users.assignGroups` | `admin:AddUserToGroup` / `admin:RemoveUserFromGroup` | 无 | 无权限时禁用 |
|
||||
| `/policies` | `New Policy` | `policies.create` | `admin:CreatePolicy` | 无 | 无权限时隐藏 |
|
||||
| `/policies` | 行内 `Edit` | `policies.edit` | `admin:CreatePolicy` | 无 | 无权限时隐藏 |
|
||||
| `/policies` | 行内 `Delete` | `policies.delete` | `admin:DeletePolicy` | 无 | 无权限时隐藏 |
|
||||
|
||||
### 5.2 详情抽屉、弹窗、表单内部按钮
|
||||
|
||||
| 页面/组件 | 按钮 | Capability | 后端 Action | 资源上下文 | 当前控制方式 |
|
||||
| ------------------------------------- | ------------------------- | --------------------------------------------------------- | ---------------------------------------------- | ------------- | ------------------------------------------------- |
|
||||
| `components/object/upload-picker.tsx` | `Select File` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 |
|
||||
| `components/object/upload-picker.tsx` | `Select Folder` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 |
|
||||
| `components/object/upload-picker.tsx` | `Clear All` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 |
|
||||
| `components/object/upload-picker.tsx` | `Start Upload` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 |
|
||||
| `components/object/info.tsx` | `Download` | `objects.download` | `s3:GetObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/object/info.tsx` | `Preview` | `objects.preview` | `s3:GetObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/object/info.tsx` | `Set Tags` | `objects.tag.view` 或 `objects.tag.edit` | `s3:GetObjectTagging` / `s3:PutObjectTagging` | 当前对象 | 无查看/编辑权限时隐藏 |
|
||||
| `components/object/info.tsx` | `Versions` | `objects.version.view` | `s3:GetObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/object/info.tsx` | `Retention` | `objects.retention.edit` | `s3:PutObjectRetention` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/object/info.tsx` | `Generate URL` | `objects.share` | `s3:GetObject` | 当前对象 | 无权限时禁用 |
|
||||
| `components/object/info.tsx` | 标签删除图标 | `objects.tag.edit` | `s3:PutObjectTagging` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/object/info.tsx` | 标签弹窗 `Add` | `objects.tag.edit` | `s3:PutObjectTagging` | 当前对象 | 无权限时禁用,提交函数再次兜底 |
|
||||
| `components/object/info.tsx` | 保留期弹窗 `Reset` | `objects.retention.edit` | `s3:PutObjectRetention` | 当前对象 | 无权限时禁用,提交函数再次兜底 |
|
||||
| `components/object/info.tsx` | 保留期弹窗 `Confirm` | `objects.retention.edit` | `s3:PutObjectRetention` | 当前对象 | 无权限时禁用,提交函数再次兜底 |
|
||||
| `components/object/versions.tsx` | 行内 `Preview` | `objects.version.view` | `s3:GetObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/object/versions.tsx` | 行内 `Download` | `objects.download` | `s3:GetObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/object/versions.tsx` | 行内 `Delete` | `objects.delete` | `s3:DeleteObject` | 当前对象 | 无权限时隐藏 |
|
||||
| `components/buckets/info.tsx` | `Edit`(Access Policy) | `bucket.policy.put` / `bucket.policy.delete` | `s3:PutBucketPolicy` / `s3:DeleteBucketPolicy` | 当前桶 | 有任一策略编辑能力时显示 |
|
||||
| `components/buckets/info.tsx` | `Edit`(Encryption) | `bucket.encryption.edit` | `s3:PutBucketEncryption` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/info.tsx` | `Add`(Tag) | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/info.tsx` | 标签点击编辑 | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 无权限时禁用 |
|
||||
| `components/buckets/info.tsx` | 标签删除图标 | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 无权限时禁用 |
|
||||
| `components/buckets/info.tsx` | `Edit`(Bucket Quota) | `bucket.quota.edit` | `admin:SetBucketQuota` | 无 | 无权限时隐藏 |
|
||||
| `components/buckets/info.tsx` | `Edit`(Retention) | `bucket.objectLock.edit` | `s3:PutBucketObjectLockConfiguration` | 当前桶 | 无权限时隐藏 |
|
||||
| `components/buckets/info.tsx` | Policy 弹窗 `Confirm` | `bucket.policy.put` / `bucket.policy.delete` | 对应策略动作 | 当前桶 | 提交函数按 policy 类型再次校验 |
|
||||
| `components/buckets/info.tsx` | Encryption 弹窗 `Confirm` | `bucket.encryption.edit` | `s3:PutBucketEncryption` | 当前桶 | 通过入口控制进入 |
|
||||
| `components/buckets/info.tsx` | Tag 弹窗 `Confirm` | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 提交函数再次兜底 |
|
||||
| `components/buckets/info.tsx` | Quota 弹窗 `Confirm` | `bucket.quota.edit` | `admin:SetBucketQuota` | 无 | 提交函数再次兜底 |
|
||||
| `components/buckets/info.tsx` | Retention 弹窗 `Confirm` | `bucket.objectLock.edit` | `s3:PutBucketObjectLockConfiguration` | 当前桶 | 提交函数再次兜底 |
|
||||
| `components/buckets/new-form.tsx` | `Create` | `bucket.create` | `s3:CreateBucket` | 全部桶 | 无权限时禁用 |
|
||||
| `components/user/edit-form.tsx` | `Submit` | `users.edit` / `users.assignGroups` / `users.policy.edit` | 组合能力 | 无 | 没有任何可编辑 tab 时禁用;提交仅发送允许的子操作 |
|
||||
|
||||
### 5.3 非按钮交互控件
|
||||
|
||||
| 页面/组件 | 交互控件 | Capability | 当前控制方式 |
|
||||
| --------------------------------- | -------------------------------------- | ------------------------ | ---------------------- |
|
||||
| `components/object/info.tsx` | `Legal Hold` 开关 | `objects.legalHold.edit` | 无权限时禁用 |
|
||||
| `components/buckets/info.tsx` | `Version Control` 开关 | `bucket.versioning.edit` | 无权限时禁用 |
|
||||
| `components/buckets/new-form.tsx` | `Version` 开关 | `bucket.versioning.edit` | 无权限时禁用 |
|
||||
| `components/buckets/new-form.tsx` | `Object Lock` 开关 | `bucket.objectLock.edit` | 无权限时禁用 |
|
||||
| `components/buckets/new-form.tsx` | `Bucket Quota` 开关 | `bucket.quota.edit` | 无权限时禁用 |
|
||||
| `components/user/edit-form.tsx` | `Account` / `Groups` / `Policies` Tabs | 对应用户能力 | 无对应权限时不渲染 tab |
|
||||
| `components/user/edit-form.tsx` | 用户状态开关 | `users.edit` | 无权限时禁用 |
|
||||
|
||||
## 6. 当前边界与已知待补位项
|
||||
|
||||
### 6.1 本轮明确不纳入
|
||||
|
||||
| 范围 | 说明 |
|
||||
| -------------------- | ------------------------------------------ |
|
||||
| admin-only 页面 | 菜单入口已受控,本轮不继续做页面内按钮矩阵 |
|
||||
| 未纳入权限矩阵的页面 | 按当前决策先忽略,不在本轮补齐 |
|
||||
|
||||
### 6.2 当前仍需后续完善
|
||||
|
||||
| 范围 | 当前状态 | 后续建议 |
|
||||
| -------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `/events` 页面 | 只适配了 `getEventsColumns` 的 `canDelete` 参数,当前仍传 `true` | 后续补上 `bucket.events.edit` 的真实 capability 判断 |
|
||||
| `components/access-keys/new-item.tsx` | 页面入口已受控,弹窗内部未单独做 capability 下沉 | 若存在绕过入口的调用路径,再补内部兜底 |
|
||||
| `components/access-keys/edit-item.tsx` | 同上 | 同上 |
|
||||
| `components/policies/form.tsx` | 页面入口已受控,表单内部未单独补 capability | 后续可补提交兜底 |
|
||||
| `components/user/new-form.tsx` | 页面入口已受控,弹窗内部未单独补 capability | 后续可补提交兜底 |
|
||||
| `components/lifecycle/new-form.tsx` | 入口按钮已受控,表单内部未单独补 capability | 后续可补提交兜底 |
|
||||
| `components/replication/new-form.tsx` | 同上 | 同上 |
|
||||
| `components/events/new-form.tsx` | 同上 | 同上 |
|
||||
| 对象版本能力精度 | `objects.version.view` 目前用 `s3:GetObject` 近似 | 后续若后端暴露更细粒度版本动作,可继续细化 |
|
||||
| Governance bypass | `s3:BypassGovernanceRetention` 尚未单独建 capability | 如果前端后续暴露该操作,再单独建模 |
|
||||
|
||||
## 7. README 可复用内容
|
||||
|
||||
下面这段可以直接作为 README 中“权限控制”或“按钮级权限控制”章节的基础说明:
|
||||
|
||||
```md
|
||||
## Button-Level Permission Control
|
||||
|
||||
The console now supports capability-based button control on top of menu-level access control.
|
||||
|
||||
- Policies are parsed with S3/MinIO-compatible semantics and aligned with current RustFS backend behavior.
|
||||
- Frontend capability checks support `Deny` precedence, `NotAction`, `NotResource`, wildcard matching, and RustFS action aliases.
|
||||
- A dedicated capability layer maps UI actions to backend permissions and resource scopes.
|
||||
- Bucket-, object-, IAM-, and policy-related operations on non-admin pages are now gated by capability checks.
|
||||
- Frontend button control is an experience-layer safeguard only; backend authorization remains the source of truth.
|
||||
```
|
||||
|
||||
如果 README 需要中文版本,可以使用:
|
||||
|
||||
```md
|
||||
## 按钮级权限控制
|
||||
|
||||
控制台目前已在菜单权限控制基础上,补充了非 admin 页面中的按钮级权限控制。
|
||||
|
||||
- 策略解析参考 S3 / MinIO 语义,并对齐当前 RustFS 后端行为。
|
||||
- 前端已支持 `Deny` 优先、`NotAction`、`NotResource`、通配符匹配以及 RustFS 动作别名归一化。
|
||||
- 新增 capability 能力层,将页面按钮动作统一映射为后端权限与资源范围。
|
||||
- 当前已覆盖桶、对象、Access Key、用户、策略、生命周期、复制、桶事件等主要非 admin 页面操作。
|
||||
- 前端按钮控制仅用于体验层约束,最终权限校验仍以后端为准。
|
||||
```
|
||||
|
||||
## 8. PR 描述草稿
|
||||
|
||||
下面内容可以直接作为 PR 描述初稿:
|
||||
|
||||
```md
|
||||
## Summary
|
||||
|
||||
This PR adds button-level permission control on top of the existing menu-level access control.
|
||||
|
||||
It introduces a capability layer for mapping UI actions to backend permissions, and aligns frontend policy parsing with current RustFS behavior.
|
||||
|
||||
## What Changed
|
||||
|
||||
- added `permission-capabilities` and `permission-resources` to map UI capabilities to backend actions and resource scopes
|
||||
- extended `console-policy-parser` to support `NotResource`, RustFS action aliases, wildcard normalization, and admin action matching behavior
|
||||
- exposed `canCapability(capability, context?)` from `usePermissions`
|
||||
- gated non-admin page actions for:
|
||||
- bucket creation and deletion
|
||||
- bucket policy, encryption, tagging, versioning, quota, retention, lifecycle, replication, and bucket events
|
||||
- object upload, preview, download, delete, bulk delete, tags, legal hold, retention, versions, and share URL generation
|
||||
- access key create/edit/delete/bulk delete
|
||||
- user create/edit/delete/bulk delete/group assignment/policy editing
|
||||
- policy create/edit/delete
|
||||
- added internal submit-path guards for several bucket/object/user edit flows to avoid relying only on button visibility
|
||||
|
||||
## Notes
|
||||
|
||||
- this is frontend UX gating only; backend authorization remains authoritative
|
||||
- admin-only pages are intentionally out of scope for this round
|
||||
- pages outside the current permission matrix are intentionally ignored for now
|
||||
- `/events` page is only partially adapted and still needs real capability-based gating in a follow-up
|
||||
|
||||
## Validation
|
||||
|
||||
- aligned permission semantics against current RustFS policy behavior
|
||||
- manually reviewed capability coverage across bucket, object, IAM, and policy pages
|
||||
```
|
||||
|
||||
## 9. 补充建议
|
||||
|
||||
如果后续要继续推进“全按钮覆盖”,建议按下面顺序继续做:
|
||||
|
||||
1. 先补 `/events` 页面真实 capability 控制,避免桶事件页面存在前后不一致。
|
||||
2. 再把 `new-form` / `edit-item` / `form` 这类弹窗内部提交逻辑全部下沉 capability 兜底。
|
||||
3. 最后再扩展到当前未纳入权限矩阵的页面和 admin-only 页面中的细粒度按钮。
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
@@ -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}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
@@ -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 === ""
|
||||
@@ -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
|
||||
@@ -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>())
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
@@ -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>
|
||||
),
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
@@ -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([])
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
@@ -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}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -13,6 +13,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 { UserNewForm } from "@/components/user/new-form"
|
||||
import { UserEditForm } from "@/components/user/edit-form"
|
||||
import { useUsers } from "@/hooks/use-users"
|
||||
@@ -49,6 +50,14 @@ export default function UsersPage() {
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { listUsers, deleteUser } = useUsers()
|
||||
const { canCapability } = usePermissions()
|
||||
|
||||
const canCreateUser = canCapability("users.create")
|
||||
const canEditUser =
|
||||
canCapability("users.edit") || canCapability("users.assignGroups") || canCapability("users.policy.edit")
|
||||
const canDeleteUser = canCapability("users.delete")
|
||||
const canBulkDeleteUsers = canCapability("users.bulkDelete")
|
||||
const canAssignGroups = canCapability("users.assignGroups")
|
||||
|
||||
const [data, setData] = useState<UserRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -121,14 +130,18 @@ export default function UsersPage() {
|
||||
meta: { width: 200 },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
|
||||
<RiEdit2Line className="size-4" />
|
||||
<span>{t("Edit")}</span>
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
{canEditUser ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
|
||||
<RiEdit2Line className="size-4" />
|
||||
<span>{t("Edit")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canDeleteUser ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -210,18 +223,30 @@ export default function UsersPage() {
|
||||
clearable
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button type="button" variant="outline" disabled={!selectedKeys.length} onClick={deleteByList}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canBulkDeleteUsers || !selectedKeys.length}
|
||||
onClick={deleteByList}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
{t("Delete Selected")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={!selectedKeys.length} onClick={addToGroup}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canAssignGroups || !selectedKeys.length}
|
||||
onClick={addToGroup}
|
||||
>
|
||||
<RiGroup2Fill className="size-4" />
|
||||
{t("Add to Group")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
{t("Add User")}
|
||||
</Button>
|
||||
{canCreateUser ? (
|
||||
<Button type="button" variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
{t("Add User")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -62,8 +62,19 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic
|
||||
setErrors({ accessKey: "", secretKey: "", name: "" })
|
||||
api
|
||||
.get("/accountinfo")
|
||||
.then((userInfo: { Policy?: unknown }) => {
|
||||
setPolicy(JSON.stringify(userInfo?.Policy ?? {}, null, 2))
|
||||
.then((userInfo: { policy?: unknown; Policy?: unknown }) => {
|
||||
const rawPolicy = userInfo.policy ?? userInfo.Policy ?? {}
|
||||
|
||||
if (typeof rawPolicy === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(rawPolicy)
|
||||
setPolicy(JSON.stringify(parsed, null, 2))
|
||||
} catch {
|
||||
setPolicy(rawPolicy)
|
||||
}
|
||||
} else {
|
||||
setPolicy(JSON.stringify(rawPolicy ?? {}, null, 2))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setPolicy("{}")
|
||||
|
||||
@@ -9,6 +9,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"
|
||||
@@ -21,7 +22,10 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { canCapability } = usePermissions()
|
||||
const { listBucketNotifications, putBucketNotifications } = useBucket()
|
||||
const eventsContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
|
||||
const canEditEvents = canCapability("bucket.events.edit", eventsContext)
|
||||
|
||||
const [data, setData] = React.useState<NotificationItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
@@ -91,6 +95,7 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
|
||||
const handleRowDelete = React.useCallback(
|
||||
async (row: NotificationItem) => {
|
||||
if (!canEditEvents) return
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
dialog.warning({
|
||||
title: t("Confirm Delete"),
|
||||
@@ -136,10 +141,13 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[bucketName, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t],
|
||||
[bucketName, canEditEvents, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t],
|
||||
)
|
||||
|
||||
const columns = React.useMemo(() => getEventsColumns(t, handleRowDelete), [t, handleRowDelete])
|
||||
const columns = React.useMemo(
|
||||
() => getEventsColumns(t, handleRowDelete, canEditEvents),
|
||||
[canEditEvents, t, handleRowDelete],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<NotificationItem>({
|
||||
data,
|
||||
@@ -152,10 +160,12 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">{t("Events")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Event Subscription")}</span>
|
||||
</Button>
|
||||
{canEditEvents ? (
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Event Subscription")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData} disabled={loading}>
|
||||
<RiRefreshLine className="size-4" />
|
||||
<span>{t("Refresh")}</span>
|
||||
|
||||
+72
-21
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { RiAddLine, RiCloseLine, RiEdit2Line } from "@remixicon/react"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { useSSE } from "@/hooks/use-sse"
|
||||
import { Item, ItemContent, ItemDescription, ItemGroup, ItemHeader, ItemTitle, ItemActions } from "@/components/ui/item"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -61,7 +62,17 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const bucketApi = useBucket()
|
||||
const { canCapability } = usePermissions()
|
||||
const { getKeyList } = useSSE()
|
||||
const bucketContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
|
||||
const canPutBucketPolicy = canCapability("bucket.policy.put", bucketContext)
|
||||
const canDeleteBucketPolicy = canCapability("bucket.policy.delete", bucketContext)
|
||||
const canEditBucketPolicy = canPutBucketPolicy || canDeleteBucketPolicy
|
||||
const canEditEncryption = canCapability("bucket.encryption.edit", bucketContext)
|
||||
const canEditTags = canCapability("bucket.tag.edit", bucketContext)
|
||||
const canEditVersioning = canCapability("bucket.versioning.edit", bucketContext)
|
||||
const canEditQuota = canCapability("bucket.quota.edit", bucketContext)
|
||||
const canEditObjectLock = canCapability("bucket.objectLock.edit", bucketContext)
|
||||
|
||||
const [policy, setPolicy] = React.useState<string | null>(null)
|
||||
const [policyType, setPolicyType] = React.useState<BucketPolicyType | "custom">("private")
|
||||
@@ -181,6 +192,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}, [fetchData])
|
||||
|
||||
const openPolicyModal = () => {
|
||||
if (!canEditBucketPolicy) return
|
||||
setPolicyFormPolicy(policyType)
|
||||
setPolicyFormContent(policy ?? "{}")
|
||||
setShowPolicyModal(true)
|
||||
@@ -188,6 +200,15 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
|
||||
const submitPolicy = async () => {
|
||||
try {
|
||||
if (policyFormPolicy === "private" && !canDeleteBucketPolicy) {
|
||||
message.error(t("Edit Failed"))
|
||||
return
|
||||
}
|
||||
if (policyFormPolicy !== "private" && !canPutBucketPolicy) {
|
||||
message.error(t("Edit Failed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (policyFormPolicy === "private") {
|
||||
await bucketApi.deleteBucketPolicy(bucketName)
|
||||
} else if (policyFormPolicy === "custom") {
|
||||
@@ -206,6 +227,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const openEncryptModal = async () => {
|
||||
if (!canEditEncryption) return
|
||||
const enc = encryption as {
|
||||
ServerSideEncryptionConfiguration?: {
|
||||
Rules?: Array<{
|
||||
@@ -281,6 +303,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const openTagModal = (index = -1) => {
|
||||
if (!canEditTags) return
|
||||
setEditingTagIndex(index)
|
||||
if (index >= 0 && tags[index]) {
|
||||
setTagFormKey(tags[index].Key)
|
||||
@@ -293,6 +316,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const submitTag = async () => {
|
||||
if (!canEditTags) return
|
||||
if (!tagFormKey.trim() || !tagFormValue.trim()) {
|
||||
message.error(t("Please fill in complete tag information"))
|
||||
return
|
||||
@@ -314,6 +338,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const confirmDeleteTag = async () => {
|
||||
if (!canEditTags) return
|
||||
if (deleteTagIndex === null) return
|
||||
const next = tags.filter((_, i) => i !== deleteTagIndex)
|
||||
try {
|
||||
@@ -331,6 +356,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const handleVersionToggle = async (enabled: boolean) => {
|
||||
if (!canEditVersioning) return
|
||||
setVersionLoading(true)
|
||||
const prev = versioning
|
||||
try {
|
||||
@@ -346,6 +372,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const openQuotaModal = () => {
|
||||
if (!canEditQuota) return
|
||||
if (quotaInfo?.quota && quotaInfo.quota > 0) {
|
||||
const bytes = quotaInfo.quota
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
|
||||
@@ -365,6 +392,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const submitQuota = async () => {
|
||||
if (!canEditQuota) return
|
||||
try {
|
||||
if (!quotaFormEnabled) {
|
||||
await bucketApi.deleteBucketQuota(bucketName)
|
||||
@@ -383,6 +411,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const openRetentionModal = () => {
|
||||
if (!canEditObjectLock) return
|
||||
if (!objectLock) {
|
||||
message.error(t("Object lock is not enabled, cannot set retention"))
|
||||
return
|
||||
@@ -394,6 +423,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
const submitRetention = async () => {
|
||||
if (!canEditObjectLock) return
|
||||
const mode = retentionFormMode
|
||||
const unit = retentionFormUnit
|
||||
const period = retentionPeriodInput ? Math.max(1, Number.parseInt(retentionPeriodInput, 10) || 1) : null
|
||||
@@ -447,10 +477,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
<ItemHeader className="items-center">
|
||||
<ItemTitle>{t("Access Policy")}</ItemTitle>
|
||||
<ItemActions>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openPolicyModal}>
|
||||
<RiEdit2Line className="me-2 size-4" />
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
{canEditBucketPolicy ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openPolicyModal}>
|
||||
<RiEdit2Line className="me-2 size-4" />
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
@@ -466,10 +498,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
<ItemDescription className="text-xs text-muted-foreground">{encryptionLabel}</ItemDescription>
|
||||
</div>
|
||||
<ItemActions>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openEncryptModal}>
|
||||
<RiEdit2Line className="me-2 size-4" />
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
{canEditEncryption ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openEncryptModal}>
|
||||
<RiEdit2Line className="me-2 size-4" />
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
</Item>
|
||||
@@ -479,10 +513,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
<ItemHeader className="items-center">
|
||||
<ItemTitle>{t("Tag")}</ItemTitle>
|
||||
<ItemActions>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => openTagModal()}>
|
||||
<RiAddLine className="me-2 size-4" />
|
||||
{t("Add")}
|
||||
</Button>
|
||||
{canEditTags ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => openTagModal()}>
|
||||
<RiAddLine className="me-2 size-4" />
|
||||
{t("Add")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
@@ -493,10 +529,21 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
key={`${tag.Key}-${index}`}
|
||||
className="flex items-center gap-2 rounded-full border bg-muted/40 px-3 py-1 text-xs"
|
||||
>
|
||||
<button type="button" className="text-left hover:underline" onClick={() => openTagModal(index)}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left hover:underline disabled:no-underline"
|
||||
onClick={() => openTagModal(index)}
|
||||
disabled={!canEditTags}
|
||||
>
|
||||
{tag.Key}:{tag.Value}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setDeleteTagIndex(index)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setDeleteTagIndex(index)}
|
||||
disabled={!canEditTags}
|
||||
>
|
||||
<RiCloseLine className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -525,7 +572,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
</div>
|
||||
<Switch
|
||||
checked={versioning === "Enabled"}
|
||||
disabled={(objectLock ?? false) || versionLoading}
|
||||
disabled={!canEditVersioning || (objectLock ?? false) || versionLoading}
|
||||
onCheckedChange={handleVersionToggle}
|
||||
/>
|
||||
</div>
|
||||
@@ -542,9 +589,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
</ItemDescription>
|
||||
</div>
|
||||
<ItemActions>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openQuotaModal}>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
{canEditQuota ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openQuotaModal}>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
{quotaInfo?.quota ? (
|
||||
@@ -575,9 +624,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
</ItemDescription>
|
||||
</div>
|
||||
<ItemActions>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openRetentionModal}>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
{canEditObjectLock ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openRetentionModal}>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { LifecycleNewForm } from "@/components/lifecycle/new-form"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
@@ -43,7 +44,10 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { canCapability } = usePermissions()
|
||||
const { getBucketLifecycleConfiguration, deleteBucketLifecycle, putBucketLifecycleConfiguration } = useBucket()
|
||||
const lifecycleContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
|
||||
const canEditLifecycle = canCapability("bucket.lifecycle.edit", lifecycleContext)
|
||||
|
||||
const [data, setData] = React.useState<LifecycleRule[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
@@ -70,6 +74,7 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) {
|
||||
|
||||
const handleRowDelete = React.useCallback(
|
||||
async (row: LifecycleRule) => {
|
||||
if (!canEditLifecycle) return
|
||||
const remaining = data.filter((item) => item.ID !== row.ID)
|
||||
|
||||
try {
|
||||
@@ -86,7 +91,7 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) {
|
||||
message.error((error as Error).message || t("Delete Failed"))
|
||||
}
|
||||
},
|
||||
[data, deleteBucketLifecycle, bucketName, putBucketLifecycleConfiguration, message, t, loadData],
|
||||
[canEditLifecycle, data, deleteBucketLifecycle, bucketName, putBucketLifecycleConfiguration, message, t, loadData],
|
||||
)
|
||||
|
||||
const confirmDelete = React.useCallback(
|
||||
@@ -159,15 +164,17 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) {
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
{canEditLifecycle ? (
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, confirmDelete],
|
||||
[canEditLifecycle, t, confirmDelete],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<LifecycleRule>({
|
||||
@@ -181,10 +188,12 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">{t("Lifecycle")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Lifecycle Rule")}</span>
|
||||
</Button>
|
||||
{canEditLifecycle ? (
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Lifecycle Rule")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" />
|
||||
<span>{t("Refresh")}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { getBytes } from "@/lib/functions"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -32,6 +33,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { createBucket, putBucketVersioning, putObjectLockConfiguration, putBucketQuota } = useBucket()
|
||||
const { canCapability } = usePermissions()
|
||||
|
||||
const [objectKey, setObjectKey] = React.useState("")
|
||||
const [version, setVersion] = React.useState(false)
|
||||
@@ -49,6 +51,11 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
const showNameError = trimmedBucketName.length > 0 && (trimmedBucketName.length < 3 || trimmedBucketName.length > 63)
|
||||
const parsedRetentionPeriod = Math.max(1, Number.parseInt(retentionPeriod, 10) || 1)
|
||||
const isSubmitDisabled = creating || trimmedBucketName.length < 3 || trimmedBucketName.length > 63
|
||||
const canCreateBucket = canCapability("bucket.create")
|
||||
const bucketContext = trimmedBucketName ? { bucket: trimmedBucketName } : {}
|
||||
const canEditVersioning = canCapability("bucket.versioning.edit", bucketContext)
|
||||
const canEditObjectLock = canCapability("bucket.objectLock.edit", bucketContext)
|
||||
const canEditQuota = canCapability("bucket.quota.edit", bucketContext)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (objectLock) setVersion(true)
|
||||
@@ -77,7 +84,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
}
|
||||
|
||||
const handleCreateBucket = async () => {
|
||||
if (isSubmitDisabled) return
|
||||
if (!canCreateBucket || isSubmitDisabled) return
|
||||
|
||||
const bucketName = trimmedBucketName
|
||||
setCreating(true)
|
||||
@@ -85,11 +92,11 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
try {
|
||||
await createBucket({
|
||||
Bucket: bucketName,
|
||||
ObjectLockEnabledForBucket: objectLock,
|
||||
ObjectLockEnabledForBucket: canEditObjectLock && objectLock,
|
||||
})
|
||||
|
||||
const applyRetention = async () => {
|
||||
if (objectLock && retentionEnabled) {
|
||||
if (canEditObjectLock && objectLock && retentionEnabled) {
|
||||
await putObjectLockConfiguration(bucketName, {
|
||||
ObjectLockEnabled: "Enabled",
|
||||
Rule: {
|
||||
@@ -103,11 +110,11 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (version) {
|
||||
if (canEditVersioning && version) {
|
||||
await putBucketVersioning(bucketName, "Enabled")
|
||||
}
|
||||
|
||||
if (quotaEnabled) {
|
||||
if (canEditQuota && quotaEnabled) {
|
||||
try {
|
||||
await putBucketQuota(bucketName, {
|
||||
quota: Number.parseInt(getBytes(quotaSize, quotaUnit), 10),
|
||||
@@ -162,21 +169,21 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
<Field orientation="responsive" className="items-center">
|
||||
<FieldLabel>{t("Version")}</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch checked={version} onCheckedChange={setVersion} />
|
||||
<Switch checked={version} onCheckedChange={setVersion} disabled={!canEditVersioning} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-center">
|
||||
<FieldLabel>{t("Object Lock")}</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch checked={objectLock} onCheckedChange={setObjectLock} />
|
||||
<Switch checked={objectLock} onCheckedChange={setObjectLock} disabled={!canEditObjectLock} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
<Field orientation="responsive" className="items-center">
|
||||
<FieldLabel>{t("Bucket Quota")}</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch checked={quotaEnabled} onCheckedChange={setQuotaEnabled} />
|
||||
<Switch checked={quotaEnabled} onCheckedChange={setQuotaEnabled} disabled={!canEditQuota} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
@@ -280,7 +287,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
|
||||
<Button variant="outline" onClick={closeModal}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="default" disabled={isSubmitDisabled} onClick={handleCreateBucket}>
|
||||
<Button variant="default" disabled={!canCreateBucket || isSubmitDisabled} onClick={handleCreateBucket}>
|
||||
{creating ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { ReplicationNewForm } from "@/components/replication/new-form"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
@@ -29,8 +30,11 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { canCapability } = usePermissions()
|
||||
const { getBucketReplication, putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget } =
|
||||
useBucket()
|
||||
const replicationContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
|
||||
const canEditReplication = canCapability("bucket.replication.edit", replicationContext)
|
||||
|
||||
const [data, setData] = React.useState<ReplicationRule[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
@@ -54,6 +58,7 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
|
||||
|
||||
const handleRowDelete = React.useCallback(
|
||||
async (rule: ReplicationRule) => {
|
||||
if (!canEditReplication) return
|
||||
const remaining = data.filter((item) => item !== rule)
|
||||
|
||||
try {
|
||||
@@ -78,6 +83,7 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
|
||||
}
|
||||
},
|
||||
[
|
||||
canEditReplication,
|
||||
data,
|
||||
deleteBucketReplication,
|
||||
bucketName,
|
||||
@@ -148,15 +154,17 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin7Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
{canEditReplication ? (
|
||||
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
|
||||
<RiDeleteBin7Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, confirmDelete],
|
||||
[canEditReplication, t, confirmDelete],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<ReplicationRule>({
|
||||
@@ -170,10 +178,12 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">{t("Bucket Replication")}</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Replication Rule")}</span>
|
||||
</Button>
|
||||
{canEditReplication ? (
|
||||
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
|
||||
<RiAddLine className="size-4" />
|
||||
<span>{t("Add Replication Rule")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={loadData}>
|
||||
<RiRefreshLine className="size-4" />
|
||||
<span>{t("Refresh")}</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TFunction } from "i18next"
|
||||
export function getEventsColumns(
|
||||
t: TFunction,
|
||||
onDelete: (row: NotificationItem) => void,
|
||||
canDelete: boolean,
|
||||
): ColumnDef<NotificationItem>[] {
|
||||
return [
|
||||
{
|
||||
@@ -57,19 +58,21 @@ export function getEventsColumns(
|
||||
meta: { maxWidth: "6rem" },
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(row.original)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBin7Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(row.original)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBin7Line className="size-4" aria-hidden />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
+67
-46
@@ -16,6 +16,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { CopyInput } from "@/components/copy-input"
|
||||
import { useObject } from "@/hooks/use-object"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { exportFile } from "@/lib/export-file"
|
||||
@@ -42,6 +43,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const objectApi = useObject(bucketName)
|
||||
const { canCapability } = usePermissions()
|
||||
const client = useS3()
|
||||
|
||||
const [object, setObject] = React.useState<Record<string, unknown> | null>(null)
|
||||
@@ -65,7 +67,22 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
const [totalExpirationSeconds, setTotalExpirationSeconds] = React.useState(0)
|
||||
const [isExpirationValid, setIsExpirationValid] = React.useState(false)
|
||||
const [isGeneratingUrl, setIsGeneratingUrl] = React.useState(false)
|
||||
const previewParamSyncTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const resolvedObjectKey = React.useMemo(() => String(object?.Key ?? objectKey ?? ""), [object?.Key, objectKey])
|
||||
const objectPermissionContext = React.useMemo(
|
||||
() => ({
|
||||
bucket: bucketName,
|
||||
objectKey: resolvedObjectKey || undefined,
|
||||
}),
|
||||
[bucketName, resolvedObjectKey],
|
||||
)
|
||||
const canDownloadObject = canCapability("objects.download", objectPermissionContext)
|
||||
const canPreviewObject = canCapability("objects.preview", objectPermissionContext)
|
||||
const canViewObjectTags = canCapability("objects.tag.view", objectPermissionContext)
|
||||
const canEditObjectTags = canCapability("objects.tag.edit", objectPermissionContext)
|
||||
const canViewVersions = canCapability("objects.version.view", objectPermissionContext)
|
||||
const canEditLegalHold = canCapability("objects.legalHold.edit", objectPermissionContext)
|
||||
const canEditRetention = canCapability("objects.retention.edit", objectPermissionContext)
|
||||
const canShareObject = canCapability("objects.share", objectPermissionContext)
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
if (seconds === 0) return ""
|
||||
@@ -201,7 +218,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
}, [open, objectKey, message, t])
|
||||
|
||||
const download = async () => {
|
||||
if (!object?.Key) return
|
||||
if (!canDownloadObject || !object?.Key) return
|
||||
try {
|
||||
const url = await objectApi.getSignedUrl(object.Key as string)
|
||||
const response = await fetch(url)
|
||||
@@ -217,14 +234,6 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (previewParamSyncTimerRef.current) {
|
||||
clearTimeout(previewParamSyncTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePreviewVersion = async (versionId: string) => {
|
||||
if (!object?.Key) return
|
||||
try {
|
||||
@@ -255,7 +264,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
}
|
||||
}
|
||||
const toggleLegalHold = async () => {
|
||||
if (!object?.Key) return
|
||||
if (!canEditLegalHold || !object?.Key) return
|
||||
try {
|
||||
await objectApi.setLegalHold(object.Key as string, !lockStatus)
|
||||
setLockStatus(!lockStatus)
|
||||
@@ -266,7 +275,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
}
|
||||
|
||||
const generateTemporaryUrl = async () => {
|
||||
if (!object?.Key || isGeneratingUrl) return
|
||||
if (!canShareObject || !object?.Key || isGeneratingUrl) return
|
||||
if (!validateExpiration()) {
|
||||
message.error(expirationError || t("Please enter a valid expiration time"))
|
||||
return
|
||||
@@ -285,7 +294,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
|
||||
const submitTagForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!object?.Key) return
|
||||
if (!canEditObjectTags || !object?.Key) return
|
||||
if (!tagFormValue.Key || !tagFormValue.Value) {
|
||||
message.error(t("Please fill in the correct format"))
|
||||
return
|
||||
@@ -312,7 +321,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
}
|
||||
|
||||
const removeTag = async (tagKey: string) => {
|
||||
if (!object?.Key) return
|
||||
if (!canEditObjectTags || !object?.Key) return
|
||||
try {
|
||||
const nextTags = tags.filter((tag) => tag.Key !== tagKey)
|
||||
await objectApi.putObjectTags(object.Key as string, nextTags)
|
||||
@@ -325,7 +334,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
|
||||
const submitRetention = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!object?.Key) return
|
||||
if (!canEditRetention || !object?.Key) return
|
||||
try {
|
||||
await objectApi.putObjectRetention(object.Key as string, {
|
||||
Mode: retentionMode,
|
||||
@@ -340,7 +349,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
}
|
||||
|
||||
const resetRetention = async () => {
|
||||
if (!object?.Key) return
|
||||
if (!canEditRetention || !object?.Key) return
|
||||
try {
|
||||
await objectApi.putObjectRetention(object.Key as string, {
|
||||
Mode: "GOVERNANCE",
|
||||
@@ -364,28 +373,36 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
</DrawerHeader>
|
||||
<div className="min-w-0 space-y-4 p-4 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={download}>
|
||||
<RiDownloadLine className="size-4" />
|
||||
{t("Download")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onPreview({ key: String(object?.Key ?? "") })}>
|
||||
<RiEyeLine className="size-4" />
|
||||
{t("Preview")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowTagView(true)}>
|
||||
<RiPriceTag3Line className="size-4" />
|
||||
{t("Set Tags")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowVersions(true)}>
|
||||
<RiFileList2Line className="size-4" />
|
||||
{t("Versions")}
|
||||
</Button>
|
||||
{lockStatus && (
|
||||
{canDownloadObject ? (
|
||||
<Button variant="outline" size="sm" onClick={download}>
|
||||
<RiDownloadLine className="size-4" />
|
||||
{t("Download")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canPreviewObject ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onPreview({ key: String(object?.Key ?? "") })}>
|
||||
<RiEyeLine className="size-4" />
|
||||
{t("Preview")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewObjectTags || canEditObjectTags ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowTagView(true)}>
|
||||
<RiPriceTag3Line className="size-4" />
|
||||
{t("Set Tags")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewVersions ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowVersions(true)}>
|
||||
<RiFileList2Line className="size-4" />
|
||||
{t("Versions")}
|
||||
</Button>
|
||||
) : null}
|
||||
{lockStatus && canEditRetention ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRetentionView(true)}>
|
||||
<RiLockLine className="size-4" />
|
||||
{t("Retention")}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Item variant="outline" className="flex-col items-stretch gap-4">
|
||||
@@ -433,7 +450,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 min-w-0">
|
||||
<span className="font-medium text-muted-foreground">{t("Legal Hold")}</span>
|
||||
<Switch checked={lockStatus} onCheckedChange={toggleLegalHold} />
|
||||
<Switch checked={lockStatus} onCheckedChange={toggleLegalHold} disabled={!canEditLegalHold} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
@@ -519,7 +536,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!object?.Key || !isExpirationValid || isGeneratingUrl}
|
||||
disabled={!canShareObject || !object?.Key || !isExpirationValid || isGeneratingUrl}
|
||||
onClick={generateTemporaryUrl}
|
||||
>
|
||||
{isGeneratingUrl ? <Spinner className="size-4" /> : null}
|
||||
@@ -557,13 +574,15 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag.Key} variant="secondary" className="gap-1 pr-1">
|
||||
{tag.Key}: {tag.Value}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-destructive transition-colors"
|
||||
onClick={() => confirmDeleteTag(tag.Key)}
|
||||
>
|
||||
<RiCloseLine className="size-3" />
|
||||
</button>
|
||||
{canEditObjectTags ? (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-destructive transition-colors"
|
||||
onClick={() => confirmDeleteTag(tag.Key)}
|
||||
>
|
||||
<RiCloseLine className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -576,6 +595,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
value={tagFormValue.Key}
|
||||
onChange={(e) => setTagFormValue((v) => ({ ...v, Key: e.target.value }))}
|
||||
placeholder={t("Tag Key Placeholder")}
|
||||
disabled={!canEditObjectTags}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
@@ -591,12 +611,13 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
}))
|
||||
}
|
||||
placeholder={t("Tag Value Placeholder")}
|
||||
disabled={!canEditObjectTags}
|
||||
/>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="default">
|
||||
<Button type="submit" variant="default" disabled={!canEditObjectTags}>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -645,10 +666,10 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
|
||||
</FieldContent>
|
||||
</Field>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={resetRetention}>
|
||||
<Button type="button" variant="secondary" onClick={resetRetention} disabled={!canEditRetention}>
|
||||
{t("Reset")}
|
||||
</Button>
|
||||
<Button type="submit" variant="default">
|
||||
<Button type="submit" variant="default" disabled={!canEditRetention}>
|
||||
{t("Confirm")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowRetentionView(false)}>
|
||||
|
||||
+51
-33
@@ -33,6 +33,7 @@ import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useObject } from "@/hooks/use-object"
|
||||
import { useBucket } from "@/hooks/use-bucket"
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { exportFile } from "@/lib/export-file"
|
||||
import { getContentType } from "@/lib/mime-types"
|
||||
@@ -57,6 +58,7 @@ interface ObjectListProps {
|
||||
path: string
|
||||
onOpenInfo: (bucket: string, key: string) => void
|
||||
onUploadClick: () => void
|
||||
canUpload?: boolean
|
||||
pageSize?: number
|
||||
onRefresh?: () => void
|
||||
refreshTrigger?: number
|
||||
@@ -68,6 +70,7 @@ export function ObjectList({
|
||||
path,
|
||||
onOpenInfo,
|
||||
onUploadClick,
|
||||
canUpload = false,
|
||||
pageSize = 25,
|
||||
onRefresh,
|
||||
refreshTrigger = 0,
|
||||
@@ -77,6 +80,7 @@ export function ObjectList({
|
||||
const message = useMessage()
|
||||
const { listObject, getSignedUrl, mapAllFiles } = useObject(bucket)
|
||||
const { getBucketVersioning } = useBucket()
|
||||
const { canCapability } = usePermissions()
|
||||
const addDeleteKeys = useAddDeleteKeys()
|
||||
const addDeleteFolder = useAddDeleteFolder()
|
||||
const tasks = useTasks()
|
||||
@@ -94,6 +98,8 @@ export function ObjectList({
|
||||
const [deleteAllVersions, setDeleteAllVersions] = React.useState(false)
|
||||
|
||||
const prefix = decodeURIComponent(path)
|
||||
const canBulkDelete = canCapability("objects.bulkDelete", { bucket, prefix })
|
||||
const canBulkDownload = canCapability("objects.download", { bucket, prefix })
|
||||
|
||||
const bucketPath = React.useCallback((p?: string | string[]) => buildBucketPath(bucket, p), [bucket])
|
||||
|
||||
@@ -288,31 +294,37 @@ export function ObjectList({
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.type === "object" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onPreview({ key: row.original.Key })
|
||||
}}
|
||||
>
|
||||
<RiEyeLine className="size-4" />
|
||||
<span>{t("Preview")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => downloadFile(row.original.Key)}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
<span>{t("Download")}</span>
|
||||
</Button>
|
||||
{canCapability("objects.preview", { bucket, objectKey: row.original.Key }) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onPreview({ key: row.original.Key })
|
||||
}}
|
||||
>
|
||||
<RiEyeLine className="size-4" />
|
||||
<span>{t("Preview")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canCapability("objects.download", { bucket, objectKey: row.original.Key }) ? (
|
||||
<Button variant="outline" size="sm" onClick={() => downloadFile(row.original.Key)}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
<span>{t("Download")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={() => openDeleteDialog([row.original.Key])}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
{canCapability("objects.delete", { bucket, objectKey: row.original.Key }) ? (
|
||||
<Button variant="outline" size="sm" onClick={() => openDeleteDialog([row.original.Key])}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, displayKey, bucketPath, onOpenInfo, bucket, prefix, downloadFile],
|
||||
[t, displayKey, bucketPath, onOpenInfo, bucket, downloadFile, canCapability, onPreview],
|
||||
)
|
||||
|
||||
const { table, selectedRowIds } = useDataTable<ObjectRow>({
|
||||
@@ -526,22 +538,28 @@ export function ObjectList({
|
||||
actions={
|
||||
<>
|
||||
<TaskStatsButton />
|
||||
<Button variant="outline" onClick={onUploadClick}>
|
||||
<RiFileAddLine className="size-4" />
|
||||
<span>
|
||||
{t("Upload File")}/{t("Folder")}
|
||||
</span>
|
||||
</Button>
|
||||
{canUpload ? (
|
||||
<Button variant="outline" onClick={onUploadClick}>
|
||||
<RiFileAddLine className="size-4" />
|
||||
<span>
|
||||
{t("Upload File")}/{t("Folder")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{checkedKeys.length > 0 ? (
|
||||
<>
|
||||
<Button variant="outline" className="text-destructive border-destructive" onClick={handleBatchDelete}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete Selected")}</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={downloadMultiple}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
<span>{t("Download")}</span>
|
||||
</Button>
|
||||
{canBulkDelete ? (
|
||||
<Button variant="outline" className="text-destructive border-destructive" onClick={handleBatchDelete}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
<span>{t("Delete Selected")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canBulkDownload ? (
|
||||
<Button variant="outline" onClick={downloadMultiple}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
<span>{t("Download")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={() => (onRefresh ?? fetchObjects)()}>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ObjectUploadPickerProps {
|
||||
onShowChange: (show: boolean) => void
|
||||
bucketName: string
|
||||
prefix: string
|
||||
canUpload?: boolean
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
@@ -182,7 +183,14 @@ function collectFilesFromFileList(files: FileList, basePath = ""): FileItem[] {
|
||||
return items
|
||||
}
|
||||
|
||||
export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onSuccess }: ObjectUploadPickerProps) {
|
||||
export function ObjectUploadPicker({
|
||||
show,
|
||||
onShowChange,
|
||||
bucketName,
|
||||
prefix,
|
||||
canUpload = false,
|
||||
onSuccess,
|
||||
}: ObjectUploadPickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const addUploadFiles = useAddUploadFiles()
|
||||
@@ -214,6 +222,12 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
|
||||
}
|
||||
}, [show, prefix])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (show && !canUpload) {
|
||||
onShowChange(false)
|
||||
}
|
||||
}, [canUpload, onShowChange, show])
|
||||
|
||||
const effectivePrefix = editablePrefix.replace(/\/$/, "") || ""
|
||||
|
||||
const ensureCapacity = React.useCallback(
|
||||
@@ -396,7 +410,7 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!items.length) return
|
||||
if (!canUpload || !items.length) return
|
||||
setIsAdding(true)
|
||||
setAddProgress(0)
|
||||
try {
|
||||
@@ -466,16 +480,26 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
|
||||
onChange={(e) => setEditablePrefix(e.target.value)}
|
||||
className="h-7 py-1 px-2 text-xs"
|
||||
placeholder={t("Enter prefix...")}
|
||||
disabled={isAdding || isFolderLoading}
|
||||
disabled={!canUpload || isAdding || isFolderLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button variant="outline" size="sm" disabled={isAdding || isFolderLoading} onClick={selectFile}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canUpload || isAdding || isFolderLoading}
|
||||
onClick={selectFile}
|
||||
>
|
||||
<RiFileAddLine className="size-4" />
|
||||
{t("Select File")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={isAdding || isFolderLoading} onClick={selectFolder}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canUpload || isAdding || isFolderLoading}
|
||||
onClick={selectFolder}
|
||||
>
|
||||
<RiFolderAddLine className="size-4" />
|
||||
{t("Select Folder")}
|
||||
</Button>
|
||||
@@ -517,7 +541,7 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!items.length || isAdding || isFolderLoading}
|
||||
disabled={!canUpload || !items.length || isAdding || isFolderLoading}
|
||||
onClick={clearAllFiles}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4" />
|
||||
@@ -563,7 +587,11 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="default" disabled={!items.length || isAdding || isFolderLoading} onClick={handleUpload}>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!canUpload || !items.length || isAdding || isFolderLoading}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
{isAdding ? t("Adding to Upload Queue") : t("Start Upload")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||
import { DataTable } from "@/components/data-table/data-table"
|
||||
import { useDataTable } from "@/hooks/use-data-table"
|
||||
import { useObject } from "@/hooks/use-object"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { copyToClipboard } from "@/lib/clipboard"
|
||||
import { exportFile } from "@/lib/export-file"
|
||||
@@ -46,6 +47,7 @@ export function ObjectVersions({
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { listObjectVersions, deleteObject } = useObject(bucketName)
|
||||
const { canCapability } = usePermissions()
|
||||
const client = useS3()
|
||||
|
||||
const [versions, setVersions] = React.useState<VersionRow[]>([])
|
||||
@@ -167,25 +169,43 @@ export function ObjectVersions({
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("Action"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onPreview(row.original.VersionId ?? "")}>
|
||||
<RiEyeLine className="size-4" />
|
||||
{t("Preview")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => downloadVersion(row.original)}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
{t("Download")}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="text-white" onClick={() => deleteVersion(row.original)}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const objectContext = {
|
||||
bucket: bucketName,
|
||||
objectKey,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{canCapability("objects.version.view", objectContext) ? (
|
||||
<Button variant="outline" size="sm" onClick={() => onPreview(row.original.VersionId ?? "")}>
|
||||
<RiEyeLine className="size-4" />
|
||||
{t("Preview")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canCapability("objects.download", objectContext) ? (
|
||||
<Button variant="outline" size="sm" onClick={() => downloadVersion(row.original)}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
{t("Download")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canCapability("objects.delete", objectContext) ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="text-white"
|
||||
onClick={() => deleteVersion(row.original)}
|
||||
>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[t, onPreview, copyVersionId, downloadVersion, deleteVersion],
|
||||
[t, onPreview, copyVersionId, downloadVersion, deleteVersion, canCapability, bucketName, objectKey],
|
||||
)
|
||||
|
||||
const { table } = useDataTable<VersionRow>({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { useUsers } from "@/hooks/use-users"
|
||||
import { usePermissions } from "@/hooks/use-permissions"
|
||||
import { usePolicies } from "@/hooks/use-policies"
|
||||
import { useGroups } from "@/hooks/use-groups"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
@@ -42,8 +43,12 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const { getUser, changeUserStatus, createUser } = useUsers()
|
||||
const { canCapability } = usePermissions()
|
||||
const { listPolicies, setUserOrGroupPolicy } = usePolicies()
|
||||
const { listGroup, updateGroupMembers } = useGroups()
|
||||
const canEditAccount = canCapability("users.edit")
|
||||
const canAssignGroups = canCapability("users.assignGroups")
|
||||
const canEditPolicies = canCapability("users.policy.edit")
|
||||
|
||||
const [user, setUser] = React.useState<{
|
||||
accessKey: string
|
||||
@@ -66,6 +71,13 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
|
||||
const statusBoolean = user.status === "enabled"
|
||||
const availableTabs = React.useMemo(() => {
|
||||
const tabs: string[] = []
|
||||
if (canEditAccount) tabs.push("account")
|
||||
if (canAssignGroups) tabs.push("groups")
|
||||
if (canEditPolicies) tabs.push("policy")
|
||||
return tabs
|
||||
}, [canAssignGroups, canEditAccount, canEditPolicies])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !row?.accessKey) return
|
||||
@@ -84,7 +96,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
setOriginalMemberOf(initialMemberOf)
|
||||
setSecretKey("")
|
||||
setErrors({ secretKey: "" })
|
||||
setActiveTab("account")
|
||||
setActiveTab(availableTabs[0] ?? "account")
|
||||
setLoading(true)
|
||||
|
||||
Promise.all([getUser(row.accessKey), listGroup(), listPolicies()])
|
||||
@@ -111,7 +123,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
message.error(t("Failed to get data"))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, row, getUser, listGroup, listPolicies, message, t])
|
||||
}, [availableTabs, open, row, getUser, listGroup, listPolicies, message, t])
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = { secretKey: "" }
|
||||
@@ -123,6 +135,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
}
|
||||
|
||||
const handleStatusChange = async (checked: boolean) => {
|
||||
if (!canEditAccount) return
|
||||
const nextStatus = checked ? "enabled" : "disabled"
|
||||
if (!user.accessKey) return
|
||||
|
||||
@@ -146,7 +159,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (secretKey) {
|
||||
if (canEditAccount && secretKey) {
|
||||
await createUser(
|
||||
{
|
||||
accessKey: user.accessKey,
|
||||
@@ -157,14 +170,16 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
)
|
||||
}
|
||||
|
||||
await setUserOrGroupPolicy({
|
||||
policyName: user.policy,
|
||||
userOrGroup: user.accessKey,
|
||||
isGroup: false,
|
||||
})
|
||||
if (canEditPolicies) {
|
||||
await setUserOrGroupPolicy({
|
||||
policyName: user.policy,
|
||||
userOrGroup: user.accessKey,
|
||||
isGroup: false,
|
||||
})
|
||||
}
|
||||
|
||||
const removedGroups = originalMemberOf.filter((group) => !user.memberOf.includes(group))
|
||||
const addedGroups = user.memberOf.filter((group) => !originalMemberOf.includes(group))
|
||||
const removedGroups = canAssignGroups ? originalMemberOf.filter((group) => !user.memberOf.includes(group)) : []
|
||||
const addedGroups = canAssignGroups ? user.memberOf.filter((group) => !originalMemberOf.includes(group)) : []
|
||||
|
||||
if (removedGroups.length) {
|
||||
await Promise.all(
|
||||
@@ -219,41 +234,51 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
<div className="space-y-4 max-h-[80vh] overflow-auto px-2 -mx-2">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-4">
|
||||
<TabsList className="justify-start overflow-x-auto">
|
||||
<TabsTrigger value="account">{t("Account")}</TabsTrigger>
|
||||
<TabsTrigger value="groups">{t("Groups")}</TabsTrigger>
|
||||
<TabsTrigger value="policy">{t("Policies")}</TabsTrigger>
|
||||
{canEditAccount ? <TabsTrigger value="account">{t("Account")}</TabsTrigger> : null}
|
||||
{canAssignGroups ? <TabsTrigger value="groups">{t("Groups")}</TabsTrigger> : null}
|
||||
{canEditPolicies ? <TabsTrigger value="policy">{t("Policies")}</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="account" className="mt-0 space-y-4">
|
||||
<div className="flex items-center justify-start gap-2 rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">{t("Status")}</span>
|
||||
<Switch checked={statusBoolean} onCheckedChange={handleStatusChange} disabled={loading || submitting} />
|
||||
</div>
|
||||
<UserEditSecretKey
|
||||
value={secretKey}
|
||||
error={errors.secretKey}
|
||||
disabled={loading || submitting}
|
||||
onChange={setSecretKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canEditAccount ? (
|
||||
<TabsContent value="account" className="mt-0 space-y-4">
|
||||
<div className="flex items-center justify-start gap-2 rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">{t("Status")}</span>
|
||||
<Switch
|
||||
checked={statusBoolean}
|
||||
onCheckedChange={handleStatusChange}
|
||||
disabled={loading || submitting}
|
||||
/>
|
||||
</div>
|
||||
<UserEditSecretKey
|
||||
value={secretKey}
|
||||
error={errors.secretKey}
|
||||
disabled={loading || submitting}
|
||||
onChange={setSecretKey}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="groups" className="mt-0">
|
||||
<UserEditGroups
|
||||
value={user.memberOf}
|
||||
options={groupsList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(memberOf) => setUser((prev) => ({ ...prev, memberOf }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canAssignGroups ? (
|
||||
<TabsContent value="groups" className="mt-0">
|
||||
<UserEditGroups
|
||||
value={user.memberOf}
|
||||
options={groupsList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(memberOf) => setUser((prev) => ({ ...prev, memberOf }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="policy" className="mt-0">
|
||||
<UserEditPolicies
|
||||
value={user.policy}
|
||||
options={policiesList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(policy) => setUser((prev) => ({ ...prev, policy }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canEditPolicies ? (
|
||||
<TabsContent value="policy" className="mt-0">
|
||||
<UserEditPolicies
|
||||
value={user.policy}
|
||||
options={policiesList}
|
||||
disabled={loading || submitting}
|
||||
onChange={(policy) => setUser((prev) => ({ ...prev, policy }))}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -261,7 +286,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
|
||||
<Button variant="outline" onClick={closeModal} disabled={submitting}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={submitForm} disabled={loading || submitting}>
|
||||
<Button onClick={submitForm} disabled={loading || submitting || availableTabs.length === 0}>
|
||||
{submitting ? <Spinner className="size-4" /> : null}
|
||||
<span>{t("Submit")}</span>
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { hasConsoleScopes, type ConsolePolicy } from "@/lib/console-policy-parser"
|
||||
import { hasConsoleCapability, type ConsoleCapability } from "@/lib/permission-capabilities"
|
||||
import type { PermissionResourceContext } from "@/lib/permission-resources"
|
||||
import { CONSOLE_SCOPES, PAGE_PERMISSIONS } from "@/lib/console-permissions"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useApiOptional } from "@/contexts/api-context"
|
||||
@@ -14,6 +16,7 @@ interface PermissionsContextValue {
|
||||
hasFetchedPolicy: boolean
|
||||
fetchUserPolicy: () => Promise<void>
|
||||
hasPermission: (action: string | string[], matchAll?: boolean) => boolean
|
||||
canCapability: (capability: ConsoleCapability, context?: PermissionResourceContext) => boolean
|
||||
canAccessPath: (path: string) => boolean
|
||||
isAdmin: boolean
|
||||
/** True when user has consoleAdmin (or is admin). Only these users can change password per RustFS backend. */
|
||||
@@ -89,6 +92,14 @@ export function PermissionsProvider({ children }: { children: React.ReactNode })
|
||||
[isAdmin, userPolicy],
|
||||
)
|
||||
|
||||
const canCapability = useCallback(
|
||||
(capability: ConsoleCapability, context: PermissionResourceContext = {}) => {
|
||||
if (isAdmin) return true
|
||||
return hasConsoleCapability(userPolicy, capability, context)
|
||||
},
|
||||
[isAdmin, userPolicy],
|
||||
)
|
||||
|
||||
const canAccessPath = useCallback(
|
||||
(path: string) => {
|
||||
if (isAdmin) return true
|
||||
@@ -131,6 +142,7 @@ export function PermissionsProvider({ children }: { children: React.ReactNode })
|
||||
hasFetchedPolicy,
|
||||
fetchUserPolicy,
|
||||
hasPermission,
|
||||
canCapability,
|
||||
canAccessPath,
|
||||
isAdmin,
|
||||
canChangePassword,
|
||||
@@ -143,6 +155,7 @@ export function PermissionsProvider({ children }: { children: React.ReactNode })
|
||||
hasFetchedPolicy,
|
||||
fetchUserPolicy,
|
||||
hasPermission,
|
||||
canCapability,
|
||||
canAccessPath,
|
||||
isAdmin,
|
||||
canChangePassword,
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ConsoleStatement {
|
||||
Action?: string[]
|
||||
NotAction?: string[]
|
||||
Resource?: string[]
|
||||
NotResource?: string[]
|
||||
}
|
||||
|
||||
export interface ConsolePolicy {
|
||||
@@ -15,19 +16,38 @@ export interface ConsolePolicy {
|
||||
|
||||
/**
|
||||
* Check if an action matches the policy actions
|
||||
* @param policyActions - Array of action patterns (empty array means match all, undefined means no match)
|
||||
* RustFS policy parser treats bare "*" as S3 wildcard only, not a cross-service wildcard.
|
||||
* Some action names in the console still need to understand a few backend aliases.
|
||||
* @param requestAction - The action to check
|
||||
* @returns true if the action matches
|
||||
*/
|
||||
const ACTION_ALIASES: Record<string, string> = {
|
||||
"*": "s3:*",
|
||||
"s3:GetLifecycleConfiguration": "s3:GetBucketLifecycle",
|
||||
"s3:GetBucketLifecycleConfiguration": "s3:GetBucketLifecycle",
|
||||
"s3:PutLifecycleConfiguration": "s3:PutBucketLifecycle",
|
||||
"s3:PutBucketLifecycleConfiguration": "s3:PutBucketLifecycle",
|
||||
"s3:DeleteBucketLifecycle": "s3:PutBucketLifecycle",
|
||||
"s3:DeleteBucketTagging": "s3:PutBucketTagging",
|
||||
"s3:DeleteBucketReplication": "s3:PutReplicationConfiguration",
|
||||
}
|
||||
|
||||
function normalizeAction(action: string): string {
|
||||
return ACTION_ALIASES[action] ?? action
|
||||
}
|
||||
|
||||
function isAdminAction(action: string): boolean {
|
||||
return normalizeAction(action).startsWith("admin:")
|
||||
}
|
||||
|
||||
function matchAction(policyActions: string[] | undefined, requestAction: string): boolean {
|
||||
// Undefined means no match; explicit empty array means match all actions
|
||||
if (policyActions === undefined) {
|
||||
// Undefined or empty means no match.
|
||||
if (!policyActions || policyActions.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (policyActions.length === 0) {
|
||||
return true
|
||||
}
|
||||
return policyActions.some((pattern) => resourceMatch(pattern, requestAction))
|
||||
|
||||
const normalizedRequestAction = normalizeAction(requestAction)
|
||||
return policyActions.some((pattern) => resourceMatch(normalizeAction(pattern), normalizedRequestAction))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +60,9 @@ function matchNotAction(notActions: string[] | undefined, requestAction: string)
|
||||
if (!notActions || notActions.length === 0) {
|
||||
return false
|
||||
}
|
||||
return notActions.some((pattern) => resourceMatch(pattern, requestAction))
|
||||
|
||||
const normalizedRequestAction = normalizeAction(requestAction)
|
||||
return notActions.some((pattern) => resourceMatch(normalizeAction(pattern), normalizedRequestAction))
|
||||
}
|
||||
|
||||
const IMPLIED_SCOPES: Record<string, string[]> = {
|
||||
@@ -51,13 +73,11 @@ const IMPLIED_SCOPES: Record<string, string[]> = {
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject",
|
||||
"s3:ListObjects",
|
||||
"s3:GetObjectTagging",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:DeleteObjectTagging",
|
||||
"s3:GetBucketTagging",
|
||||
"s3:PutBucketTagging",
|
||||
"s3:DeleteBucketTagging",
|
||||
"s3:GetBucketPolicy",
|
||||
"s3:PutBucketPolicy",
|
||||
"s3:DeleteBucketPolicy",
|
||||
@@ -101,18 +121,71 @@ const IMPLIED_SCOPES: Record<string, string[]> = {
|
||||
"s3:PutReplicationConfiguration",
|
||||
"s3:*",
|
||||
],
|
||||
[CONSOLE_SCOPES.VIEW_BUCKET_LIFECYCLE]: ["s3:GetLifecycleConfiguration", "s3:PutLifecycleConfiguration", "s3:*"],
|
||||
[CONSOLE_SCOPES.VIEW_BUCKET_LIFECYCLE]: ["s3:GetBucketLifecycle", "s3:PutBucketLifecycle", "s3:*"],
|
||||
[CONSOLE_SCOPES.VIEW_TIERED_STORAGE]: ["admin:ConfigUpdate", "admin:*"],
|
||||
[CONSOLE_SCOPES.VIEW_EVENT_DESTINATIONS]: ["admin:ConfigUpdate", "admin:*"],
|
||||
[CONSOLE_SCOPES.VIEW_SSE_SETTINGS]: ["admin:ConfigUpdate", "admin:*", "kms:*"],
|
||||
[CONSOLE_SCOPES.VIEW_LICENSE]: ["admin:ServerInfo", "admin:*"],
|
||||
}
|
||||
|
||||
function matchResource(policyResources: string[] | undefined, requestResource: string): boolean {
|
||||
if (!policyResources || policyResources.length === 0) {
|
||||
function isConsoleResource(resource: string): boolean {
|
||||
return resource === "console" || resource === "*" || resource.includes("console")
|
||||
}
|
||||
|
||||
function shouldIgnoreConsoleResourceCheck(statement: ConsoleStatement, action: string): boolean {
|
||||
if (!isConsoleScope(action)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const statementResources = [...(statement.Resource ?? []), ...(statement.NotResource ?? [])]
|
||||
return statementResources.length > 0 && !statementResources.some(isConsoleResource)
|
||||
}
|
||||
|
||||
function matchStatementResource(statement: ConsoleStatement, requestResource: string, action: string): boolean {
|
||||
if (isAdminAction(action)) {
|
||||
return true
|
||||
}
|
||||
return policyResources.some((pattern) => resourceMatch(pattern, requestResource))
|
||||
|
||||
if (shouldIgnoreConsoleResourceCheck(statement, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const resourceMatched =
|
||||
!statement.Resource || statement.Resource.length === 0
|
||||
? true
|
||||
: statement.Resource.some((pattern) => resourceMatch(pattern, requestResource))
|
||||
|
||||
if (!resourceMatched) {
|
||||
return false
|
||||
}
|
||||
|
||||
const notResourceMatched =
|
||||
!!statement.NotResource &&
|
||||
statement.NotResource.length > 0 &&
|
||||
statement.NotResource.some((pattern) => resourceMatch(pattern, requestResource))
|
||||
|
||||
return !notResourceMatched
|
||||
}
|
||||
|
||||
function matchesRequestedAction(policyActions: string[] | undefined, action: string): boolean {
|
||||
if (matchAction(policyActions, action)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isConsoleScope(action)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
matchAction(policyActions, CONSOLE_SCOPES.CONSOLE_ADMIN) ||
|
||||
matchAction(policyActions, "console:*") ||
|
||||
matchAction(policyActions, "admin:*")
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const impliedActions = IMPLIED_SCOPES[action]
|
||||
return !!impliedActions && impliedActions.some((implied) => matchAction(policyActions, implied))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,23 +205,6 @@ export function hasConsolePermission(
|
||||
const statements = Array.isArray(policy) ? policy : policy.Statement || []
|
||||
if (statements.length === 0) return false
|
||||
|
||||
// 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
|
||||
@@ -157,14 +213,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) : false
|
||||
return matchStatementResource(s, resource, action)
|
||||
}
|
||||
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) : false
|
||||
if (matchesRequestedAction(s.Action, action)) {
|
||||
return matchStatementResource(s, resource, action)
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -181,17 +237,8 @@ export function hasConsolePermission(
|
||||
// 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
|
||||
}
|
||||
if (!matchesRequestedAction(s.Action, action)) {
|
||||
return false // Action doesn't match Action list
|
||||
}
|
||||
|
||||
// Action matches Action list, now check NotAction exclusion
|
||||
@@ -205,30 +252,13 @@ export function hasConsolePermission(
|
||||
}
|
||||
}
|
||||
// Action is allowed (matches Action if present, and not in NotAction), check resource
|
||||
return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true
|
||||
return matchStatementResource(s, resource, action)
|
||||
}
|
||||
|
||||
// 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
|
||||
? shouldCheckResource(s)
|
||||
? matchResource(s.Resource, resource)
|
||||
: true
|
||||
: false
|
||||
const explicitMatch = matchesRequestedAction(s.Action, action) ? matchStatementResource(s, resource, action) : false
|
||||
if (explicitMatch) return true
|
||||
|
||||
const impliedActions = IMPLIED_SCOPES[action]
|
||||
if (impliedActions) {
|
||||
if (impliedActions.some((implied) => matchAction(s.Action, implied))) {
|
||||
return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { hasConsolePermission, type ConsolePolicy } from "./console-policy-parser"
|
||||
import {
|
||||
toAllBucketsArn,
|
||||
toBucketArn,
|
||||
toObjectArn,
|
||||
toObjectPatternArn,
|
||||
type PermissionResourceContext,
|
||||
} from "./permission-resources"
|
||||
|
||||
export type ConsoleCapability =
|
||||
| "bucket.create"
|
||||
| "bucket.delete"
|
||||
| "bucket.policy.put"
|
||||
| "bucket.policy.delete"
|
||||
| "bucket.policy.edit"
|
||||
| "bucket.encryption.edit"
|
||||
| "bucket.tag.edit"
|
||||
| "bucket.versioning.edit"
|
||||
| "bucket.objectLock.edit"
|
||||
| "bucket.quota.edit"
|
||||
| "bucket.lifecycle.edit"
|
||||
| "bucket.replication.edit"
|
||||
| "bucket.events.edit"
|
||||
| "objects.upload"
|
||||
| "objects.view"
|
||||
| "objects.preview"
|
||||
| "objects.download"
|
||||
| "objects.delete"
|
||||
| "objects.bulkDelete"
|
||||
| "objects.tag.view"
|
||||
| "objects.tag.edit"
|
||||
| "objects.legalHold.edit"
|
||||
| "objects.retention.edit"
|
||||
| "objects.version.view"
|
||||
| "objects.share"
|
||||
| "accessKeys.create"
|
||||
| "accessKeys.edit"
|
||||
| "accessKeys.delete"
|
||||
| "accessKeys.bulkDelete"
|
||||
| "users.create"
|
||||
| "users.edit"
|
||||
| "users.delete"
|
||||
| "users.bulkDelete"
|
||||
| "users.assignGroups"
|
||||
| "users.policy.edit"
|
||||
| "policies.create"
|
||||
| "policies.edit"
|
||||
| "policies.delete"
|
||||
|
||||
type ResourceTarget = "none" | "bucket" | "object" | "objectPattern" | "allBuckets"
|
||||
type RequirementMode = "all" | "any"
|
||||
|
||||
interface CapabilityRequirement {
|
||||
actions: string[]
|
||||
mode?: RequirementMode
|
||||
resource: ResourceTarget
|
||||
}
|
||||
|
||||
const CAPABILITY_REQUIREMENTS: Record<ConsoleCapability, CapabilityRequirement[]> = {
|
||||
"bucket.create": [{ actions: ["s3:CreateBucket"], resource: "allBuckets" }],
|
||||
"bucket.delete": [{ actions: ["s3:DeleteBucket"], resource: "bucket" }],
|
||||
"bucket.policy.put": [{ actions: ["s3:PutBucketPolicy"], resource: "bucket" }],
|
||||
"bucket.policy.delete": [{ actions: ["s3:DeleteBucketPolicy"], resource: "bucket" }],
|
||||
"bucket.policy.edit": [{ actions: ["s3:PutBucketPolicy", "s3:DeleteBucketPolicy"], mode: "any", resource: "bucket" }],
|
||||
"bucket.encryption.edit": [{ actions: ["s3:PutBucketEncryption"], resource: "bucket" }],
|
||||
"bucket.tag.edit": [{ actions: ["s3:PutBucketTagging"], resource: "bucket" }],
|
||||
"bucket.versioning.edit": [{ actions: ["s3:PutBucketVersioning"], resource: "bucket" }],
|
||||
"bucket.objectLock.edit": [{ actions: ["s3:PutBucketObjectLockConfiguration"], resource: "bucket" }],
|
||||
"bucket.quota.edit": [{ actions: ["admin:SetBucketQuota"], resource: "none" }],
|
||||
"bucket.lifecycle.edit": [{ actions: ["s3:PutBucketLifecycle"], resource: "bucket" }],
|
||||
"bucket.replication.edit": [{ actions: ["s3:PutReplicationConfiguration"], resource: "bucket" }],
|
||||
"bucket.events.edit": [{ actions: ["s3:PutBucketNotification"], resource: "bucket" }],
|
||||
"objects.upload": [{ actions: ["s3:PutObject"], resource: "objectPattern" }],
|
||||
"objects.view": [{ actions: ["s3:GetObject"], resource: "object" }],
|
||||
"objects.preview": [{ actions: ["s3:GetObject"], resource: "object" }],
|
||||
"objects.download": [{ actions: ["s3:GetObject"], resource: "object" }],
|
||||
"objects.delete": [{ actions: ["s3:DeleteObject"], resource: "object" }],
|
||||
"objects.bulkDelete": [{ actions: ["s3:DeleteObject"], resource: "objectPattern" }],
|
||||
"objects.tag.view": [{ actions: ["s3:GetObjectTagging"], resource: "object" }],
|
||||
"objects.tag.edit": [{ actions: ["s3:PutObjectTagging"], resource: "object" }],
|
||||
"objects.legalHold.edit": [{ actions: ["s3:PutObjectLegalHold"], resource: "object" }],
|
||||
"objects.retention.edit": [{ actions: ["s3:PutObjectRetention"], resource: "object" }],
|
||||
"objects.version.view": [{ actions: ["s3:GetObject"], resource: "object" }],
|
||||
"objects.share": [{ actions: ["s3:GetObject"], resource: "object" }],
|
||||
"accessKeys.create": [{ actions: ["admin:CreateServiceAccount"], resource: "none" }],
|
||||
"accessKeys.edit": [{ actions: ["admin:UpdateServiceAccount"], resource: "none" }],
|
||||
"accessKeys.delete": [{ actions: ["admin:RemoveServiceAccount"], resource: "none" }],
|
||||
"accessKeys.bulkDelete": [{ actions: ["admin:RemoveServiceAccount"], resource: "none" }],
|
||||
"users.create": [{ actions: ["admin:CreateUser"], resource: "none" }],
|
||||
"users.edit": [
|
||||
{ actions: ["admin:GetUser"], resource: "none" },
|
||||
{ actions: ["admin:CreateUser", "admin:EnableUser", "admin:DisableUser"], mode: "any", resource: "none" },
|
||||
],
|
||||
"users.delete": [{ actions: ["admin:DeleteUser"], resource: "none" }],
|
||||
"users.bulkDelete": [{ actions: ["admin:DeleteUser"], resource: "none" }],
|
||||
"users.assignGroups": [
|
||||
{ actions: ["admin:AddUserToGroup", "admin:RemoveUserFromGroup"], mode: "any", resource: "none" },
|
||||
],
|
||||
"users.policy.edit": [
|
||||
{ actions: ["admin:AttachUserOrGroupPolicy", "admin:UpdatePolicyAssociation"], mode: "any", resource: "none" },
|
||||
],
|
||||
"policies.create": [{ actions: ["admin:CreatePolicy"], resource: "none" }],
|
||||
"policies.edit": [{ actions: ["admin:CreatePolicy"], resource: "none" }],
|
||||
"policies.delete": [{ actions: ["admin:DeletePolicy"], resource: "none" }],
|
||||
}
|
||||
|
||||
function resolveResource(resource: ResourceTarget, context: PermissionResourceContext): string {
|
||||
switch (resource) {
|
||||
case "none":
|
||||
return "*"
|
||||
case "bucket":
|
||||
return context.bucket ? toBucketArn(context.bucket) : toAllBucketsArn()
|
||||
case "object":
|
||||
if (context.bucket && context.objectKey) {
|
||||
return toObjectArn(context.bucket, context.objectKey)
|
||||
}
|
||||
return toObjectPatternArn(context.bucket, context.prefix)
|
||||
case "objectPattern":
|
||||
return toObjectPatternArn(context.bucket, context.prefix ?? context.objectKey)
|
||||
case "allBuckets":
|
||||
return toAllBucketsArn()
|
||||
}
|
||||
}
|
||||
|
||||
export function hasConsoleCapability(
|
||||
policy: ConsolePolicy | null | undefined,
|
||||
capability: ConsoleCapability,
|
||||
context: PermissionResourceContext = {},
|
||||
): boolean {
|
||||
if (!policy) return false
|
||||
|
||||
const requirements = CAPABILITY_REQUIREMENTS[capability]
|
||||
if (!requirements) return false
|
||||
|
||||
return requirements.every((requirement) => {
|
||||
const resource = resolveResource(requirement.resource, context)
|
||||
const mode = requirement.mode ?? "all"
|
||||
|
||||
if (mode === "any") {
|
||||
return requirement.actions.some((action) => hasConsolePermission(policy, action, resource))
|
||||
}
|
||||
|
||||
return requirement.actions.every((action) => hasConsolePermission(policy, action, resource))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface PermissionResourceContext {
|
||||
bucket?: string
|
||||
objectKey?: string
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
const S3_ARN_PREFIX = "arn:aws:s3:::"
|
||||
|
||||
function normalizeS3Path(path: string): string {
|
||||
return path.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
export function toBucketArn(bucket: string): string {
|
||||
return `${S3_ARN_PREFIX}${bucket}`
|
||||
}
|
||||
|
||||
export function toObjectArn(bucket: string, objectKey: string): string {
|
||||
return `${toBucketArn(bucket)}/${normalizeS3Path(objectKey)}`
|
||||
}
|
||||
|
||||
export function toObjectPatternArn(bucket?: string, prefix?: string): string {
|
||||
if (!bucket) {
|
||||
return `${S3_ARN_PREFIX}*`
|
||||
}
|
||||
|
||||
if (!prefix) {
|
||||
return `${toBucketArn(bucket)}/*`
|
||||
}
|
||||
|
||||
const normalizedPrefix = normalizeS3Path(prefix)
|
||||
return `${toBucketArn(bucket)}/${normalizedPrefix}*`
|
||||
}
|
||||
|
||||
export function toAllBucketsArn(): string {
|
||||
return `${S3_ARN_PREFIX}*`
|
||||
}
|
||||
Reference in New Issue
Block a user